feat: introduce CardPhysics class to encapsulate card drag and physics state from LayoutCard.
This commit is contained in:
@@ -1,177 +0,0 @@
|
||||
Design Document: Spatial Workspace Architecture Refactor
|
||||
Status: Draft Target System: Desk/Workspace (Canvas, Dragging, Physics) Primary Goal: Decompose "God Objects" into a composable, layered architecture to improve performance, maintainability, and testability.
|
||||
|
||||
1. Executive Summary
|
||||
The current implementation relies on a monolithic class (WorkspaceEngine) and an overloaded hook (useDeskPointer). This coupling forces React to handle high-frequency logic (physics/drag), resulting in brittle code and potential performance bottlenecks.
|
||||
|
||||
The Proposal: Transition to a Layered Architecture. We will separate "Pure Math" (Physics/Geometry), "Mutable State" (Performance), and "React Interaction" (Events/Business Logic).
|
||||
|
||||
2. Architectural Overview
|
||||
We will adopt a unidirectional, event-driven flow for interactions, bypassing React's render cycle for high-frequency updates (dragging/animating), while using React for low-frequency updates (selection/mounting).
|
||||
|
||||
The Four Layers
|
||||
|
||||
The Physics Layer (Core): Stateless, pure functions for geometry and kinetics.
|
||||
|
||||
The Scene Layer (Store): A lightweight, mutable registry that holds the "truth" of layout (x, y, rotation) and manages direct DOM updates.
|
||||
|
||||
The Interaction Layer (Hooks): React hooks that bind DOM events to the Scene Layer.
|
||||
|
||||
The Persistence Layer: An asynchronous observer that syncs the Scene Layer to the Backend/DB.
|
||||
|
||||
Code-Snippet
|
||||
graph TD
|
||||
User[User Input] -->|Pointer Events| Interaction[Interaction Layer Hooks]
|
||||
Interaction -->|Calculate| Physics[Physics Layer Pure Math]
|
||||
Interaction -->|Update| Scene[Scene Layer Mutable Store]
|
||||
Scene -->|Direct Manipulation| DOM[DOM Elements 60fps]
|
||||
Scene -.->|Debounced Snapshot| DB[Persistence Layer]
|
||||
3. Detailed Component Design
|
||||
Layer 1: Physics & Geometry (lib/spatial)
|
||||
|
||||
Responsibility: Pure math. No side effects. No DOM references.
|
||||
|
||||
Key Modules:
|
||||
|
||||
geometry.ts: Hit testing, polygon intersection, coordinate projection (Screen <-> Canvas).
|
||||
|
||||
kinetics.ts: Inertia decay, angular velocity calculation, clamping.
|
||||
|
||||
Benefit: 100% Unit testable without mocking the DOM.
|
||||
|
||||
Layer 2: The Scene Store (lib/scene)
|
||||
|
||||
Responsibility: High-performance state management. It acts as the bridge between React and the DOM.
|
||||
|
||||
Structure:
|
||||
|
||||
TypeScript
|
||||
class SceneStore {
|
||||
// Fast lookups
|
||||
items: Map<string, SceneItem>;
|
||||
|
||||
// Updates DOM style immediately, skips React render
|
||||
updateItem(id, transform) { ... }
|
||||
|
||||
// Used by Persistence Layer
|
||||
getSnapshot() { ... }
|
||||
}
|
||||
Why: React State is too slow for 60fps drag interactions on complex DOM trees. We need direct manipulation.
|
||||
|
||||
Layer 3: Interaction Hooks (hooks/)
|
||||
|
||||
We split the "God Hook" (useDeskPointer) into specific responsibilities.
|
||||
|
||||
usePointerGesture:
|
||||
|
||||
Role: The "driver." Handles down, move, up, cancel.
|
||||
|
||||
Logic: Manages drag thresholds, long-press timers, and distinguishing taps from drags.
|
||||
|
||||
Output: Emits high-level events: onTap, onDragStart, onDrag, onDragEnd.
|
||||
|
||||
useSpatialQuery:
|
||||
|
||||
Role: The "eyes."
|
||||
|
||||
Logic: Wraps lib/spatial. Given an event (x, y), returns [DocID, StackInfo].
|
||||
|
||||
useDragController:
|
||||
|
||||
Role: The "business logic."
|
||||
|
||||
Logic: Listens to usePointerGesture. When a drag starts:
|
||||
|
||||
Locks the React View (prevents re-renders).
|
||||
|
||||
Calculates physics via lib/spatial.
|
||||
|
||||
Pushes updates to SceneStore.
|
||||
|
||||
On release, triggers inertia animation loop.
|
||||
|
||||
Layer 4: Persistence (Observer)
|
||||
|
||||
Role: Syncs the mutable SceneStore back to the database.
|
||||
|
||||
Mechanism:
|
||||
|
||||
Subscribes to onDragEnd or an internal dirty flag in the Store.
|
||||
|
||||
Uses a debounce strategy (e.g., wait 500ms after last movement) to save to the backend.
|
||||
|
||||
4. Data Flow Scenarios
|
||||
Scenario A: Selecting a Card
|
||||
|
||||
User: Clicks on a card.
|
||||
|
||||
usePointerGesture: Detects pointerDown + pointerUp (no movement). Fires onTap.
|
||||
|
||||
useDeskSelection: Receives onTap. Checks event.metaKey. Updates React State (setSelectedIds).
|
||||
|
||||
React: Re-renders to show selection border.
|
||||
|
||||
Scenario B: Dragging a Card (The Performance Path)
|
||||
|
||||
User: Presses and moves mouse > 5px.
|
||||
|
||||
usePointerGesture: Fires onDragStart.
|
||||
|
||||
useDragController:
|
||||
|
||||
Calculates initialOffsets.
|
||||
|
||||
While moving:
|
||||
|
||||
Calculates new x, y, rotation (using Physics Layer).
|
||||
|
||||
Calls SceneStore.updateItem().
|
||||
|
||||
Result: The DOM element moves via CSS Transform. React does not re-render.
|
||||
|
||||
User: Releases mouse.
|
||||
|
||||
useDragController: Fires onDragEnd. Starts Inertia Animation loop (updating SceneStore via requestAnimationFrame).
|
||||
|
||||
Persistence: Detects end of movement, saves new coordinates.
|
||||
|
||||
5. Migration Strategy
|
||||
We will apply the Strangler Fig Pattern: replacing pieces of the monolith gradually.
|
||||
|
||||
Phase 1: Math Extraction (Safe)
|
||||
|
||||
Extract geometry/physics logic from WorkspaceEngine and pointerUtils into pure functions in lib/spatial.
|
||||
|
||||
Risk: Low.
|
||||
|
||||
Phase 2: The Gesture Hook (Cleanup)
|
||||
|
||||
Implement usePointerGesture. Replace the event listeners in useDeskPointer with this hook.
|
||||
|
||||
Risk: Low.
|
||||
|
||||
Phase 3: The Scene Store (Core Replacement)
|
||||
|
||||
Build SceneStore.
|
||||
|
||||
Modify useDocumentDrag to write to SceneStore instead of WorkspaceEngine.
|
||||
|
||||
Risk: Medium. Visual synchronization bugs might occur during transition.
|
||||
|
||||
Phase 4: Persistence Decoupling
|
||||
|
||||
Move loadPersistedLayout and upsertLayoutRecords out of the Engine and into a specialized React Effect or standard async function triggered by the Store.
|
||||
|
||||
6. Comparison: Old vs. New
|
||||
Feature Old Architecture New Architecture
|
||||
State Monolithic Class (WorkspaceEngine) Mutable Store (SceneStore) + React State
|
||||
Dragging Mixed into Engine & Hooks Isolated Controller Hook
|
||||
Physics Hardcoded in Engine Pure Functional Module
|
||||
DOM Access Cached Refs inside Engine Direct management via Store
|
||||
Testing Difficult (Mocking Engine required) Easy (Test Physics/Store in isolation)
|
||||
7. Open Questions / Risks
|
||||
Z-Index Management: Currently handled by zCounter in the Engine. The SceneStore must maintain a global Z-index counter to ensure "Bring to Front" works reliably.
|
||||
|
||||
** React Context vs. Global Singleton:** Should SceneStore be a global singleton or provided via Context?
|
||||
|
||||
Decision: Context. This allows multiple independent Workspaces on one screen if needed in the future.
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user