feat: introduce CardPhysics class to encapsulate card drag and physics state from LayoutCard.

This commit is contained in:
2025-11-27 20:37:20 +01:00
parent 9bf9550b03
commit e164a88a97
5 changed files with 110 additions and 217 deletions
+10 -10
View File
@@ -10,7 +10,7 @@ export const handleDragStart = (store: LayoutStore, selection: string[], leading
const card = store.items.get(id);
if (card) {
// If card is already dragging by another pointer, skip it
if (card.isDragging && card.dragPointerId !== pointerId) return;
if (card.physics.isDragging && card.physics.dragPointerId !== pointerId) return;
const leadingCenterX = leadingCard.x + leadingCard.width / 2;
const leadingCenterY = leadingCard.y + leadingCard.height / 2;
@@ -27,7 +27,7 @@ export const handleDragStart = (store: LayoutStore, selection: string[], leading
if (!card) return;
// If card is already dragging by another pointer, skip it
if (card.isDragging && card.dragPointerId !== pointerId) return;
if (card.physics.isDragging && card.physics.dragPointerId !== pointerId) return;
let myOffset = offset;
@@ -41,20 +41,20 @@ export const handleDragStart = (store: LayoutStore, selection: string[], leading
};
}
card.beginDrag(myOffset, pointerId);
card.physics.beginDrag(myOffset, pointerId);
});
};
export const attachToDragGroup = (store: LayoutStore, selection: string[], leadingId: string, pointerId: number) => {
const leadingCard = store.items.get(leadingId);
if (!leadingCard || !leadingCard.isDragging) return;
if (!leadingCard || !leadingCard.physics.isDragging) return;
selection.forEach(id => {
if (id === leadingId) return;
const card = store.items.get(id);
// If card exists and is not already dragging, attach it
if (card && !card.isDragging) {
if (card && !card.physics.isDragging) {
const leadingCenterX = leadingCard.x + leadingCard.width / 2;
const leadingCenterY = leadingCard.y + leadingCard.height / 2;
@@ -62,23 +62,23 @@ export const attachToDragGroup = (store: LayoutStore, selection: string[], leadi
const targetY = leadingCenterY - card.height / 2;
card.snapTo(targetX, targetY);
card.beginDrag({ x: 0, y: 0 }, pointerId);
card.physics.beginDrag({ x: 0, y: 0 }, pointerId);
}
});
};
export const handleDragMove = (store: LayoutStore, _selection: string[], delta: { x: number, y: number }, pointerId: number) => {
for (const card of store.items.values()) {
if (card.isDragging && card.dragPointerId === pointerId) {
card.continueDrag(delta);
if (card.physics.isDragging && card.physics.dragPointerId === pointerId) {
card.physics.continueDrag(delta);
}
}
};
export const handleDragEnd = (store: LayoutStore, _selection: string[], pointerId: number) => {
for (const card of store.items.values()) {
if (card.dragPointerId === pointerId) {
card.finishDrag();
if (card.physics.dragPointerId === pointerId) {
card.physics.finishDrag();
}
}
store.saveLayout();
+92
View File
@@ -0,0 +1,92 @@
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 };
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;
}
beginDrag(offset: { x: number, y: number }, pointerId: number) {
this._isDragging = true;
this.dragPointerId = pointerId;
this.startPhysicsLoop();
}
continueDrag(delta: { x: number, y: number }) {
this.pendingDelta.x += delta.x;
this.pendingDelta.y += delta.y;
}
finishDrag() {
this._isDragging = false;
this.dragPointerId = null;
// Loop continues for decay/settling if needed
}
private startPhysicsLoop() {
if (this.physicsRafId) return;
this.lastTickTime = performance.now();
this.physicsRafId = requestAnimationFrame(this.physicsTick);
}
private stopPhysicsLoop() {
if (this.physicsRafId) {
cancelAnimationFrame(this.physicsRafId);
this.physicsRafId = null;
}
}
private physicsTick = (time: number) => {
const dt = Math.min((time - this.lastTickTime) / 1000, 0.1);
this.lastTickTime = time;
if (this._isDragging) {
// Minimal Physics: Apply pending delta directly
const dx = this.pendingDelta.x;
const dy = this.pendingDelta.y;
// Update velocity (simple instantaneous)
if (dt > 0.001) {
this.velocity.x = dx / dt;
this.velocity.y = dy / dt;
}
// Reset pending delta
this.pendingDelta = { x: 0, y: 0 };
// Update position
const newX = this.card.x + dx;
const newY = this.card.y + dy;
// Apply Constraints
const constrained = this.card.getConstrainedPosition(newX, newY);
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;
}
// Request next frame
this.physicsRafId = requestAnimationFrame(this.physicsTick);
};
}
+7 -29
View File
@@ -1,5 +1,7 @@
import { constrainDimensions, getInitialPosition, CONTAINER_PADDING } from './utils/layoutUtils';
import { fetchLayoutRecords, upsertLayoutRecords } from './db';
import { CardPhysics } from './CardPhysics';
export interface LayoutCardState {
id: string;
@@ -26,18 +28,16 @@ export class LayoutCard implements LayoutCardState {
private _centerX: number = 0;
private _centerY: number = 0;
public store: LayoutStore;
public dragStartX: number = 0;
public dragStartY: number = 0;
public isDirty: boolean = false;
public physics: CardPhysics;
public intendedX: number = 0;
public intendedY: number = 0;
private dragOffset: { x: number, y: number } | null = null;
public dragPointerId: number | null = null;
public isDirty: boolean = false;
constructor(id: string, store: LayoutStore, initialData: Partial<LayoutCardState> = {}, ref: HTMLElement | null = null) {
this.id = id;
this.store = store;
Object.assign(this, initialData);
this.physics = new CardPhysics(this);
this.intendedX = this.x;
this.intendedY = this.y;
this.ref = ref;
@@ -50,12 +50,7 @@ export class LayoutCard implements LayoutCardState {
this.applyTransform();
}
beginDrag(offset: { x: number, y: number }, pointerId: number) {
this.dragOffset = offset;
this.dragStartX = this.x;
this.dragStartY = this.y;
this.dragPointerId = pointerId;
}
getConstrainedPosition(x: number, y: number): { x: number, y: number } {
const rad = (this.rotation * Math.PI) / 180;
@@ -77,24 +72,7 @@ export class LayoutCard implements LayoutCardState {
return { x: newX, y: newY };
}
continueDrag(delta: { x: number, y: number }) {
if (!this.dragOffset) return;
const targetX = this.x + delta.x;
const targetY = this.y + delta.y;
const { x: newX, y: newY } = this.getConstrainedPosition(targetX, targetY);
this.update({
x: newX,
y: newY
}, { markDirty: true });
}
finishDrag() {
this.dragOffset = null;
this.dragPointerId = null;
}
update(changes: Partial<LayoutCardState>, options: { markDirty?: boolean, isConstraintUpdate?: boolean } = {}) {
Object.assign(this, changes);
@@ -283,7 +261,7 @@ export class LayoutCard implements LayoutCardState {
}
get isDragging(): boolean {
return !!this.dragOffset;
return this.physics.isDragging;
}
}
+1 -1
View File
@@ -87,7 +87,7 @@ export const useCardPointer = (
const c = card.store.items.get(cId);
if (c && c.isDragging) {
targetLeaderId = cId;
targetPointerId = c.dragPointerId; // Use the pointer driving that card
targetPointerId = c.physics.dragPointerId; // Use the pointer driving that card
break; // Attach to the first found drag group
}
}