1098 lines
37 KiB
TypeScript
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;
|