918 lines
31 KiB
JavaScript
918 lines
31 KiB
JavaScript
import { useCallback, useRef } from 'react';
|
|
import { useDesktopContext } from './context';
|
|
import { preventAll } from './events';
|
|
import { clamp, formatTransform } from './math';
|
|
import usePointerTap from '../ui/usePointerTap';
|
|
|
|
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 useDocumentDrag = () => {
|
|
const {
|
|
layoutRef,
|
|
itemRefs,
|
|
documentLookup,
|
|
ensureDocumentSize,
|
|
resolveBaseMetrics,
|
|
bringToFront,
|
|
setDraggingId,
|
|
syncLayoutSnapshot,
|
|
canvasSize,
|
|
openOverlayForDoc,
|
|
recalcVisibleDocIds,
|
|
settings,
|
|
containerRef,
|
|
onDocumentOpen,
|
|
onInspectDocument,
|
|
selectedDocumentIds,
|
|
markLayoutDirty,
|
|
} = useDesktopContext();
|
|
|
|
const applyTransform = useCallback(
|
|
(docId, centerX, centerY, width, height, rotation, scale = 1) => {
|
|
const node = itemRefs.current.get(docId);
|
|
if (!node) {
|
|
return;
|
|
}
|
|
node.style.transform = formatTransform(
|
|
centerX - width / 2,
|
|
centerY - height / 2,
|
|
rotation,
|
|
scale,
|
|
);
|
|
},
|
|
[itemRefs],
|
|
);
|
|
|
|
const finalizeGroupDrag = useCallback(
|
|
(dragState) => {
|
|
if (!dragState?.groupItems) {
|
|
return;
|
|
}
|
|
|
|
dragState.groupItems.forEach((item) => {
|
|
if (!item) {
|
|
return;
|
|
}
|
|
|
|
const entryItem = layoutRef.current.get(item.docId) || {};
|
|
const centerX = item.currentCenterX ?? entryItem.centerX ?? dragState.originCenterX;
|
|
const centerY = item.currentCenterY ?? entryItem.centerY ?? dragState.originCenterY;
|
|
const rotation = item.displayRotation ?? entryItem.rotation ?? 0;
|
|
|
|
layoutRef.current.set(item.docId, {
|
|
...entryItem,
|
|
centerX,
|
|
centerY,
|
|
rotation,
|
|
});
|
|
|
|
applyTransform(
|
|
item.docId,
|
|
centerX,
|
|
centerY,
|
|
item.width,
|
|
item.height,
|
|
rotation,
|
|
item.docId === dragState.docKey ? dragState.dragScale || 1 : 1,
|
|
);
|
|
});
|
|
|
|
markLayoutDirty?.();
|
|
},
|
|
[applyTransform, layoutRef, markLayoutDirty],
|
|
);
|
|
|
|
const tapHandler = usePointerTap({
|
|
delay: 220,
|
|
onSingle: ({ data, event }) => {
|
|
if (!data || !data.docId) {
|
|
return;
|
|
}
|
|
if (event && (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)) {
|
|
return;
|
|
}
|
|
if (typeof onInspectDocument === 'function') {
|
|
onInspectDocument(data.docId);
|
|
return;
|
|
}
|
|
onDocumentOpen?.(data.docId);
|
|
},
|
|
onDouble: ({ data, event }) => {
|
|
if (!data || !data.docId) {
|
|
return;
|
|
}
|
|
if (event && (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)) {
|
|
return;
|
|
}
|
|
openOverlayForDoc(data.docId, data.originInfo);
|
|
},
|
|
});
|
|
|
|
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;
|
|
if (!state || state.pointerId !== pointerId) {
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
dragStateRef.current = null;
|
|
setDraggingId((current) => (current === state.docId ? null : current));
|
|
syncLayoutSnapshot();
|
|
},
|
|
[debugDrag, setDraggingId, syncLayoutSnapshot],
|
|
);
|
|
|
|
const handlePointerDown = useCallback(
|
|
(event, docIdInput, options = {}) => {
|
|
if (debugDrag) {
|
|
console.log(
|
|
'[desk] handlePointerDown fired for doc',
|
|
docIdInput,
|
|
'button',
|
|
event.button,
|
|
'pointerType',
|
|
event.pointerType,
|
|
'pointerId',
|
|
event.pointerId,
|
|
);
|
|
}
|
|
preventAll(event);
|
|
|
|
const docId = docIdInput != null ? docIdInput : null;
|
|
const docKey = docId != null ? String(docId) : null;
|
|
if (!docKey) {
|
|
return;
|
|
}
|
|
|
|
cancelInertiaAnimation(docId);
|
|
|
|
const doc = documentLookup.get(docKey);
|
|
if (!doc) {
|
|
return;
|
|
}
|
|
|
|
const stackDocIdsOption = Array.isArray(options?.stackDocIds)
|
|
? options.stackDocIds
|
|
.map((value) => (value != null ? String(value) : null))
|
|
.filter(Boolean)
|
|
: null;
|
|
|
|
let selectionIds = Array.isArray(selectedDocumentIds)
|
|
? selectedDocumentIds.map((id) => String(id))
|
|
: [];
|
|
|
|
if (stackDocIdsOption && stackDocIdsOption.length) {
|
|
selectionIds = stackDocIdsOption;
|
|
}
|
|
|
|
const metaOrCtrl = event.metaKey || event.ctrlKey;
|
|
if (!stackDocIdsOption && metaOrCtrl && !selectionIds.includes(docKey)) {
|
|
selectionIds = [...selectionIds, docKey];
|
|
}
|
|
let groupDocIds = [];
|
|
if (stackDocIdsOption && stackDocIdsOption.length) {
|
|
groupDocIds = stackDocIdsOption.filter((id, index, array) => {
|
|
const unique = array.indexOf(id) === index;
|
|
return unique && documentLookup.has(id);
|
|
});
|
|
} else if (selectionIds.includes(docKey) && selectionIds.length > 1) {
|
|
groupDocIds = selectionIds
|
|
.map((id) => String(id))
|
|
.filter((id, index, array) => array.indexOf(id) === index && documentLookup.has(id));
|
|
}
|
|
if (!groupDocIds.includes(docKey)) {
|
|
groupDocIds.unshift(docKey);
|
|
}
|
|
groupDocIds = groupDocIds.filter((id, index, array) => array.indexOf(id) === index);
|
|
if (!groupDocIds.length) {
|
|
groupDocIds = [docKey];
|
|
}
|
|
const isGroupDrag = groupDocIds.length > 1;
|
|
|
|
if (isGroupDrag) {
|
|
groupDocIds.forEach((id) => {
|
|
if (id !== docKey) {
|
|
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 = typeof entry?.centerX === 'number' ? entry.centerX : defaultCenterX;
|
|
const centerY = typeof entry?.centerY === 'number' ? entry.centerY : defaultCenterY;
|
|
|
|
const modifierPressed = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
|
|
if (!modifierPressed) {
|
|
if (isGroupDrag) {
|
|
groupDocIds.forEach((id) => bringToFront(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 && typeof capturedTarget.setPointerCapture === 'function') {
|
|
try {
|
|
capturedTarget.setPointerCapture(event.pointerId);
|
|
} catch (error) {
|
|
if (debugDrag) {
|
|
console.warn('[desk] setPointerCapture failed', 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 stackRandom = () => Math.random();
|
|
const groupItems = groupDocIds.map((id, index) => {
|
|
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 =
|
|
typeof itemEntry?.centerX === 'number' ? itemEntry.centerX : canvasPadding + itemWidth / 2;
|
|
const itemCenterY =
|
|
typeof itemEntry?.centerY === 'number' ? itemEntry.centerY : canvasPadding + itemHeight / 2;
|
|
const radius = index === 0 ? 0 : 24 + index * 8;
|
|
const offsetAngle = (index * 1.618 + stackRandom() * 0.5) * Math.PI;
|
|
const offsetX = Math.cos(offsetAngle) * radius;
|
|
const offsetY = Math.sin(offsetAngle) * radius;
|
|
const initialRotation = itemEntry?.rotation ?? 0;
|
|
const targetRotation = initialRotation;
|
|
return {
|
|
docId: id,
|
|
width: itemWidth,
|
|
height: itemHeight,
|
|
currentCenterX: itemCenterX,
|
|
currentCenterY: itemCenterY,
|
|
offsetX,
|
|
offsetY,
|
|
initialRotation,
|
|
displayRotation: initialRotation,
|
|
targetRotation,
|
|
};
|
|
});
|
|
|
|
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,
|
|
docKey,
|
|
pointerId: event.pointerId,
|
|
originCenterX: centerX,
|
|
originCenterY: 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,
|
|
groupDocIds,
|
|
groupItems,
|
|
groupElevated: !isGroupDrag,
|
|
};
|
|
|
|
setDraggingId(docId);
|
|
|
|
if (isGroupDrag) {
|
|
groupItems.forEach((item) => {
|
|
if (item.docId === docKey) {
|
|
return;
|
|
}
|
|
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,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
},
|
|
[
|
|
bringToFront,
|
|
canvasPadding,
|
|
cancelInertiaAnimation,
|
|
containerRef,
|
|
documentLookup,
|
|
ensureDocumentSize,
|
|
layoutRef,
|
|
resolveBaseMetrics,
|
|
selectedDocumentIds,
|
|
setDraggingId,
|
|
debugDrag,
|
|
itemRefs,
|
|
],
|
|
);
|
|
|
|
const handlePointerMove = useCallback(
|
|
(event) => {
|
|
const state = dragStateRef.current;
|
|
if (!state) {
|
|
if (debugDrag) {
|
|
console.log('[desk] handlePointerMove: no drag state for pointer', event.pointerId);
|
|
}
|
|
return;
|
|
}
|
|
if (state.pointerId !== event.pointerId) {
|
|
if (debugDrag) {
|
|
console.log(
|
|
'[desk] handlePointerMove: pointer mismatch expected',
|
|
state.pointerId,
|
|
'got',
|
|
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.groupElevated) {
|
|
const layout = layoutRef.current;
|
|
const sortedGroup = state.groupDocIds
|
|
.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.docId);
|
|
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);
|
|
|
|
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.groupItems.forEach((item) => {
|
|
const isPrimary = item.docId === state.docKey;
|
|
|
|
if (isPrimary) {
|
|
item.currentCenterX = centerX;
|
|
item.currentCenterY = centerY;
|
|
item.offsetX *= 0.92;
|
|
item.offsetY *= 0.92;
|
|
item.displayRotation = state.rotation ?? item.displayRotation ?? 0;
|
|
} else {
|
|
item.offsetX *= 0.92;
|
|
item.offsetY *= 0.92;
|
|
if (Math.abs(item.offsetX) < 1) item.offsetX = 0;
|
|
if (Math.abs(item.offsetY) < 1) item.offsetY = 0;
|
|
|
|
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 entryItem = layoutRef.current.get(item.docId) || {};
|
|
layoutRef.current.set(item.docId, {
|
|
...entryItem,
|
|
centerX: item.currentCenterX,
|
|
centerY: item.currentCenterY,
|
|
rotation: item.displayRotation ?? entryItem.rotation ?? 0,
|
|
});
|
|
|
|
applyTransform(
|
|
item.docId,
|
|
item.currentCenterX,
|
|
item.currentCenterY,
|
|
item.width,
|
|
item.height,
|
|
item.displayRotation ?? entryItem.rotation ?? 0,
|
|
isPrimary ? state.dragScale || 1 : 1,
|
|
);
|
|
});
|
|
|
|
state.lastClientX = event.clientX;
|
|
state.lastClientY = event.clientY;
|
|
state.lastTimestamp =
|
|
typeof event.timeStamp === 'number' && Number.isFinite(event.timeStamp)
|
|
? event.timeStamp
|
|
: typeof performance !== 'undefined' && typeof performance.now === 'function'
|
|
? performance.now()
|
|
: Date.now();
|
|
|
|
recalcVisibleDocIds();
|
|
return;
|
|
}
|
|
if (state.locked) {
|
|
if (debugDrag) {
|
|
console.log('[desk] handlePointerMove: locked drag for doc', state.docId);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const entry = layoutRef.current.get(state.docId);
|
|
if (!entry) {
|
|
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 rotationDeg = 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(entry.centerX) ? entry.centerX : state.originCenterX;
|
|
const previousCenterY = Number.isFinite(entry.centerY) ? entry.centerY : 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.docId);
|
|
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;
|
|
}
|
|
|
|
const updated = { ...entry, centerX: currentCenterX, centerY: currentCenterY };
|
|
layoutRef.current.set(state.docId, updated);
|
|
|
|
applyTransform(
|
|
state.docId,
|
|
currentCenterX,
|
|
currentCenterY,
|
|
state.width,
|
|
state.height,
|
|
rotationDeg,
|
|
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,
|
|
);
|
|
}
|
|
|
|
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 =
|
|
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);
|
|
|
|
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;
|
|
}
|
|
|
|
if (debugDrag) {
|
|
console.log('[desk] handlePointerMove: moved doc', state.docId, 'to', currentCenterX, currentCenterY);
|
|
}
|
|
recalcVisibleDocIds();
|
|
},
|
|
[
|
|
bringToFront,
|
|
canvasPadding,
|
|
canvasSize.height,
|
|
canvasSize.width,
|
|
defaultCanvasHeight,
|
|
defaultCanvasWidth,
|
|
containerRef,
|
|
layoutRef,
|
|
itemRefs,
|
|
applyTransform,
|
|
recalcVisibleDocIds,
|
|
debugDrag,
|
|
],
|
|
);
|
|
|
|
const handlePointerUp = useCallback(
|
|
(event) => {
|
|
const state = dragStateRef.current;
|
|
if (!state || state.pointerId !== event.pointerId) {
|
|
finishDrag(event.pointerId);
|
|
return;
|
|
}
|
|
|
|
if (state.isGroup) {
|
|
finalizeGroupDrag(state);
|
|
finishDrag(event.pointerId);
|
|
recalcVisibleDocIds();
|
|
return;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
const docId = state.docId;
|
|
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,
|
|
documentLookup,
|
|
finishDrag,
|
|
finalizeGroupDrag,
|
|
startInertiaAnimation,
|
|
recalcVisibleDocIds,
|
|
tapHandler,
|
|
],
|
|
);
|
|
|
|
const handlePointerCancel = useCallback(
|
|
(event) => {
|
|
const state = dragStateRef.current;
|
|
if (state && state.pointerId === event.pointerId && state.moved) {
|
|
if (state.isGroup) {
|
|
finalizeGroupDrag(state);
|
|
finishDrag(event.pointerId);
|
|
recalcVisibleDocIds();
|
|
return;
|
|
}
|
|
|
|
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);
|
|
},
|
|
[finalizeGroupDrag, finishDrag, recalcVisibleDocIds, startInertiaAnimation],
|
|
);
|
|
|
|
return {
|
|
handlePointerDown,
|
|
handlePointerMove,
|
|
handlePointerUp,
|
|
handlePointerCancel,
|
|
};
|
|
};
|
|
|
|
export default useDocumentDrag;
|