diff --git a/frontend/src/desktop/CardDragLogic.ts b/frontend/src/desktop/CardDragLogic.ts index 0d53c96..0f473c9 100644 --- a/frontend/src/desktop/CardDragLogic.ts +++ b/frontend/src/desktop/CardDragLogic.ts @@ -1,6 +1,6 @@ import { LayoutStore } from './LayoutSystem'; -export const handleDragStart = (store: LayoutStore, selection: string[], leadingId: string, offset: { x: number, y: number }) => { +export const handleDragStart = (store: LayoutStore, selection: string[], leadingId: string, offset: { x: number, y: number }, pointerId: number) => { const leadingCard = store.items.get(leadingId); if (!leadingCard) return; @@ -9,6 +9,9 @@ export const handleDragStart = (store: LayoutStore, selection: string[], leading if (id === leadingId) return; 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; + const leadingCenterX = leadingCard.x + leadingCard.width / 2; const leadingCenterY = leadingCard.y + leadingCard.height / 2; @@ -23,6 +26,9 @@ export const handleDragStart = (store: LayoutStore, selection: string[], leading const card = store.items.get(id); if (!card) return; + // If card is already dragging by another pointer, skip it + if (card.isDragging && card.dragPointerId !== pointerId) return; + let myOffset = offset; if (id !== leadingId) { @@ -35,25 +41,45 @@ export const handleDragStart = (store: LayoutStore, selection: string[], leading }; } - card.beginDrag(myOffset); + card.beginDrag(myOffset, pointerId); }); }; -export const handleDragMove = (store: LayoutStore, selection: string[], delta: { x: number, y: number }) => { +export const attachToDragGroup = (store: LayoutStore, selection: string[], leadingId: string, pointerId: number) => { + const leadingCard = store.items.get(leadingId); + if (!leadingCard || !leadingCard.isDragging) return; + selection.forEach(id => { + if (id === leadingId) return; const card = store.items.get(id); - if (card) { + + // If card exists and is not already dragging, attach it + if (card && !card.isDragging) { + const leadingCenterX = leadingCard.x + leadingCard.width / 2; + const leadingCenterY = leadingCard.y + leadingCard.height / 2; + + const targetX = leadingCenterX - card.width / 2; + const targetY = leadingCenterY - card.height / 2; + + card.snapTo(targetX, targetY); + card.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); } - }); + } }; -export const handleDragEnd = (store: LayoutStore, selection: string[]) => { - selection.forEach(id => { - const card = store.items.get(id); - if (card) { +export const handleDragEnd = (store: LayoutStore, _selection: string[], pointerId: number) => { + for (const card of store.items.values()) { + if (card.dragPointerId === pointerId) { card.finishDrag(); } - }); + } store.saveLayout(); }; diff --git a/frontend/src/desktop/DesktopWorkspace.tsx b/frontend/src/desktop/DesktopWorkspace.tsx index a0b53b1..e22d549 100644 --- a/frontend/src/desktop/DesktopWorkspace.tsx +++ b/frontend/src/desktop/DesktopWorkspace.tsx @@ -16,6 +16,7 @@ import '../styles/workspace/workspace-items.css'; import '../styles/workspace/workspace-cards.css'; import { useWorkspaceSelectionContext } from '../app/WorkspaceSelectionContext'; import { createDocumentEntryKey } from '../app/entryKey'; +import { PointerTrackingProvider, usePointerTracking } from './PointerTrackingContext'; import type { Identifier } from '../types/identifiers'; import type { DocumentsListEntry, Document } from '../types/documents'; @@ -77,9 +78,10 @@ const DesktopDocumentContainer: React.FC ); }); + DesktopDocumentContainer.displayName = 'DesktopDocumentContainer'; -const DesktopWorkspace: React.FC = ({ +const DesktopWorkspaceContent: React.FC = ({ entries, ensureAssetUrl, getDocumentAsset, @@ -89,6 +91,7 @@ const DesktopWorkspace: React.FC = ({ tenantId, viewId, }) => { + const { addPointer, removePointer } = usePointerTracking(); const containerRef = useRef(null); const [isLayoutReady, setIsLayoutReady] = useState(false); @@ -219,10 +222,26 @@ const DesktopWorkspace: React.FC = ({ onKeyDown={handleShellKeyDown} onPointerDown={(e) => { if (e.target === e.currentTarget) { + // Register background pointer + addPointer(e.pointerId); + (e.target as Element).setPointerCapture(e.pointerId); + onClearSelection(); focusShell(); } }} + onPointerUp={(e) => { + if (e.target === e.currentTarget) { + removePointer(e.pointerId); + (e.target as Element).releasePointerCapture(e.pointerId); + } + }} + onPointerCancel={(e) => { + if (e.target === e.currentTarget) { + removePointer(e.pointerId); + (e.target as Element).releasePointerCapture(e.pointerId); + } + }} > {isLayoutReady && items.map((doc, index) => { const docId = doc.id ? String(doc.id) : `temp-${index}`; @@ -295,4 +314,12 @@ const DesktopWorkspace: React.FC = ({ ); }; +const DesktopWorkspace: React.FC = (props) => { + return ( + + + + ); +}; + export default DesktopWorkspace; diff --git a/frontend/src/desktop/LayoutSystem.ts b/frontend/src/desktop/LayoutSystem.ts index ded71b7..f3f8971 100644 --- a/frontend/src/desktop/LayoutSystem.ts +++ b/frontend/src/desktop/LayoutSystem.ts @@ -335,7 +335,7 @@ export class LayoutStore { }, ref); // Calculate initial position using the card instance - const { x: initX, y: initY } = getInitialPosition( + const { x: initX, y: initY, rotation: initRotation } = getInitialPosition( this.containerWidth, this.containerHeight, card, @@ -343,7 +343,7 @@ export class LayoutStore { ); // Update card with calculated position - card.update({ x: initX, y: initY }, { markDirty: true }); + card.update({ x: initX, y: initY, rotation: initRotation }, { markDirty: true }); } this.items.set(id, card); diff --git a/frontend/src/desktop/PointerTrackingContext.tsx b/frontend/src/desktop/PointerTrackingContext.tsx new file mode 100644 index 0000000..d3fd6ae --- /dev/null +++ b/frontend/src/desktop/PointerTrackingContext.tsx @@ -0,0 +1,35 @@ +import React, { createContext, useContext, useRef, useCallback } from 'react'; + +interface PointerTrackingContextType { + activePointersRef: React.MutableRefObject>; + addPointer: (id: number, cardId?: string) => void; + removePointer: (id: number) => void; +} + +const PointerTrackingContext = createContext(null); + +export const PointerTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const activePointersRef = useRef(new Map()); + + const addPointer = useCallback((id: number, cardId?: string) => { + activePointersRef.current.set(id, cardId); + }, []); + + const removePointer = useCallback((id: number) => { + activePointersRef.current.delete(id); + }, []); + + return React.createElement( + PointerTrackingContext.Provider, + { value: { activePointersRef, addPointer, removePointer } }, + children + ); +}; + +export const usePointerTracking = () => { + const context = useContext(PointerTrackingContext); + if (!context) { + throw new Error('usePointerTracking must be used within a PointerTrackingProvider'); + } + return context; +}; diff --git a/frontend/src/desktop/useCardPointer.ts b/frontend/src/desktop/useCardPointer.ts index 5922294..e2a67c7 100644 --- a/frontend/src/desktop/useCardPointer.ts +++ b/frontend/src/desktop/useCardPointer.ts @@ -1,4 +1,5 @@ import React, { useCallback, useRef } from 'react'; +import { usePointerTracking } from './PointerTrackingContext'; import { LayoutCard } from './LayoutSystem'; import { handleDragMove, handleDragEnd, handleDragStart } from './CardDragLogic'; @@ -18,6 +19,9 @@ 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 longPressTimer = useRef | null>(null); + + const { activePointersRef, addPointer, removePointer } = usePointerTracking(); const updateState = useCallback((e: React.PointerEvent) => { if (state === 'click' && initialPosition.current) { @@ -38,17 +42,59 @@ export const useCardPointer = ( e.preventDefault(); (e.target as Element).setPointerCapture(e.pointerId); + + // Register pointer with card ID + addPointer(e.pointerId, card.id); + setState('click'); initialPosition.current = { x: e.clientX, y: e.clientY }; lastPosition.current = { x: e.clientX, y: e.clientY }; - }, []); + + // Long press detection for touch devices + if (e.pointerType === 'touch') { + longPressTimer.current = setTimeout(() => { + // Select stack + const stackIds = card.store.getStackBelow(card); + const idsToAdd = new Set(); + + if (!isSelected) idsToAdd.add(card.id); + stackIds.forEach(id => idsToAdd.add(id)); + + // Only select what isn't already selected + const unselectedIdsToAdd = Array.from(idsToAdd).filter(id => !selection.includes(id)); + + if (unselectedIdsToAdd.length > 0) { + onSelect(unselectedIdsToAdd, true); + // Haptic feedback if available + if (navigator.vibrate) { + navigator.vibrate(50); + } + } + }, 500); // 500ms long press + } + }, [card, isSelected, selection, onSelect, addPointer]); const onPointerMove = useCallback((e: React.PointerEvent) => { e.preventDefault(); - const hasModifier = e.metaKey || e.ctrlKey || e.shiftKey; + // Check for multi-touch (more than 1 active pointer implies we should add to selection) + // We check > 1 because the current pointer is already added + const isMultiTouch = activePointersRef.current.size > 1; + const hasModifier = e.metaKey || e.ctrlKey || e.shiftKey || isMultiTouch; + updateState(e); + // Cancel long press if moved + if (state === 'click' && initialPosition.current) { + const dx = e.clientX - initialPosition.current.x; + const dy = e.clientY - initialPosition.current.y; + const distance = Math.sqrt(dx * dx + dy * dy); + if (distance > DRAG_THRESHOLD && longPressTimer.current) { + clearTimeout(longPressTimer.current); + longPressTimer.current = null; + } + } + if (state === 'drag-start') { let effectiveSelection = selection; @@ -82,12 +128,21 @@ export const useCardPointer = ( 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 offset = { x: initialPosition.current.x - centerX, y: initialPosition.current.y - centerY }; - handleDragStart(card.store, effectiveSelection, card.id, offset); + handleDragStart(card.store, effectiveSelection, leadingCardId, offset); // Reset lastPosition to current pointer to avoid jump on first move lastPosition.current = { x: e.clientX, y: e.clientY }; } @@ -104,12 +159,23 @@ export const useCardPointer = ( lastPosition.current = { x: e.clientX, y: e.clientY }; } } - }, [card, state, updateState, isSelected, selection, onSelect]); + }, [card, state, updateState, isSelected, selection, onSelect, activePointersRef]); const onPointerUp = useCallback((e: React.PointerEvent) => { e.preventDefault(); - const hasModifier = e.metaKey || e.ctrlKey || e.shiftKey; + if (longPressTimer.current) { + clearTimeout(longPressTimer.current); + longPressTimer.current = null; + } + + // Check for multi-touch before removing the pointer + const isMultiTouch = activePointersRef.current.size > 1; + const hasModifier = e.metaKey || e.ctrlKey || e.shiftKey || isMultiTouch; + + // Unregister pointer + removePointer(e.pointerId); + updateState(e); if (state === 'drag' || state === 'drag-start') { @@ -136,7 +202,7 @@ export const useCardPointer = ( initialPosition.current = null; lastPosition.current = null; (e.target as Element).releasePointerCapture(e.pointerId); - }, [card, state, isSelected, onSelect, onDeselect, onDocumentActivate, updateState, selection]); + }, [card, state, isSelected, onSelect, onDeselect, onDocumentActivate, updateState, selection, activePointersRef, removePointer]); return { onPointerDown, diff --git a/frontend/src/desktop/utils/layoutUtils.ts b/frontend/src/desktop/utils/layoutUtils.ts index 8dbbc24..d51e4a2 100644 --- a/frontend/src/desktop/utils/layoutUtils.ts +++ b/frontend/src/desktop/utils/layoutUtils.ts @@ -26,10 +26,10 @@ export const getInitialPosition = ( containerHeight: number, card: LayoutCard, _existingCards: LayoutCard[] = [] -): { x: number, y: number } => { +): { x: number, y: number, rotation: number } => { // Mitchell's Best-Candidate Algorithm (Monte Carlo) const K = 20; // Number of candidates to test - let bestCandidate = { x: 0, y: 0 }; + let bestCandidate = { x: 0, y: 0, rotation: 0 }; let bestScore = -Infinity; // Padding to keep cards inside @@ -79,7 +79,7 @@ export const getInitialPosition = ( if (score > bestScore) { bestScore = score; - bestCandidate = { x, y }; + bestCandidate = { x, y, rotation: Math.random() * 10 - 5 }; } } diff --git a/frontend/src/styles/workspace/workspace-cards.css b/frontend/src/styles/workspace/workspace-cards.css index 0a8bac0..ca5bbbc 100644 --- a/frontend/src/styles/workspace/workspace-cards.css +++ b/frontend/src/styles/workspace/workspace-cards.css @@ -71,4 +71,4 @@ overflow-wrap: anywhere; word-break: break-word; white-space: normal; -} +} \ No newline at end of file