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/lib/selection/attributes.ts | 1 - apps/web/src/lib/selection/context.tsx | 52 ---- apps/web/src/lib/selection/editor-selection.ts | 25 -- apps/web/src/lib/selection/hit-testing.ts | 50 ---- apps/web/src/lib/selection/hooks/use-box-select.ts | 170 ------------ .../src/lib/selection/hooks/use-selection-scope.ts | 28 -- apps/web/src/lib/selection/index.ts | 4 - apps/web/src/lib/selection/scope.ts | 30 --- apps/web/src/lib/selection/selectable-item.tsx | 107 -------- apps/web/src/lib/selection/selectable-surface.tsx | 288 --------------------- apps/web/src/lib/selection/selection-box.tsx | 50 ---- apps/web/src/lib/selection/state.ts | 165 ------------ apps/web/src/lib/selection/types.ts | 39 --- 13 files changed, 1009 deletions(-) delete mode 100644 apps/web/src/lib/selection/attributes.ts delete mode 100644 apps/web/src/lib/selection/context.tsx delete mode 100644 apps/web/src/lib/selection/editor-selection.ts delete mode 100644 apps/web/src/lib/selection/hit-testing.ts delete mode 100644 apps/web/src/lib/selection/hooks/use-box-select.ts delete mode 100644 apps/web/src/lib/selection/hooks/use-selection-scope.ts delete mode 100644 apps/web/src/lib/selection/index.ts delete mode 100644 apps/web/src/lib/selection/scope.ts delete mode 100644 apps/web/src/lib/selection/selectable-item.tsx delete mode 100644 apps/web/src/lib/selection/selectable-surface.tsx delete mode 100644 apps/web/src/lib/selection/selection-box.tsx delete mode 100644 apps/web/src/lib/selection/state.ts delete mode 100644 apps/web/src/lib/selection/types.ts (limited to 'apps/web/src/lib/selection') diff --git a/apps/web/src/lib/selection/attributes.ts b/apps/web/src/lib/selection/attributes.ts deleted file mode 100644 index a1aee842..00000000 --- a/apps/web/src/lib/selection/attributes.ts +++ /dev/null @@ -1 +0,0 @@ -export const SELECTABLE_ITEM_ATTRIBUTE = "data-selectable-item"; diff --git a/apps/web/src/lib/selection/context.tsx b/apps/web/src/lib/selection/context.tsx deleted file mode 100644 index 8e87aa40..00000000 --- a/apps/web/src/lib/selection/context.tsx +++ /dev/null @@ -1,52 +0,0 @@ -"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/lib/selection/editor-selection.ts b/apps/web/src/lib/selection/editor-selection.ts deleted file mode 100644 index e9563b97..00000000 --- a/apps/web/src/lib/selection/editor-selection.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { SelectedKeyframeRef } from "@/lib/animation/types"; -import type { ElementRef } from "@/lib/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/lib/selection/hit-testing.ts b/apps/web/src/lib/selection/hit-testing.ts deleted file mode 100644 index d8186238..00000000 --- a/apps/web/src/lib/selection/hit-testing.ts +++ /dev/null @@ -1,50 +0,0 @@ -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/lib/selection/hooks/use-box-select.ts b/apps/web/src/lib/selection/hooks/use-box-select.ts deleted file mode 100644 index ab8464ec..00000000 --- a/apps/web/src/lib/selection/hooks/use-box-select.ts +++ /dev/null @@ -1,170 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useRef, useState } from "react"; -import type { - BoxSelectionSnapshot, - ResolveIntersections, -} from "@/lib/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/lib/selection/hooks/use-selection-scope.ts b/apps/web/src/lib/selection/hooks/use-selection-scope.ts deleted file mode 100644 index a7090c5d..00000000 --- a/apps/web/src/lib/selection/hooks/use-selection-scope.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { useEffect, useRef } from "react"; -import { useSelectionContext } from "@/lib/selection/context"; -import { activateScope, type ScopeEntry } from "@/lib/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/lib/selection/index.ts b/apps/web/src/lib/selection/index.ts deleted file mode 100644 index b1ded820..00000000 --- a/apps/web/src/lib/selection/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { SelectableItem } from "@/lib/selection/selectable-item"; -export { SelectableSurface } from "@/lib/selection/selectable-surface"; -export { useSelection } from "@/lib/selection/context"; -export { useSelectionScope } from "@/lib/selection/hooks/use-selection-scope"; diff --git a/apps/web/src/lib/selection/scope.ts b/apps/web/src/lib/selection/scope.ts deleted file mode 100644 index 22c7710c..00000000 --- a/apps/web/src/lib/selection/scope.ts +++ /dev/null @@ -1,30 +0,0 @@ -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/lib/selection/selectable-item.tsx b/apps/web/src/lib/selection/selectable-item.tsx deleted file mode 100644 index 03264753..00000000 --- a/apps/web/src/lib/selection/selectable-item.tsx +++ /dev/null @@ -1,107 +0,0 @@ -"use client"; - -import { forwardRef, useCallback } from "react"; -import { useSelectionContext } from "@/lib/selection/context"; -import { SELECTABLE_ITEM_ATTRIBUTE } from "@/lib/selection/attributes"; -import type { SelectableItemProps } from "@/lib/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/lib/selection/selectable-surface.tsx b/apps/web/src/lib/selection/selectable-surface.tsx deleted file mode 100644 index 012bc486..00000000 --- a/apps/web/src/lib/selection/selectable-surface.tsx +++ /dev/null @@ -1,288 +0,0 @@ -"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/attributes"; -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(() => - 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/lib/selection/selection-box.tsx b/apps/web/src/lib/selection/selection-box.tsx deleted file mode 100644 index e30f574d..00000000 --- a/apps/web/src/lib/selection/selection-box.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"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/lib/selection/state.ts b/apps/web/src/lib/selection/state.ts deleted file mode 100644 index b0ba3089..00000000 --- a/apps/web/src/lib/selection/state.ts +++ /dev/null @@ -1,165 +0,0 @@ -import type { - BoxSelectionChange, - SelectionState, -} from "@/lib/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/lib/selection/types.ts b/apps/web/src/lib/selection/types.ts deleted file mode 100644 index 31520788..00000000 --- a/apps/web/src/lib/selection/types.ts +++ /dev/null @@ -1,39 +0,0 @@ -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