Files
papercrate/frontend/src/desktop/useDocumentDrag.ts
T
2025-11-24 20:42:43 +01:00

1098 lines
37 KiB
TypeScript

import {
useCallback,
useEffect,
useRef,
type MutableRefObject,
type RefObject,
} from 'react';
import type { PointerEvent as ReactPointerEvent } from 'react';
import { preventAll, safeInvoke } from './events';
import { clamp } from '../utils/math';
import usePointerTap from '../ui/usePointerTap';
import {
MIN_TIMESTEP,
MAX_TIMESTEP,
MAX_ANGULAR_VELOCITY,
MAX_DYNAMIC_ROTATION,
CARD_BASE_WEIGHT_GRAMS,
CARD_PAGE_WEIGHT_GRAMS,
applyDomTransform,
type WorkspaceEngine,
} from './workspaceEngine';
type Identifier = string | number;
interface DocumentLike {
id?: Identifier | null;
title?: string;
current_version?: {
metadata?: { page_count?: number | string | null } | null;
} | null;
metadata?: { page_count?: number | string | null } | null;
[key: string]: unknown;
}
interface DocumentSizeInfo {
width: number;
height: number;
}
interface LayoutEntry {
centerX?: number;
centerY?: number;
rotation?: number;
z?: number;
width?: number;
height?: number;
}
interface DragTransform {
centerX: number;
centerY: number;
rotation: number;
width?: number;
height?: number;
scale?: number;
}
type EngineDragState = Parameters<WorkspaceEngine['finalizeGroupDrag']>[0];
type EngineGroupItem = NonNullable<EngineDragState['groupItems']>[number];
interface DragGroupItemInternal extends EngineGroupItem {
baseOffsetX?: number;
baseOffsetY?: number;
offsetX?: number;
offsetY?: number;
targetRotation?: number;
initialRotation?: number;
}
type EnsureDocumentSizeFn = (doc: DocumentLike | null) => DocumentSizeInfo | null;
type ResolveBaseMetricsFn = (
doc: DocumentLike | null,
width: number,
height: number,
) => { baseWidth: number; baseHeight: number; baseScale: number };
interface DragSettings {
canvasPadding?: number;
defaultCanvasWidth?: number;
defaultCanvasHeight?: number;
debugDrag?: boolean;
}
interface PointerDownOptions {
stackDocIds?: Array<Identifier | null>;
stackSelectionApplied?: boolean;
wasSelected?: boolean;
modifierActive?: boolean;
stackReplace?: boolean;
}
interface UseDocumentDragOptions {
engine?: WorkspaceEngine | null;
layoutRef: MutableRefObject<Map<string, LayoutEntry>>;
dragTransformsRef: MutableRefObject<Map<string, DragTransform>>;
itemRefs: MutableRefObject<Map<string, HTMLElement | null>>;
documentLookup: Map<string, DocumentLike>;
ensureDocumentSize: EnsureDocumentSizeFn;
resolveBaseMetrics: ResolveBaseMetricsFn;
bringToFront: (docId: Identifier | null) => void;
setDraggingId: (docKey: string | null) => void;
canvasSize: { width: number; height: number };
openOverlayForDoc?: (
docId: Identifier | null,
originInfo?: { rotation: number; scale: number; width: number; height: number },
) => void;
recalcVisibleDocIds: () => void;
settings?: DragSettings;
containerRef?: RefObject<HTMLElement>;
onDocumentActivate?: (docId: Identifier | null, event?: PointerEvent | ReactPointerEvent) => void;
onDocumentStackSelect?: (
docIds: Identifier[],
event: PointerEvent | ReactPointerEvent,
options?: { replace?: boolean },
) => void;
selectedDocumentIds?: Array<Identifier | null>;
markLayoutDirty?: () => void;
}
type EngineInertiaState = Parameters<WorkspaceEngine['startInertiaAnimation']>[1];
interface DragStateInternal extends EngineDragState {
docId: string;
docKey: string;
pointerId: number;
originCenterX: number;
originCenterY: number;
currentCenterX: number;
currentCenterY: number;
startX: number;
startY: number;
rotation: number;
restRotation: number;
dynamicRotation: number;
angularVelocity: number;
moved: boolean;
locked: boolean;
width: number;
height: number;
dragScale: number;
baseScale: number;
capturedTarget: HTMLElement | null;
lastClientX: number;
lastClientY: number;
lastTimestamp: number;
localPointerOffsetX: number;
localPointerOffsetY: number;
containerRectLeft: number;
containerRectTop: number;
isGroup: boolean;
activeDocIds: string[];
groupItems: DragGroupItemInternal[];
groupElevated: boolean;
stackDocIds: string[] | null;
stackSelectionApplied: boolean;
stackReplace: boolean;
massGrams: number;
pointerRadiusScale: number;
lastPointerCanvasX: number;
lastPointerCanvasY: number;
}
type PointerEventLike = PointerEvent | ReactPointerEvent<HTMLElement>;
interface DragTapMetadata {
docId: Identifier | null;
originInfo?: { rotation: number; scale: number; width: number; height: number };
docTitle: string;
}
const DRAG_HYSTERESIS_PX = 4;
const DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX;
const EDGE_COLLISION_THRESHOLD = 0.5;
const getDocumentPageCount = (doc?: DocumentLike | null): number | null => {
const raw = doc?.current_version?.metadata?.page_count ?? doc?.metadata?.page_count;
if (raw == null) {
return null;
}
const value = Number(raw);
return Number.isFinite(value) ? value : null;
};
const computeDocumentMassGrams = (doc?: DocumentLike | null): number => {
const pages = Math.max(1, Math.round(getDocumentPageCount(doc) ?? 1));
return CARD_BASE_WEIGHT_GRAMS + pages * CARD_PAGE_WEIGHT_GRAMS;
};
const getEventTargetElement = (event?: PointerEventLike | null): Element | null => {
if (!event) {
return null;
}
const nativeEvent = 'nativeEvent' in event ? (event as ReactPointerEvent).nativeEvent : null;
const candidate = (event.target as Element | null) || (nativeEvent ? (nativeEvent.target as Element | null) : null);
return candidate instanceof Element ? candidate : null;
};
const useDocumentDrag = (options: UseDocumentDragOptions) => {
const {
engine,
layoutRef,
dragTransformsRef,
itemRefs,
documentLookup,
ensureDocumentSize,
resolveBaseMetrics,
bringToFront,
setDraggingId,
canvasSize,
openOverlayForDoc,
recalcVisibleDocIds,
settings,
containerRef: providedContainerRef,
onDocumentActivate,
onDocumentStackSelect,
selectedDocumentIds = [],
markLayoutDirty,
} = options;
const fallbackContainerRef = useRef<HTMLElement | null>(null);
const containerRef = providedContainerRef ?? fallbackContainerRef;
const {
canvasPadding = 24,
defaultCanvasWidth = 1024,
defaultCanvasHeight = 680,
debugDrag = false,
} = settings || {};
useEffect(
() => () => {
engine?.disposeInertiaAnimations?.();
},
[engine],
);
const tapHandler = usePointerTap<DragTapMetadata>({
delay: 220,
onSingle: () => {},
onDouble: ({ data, event }) => {
if (!data?.docId) {
return;
}
if (event?.altKey) {
openOverlayForDoc?.(data.docId, data.originInfo);
return;
}
onDocumentActivate?.(data.docId, event);
},
});
const dragStateRef = useRef<DragStateInternal | null>(null);
const setDragTransform = useCallback((docKey: Identifier | null, transform: DragTransform | null) => {
if (!docKey) {
return;
}
const map = dragTransformsRef?.current;
if (!map) {
return;
}
if (transform) {
map.set(String(docKey), transform);
} else {
map.delete(String(docKey));
}
}, [dragTransformsRef]);
const clearDragTransforms = useCallback(() => {
const map = dragTransformsRef?.current;
if (!map?.clear) {
return;
}
map.clear();
}, [dragTransformsRef]);
const commitActiveDragTransforms = useCallback((docIds: Array<Identifier | null> | null = null) => {
const map = dragTransformsRef?.current;
if (!map || !map.size) {
return;
}
const keys = Array.isArray(docIds) && docIds.length
? docIds
.map((id) => (id != null ? String(id) : null))
.filter((value): value is string => Boolean(value))
: Array.from(map.keys());
keys.forEach((key) => {
const transform = map.get(key);
if (!transform) {
return;
}
const previous = layoutRef.current.get(key) || {};
layoutRef.current.set(key, {
...previous,
centerX: transform.centerX,
centerY: transform.centerY,
rotation: transform.rotation ?? previous.rotation ?? 0,
});
});
markLayoutDirty?.();
}, [dragTransformsRef, layoutRef, markLayoutDirty]);
const finishDrag = useCallback(
(pointerId: number, { clearTransforms = true }: { clearTransforms?: boolean } = {}) => {
const state = dragStateRef.current;
if (state && state.pointerId === pointerId) {
const capturedTarget = state.capturedTarget;
if (capturedTarget?.releasePointerCapture) {
try {
capturedTarget.releasePointerCapture(pointerId);
} catch (error) {
if (debugDrag) {
void error;
}
}
}
}
dragStateRef.current = null;
setDraggingId(null);
engine?.endDrag?.();
if (clearTransforms) {
clearDragTransforms();
}
},
[clearDragTransforms, debugDrag, engine, setDraggingId],
);
const handlePointerDown = useCallback(
(event: PointerEventLike, docIdInput?: Identifier | null, options: PointerDownOptions = {}) => {
const targetElement = getEventTargetElement(event);
if (targetElement?.closest && targetElement.closest('[data-desk-tag-chip="true"]')) {
return;
}
preventAll(event);
const docId = docIdInput != null ? docIdInput : null;
const docKey = docId != null ? String(docId) : null;
if (!docKey) {
return;
}
engine?.cancelInertiaAnimation?.(docKey);
const doc = documentLookup.get(docKey);
if (!doc) {
return;
}
const massGrams = computeDocumentMassGrams(doc);
const stackDocIdsOptionRaw = options?.stackDocIds;
const stackDocIdsOption = Array.isArray(stackDocIdsOptionRaw)
? stackDocIdsOptionRaw
.map((value) => (value != null ? String(value) : null))
.filter((value): value is string => Boolean(value))
: null;
const stackSelectionAppliedInitial = Boolean(options?.stackSelectionApplied);
const wasSelectedAtPointerDown = Boolean(options?.wasSelected);
const pointerModifierActive =
options?.modifierActive ?? Boolean(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey);
const stackReplace = Boolean(options?.stackReplace);
let selectionIds: string[] = Array.isArray(selectedDocumentIds)
? selectedDocumentIds
.map((id) => (id != null ? String(id) : null))
.filter((id): id is string => Boolean(id))
: [];
if (!stackDocIdsOption && !pointerModifierActive && !wasSelectedAtPointerDown) {
selectionIds = [docKey];
}
if (stackDocIdsOption && stackDocIdsOption.length) {
const selectionSet = new Set(selectionIds);
stackDocIdsOption.forEach((value) => {
if (value != null) {
selectionSet.add(String(value));
}
});
selectionIds = Array.from(selectionSet);
}
const metaOrCtrl = event.metaKey || event.ctrlKey;
if (!stackDocIdsOption && metaOrCtrl && !selectionIds.includes(docKey)) {
selectionIds = [...selectionIds, docKey];
}
selectionIds = selectionIds.filter((id, index, array) => array.indexOf(id) === index && documentLookup.has(id));
if (!selectionIds.includes(docKey)) {
selectionIds.unshift(docKey);
}
if (!selectionIds.length) {
selectionIds = [docKey];
}
const isGroupDrag = selectionIds.length > 1;
if (isGroupDrag) {
selectionIds.forEach((id) => {
if (id !== docKey) {
engine?.cancelInertiaAnimation?.(id);
}
});
}
const sizeInfo = ensureDocumentSize(doc) || { width: 0, height: 0 };
const docWidth = sizeInfo.width || 320;
const docHeight = sizeInfo.height || 240;
const { baseScale } = resolveBaseMetrics(doc, docWidth, docHeight);
const normalizedBaseScale =
Number.isFinite(baseScale) && baseScale > 0 ? baseScale : 1;
const entry = layoutRef.current.get(docKey) || null;
const defaultCenterX = canvasPadding + docWidth / 2;
const defaultCenterY = canvasPadding + docHeight / 2;
const centerX = Number.isFinite(entry?.centerX) ? entry.centerX : defaultCenterX;
const centerY = Number.isFinite(entry?.centerY) ? entry.centerY : defaultCenterY;
const modifierPressed = pointerModifierActive;
if (!modifierPressed) {
if (isGroupDrag) {
const layout = layoutRef.current;
const ordered = [...selectionIds]
.filter((id, index, array) => array.indexOf(id) === index)
.sort((a, b) => {
const aZ = layout.get(a)?.z ?? 0;
const bZ = layout.get(b)?.z ?? 0;
return aZ - bZ;
});
ordered.forEach((id) => {
bringToFront(id === docKey ? docId : id);
});
} else {
bringToFront(docId);
}
}
if (entry && (entry.centerX !== centerX || entry.centerY !== centerY)) {
layoutRef.current.set(docKey, { ...entry, centerX, centerY });
}
const capturedTarget = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
if (capturedTarget?.setPointerCapture) {
try {
capturedTarget.setPointerCapture(event.pointerId);
} catch (error) {
if (debugDrag) {
void error;
}
}
}
const containerRect = containerRef.current?.getBoundingClientRect?.() || null;
const containerLeft = containerRect?.left || 0;
const containerTop = containerRect?.top || 0;
const pointerCanvasX = event.clientX - containerLeft;
const pointerCanvasY = event.clientY - containerTop;
const pointerOffsetX = pointerCanvasX - centerX;
const pointerOffsetY = pointerCanvasY - centerY;
const initialRotationDeg = entry?.rotation ?? 0;
const initialRotationRad = (initialRotationDeg * Math.PI) / 180;
const cosInitial = Math.cos(-initialRotationRad);
const sinInitial = Math.sin(-initialRotationRad);
const localPointerOffsetX = pointerOffsetX * cosInitial - pointerOffsetY * sinInitial;
const localPointerOffsetY = pointerOffsetX * sinInitial + pointerOffsetY * cosInitial;
const groupItems: DragGroupItemInternal[] = selectionIds.map((id) => {
const itemDoc = documentLookup.get(id);
const itemSize = ensureDocumentSize(itemDoc) || sizeInfo;
const itemWidth = itemSize.width || docWidth;
const itemHeight = itemSize.height || docHeight;
const itemEntry = layoutRef.current.get(id) || null;
const itemCenterX =
Number.isFinite(itemEntry?.centerX) ? itemEntry.centerX : canvasPadding + itemWidth / 2;
const itemCenterY =
Number.isFinite(itemEntry?.centerY) ? itemEntry.centerY : canvasPadding + itemHeight / 2;
const baseOffsetX = itemCenterX - centerX;
const baseOffsetY = itemCenterY - centerY;
const initialRotation = itemEntry?.rotation ?? 0;
return {
docId: id,
width: itemWidth,
height: itemHeight,
currentCenterX: itemCenterX,
currentCenterY: itemCenterY,
baseOffsetX,
baseOffsetY,
offsetX: baseOffsetX,
offsetY: baseOffsetY,
targetRotation: initialRotation,
displayRotation: initialRotation,
} satisfies DragGroupItemInternal;
});
const eventTimestamp =
(Number.isFinite(event?.timeStamp))
? event.timeStamp
: performance?.now
? performance.now()
: Date.now();
const hasStackSource = Array.isArray(stackDocIdsOption) && stackDocIdsOption.length > 1;
dragStateRef.current = {
docId: docKey,
docKey,
pointerId: event.pointerId,
originCenterX: centerX,
originCenterY: centerY,
currentCenterX: centerX,
currentCenterY: centerY,
startX: event.clientX,
startY: event.clientY,
rotation: entry?.rotation ?? 0,
restRotation: entry?.rotation ?? 0,
dynamicRotation: 0,
angularVelocity: 0,
moved: false,
locked: false,
width: docWidth,
height: docHeight,
dragScale: 1,
baseScale: normalizedBaseScale,
capturedTarget,
lastClientX: event.clientX,
lastClientY: event.clientY,
lastTimestamp: eventTimestamp,
localPointerOffsetX,
localPointerOffsetY,
containerRectLeft: containerLeft,
containerRectTop: containerTop,
isGroup: isGroupDrag,
activeDocIds: selectionIds,
groupItems,
groupElevated: !isGroupDrag,
stackDocIds: hasStackSource ? stackDocIdsOption : null,
stackSelectionApplied: stackSelectionAppliedInitial || !hasStackSource,
stackReplace,
massGrams,
pointerRadiusScale: 1,
lastPointerCanvasX: pointerCanvasX,
lastPointerCanvasY: pointerCanvasY,
} satisfies DragStateInternal;
const state = dragStateRef.current;
if (!state) {
return;
}
clearDragTransforms();
state.groupItems.forEach((item) => {
if (!item?.docId) {
return;
}
setDragTransform(item.docId, {
centerX: item.currentCenterX,
centerY: item.currentCenterY,
rotation: item.displayRotation ?? item.initialRotation ?? 0,
width: item.width,
height: item.height,
scale: item.docId === state.docKey ? state.dragScale || 1 : 1,
});
});
engine?.beginDrag?.(state.activeDocIds);
setDraggingId(docKey);
if (isGroupDrag) {
groupItems.forEach((item) => {
if (item.docId === docKey) {
return;
}
const node = itemRefs.current.get(item.docId);
if (node) {
item.displayRotation = item.initialRotation;
const itemEntry = layoutRef.current.get(item.docId) || null;
applyDomTransform(node, {
centerX: item.currentCenterX,
centerY: item.currentCenterY,
width: item.width,
height: item.height,
rotation: item.displayRotation ?? 0,
scale: 1,
zIndex: itemEntry?.z,
});
}
});
}
}, [
bringToFront,
canvasPadding,
containerRef,
documentLookup,
engine,
ensureDocumentSize,
layoutRef,
resolveBaseMetrics,
selectedDocumentIds,
setDraggingId,
debugDrag,
itemRefs,
clearDragTransforms,
setDragTransform,
]);
const handlePointerMove = useCallback(
(event: PointerEventLike) => {
const state = dragStateRef.current;
if (!state) {
return;
}
if (state.pointerId !== event.pointerId) {
return;
}
preventAll(event);
const updatePointerAngularVelocity = (
pointerCanvasX: number,
pointerCanvasY: number,
centerX: number,
centerY: number,
dtSeconds: number,
) => {
if (!Number.isFinite(dtSeconds) || dtSeconds <= 0) {
return;
}
const leverX = pointerCanvasX - centerX;
const leverY = pointerCanvasY - centerY;
if (!Number.isFinite(leverX) || !Number.isFinite(leverY)) {
return;
}
const prevCanvasX = Number.isFinite(state.lastPointerCanvasX)
? state.lastPointerCanvasX
: pointerCanvasX;
const prevCanvasY = Number.isFinite(state.lastPointerCanvasY)
? state.lastPointerCanvasY
: pointerCanvasY;
const velocityCanvasX = (pointerCanvasX - prevCanvasX) / dtSeconds;
const velocityCanvasY = (pointerCanvasY - prevCanvasY) / dtSeconds;
state.lastPointerCanvasX = pointerCanvasX;
state.lastPointerCanvasY = pointerCanvasY;
if (!Number.isFinite(velocityCanvasX) || !Number.isFinite(velocityCanvasY)) {
return;
}
const torque = leverX * velocityCanvasY - leverY * velocityCanvasX;
const influenceRadius = Math.max(state.width, state.height) / 2 || 1;
const radiusScale = clamp(Math.hypot(leverX, leverY) / influenceRadius, 0.2, 2.5);
state.pointerRadiusScale = radiusScale;
const torqueResponse = 0.0025 * radiusScale;
const angularVelocityDeg = clamp(
torque * torqueResponse,
-MAX_ANGULAR_VELOCITY,
MAX_ANGULAR_VELOCITY,
);
const mass = Math.max(state.massGrams || CARD_BASE_WEIGHT_GRAMS, CARD_BASE_WEIGHT_GRAMS);
const massScale = Math.max(mass / CARD_BASE_WEIGHT_GRAMS, 1);
state.angularVelocity = angularVelocityDeg / massScale;
};
const applyDynamicRotation = (dtSeconds: number, dampingFactor = 0.94) => {
if (!Number.isFinite(dtSeconds) || dtSeconds <= 0) {
return;
}
const radiusInfluence = clamp(state.pointerRadiusScale || 1, 0.3, 3);
const response = 1.1 * radiusInfluence;
let nextDynamic = state.dynamicRotation + state.angularVelocity * dtSeconds * response;
nextDynamic = clamp(nextDynamic, -MAX_DYNAMIC_ROTATION, MAX_DYNAMIC_ROTATION);
const adjustedDamping = Math.pow(dampingFactor, 1 / Math.max(radiusInfluence, 0.8));
state.dynamicRotation = nextDynamic * adjustedDamping;
state.rotation = state.restRotation + state.dynamicRotation;
};
if (state.isGroup) {
const containerRect = containerRef.current?.getBoundingClientRect?.();
if (containerRect) {
state.containerRectLeft = containerRect.left;
state.containerRectTop = containerRect.top;
}
const pointerCanvasX = event.clientX - state.containerRectLeft;
const pointerCanvasY = event.clientY - state.containerRectTop;
const deltaX = event.clientX - state.startX;
const deltaY = event.clientY - state.startY;
if (!state.moved) {
const distanceSquared = deltaX * deltaX + deltaY * deltaY;
if (distanceSquared < DRAG_HYSTERESIS_SQUARED) {
return;
}
state.moved = true;
if (
!state.stackSelectionApplied
&& Array.isArray(state.stackDocIds)
&& state.stackDocIds.length > 0
) {
safeInvoke(onDocumentStackSelect, state.stackDocIds as Identifier[], event, {
replace: state.stackReplace,
});
state.stackSelectionApplied = true;
}
if (!state.groupElevated) {
const layout = layoutRef.current;
const sortedGroup = state.activeDocIds
.filter((id) => id !== state.docKey)
.sort((a, b) => {
const aZ = layout.get(a)?.z ?? 0;
const bZ = layout.get(b)?.z ?? 0;
return aZ - bZ;
});
sortedGroup.forEach((id) => bringToFront(id));
bringToFront(state.docKey);
state.groupElevated = true;
}
}
const docWidth = state.width;
const docHeight = state.height;
const halfWidth = docWidth / 2;
const halfHeight = docHeight / 2;
const canvasWidth = canvasSize.width || defaultCanvasWidth;
const canvasHeight = canvasSize.height || defaultCanvasHeight;
const minCenterX = canvasPadding + halfWidth;
const maxCenterX = Math.max(minCenterX, canvasWidth - canvasPadding - halfWidth);
const minCenterY = canvasPadding + halfHeight;
const maxCenterY = Math.max(minCenterY, canvasHeight - canvasPadding - halfHeight);
const desiredCenterX = pointerCanvasX - state.localPointerOffsetX;
const desiredCenterY = pointerCanvasY - state.localPointerOffsetY;
const centerX = clamp(desiredCenterX, minCenterX, maxCenterX);
const centerY = clamp(desiredCenterY, minCenterY, maxCenterY);
state.currentCenterX = centerX;
state.currentCenterY = centerY;
state.groupItems.forEach((item) => {
const isPrimary = item.docId === state.docKey;
if (isPrimary) {
item.currentCenterX = centerX;
item.currentCenterY = centerY;
item.offsetX = item.baseOffsetX ?? 0;
item.offsetY = item.baseOffsetY ?? 0;
item.displayRotation = state.rotation ?? item.displayRotation ?? 0;
} else {
const decay = 0.82;
const currentOffsetX = (item.offsetX ?? item.baseOffsetX ?? 0) * decay;
const currentOffsetY = (item.offsetY ?? item.baseOffsetY ?? 0) * decay;
item.offsetX = Math.abs(currentOffsetX) < 0.5 ? 0 : currentOffsetX;
item.offsetY = Math.abs(currentOffsetY) < 0.5 ? 0 : currentOffsetY;
const targetX = centerX + item.offsetX;
const targetY = centerY + item.offsetY;
const smoothing = 0.18;
item.currentCenterX += (targetX - item.currentCenterX) * smoothing;
item.currentCenterY += (targetY - item.currentCenterY) * smoothing;
const halfW = item.width / 2;
const halfH = item.height / 2;
const minX = canvasPadding + halfW;
const maxX = Math.max(minX, canvasWidth - canvasPadding - halfW);
const minY = canvasPadding + halfH;
const maxY = Math.max(minY, canvasHeight - canvasPadding - halfH);
item.currentCenterX = clamp(item.currentCenterX, minX, maxX);
item.currentCenterY = clamp(item.currentCenterY, minY, maxY);
const rotationBlend = 0.16;
item.displayRotation += (item.targetRotation - item.displayRotation) * rotationBlend;
}
const entry = layoutRef.current.get(item.docId) || null;
const payload = {
centerX: item.currentCenterX,
centerY: item.currentCenterY,
rotation: item.displayRotation ?? 0,
width: item.width,
height: item.height,
scale: isPrimary ? state.dragScale || 1 : 1,
zIndex: entry?.z,
};
setDragTransform(item.docId, payload);
const node = itemRefs.current.get(item.docId);
applyDomTransform(node, payload);
});
const currentTimestampGroup =
(Number.isFinite(event?.timeStamp))
? event.timeStamp
: performance?.now
? performance.now()
: Date.now();
const previousTimestampGroup = state.lastTimestamp ?? currentTimestampGroup;
let dtGroup = (currentTimestampGroup - previousTimestampGroup) / 1000;
if (!Number.isFinite(dtGroup) || dtGroup <= 0) {
dtGroup = MIN_TIMESTEP;
}
dtGroup = clamp(dtGroup, MIN_TIMESTEP, MAX_TIMESTEP);
state.lastClientX = event.clientX;
state.lastClientY = event.clientY;
state.lastTimestamp = currentTimestampGroup;
updatePointerAngularVelocity(pointerCanvasX, pointerCanvasY, centerX, centerY, dtGroup);
applyDynamicRotation(dtGroup, 0.96);
state.groupItems.forEach((item) => {
if (item.docId === state.docKey) {
item.displayRotation = state.rotation ?? item.displayRotation ?? 0;
}
});
return;
}
if (state.locked) {
return;
}
const deltaX = event.clientX - state.startX;
const deltaY = event.clientY - state.startY;
const docWidth = state.width;
const docHeight = state.height;
const halfWidth = docWidth / 2;
const halfHeight = docHeight / 2;
const containerRect = containerRef.current?.getBoundingClientRect?.();
if (containerRect) {
state.containerRectLeft = containerRect.left;
state.containerRectTop = containerRect.top;
}
const containerLeft = state.containerRectLeft;
const containerTop = state.containerRectTop;
const pointerCanvasX = event.clientX - containerLeft;
const pointerCanvasY = event.clientY - containerTop;
const entry = layoutRef.current.get(state.docKey) || {};
const previousCenterX = Number.isFinite(state.currentCenterX)
? state.currentCenterX
: state.originCenterX;
const previousCenterY = Number.isFinite(state.currentCenterY)
? state.currentCenterY
: state.originCenterY;
const currentTimestamp =
(Number.isFinite(event?.timeStamp))
? event.timeStamp
: performance?.now
? performance.now()
: Date.now();
const previousTimestamp = state.lastTimestamp ?? currentTimestamp;
let dt = (currentTimestamp - previousTimestamp) / 1000;
if (!Number.isFinite(dt) || dt <= 0) {
dt = MIN_TIMESTEP;
}
dt = clamp(dt, MIN_TIMESTEP, MAX_TIMESTEP);
const torqueCenterX = Number.isFinite(previousCenterX) ? previousCenterX : state.originCenterX;
const torqueCenterY = Number.isFinite(previousCenterY) ? previousCenterY : state.originCenterY;
updatePointerAngularVelocity(pointerCanvasX, pointerCanvasY, torqueCenterX, torqueCenterY, dt);
applyDynamicRotation(dt);
state.lastClientX = event.clientX;
state.lastClientY = event.clientY;
state.lastTimestamp = currentTimestamp;
const rotationDeg = state.rotation ?? entry.rotation ?? 0;
const rotationRad = (rotationDeg * Math.PI) / 180;
const cosRot = Math.cos(rotationRad);
const sinRot = Math.sin(rotationRad);
const rotatedOffsetX =
state.localPointerOffsetX * cosRot - state.localPointerOffsetY * sinRot;
const rotatedOffsetY =
state.localPointerOffsetX * sinRot + state.localPointerOffsetY * cosRot;
const absCos = Math.abs(cosRot);
const absSin = Math.abs(sinRot);
const rotatedHalfWidth = absCos * halfWidth + absSin * halfHeight;
const rotatedHalfHeight = absSin * halfWidth + absCos * halfHeight;
const canvasWidth = canvasSize.width || defaultCanvasWidth;
const canvasHeight = canvasSize.height || defaultCanvasHeight;
const minCenterX = canvasPadding + rotatedHalfWidth;
const maxCenterX = Math.max(minCenterX, canvasWidth - canvasPadding - rotatedHalfWidth);
const minCenterY = canvasPadding + rotatedHalfHeight;
const maxCenterY = Math.max(minCenterY, canvasHeight - canvasPadding - rotatedHalfHeight);
const pointerRelativePrevX = pointerCanvasX - previousCenterX;
const pointerRelativePrevY = pointerCanvasY - previousCenterY;
const cosInversePrev = Math.cos(-rotationRad);
const sinInversePrev = Math.sin(-rotationRad);
const pointerLocalPrevX = pointerRelativePrevX * cosInversePrev - pointerRelativePrevY * sinInversePrev;
const pointerLocalPrevY = pointerRelativePrevX * sinInversePrev + pointerRelativePrevY * cosInversePrev;
const pointerInsideRelativeToPrev =
Math.abs(pointerLocalPrevX) <= halfWidth && Math.abs(pointerLocalPrevY) <= halfHeight;
const desiredCenterX = pointerCanvasX - rotatedOffsetX;
const desiredCenterY = pointerCanvasY - rotatedOffsetY;
const clampedCenterX = clamp(desiredCenterX, minCenterX, maxCenterX);
const clampedCenterY = clamp(desiredCenterY, minCenterY, maxCenterY);
if (!state.moved) {
const distanceSquared = deltaX * deltaX + deltaY * deltaY;
if (distanceSquared < DRAG_HYSTERESIS_SQUARED) {
return;
}
bringToFront(state.docKey);
state.moved = true;
}
const collidedWithHorizontalEdge =
Math.abs(desiredCenterY - clampedCenterY) > EDGE_COLLISION_THRESHOLD;
const collidedWithVerticalEdge =
Math.abs(desiredCenterX - clampedCenterX) > EDGE_COLLISION_THRESHOLD;
const collidedWithEdge = collidedWithHorizontalEdge || collidedWithVerticalEdge;
let currentCenterX = clampedCenterX;
let currentCenterY = clampedCenterY;
if (collidedWithEdge && !pointerInsideRelativeToPrev) {
currentCenterX = previousCenterX;
currentCenterY = previousCenterY;
}
state.currentCenterX = currentCenterX;
state.currentCenterY = currentCenterY;
const layoutEntry = layoutRef.current.get(state.docKey) || null;
const transformPayload = {
centerX: currentCenterX,
centerY: currentCenterY,
rotation: rotationDeg,
width: state.width,
height: state.height,
scale: state.dragScale || 1,
zIndex: layoutEntry?.z,
};
setDragTransform(state.docKey, transformPayload);
const primaryNode = itemRefs.current.get(state.docKey);
applyDomTransform(primaryNode, transformPayload);
const offsetX = pointerCanvasX - currentCenterX;
const offsetY = pointerCanvasY - currentCenterY;
const cosInverseCurrent = Math.cos(-rotationRad);
const sinInverseCurrent = Math.sin(-rotationRad);
const pointerLocalX = offsetX * cosInverseCurrent - offsetY * sinInverseCurrent;
const pointerLocalY = offsetX * sinInverseCurrent + offsetY * cosInverseCurrent;
const pointerInsideCard =
Math.abs(pointerLocalX) <= halfWidth && Math.abs(pointerLocalY) <= halfHeight;
const rotationForOffsetDeg = state.rotation || 0;
const rotationForOffsetRad = (rotationForOffsetDeg * Math.PI) / 180;
const cosInverse = Math.cos(-rotationForOffsetRad);
const sinInverse = Math.sin(-rotationForOffsetRad);
const pointerRelativeX = pointerCanvasX - currentCenterX;
const pointerRelativeY = pointerCanvasY - currentCenterY;
const updatedLocalOffsetX = pointerRelativeX * cosInverse - pointerRelativeY * sinInverse;
const updatedLocalOffsetY = pointerRelativeX * sinInverse + pointerRelativeY * cosInverse;
if (!collidedWithEdge || pointerInsideCard) {
state.localPointerOffsetX = updatedLocalOffsetX;
state.localPointerOffsetY = updatedLocalOffsetY;
}
void debugDrag;
},
[
bringToFront,
canvasPadding,
canvasSize.height,
canvasSize.width,
defaultCanvasHeight,
defaultCanvasWidth,
containerRef,
layoutRef,
itemRefs,
debugDrag,
onDocumentStackSelect,
setDragTransform,
],
);
const handlePointerUp = useCallback(
(event: PointerEventLike) => {
const state = dragStateRef.current;
if (!state || state.pointerId !== event.pointerId) {
finishDrag(event.pointerId);
return;
}
if (state.isGroup) {
engine?.finalizeGroupDrag?.(state);
commitActiveDragTransforms(state.activeDocIds);
finishDrag(event.pointerId);
recalcVisibleDocIds();
return;
}
if (state.moved) {
commitActiveDragTransforms([state.docKey]);
const finalRotation = state.rotation ?? state.restRotation;
const inertiaState: EngineInertiaState = {
docId: state.docKey,
restRotation: finalRotation,
dynamicRotation: 0,
angularVelocity: state.angularVelocity,
rotation: finalRotation,
width: state.width,
height: state.height,
dragScale: state.dragScale || 1,
lastTimestamp: state.lastTimestamp,
massGrams: state.massGrams,
};
const docId = state.docKey;
finishDrag(event.pointerId);
engine?.startInertiaAnimation?.(docId, inertiaState);
return;
}
const docId = state.docKey;
const metaPressed = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
if (!metaPressed) {
bringToFront(docId);
}
const originInfo = {
rotation: state.rotation || 0,
scale: state.baseScale || 1,
width: state.width,
height: state.height,
};
const docKey = docId != null ? String(docId) : null;
const doc = docKey ? documentLookup.get(docKey) : null;
tapHandler(event, {
docId,
originInfo,
docTitle: doc?.title || 'document',
});
finishDrag(event.pointerId);
},
[
bringToFront,
commitActiveDragTransforms,
documentLookup,
engine,
finishDrag,
recalcVisibleDocIds,
tapHandler,
],
);
const handlePointerCancel = useCallback(
(event: PointerEventLike) => {
const state = dragStateRef.current;
if (state && state.pointerId === event.pointerId && state.moved) {
if (state.isGroup) {
engine?.finalizeGroupDrag?.(state);
commitActiveDragTransforms(state.activeDocIds);
finishDrag(event.pointerId);
recalcVisibleDocIds();
return;
}
commitActiveDragTransforms([state.docKey]);
const finalRotation = state.rotation ?? state.restRotation;
const inertiaState: EngineInertiaState = {
docId: state.docKey,
restRotation: finalRotation,
dynamicRotation: 0,
angularVelocity: state.angularVelocity,
rotation: finalRotation,
width: state.width,
height: state.height,
dragScale: state.dragScale || 1,
lastTimestamp: state.lastTimestamp,
massGrams: state.massGrams,
};
const docId = state.docKey;
finishDrag(event.pointerId);
engine?.startInertiaAnimation?.(docId, inertiaState);
return;
}
finishDrag(event.pointerId);
},
[commitActiveDragTransforms, engine, finishDrag, recalcVisibleDocIds],
);
return {
handlePointerDown,
handlePointerMove,
handlePointerUp,
handlePointerCancel,
};
};
export default useDocumentDrag;