From e512c8215ea8a67fd478bbd2a5d158938ba22bb1 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Fri, 28 Nov 2025 01:10:57 +0100 Subject: [PATCH] feat: Implement enhanced card physics with rotational dynamics, mass scaling, stacking behavior, and drag torque. --- frontend/src/desktop/CardDragLogic.ts | 39 +-- frontend/src/desktop/CardPhysics.ts | 273 ++++++++++++++++-- frontend/src/desktop/DesktopWorkspace.tsx | 7 +- frontend/src/desktop/LayoutSystem.ts | 20 +- .../src/styles/workspace/workspace-cards.css | 2 +- 5 files changed, 278 insertions(+), 63 deletions(-) diff --git a/frontend/src/desktop/CardDragLogic.ts b/frontend/src/desktop/CardDragLogic.ts index 16a1233..957c341 100644 --- a/frontend/src/desktop/CardDragLogic.ts +++ b/frontend/src/desktop/CardDragLogic.ts @@ -4,7 +4,8 @@ export const handleDragStart = (store: LayoutStore, selection: string[], leading const leadingCard = store.items.get(leadingId); if (!leadingCard) return; - // Snap all non-leading cards to the leading card's position + // 1. Snap all followers to leader's center + // 2. Attach them as followers to the leader's physics selection.forEach(id => { if (id === leadingId) return; const card = store.items.get(id); @@ -19,30 +20,17 @@ export const handleDragStart = (store: LayoutStore, selection: string[], leading const targetY = leadingCenterY - card.height / 2; card.snapTo(targetX, targetY); + + // Stop any existing physics on the follower + card.physics.stop(); + + // Attach as follower + leadingCard.physics.addFollower(card); } }); - selection.forEach(id => { - const card = store.items.get(id); - if (!card) return; - - // If card is already dragging by another pointer, skip it - if (card.physics.isDragging && card.physics.dragPointerId !== pointerId) return; - - let myOffset = offset; - - if (id !== leadingId) { - const pointerX = leadingCard.x + offset.x; - const pointerY = leadingCard.y + offset.y; - - myOffset = { - x: pointerX - card.x, - y: pointerY - card.y - }; - } - - card.physics.beginDrag(myOffset, pointerId); - }); + // 3. Begin drag ONLY on the leader + leadingCard.physics.beginDrag(offset, pointerId); }; export const attachToDragGroup = (store: LayoutStore, selection: string[], leadingId: string, pointerId: number) => { @@ -62,7 +50,12 @@ export const attachToDragGroup = (store: LayoutStore, selection: string[], leadi const targetY = leadingCenterY - card.height / 2; card.snapTo(targetX, targetY); - card.physics.beginDrag({ x: 0, y: 0 }, pointerId); + + // Stop any existing physics + card.physics.stop(); + + // Attach as follower + leadingCard.physics.addFollower(card); } }); }; diff --git a/frontend/src/desktop/CardPhysics.ts b/frontend/src/desktop/CardPhysics.ts index 5a17fa9..c17d366 100644 --- a/frontend/src/desktop/CardPhysics.ts +++ b/frontend/src/desktop/CardPhysics.ts @@ -1,28 +1,66 @@ import { LayoutCard } from './LayoutSystem'; export class CardPhysics { - // State private velocity: { x: number, y: number, rotation: number } = { x: 0, y: 0, rotation: 0 }; private _isDragging: boolean = false; public dragPointerId: number | null = null; private pendingDelta: { x: number, y: number } = { x: 0, y: 0 }; + public mass: number = 30; + private baseMass: number = 30; + private massScale: number = 1; + private angularVelocity: number = 0; + private lastTimestamp: number = 0; + + private dragOffset: { x: number, y: number } | null = null; + get isDragging(): boolean { return this._isDragging; } - // Loop State private physicsRafId: number | null = null; private lastTickTime: number = 0; private card: LayoutCard; constructor(card: LayoutCard) { this.card = card; + this.updateMass(card.pageCount); + } + + updateMass(pageCount: number) { + const pages = Math.max(1, pageCount); + this.baseMass = 30 + 5 * pages; + this.mass = this.baseMass; + this.updateMassScale(); + } + + private updateMassScale() { + this.massScale = Math.max(this.mass / 30, 1); + } + + private normalizeAngle(angle: number): number { + let a = angle % 360; + if (a > 180) a -= 360; + if (a <= -180) a += 360; + return a; } beginDrag(offset: { x: number, y: number }, pointerId: number) { this._isDragging = true; this.dragPointerId = pointerId; + + // Store the offset from center where we grabbed the card + // Convert world offset to local offset (rotate by -rotation) + const rad = -this.card.rotation * Math.PI / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + + this.dragOffset = { + x: offset.x * cos - offset.y * sin, + y: offset.x * sin + offset.y * cos + }; + + this.angularVelocity = 0; this.startPhysicsLoop(); } @@ -34,7 +72,9 @@ export class CardPhysics { finishDrag() { this._isDragging = false; this.dragPointerId = null; - // Loop continues for decay/settling if needed + // Keep dragOffset for inertia pivot correction + this.lastTimestamp = performance.now(); + this.angularVelocity = 0; // Kill momentum on release } private startPhysicsLoop() { @@ -43,50 +83,219 @@ export class CardPhysics { this.physicsRafId = requestAnimationFrame(this.physicsTick); } + public stop() { + this.stopPhysicsLoop(); + } + private stopPhysicsLoop() { if (this.physicsRafId) { cancelAnimationFrame(this.physicsRafId); this.physicsRafId = null; } + this.dragOffset = null; + this.clearFollowers(); } private physicsTick = (time: number) => { - const dt = Math.min((time - this.lastTickTime) / 1000, 0.1); + const rawDt = (time - this.lastTickTime) / 1000; + const dt = Math.max(1 / 120, Math.min(rawDt, 1 / 20)); this.lastTickTime = time; - if (this._isDragging) { - // Minimal Physics: Apply pending delta directly - const dx = this.pendingDelta.x; - const dy = this.pendingDelta.y; + this.updatePhysics(dt); - // Update velocity (simple instantaneous) - if (dt > 0.001) { - this.velocity.x = dx / dt; - this.velocity.y = dy / dt; - } + if (this.physicsRafId) { + this.physicsRafId = requestAnimationFrame(this.physicsTick); + } + }; - // Reset pending delta - this.pendingDelta = { x: 0, y: 0 }; + private readonly ROTATION_LIMIT = 5; + private minLimit: number = -5; + private maxLimit: number = 5; - // Update position - const newX = this.card.x + dx; - const newY = this.card.y + dy; + private followers: { card: LayoutCard, offsetRotation: number }[] = []; - // Apply Constraints - const constrained = this.card.getConstrainedPosition(newX, newY); + addFollower(card: LayoutCard) { + // Calculate relative rotation + // follower = leader + offset => offset = follower - leader + const offset = this.normalizeAngle(card.rotation - this.card.rotation); + this.followers.push({ card, offsetRotation: offset }); - this.card.update({ - x: constrained.x, - y: constrained.y, - rotation: this.card.rotation // No rotation change for now - }, { markDirty: true }); - } else { - // Minimal Decay: Stop immediately for now (skeleton behavior) - this.stopPhysicsLoop(); - return; + // Add follower mass to leader + this.mass += card.physics.mass; + this.updateMassScale(); + + // Constrain leader limits to ensure follower stays within [-5, 5] + // -5 <= leader + offset <= 5 + // -5 - offset <= leader <= 5 - offset + this.minLimit = Math.max(this.minLimit, -this.ROTATION_LIMIT - offset); + this.maxLimit = Math.min(this.maxLimit, this.ROTATION_LIMIT - offset); + } + + clearFollowers() { + this.followers = []; + // Reset mass to base mass + this.mass = this.baseMass; + this.updateMassScale(); + // Reset limits + this.minLimit = -this.ROTATION_LIMIT; + this.maxLimit = this.ROTATION_LIMIT; + } + + private updatePhysics(dt: number) { + const dx = this.pendingDelta.x; + const dy = this.pendingDelta.y; + + this.pendingDelta = { x: 0, y: 0 }; + + const vx = dx / dt; + const vy = dy / dt; + + // 1. Update Position (Direct 1:1 movement) + const newX = this.card.x + dx; + const newY = this.card.y + dy; + const constrained = this.card.getConstrainedPosition(newX, newY); + + // 2. Calculate Torque & Forces + let torque = 0; + let recoveryTorque = 0; + let isRecovering = false; + + // Drag Torque + if (this.dragOffset && this._isDragging) { + const rad = this.card.rotation * Math.PI / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + + const worldLeverX = this.dragOffset.x * cos - this.dragOffset.y * sin; + const worldLeverY = this.dragOffset.x * sin + this.dragOffset.y * cos; + + torque = worldLeverX * vy - worldLeverY * vx; } - // Request next frame - this.physicsRafId = requestAnimationFrame(this.physicsTick); - }; + // Recovery Torque + const normRot = this.normalizeAngle(this.card.rotation); + if (normRot > this.maxLimit) { + recoveryTorque = (this.maxLimit - normRot) * 2500; + isRecovering = true; + } else if (normRot < this.minLimit) { + recoveryTorque = (this.minLimit - normRot) * 2500; + isRecovering = true; + } + + const totalTorque = torque + recoveryTorque; + const alpha = (totalTorque * 0.05) / this.massScale; + this.angularVelocity += alpha * dt; + + // Friction + this.angularVelocity *= 0.85; + if (isRecovering) { + this.angularVelocity *= 0.6; + } + + // Deadzone + if (Math.abs(this.angularVelocity) < 1) { + this.angularVelocity = 0; + } + + // 3. Update Rotation + let newRot = this.card.rotation + this.angularVelocity * dt; + + // Ratchet clamping + const newNormRot = this.normalizeAngle(newRot); + + if (newNormRot > this.maxLimit) { + // If moving further past max, clamp + if (newNormRot > normRot) { + newRot = this.card.rotation + (this.maxLimit - normRot); + this.angularVelocity = 0; + } + } else if (newNormRot < this.minLimit) { + // If moving further past min, clamp + if (newNormRot < normRot) { + newRot = this.card.rotation + (this.minLimit - normRot); + this.angularVelocity = 0; + } + } + + // 4. Pivot Correction + let correctionX = 0; + let correctionY = 0; + + if (this.dragOffset) { + const oldRad = this.card.rotation * Math.PI / 180; + const oldCos = Math.cos(oldRad); + const oldSin = Math.sin(oldRad); + + const newRad = newRot * Math.PI / 180; + const newCos = Math.cos(newRad); + const newSin = Math.sin(newRad); + + const oldLeverX = this.dragOffset.x * oldCos - this.dragOffset.y * oldSin; + const oldLeverY = this.dragOffset.x * oldSin + this.dragOffset.y * oldCos; + + const newLeverX = this.dragOffset.x * newCos - this.dragOffset.y * newSin; + const newLeverY = this.dragOffset.x * newSin + this.dragOffset.y * newCos; + + correctionX = oldLeverX - newLeverX; + correctionY = oldLeverY - newLeverY; + } + + // Stop Condition + if (!this._isDragging) { + const currentNormRot = this.normalizeAngle(this.card.rotation); + const isOutside = currentNormRot > this.maxLimit || currentNormRot < this.minLimit; + + if (isOutside) { + const targetAngle = currentNormRot > this.maxLimit ? this.maxLimit : this.minLimit; + const dist = Math.abs(this.normalizeAngle(currentNormRot - targetAngle)); + + if (Math.abs(this.angularVelocity) < 0.5 && dist < 0.1) { + this.card.update({ rotation: targetAngle }, { markDirty: true }); + this.angularVelocity = 0; + this.stopPhysicsLoop(); + return; + } + } else { + // Inside range - just stop if slow + if (Math.abs(this.angularVelocity) < 0.5) { + this.angularVelocity = 0; + this.stopPhysicsLoop(); + return; + } + } + } + + // 5. Apply to Leader + const finalX = constrained.x + correctionX; + const finalY = constrained.y + correctionY; + const finalConstrained = this.card.getConstrainedPosition(finalX, finalY); + + this.card.update({ + x: finalConstrained.x, + y: finalConstrained.y, + rotation: newRot + }, { markDirty: true }); + + // 6. Apply to Followers + for (const follower of this.followers) { + // Followers match leader's position exactly (center aligned) + // But we need to account for their own dimensions if we want center-to-center alignment + // The LayoutCard.x/y is top-left. + // Leader Center: finalConstrained.x + leader.width/2, finalConstrained.y + leader.height/2 + + const leaderCenterX = finalConstrained.x + this.card.width / 2; + const leaderCenterY = finalConstrained.y + this.card.height / 2; + + const followerX = leaderCenterX - follower.card.width / 2; + const followerY = leaderCenterY - follower.card.height / 2; + + const followerRot = newRot + follower.offsetRotation; + + follower.card.update({ + x: followerX, + y: followerY, + rotation: followerRot + }, { markDirty: true }); + } + } } diff --git a/frontend/src/desktop/DesktopWorkspace.tsx b/frontend/src/desktop/DesktopWorkspace.tsx index 6c4c0e5..944ab54 100644 --- a/frontend/src/desktop/DesktopWorkspace.tsx +++ b/frontend/src/desktop/DesktopWorkspace.tsx @@ -252,9 +252,14 @@ const DesktopWorkspaceContent: React.FC = ({ const isSelected = selectedDocumentIds.includes(docId); const size = ensureDocumentSize(doc); + // Extract page count + const metadata = doc.current_version?.metadata as { page_count?: number } | undefined; + const pageCount = metadata?.page_count ?? 1; + const layoutCard = layoutStore.initialize(docId, null, { width: Number.isFinite(size.width) && size.width > 0 ? size.width : 200, - height: Number.isFinite(size.height) && size.height > 0 ? size.height : 200 + height: Number.isFinite(size.height) && size.height > 0 ? size.height : 200, + pageCount }); return ( diff --git a/frontend/src/desktop/LayoutSystem.ts b/frontend/src/desktop/LayoutSystem.ts index e7f3b3c..eaa60f5 100644 --- a/frontend/src/desktop/LayoutSystem.ts +++ b/frontend/src/desktop/LayoutSystem.ts @@ -11,6 +11,7 @@ export interface LayoutCardState { rotation: number; width: number; height: number; + pageCount: number; } export class LayoutCard implements LayoutCardState { @@ -21,6 +22,7 @@ export class LayoutCard implements LayoutCardState { rotation: number = 0; width: number = 0; height: number = 0; + pageCount: number = 1; ref: HTMLElement | null = null; private _innerRadius: number = 0; @@ -50,8 +52,6 @@ export class LayoutCard implements LayoutCardState { this.applyTransform(); } - - getConstrainedPosition(x: number, y: number): { x: number, y: number } { const rad = (this.rotation * Math.PI) / 180; const sin = Math.abs(Math.sin(rad)); @@ -90,6 +90,10 @@ export class LayoutCard implements LayoutCardState { this.recalculateCenters(); } + if (changes.pageCount !== undefined) { + this.physics.updateMass(changes.pageCount); + } + if (options.markDirty) { this.isDirty = true; } @@ -240,7 +244,8 @@ export class LayoutCard implements LayoutCardState { z: this.z, rotation: this.rotation, width: this.width, - height: this.height + height: this.height, + pageCount: this.pageCount }; } @@ -278,6 +283,7 @@ export class LayoutStore { initialize(id: string, ref: HTMLElement | null, config: { width: number; height: number; + pageCount: number; }) { let card = this.items.get(id); @@ -309,14 +315,16 @@ export class LayoutStore { rotation, width, height, - z + z, + pageCount: config.pageCount }, ref); } else { // Create card with temporary position card = new LayoutCard(id, this, { width, height, - z: this.zCounter++ + z: this.zCounter++, + pageCount: config.pageCount }, ref); // Calculate initial position using the card instance @@ -333,7 +341,7 @@ export class LayoutStore { this.items.set(id, card); } else { - card.update({ width, height }, { markDirty: false }); + card.update({ width, height, pageCount: config.pageCount }, { markDirty: false }); } // Always update ref and ensure transform is applied diff --git a/frontend/src/styles/workspace/workspace-cards.css b/frontend/src/styles/workspace/workspace-cards.css index ca5bbbc..d1575c5 100644 --- a/frontend/src/styles/workspace/workspace-cards.css +++ b/frontend/src/styles/workspace/workspace-cards.css @@ -16,7 +16,7 @@ } .desk-item--swoop { - transition: transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1); + transition: transform 0.5s cubic-bezier(0.2, 0.8, 0.2, 1); } .desk-item__card img {