feat: introduce physics-based dynamic rotation for dragged documents by adding angular velocity and mass properties, and refine group drag selection logic.

This commit is contained in:
2025-11-25 21:26:10 +01:00
parent 5accfb4339
commit 45ff3fb9ac
+200 -187
View File
@@ -65,6 +65,9 @@ interface DragGroupItemInternal extends EngineGroupItem {
offsetY?: number; offsetY?: number;
targetRotation?: number; targetRotation?: number;
initialRotation?: number; initialRotation?: number;
angularVelocity?: number;
dynamicRotation?: number;
massGrams?: number;
} }
type EnsureDocumentSizeFn = (doc: DocumentLike | null) => DocumentSizeInfo | null; type EnsureDocumentSizeFn = (doc: DocumentLike | null) => DocumentSizeInfo | null;
@@ -317,33 +320,49 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
} }
preventAll(event); preventAll(event);
const docId = docIdInput != null ? docIdInput : null; // 1. Get initial selection from options
const docKey = docId != null ? String(docId) : null;
if (!docKey) {
return;
}
engine?.cancelInertiaAnimation?.(docKey);
const doc = documentLookup.get(docKey);
if (!doc) {
return;
}
const massGrams = computeDocumentMassGrams(doc);
const draggedDocIds = options.draggedDocIds; const draggedDocIds = options.draggedDocIds;
let selectionIds: string[] = draggedDocIds; let selectionIds: string[] = draggedDocIds;
// 2. Filter for valid documents
selectionIds = selectionIds.filter((id, index, array) => array.indexOf(id) === index && documentLookup.has(id)); selectionIds = selectionIds.filter((id, index, array) => array.indexOf(id) === index && documentLookup.has(id));
if (!selectionIds.length) { if (!selectionIds.length) {
return; return;
} }
const isGroupDrag = selectionIds.length > 1; // 3. Sort by Z-index (ascending) so the top-most card is last
const layout = layoutRef.current;
const sortedSelectionIds = [...selectionIds]
.sort((a, b) => {
const aZ = layout.get(a)?.z ?? 0;
const bZ = layout.get(b)?.z ?? 0;
return aZ - bZ;
});
// 4. Determine Anchor
// Anchor is the top-most valid card (last in sorted list)
// We iterate backwards to find the first one with a valid layout
let anchorId = sortedSelectionIds[sortedSelectionIds.length - 1];
for (let i = sortedSelectionIds.length - 1; i >= 0; i--) {
if (layout.has(sortedSelectionIds[i])) {
anchorId = sortedSelectionIds[i];
break;
}
}
const docKey = anchorId;
const doc = documentLookup.get(docKey);
if (!doc) {
return;
}
engine?.cancelInertiaAnimation?.(docKey);
const isGroupDrag = sortedSelectionIds.length > 1;
if (isGroupDrag) { if (isGroupDrag) {
selectionIds.forEach((id) => { sortedSelectionIds.forEach((id) => {
if (id !== docKey) { if (id !== docKey) {
engine?.cancelInertiaAnimation?.(id); engine?.cancelInertiaAnimation?.(id);
} }
@@ -366,20 +385,11 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
const modifierPressed = Boolean(options?.modifierActive); const modifierPressed = Boolean(options?.modifierActive);
if (!modifierPressed) { if (!modifierPressed) {
if (isGroupDrag) { if (isGroupDrag) {
const layout = layoutRef.current; sortedSelectionIds.forEach((id) => {
const ordered = [...selectionIds] bringToFront(id);
.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 { } else {
bringToFront(docId); bringToFront(docKey);
} }
} }
@@ -412,7 +422,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
const localPointerOffsetX = pointerOffsetX * cosInitial - pointerOffsetY * sinInitial; const localPointerOffsetX = pointerOffsetX * cosInitial - pointerOffsetY * sinInitial;
const localPointerOffsetY = pointerOffsetX * sinInitial + pointerOffsetY * cosInitial; const localPointerOffsetY = pointerOffsetX * sinInitial + pointerOffsetY * cosInitial;
const groupItems: DragGroupItemInternal[] = selectionIds.map((id) => { const groupItems: DragGroupItemInternal[] = sortedSelectionIds.map((id) => {
const itemDoc = documentLookup.get(id); const itemDoc = documentLookup.get(id);
const itemSize = ensureDocumentSize(itemDoc) || sizeInfo; const itemSize = ensureDocumentSize(itemDoc) || sizeInfo;
const itemWidth = itemSize.width || docWidth; const itemWidth = itemSize.width || docWidth;
@@ -425,6 +435,8 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
const baseOffsetX = itemCenterX - centerX; const baseOffsetX = itemCenterX - centerX;
const baseOffsetY = itemCenterY - centerY; const baseOffsetY = itemCenterY - centerY;
const initialRotation = itemEntry?.rotation ?? 0; const initialRotation = itemEntry?.rotation ?? 0;
const itemMass = computeDocumentMassGrams(itemDoc);
return { return {
docId: id, docId: id,
width: itemWidth, width: itemWidth,
@@ -437,7 +449,9 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
offsetY: baseOffsetY, offsetY: baseOffsetY,
targetRotation: initialRotation, targetRotation: initialRotation,
displayRotation: initialRotation, displayRotation: initialRotation,
angularVelocity: 0,
dynamicRotation: 0,
massGrams: itemMass,
} satisfies DragGroupItemInternal; } satisfies DragGroupItemInternal;
}); });
@@ -448,6 +462,8 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
? performance.now() ? performance.now()
: Date.now(); : Date.now();
const massGrams = computeDocumentMassGrams(doc);
dragStateRef.current = { dragStateRef.current = {
docId: docKey, docId: docKey,
docKey, docKey,
@@ -477,7 +493,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
containerRectLeft: containerLeft, containerRectLeft: containerLeft,
containerRectTop: containerTop, containerRectTop: containerTop,
isGroup: isGroupDrag, isGroup: isGroupDrag,
activeDocIds: selectionIds, activeDocIds: sortedSelectionIds,
groupItems, groupItems,
groupElevated: !isGroupDrag, groupElevated: !isGroupDrag,
stackSelectionApplied: true, stackSelectionApplied: true,
@@ -559,60 +575,106 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
} }
preventAll(event); preventAll(event);
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;
// Helper for updating angular velocity based on pointer movement
const updatePointerAngularVelocity = ( const updatePointerAngularVelocity = (
pointerCanvasX: number, pX: number,
pointerCanvasY: number, pY: number,
centerX: number, cX: number,
centerY: number, cY: number,
dtSeconds: number, dtSec: number,
targetState: DragStateInternal | DragGroupItemInternal = state
) => { ) => {
if (!Number.isFinite(dtSeconds) || dtSeconds <= 0) { if (!Number.isFinite(dtSec) || dtSec <= 0) {
return; return;
} }
const leverX = pointerCanvasX - centerX; const leverX = pX - cX;
const leverY = pointerCanvasY - centerY; const leverY = pY - cY;
if (!Number.isFinite(leverX) || !Number.isFinite(leverY)) { if (!Number.isFinite(leverX) || !Number.isFinite(leverY)) {
return; return;
} }
// Use shared state for previous pointer position to calculate velocity
// Note: For group items, we use the same pointer velocity
const prevCanvasX = Number.isFinite(state.lastPointerCanvasX) const prevCanvasX = Number.isFinite(state.lastPointerCanvasX)
? state.lastPointerCanvasX ? state.lastPointerCanvasX
: pointerCanvasX; : pX;
const prevCanvasY = Number.isFinite(state.lastPointerCanvasY) const prevCanvasY = Number.isFinite(state.lastPointerCanvasY)
? state.lastPointerCanvasY ? state.lastPointerCanvasY
: pointerCanvasY; : pY;
const velocityCanvasX = (pointerCanvasX - prevCanvasX) / dtSeconds;
const velocityCanvasY = (pointerCanvasY - prevCanvasY) / dtSeconds; const velocityCanvasX = (pX - prevCanvasX) / dtSec;
state.lastPointerCanvasX = pointerCanvasX; const velocityCanvasY = (pY - prevCanvasY) / dtSec;
state.lastPointerCanvasY = pointerCanvasY;
if (!Number.isFinite(velocityCanvasX) || !Number.isFinite(velocityCanvasY)) { if (!Number.isFinite(velocityCanvasX) || !Number.isFinite(velocityCanvasY)) {
return; return;
} }
const torque = leverX * velocityCanvasY - leverY * velocityCanvasX; const torque = leverX * velocityCanvasY - leverY * velocityCanvasX;
const influenceRadius = Math.max(state.width, state.height) / 2 || 1; const influenceRadius = Math.max(targetState.width, targetState.height) / 2 || 1;
const radiusScale = clamp(Math.hypot(leverX, leverY) / influenceRadius, 0.2, 2.5); const radiusScale = clamp(Math.hypot(leverX, leverY) / influenceRadius, 0.2, 2.5);
state.pointerRadiusScale = radiusScale;
// If it's the main state, update pointerRadiusScale
if (targetState === state) {
state.pointerRadiusScale = radiusScale;
}
const torqueResponse = 0.0025 * radiusScale; const torqueResponse = 0.0025 * radiusScale;
const angularVelocityDeg = clamp( const angularVelocityDeg = clamp(
torque * torqueResponse, torque * torqueResponse,
-MAX_ANGULAR_VELOCITY, -MAX_ANGULAR_VELOCITY,
MAX_ANGULAR_VELOCITY, MAX_ANGULAR_VELOCITY,
); );
const mass = Math.max(state.massGrams || CARD_BASE_WEIGHT_GRAMS, CARD_BASE_WEIGHT_GRAMS);
const mass = Math.max(targetState.massGrams || CARD_BASE_WEIGHT_GRAMS, CARD_BASE_WEIGHT_GRAMS);
const massScale = Math.max(mass / CARD_BASE_WEIGHT_GRAMS, 1); const massScale = Math.max(mass / CARD_BASE_WEIGHT_GRAMS, 1);
state.angularVelocity = angularVelocityDeg / massScale; targetState.angularVelocity = angularVelocityDeg / massScale;
}; };
const applyDynamicRotation = (dtSeconds: number, dampingFactor = 0.94) => { // Helper for applying dynamic rotation
if (!Number.isFinite(dtSeconds) || dtSeconds <= 0) { const applyDynamicRotation = (
dtSec: number,
targetState: DragStateInternal | DragGroupItemInternal = state,
dampingFactor = 0.94
) => {
if (!Number.isFinite(dtSec) || dtSec <= 0) {
return; return;
} }
// Use state.pointerRadiusScale as a proxy for influence if not available on item?
// Actually, let's just use 1 if not available, or recalculate.
// For simplicity, we'll use the one calculated in updatePointerAngularVelocity if available,
// or default.
const radiusInfluence = clamp(state.pointerRadiusScale || 1, 0.3, 3); const radiusInfluence = clamp(state.pointerRadiusScale || 1, 0.3, 3);
const response = 1.1 * radiusInfluence; const response = 1.1 * radiusInfluence;
let nextDynamic = state.dynamicRotation + state.angularVelocity * dtSeconds * response;
let nextDynamic = (targetState.dynamicRotation || 0) + (targetState.angularVelocity || 0) * dtSec * response;
nextDynamic = clamp(nextDynamic, -MAX_DYNAMIC_ROTATION, MAX_DYNAMIC_ROTATION); nextDynamic = clamp(nextDynamic, -MAX_DYNAMIC_ROTATION, MAX_DYNAMIC_ROTATION);
const adjustedDamping = Math.pow(dampingFactor, 1 / Math.max(radiusInfluence, 0.8)); const adjustedDamping = Math.pow(dampingFactor, 1 / Math.max(radiusInfluence, 0.8));
state.dynamicRotation = nextDynamic * adjustedDamping; targetState.dynamicRotation = nextDynamic * adjustedDamping;
state.rotation = state.restRotation + state.dynamicRotation;
if (targetState === state) {
state.rotation = state.restRotation + state.dynamicRotation;
} else {
// For group items
const item = targetState as DragGroupItemInternal;
item.displayRotation = (item.initialRotation || 0) + item.dynamicRotation;
}
}; };
if (state.isGroup) { if (state.isGroup) {
@@ -633,81 +695,56 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
return; return;
} }
state.moved = true; state.moved = true;
if ( if (!state.stackSelectionApplied) {
!state.stackSelectionApplied
) {
state.stackSelectionApplied = true; state.stackSelectionApplied = true;
} }
if (!state.groupElevated) { if (!state.groupElevated) {
const layout = layoutRef.current; // Ensure Z-order is preserved during drag
const sortedGroup = state.activeDocIds state.activeDocIds.forEach((id) => bringToFront(id));
.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; 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 canvasWidth = canvasSize.width || defaultCanvasWidth;
const canvasHeight = canvasSize.height || defaultCanvasHeight; 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;
// Independent Physics for each item
state.groupItems.forEach((item) => { state.groupItems.forEach((item) => {
const isPrimary = item.docId === state.docKey; // 1. Calculate Physics (Torque & Rotation)
updatePointerAngularVelocity(
pointerCanvasX,
pointerCanvasY,
item.currentCenterX,
item.currentCenterY,
dt,
item
);
if (isPrimary) { applyDynamicRotation(dt, item, 0.96);
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; // 2. Calculate Target Position
const targetY = centerY + item.offsetY; const targetX = pointerCanvasX - state.localPointerOffsetX + (item.baseOffsetX || 0);
const smoothing = 0.18; const targetY = pointerCanvasY - state.localPointerOffsetY + (item.baseOffsetY || 0);
item.currentCenterX += (targetX - item.currentCenterX) * smoothing;
item.currentCenterY += (targetY - item.currentCenterY) * smoothing;
const halfW = item.width / 2; // 3. Apply Smoothing / Damping
const halfH = item.height / 2; const smoothing = 0.18; // Base smoothing
const minX = canvasPadding + halfW; const stackFriction = 0.85; // Additional damping for stack feel
const maxX = Math.max(minX, canvasWidth - canvasPadding - halfW); const effectiveSmoothing = smoothing * stackFriction;
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.currentCenterX += (targetX - item.currentCenterX) * effectiveSmoothing;
item.displayRotation += (item.targetRotation - item.displayRotation) * rotationBlend; item.currentCenterY += (targetY - item.currentCenterY) * effectiveSmoothing;
}
// Clamp to canvas
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);
// Apply transform
const entry = layoutRef.current.get(item.docId) || null; const entry = layoutRef.current.get(item.docId) || null;
const payload = { const payload = {
centerX: item.currentCenterX, centerX: item.currentCenterX,
@@ -715,7 +752,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
rotation: item.displayRotation ?? 0, rotation: item.displayRotation ?? 0,
width: item.width, width: item.width,
height: item.height, height: item.height,
scale: isPrimary ? state.dragScale || 1 : 1, scale: item.docId === state.docKey ? state.dragScale || 1 : 1,
zIndex: entry?.z, zIndex: entry?.z,
}; };
@@ -724,33 +761,15 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
applyDomTransform(node, payload); applyDomTransform(node, payload);
}); });
const currentTimestampGroup = // Update shared state for next frame velocity calculation
(Number.isFinite(event?.timeStamp)) state.lastPointerCanvasX = pointerCanvasX;
? event.timeStamp state.lastPointerCanvasY = pointerCanvasY;
: performance?.now
? performance.now()
: Date.now();
const previousTimestampGroup = state.lastTimestamp ?? currentTimestampGroup;
let dtGroup = (currentTimestampGroup - previousTimestampGroup) / 1000;
if (!Number.isFinite(dtGroup) || dtGroup <= 0) {
dtGroup = MIN_TIMESTEP;
}
dtGroup = clamp(dtGroup, MIN_TIMESTEP, MAX_TIMESTEP);
state.lastClientX = event.clientX;
state.lastClientY = event.clientY;
state.lastTimestamp = currentTimestampGroup;
updatePointerAngularVelocity(pointerCanvasX, pointerCanvasY, centerX, centerY, dtGroup);
applyDynamicRotation(dtGroup, 0.96);
state.groupItems.forEach((item) => {
if (item.docId === state.docKey) {
item.displayRotation = state.rotation ?? item.displayRotation ?? 0;
}
});
return; return;
} }
// --- Single Item Drag Logic ---
if (state.locked) { if (state.locked) {
return; return;
} }
@@ -758,11 +777,6 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
const deltaX = event.clientX - state.startX; const deltaX = event.clientX - state.startX;
const deltaY = event.clientY - state.startY; 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?.(); const containerRect = containerRef.current?.getBoundingClientRect?.();
if (containerRect) { if (containerRect) {
state.containerRectLeft = containerRect.left; state.containerRectLeft = containerRect.left;
@@ -774,7 +788,6 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
const pointerCanvasX = event.clientX - containerLeft; const pointerCanvasX = event.clientX - containerLeft;
const pointerCanvasY = event.clientY - containerTop; const pointerCanvasY = event.clientY - containerTop;
const entry = layoutRef.current.get(state.docKey) || {};
const previousCenterX = Number.isFinite(state.currentCenterX) const previousCenterX = Number.isFinite(state.currentCenterX)
? state.currentCenterX ? state.currentCenterX
: state.originCenterX; : state.originCenterX;
@@ -782,29 +795,25 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
? state.currentCenterY ? state.currentCenterY
: state.originCenterY; : state.originCenterY;
const currentTimestamp =
(Number.isFinite(event?.timeStamp))
? event.timeStamp
: performance?.now
? performance.now()
: Date.now();
const previousTimestamp = state.lastTimestamp ?? currentTimestamp;
let dt = (currentTimestamp - previousTimestamp) / 1000;
if (!Number.isFinite(dt) || dt <= 0) {
dt = MIN_TIMESTEP;
}
dt = clamp(dt, MIN_TIMESTEP, MAX_TIMESTEP);
const torqueCenterX = Number.isFinite(previousCenterX) ? previousCenterX : state.originCenterX; const torqueCenterX = Number.isFinite(previousCenterX) ? previousCenterX : state.originCenterX;
const torqueCenterY = Number.isFinite(previousCenterY) ? previousCenterY : state.originCenterY; const torqueCenterY = Number.isFinite(previousCenterY) ? previousCenterY : state.originCenterY;
updatePointerAngularVelocity(pointerCanvasX, pointerCanvasY, torqueCenterX, torqueCenterY, dt);
applyDynamicRotation(dt);
state.lastClientX = event.clientX; updatePointerAngularVelocity(pointerCanvasX, pointerCanvasY, torqueCenterX, torqueCenterY, dt, state);
state.lastClientY = event.clientY; applyDynamicRotation(dt, state, 0.96);
state.lastTimestamp = currentTimestamp;
const rotationDeg = state.rotation ?? entry.rotation ?? 0; state.lastPointerCanvasX = pointerCanvasX;
state.lastPointerCanvasY = pointerCanvasY;
// Position Calculation
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 rotationDeg = state.rotation ?? 0;
const rotationRad = (rotationDeg * Math.PI) / 180; const rotationRad = (rotationDeg * Math.PI) / 180;
const cosRot = Math.cos(rotationRad); const cosRot = Math.cos(rotationRad);
const sinRot = Math.sin(rotationRad); const sinRot = Math.sin(rotationRad);
@@ -817,26 +826,16 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
const absSin = Math.abs(sinRot); const absSin = Math.abs(sinRot);
const rotatedHalfWidth = absCos * halfWidth + absSin * halfHeight; const rotatedHalfWidth = absCos * halfWidth + absSin * halfHeight;
const rotatedHalfHeight = absSin * halfWidth + absCos * 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 minCenterXRotated = canvasPadding + rotatedHalfWidth;
const pointerRelativePrevY = pointerCanvasY - previousCenterY; const maxCenterXRotated = Math.max(minCenterXRotated, canvasWidth - canvasPadding - rotatedHalfWidth);
const cosInversePrev = Math.cos(-rotationRad); const minCenterYRotated = canvasPadding + rotatedHalfHeight;
const sinInversePrev = Math.sin(-rotationRad); const maxCenterYRotated = Math.max(minCenterYRotated, canvasHeight - canvasPadding - rotatedHalfHeight);
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 desiredCenterX = pointerCanvasX - rotatedOffsetX;
const desiredCenterY = pointerCanvasY - rotatedOffsetY; const desiredCenterY = pointerCanvasY - rotatedOffsetY;
const clampedCenterX = clamp(desiredCenterX, minCenterX, maxCenterX); const clampedCenterX = clamp(desiredCenterX, minCenterXRotated, maxCenterXRotated);
const clampedCenterY = clamp(desiredCenterY, minCenterY, maxCenterY); const clampedCenterY = clamp(desiredCenterY, minCenterYRotated, maxCenterYRotated);
if (!state.moved) { if (!state.moved) {
const distanceSquared = deltaX * deltaX + deltaY * deltaY; const distanceSquared = deltaX * deltaX + deltaY * deltaY;
@@ -847,12 +846,23 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
state.moved = true; state.moved = true;
} }
// Edge collision logic
const collidedWithHorizontalEdge = const collidedWithHorizontalEdge =
Math.abs(desiredCenterY - clampedCenterY) > EDGE_COLLISION_THRESHOLD; Math.abs(desiredCenterY - clampedCenterY) > EDGE_COLLISION_THRESHOLD;
const collidedWithVerticalEdge = const collidedWithVerticalEdge =
Math.abs(desiredCenterX - clampedCenterX) > EDGE_COLLISION_THRESHOLD; Math.abs(desiredCenterX - clampedCenterX) > EDGE_COLLISION_THRESHOLD;
const collidedWithEdge = collidedWithHorizontalEdge || collidedWithVerticalEdge; const collidedWithEdge = collidedWithHorizontalEdge || collidedWithVerticalEdge;
// Check if pointer is inside the card relative to previous position
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;
let currentCenterX = clampedCenterX; let currentCenterX = clampedCenterX;
let currentCenterY = clampedCenterY; let currentCenterY = clampedCenterY;
if (collidedWithEdge && !pointerInsideRelativeToPrev) { if (collidedWithEdge && !pointerInsideRelativeToPrev) {
@@ -878,6 +888,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
const primaryNode = itemRefs.current.get(state.docKey); const primaryNode = itemRefs.current.get(state.docKey);
applyDomTransform(primaryNode, transformPayload); applyDomTransform(primaryNode, transformPayload);
// Update local pointer offset if we didn't collide or pointer is inside
const offsetX = pointerCanvasX - currentCenterX; const offsetX = pointerCanvasX - currentCenterX;
const offsetY = pointerCanvasY - currentCenterY; const offsetY = pointerCanvasY - currentCenterY;
const cosInverseCurrent = Math.cos(-rotationRad); const cosInverseCurrent = Math.cos(-rotationRad);
@@ -887,15 +898,17 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
const pointerInsideCard = const pointerInsideCard =
Math.abs(pointerLocalX) <= halfWidth && Math.abs(pointerLocalY) <= halfHeight; Math.abs(pointerLocalX) <= halfWidth && Math.abs(pointerLocalY) <= halfHeight;
const rotationForOffsetDeg = state.rotation || 0;
const rotationForOffsetRad = (rotationForOffsetDeg * Math.PI) / 180;
const cosInverse = Math.cos(-rotationForOffsetRad);
const sinInverse = Math.sin(-rotationForOffsetRad);
const pointerRelativeX = pointerCanvasX - currentCenterX;
const pointerRelativeY = pointerCanvasY - currentCenterY;
const updatedLocalOffsetX = pointerRelativeX * cosInverse - pointerRelativeY * sinInverse;
const updatedLocalOffsetY = pointerRelativeX * sinInverse + pointerRelativeY * cosInverse;
if (!collidedWithEdge || pointerInsideCard) { if (!collidedWithEdge || pointerInsideCard) {
// Recalculate local offset based on current rotation
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;
state.localPointerOffsetX = updatedLocalOffsetX; state.localPointerOffsetX = updatedLocalOffsetX;
state.localPointerOffsetY = updatedLocalOffsetY; state.localPointerOffsetY = updatedLocalOffsetY;
} }