This commit is contained in:
2025-11-06 14:43:43 +01:00
parent 28b9112781
commit 3f49e26d19
19 changed files with 3980 additions and 3366 deletions
+176 -162
View File
@@ -1,17 +1,18 @@
import { useCallback, useEffect, useRef } from 'react';
import { useDesktopContext } from './context';
import { preventAll } from './events';
import { clamp, formatTransform } from './math';
import { preventAll, safeInvoke } from './events';
import { clamp } from './math';
import usePointerTap from '../ui/usePointerTap';
import createDragPhysics, { MIN_TIMESTEP, MAX_TIMESTEP } from './dragPhysics';
import { MIN_TIMESTEP, MAX_TIMESTEP, applyDomTransform } from './workspaceEngine';
const DRAG_HYSTERESIS_PX = 4;
const DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX;
const EDGE_COLLISION_THRESHOLD = 0.5;
const useDocumentDrag = () => {
const useDocumentDrag = (options = {}) => {
const {
engine,
layoutRef,
dragTransformsRef,
itemRefs,
documentLookup,
ensureDocumentSize,
@@ -28,42 +29,22 @@ const useDocumentDrag = () => {
onDocumentStackSelect,
selectedDocumentIds,
markLayoutDirty,
} = useDesktopContext();
} = options;
const markLayoutDirtyRef = useRef(markLayoutDirty);
useEffect(() => {
markLayoutDirtyRef.current = markLayoutDirty;
}, [markLayoutDirty]);
const syncLayoutSnapshotRef = useRef(syncLayoutSnapshot);
useEffect(() => {
syncLayoutSnapshotRef.current = syncLayoutSnapshot;
}, [syncLayoutSnapshot]);
const physicsRef = useRef(null);
if (!physicsRef.current) {
physicsRef.current = createDragPhysics({
layoutRef,
itemRefs,
markLayoutDirtyRef,
syncLayoutSnapshotRef,
});
}
const {
canvasPadding = 24,
defaultCanvasWidth = 1024,
defaultCanvasHeight = 680,
debugDrag = false,
} = settings || {};
useEffect(
() => () => {
physicsRef.current?.dispose?.();
engine?.disposeInertiaAnimations?.();
},
[],
[engine],
);
const {
applyTransform,
finalizeGroupDrag,
cancelInertiaAnimation,
startInertiaAnimation,
} = physicsRef.current;
const tapHandler = usePointerTap({
delay: 220,
onSingle: ({ data, event }) => {
@@ -87,31 +68,77 @@ const useDocumentDrag = () => {
openOverlayForDoc(data.docId, data.originInfo);
},
});
const dragStateRef = useRef(null);
const { canvasPadding, defaultCanvasWidth, defaultCanvasHeight, debugDrag } = settings;
const finishDrag = useCallback(
(pointerId) => {
const state = dragStateRef.current;
if (!state || state.pointerId !== pointerId) {
const setDragTransform = useCallback((docKey, transform) => {
if (!docKey) {
return;
}
const map = dragTransformsRef?.current;
if (!map) {
return;
}
map.set(String(docKey), transform);
}, [dragTransformsRef]);
const clearDragTransforms = useCallback(() => {
const map = dragTransformsRef?.current;
if (!map || typeof map.clear !== 'function') {
return;
}
map.clear();
}, [dragTransformsRef]);
const commitActiveDragTransforms = useCallback((docIds = 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(Boolean)
: Array.from(map.keys());
keys.forEach((key) => {
const transform = map.get(key);
if (!transform) {
return;
}
const capturedTarget = state.capturedTarget;
if (capturedTarget && typeof capturedTarget.releasePointerCapture === 'function') {
try {
capturedTarget.releasePointerCapture(pointerId);
} catch (error) {
if (debugDrag) {
console.warn('[desk] releasePointerCapture failed', error);
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, { shouldSync = false, clearTransforms = true } = {}) => {
const state = dragStateRef.current;
if (state && state.pointerId === pointerId) {
const capturedTarget = state.capturedTarget;
if (capturedTarget && typeof capturedTarget.releasePointerCapture === 'function') {
try {
capturedTarget.releasePointerCapture(pointerId);
} catch (error) {
if (debugDrag) {
console.warn('[desk] releasePointerCapture failed', error);
}
}
}
}
dragStateRef.current = null;
setDraggingId((current) => (current === state.docId ? null : current));
syncLayoutSnapshot();
setDraggingId(null);
engine?.endDrag?.();
if (clearTransforms) {
clearDragTransforms();
}
if (shouldSync) {
syncLayoutSnapshot(true);
}
},
[debugDrag, setDraggingId, syncLayoutSnapshot],
[clearDragTransforms, debugDrag, engine, setDraggingId, syncLayoutSnapshot],
);
const handlePointerDown = useCallback(
@@ -136,7 +163,7 @@ const useDocumentDrag = () => {
return;
}
cancelInertiaAnimation(docId);
engine?.cancelInertiaAnimation?.(docKey);
const doc = documentLookup.get(docKey);
if (!doc) {
@@ -193,7 +220,7 @@ const useDocumentDrag = () => {
if (isGroupDrag) {
groupDocIds.forEach((id) => {
if (id !== docKey) {
cancelInertiaAnimation(id);
engine?.cancelInertiaAnimation?.(id);
}
});
}
@@ -300,11 +327,13 @@ const useDocumentDrag = () => {
const hasStackSource = Array.isArray(stackDocIdsOption) && stackDocIdsOption.length > 1;
dragStateRef.current = {
docId,
docId: docKey,
docKey,
pointerId: event.pointerId,
originCenterX: centerX,
originCenterY: centerY,
currentCenterX: centerX,
currentCenterY: centerY,
startX: event.clientX,
startY: event.clientY,
rotation: entry?.rotation ?? 0,
@@ -334,7 +363,26 @@ const useDocumentDrag = () => {
stackReplace,
};
setDraggingId(docId);
const state = dragStateRef.current;
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.groupDocIds);
setDraggingId(docKey);
if (isGroupDrag) {
groupItems.forEach((item) => {
@@ -344,22 +392,23 @@ const useDocumentDrag = () => {
const node = itemRefs.current.get(item.docId);
if (node) {
item.displayRotation = item.initialRotation;
node.style.transform = formatTransform(
item.currentCenterX - item.width / 2,
item.currentCenterY - item.height / 2,
item.displayRotation,
1,
);
applyDomTransform(node, {
centerX: item.currentCenterX,
centerY: item.currentCenterY,
width: item.width,
height: item.height,
rotation: item.displayRotation ?? 0,
scale: 1,
});
}
});
}
},
[
}, [
bringToFront,
canvasPadding,
cancelInertiaAnimation,
containerRef,
documentLookup,
canvasPadding,
containerRef,
documentLookup,
engine,
ensureDocumentSize,
layoutRef,
resolveBaseMetrics,
@@ -367,8 +416,9 @@ const useDocumentDrag = () => {
setDraggingId,
debugDrag,
itemRefs,
],
);
clearDragTransforms,
setDragTransform,
]);
const handlePointerMove = useCallback(
(event) => {
@@ -411,14 +461,11 @@ const useDocumentDrag = () => {
}
state.moved = true;
if (
state.isGroup
&& !state.stackSelectionApplied
!state.stackSelectionApplied
&& Array.isArray(state.stackDocIds)
&& state.stackDocIds.length > 0
) {
if (typeof onDocumentStackSelect === 'function') {
onDocumentStackSelect(state.stackDocIds, event, { replace: state.stackReplace });
}
safeInvoke(onDocumentStackSelect, state.stackDocIds, event, { replace: state.stackReplace });
state.stackSelectionApplied = true;
}
if (!state.groupElevated) {
@@ -431,11 +478,8 @@ const useDocumentDrag = () => {
return aZ - bZ;
});
sortedGroup.forEach((id) => {
bringToFront(id);
});
bringToFront(state.docId);
sortedGroup.forEach((id) => bringToFront(id));
bringToFront(state.docKey);
state.groupElevated = true;
}
}
@@ -456,22 +500,8 @@ const useDocumentDrag = () => {
const centerX = clamp(desiredCenterX, minCenterX, maxCenterX);
const centerY = clamp(desiredCenterY, minCenterY, maxCenterY);
const primaryEntry = layoutRef.current.get(state.docKey) || {};
const primaryRotation = state.rotation ?? primaryEntry.rotation ?? 0;
layoutRef.current.set(state.docKey, {
...primaryEntry,
centerX,
centerY,
});
const primaryNode = itemRefs.current.get(state.docId);
if (primaryNode) {
primaryNode.style.transform = formatTransform(
centerX - docWidth / 2,
centerY - docHeight / 2,
primaryRotation,
state.dragScale || 1,
);
}
state.currentCenterX = centerX;
state.currentCenterY = centerY;
state.groupItems.forEach((item) => {
const isPrimary = item.docId === state.docKey;
@@ -508,25 +538,18 @@ const useDocumentDrag = () => {
item.displayRotation += (item.targetRotation - item.displayRotation) * rotationBlend;
}
markLayoutDirty?.();
const entryItem = layoutRef.current.get(item.docId) || {};
layoutRef.current.set(item.docId, {
...entryItem,
const payload = {
centerX: item.currentCenterX,
centerY: item.currentCenterY,
rotation: item.displayRotation ?? entryItem.rotation ?? 0,
});
rotation: item.displayRotation ?? 0,
width: item.width,
height: item.height,
scale: isPrimary ? state.dragScale || 1 : 1,
};
applyTransform(
item.docId,
item.currentCenterX,
item.currentCenterY,
item.width,
item.height,
item.displayRotation ?? entryItem.rotation ?? 0,
isPrimary ? state.dragScale || 1 : 1,
);
setDragTransform(item.docId, payload);
const node = itemRefs.current.get(item.docId);
applyDomTransform(node, payload);
});
state.lastClientX = event.clientX;
@@ -538,21 +561,15 @@ const useDocumentDrag = () => {
? performance.now()
: Date.now();
recalcVisibleDocIds();
return;
}
if (state.locked) {
if (debugDrag) {
console.log('[desk] handlePointerMove: locked drag for doc', state.docId);
console.log('[desk] handlePointerMove: locked drag for doc', state.docKey);
}
return;
}
const entry = layoutRef.current.get(state.docId);
if (!entry) {
return;
}
const deltaX = event.clientX - state.startX;
const deltaY = event.clientY - state.startY;
@@ -572,7 +589,8 @@ const useDocumentDrag = () => {
const pointerCanvasX = event.clientX - containerLeft;
const pointerCanvasY = event.clientY - containerTop;
const rotationDeg = entry?.rotation ?? 0;
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);
@@ -581,8 +599,12 @@ const useDocumentDrag = () => {
const rotatedOffsetY =
state.localPointerOffsetX * sinRot + state.localPointerOffsetY * cosRot;
const previousCenterX = Number.isFinite(entry.centerX) ? entry.centerX : state.originCenterX;
const previousCenterY = Number.isFinite(entry.centerY) ? entry.centerY : state.originCenterY;
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);
@@ -614,7 +636,7 @@ const useDocumentDrag = () => {
if (distanceSquared < DRAG_HYSTERESIS_SQUARED) {
return;
}
bringToFront(state.docId);
bringToFront(state.docKey);
state.moved = true;
}
@@ -631,28 +653,21 @@ const useDocumentDrag = () => {
currentCenterY = previousCenterY;
}
const updated = { ...entry, centerX: currentCenterX, centerY: currentCenterY };
layoutRef.current.set(state.docId, updated);
state.currentCenterX = currentCenterX;
state.currentCenterY = currentCenterY;
applyTransform(
state.docId,
currentCenterX,
currentCenterY,
state.width,
state.height,
rotationDeg,
state.dragScale || 1,
);
const transformPayload = {
centerX: currentCenterX,
centerY: currentCenterY,
rotation: rotationDeg,
width: state.width,
height: state.height,
scale: state.dragScale || 1,
};
const primaryNode = itemRefs.current.get(state.docId);
if (primaryNode) {
primaryNode.style.transform = formatTransform(
currentCenterX - state.width / 2,
currentCenterY - state.height / 2,
rotationDeg,
state.dragScale || 1,
);
}
setDragTransform(state.docKey, transformPayload);
const primaryNode = itemRefs.current.get(state.docKey);
applyDomTransform(primaryNode, transformPayload);
const offsetX = pointerCanvasX - currentCenterX;
const offsetY = pointerCanvasY - currentCenterY;
@@ -663,8 +678,6 @@ const useDocumentDrag = () => {
const pointerInsideCard =
Math.abs(pointerLocalX) <= halfWidth && Math.abs(pointerLocalY) <= halfHeight;
markLayoutDirty?.();
const currentTimestamp =
typeof event.timeStamp === 'number' && Number.isFinite(event.timeStamp)
? event.timeStamp
@@ -696,9 +709,8 @@ const useDocumentDrag = () => {
}
if (debugDrag) {
console.log('[desk] handlePointerMove: moved doc', state.docId, 'to', currentCenterX, currentCenterY);
console.log('[desk] handlePointerMove: moved doc', state.docKey, 'to', currentCenterX, currentCenterY);
}
recalcVisibleDocIds();
},
[
bringToFront,
@@ -710,11 +722,9 @@ const useDocumentDrag = () => {
containerRef,
layoutRef,
itemRefs,
applyTransform,
recalcVisibleDocIds,
debugDrag,
onDocumentStackSelect,
markLayoutDirty,
setDragTransform,
],
);
@@ -727,13 +737,15 @@ const useDocumentDrag = () => {
}
if (state.isGroup) {
finalizeGroupDrag(state);
finishDrag(event.pointerId);
engine?.finalizeGroupDrag?.(state);
commitActiveDragTransforms(state.groupDocIds);
finishDrag(event.pointerId, { shouldSync: true });
recalcVisibleDocIds();
return;
}
if (state.moved) {
commitActiveDragTransforms([state.docKey]);
const inertiaState = {
restRotation: state.restRotation,
dynamicRotation: state.dynamicRotation,
@@ -743,13 +755,13 @@ const useDocumentDrag = () => {
height: state.height,
dragScale: state.dragScale || 1,
};
const docId = state.docId;
finishDrag(event.pointerId);
startInertiaAnimation(docId, inertiaState);
const docId = state.docKey;
finishDrag(event.pointerId, { shouldSync: true });
engine?.startInertiaAnimation?.(docId, inertiaState);
return;
}
const docId = state.docId;
const docId = state.docKey;
const metaPressed = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
if (!metaPressed) {
bringToFront(docId);
@@ -771,10 +783,10 @@ const useDocumentDrag = () => {
},
[
bringToFront,
commitActiveDragTransforms,
documentLookup,
engine,
finishDrag,
finalizeGroupDrag,
startInertiaAnimation,
recalcVisibleDocIds,
tapHandler,
],
@@ -785,12 +797,14 @@ const useDocumentDrag = () => {
const state = dragStateRef.current;
if (state && state.pointerId === event.pointerId && state.moved) {
if (state.isGroup) {
finalizeGroupDrag(state);
finishDrag(event.pointerId);
engine?.finalizeGroupDrag?.(state);
commitActiveDragTransforms(state.groupDocIds);
finishDrag(event.pointerId, { shouldSync: true });
recalcVisibleDocIds();
return;
}
commitActiveDragTransforms([state.docKey]);
const inertiaState = {
restRotation: state.restRotation,
dynamicRotation: state.dynamicRotation,
@@ -800,14 +814,14 @@ const useDocumentDrag = () => {
height: state.height,
dragScale: state.dragScale || 1,
};
const docId = state.docId;
finishDrag(event.pointerId);
startInertiaAnimation(docId, inertiaState);
const docId = state.docKey;
finishDrag(event.pointerId, { shouldSync: true });
engine?.startInertiaAnimation?.(docId, inertiaState);
return;
}
finishDrag(event.pointerId);
},
[finalizeGroupDrag, finishDrag, recalcVisibleDocIds, startInertiaAnimation],
[commitActiveDragTransforms, engine, finishDrag, recalcVisibleDocIds],
);
return {