diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-03-27 00:55:15 +0100 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-03-29 15:56:48 +0200 |
| commit | 4d127a038bf768eded68aae3cfa5c1b12241bdac (patch) | |
| tree | 16bbb801caf6770546cff4fdb37b6408ec9ead98 /apps/web/src/lib/selection | |
| parent | 82817cb50cb0487350573d6129130aec705f924b (diff) | |
| download | opencut-4d127a038bf768eded68aae3cfa5c1b12241bdac.tar.gz | |
chore: normalize line endings
Diffstat (limited to 'apps/web/src/lib/selection')
| -rw-r--r-- | apps/web/src/lib/selection/constants.ts | 2 | ||||
| -rw-r--r-- | apps/web/src/lib/selection/context.tsx | 104 | ||||
| -rw-r--r-- | apps/web/src/lib/selection/hit-testing.ts | 100 | ||||
| -rw-r--r-- | apps/web/src/lib/selection/hooks/use-box-select.ts | 340 | ||||
| -rw-r--r-- | apps/web/src/lib/selection/hooks/use-selection-scope.ts | 56 | ||||
| -rw-r--r-- | apps/web/src/lib/selection/index.ts | 8 | ||||
| -rw-r--r-- | apps/web/src/lib/selection/scope.ts | 58 | ||||
| -rw-r--r-- | apps/web/src/lib/selection/selectable-item.tsx | 214 | ||||
| -rw-r--r-- | apps/web/src/lib/selection/selectable-surface.tsx | 576 | ||||
| -rw-r--r-- | apps/web/src/lib/selection/selection-box.tsx | 100 | ||||
| -rw-r--r-- | apps/web/src/lib/selection/state.ts | 330 | ||||
| -rw-r--r-- | apps/web/src/lib/selection/types.ts | 78 |
12 files changed, 983 insertions, 983 deletions
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<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>; - id: string; - }) => void; - handleItemMouseDown: ({ - event, - id, - }: { - event: React.MouseEvent<HTMLDivElement>; - id: string; - }) => void; - registerItem: (id: string, element: HTMLElement | null) => void; -} - -export const SelectionContext = createContext<SelectionContextValue | null>(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<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>;
+ id: string;
+ }) => void;
+ handleItemMouseDown: ({
+ event,
+ id,
+ }: {
+ event: React.MouseEvent<HTMLDivElement>;
+ id: string;
+ }) => void;
+ registerItem: (id: string, element: HTMLElement | null) => void;
+}
+
+export const SelectionContext = createContext<SelectionContextValue | null>(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<string, HTMLElement>; -}) { - 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<string, HTMLElement>;
+}) {
+ 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<TId> extends BoxSelectionSnapshot<TId> { - startPos: { x: number; y: number }; - currentPos: { x: number; y: number }; - isActive: boolean; - isAdditive: boolean; -} - -export function useBoxSelect<TId>({ - containerRef, - resolveIntersections, - selectedIds, - anchorId, - onSelectionChange, - shouldStartSelection, - getIsAdditiveSelection, - isEnabled = true, -}: { - containerRef: React.RefObject<HTMLElement | null>; - resolveIntersections: ResolveIntersections<TId>; - selectedIds: TId[]; - anchorId: TId | null; - onSelectionChange: (state: { - intersectedIds: TId[]; - initialSelectedIds: TId[]; - initialAnchorId: TId | null; - isAdditive: boolean; - }) => void; - shouldStartSelection?: (event: React.MouseEvent<Element>) => boolean; - getIsAdditiveSelection?: (event: React.MouseEvent<Element>) => boolean; - isEnabled?: boolean; -}) { - const [selectionBox, setSelectionBox] = useState<SelectionBoxState<TId> | null>( - null, - ); - const justFinishedSelectingRef = useRef(false); - const shouldStartSelectionCheck = shouldStartSelection ?? (() => true); - const getIsAdditiveSelectionCheck = - getIsAdditiveSelection ?? - ((event: React.MouseEvent<Element>) => event.ctrlKey || event.metaKey); - - const handleMouseDown = useCallback( - (event: React.MouseEvent<Element>) => { - 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<TId>) => { - 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<TId> extends BoxSelectionSnapshot<TId> {
+ startPos: { x: number; y: number };
+ currentPos: { x: number; y: number };
+ isActive: boolean;
+ isAdditive: boolean;
+}
+
+export function useBoxSelect<TId>({
+ containerRef,
+ resolveIntersections,
+ selectedIds,
+ anchorId,
+ onSelectionChange,
+ shouldStartSelection,
+ getIsAdditiveSelection,
+ isEnabled = true,
+}: {
+ containerRef: React.RefObject<HTMLElement | null>;
+ resolveIntersections: ResolveIntersections<TId>;
+ selectedIds: TId[];
+ anchorId: TId | null;
+ onSelectionChange: (state: {
+ intersectedIds: TId[];
+ initialSelectedIds: TId[];
+ initialAnchorId: TId | null;
+ isAdditive: boolean;
+ }) => void;
+ shouldStartSelection?: (event: React.MouseEvent<Element>) => boolean;
+ getIsAdditiveSelection?: (event: React.MouseEvent<Element>) => boolean;
+ isEnabled?: boolean;
+}) {
+ const [selectionBox, setSelectionBox] = useState<SelectionBoxState<TId> | null>(
+ null,
+ );
+ const justFinishedSelectingRef = useRef(false);
+ const shouldStartSelectionCheck = shouldStartSelection ?? (() => true);
+ const getIsAdditiveSelectionCheck =
+ getIsAdditiveSelection ??
+ ((event: React.MouseEvent<Element>) => event.ctrlKey || event.metaKey);
+
+ const handleMouseDown = useCallback(
+ (event: React.MouseEvent<Element>) => {
+ 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<TId>) => {
+ 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<ScopeEntry>({ - 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<ScopeEntry>({
+ 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<T>(ref: React.ForwardedRef<T>, value: T | null) { - if (typeof ref === "function") { - ref(value); - return; - } - - if (ref) { - ref.current = value; - } -} - -export const SelectableItem = forwardRef<HTMLDivElement, SelectableItemProps>( - 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 ( - <div - ref={handleRef} - className={cn(stateClassName, className)} - {...{ [SELECTABLE_ITEM_ATTRIBUTE]: "true" }} - role="option" - aria-selected={isItemSelected} - tabIndex={tabIndex ?? 0} - onClick={(event) => { - 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} - </div> - ); - }, -); +"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<T>(ref: React.ForwardedRef<T>, value: T | null) {
+ if (typeof ref === "function") {
+ ref(value);
+ return;
+ }
+
+ if (ref) {
+ ref.current = value;
+ }
+}
+
+export const SelectableItem = forwardRef<HTMLDivElement, SelectableItemProps>(
+ 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 (
+ <div
+ ref={handleRef}
+ className={cn(stateClassName, className)}
+ {...{ [SELECTABLE_ITEM_ATTRIBUTE]: "true" }}
+ role="option"
+ aria-selected={isItemSelected}
+ tabIndex={tabIndex ?? 0}
+ onClick={(event) => {
+ 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}
+ </div>
+ );
+ },
+);
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<SelectionState>(() => - clearSelection(), - ); - const [highlightedId, setHighlightedId] = useState<string | null>(null); - const containerRef = useRef<HTMLDivElement>(null); - const itemElementsRef = useRef<Map<string, HTMLElement>>(new Map()); - - const registerItem = useCallback( - (id: string, element: HTMLElement | null) => { - if (element) { - itemElementsRef.current.set(id, element); - } else { - itemElementsRef.current.delete(id); - } - }, - [], - ); - - const getSelectableElements = useCallback(() => { - return itemElementsRef.current; - }, []); - - const getItemElement = useCallback((id: string) => { - return itemElementsRef.current.get(id) ?? null; - }, []); - const shouldStartSelection = useCallback( - (event: React.MouseEvent<Element>) => { - return !( - event.target instanceof Element && - event.target.closest(`[${SELECTABLE_ITEM_ATTRIBUTE}='true']`) - ); - }, - [], - ); - const resolveIntersections = useCallback( - ({ - startPos, - currentPos, - }: { - startPos: { x: number; y: number }; - currentPos: { x: number; y: number }; - }) => { - return resolveElementIntersections({ - startPos, - currentPos, - elements: getSelectableElements(), - }); - }, - [getSelectableElements], - ); - - const clearSelectionState = useCallback(() => { - setSelectionState(clearSelection()); - }, []); - - const selectedIdSet = useMemo( - () => new Set(selectionState.selectedIds), - [selectionState.selectedIds], - ); - - const isSelected = useCallback( - (id: string) => selectedIdSet.has(id), - [selectedIdSet], - ); - - const handleItemClick = useCallback( - ({ - event, - id, - }: { - event: - | React.MouseEvent<HTMLDivElement> - | React.KeyboardEvent<HTMLDivElement>; - id: string; - }) => { - setSelectionState((state) => { - const isToggleSelection = event.ctrlKey || event.metaKey; - - if (event.shiftKey) { - return selectRange({ - state, - orderedIds, - targetId: id, - isAdditive: isToggleSelection, - }); - } - - if (isToggleSelection) { - return toggleSelection({ - state, - id, - }); - } - - return replaceSelection({ - ids: [id], - anchorId: id, - }); - }); - }, - [orderedIds], - ); - - const selectUnselectedItem = useCallback((id: string) => { - setSelectionState((state) => { - if (getIsSelected({ state, id })) { - return state; - } - - return replaceSelection({ ids: [id], anchorId: id }); - }); - }, []); - - const handleItemMouseDown = useCallback( - ({ - event, - id, - }: { - event: React.MouseEvent<HTMLDivElement>; - id: string; - }) => { - if (event.button !== 2) { - return; - } - - selectUnselectedItem(id); - }, - [selectUnselectedItem], - ); - - const handleBoxSelectionChange = useCallback( - (change: Parameters<typeof applyBoxSelection>[0]) => { - setSelectionState(applyBoxSelection(change)); - }, - [], - ); - - const { selectionBox, handleMouseDown, shouldIgnoreClick } = useBoxSelect({ - containerRef, - resolveIntersections, - selectedIds: selectionState.selectedIds, - anchorId: selectionState.anchorId, - onSelectionChange: handleBoxSelectionChange, - shouldStartSelection, - }); - - const handleBackgroundClick = useCallback( - (event: React.MouseEvent<HTMLDivElement>) => { - const clickedSelectableItem = - event.target instanceof Element && - event.target.closest(`[${SELECTABLE_ITEM_ATTRIBUTE}='true']`); - const isIgnoringClick = shouldIgnoreClick(); - if (clickedSelectableItem || isIgnoringClick) { - return; - } - - clearSelectionState(); - }, - [clearSelectionState, shouldIgnoreClick], - ); - - const handleBackgroundKeyDown = useCallback( - (event: React.KeyboardEvent<HTMLDivElement>) => { - if (event.target !== event.currentTarget) { - return; - } - - if (event.key !== "Enter" && event.key !== " " && event.key !== "Escape") { - return; - } - - event.preventDefault(); - clearSelectionState(); - }, - [clearSelectionState], - ); - - useEffect(() => { - setSelectionState((state) => - pruneSelection({ - state, - orderedIds, - }), - ); - }, [orderedIds]); - - useEffect(() => { - onSelectionChange?.(selectionState); - }, [onSelectionChange, selectionState]); - - useEffect(() => { - if (!revealId) { - return; - } - - setHighlightedId(revealId); - getItemElement(revealId)?.scrollIntoView({ block: "center" }); - - const timer = setTimeout(() => { - setHighlightedId(null); - onRevealComplete?.(); - }, 1500); - - return () => clearTimeout(timer); - }, [getItemElement, onRevealComplete, revealId]); - - const isBoxSelecting = selectionBox?.isActive ?? false; - - const contextValue = useMemo(() => { - return { - selectedIds: selectionState.selectedIds, - anchorId: selectionState.anchorId, - highlightedId, - isBoxSelecting, - isSelected, - clearSelection: clearSelectionState, - handleItemClick, - handleItemMouseDown, - registerItem, - }; - }, [ - clearSelectionState, - handleItemClick, - handleItemMouseDown, - highlightedId, - isBoxSelecting, - isSelected, - registerItem, - selectionState.anchorId, - selectionState.selectedIds, - ]); - - return ( - <SelectionContext.Provider value={contextValue}> - <div - ref={containerRef} - className={cn("relative min-h-full", className)} - role="listbox" - aria-label={ariaLabel} - aria-multiselectable="true" - tabIndex={0} - onMouseDown={handleMouseDown} - onClick={handleBackgroundClick} - onKeyDown={handleBackgroundKeyDown} - > - {children} - <SelectionBox - startPos={selectionBox?.startPos ?? null} - currentPos={selectionBox?.currentPos ?? null} - containerRef={containerRef} - isActive={selectionBox?.isActive ?? false} - /> - </div> - </SelectionContext.Provider> - ); -} +"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { SelectionBox } from "@/lib/selection/selection-box";
+import { SelectionContext } from "@/lib/selection/context";
+import { SELECTABLE_ITEM_ATTRIBUTE } from "@/lib/selection/constants";
+import { resolveElementIntersections } from "@/lib/selection/hit-testing";
+import {
+ applyBoxSelection,
+ clearSelection,
+ isSelected as getIsSelected,
+ pruneSelection,
+ replaceSelection,
+ selectRange,
+ toggleSelection,
+} from "@/lib/selection/state";
+import type {
+ SelectableSurfaceProps,
+ SelectionState,
+} from "@/lib/selection/types";
+import { useBoxSelect } from "@/lib/selection/hooks/use-box-select";
+import { cn } from "@/utils/ui";
+
+export function SelectableSurface({
+ orderedIds,
+ children,
+ className,
+ ariaLabel = "Selectable items",
+ revealId = null,
+ onRevealComplete,
+ onSelectionChange,
+}: SelectableSurfaceProps) {
+ const [selectionState, setSelectionState] = useState<SelectionState>(() =>
+ clearSelection(),
+ );
+ const [highlightedId, setHighlightedId] = useState<string | null>(null);
+ const containerRef = useRef<HTMLDivElement>(null);
+ const itemElementsRef = useRef<Map<string, HTMLElement>>(new Map());
+
+ const registerItem = useCallback(
+ (id: string, element: HTMLElement | null) => {
+ if (element) {
+ itemElementsRef.current.set(id, element);
+ } else {
+ itemElementsRef.current.delete(id);
+ }
+ },
+ [],
+ );
+
+ const getSelectableElements = useCallback(() => {
+ return itemElementsRef.current;
+ }, []);
+
+ const getItemElement = useCallback((id: string) => {
+ return itemElementsRef.current.get(id) ?? null;
+ }, []);
+ const shouldStartSelection = useCallback(
+ (event: React.MouseEvent<Element>) => {
+ return !(
+ event.target instanceof Element &&
+ event.target.closest(`[${SELECTABLE_ITEM_ATTRIBUTE}='true']`)
+ );
+ },
+ [],
+ );
+ const resolveIntersections = useCallback(
+ ({
+ startPos,
+ currentPos,
+ }: {
+ startPos: { x: number; y: number };
+ currentPos: { x: number; y: number };
+ }) => {
+ return resolveElementIntersections({
+ startPos,
+ currentPos,
+ elements: getSelectableElements(),
+ });
+ },
+ [getSelectableElements],
+ );
+
+ const clearSelectionState = useCallback(() => {
+ setSelectionState(clearSelection());
+ }, []);
+
+ const selectedIdSet = useMemo(
+ () => new Set(selectionState.selectedIds),
+ [selectionState.selectedIds],
+ );
+
+ const isSelected = useCallback(
+ (id: string) => selectedIdSet.has(id),
+ [selectedIdSet],
+ );
+
+ const handleItemClick = useCallback(
+ ({
+ event,
+ id,
+ }: {
+ event:
+ | React.MouseEvent<HTMLDivElement>
+ | React.KeyboardEvent<HTMLDivElement>;
+ id: string;
+ }) => {
+ setSelectionState((state) => {
+ const isToggleSelection = event.ctrlKey || event.metaKey;
+
+ if (event.shiftKey) {
+ return selectRange({
+ state,
+ orderedIds,
+ targetId: id,
+ isAdditive: isToggleSelection,
+ });
+ }
+
+ if (isToggleSelection) {
+ return toggleSelection({
+ state,
+ id,
+ });
+ }
+
+ return replaceSelection({
+ ids: [id],
+ anchorId: id,
+ });
+ });
+ },
+ [orderedIds],
+ );
+
+ const selectUnselectedItem = useCallback((id: string) => {
+ setSelectionState((state) => {
+ if (getIsSelected({ state, id })) {
+ return state;
+ }
+
+ return replaceSelection({ ids: [id], anchorId: id });
+ });
+ }, []);
+
+ const handleItemMouseDown = useCallback(
+ ({
+ event,
+ id,
+ }: {
+ event: React.MouseEvent<HTMLDivElement>;
+ id: string;
+ }) => {
+ if (event.button !== 2) {
+ return;
+ }
+
+ selectUnselectedItem(id);
+ },
+ [selectUnselectedItem],
+ );
+
+ const handleBoxSelectionChange = useCallback(
+ (change: Parameters<typeof applyBoxSelection>[0]) => {
+ setSelectionState(applyBoxSelection(change));
+ },
+ [],
+ );
+
+ const { selectionBox, handleMouseDown, shouldIgnoreClick } = useBoxSelect({
+ containerRef,
+ resolveIntersections,
+ selectedIds: selectionState.selectedIds,
+ anchorId: selectionState.anchorId,
+ onSelectionChange: handleBoxSelectionChange,
+ shouldStartSelection,
+ });
+
+ const handleBackgroundClick = useCallback(
+ (event: React.MouseEvent<HTMLDivElement>) => {
+ const clickedSelectableItem =
+ event.target instanceof Element &&
+ event.target.closest(`[${SELECTABLE_ITEM_ATTRIBUTE}='true']`);
+ const isIgnoringClick = shouldIgnoreClick();
+ if (clickedSelectableItem || isIgnoringClick) {
+ return;
+ }
+
+ clearSelectionState();
+ },
+ [clearSelectionState, shouldIgnoreClick],
+ );
+
+ const handleBackgroundKeyDown = useCallback(
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
+ if (event.target !== event.currentTarget) {
+ return;
+ }
+
+ if (event.key !== "Enter" && event.key !== " " && event.key !== "Escape") {
+ return;
+ }
+
+ event.preventDefault();
+ clearSelectionState();
+ },
+ [clearSelectionState],
+ );
+
+ useEffect(() => {
+ setSelectionState((state) =>
+ pruneSelection({
+ state,
+ orderedIds,
+ }),
+ );
+ }, [orderedIds]);
+
+ useEffect(() => {
+ onSelectionChange?.(selectionState);
+ }, [onSelectionChange, selectionState]);
+
+ useEffect(() => {
+ if (!revealId) {
+ return;
+ }
+
+ setHighlightedId(revealId);
+ getItemElement(revealId)?.scrollIntoView({ block: "center" });
+
+ const timer = setTimeout(() => {
+ setHighlightedId(null);
+ onRevealComplete?.();
+ }, 1500);
+
+ return () => clearTimeout(timer);
+ }, [getItemElement, onRevealComplete, revealId]);
+
+ const isBoxSelecting = selectionBox?.isActive ?? false;
+
+ const contextValue = useMemo(() => {
+ return {
+ selectedIds: selectionState.selectedIds,
+ anchorId: selectionState.anchorId,
+ highlightedId,
+ isBoxSelecting,
+ isSelected,
+ clearSelection: clearSelectionState,
+ handleItemClick,
+ handleItemMouseDown,
+ registerItem,
+ };
+ }, [
+ clearSelectionState,
+ handleItemClick,
+ handleItemMouseDown,
+ highlightedId,
+ isBoxSelecting,
+ isSelected,
+ registerItem,
+ selectionState.anchorId,
+ selectionState.selectedIds,
+ ]);
+
+ return (
+ <SelectionContext.Provider value={contextValue}>
+ <div
+ ref={containerRef}
+ className={cn("relative min-h-full", className)}
+ role="listbox"
+ aria-label={ariaLabel}
+ aria-multiselectable="true"
+ tabIndex={0}
+ onMouseDown={handleMouseDown}
+ onClick={handleBackgroundClick}
+ onKeyDown={handleBackgroundKeyDown}
+ >
+ {children}
+ <SelectionBox
+ startPos={selectionBox?.startPos ?? null}
+ currentPos={selectionBox?.currentPos ?? null}
+ containerRef={containerRef}
+ isActive={selectionBox?.isActive ?? false}
+ />
+ </div>
+ </SelectionContext.Provider>
+ );
+}
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<HTMLElement | null>; - 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 ( - <div - style={selectionBoxStyle} - className="border-foreground/50 bg-foreground/5 pointer-events-none absolute z-50 border" - /> - ); -} +"use client";
+
+import { useMemo } from "react";
+
+interface SelectionBoxProps {
+ startPos: { x: number; y: number } | null;
+ currentPos: { x: number; y: number } | null;
+ containerRef: React.RefObject<HTMLElement | null>;
+ 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 (
+ <div
+ style={selectionBoxStyle}
+ className="border-foreground/50 bg-foreground/5 pointer-events-none absolute z-50 border"
+ />
+ );
+}
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<TId = string> { - initialSelectedIds: TId[]; - initialAnchorId: TId | null; -} - -export interface BoxSelectionChange<TId = string> - extends BoxSelectionSnapshot<TId> { - intersectedIds: TId[]; - isAdditive: boolean; -} - -export type ResolveIntersections<TId = string> = ({ - 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<HTMLDivElement> { - id: string; - children: React.ReactNode; -} +export interface SelectionState {
+ selectedIds: string[];
+ anchorId: string | null;
+}
+
+export interface BoxSelectionSnapshot<TId = string> {
+ initialSelectedIds: TId[];
+ initialAnchorId: TId | null;
+}
+
+export interface BoxSelectionChange<TId = string>
+ extends BoxSelectionSnapshot<TId> {
+ intersectedIds: TId[];
+ isAdditive: boolean;
+}
+
+export type ResolveIntersections<TId = string> = ({
+ 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<HTMLDivElement> {
+ id: string;
+ children: React.ReactNode;
+}
|
