From 9bf9550b033ce3796480d3b6be2eb0fe31a22230 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Thu, 27 Nov 2025 17:58:52 +0100 Subject: [PATCH] feat: Refactor tag drag and drop interactions --- frontend/docs/physics model.md | 177 +++++++++++++++++ frontend/src/constants/desktop.ts | 2 - frontend/src/desktop/DesktopDocumentCard.tsx | 30 +-- frontend/src/desktop/DesktopWorkspace.tsx | 31 ++- .../desktop/tags/useDeskTagInteractions.js | 183 +++++++++--------- frontend/src/desktop/useCardPointer.ts | 23 ++- .../src/documents/components/DocumentTags.tsx | 23 ++- .../components/DocumentsGridCard.tsx | 4 +- .../documents/components/DocumentsListRow.tsx | 3 + .../src/documents/components/EntryTags.tsx | 8 +- .../documents/hooks/useDocumentItemLogic.ts | 13 +- .../documents/hooks/useDocumentsPanelProps.ts | 12 +- .../src/documents/panel/DocumentsPanel.tsx | 40 +++- .../hooks/documents/useDocumentsWorkspace.ts | 27 +-- 14 files changed, 380 insertions(+), 196 deletions(-) create mode 100644 frontend/docs/physics model.md diff --git a/frontend/docs/physics model.md b/frontend/docs/physics model.md new file mode 100644 index 0000000..1c13bd8 --- /dev/null +++ b/frontend/docs/physics model.md @@ -0,0 +1,177 @@ +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; + + // 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. \ No newline at end of file diff --git a/frontend/src/constants/desktop.ts b/frontend/src/constants/desktop.ts index b24ec25..dfe35b5 100644 --- a/frontend/src/constants/desktop.ts +++ b/frontend/src/constants/desktop.ts @@ -25,8 +25,6 @@ export const DRAG_HYSTERESIS_PX = 4; export const DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX; export const EDGE_COLLISION_THRESHOLD = 0.5; -export const TAG_REMOVE_DISTANCE = 160; - export const DEBUG_DRAG = false; export const DEBUG_FOCUS = false; diff --git a/frontend/src/desktop/DesktopDocumentCard.tsx b/frontend/src/desktop/DesktopDocumentCard.tsx index be1988a..f49be1c 100644 --- a/frontend/src/desktop/DesktopDocumentCard.tsx +++ b/frontend/src/desktop/DesktopDocumentCard.tsx @@ -4,6 +4,7 @@ import { resolveCorrespondents } from '../documents/correspondents'; import { getTagColorStyle } from '../utils/colors'; import type { DocumentId } from '../types/identifiers'; import type { Document } from '../types/documents'; +import { LayoutCard } from './LayoutSystem'; const preventAll = (event?: React.SyntheticEvent | Event | null) => { if (!event) return; @@ -11,20 +12,11 @@ const preventAll = (event?: React.SyntheticEvent | Event | null) => { if (typeof event.stopPropagation === 'function') event.stopPropagation(); }; -interface PendingRemovalTag { - docId?: string; - tagId?: string; -} - -import { LayoutCard } from './LayoutSystem'; - interface DesktopDocumentCardProps { doc: Document; style?: React.CSSProperties; shouldLoad?: boolean; matchesFilter?: boolean; - tagTargetActive?: boolean; - tagTargetPending?: boolean; selected?: boolean; docTagTokens?: string; ensureAssetUrl?: (...args: any[]) => Promise; @@ -36,11 +28,8 @@ interface DesktopDocumentCardProps { onTagDragOver?: (event: React.DragEvent, docId: DocumentId) => void; onTagDragLeave?: (event: React.DragEvent, docId: DocumentId) => void; onTagDrop?: (event: React.DragEvent, doc: Document) => void; - onDocTagPointerDown?: (event: React.PointerEvent, doc: Document, tag: any) => void; onDocTagDragStart?: (event: React.DragEvent, doc: Document, tag: any) => void; - onDocTagDrag?: (event: React.DragEvent) => void; onDocTagDragEnd?: (event: React.DragEvent) => void; - pendingRemovalTag?: PendingRemovalTag | null; layoutCard?: LayoutCard; } @@ -49,8 +38,6 @@ const DesktopDocumentCard: React.FC = ({ style, shouldLoad, matchesFilter, - tagTargetActive, - tagTargetPending, selected, docTagTokens, ensureAssetUrl, @@ -62,19 +49,14 @@ const DesktopDocumentCard: React.FC = ({ onTagDragOver, onTagDragLeave, onTagDrop, - onDocTagPointerDown, onDocTagDragStart, - onDocTagDrag, onDocTagDragEnd, - pendingRemovalTag, layoutCard, }) => { const correspondents = useMemo(() => resolveCorrespondents(doc), [doc]); const tags = Array.isArray(doc?.tags) ? doc.tags : []; const itemClasses = ['desk-item']; - if (tagTargetActive) itemClasses.push('is-tag-target'); - if (tagTargetPending) itemClasses.push('is-tag-pending'); if (!matchesFilter) itemClasses.push('is-filtered-out'); if (selected) itemClasses.push('is-selected'); @@ -145,14 +127,8 @@ const DesktopDocumentCard: React.FC = ({ {tags.length > 0 && ( diff --git a/frontend/src/documents/components/DocumentsListRow.tsx b/frontend/src/documents/components/DocumentsListRow.tsx index e565812..3539005 100644 --- a/frontend/src/documents/components/DocumentsListRow.tsx +++ b/frontend/src/documents/components/DocumentsListRow.tsx @@ -128,6 +128,9 @@ const DocumentsListRow: React.FC = (props) => { tagLookupById={props.tagLookupById} onTagClick={props.onTagClick} docId={doc.id} + onDocumentTagDetach={props.onDocumentTagDetach} + onTagDragStart={logic.handlers.onTagDragStart} + onTagDragEnd={logic.handlers.onTagDragEnd} /> diff --git a/frontend/src/documents/components/EntryTags.tsx b/frontend/src/documents/components/EntryTags.tsx index 9dcdf97..fa3f589 100644 --- a/frontend/src/documents/components/EntryTags.tsx +++ b/frontend/src/documents/components/EntryTags.tsx @@ -8,7 +8,9 @@ interface EntryTagsProps { tagLookupById?: Map | null; onTagClick?: (tagId: Identifier) => void; docId: Identifier; - maxTags?: number; + onDocumentTagDetach?: (docId: Identifier, tagId: Identifier) => void; + onTagDragStart?: (event: React.DragEvent, tagId: Identifier) => void; + onTagDragEnd?: (event: React.DragEvent) => void; } const EntryTags: React.FC = (props) => { @@ -22,7 +24,9 @@ const EntryTags: React.FC = (props) => { tagLookupById={props.tagLookupById} onTagClick={props.onTagClick} docId={props.docId} - maxTags={props.maxTags} + onDocumentTagDetach={props.onDocumentTagDetach} + onTagDragStart={props.onTagDragStart} + onTagDragEnd={props.onTagDragEnd} /> ); }; diff --git a/frontend/src/documents/hooks/useDocumentItemLogic.ts b/frontend/src/documents/hooks/useDocumentItemLogic.ts index a1d77cf..64bea6d 100644 --- a/frontend/src/documents/hooks/useDocumentItemLogic.ts +++ b/frontend/src/documents/hooks/useDocumentItemLogic.ts @@ -4,6 +4,7 @@ import { createDocumentEntryKey } from '../../app/entryKey'; import type { Document } from '../../types/documents'; import type { DocumentsViewProps } from '../panel/DocumentsPanel'; import type { DocumentViewLogic } from './useDocumentViewLogic'; +import type { Identifier } from '../../types/identifiers'; interface UseDocumentItemLogicProps extends DocumentsViewProps { doc: Document; @@ -18,9 +19,11 @@ export const useDocumentItemLogic = (props: UseDocumentItemLogicProps) => { onDocumentActivate, onDocumentDragStart, onDocumentDragEnd, + onDocumentTagDragStart, + onDocumentTagDragEnd, onDocumentTagDragOver, onDocumentTagDragLeave, - onDocumentTagDrop, + onDocumentTagAttach, onDocumentRename, } = props; @@ -58,14 +61,16 @@ export const useDocumentItemLogic = (props: UseDocumentItemLogicProps) => { onDoubleClick: (event: React.MouseEvent) => onDocumentActivate?.(doc, event), onDragStart: (event: DragEvent) => onDocumentDragStart?.(event, doc), onDragEnd: (event: DragEvent) => onDocumentDragEnd?.(event), - onDragOver: (event: DragEvent) => onDocumentTagDragOver?.(event), + onDragOver: (event: DragEvent) => onDocumentTagDragOver?.(event, doc.id), onDragLeave: onDocumentTagDragLeave, + onTagDragStart: (event: DragEvent, tagId: Identifier) => onDocumentTagDragStart?.(event, doc.id, tagId), + onTagDragEnd: (event: DragEvent) => onDocumentTagDragEnd?.(event), onDrop: (event: DragEvent) => { event.preventDefault(); event.stopPropagation(); const payload = parseTagTransferPayload(event); - if (payload && onDocumentTagDrop) { - onDocumentTagDrop(doc.id, payload); + if (payload && payload.id && onDocumentTagAttach) { + onDocumentTagAttach(doc.id, payload.id); } }, onRenameChange: setDocumentDraft, diff --git a/frontend/src/documents/hooks/useDocumentsPanelProps.ts b/frontend/src/documents/hooks/useDocumentsPanelProps.ts index 483873a..0968db6 100644 --- a/frontend/src/documents/hooks/useDocumentsPanelProps.ts +++ b/frontend/src/documents/hooks/useDocumentsPanelProps.ts @@ -47,7 +47,8 @@ export interface UseDocumentsPanelPropsArgs { activeCorrespondentFilters?: Identifier[]; ensureAssetUrl?: (...args: unknown[]) => void; getDocumentAsset?: (...args: unknown[]) => unknown; - handleDocumentTagDrop?: (...args: unknown[]) => void; + handleDocumentTagAttach?: (docId: Identifier, tagId: Identifier) => void; + handleDocumentTagDetach?: (docId: Identifier, tagId: Identifier) => void; documentsViewMode?: string; documentsSortField?: string; documentsSortDirection?: string; @@ -104,7 +105,8 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => { activeCorrespondentFilters, ensureAssetUrl, getDocumentAsset, - handleDocumentTagDrop, + handleDocumentTagAttach, + handleDocumentTagDetach, documentsViewMode, documentsSortField, documentsSortDirection, @@ -161,7 +163,8 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => { activeCorrespondentIds: activeCorrespondentFilters, ensureAssetUrl, getDocumentAsset, - onDocumentTagDrop: handleDocumentTagDrop, + onDocumentTagAttach: handleDocumentTagAttach, + onDocumentTagDetach: handleDocumentTagDetach, viewMode: documentsViewMode, sortField: documentsSortField, sortDirection: documentsSortDirection, @@ -210,7 +213,8 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => { handleDeleteSelection, handleDocumentDragEnd, handleDocumentDragStart, - handleDocumentTagDrop, + handleDocumentTagAttach, + handleDocumentTagDetach, handleDocumentTitleUpdate, handleDocumentsSortDirectionToggle, handleDocumentsSortFieldChange, diff --git a/frontend/src/documents/panel/DocumentsPanel.tsx b/frontend/src/documents/panel/DocumentsPanel.tsx index e73e8ef..c58dcdd 100644 --- a/frontend/src/documents/panel/DocumentsPanel.tsx +++ b/frontend/src/documents/panel/DocumentsPanel.tsx @@ -59,11 +59,15 @@ export interface DocumentsViewProps { onFolderDragEnd?: (event: DragEvent) => void; onFolderRename?: (folderId: Identifier | 'root', nextName: string) => Promise | boolean; onDocumentOpen?: DocumentEventHandler; + onDocumentActivate?: (doc: Document, event?: unknown) => void; onDocumentDragStart?: (event: DragEvent, document: Document) => void; onDocumentDragEnd?: (event: DragEvent) => void; - onDocumentTagDragOver?: (event: DragEvent) => void; + onDocumentTagDragStart?: (event: DragEvent, docId: Identifier, tagId: Identifier) => void; + onDocumentTagDragEnd?: (event: DragEvent) => void; + onDocumentTagDragOver?: (event: DragEvent, docId: Identifier) => void; onDocumentTagDragLeave?: (event: DragEvent) => void; - onDocumentTagDrop?: (documentId: Identifier, tag: any) => void; + onDocumentTagAttach?: (documentId: Identifier, tagId: Identifier) => void; + onDocumentTagDetach?: (documentId: Identifier, tagId: Identifier) => void; onDocumentRename?: (documentId: Identifier, nextTitle: string) => Promise | boolean; tagLookupById?: Map | null; onTagClick?: (tagId: Identifier) => void; @@ -126,7 +130,8 @@ const DocumentsPanelInner: React.FC = ({ activeTagFilters = [], activeCorrespondentFilters = [], selectedFolder = null, - onDocumentTagDrop, + onDocumentTagAttach, + onDocumentTagDetach, currentTenantId, }): ReactNode => { const { @@ -317,13 +322,32 @@ const DocumentsPanelInner: React.FC = ({ const isTagDragEvent = useCallback((event) => isTagTransferEvent(event), []); + const draggingTagRef = useRef<{ docId: Identifier; tagId: Identifier } | null>(null); + + const handleDocumentTagDragStart = useCallback( + (_event, docId, tagId) => { + draggingTagRef.current = { docId, tagId }; + }, + [], + ); + + const handleDocumentTagDragEnd = useCallback( + (_event) => { + draggingTagRef.current = null; + }, + [], + ); + const handleDocumentTagDragOver = useCallback( - (event) => { + (event, docId) => { if (!isTagDragEvent(event)) { return; } event.preventDefault(); - event.dataTransfer.dropEffect = 'copy'; + + const isSource = draggingTagRef.current?.docId === docId; + event.dataTransfer.dropEffect = isSource ? 'copy' : 'move'; + event.currentTarget.classList.add('tag-drop-target'); }, [isTagDragEvent], @@ -422,13 +446,15 @@ const DocumentsPanelInner: React.FC = ({ onFolderDragStart, onFolderDragEnd, onFolderRename, - onDocumentActivate: handleDocumentActivate, onDocumentDragStart: handleDocumentDragStartLocal, onDocumentDragEnd: handleDocumentDragEndLocal, + onDocumentTagDragStart: handleDocumentTagDragStart, + onDocumentTagDragEnd: handleDocumentTagDragEnd, onDocumentTagDragOver: handleDocumentTagDragOver, onDocumentTagDragLeave: handleDocumentTagDragLeave, - onDocumentTagDrop, + onDocumentTagAttach, + onDocumentTagDetach, onDocumentRename, ensureAssetUrl, getDocumentAsset, diff --git a/frontend/src/hooks/documents/useDocumentsWorkspace.ts b/frontend/src/hooks/documents/useDocumentsWorkspace.ts index c90a6db..2296e29 100644 --- a/frontend/src/hooks/documents/useDocumentsWorkspace.ts +++ b/frontend/src/hooks/documents/useDocumentsWorkspace.ts @@ -937,32 +937,16 @@ const useDocumentsWorkspace = ({ ); const handleDocumentTagDrop = useCallback( - async (documentId, tag) => { - if (!documentId || !tag?.id) { + async (documentId, tagId) => { + if (!documentId || !tagId) { return; } - if (tag.sourceDocId && tag.sourceDocId === documentId) { - return; - } - - const attached = await handleDocumentTagAttach({ documentId, tagId: tag.id, tag }); - if (!attached) { - return; - } - - if (tag.sourceDocId && tag.sourceDocId !== documentId) { - await handleTagRemove(tag.sourceDocId, tag.id, { - refreshTagList: false, - showMessage: false, - }); - } + await handleDocumentTagAttach({ documentId, tagId }); }, - [handleDocumentTagAttach, handleTagRemove], + [handleDocumentTagAttach], ); - - const handlePromptCreateFolder = useCallback(async (parentId?: Identifier | null) => { if (creatingFolder) { return; @@ -1214,7 +1198,8 @@ const useDocumentsWorkspace = ({ activeCorrespondentFilters, ensureAssetUrl, getDocumentAsset, - handleDocumentTagDrop, + handleDocumentTagAttach: handleDocumentTagDrop, + handleDocumentTagDetach: handleTagRemove, documentsViewMode, documentsSortField, documentsSortDirection,