This commit is contained in:
2025-11-02 04:40:07 +01:00
parent f7b274c1ec
commit e7e7881772
13 changed files with 560 additions and 243 deletions
+379 -21
View File
@@ -5,6 +5,21 @@ import { clamp, formatTransform } from './math';
const DRAG_HYSTERESIS_PX = 4;
const DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX;
const MIN_TIMESTEP = 1 / 120;
const MAX_TIMESTEP = 1 / 20;
const MAX_DYNAMIC_ROTATION = 4;
const MAX_ANGULAR_VELOCITY = 180;
const ANGULAR_DAMPING = 11;
const TORQUE_TO_ACCELERATION = 0.006;
const SETTLE_ANGULAR_VELOCITY = 1.2;
const EDGE_COLLISION_THRESHOLD = 0.5;
const EDGE_ALIGNMENT_STIFFNESS = 30;
const BASE_TORQUE_FACTOR = 0.4;
const EDGE_COLLISION_TORQUE_FACTOR = 0.08;
const EDGE_ALIGNMENT_TORQUE_MULTIPLIER = 35;
const EDGE_COLLISION_EXTRA_DAMPING = 4;
const EDGE_REST_REALIGN_RATE = 10;
const EDGE_ALIGNMENT_EPSILON = 0.15;
const useDocumentDrag = () => {
const {
@@ -20,11 +35,121 @@ const useDocumentDrag = () => {
openOverlayForDoc,
recalcVisibleDocIds,
settings,
containerRef,
} = useDesktopContext();
const dragStateRef = useRef(null);
const inertiaAnimationsRef = useRef(new Map());
const { canvasPadding, defaultCanvasWidth, defaultCanvasHeight, debugDrag } = settings;
const cancelInertiaAnimation = useCallback((docId) => {
if (typeof window === 'undefined') {
inertiaAnimationsRef.current.delete(docId);
return;
}
const existing = inertiaAnimationsRef.current.get(docId);
if (existing && typeof window.cancelAnimationFrame === 'function') {
window.cancelAnimationFrame(existing.frameId);
}
inertiaAnimationsRef.current.delete(docId);
}, []);
const integrateRotation = useCallback(
(simulationState, dt, torque = 0, dampingOverride = null) => {
const { docId } = simulationState;
const entry = layoutRef.current.get(docId);
if (!entry) {
return true;
}
const centerX = Number(entry.centerX);
const centerY = Number(entry.centerY);
if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) {
return true;
}
const torqueAcceleration = torque * TORQUE_TO_ACCELERATION;
let angularVelocity = simulationState.angularVelocity + torqueAcceleration * dt;
angularVelocity = clamp(angularVelocity, -MAX_ANGULAR_VELOCITY, MAX_ANGULAR_VELOCITY);
const dampingConstant = typeof dampingOverride === 'number' ? dampingOverride : ANGULAR_DAMPING;
const dampingFactor = Math.exp(-dampingConstant * dt);
angularVelocity *= dampingFactor;
let dynamicRotation = simulationState.dynamicRotation + angularVelocity * dt;
if (dynamicRotation > MAX_DYNAMIC_ROTATION) {
dynamicRotation = MAX_DYNAMIC_ROTATION;
angularVelocity = Math.min(angularVelocity, 0);
} else if (dynamicRotation < -MAX_DYNAMIC_ROTATION) {
dynamicRotation = -MAX_DYNAMIC_ROTATION;
angularVelocity = Math.max(angularVelocity, 0);
}
simulationState.angularVelocity = angularVelocity;
simulationState.dynamicRotation = dynamicRotation;
simulationState.rotation = simulationState.restRotation + dynamicRotation;
const rotation = simulationState.rotation;
layoutRef.current.set(docId, { ...entry, rotation });
const node = itemRefs.current.get(docId);
if (node) {
node.style.transform = formatTransform(
centerX - simulationState.width / 2,
centerY - simulationState.height / 2,
rotation,
simulationState.dragScale || 1,
);
}
const isSettled = Math.abs(angularVelocity) < SETTLE_ANGULAR_VELOCITY;
return isSettled;
},
[itemRefs, layoutRef],
);
const startInertiaAnimation = useCallback(
(docId, baseState) => {
if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') {
return;
}
cancelInertiaAnimation(docId);
const now =
typeof performance !== 'undefined' && typeof performance.now === 'function'
? performance.now()
: Date.now();
const simulationState = {
...baseState,
docId,
dragScale: baseState.dragScale || 1,
lastTimestamp: now,
};
const step = (timestamp) => {
const safeTimestamp = Number.isFinite(timestamp) ? timestamp : now + 16;
const previous = simulationState.lastTimestamp;
let dt = (safeTimestamp - previous) / 1000;
if (!Number.isFinite(dt) || dt <= 0) {
dt = MIN_TIMESTEP;
}
dt = clamp(dt, MIN_TIMESTEP, MAX_TIMESTEP);
simulationState.lastTimestamp = safeTimestamp;
const settled = integrateRotation(simulationState, dt, 0);
if (settled) {
inertiaAnimationsRef.current.delete(docId);
syncLayoutSnapshot();
return;
}
simulationState.frameId = window.requestAnimationFrame(step);
};
simulationState.frameId = window.requestAnimationFrame(step);
inertiaAnimationsRef.current.set(docId, simulationState);
},
[cancelInertiaAnimation, integrateRotation, syncLayoutSnapshot],
);
const finishDrag = useCallback(
(pointerId) => {
const state = dragStateRef.current;
@@ -63,6 +188,7 @@ const useDocumentDrag = () => {
);
}
preventAll(event);
cancelInertiaAnimation(docId);
const docKey = docId != null ? String(docId) : null;
const doc = docKey ? documentLookup.get(docKey) : null;
const { width: docWidth, height: docHeight } = ensureDocumentSize(doc);
@@ -90,6 +216,25 @@ const useDocumentDrag = () => {
}
}
}
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 eventTimestamp =
typeof event.timeStamp === 'number' && Number.isFinite(event.timeStamp)
? event.timeStamp
: typeof performance !== 'undefined' && typeof performance.now === 'function'
? performance.now()
: Date.now();
dragStateRef.current = {
docId,
pointerId: event.pointerId,
@@ -98,6 +243,9 @@ const useDocumentDrag = () => {
startX: event.clientX,
startY: event.clientY,
rotation: entry?.rotation ?? 0,
restRotation: entry?.rotation ?? 0,
dynamicRotation: 0,
angularVelocity: 0,
moved: false,
locked: false,
width: docWidth,
@@ -105,18 +253,27 @@ const useDocumentDrag = () => {
dragScale: 1,
baseScale: normalizedBaseScale,
capturedTarget,
lastClientX: event.clientX,
lastClientY: event.clientY,
lastTimestamp: eventTimestamp,
localPointerOffsetX,
localPointerOffsetY,
containerRectLeft: containerLeft,
containerRectTop: containerTop,
};
setDraggingId(docId);
},
[
bringToFront,
canvasPadding,
cancelInertiaAnimation,
documentLookup,
ensureDocumentSize,
layoutRef,
resolveBaseMetrics,
setDraggingId,
debugDrag,
containerRef,
],
);
@@ -155,21 +312,47 @@ const useDocumentDrag = () => {
const deltaX = event.clientX - state.startX;
const deltaY = event.clientY - state.startY;
const nextCenterX = state.originCenterX + deltaX;
const nextCenterY = state.originCenterY + deltaY;
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 rotationDeg = state.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 desiredCenterX = pointerCanvasX - rotatedOffsetX;
const desiredCenterY = pointerCanvasY - rotatedOffsetY;
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 + halfWidth;
const maxCenterX = Math.max(minCenterX, canvasWidth - canvasPadding - halfWidth);
const minCenterY = canvasPadding + halfHeight;
const maxCenterY = Math.max(minCenterY, canvasHeight - canvasPadding - halfHeight);
const clampedCenterX = clamp(nextCenterX, minCenterX, maxCenterX);
const clampedCenterY = clamp(nextCenterY, minCenterY, maxCenterY);
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 clampedCenterX = clamp(desiredCenterX, minCenterX, maxCenterX);
const clampedCenterY = clamp(desiredCenterY, minCenterY, maxCenterY);
if (!state.moved) {
const distanceSquared = deltaX * deltaX + deltaY * deltaY;
@@ -180,20 +363,167 @@ const useDocumentDrag = () => {
state.moved = true;
}
const updated = { ...entry, centerX: clampedCenterX, centerY: clampedCenterY };
let currentCenterX = clampedCenterX;
let currentCenterY = clampedCenterY;
const updated = { ...entry, centerX: currentCenterX, centerY: currentCenterY };
layoutRef.current.set(state.docId, updated);
const node = itemRefs.current.get(state.docId);
if (node) {
node.style.transform = formatTransform(
clampedCenterX - state.width / 2,
clampedCenterY - state.height / 2,
state.rotation,
state.dragScale || 1,
);
const collidedWithHorizontalEdge =
Math.abs(desiredCenterY - clampedCenterY) > EDGE_COLLISION_THRESHOLD;
const collidedWithVerticalEdge =
Math.abs(desiredCenterX - clampedCenterX) > EDGE_COLLISION_THRESHOLD;
const collidedWithEdge = collidedWithHorizontalEdge || collidedWithVerticalEdge;
const offsetX = pointerCanvasX - currentCenterX;
const offsetY = pointerCanvasY - currentCenterY;
const currentTimestamp =
typeof event.timeStamp === 'number' && Number.isFinite(event.timeStamp)
? event.timeStamp
: typeof performance !== 'undefined' && typeof performance.now === 'function'
? 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 previousClientX = state.lastClientX;
const previousClientY = state.lastClientY;
const velocityX = (event.clientX - previousClientX) / dt;
const velocityY = (event.clientY - previousClientY) / dt;
const rawTorque = offsetX * velocityY - offsetY * velocityX;
const rotationBeforeIntegration = state.rotation;
let torque = rawTorque * BASE_TORQUE_FACTOR;
let dampingOverride = null;
if (collidedWithEdge) {
const currentRotation =
typeof state.rotation === 'number'
? state.rotation
: state.restRotation + state.dynamicRotation;
torque =
(rawTorque * EDGE_COLLISION_TORQUE_FACTOR + currentRotation * EDGE_ALIGNMENT_STIFFNESS) *
EDGE_ALIGNMENT_TORQUE_MULTIPLIER;
const restBlend = 1 - Math.exp(-EDGE_REST_REALIGN_RATE * dt);
if (restBlend > 0) {
const previousRest = state.restRotation;
const nextRest = previousRest + (0 - previousRest) * restBlend;
state.restRotation = nextRest;
}
dampingOverride = ANGULAR_DAMPING + EDGE_COLLISION_EXTRA_DAMPING;
}
state.lastClientX = event.clientX;
state.lastClientY = event.clientY;
state.lastTimestamp = currentTimestamp;
integrateRotation(state, dt, torque, dampingOverride);
if (!collidedWithEdge) {
const rotationAfter = state.rotation || 0;
if (rotationAfter !== rotationBeforeIntegration) {
const rotationAfterRad = (rotationAfter * Math.PI) / 180;
const cosAfter = Math.cos(rotationAfterRad);
const sinAfter = Math.sin(rotationAfterRad);
const rotatedOffsetXAfter =
state.localPointerOffsetX * cosAfter - state.localPointerOffsetY * sinAfter;
const rotatedOffsetYAfter =
state.localPointerOffsetX * sinAfter + state.localPointerOffsetY * cosAfter;
const desiredCenterXAfter = pointerCanvasX - rotatedOffsetXAfter;
const desiredCenterYAfter = pointerCanvasY - rotatedOffsetYAfter;
const absCosAfter = Math.abs(cosAfter);
const absSinAfter = Math.abs(sinAfter);
const rotatedHalfWidthAfter = absCosAfter * halfWidth + absSinAfter * halfHeight;
const rotatedHalfHeightAfter = absSinAfter * halfWidth + absCosAfter * halfHeight;
const minCenterXAfter = canvasPadding + rotatedHalfWidthAfter;
const maxCenterXAfter = Math.max(
minCenterXAfter,
canvasWidth - canvasPadding - rotatedHalfWidthAfter,
);
const minCenterYAfter = canvasPadding + rotatedHalfHeightAfter;
const maxCenterYAfter = Math.max(
minCenterYAfter,
canvasHeight - canvasPadding - rotatedHalfHeightAfter,
);
const correctedCenterX = clamp(
desiredCenterXAfter,
minCenterXAfter,
maxCenterXAfter,
);
const correctedCenterY = clamp(
desiredCenterYAfter,
minCenterYAfter,
maxCenterYAfter,
);
if (
Math.abs(correctedCenterX - currentCenterX) > 0.01 ||
Math.abs(correctedCenterY - currentCenterY) > 0.01
) {
const entryAfter = layoutRef.current.get(state.docId);
if (entryAfter) {
const adjustedEntry = {
...entryAfter,
centerX: correctedCenterX,
centerY: correctedCenterY,
};
layoutRef.current.set(state.docId, adjustedEntry);
const nodeAfter = itemRefs.current.get(state.docId);
if (nodeAfter) {
nodeAfter.style.transform = formatTransform(
correctedCenterX - state.width / 2,
correctedCenterY - state.height / 2,
state.rotation,
state.dragScale || 1,
);
}
currentCenterX = correctedCenterX;
currentCenterY = correctedCenterY;
}
}
}
}
if (collidedWithEdge) {
const rotationAfter = state.rotation;
const crossedAlignment =
rotationBeforeIntegration > EDGE_ALIGNMENT_EPSILON && rotationAfter < 0
? rotationBeforeIntegration - rotationAfter > EDGE_ALIGNMENT_EPSILON
: rotationBeforeIntegration < -EDGE_ALIGNMENT_EPSILON && rotationAfter > 0
? rotationAfter - rotationBeforeIntegration > EDGE_ALIGNMENT_EPSILON
: false;
const nearAlignment = Math.abs(rotationAfter) <= EDGE_ALIGNMENT_EPSILON;
if (crossedAlignment || nearAlignment) {
state.restRotation = 0;
state.dynamicRotation = 0;
state.angularVelocity = 0;
state.rotation = 0;
const updatedEntry = layoutRef.current.get(state.docId);
if (updatedEntry) {
const alignedEntry = { ...updatedEntry, rotation: 0 };
layoutRef.current.set(state.docId, alignedEntry);
const node = itemRefs.current.get(state.docId);
if (node) {
node.style.transform = formatTransform(
alignedEntry.centerX - state.width / 2,
alignedEntry.centerY - state.height / 2,
0,
state.dragScale || 1,
);
}
}
}
}
if (debugDrag) {
console.log('[desk] handlePointerMove: moved doc', state.docId, 'to', clampedCenterX, clampedCenterY);
console.log('[desk] handlePointerMove: moved doc', state.docId, 'to', currentCenterX, currentCenterY);
}
recalcVisibleDocIds();
},
@@ -204,7 +534,8 @@ const useDocumentDrag = () => {
canvasSize.width,
defaultCanvasHeight,
defaultCanvasWidth,
itemRefs,
containerRef,
integrateRotation,
layoutRef,
recalcVisibleDocIds,
debugDrag,
@@ -216,7 +547,18 @@ const useDocumentDrag = () => {
const state = dragStateRef.current;
if (state && state.pointerId === event.pointerId) {
if (state.moved) {
const inertiaState = {
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.docId;
finishDrag(event.pointerId);
startInertiaAnimation(docId, inertiaState);
return;
}
@@ -234,14 +576,30 @@ const useDocumentDrag = () => {
}
finishDrag(event.pointerId);
},
[bringToFront, finishDrag, openOverlayForDoc],
[bringToFront, finishDrag, openOverlayForDoc, startInertiaAnimation],
);
const handlePointerCancel = useCallback(
(event) => {
const state = dragStateRef.current;
if (state && state.pointerId === event.pointerId && state.moved) {
const inertiaState = {
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.docId;
finishDrag(event.pointerId);
startInertiaAnimation(docId, inertiaState);
return;
}
finishDrag(event.pointerId);
},
[finishDrag],
[finishDrag, startInertiaAnimation],
);
return {