feat: Implement enhanced card physics with rotational dynamics, mass scaling, stacking behavior, and drag torque.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,9 +252,14 @@ const DesktopWorkspaceContent: React.FC<DesktopWorkspaceProps> = ({
|
||||
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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user