mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
285 lines
6.5 KiB
TypeScript
285 lines
6.5 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { SelectionBox } from "@/selection/selection-box";
|
|
import { SelectionContext } from "@/selection/context";
|
|
import { SELECTABLE_ITEM_ATTRIBUTE } from "@/selection/attributes";
|
|
import { resolveElementIntersections } from "@/selection/hit-testing";
|
|
import {
|
|
applyBoxSelection,
|
|
clearSelection,
|
|
isSelected as getIsSelected,
|
|
pruneSelection,
|
|
replaceSelection,
|
|
selectRange,
|
|
toggleSelection,
|
|
} from "@/selection/state";
|
|
import type {
|
|
SelectableSurfaceProps,
|
|
SelectionState,
|
|
} from "@/selection/types";
|
|
import { useBoxSelect } from "@/selection/hooks/use-box-select";
|
|
import { cn } from "@/utils/ui";
|
|
|
|
export function SelectableSurface({
|
|
orderedIds,
|
|
children,
|
|
className,
|
|
ariaLabel = "Selectable items",
|
|
revealId = null,
|
|
onRevealComplete,
|
|
onSelectionChange,
|
|
}: SelectableSurfaceProps) {
|
|
const [selectionState, setSelectionState] = useState<SelectionState>(() =>
|
|
clearSelection(),
|
|
);
|
|
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const itemElementsRef = useRef<Map<string, HTMLElement>>(new Map());
|
|
|
|
const registerItem = useCallback(
|
|
(id: string, element: HTMLElement | null) => {
|
|
if (element) {
|
|
itemElementsRef.current.set(id, element);
|
|
} else {
|
|
itemElementsRef.current.delete(id);
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
const getSelectableElements = useCallback(() => {
|
|
return itemElementsRef.current;
|
|
}, []);
|
|
|
|
const getItemElement = useCallback((id: string) => {
|
|
return itemElementsRef.current.get(id) ?? null;
|
|
}, []);
|
|
const shouldStartSelection = useCallback(
|
|
(event: React.MouseEvent<Element>) => {
|
|
return !(
|
|
event.target instanceof Element &&
|
|
event.target.closest(`[${SELECTABLE_ITEM_ATTRIBUTE}='true']`)
|
|
);
|
|
},
|
|
[],
|
|
);
|
|
const resolveIntersections = useCallback(
|
|
({
|
|
startPos,
|
|
currentPos,
|
|
}: {
|
|
startPos: { x: number; y: number };
|
|
currentPos: { x: number; y: number };
|
|
}) => {
|
|
return resolveElementIntersections({
|
|
startPos,
|
|
currentPos,
|
|
elements: getSelectableElements(),
|
|
});
|
|
},
|
|
[getSelectableElements],
|
|
);
|
|
|
|
const clearSelectionState = useCallback(() => {
|
|
setSelectionState(clearSelection());
|
|
}, []);
|
|
|
|
const selectedIdSet = useMemo(
|
|
() => new Set(selectionState.selectedIds),
|
|
[selectionState.selectedIds],
|
|
);
|
|
|
|
const isSelected = useCallback(
|
|
(id: string) => selectedIdSet.has(id),
|
|
[selectedIdSet],
|
|
);
|
|
|
|
const handleItemClick = useCallback(
|
|
({
|
|
event,
|
|
id,
|
|
}: {
|
|
event:
|
|
| React.MouseEvent<HTMLDivElement>
|
|
| React.KeyboardEvent<HTMLDivElement>;
|
|
id: string;
|
|
}) => {
|
|
setSelectionState((state) => {
|
|
const isToggleSelection = event.ctrlKey || event.metaKey;
|
|
|
|
if (event.shiftKey) {
|
|
return selectRange({
|
|
state,
|
|
orderedIds,
|
|
targetId: id,
|
|
isAdditive: isToggleSelection,
|
|
});
|
|
}
|
|
|
|
if (isToggleSelection) {
|
|
return toggleSelection({
|
|
state,
|
|
id,
|
|
});
|
|
}
|
|
|
|
return replaceSelection({
|
|
ids: [id],
|
|
anchorId: id,
|
|
});
|
|
});
|
|
},
|
|
[orderedIds],
|
|
);
|
|
|
|
const selectUnselectedItem = useCallback((id: string) => {
|
|
setSelectionState((state) => {
|
|
if (getIsSelected({ state, id })) {
|
|
return state;
|
|
}
|
|
|
|
return replaceSelection({ ids: [id], anchorId: id });
|
|
});
|
|
}, []);
|
|
|
|
const handleItemMouseDown = useCallback(
|
|
({
|
|
event,
|
|
id,
|
|
}: {
|
|
event: React.MouseEvent<HTMLDivElement>;
|
|
id: string;
|
|
}) => {
|
|
if (event.button !== 2) {
|
|
return;
|
|
}
|
|
|
|
selectUnselectedItem(id);
|
|
},
|
|
[selectUnselectedItem],
|
|
);
|
|
|
|
const handleBoxSelectionChange = useCallback(
|
|
(change: Parameters<typeof applyBoxSelection>[0]) => {
|
|
setSelectionState(applyBoxSelection(change));
|
|
},
|
|
[],
|
|
);
|
|
|
|
const { selectionBox, handleMouseDown, isSelecting, shouldIgnoreClick } =
|
|
useBoxSelect({
|
|
containerRef,
|
|
resolveIntersections,
|
|
selectedIds: selectionState.selectedIds,
|
|
anchorId: selectionState.anchorId,
|
|
onSelectionChange: handleBoxSelectionChange,
|
|
shouldStartSelection,
|
|
});
|
|
|
|
const handleBackgroundClick = useCallback(
|
|
(event: React.MouseEvent<HTMLDivElement>) => {
|
|
const clickedSelectableItem =
|
|
event.target instanceof Element &&
|
|
event.target.closest(`[${SELECTABLE_ITEM_ATTRIBUTE}='true']`);
|
|
const isIgnoringClick = shouldIgnoreClick();
|
|
if (clickedSelectableItem || isIgnoringClick) {
|
|
return;
|
|
}
|
|
|
|
clearSelectionState();
|
|
},
|
|
[clearSelectionState, shouldIgnoreClick],
|
|
);
|
|
|
|
const handleBackgroundKeyDown = useCallback(
|
|
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
if (event.target !== event.currentTarget) {
|
|
return;
|
|
}
|
|
|
|
if (event.key !== "Enter" && event.key !== " " && event.key !== "Escape") {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
clearSelectionState();
|
|
},
|
|
[clearSelectionState],
|
|
);
|
|
|
|
useEffect(() => {
|
|
setSelectionState((state) =>
|
|
pruneSelection({
|
|
state,
|
|
orderedIds,
|
|
}),
|
|
);
|
|
}, [orderedIds]);
|
|
|
|
useEffect(() => {
|
|
onSelectionChange?.(selectionState);
|
|
}, [onSelectionChange, selectionState]);
|
|
|
|
useEffect(() => {
|
|
if (!revealId) {
|
|
return;
|
|
}
|
|
|
|
setHighlightedId(revealId);
|
|
getItemElement(revealId)?.scrollIntoView({ block: "center" });
|
|
|
|
const timer = setTimeout(() => {
|
|
setHighlightedId(null);
|
|
onRevealComplete?.();
|
|
}, 1500);
|
|
|
|
return () => clearTimeout(timer);
|
|
}, [getItemElement, onRevealComplete, revealId]);
|
|
|
|
const isBoxSelecting = isSelecting;
|
|
|
|
const contextValue = useMemo(() => {
|
|
return {
|
|
selectedIds: selectionState.selectedIds,
|
|
anchorId: selectionState.anchorId,
|
|
highlightedId,
|
|
isBoxSelecting,
|
|
isSelected,
|
|
clearSelection: clearSelectionState,
|
|
handleItemClick,
|
|
handleItemMouseDown,
|
|
registerItem,
|
|
};
|
|
}, [
|
|
clearSelectionState,
|
|
handleItemClick,
|
|
handleItemMouseDown,
|
|
highlightedId,
|
|
isBoxSelecting,
|
|
isSelected,
|
|
registerItem,
|
|
selectionState.anchorId,
|
|
selectionState.selectedIds,
|
|
]);
|
|
|
|
return (
|
|
<SelectionContext.Provider value={contextValue}>
|
|
<div
|
|
ref={containerRef}
|
|
className={cn("relative min-h-full", className)}
|
|
role="listbox"
|
|
aria-label={ariaLabel}
|
|
aria-multiselectable="true"
|
|
tabIndex={0}
|
|
onMouseDown={handleMouseDown}
|
|
onClick={handleBackgroundClick}
|
|
onKeyDown={handleBackgroundKeyDown}
|
|
>
|
|
{children}
|
|
<SelectionBox bounds={selectionBox?.bounds ?? null} />
|
|
</div>
|
|
</SelectionContext.Provider>
|
|
);
|
|
}
|