From 4d127a038bf768eded68aae3cfa5c1b12241bdac Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Fri, 27 Mar 2026 00:55:15 +0100 Subject: chore: normalize line endings --- apps/web/src/lib/selection/constants.ts | 2 +- apps/web/src/lib/selection/context.tsx | 104 ++-- apps/web/src/lib/selection/hit-testing.ts | 100 ++-- apps/web/src/lib/selection/hooks/use-box-select.ts | 340 ++++++------ .../src/lib/selection/hooks/use-selection-scope.ts | 56 +- apps/web/src/lib/selection/index.ts | 8 +- apps/web/src/lib/selection/scope.ts | 58 +-- apps/web/src/lib/selection/selectable-item.tsx | 214 ++++---- apps/web/src/lib/selection/selectable-surface.tsx | 576 ++++++++++----------- apps/web/src/lib/selection/selection-box.tsx | 100 ++-- apps/web/src/lib/selection/state.ts | 330 ++++++------ apps/web/src/lib/selection/types.ts | 78 +-- 12 files changed, 983 insertions(+), 983 deletions(-) (limited to 'apps/web/src/lib/selection') diff --git a/apps/web/src/lib/selection/constants.ts b/apps/web/src/lib/selection/constants.ts index a1aee842..a262c8ce 100644 --- a/apps/web/src/lib/selection/constants.ts +++ b/apps/web/src/lib/selection/constants.ts @@ -1 +1 @@ -export const SELECTABLE_ITEM_ATTRIBUTE = "data-selectable-item"; +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 index ced49c88..8e87aa40 100644 --- a/apps/web/src/lib/selection/context.tsx +++ b/apps/web/src/lib/selection/context.tsx @@ -1,52 +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, - }; -} +"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/hit-testing.ts b/apps/web/src/lib/selection/hit-testing.ts index ba086425..d8186238 100644 --- a/apps/web/src/lib/selection/hit-testing.ts +++ b/apps/web/src/lib/selection/hit-testing.ts @@ -1,50 +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); -} +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 index 5a9a5cd0..ab8464ec 100644 --- a/apps/web/src/lib/selection/hooks/use-box-select.ts +++ b/apps/web/src/lib/selection/hooks/use-box-select.ts @@ -1,170 +1,170 @@ -"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, - }; -} +"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 index 7ed63815..a7090c5d 100644 --- a/apps/web/src/lib/selection/hooks/use-selection-scope.ts +++ b/apps/web/src/lib/selection/hooks/use-selection-scope.ts @@ -1,28 +1,28 @@ -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]); -} +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 index b8699ecf..b1ded820 100644 --- a/apps/web/src/lib/selection/index.ts +++ b/apps/web/src/lib/selection/index.ts @@ -1,4 +1,4 @@ -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"; +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 index 45bf6b56..35e86945 100644 --- a/apps/web/src/lib/selection/scope.ts +++ b/apps/web/src/lib/selection/scope.ts @@ -1,29 +1,29 @@ -export type ScopeEntry = { - hasSelection: () => boolean; - clear: () => 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.clear(); - return true; -} +export type ScopeEntry = { + hasSelection: () => boolean; + clear: () => 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.clear(); + return true; +} diff --git a/apps/web/src/lib/selection/selectable-item.tsx b/apps/web/src/lib/selection/selectable-item.tsx index 1d7e74a8..52fd679b 100644 --- a/apps/web/src/lib/selection/selectable-item.tsx +++ b/apps/web/src/lib/selection/selectable-item.tsx @@ -1,107 +1,107 @@ -"use client"; - -import { forwardRef, useCallback } from "react"; -import { useSelectionContext } from "@/lib/selection/context"; -import { SELECTABLE_ITEM_ATTRIBUTE } from "@/lib/selection/constants"; -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} -
- ); - }, -); +"use client"; + +import { forwardRef, useCallback } from "react"; +import { useSelectionContext } from "@/lib/selection/context"; +import { SELECTABLE_ITEM_ATTRIBUTE } from "@/lib/selection/constants"; +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 index 605b3d18..20c7adb3 100644 --- a/apps/web/src/lib/selection/selectable-surface.tsx +++ b/apps/web/src/lib/selection/selectable-surface.tsx @@ -1,288 +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(() => - 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} - -
-
- ); -} +"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(() => + 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 index 0b757e36..e30f574d 100644 --- a/apps/web/src/lib/selection/selection-box.tsx +++ b/apps/web/src/lib/selection/selection-box.tsx @@ -1,50 +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 ( -
- ); -} +"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 index b4c454fb..b0ba3089 100644 --- a/apps/web/src/lib/selection/state.ts +++ b/apps/web/src/lib/selection/state.ts @@ -1,165 +1,165 @@ -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, - }); -} +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 index 94fd7abe..31520788 100644 --- a/apps/web/src/lib/selection/types.ts +++ b/apps/web/src/lib/selection/types.ts @@ -1,39 +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; -} +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