From 45ff3fb9acbcad9d33e32f1f6ce9d0537a42de38 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Tue, 25 Nov 2025 21:26:10 +0100 Subject: [PATCH] feat: introduce physics-based dynamic rotation for dragged documents by adding angular velocity and mass properties, and refine group drag selection logic. --- frontend/src/desktop/useDocumentDrag.ts | 387 ++++++++++++------------ 1 file changed, 200 insertions(+), 187 deletions(-) diff --git a/frontend/src/desktop/useDocumentDrag.ts b/frontend/src/desktop/useDocumentDrag.ts index 94e17ac..a08fa6a 100644 --- a/frontend/src/desktop/useDocumentDrag.ts +++ b/frontend/src/desktop/useDocumentDrag.ts @@ -65,6 +65,9 @@ interface DragGroupItemInternal extends EngineGroupItem { offsetY?: number; targetRotation?: number; initialRotation?: number; + angularVelocity?: number; + dynamicRotation?: number; + massGrams?: number; } type EnsureDocumentSizeFn = (doc: DocumentLike | null) => DocumentSizeInfo | null; @@ -317,33 +320,49 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { } 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 massGrams = computeDocumentMassGrams(doc); + // 1. Get initial selection from options const draggedDocIds = options.draggedDocIds; - let selectionIds: string[] = draggedDocIds; + // 2. Filter for valid documents selectionIds = selectionIds.filter((id, index, array) => array.indexOf(id) === index && documentLookup.has(id)); if (!selectionIds.length) { 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) { - selectionIds.forEach((id) => { + sortedSelectionIds.forEach((id) => { if (id !== docKey) { engine?.cancelInertiaAnimation?.(id); } @@ -366,20 +385,11 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { const modifierPressed = Boolean(options?.modifierActive); 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); + sortedSelectionIds.forEach((id) => { + bringToFront(id); }); } else { - bringToFront(docId); + bringToFront(docKey); } } @@ -412,7 +422,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { const localPointerOffsetX = pointerOffsetX * cosInitial - pointerOffsetY * sinInitial; const localPointerOffsetY = pointerOffsetX * sinInitial + pointerOffsetY * cosInitial; - const groupItems: DragGroupItemInternal[] = selectionIds.map((id) => { + const groupItems: DragGroupItemInternal[] = sortedSelectionIds.map((id) => { const itemDoc = documentLookup.get(id); const itemSize = ensureDocumentSize(itemDoc) || sizeInfo; const itemWidth = itemSize.width || docWidth; @@ -425,6 +435,8 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { const baseOffsetX = itemCenterX - centerX; const baseOffsetY = itemCenterY - centerY; const initialRotation = itemEntry?.rotation ?? 0; + const itemMass = computeDocumentMassGrams(itemDoc); + return { docId: id, width: itemWidth, @@ -437,7 +449,9 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { offsetY: baseOffsetY, targetRotation: initialRotation, displayRotation: initialRotation, - + angularVelocity: 0, + dynamicRotation: 0, + massGrams: itemMass, } satisfies DragGroupItemInternal; }); @@ -448,6 +462,8 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { ? performance.now() : Date.now(); + const massGrams = computeDocumentMassGrams(doc); + dragStateRef.current = { docId: docKey, docKey, @@ -477,7 +493,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { containerRectLeft: containerLeft, containerRectTop: containerTop, isGroup: isGroupDrag, - activeDocIds: selectionIds, + activeDocIds: sortedSelectionIds, groupItems, groupElevated: !isGroupDrag, stackSelectionApplied: true, @@ -559,60 +575,106 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { } 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 = ( - pointerCanvasX: number, - pointerCanvasY: number, - centerX: number, - centerY: number, - dtSeconds: number, + pX: number, + pY: number, + cX: number, + cY: number, + dtSec: number, + targetState: DragStateInternal | DragGroupItemInternal = state ) => { - if (!Number.isFinite(dtSeconds) || dtSeconds <= 0) { + if (!Number.isFinite(dtSec) || dtSec <= 0) { return; } - const leverX = pointerCanvasX - centerX; - const leverY = pointerCanvasY - centerY; + const leverX = pX - cX; + const leverY = pY - cY; if (!Number.isFinite(leverX) || !Number.isFinite(leverY)) { 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) ? state.lastPointerCanvasX - : pointerCanvasX; + : pX; const prevCanvasY = Number.isFinite(state.lastPointerCanvasY) ? state.lastPointerCanvasY - : pointerCanvasY; - const velocityCanvasX = (pointerCanvasX - prevCanvasX) / dtSeconds; - const velocityCanvasY = (pointerCanvasY - prevCanvasY) / dtSeconds; - state.lastPointerCanvasX = pointerCanvasX; - state.lastPointerCanvasY = pointerCanvasY; + : pY; + + const velocityCanvasX = (pX - prevCanvasX) / dtSec; + const velocityCanvasY = (pY - prevCanvasY) / dtSec; + if (!Number.isFinite(velocityCanvasX) || !Number.isFinite(velocityCanvasY)) { return; } + 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); - state.pointerRadiusScale = radiusScale; + + // If it's the main state, update pointerRadiusScale + if (targetState === state) { + state.pointerRadiusScale = radiusScale; + } + const torqueResponse = 0.0025 * radiusScale; const angularVelocityDeg = clamp( torque * torqueResponse, -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); - state.angularVelocity = angularVelocityDeg / massScale; + targetState.angularVelocity = angularVelocityDeg / massScale; }; - const applyDynamicRotation = (dtSeconds: number, dampingFactor = 0.94) => { - if (!Number.isFinite(dtSeconds) || dtSeconds <= 0) { + // Helper for applying dynamic rotation + const applyDynamicRotation = ( + dtSec: number, + targetState: DragStateInternal | DragGroupItemInternal = state, + dampingFactor = 0.94 + ) => { + if (!Number.isFinite(dtSec) || dtSec <= 0) { 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 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); const adjustedDamping = Math.pow(dampingFactor, 1 / Math.max(radiusInfluence, 0.8)); - state.dynamicRotation = nextDynamic * adjustedDamping; - state.rotation = state.restRotation + state.dynamicRotation; + targetState.dynamicRotation = nextDynamic * adjustedDamping; + + 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) { @@ -633,81 +695,56 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { return; } state.moved = true; - if ( - !state.stackSelectionApplied - ) { + if (!state.stackSelectionApplied) { 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); + // Ensure Z-order is preserved during drag + state.activeDocIds.forEach((id) => bringToFront(id)); 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; + // Independent Physics for each 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) { - 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; + applyDynamicRotation(dt, item, 0.96); - 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; + // 2. Calculate Target Position + const targetX = pointerCanvasX - state.localPointerOffsetX + (item.baseOffsetX || 0); + const targetY = pointerCanvasY - state.localPointerOffsetY + (item.baseOffsetY || 0); - 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); + // 3. Apply Smoothing / Damping + const smoothing = 0.18; // Base smoothing + const stackFriction = 0.85; // Additional damping for stack feel + const effectiveSmoothing = smoothing * stackFriction; - const rotationBlend = 0.16; - item.displayRotation += (item.targetRotation - item.displayRotation) * rotationBlend; - } + item.currentCenterX += (targetX - item.currentCenterX) * effectiveSmoothing; + 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 payload = { centerX: item.currentCenterX, @@ -715,7 +752,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { rotation: item.displayRotation ?? 0, width: item.width, height: item.height, - scale: isPrimary ? state.dragScale || 1 : 1, + scale: item.docId === state.docKey ? state.dragScale || 1 : 1, zIndex: entry?.z, }; @@ -724,33 +761,15 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { applyDomTransform(node, payload); }); - const currentTimestampGroup = - (Number.isFinite(event?.timeStamp)) - ? event.timeStamp - : 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; - } - }); + // Update shared state for next frame velocity calculation + state.lastPointerCanvasX = pointerCanvasX; + state.lastPointerCanvasY = pointerCanvasY; return; } + + // --- Single Item Drag Logic --- + if (state.locked) { return; } @@ -758,11 +777,6 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { 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; @@ -774,7 +788,6 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { const pointerCanvasX = event.clientX - containerLeft; const pointerCanvasY = event.clientY - containerTop; - const entry = layoutRef.current.get(state.docKey) || {}; const previousCenterX = Number.isFinite(state.currentCenterX) ? state.currentCenterX : state.originCenterX; @@ -782,29 +795,25 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { ? state.currentCenterY : 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 torqueCenterY = Number.isFinite(previousCenterY) ? previousCenterY : state.originCenterY; - updatePointerAngularVelocity(pointerCanvasX, pointerCanvasY, torqueCenterX, torqueCenterY, dt); - applyDynamicRotation(dt); - state.lastClientX = event.clientX; - state.lastClientY = event.clientY; - state.lastTimestamp = currentTimestamp; + updatePointerAngularVelocity(pointerCanvasX, pointerCanvasY, torqueCenterX, torqueCenterY, dt, state); + applyDynamicRotation(dt, state, 0.96); - 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 cosRot = Math.cos(rotationRad); const sinRot = Math.sin(rotationRad); @@ -817,26 +826,16 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { 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 minCenterXRotated = canvasPadding + rotatedHalfWidth; + const maxCenterXRotated = Math.max(minCenterXRotated, canvasWidth - canvasPadding - rotatedHalfWidth); + const minCenterYRotated = canvasPadding + rotatedHalfHeight; + const maxCenterYRotated = Math.max(minCenterYRotated, canvasHeight - canvasPadding - rotatedHalfHeight); const desiredCenterX = pointerCanvasX - rotatedOffsetX; const desiredCenterY = pointerCanvasY - rotatedOffsetY; - const clampedCenterX = clamp(desiredCenterX, minCenterX, maxCenterX); - const clampedCenterY = clamp(desiredCenterY, minCenterY, maxCenterY); + const clampedCenterX = clamp(desiredCenterX, minCenterXRotated, maxCenterXRotated); + const clampedCenterY = clamp(desiredCenterY, minCenterYRotated, maxCenterYRotated); if (!state.moved) { const distanceSquared = deltaX * deltaX + deltaY * deltaY; @@ -847,12 +846,23 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { state.moved = true; } + // Edge collision logic const collidedWithHorizontalEdge = Math.abs(desiredCenterY - clampedCenterY) > EDGE_COLLISION_THRESHOLD; const collidedWithVerticalEdge = Math.abs(desiredCenterX - clampedCenterX) > EDGE_COLLISION_THRESHOLD; 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 currentCenterY = clampedCenterY; if (collidedWithEdge && !pointerInsideRelativeToPrev) { @@ -878,6 +888,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { const primaryNode = itemRefs.current.get(state.docKey); applyDomTransform(primaryNode, transformPayload); + // Update local pointer offset if we didn't collide or pointer is inside const offsetX = pointerCanvasX - currentCenterX; const offsetY = pointerCanvasY - currentCenterY; const cosInverseCurrent = Math.cos(-rotationRad); @@ -887,15 +898,17 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { const pointerInsideCard = 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) { + // 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.localPointerOffsetY = updatedLocalOffsetY; }