typescript
This commit is contained in:
@@ -0,0 +1,985 @@
|
||||
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 './math';
|
||||
import usePointerTap from '../ui/usePointerTap';
|
||||
import {
|
||||
MIN_TIMESTEP,
|
||||
MAX_TIMESTEP,
|
||||
applyDomTransform,
|
||||
type WorkspaceEngine,
|
||||
} from './workspaceEngine';
|
||||
|
||||
type Identifier = string | number;
|
||||
|
||||
interface DocumentLike {
|
||||
id?: Identifier | null;
|
||||
title?: string;
|
||||
[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;
|
||||
}
|
||||
|
||||
type EnsureDocumentSizeFn = (doc: DocumentLike | null | undefined) => DocumentSizeInfo | null;
|
||||
|
||||
type ResolveBaseMetricsFn = (
|
||||
doc: DocumentLike | null | undefined,
|
||||
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 | undefined>;
|
||||
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 | undefined) => void;
|
||||
setDraggingId: (docKey: string | null) => void;
|
||||
canvasSize: { width: number; height: number };
|
||||
openOverlayForDoc?: (
|
||||
docId: Identifier | null | undefined,
|
||||
originInfo?: { rotation: number; scale: number; width: number; height: number },
|
||||
) => void;
|
||||
recalcVisibleDocIds: () => void;
|
||||
settings?: DragSettings;
|
||||
containerRef?: RefObject<HTMLElement>;
|
||||
onInspectDocument?: (docId: Identifier | null | undefined, event?: PointerEvent | ReactPointerEvent) => void;
|
||||
onDocumentStackSelect?: (
|
||||
docIds: Identifier[],
|
||||
event: PointerEvent | ReactPointerEvent,
|
||||
options?: { replace?: boolean },
|
||||
) => void;
|
||||
selectedDocumentIds?: Array<Identifier | null | undefined>;
|
||||
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;
|
||||
}
|
||||
|
||||
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 getEventTargetElement = (event?: PointerEventLike | null): Element | null => {
|
||||
if (!event) {
|
||||
return null;
|
||||
}
|
||||
const ElementCtor = typeof window !== 'undefined' ? window.Element : null;
|
||||
if (!ElementCtor) {
|
||||
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 ElementCtor ? candidate : null;
|
||||
};
|
||||
|
||||
const useDocumentDrag = (options: UseDocumentDragOptions) => {
|
||||
const {
|
||||
engine,
|
||||
layoutRef,
|
||||
dragTransformsRef,
|
||||
itemRefs,
|
||||
documentLookup,
|
||||
ensureDocumentSize,
|
||||
resolveBaseMetrics,
|
||||
bringToFront,
|
||||
setDraggingId,
|
||||
canvasSize,
|
||||
openOverlayForDoc,
|
||||
recalcVisibleDocIds,
|
||||
settings,
|
||||
containerRef: providedContainerRef,
|
||||
onInspectDocument,
|
||||
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;
|
||||
}
|
||||
onInspectDocument?.(data.docId, event);
|
||||
},
|
||||
});
|
||||
const dragStateRef = useRef<DragStateInternal | null>(null);
|
||||
|
||||
const setDragTransform = useCallback((docKey: Identifier | null | undefined, 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 | undefined> | 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 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;
|
||||
const targetRotation = initialRotation;
|
||||
return {
|
||||
docId: id,
|
||||
width: itemWidth,
|
||||
height: itemHeight,
|
||||
currentCenterX: itemCenterX,
|
||||
currentCenterY: itemCenterY,
|
||||
baseOffsetX,
|
||||
baseOffsetY,
|
||||
offsetX: baseOffsetX,
|
||||
offsetY: baseOffsetY,
|
||||
initialRotation,
|
||||
displayRotation: initialRotation,
|
||||
targetRotation,
|
||||
} 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,
|
||||
} 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);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
state.lastClientX = event.clientX;
|
||||
state.lastClientY = event.clientY;
|
||||
state.lastTimestamp =
|
||||
(Number.isFinite(event?.timeStamp))
|
||||
? event.timeStamp
|
||||
: performance?.now
|
||||
? performance.now()
|
||||
: Date.now();
|
||||
|
||||
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 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 previousCenterX = Number.isFinite(state.currentCenterX)
|
||||
? state.currentCenterX
|
||||
: state.originCenterX;
|
||||
const previousCenterY = Number.isFinite(state.currentCenterY)
|
||||
? state.currentCenterY
|
||||
: state.originCenterY;
|
||||
|
||||
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 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);
|
||||
|
||||
state.lastClientX = event.clientX;
|
||||
state.lastClientY = event.clientY;
|
||||
state.lastTimestamp = currentTimestamp;
|
||||
|
||||
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 inertiaState: EngineInertiaState = {
|
||||
restRotation: state.restRotation,
|
||||
dynamicRotation: state.dynamicRotation,
|
||||
angularVelocity: state.angularVelocity,
|
||||
rotation: state.rotation,
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
dragScale: state.dragScale || 1,
|
||||
};
|
||||
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 inertiaState: EngineInertiaState = {
|
||||
restRotation: state.restRotation,
|
||||
dynamicRotation: state.dynamicRotation,
|
||||
angularVelocity: state.angularVelocity,
|
||||
rotation: state.rotation,
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
dragScale: state.dragScale || 1,
|
||||
};
|
||||
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;
|
||||
Reference in New Issue
Block a user