summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/selection/selectable-surface.tsx
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-03-25 16:47:22 +0100
committerMaze Winther <mazewinther@gmail.com>2026-03-29 15:48:22 +0200
commit8db3bead13441a5882d8689dfc6a9f09c82375d9 (patch)
treee9d0cafa93904ffcbec3188c37961bcc16c6422d /apps/web/src/lib/selection/selectable-surface.tsx
parent39ea298a9ccba3104c549c0ca079c231981811f5 (diff)
downloadopencut-8db3bead13441a5882d8689dfc6a9f09c82375d9.tar.gz
feat: masks, properties refactor, shaders, storage migrations, and more
Diffstat (limited to 'apps/web/src/lib/selection/selectable-surface.tsx')
-rw-r--r--apps/web/src/lib/selection/selectable-surface.tsx288
1 files changed, 288 insertions, 0 deletions
diff --git a/apps/web/src/lib/selection/selectable-surface.tsx b/apps/web/src/lib/selection/selectable-surface.tsx
new file mode 100644
index 00000000..605b3d18
--- /dev/null
+++ b/apps/web/src/lib/selection/selectable-surface.tsx
@@ -0,0 +1,288 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { SelectionBox } from "@/lib/selection/selection-box";
+import { SelectionContext } from "@/lib/selection/context";
+import { SELECTABLE_ITEM_ATTRIBUTE } from "@/lib/selection/constants";
+import { resolveElementIntersections } from "@/lib/selection/hit-testing";
+import {
+ applyBoxSelection,
+ clearSelection,
+ isSelected as getIsSelected,
+ pruneSelection,
+ replaceSelection,
+ selectRange,
+ toggleSelection,
+} from "@/lib/selection/state";
+import type {
+ SelectableSurfaceProps,
+ SelectionState,
+} from "@/lib/selection/types";
+import { useBoxSelect } from "@/lib/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, 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 = selectionBox?.isActive ?? false;
+
+ 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
+ startPos={selectionBox?.startPos ?? null}
+ currentPos={selectionBox?.currentPos ?? null}
+ containerRef={containerRef}
+ isActive={selectionBox?.isActive ?? false}
+ />
+ </div>
+ </SelectionContext.Provider>
+ );
+}