From 3e89d2998588f8e35c524e523ba2ee853f0f6475 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 20 Apr 2026 11:31:17 +0200 Subject: refactor: restructure files to their domains, new preview overlay system, and dep graph --- apps/web/src/selection/attributes.ts | 1 + apps/web/src/selection/context.tsx | 52 ++++ apps/web/src/selection/editor-selection.ts | 25 ++ apps/web/src/selection/hit-testing.ts | 50 ++++ apps/web/src/selection/hooks/use-box-select.ts | 170 ++++++++++++ .../web/src/selection/hooks/use-selection-scope.ts | 28 ++ apps/web/src/selection/index.ts | 4 + apps/web/src/selection/scope.ts | 30 +++ apps/web/src/selection/selectable-item.tsx | 107 ++++++++ apps/web/src/selection/selectable-surface.tsx | 288 +++++++++++++++++++++ apps/web/src/selection/selection-box.tsx | 50 ++++ apps/web/src/selection/state.ts | 165 ++++++++++++ apps/web/src/selection/types.ts | 39 +++ 13 files changed, 1009 insertions(+) create mode 100644 apps/web/src/selection/attributes.ts create mode 100644 apps/web/src/selection/context.tsx create mode 100644 apps/web/src/selection/editor-selection.ts create mode 100644 apps/web/src/selection/hit-testing.ts create mode 100644 apps/web/src/selection/hooks/use-box-select.ts create mode 100644 apps/web/src/selection/hooks/use-selection-scope.ts create mode 100644 apps/web/src/selection/index.ts create mode 100644 apps/web/src/selection/scope.ts create mode 100644 apps/web/src/selection/selectable-item.tsx create mode 100644 apps/web/src/selection/selectable-surface.tsx create mode 100644 apps/web/src/selection/selection-box.tsx create mode 100644 apps/web/src/selection/state.ts create mode 100644 apps/web/src/selection/types.ts (limited to 'apps/web/src/selection') diff --git a/apps/web/src/selection/attributes.ts b/apps/web/src/selection/attributes.ts new file mode 100644 index 00000000..a1aee842 --- /dev/null +++ b/apps/web/src/selection/attributes.ts @@ -0,0 +1 @@ +export const SELECTABLE_ITEM_ATTRIBUTE = "data-selectable-item"; diff --git a/apps/web/src/selection/context.tsx b/apps/web/src/selection/context.tsx new file mode 100644 index 00000000..8e87aa40 --- /dev/null +++ b/apps/web/src/selection/context.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { createContext, useContext } from "react"; + +interface SelectionContextValue { + selectedIds: string[]; + anchorId: string | null; + highlightedId: string | null; + isBoxSelecting: boolean; + isSelected: (id: string) => boolean; + clearSelection: () => void; + handleItemClick: ({ + event, + id, + }: { + event: React.MouseEvent | React.KeyboardEvent; + id: string; + }) => void; + handleItemMouseDown: ({ + event, + id, + }: { + event: React.MouseEvent; + id: string; + }) => void; + registerItem: (id: string, element: HTMLElement | null) => void; +} + +export const SelectionContext = createContext(null); + +export function useSelectionContext() { + const context = useContext(SelectionContext); + + if (!context) { + throw new Error("useSelectionContext must be used within SelectableSurface"); + } + + return context; +} + +export function useSelection() { + const { selectedIds, anchorId, highlightedId, isSelected, clearSelection } = + useSelectionContext(); + + return { + selectedIds, + anchorId, + highlightedId, + isSelected, + clearSelection, + }; +} diff --git a/apps/web/src/selection/editor-selection.ts b/apps/web/src/selection/editor-selection.ts new file mode 100644 index 00000000..21ab6466 --- /dev/null +++ b/apps/web/src/selection/editor-selection.ts @@ -0,0 +1,25 @@ +import type { SelectedKeyframeRef } from "@/animation/types"; +import type { ElementRef } from "@/timeline/types"; + +export interface SelectedMaskPointSelection { + trackId: string; + elementId: string; + maskId: string; + pointIds: string[]; +} + +export interface EditorSelectionSnapshot { + selectedElements: ElementRef[]; + selectedKeyframes: SelectedKeyframeRef[]; + keyframeSelectionAnchor: SelectedKeyframeRef | null; + selectedMaskPoints: SelectedMaskPointSelection | null; +} + +export interface EditorSelectionPatch { + selectedElements?: ElementRef[]; + selectedKeyframes?: SelectedKeyframeRef[]; + keyframeSelectionAnchor?: SelectedKeyframeRef | null; + selectedMaskPoints?: SelectedMaskPointSelection | null; +} + +export type EditorSelectionKind = "mask-points" | "keyframes" | "elements"; diff --git a/apps/web/src/selection/hit-testing.ts b/apps/web/src/selection/hit-testing.ts new file mode 100644 index 00000000..d8186238 --- /dev/null +++ b/apps/web/src/selection/hit-testing.ts @@ -0,0 +1,50 @@ +function isIntersecting({ + selectionRect, + itemRect, +}: { + selectionRect: DOMRect; + itemRect: DOMRect; +}) { + return !( + itemRect.right < selectionRect.left || + itemRect.left > selectionRect.right || + itemRect.bottom < selectionRect.top || + itemRect.top > selectionRect.bottom + ); +} + +function createSelectionRect({ + startPos, + currentPos, +}: { + startPos: { x: number; y: number }; + currentPos: { x: number; y: number }; +}) { + return new DOMRect( + Math.min(startPos.x, currentPos.x), + Math.min(startPos.y, currentPos.y), + Math.abs(currentPos.x - startPos.x), + Math.abs(currentPos.y - startPos.y), + ); +} + +export function resolveElementIntersections({ + startPos, + currentPos, + elements, +}: { + startPos: { x: number; y: number }; + currentPos: { x: number; y: number }; + elements: Map; +}) { + const selectionRect = createSelectionRect({ startPos, currentPos }); + + return [...elements.entries()] + .filter(([, element]) => + isIntersecting({ + selectionRect, + itemRect: element.getBoundingClientRect(), + }), + ) + .map(([id]) => id); +} diff --git a/apps/web/src/selection/hooks/use-box-select.ts b/apps/web/src/selection/hooks/use-box-select.ts new file mode 100644 index 00000000..90a584b5 --- /dev/null +++ b/apps/web/src/selection/hooks/use-box-select.ts @@ -0,0 +1,170 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { + BoxSelectionSnapshot, + ResolveIntersections, +} from "@/selection/types"; + +interface SelectionBoxState extends BoxSelectionSnapshot { + startPos: { x: number; y: number }; + currentPos: { x: number; y: number }; + isActive: boolean; + isAdditive: boolean; +} + +export function useBoxSelect({ + containerRef, + resolveIntersections, + selectedIds, + anchorId, + onSelectionChange, + shouldStartSelection, + getIsAdditiveSelection, + isEnabled = true, +}: { + containerRef: React.RefObject; + resolveIntersections: ResolveIntersections; + selectedIds: TId[]; + anchorId: TId | null; + onSelectionChange: (state: { + intersectedIds: TId[]; + initialSelectedIds: TId[]; + initialAnchorId: TId | null; + isAdditive: boolean; + }) => void; + shouldStartSelection?: (event: React.MouseEvent) => boolean; + getIsAdditiveSelection?: (event: React.MouseEvent) => boolean; + isEnabled?: boolean; +}) { + const [selectionBox, setSelectionBox] = + useState | null>(null); + const justFinishedSelectingRef = useRef(false); + const shouldStartSelectionCheck = shouldStartSelection ?? (() => true); + const getIsAdditiveSelectionCheck = + getIsAdditiveSelection ?? + ((event: React.MouseEvent) => event.ctrlKey || event.metaKey); + + const handleMouseDown = useCallback( + (event: React.MouseEvent) => { + const canStartSelection = shouldStartSelectionCheck(event); + if (!isEnabled || event.button !== 0 || !canStartSelection) { + return; + } + + setSelectionBox({ + startPos: { x: event.clientX, y: event.clientY }, + currentPos: { x: event.clientX, y: event.clientY }, + isActive: false, + isAdditive: getIsAdditiveSelectionCheck(event), + initialSelectedIds: selectedIds, + initialAnchorId: anchorId, + }); + }, + [ + anchorId, + getIsAdditiveSelectionCheck, + isEnabled, + selectedIds, + shouldStartSelectionCheck, + ], + ); + + const updateSelection = useCallback( + ({ + startPos, + currentPos, + isAdditive, + initialSelectedIds, + initialAnchorId, + }: SelectionBoxState) => { + const intersectedIds = resolveIntersections({ + startPos, + currentPos, + }); + onSelectionChange({ + intersectedIds, + initialSelectedIds, + initialAnchorId, + isAdditive, + }); + }, + [onSelectionChange, resolveIntersections], + ); + + useEffect(() => { + if (!selectionBox) { + return; + } + + const handleMouseMove = ({ clientX, clientY }: MouseEvent) => { + const deltaX = Math.abs(clientX - selectionBox.startPos.x); + const deltaY = Math.abs(clientY - selectionBox.startPos.y); + const nextSelectionBox = { + ...selectionBox, + currentPos: { x: clientX, y: clientY }, + isActive: deltaX > 5 || deltaY > 5 || selectionBox.isActive, + }; + + setSelectionBox(nextSelectionBox); + + if (!nextSelectionBox.isActive) { + return; + } + + updateSelection(nextSelectionBox); + }; + + const handleMouseUp = () => { + if (selectionBox.isActive) { + justFinishedSelectingRef.current = true; + requestAnimationFrame(() => { + justFinishedSelectingRef.current = false; + }); + } + + setSelectionBox(null); + }; + + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + + return () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + }, [selectionBox, updateSelection]); + + useEffect(() => { + if (!selectionBox) { + return; + } + + const previousBodyUserSelect = document.body.style.userSelect; + const previousContainerUserSelect = + containerRef.current?.style.userSelect ?? ""; + + document.body.style.userSelect = "none"; + if (containerRef.current) { + containerRef.current.style.userSelect = "none"; + } + + return () => { + document.body.style.userSelect = previousBodyUserSelect; + if (containerRef.current) { + containerRef.current.style.userSelect = previousContainerUserSelect; + } + }; + }, [containerRef, selectionBox]); + + const shouldIgnoreClick = useCallback(() => { + return justFinishedSelectingRef.current; + }, []); + + return { + selectionBox, + handleMouseDown, + isSelecting: selectionBox?.isActive ?? false, + shouldIgnoreClick, + }; +} diff --git a/apps/web/src/selection/hooks/use-selection-scope.ts b/apps/web/src/selection/hooks/use-selection-scope.ts new file mode 100644 index 00000000..ad4d2ce8 --- /dev/null +++ b/apps/web/src/selection/hooks/use-selection-scope.ts @@ -0,0 +1,28 @@ +import { useEffect, useRef } from "react"; +import { useSelectionContext } from "@/selection/context"; +import { activateScope, type ScopeEntry } from "@/selection/scope"; + +export function useSelectionScope() { + const { selectedIds, clearSelection } = useSelectionContext(); + const hasSelectionRef = useRef(selectedIds.length > 0); + const clearSelectionRef = useRef(clearSelection); + const hasSelection = selectedIds.length > 0; + + hasSelectionRef.current = hasSelection; + clearSelectionRef.current = clearSelection; + + const entryRef = useRef({ + hasSelection: () => hasSelectionRef.current, + clear: () => { + clearSelectionRef.current(); + }, + }); + + useEffect(() => { + if (!hasSelection) { + return; + } + + return activateScope({ entry: entryRef.current }); + }, [hasSelection]); +} diff --git a/apps/web/src/selection/index.ts b/apps/web/src/selection/index.ts new file mode 100644 index 00000000..9d62731b --- /dev/null +++ b/apps/web/src/selection/index.ts @@ -0,0 +1,4 @@ +export { SelectableItem } from "@/selection/selectable-item"; +export { SelectableSurface } from "@/selection/selectable-surface"; +export { useSelection } from "@/selection/context"; +export { useSelectionScope } from "@/selection/hooks/use-selection-scope"; diff --git a/apps/web/src/selection/scope.ts b/apps/web/src/selection/scope.ts new file mode 100644 index 00000000..22c7710c --- /dev/null +++ b/apps/web/src/selection/scope.ts @@ -0,0 +1,30 @@ +export type ScopeEntry = { + hasSelection: () => boolean; + clear: () => void; + clearActive?: () => void; +}; + +let activeScope: ScopeEntry | null = null; + +export function activateScope({ entry }: { entry: ScopeEntry }): () => void { + if (activeScope && activeScope !== entry) { + activeScope.clear(); + } + + activeScope = entry; + + return () => { + if (activeScope === entry) { + activeScope = null; + } + }; +} + +export function clearActiveScope(): boolean { + if (!activeScope?.hasSelection()) { + return false; + } + + (activeScope.clearActive ?? activeScope.clear)(); + return true; +} diff --git a/apps/web/src/selection/selectable-item.tsx b/apps/web/src/selection/selectable-item.tsx new file mode 100644 index 00000000..cc9e1dbf --- /dev/null +++ b/apps/web/src/selection/selectable-item.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { forwardRef, useCallback } from "react"; +import { useSelectionContext } from "@/selection/context"; +import { SELECTABLE_ITEM_ATTRIBUTE } from "@/selection/attributes"; +import type { SelectableItemProps } from "@/selection/types"; +import { cn } from "@/utils/ui"; + +function setForwardedRef(ref: React.ForwardedRef, value: T | null) { + if (typeof ref === "function") { + ref(value); + return; + } + + if (ref) { + ref.current = value; + } +} + +export const SelectableItem = forwardRef( + function SelectableItem( + { + id, + children, + className, + onClick, + onKeyDown, + onMouseDown, + onContextMenu, + tabIndex, + ...rest + }: SelectableItemProps, + forwardedRef, + ) { + const { + highlightedId, + isBoxSelecting, + isSelected, + handleItemClick, + handleItemMouseDown, + registerItem, + } = useSelectionContext(); + const isItemSelected = isSelected(id); + const isItemHighlighted = highlightedId === id; + const stateClassName = cn( + "relative", + isBoxSelecting && "pointer-events-none", + isItemSelected && "ring-1 ring-primary rounded-sm bg-primary/10", + isItemHighlighted && + (isItemSelected + ? "rounded-sm shadow-[0_0_0_1px_hsl(var(--primary))]" + : "ring-1 ring-primary/60 rounded-sm bg-primary/5"), + ); + + const handleRef = useCallback( + (element: HTMLDivElement | null) => { + registerItem(id, element); + setForwardedRef(forwardedRef, element); + }, + [forwardedRef, id, registerItem], + ); + + return ( +
{ + onClick?.(event); + if (event.defaultPrevented) { + return; + } + + handleItemClick({ event, id }); + }} + onMouseDown={(event) => { + onMouseDown?.(event); + if (event.defaultPrevented) { + return; + } + + handleItemMouseDown({ event, id }); + }} + onKeyDown={(event) => { + onKeyDown?.(event); + if (event.defaultPrevented || event.target !== event.currentTarget) { + return; + } + + if (event.key !== "Enter" && event.key !== " ") { + return; + } + + event.preventDefault(); + handleItemClick({ event, id }); + }} + onContextMenu={onContextMenu} + {...rest} + > + {children} +
+ ); + }, +); diff --git a/apps/web/src/selection/selectable-surface.tsx b/apps/web/src/selection/selectable-surface.tsx new file mode 100644 index 00000000..24166daf --- /dev/null +++ b/apps/web/src/selection/selectable-surface.tsx @@ -0,0 +1,288 @@ +"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(() => + clearSelection(), + ); + const [highlightedId, setHighlightedId] = useState(null); + const containerRef = useRef(null); + const itemElementsRef = useRef>(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) => { + 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 + | React.KeyboardEvent; + 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; + id: string; + }) => { + if (event.button !== 2) { + return; + } + + selectUnselectedItem(id); + }, + [selectUnselectedItem], + ); + + const handleBoxSelectionChange = useCallback( + (change: Parameters[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) => { + 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) => { + 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 ( + +
+ {children} + +
+
+ ); +} diff --git a/apps/web/src/selection/selection-box.tsx b/apps/web/src/selection/selection-box.tsx new file mode 100644 index 00000000..e30f574d --- /dev/null +++ b/apps/web/src/selection/selection-box.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useMemo } from "react"; + +interface SelectionBoxProps { + startPos: { x: number; y: number } | null; + currentPos: { x: number; y: number } | null; + containerRef: React.RefObject; + isActive: boolean; +} + +export function SelectionBox({ + startPos, + currentPos, + containerRef, + isActive, +}: SelectionBoxProps) { + const selectionBoxStyle = useMemo(() => { + if (!isActive || !startPos || !currentPos || !containerRef.current) { + return null; + } + + const containerRect = containerRef.current.getBoundingClientRect(); + const startX = startPos.x - containerRect.left; + const startY = startPos.y - containerRect.top; + const currentX = currentPos.x - containerRect.left; + const currentY = currentPos.y - containerRect.top; + + const left = Math.min(startX, currentX); + const top = Math.min(startY, currentY); + const width = Math.abs(currentX - startX); + const height = Math.abs(currentY - startY); + + return { + left: `${left}px`, + top: `${top}px`, + width: `${width}px`, + height: `${height}px`, + }; + }, [containerRef, currentPos, isActive, startPos]); + + if (!selectionBoxStyle) return null; + + return ( +
+ ); +} diff --git a/apps/web/src/selection/state.ts b/apps/web/src/selection/state.ts new file mode 100644 index 00000000..b44578a1 --- /dev/null +++ b/apps/web/src/selection/state.ts @@ -0,0 +1,165 @@ +import type { + BoxSelectionChange, + SelectionState, +} from "@/selection/types"; + +function dedupeIds({ ids }: { ids: string[] }) { + return [...new Set(ids)]; +} + +function getRangeIds({ + orderedIds, + anchorId, + targetId, +}: { + orderedIds: string[]; + anchorId: string; + targetId: string; +}) { + const anchorIndex = orderedIds.indexOf(anchorId); + const targetIndex = orderedIds.indexOf(targetId); + + if (anchorIndex === -1 || targetIndex === -1) { + return [targetId]; + } + + const rangeStart = Math.min(anchorIndex, targetIndex); + const rangeEnd = Math.max(anchorIndex, targetIndex); + return orderedIds.slice(rangeStart, rangeEnd + 1); +} + +export function replaceSelection({ + ids, + anchorId, +}: { + ids: string[]; + anchorId?: string | null; +}): SelectionState { + const selectedIds = dedupeIds({ ids }); + return { + selectedIds, + anchorId: anchorId ?? selectedIds[selectedIds.length - 1] ?? null, + }; +} + +export function clearSelection(): SelectionState { + return { + selectedIds: [], + anchorId: null, + }; +} + +export function pruneSelection({ + state, + orderedIds, +}: { + state: SelectionState; + orderedIds: string[]; +}): SelectionState { + const validIds = new Set(orderedIds); + const selectedIds = state.selectedIds.filter((id) => validIds.has(id)); + const anchorId = + state.anchorId && validIds.has(state.anchorId) + ? state.anchorId + : selectedIds[selectedIds.length - 1] ?? null; + const isUnchanged = + selectedIds.length === state.selectedIds.length && + anchorId === state.anchorId; + + if (isUnchanged) { + return state; + } + + return { + selectedIds, + anchorId, + }; +} + +export function isSelected({ + state, + id, +}: { + state: SelectionState; + id: string; +}) { + return state.selectedIds.includes(id); +} + +export function toggleSelection({ + state, + id, +}: { + state: SelectionState; + id: string; +}): SelectionState { + if (isSelected({ state, id })) { + const selectedIds = state.selectedIds.filter((selectedId) => selectedId !== id); + const anchorId = + state.anchorId === id + ? selectedIds[selectedIds.length - 1] ?? null + : state.anchorId; + return replaceSelection({ + ids: selectedIds, + anchorId, + }); + } + + return replaceSelection({ + ids: [...state.selectedIds, id], + anchorId: id, + }); +} + +export function selectRange({ + state, + orderedIds, + targetId, + isAdditive, +}: { + state: SelectionState; + orderedIds: string[]; + targetId: string; + isAdditive: boolean; +}): SelectionState { + const anchorId = + state.anchorId ?? + state.selectedIds[state.selectedIds.length - 1] ?? + targetId; + const rangeIds = getRangeIds({ + orderedIds, + anchorId, + targetId, + }); + const selectedIds = isAdditive + ? dedupeIds({ + ids: [...state.selectedIds, ...rangeIds], + }) + : rangeIds; + + return replaceSelection({ + ids: selectedIds, + anchorId, + }); +} + +export function applyBoxSelection({ + intersectedIds, + initialSelectedIds, + initialAnchorId, + isAdditive, +}: BoxSelectionChange): SelectionState { + const selectedIds = isAdditive + ? dedupeIds({ + ids: [...initialSelectedIds, ...intersectedIds], + }) + : intersectedIds; + const anchorId = isAdditive + ? initialAnchorId ?? intersectedIds[intersectedIds.length - 1] ?? null + : intersectedIds[intersectedIds.length - 1] ?? null; + + return replaceSelection({ + ids: selectedIds, + anchorId, + }); +} diff --git a/apps/web/src/selection/types.ts b/apps/web/src/selection/types.ts new file mode 100644 index 00000000..31520788 --- /dev/null +++ b/apps/web/src/selection/types.ts @@ -0,0 +1,39 @@ +export interface SelectionState { + selectedIds: string[]; + anchorId: string | null; +} + +export interface BoxSelectionSnapshot { + initialSelectedIds: TId[]; + initialAnchorId: TId | null; +} + +export interface BoxSelectionChange + extends BoxSelectionSnapshot { + intersectedIds: TId[]; + isAdditive: boolean; +} + +export type ResolveIntersections = ({ + startPos, + currentPos, +}: { + startPos: { x: number; y: number }; + currentPos: { x: number; y: number }; +}) => TId[]; + +export interface SelectableSurfaceProps { + orderedIds: string[]; + children: React.ReactNode; + className?: string; + ariaLabel?: string; + revealId?: string | null; + onRevealComplete?: () => void; + onSelectionChange?: (state: SelectionState) => void; +} + +export interface SelectableItemProps + extends React.HTMLAttributes { + id: string; + children: React.ReactNode; +} -- cgit v1.3.1