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[0]; type EngineGroupItem = NonNullable[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; stackSelectionApplied?: boolean; wasSelected?: boolean; modifierActive?: boolean; stackReplace?: boolean; } interface UseDocumentDragOptions { engine?: WorkspaceEngine | null; layoutRef: MutableRefObject>; dragTransformsRef: MutableRefObject>; itemRefs: MutableRefObject>; documentLookup: Map; 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; onDocumentActivate?: (docId: Identifier | null, event?: PointerEvent | ReactPointerEvent) => void; onDocumentStackSelect?: ( docIds: Identifier[], event: PointerEvent | ReactPointerEvent, options?: { replace?: boolean }, ) => void; selectedDocumentIds?: Array; markLayoutDirty?: () => void; } type EngineInertiaState = Parameters[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; 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(null); const containerRef = providedContainerRef ?? fallbackContainerRef; const { canvasPadding = 24, defaultCanvasWidth = 1024, defaultCanvasHeight = 680, debugDrag = false, } = settings || {}; useEffect( () => () => { engine?.disposeInertiaAnimations?.(); }, [engine], ); const tapHandler = usePointerTap({ 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(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 | 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;