diff --git a/frontend/src/desktop/CardDragLogic.ts b/frontend/src/desktop/CardDragLogic.ts new file mode 100644 index 0000000..8f7b651 --- /dev/null +++ b/frontend/src/desktop/CardDragLogic.ts @@ -0,0 +1,24 @@ +import { LayoutStore } from './LayoutSystem'; + +const capturedCards = new Set(); + +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 + }); + } + }); +}; + +export const handleDragEnd = () => { + capturedCards.clear(); +}; diff --git a/frontend/src/desktop/DesktopWorkspace.tsx b/frontend/src/desktop/DesktopWorkspace.tsx index 24d07a9..d250807 100644 --- a/frontend/src/desktop/DesktopWorkspace.tsx +++ b/frontend/src/desktop/DesktopWorkspace.tsx @@ -15,6 +15,7 @@ import '../styles/workspace/workspace-layout.css'; import '../styles/workspace/workspace-items.css'; import '../styles/workspace/workspace-cards.css'; import { useWorkspaceSelectionContext } from '../app/WorkspaceSelectionContext'; +import { createDocumentEntryKey } from '../app/entryKey'; import type { Identifier } from '../types/identifiers'; import type { DocumentsListEntry, Document } from '../types/documents'; @@ -56,10 +57,16 @@ export interface DesktopWorkspaceProps { } // Wrapper to handle hooks per card -const DesktopDocumentContainer: React.FC> = React.memo((props) => { - const { layoutCard, selected } = props; +const DesktopDocumentContainer: React.FC & { + onSelect: (ids: string[], extend?: boolean) => void; + onDeselect: (ids: string[]) => void; + onDocumentActivate?: (id: string) => void; + selection: string[]; +}> = React.memo((props) => { + const { layoutCard, selected, onSelect, onDeselect, onDocumentActivate, selection } = props; + // We assume layoutCard is always present in this context - const cardPointerHandlers = useCardPointer(layoutCard!, !!selected); + const cardPointerHandlers = useCardPointer(layoutCard!, !!selected, selection, onSelect, onDeselect, onDocumentActivate); return ( = ({ // Selection Context const { - selectedDocumentIds: contextSelectedIds, - setSelectedDocumentIds, + selectedDocumentIds, + setSelectedEntries, clearSelection, } = useWorkspaceSelectionContext(); - const [localSelectedIds, setLocalSelectedIds] = useState([]); - const selectedDocumentIds = contextSelectedIds || localSelectedIds; - const handleSelectionChange = useCallback((ids: Identifier[]) => { - if (setSelectedDocumentIds) { - setSelectedDocumentIds(ids); - } else { - setLocalSelectedIds(ids); + if (setSelectedEntries) { + const keys = ids.map(id => createDocumentEntryKey(id)); + setSelectedEntries(keys); } onSelectionChange?.(ids); - }, [setSelectedDocumentIds, onSelectionChange]); + }, [setSelectedEntries, onSelectionChange]); const onClearSelection = useCallback(() => { clearSelection ? clearSelection() : handleSelectionChange([]); @@ -207,7 +210,7 @@ const DesktopWorkspace: React.FC = ({ ensureAssetUrl={ensureAssetUrl} getDocumentAsset={getDocumentAsset} handleNavigatorSnapshot={() => { }} - onDocumentActivate={onDocumentActivate} + onDocumentActivate={(id) => { onDocumentActivate?.({ id } as DeskDocument) }} layoutCard={layoutCard} onTagDragEnter={tagInteractions.handleTagDragEnterDoc} onTagDragOver={tagInteractions.handleTagDragOverDoc} @@ -220,6 +223,21 @@ const DesktopWorkspace: React.FC = ({ tagTargetActive={tagDropTargetId === docId} tagTargetPending={pendingTagDocId === docId} pendingRemovalTag={pendingRemovalTag} + onSelect={(ids, extend = false) => { + if (!extend) { + handleSelectionChange(ids); + } else { + const newSelection = new Set(selectedDocumentIds); + ids.forEach(id => newSelection.add(id)); + handleSelectionChange(Array.from(newSelection)); + } + }} + onDeselect={(ids) => { + const newSelection = new Set(selectedDocumentIds); + ids.forEach(id => newSelection.delete(id)); + handleSelectionChange(Array.from(newSelection)); + }} + selection={selectedDocumentIds} /> ); })} diff --git a/frontend/src/desktop/LayoutSystem.ts b/frontend/src/desktop/LayoutSystem.ts index a42ae1e..8cc2e02 100644 --- a/frontend/src/desktop/LayoutSystem.ts +++ b/frontend/src/desktop/LayoutSystem.ts @@ -22,9 +22,13 @@ export class LayoutCard implements LayoutCardState { private _innerRadius: number = 0; private _outerRadius: number = 0; + public store: LayoutStore; + public dragStartX: number = 0; + public dragStartY: number = 0; - constructor(id: string, initialData: Partial = {}, ref: HTMLElement | null = null) { + constructor(id: string, store: LayoutStore, initialData: Partial = {}, ref: HTMLElement | null = null) { this.id = id; + this.store = store; Object.assign(this, initialData); this.ref = ref; this.recalculateRadii(); @@ -35,6 +39,11 @@ export class LayoutCard implements LayoutCardState { this.applyTransform(); } + captureDragStart() { + this.dragStartX = this.x; + this.dragStartY = this.y; + } + update(changes: Partial) { Object.assign(this, changes); if (changes.width !== undefined || changes.height !== undefined) { @@ -43,19 +52,113 @@ export class LayoutCard implements LayoutCardState { this.applyTransform(); } + isUnobstructed(): boolean { + for (const other of this.store.items.values()) { + if (other.id === this.id) continue; + if (other.z <= this.z) continue; + + const dx = other.x - this.x; + const dy = other.y - this.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + // Broad phase: check outer radii + if (distance < this.outerRadius + other.outerRadius) { + // Narrow phase: SAT intersection test + if (this.intersects(other)) { + return false; + } + } + } + + return true; + } + + bringToFront() { + this.z = this.store.zCounter++; + } + + private getVertices(): { x: number; y: number }[] { + const rad = (this.rotation * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + const hw = this.width / 2; + const hh = this.height / 2; + + // Corners relative to center, then rotated, then translated + // (-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh) + const corners = [ + { x: -hw, y: -hh }, + { x: hw, y: -hh }, + { x: hw, y: hh }, + { x: -hw, y: hh } + ]; + + return corners.map(p => ({ + x: (p.x * cos - p.y * sin) + this.x, + y: (p.x * sin + p.y * cos) + this.y + })); + } + + private getAxes(): { x: number; y: number }[] { + const rad = (this.rotation * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + // Normals of the edges (local x and y axes) + return [ + { x: cos, y: sin }, + { x: -sin, y: cos } + ]; + } + + private intersects(other: LayoutCard): boolean { + const verticesA = this.getVertices(); + const verticesB = other.getVertices(); + const axes = [...this.getAxes(), ...other.getAxes()]; + + for (const axis of axes) { + const pA = this.project(verticesA, axis); + const pB = this.project(verticesB, axis); + + if (pA.max < pB.min || pB.max < pA.min) { + return false; // Gap found, no intersection + } + } + return true; + } + + private project(vertices: { x: number; y: number }[], axis: { x: number; y: number }) { + let min = Infinity; + let max = -Infinity; + for (const v of vertices) { + const dot = v.x * axis.x + v.y * axis.y; + if (dot < min) min = dot; + if (dot > max) max = dot; + } + return { min, max }; + } + private recalculateRadii() { this._innerRadius = Math.min(this.width, this.height) / 2; this._outerRadius = Math.sqrt(this.width * this.width + this.height * this.height) / 2; } + private rafId: number | null = null; + private applyTransform() { - if (this.ref) { - this.ref.style.transform = - `translate3d(${this.x}px, ${this.y}px, 0) rotate(${this.rotation}deg)`; - this.ref.style.zIndex = String(this.z); - this.ref.style.width = `${this.width}px`; - this.ref.style.height = `${this.height}px`; + if (this.rafId) { + cancelAnimationFrame(this.rafId); } + + this.rafId = requestAnimationFrame(() => { + if (this.ref) { + this.ref.style.transform = + `translate3d(${this.x}px, ${this.y}px, 0) rotate(${this.rotation}deg)`; + this.ref.style.zIndex = String(this.z); + this.ref.style.width = `${this.width}px`; + this.ref.style.height = `${this.height}px`; + } + this.rafId = null; + }); } toSnapshot(): LayoutCardState { @@ -101,12 +204,13 @@ export class LayoutStore { const y = Math.random() * 500; const rotation = Math.random() * 10 - 5; - card = new LayoutCard(id, { + card = new LayoutCard(id, this, { x, y, rotation, width, - height + height, + z: this.zCounter++ }, ref); this.items.set(id, card); } else { @@ -125,18 +229,6 @@ export class LayoutStore { this.items.delete(id); } - update(id: string, updates: Partial) { - const card = this.items.get(id); - if (card) { - if (updates.z) this.zCounter = Math.max(this.zCounter, updates.z); - card.update(updates); - } - } - - bringToFront(id: string) { - this.update(id, { z: ++this.zCounter }); - } - getCardsInCircle(x: number, y: number, radius: number): LayoutCard[] { const result: LayoutCard[] = []; for (const card of this.items.values()) { diff --git a/frontend/src/desktop/useCardPointer.ts b/frontend/src/desktop/useCardPointer.ts index 8ec043f..c0c8e84 100644 --- a/frontend/src/desktop/useCardPointer.ts +++ b/frontend/src/desktop/useCardPointer.ts @@ -1,23 +1,102 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useRef } from 'react'; import { LayoutCard } from './LayoutSystem'; +import { handleDragMove, handleDragEnd } from './CardDragLogic'; + +const DRAG_THRESHOLD = 3; + +type PointerState = 'idle' | 'click' | 'drag'; + +export const useCardPointer = ( + card: LayoutCard, + isSelected: boolean, + selection: string[], + onSelect: (ids: string[], extend?: boolean) => void, + onDeselect: (ids: string[]) => void, + onDocumentActivate?: (id: string) => void +) => { + const [state, setState] = React.useState('idle'); + const initialPosition = useRef<{ x: number, y: number } | null>(null); + + const updateState = useCallback((e: React.PointerEvent) => { + 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) { + setState('drag-start'); + } + } + }, [state]); -export const useCardPointer = (card: LayoutCard, isSelected: boolean) => { const onPointerDown = useCallback((e: React.PointerEvent) => { - console.log('Pointer down on card', card.id, card.rotation, card.z, isSelected, e); - }, [card, isSelected]); + // Only left click + if (e.button !== 0) return; + + (e.target as Element).setPointerCapture(e.pointerId); + setState('click'); + initialPosition.current = { x: e.clientX, y: e.clientY }; + }, []); const onPointerMove = useCallback((e: React.PointerEvent) => { - console.log('Pointer move on card', card.id, card.rotation, e); - }, [card]); + const hasModifier = e.metaKey || e.ctrlKey || e.shiftKey; + updateState(e); + + if (state === 'drag-start') { + if (!isSelected) { + onSelect([card.id], hasModifier); + + if (!hasModifier) { + card.bringToFront(); + } + } + + setState('drag'); + } + + if (state === 'drag' && initialPosition.current) { + const delta = { + x: e.clientX - initialPosition.current.x, + y: e.clientY - initialPosition.current.y + }; + + handleDragMove(card.store, selection, delta); + } + }, [card, state, updateState, isSelected, selection, onSelect]); const onPointerUp = useCallback((e: React.PointerEvent) => { - console.log('Pointer up on card', card.id, card.rotation, e); - }, [card]); + const hasModifier = e.metaKey || e.ctrlKey || e.shiftKey; + updateState(e); + + if (state === 'drag' || state === 'drag-start') { + handleDragEnd(); + } else if (state === 'click') { + if (isSelected) { + const isUnobstructed = card.isUnobstructed(); + + if (hasModifier) { + onDeselect([card.id]); + } else if (isUnobstructed) { + onDocumentActivate?.(card.id); + } + } else { + onSelect([card.id], hasModifier); + + if (!hasModifier) { + card.bringToFront(); + } + } + } + + setState('idle'); + initialPosition.current = null; + (e.target as Element).releasePointerCapture(e.pointerId); + }, [card, state, isSelected, onSelect, onDeselect, onDocumentActivate, updateState]); return { onPointerDown, onPointerMove, - onPointerUp, + onPointerUp }; };