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;
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;
}