177 lines
5.9 KiB
Markdown
177 lines
5.9 KiB
Markdown
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. |