From 844e0ed355e18ecb7c598dcda256f5118622bf7d Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Wed, 26 Nov 2025 22:42:28 +0100 Subject: [PATCH] feat: Implement multi-touch drag and long-press stack selection with pointer tracking and physics model documentation. --- frontend/src/desktop/LayoutSystem.ts | 9 +++- frontend/src/desktop/useCardPointer.ts | 64 ++++++++++++++++++++------ 2 files changed, 59 insertions(+), 14 deletions(-) diff --git a/frontend/src/desktop/LayoutSystem.ts b/frontend/src/desktop/LayoutSystem.ts index f3f8971..2835a77 100644 --- a/frontend/src/desktop/LayoutSystem.ts +++ b/frontend/src/desktop/LayoutSystem.ts @@ -32,6 +32,7 @@ export class LayoutCard implements LayoutCardState { public intendedX: number = 0; public intendedY: number = 0; private dragOffset: { x: number, y: number } | null = null; + public dragPointerId: number | null = null; constructor(id: string, store: LayoutStore, initialData: Partial = {}, ref: HTMLElement | null = null) { this.id = id; @@ -49,10 +50,11 @@ export class LayoutCard implements LayoutCardState { this.applyTransform(); } - beginDrag(offset: { x: number, y: number }) { + 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 } { @@ -91,6 +93,7 @@ export class LayoutCard implements LayoutCardState { finishDrag() { this.dragOffset = null; + this.dragPointerId = null; } update(changes: Partial, options: { markDirty?: boolean, isConstraintUpdate?: boolean } = {}) { @@ -278,6 +281,10 @@ export class LayoutCard implements LayoutCardState { get centerY(): number { return this._centerY; } + + get isDragging(): boolean { + return !!this.dragOffset; + } } export class LayoutStore { diff --git a/frontend/src/desktop/useCardPointer.ts b/frontend/src/desktop/useCardPointer.ts index e2a67c7..390513d 100644 --- a/frontend/src/desktop/useCardPointer.ts +++ b/frontend/src/desktop/useCardPointer.ts @@ -2,7 +2,7 @@ import React, { useCallback, useRef } from 'react'; import { usePointerTracking } from './PointerTrackingContext'; import { LayoutCard } from './LayoutSystem'; -import { handleDragMove, handleDragEnd, handleDragStart } from './CardDragLogic'; +import { handleDragMove, handleDragEnd, handleDragStart, attachToDragGroup } from './CardDragLogic'; const DRAG_THRESHOLD = 3; @@ -21,6 +21,7 @@ export const useCardPointer = ( const lastPosition = useRef<{ x: number, y: number } | null>(null); const longPressTimer = useRef | null>(null); + const { activePointersRef, addPointer, removePointer } = usePointerTracking(); const updateState = useCallback((e: React.PointerEvent) => { @@ -65,6 +66,30 @@ export const useCardPointer = ( if (unselectedIdsToAdd.length > 0) { onSelect(unselectedIdsToAdd, true); + + const isMultiTouch = activePointersRef.current.size > 1; + if (isMultiTouch) { + // Find an existing drag group to attach to + let targetLeaderId: string | null = null; + let targetPointerId: number | null = null; + + for (const [ptrId, cId] of activePointersRef.current.entries()) { + if (ptrId === e.pointerId) continue; // Skip self + if (cId) { + const c = card.store.items.get(cId); + if (c && c.isDragging) { + targetLeaderId = cId; + targetPointerId = c.dragPointerId; // Use the pointer driving that card + break; // Attach to the first found drag group + } + } + } + + if (targetLeaderId && targetPointerId !== null) { + attachToDragGroup(card.store, unselectedIdsToAdd, targetLeaderId, targetPointerId); + } + } + // Haptic feedback if available if (navigator.vibrate) { navigator.vibrate(50); @@ -72,7 +97,7 @@ export const useCardPointer = ( } }, 500); // 500ms long press } - }, [card, isSelected, selection, onSelect, addPointer]); + }, [card, isSelected, selection, onSelect, addPointer, activePointersRef]); const onPointerMove = useCallback((e: React.PointerEvent) => { e.preventDefault(); @@ -124,25 +149,20 @@ export const useCardPointer = ( setState('drag'); + // Start the drag for THIS pointer if (initialPosition.current) { const rect = card.ref.getBoundingClientRect(); const centerX = rect.left + rect.width / 2; const centerY = rect.top + rect.height / 2; - // Determine leading card (anchor) based on oldest pointer - // The Map iterates in insertion order, so the first entry is the oldest - const oldestEntry = activePointersRef.current.entries().next().value; - const oldestCardId = oldestEntry ? oldestEntry[1] : card.id; - - // Use the oldest card ID if available, otherwise current card - const leadingCardId = oldestCardId || card.id; + const leadingCardId = card.id; const offset = { x: initialPosition.current.x - centerX, y: initialPosition.current.y - centerY }; - handleDragStart(card.store, effectiveSelection, leadingCardId, offset); + handleDragStart(card.store, effectiveSelection, leadingCardId, offset, e.pointerId); // Reset lastPosition to current pointer to avoid jump on first move lastPosition.current = { x: e.clientX, y: e.clientY }; } @@ -155,7 +175,7 @@ export const useCardPointer = ( }; if (delta.x !== 0 || delta.y !== 0) { - handleDragMove(card.store, selection, delta); + handleDragMove(card.store, selection, delta, e.pointerId); lastPosition.current = { x: e.clientX, y: e.clientY }; } } @@ -179,7 +199,7 @@ export const useCardPointer = ( updateState(e); if (state === 'drag' || state === 'drag-start') { - handleDragEnd(card.store, selection); + handleDragEnd(card.store, selection, e.pointerId); } else if (state === 'click') { if (isSelected) { const isUnobstructed = card.isUnobstructed(); @@ -204,9 +224,27 @@ export const useCardPointer = ( (e.target as Element).releasePointerCapture(e.pointerId); }, [card, state, isSelected, onSelect, onDeselect, onDocumentActivate, updateState, selection, activePointersRef, removePointer]); + const onPointerCancel = useCallback((e: React.PointerEvent) => { + e.preventDefault(); + if (longPressTimer.current) { + clearTimeout(longPressTimer.current); + longPressTimer.current = null; + } + + // Ensure we clean of any drags associated with this pointer + handleDragEnd(card.store, selection, e.pointerId); + + removePointer(e.pointerId); + setState('idle'); + initialPosition.current = null; + lastPosition.current = null; + (e.target as Element).releasePointerCapture(e.pointerId); + }, [card, selection, removePointer]); + return { onPointerDown, onPointerMove, - onPointerUp + onPointerUp, + onPointerCancel }; };