summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/selection
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/lib/selection')
-rw-r--r--apps/web/src/lib/selection/constants.ts2
-rw-r--r--apps/web/src/lib/selection/context.tsx104
-rw-r--r--apps/web/src/lib/selection/hit-testing.ts100
-rw-r--r--apps/web/src/lib/selection/hooks/use-box-select.ts340
-rw-r--r--apps/web/src/lib/selection/hooks/use-selection-scope.ts56
-rw-r--r--apps/web/src/lib/selection/index.ts8
-rw-r--r--apps/web/src/lib/selection/scope.ts58
-rw-r--r--apps/web/src/lib/selection/selectable-item.tsx214
-rw-r--r--apps/web/src/lib/selection/selectable-surface.tsx576
-rw-r--r--apps/web/src/lib/selection/selection-box.tsx100
-rw-r--r--apps/web/src/lib/selection/state.ts330
-rw-r--r--apps/web/src/lib/selection/types.ts78
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;
+}