From 155d8c5c4ab33533281bd44ea63ae6174827d037 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Wed, 26 Nov 2025 17:00:56 +0100 Subject: [PATCH] feat: Add spatial workspace architecture design document and refactor card drag logic to use new `LayoutCard` drag state management. --- frontend/src/desktop/CardDragLogic.ts | 39 ++++++++++++++++------- frontend/src/desktop/LayoutSystem.ts | 20 +++++++++++- frontend/src/desktop/useCardPointer.ts | 44 ++++++++++++++++++++------ 3 files changed, 80 insertions(+), 23 deletions(-) diff --git a/frontend/src/desktop/CardDragLogic.ts b/frontend/src/desktop/CardDragLogic.ts index 8f7b651..c393ce7 100644 --- a/frontend/src/desktop/CardDragLogic.ts +++ b/frontend/src/desktop/CardDragLogic.ts @@ -1,24 +1,39 @@ import { LayoutStore } from './LayoutSystem'; -const capturedCards = new Set(); +export const handleDragStart = (store: LayoutStore, selection: string[], leadingId: string, offset: { x: number, y: number }) => { + const leadingCard = store.items.get(leadingId); + if (!leadingCard) return; + + selection.forEach(id => { + const card = store.items.get(id); + if (!card) return; + + let myOffset = offset; + if (id !== leadingId) { + myOffset = { + x: offset.x + (leadingCard.x - card.x), + y: offset.y + (leadingCard.y - card.y) + }; + } + + card.beginDrag(myOffset); + }); +}; export const handleDragMove = (store: LayoutStore, selection: string[], delta: { x: number, y: number }) => { selection.forEach(id => { const card = store.items.get(id); if (card) { - if (!capturedCards.has(id)) { - card.captureDragStart(); - capturedCards.add(id); - } - - card.update({ - x: card.dragStartX + delta.x, - y: card.dragStartY + delta.y - }); + card.continueDrag(delta); } }); }; -export const handleDragEnd = () => { - capturedCards.clear(); +export const handleDragEnd = (store: LayoutStore, selection: string[]) => { + selection.forEach(id => { + const card = store.items.get(id); + if (card) { + card.finishDrag(); + } + }); }; diff --git a/frontend/src/desktop/LayoutSystem.ts b/frontend/src/desktop/LayoutSystem.ts index 8cc2e02..c2a8bf1 100644 --- a/frontend/src/desktop/LayoutSystem.ts +++ b/frontend/src/desktop/LayoutSystem.ts @@ -25,6 +25,7 @@ export class LayoutCard implements LayoutCardState { public store: LayoutStore; public dragStartX: number = 0; public dragStartY: number = 0; + private dragOffset: { x: number, y: number } | null = null; constructor(id: string, store: LayoutStore, initialData: Partial = {}, ref: HTMLElement | null = null) { this.id = id; @@ -39,11 +40,28 @@ export class LayoutCard implements LayoutCardState { this.applyTransform(); } - captureDragStart() { + beginDrag(offset: { x: number, y: number }) { + this.dragOffset = offset; this.dragStartX = this.x; this.dragStartY = this.y; } + continueDrag(delta: { x: number, y: number }) { + if (!this.dragOffset) return; + + const newX = this.x + delta.x; + const newY = this.y + delta.y; + + this.update({ + x: newX, + y: newY + }); + } + + finishDrag() { + this.dragOffset = null; + } + update(changes: Partial) { Object.assign(this, changes); if (changes.width !== undefined || changes.height !== undefined) { diff --git a/frontend/src/desktop/useCardPointer.ts b/frontend/src/desktop/useCardPointer.ts index c0c8e84..73cdecf 100644 --- a/frontend/src/desktop/useCardPointer.ts +++ b/frontend/src/desktop/useCardPointer.ts @@ -1,7 +1,7 @@ import React, { useCallback, useRef } from 'react'; import { LayoutCard } from './LayoutSystem'; -import { handleDragMove, handleDragEnd } from './CardDragLogic'; +import { handleDragMove, handleDragEnd, handleDragStart } from './CardDragLogic'; const DRAG_THRESHOLD = 3; @@ -17,6 +17,7 @@ export const useCardPointer = ( ) => { const [state, setState] = React.useState('idle'); const initialPosition = useRef<{ x: number, y: number } | null>(null); + const lastPosition = useRef<{ x: number, y: number } | null>(null); const updateState = useCallback((e: React.PointerEvent) => { if (state === 'click' && initialPosition.current) { @@ -37,6 +38,7 @@ export const useCardPointer = ( (e.target as Element).setPointerCapture(e.pointerId); setState('click'); initialPosition.current = { x: e.clientX, y: e.clientY }; + lastPosition.current = { x: e.clientX, y: e.clientY }; }, []); const onPointerMove = useCallback((e: React.PointerEvent) => { @@ -44,24 +46,45 @@ export const useCardPointer = ( updateState(e); if (state === 'drag-start') { + let effectiveSelection = selection; if (!isSelected) { onSelect([card.id], hasModifier); - - if (!hasModifier) { - card.bringToFront(); + if (hasModifier) { + effectiveSelection = [...selection, card.id]; + } else { + effectiveSelection = [card.id]; } } + card.bringToFront(); + setState('drag'); + + if (initialPosition.current) { + const rect = card.ref.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + const offset = { + x: initialPosition.current.x - centerX, + y: initialPosition.current.y - centerY + }; + + handleDragStart(card.store, effectiveSelection, card.id, offset); + // Reset lastPosition to current pointer to avoid jump on first move + lastPosition.current = { x: e.clientX, y: e.clientY }; + } } - if (state === 'drag' && initialPosition.current) { + if (state === 'drag' && lastPosition.current) { const delta = { - x: e.clientX - initialPosition.current.x, - y: e.clientY - initialPosition.current.y + x: e.clientX - lastPosition.current.x, + y: e.clientY - lastPosition.current.y }; - handleDragMove(card.store, selection, delta); + if (delta.x !== 0 || delta.y !== 0) { + handleDragMove(card.store, selection, delta); + lastPosition.current = { x: e.clientX, y: e.clientY }; + } } }, [card, state, updateState, isSelected, selection, onSelect]); @@ -70,7 +93,7 @@ export const useCardPointer = ( updateState(e); if (state === 'drag' || state === 'drag-start') { - handleDragEnd(); + handleDragEnd(card.store, selection); } else if (state === 'click') { if (isSelected) { const isUnobstructed = card.isUnobstructed(); @@ -91,8 +114,9 @@ export const useCardPointer = ( setState('idle'); initialPosition.current = null; + lastPosition.current = null; (e.target as Element).releasePointerCapture(e.pointerId); - }, [card, state, isSelected, onSelect, onDeselect, onDocumentActivate, updateState]); + }, [card, state, isSelected, onSelect, onDeselect, onDocumentActivate, updateState, selection]); return { onPointerDown,