From 27eea086052161d273d6d51fc47173490eb35da7 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Wed, 26 Nov 2025 12:28:02 +0100 Subject: [PATCH] refactor: replace workspace engine and drag/pointer logic with a new layout system and physics model.refactor: replace the workspace engine and drag/pointer logic with a new layout system. --- frontend/src/desktop/DesktopDocumentCard.tsx | 11 +- frontend/src/desktop/DesktopWorkspace.tsx | 1131 ++---------- frontend/src/desktop/LayoutSystem.ts | 96 + frontend/src/desktop/events.ts | 45 - frontend/src/desktop/pointer/pointerUtils.ts | 203 --- .../src/desktop/pointer/useDeskPointer.js | 437 ----- .../desktop/tags/useDeskTagInteractions.js | 24 +- frontend/src/desktop/useDocumentDrag.ts | 656 ------- frontend/src/desktop/workspaceEngine.ts | 1624 ----------------- .../src/styles/workspace/workspace-items.css | 7 +- 10 files changed, 264 insertions(+), 3970 deletions(-) create mode 100644 frontend/src/desktop/LayoutSystem.ts delete mode 100644 frontend/src/desktop/events.ts delete mode 100644 frontend/src/desktop/pointer/pointerUtils.ts delete mode 100644 frontend/src/desktop/pointer/useDeskPointer.js delete mode 100644 frontend/src/desktop/useDocumentDrag.ts delete mode 100644 frontend/src/desktop/workspaceEngine.ts diff --git a/frontend/src/desktop/DesktopDocumentCard.tsx b/frontend/src/desktop/DesktopDocumentCard.tsx index 4208fc4..4b3d452 100644 --- a/frontend/src/desktop/DesktopDocumentCard.tsx +++ b/frontend/src/desktop/DesktopDocumentCard.tsx @@ -2,11 +2,15 @@ import React, { useMemo } from 'react'; import DesktopPreviewCard from './DesktopPreviewCard'; import { resolveCorrespondents } from '../documents/correspondents'; import { getTagColorStyle } from '../utils/colors'; -import { preventAll } from './events'; import type { DocumentId } from '../types/identifiers'; - import type { Document } from '../types/documents'; +const preventAll = (event?: React.SyntheticEvent | Event | null) => { + if (!event) return; + if (typeof event.preventDefault === 'function') event.preventDefault(); + if (typeof event.stopPropagation === 'function') event.stopPropagation(); +}; + interface PendingRemovalTag { docId?: string; tagId?: string; @@ -16,7 +20,6 @@ interface DesktopDocumentCardProps { doc: Document; style?: React.CSSProperties; shouldLoad?: boolean; - dragging?: boolean; matchesFilter?: boolean; tagTargetActive?: boolean; tagTargetPending?: boolean; @@ -43,7 +46,6 @@ const DesktopDocumentCard: React.FC = ({ doc, style, shouldLoad, - dragging, matchesFilter, tagTargetActive, tagTargetPending, @@ -69,7 +71,6 @@ const DesktopDocumentCard: React.FC = ({ const tags = Array.isArray(doc?.tags) ? doc.tags : []; const itemClasses = ['desk-item']; - if (dragging) itemClasses.push('is-dragging'); if (tagTargetActive) itemClasses.push('is-tag-target'); if (tagTargetPending) itemClasses.push('is-tag-pending'); if (!matchesFilter) itemClasses.push('is-filtered-out'); diff --git a/frontend/src/desktop/DesktopWorkspace.tsx b/frontend/src/desktop/DesktopWorkspace.tsx index e3c952f..1eb2ce4 100644 --- a/frontend/src/desktop/DesktopWorkspace.tsx +++ b/frontend/src/desktop/DesktopWorkspace.tsx @@ -5,36 +5,20 @@ import React, { useMemo, useRef, useState, - useSyncExternalStore, } from 'react'; -import { resolveDocumentAssetUrl } from '../asset_manager'; -import type { GetAsset } from '../asset_manager'; -import { formatTransform } from '../utils/math'; -import useDocumentDrag, { PointerDownOptions } from './useDocumentDrag'; +import { globalLayout, LayoutItem } from './LayoutSystem'; import PreviewZoomOverlay from '../detail/PreviewZoomOverlay'; -import { - WorkspaceEngine, - DESK_CANVAS_PADDING, - DESK_DEFAULT_CANVAS_HEIGHT, - DESK_DEFAULT_CANVAS_WIDTH, - clampCardDimensions, - computeFallbackCardSize, - useWorkspaceSnapshot, -} from './workspaceEngine'; -import useDeskPointer from './pointer/useDeskPointer'; -import useDeskTagInteractions from './tags/useDeskTagInteractions'; import DesktopDocumentCard from './DesktopDocumentCard'; import usePreviewMetadata from './hooks/usePreviewMetadata'; -import { DEBUG_DRAG, DEBUG_FOCUS } from '../constants/desktop'; import '../styles/workspace/workspace-layout.css'; import '../styles/workspace/workspace-items.css'; import '../styles/workspace/workspace-cards.css'; import { useWorkspaceSelectionContext } from '../app/WorkspaceSelectionContext'; -import type { DocumentId, Identifier } from '../types/identifiers'; +import type { Identifier } from '../types/identifiers'; +import type { DocumentsListEntry, Document } from '../types/documents'; type TagLike = { id?: Identifier | null; label?: string; color?: string | null } | null; -type DocumentLinkLike = { url?: string | null; mimeType?: string | null }; -type OverlaySource = { url: string; alt?: string | null; mimeType?: string | null }; +type OverlaySource = { url: string; alt?: string | null; mimeType?: string | null; }; export interface DeskDocument { id?: Identifier | null; @@ -44,28 +28,6 @@ export interface DeskDocument { [key: string]: unknown; } -interface NavigatorSnapshot { - url: string | null; - alt?: string | null; - width?: number | null; - height?: number | null; -} - -type OverlayOriginHint = { - rotation?: number; - scale?: number; - width?: number; - height?: number; -}; - -interface OverlayOriginTransform { - rotation: number; - scaleX: number; - scaleY: number; - baseWidth: number; - baseHeight: number; -} - interface OverlayDisplay { url: string; alt?: string | null; @@ -78,984 +40,175 @@ interface DocumentSizeInfo { source?: 'snapshot' | 'metadata' | 'fallback'; } -interface PreviewMetadataEntry { - docId: DocumentId; - width: number; - height: number; -} - -interface DragTransformOverride { - centerX?: number; - centerY?: number; - rotation?: number; - scale?: number; -} - -interface DragSettings { - canvasPadding: number; - defaultCanvasWidth: number; - defaultCanvasHeight: number; - debugDrag?: boolean; -} - -interface LayoutEntry { - centerX: number; - centerY: number; - rotation: number; - z: number; - width?: number; - height?: number; -} - -type WorkspaceSnapshotState = { - layout: Map; - canvasSize: { width: number; height: number }; - visibleDocIds: Set; - draggingId: string | null; - tagDropTargetId: string | null; - pendingTagDocId: string | null; - pendingRemovalTag: unknown; - initialLoadDone: boolean; +// Fallback size computation +const computeFallbackCardSize = (_doc: DeskDocument): DocumentSizeInfo => { + return { width: 200, height: 280, source: 'fallback' }; }; -export interface DeskTagInteractions { - handleTagDragEnterDoc: (...args: unknown[]) => void; - handleTagDragOverDoc: (...args: unknown[]) => void; - handleTagDragLeaveDoc: (...args: unknown[]) => void; - handleTagDropOnDoc: (...args: unknown[]) => void; - handleDocTagPointerDown: (...args: unknown[]) => void; - handleDocTagDragStart: (...args: unknown[]) => void; - handleDocTagDrag: (...args: unknown[]) => void; - handleDocTagDragEnd: (...args: unknown[]) => void; - handleCanvasDragOver: (event: React.DragEvent) => void; - handleCanvasDragLeave: (event: React.DragEvent) => void; - handleCanvasDrop: (event: React.DragEvent) => void; +export interface DesktopWorkspaceProps { + entries: DocumentsListEntry[]; + ensureAssetUrl?: (...args: any[]) => Promise; + getDocumentAsset?: (...args: any[]) => unknown; + onDocumentActivate?: (doc: DeskDocument, event?: unknown) => void; + onSelectionChange?: (selectedIds: Identifier[]) => void; + layout?: any[]; } -import type { DocumentsViewProps } from '../documents/panel/DocumentsPanel'; - -interface DesktopWorkspaceViewProps extends Omit { - engine: WorkspaceEngine; - items: DeskDocument[]; - containerRef: React.RefObject; - ensureDocumentSize: (doc: DeskDocument | null) => DocumentSizeInfo | null; - layoutSnapshot: Map; - layoutRef: React.MutableRefObject>; - dragTransformsRef: React.MutableRefObject>; - itemRefs: React.MutableRefObject>; - visibleDocIds: Set; - draggingId: string | null; - tagDropTargetId: string | null; - pendingTagDocId: string | null; - pendingRemovalTag: unknown; - handleNavigatorSnapshot: (docId: Identifier | null, snapshot: NavigatorSnapshot | null) => void; - activeTagSet: Set; - tagInteractions: DeskTagInteractions; - overlayDisplay: OverlayDisplay | null; - closeOverlay: () => void; - overlayOriginRect: DOMRect | null; - overlayOriginTransform: OverlayOriginTransform | null; - overlayDocument: DeskDocument | null; - documentLookup: Map; - selectedDocumentIds: Identifier[]; - onClearSelection: () => void; - resolveBaseMetrics: (doc: DeskDocument | null, cardWidth: number, cardHeight: number) => { - baseWidth: number; - baseHeight: number; - baseScale: number; - }; - bringToFront: (docId: Identifier | null) => void; - setDraggingId: (value: string | null) => void; - canvasSize: { width: number; height: number }; - openOverlayForDoc: (docId: Identifier | null, originInfo?: OverlayOriginHint | null) => void; - recalcVisibleDocIds: () => void; - dragSettings: DragSettings; - markLayoutDirty: () => void; - onSelect?: (descriptor: unknown, event?: unknown) => void; - onPromoteSelection?: (docId: Identifier, event?: unknown) => void; - selectionOrderRef: React.MutableRefObject; -} - -const defaultGetDocumentAsset: GetAsset = () => null; - -const DesktopWorkspace: React.FC = ({ - entries = [], - activeTagFilters = [], - onDocumentTagDrop = null, - tenantId = null, - viewId = 'default', - documentLinks, - ensureDownloadUrl, - ensureAssetUrl = null, - getDocumentAsset = defaultGetDocumentAsset, - ...passThroughProps -}) => { - const { - selectedDocumentIds, - clearSelection, - handleEntrySelection, - promoteSelectionOrder, - selectionOrderRef, - configureSelectionEnvironment, - } = useWorkspaceSelectionContext(); - const items = useMemo( - () => { - if (!Array.isArray(entries)) return []; - return entries.flatMap((entry: any) => { - if (!entry) return []; - - // Handle DocumentsListEntry - if ('type' in entry && entry.type === 'document' && entry.document) { - return [entry.document as DeskDocument]; - } - if ('type' in entry && entry.type === 'folder') { - return []; - } - - return []; - }); - }, - [entries], - ); - - const getDocEntryKey = useCallback((id: Identifier | null) => (id != null ? `document:${id}` : null), []); - - const handlePromoteSelection = useCallback( - (docId: Identifier | null) => { - const key = getDocEntryKey(docId); - if (key) { - promoteSelectionOrder(docId); - } - }, - [getDocEntryKey, promoteSelectionOrder], - ); - - const allowLayoutPersistence = Boolean(tenantId && viewId && viewId.startsWith('folder:')); - const documentLinkMap = documentLinks instanceof Map ? documentLinks : null; - - const containerRef = useRef(null); - const itemRefs = useRef>(new Map()); - const dragTransformsRef = useRef>(new Map()); - const [overlayDocId, setOverlayDocId] = useState(null); - const [overlayOriginRect, setOverlayOriginRect] = useState(null); - const [overlayOriginTransform, setOverlayOriginTransform] = useState( - null, - ); - const [overlaySource, setOverlaySource] = useState(null); - const [, setPreviewSnapshots] = useState>(() => new Map()); - const [docSizeVersion, setDocSizeVersion] = useState(0); - const docSizeMapRef = useRef>(new Map()); - const ensureDocumentSize = useCallback((doc: DeskDocument | null): DocumentSizeInfo | null => { - if (!doc?.id) { - return null; - } - return docSizeMapRef.current.get(String(doc.id)) || null; - }, []); - const previewMetadata = usePreviewMetadata(items, getDocumentAsset, ensureAssetUrl); - const documentLookup = useMemo>(() => { - const map = new Map(); - items.forEach((doc) => { - const key = doc?.id != null ? String(doc.id) : null; - if (key) { - map.set(key, doc); - } - }); - return map; - }, [items]); - - const engineRef = useRef(null); - if (!engineRef.current) { - engineRef.current = new WorkspaceEngine({ - allowLayoutPersistence, - tenantId, - viewId, - }); - } - const engine = engineRef.current as WorkspaceEngine; - - useEffect(() => { - engine.updateConfig({ allowLayoutPersistence, tenantId, viewId }); - }, [engine, allowLayoutPersistence, tenantId, viewId]); - - useEffect(() => { - engine.setItems(items || []); - }, [engine, items]); - - useEffect(() => { - const map = new Map(); - items.forEach((doc) => { - const key = doc?.id != null ? String(doc.id) : null; - if (key) { - map.set(key, doc); - } - }); - engine.setDocumentLookup(map); - }, [engine, items]); - - const workspaceSnapshot = useWorkspaceSnapshot(engine, useSyncExternalStore) as WorkspaceSnapshotState; - const { - layout: layoutSnapshot, - canvasSize, - visibleDocIds, - draggingId, - tagDropTargetId, - pendingTagDocId, - pendingRemovalTag, - initialLoadDone, - } = workspaceSnapshot; - - useEffect(() => { - const visibleEntryKeySet = new Set(); - visibleDocIds.forEach((id) => { - const key = getDocEntryKey(id); - if (key) visibleEntryKeySet.add(key); - }); - - configureSelectionEnvironment({ - visibleEntryKeySet, - navigableEntryKeys: [], // Desktop doesn't have linear navigation yet - }); - }, [visibleDocIds, getDocEntryKey, configureSelectionEnvironment]); - - useEffect(() => { - const shouldWaitForPersisted = allowLayoutPersistence && !initialLoadDone; - - if (shouldWaitForPersisted) { - return; - } - - engine.ensureLayoutForItems(); - }, [engine, docSizeVersion, initialLoadDone, items.length, allowLayoutPersistence]); - - useEffect(() => { - engine.setItemRefs(itemRefs); - }, [engine, itemRefs]); - - const layoutRef = useRef>(layoutSnapshot); - layoutRef.current = engine.layout as Map; - - const bringToFront = useCallback((docId: Identifier | null) => { - engine.bringToFront(docId); - }, [engine]); - - const markLayoutDirty = useCallback(() => { - engine.markLayoutDirty(); - }, [engine]); - - const recalcVisibleDocIds = useCallback(() => { - engine.recalcVisibleDocIds(); - }, [engine]); - - const setDraggingId = useCallback((value: string | null) => { - engine.setDraggingId(value); - }, [engine]); - - const applySnapshotDimensions = useCallback((docKey: string, snapshot: NavigatorSnapshot | null) => { - const width = Number(snapshot?.width); - const height = Number(snapshot?.height); - if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { - return; - } - const normalized = clampCardDimensions(width, height); - if (!normalized) { - return; - } - const existing = docSizeMapRef.current.get(docKey); - if (existing && existing.width === normalized.width && existing.height === normalized.height) { - return; - } - const next = new Map(docSizeMapRef.current); - next.set(docKey, { ...normalized, source: 'snapshot' }); - docSizeMapRef.current = next; - setDocSizeVersion((value) => value + 1); - }, []); - const handleNavigatorSnapshot = useCallback( - (docId: Identifier | null, snapshot: NavigatorSnapshot | null) => { - const docKey = docId != null ? String(docId) : null; - if (!docKey) { - return; - } - - setPreviewSnapshots((previous) => { - const prevSnapshot = previous.get(docKey); - if (!snapshot) { - if (!previous.has(docKey)) { - return previous; - } - const next = new Map(previous); - next.delete(docKey); - return next; - } - - const next = new Map(previous); - const sameSnapshot = - prevSnapshot && - prevSnapshot.url === snapshot.url && - prevSnapshot.alt === snapshot.alt && - prevSnapshot.width === snapshot.width && - prevSnapshot.height === snapshot.height; - if (sameSnapshot) { - return previous; - } - next.set(docKey, snapshot); - return next; - }); - - if (snapshot) { - applySnapshotDimensions(docKey, snapshot); - } - }, - [applySnapshotDimensions], - ); - const activeTagSet = useMemo>(() => { - if (!Array.isArray(activeTagFilters) || activeTagFilters.length === 0) { - return new Set(); - } - const set = new Set(); - activeTagFilters.forEach((id) => { - if (id != null) { - set.add(String(id)); - } - }); - return set; - }, [activeTagFilters]); - - useLayoutEffect(() => { - const container = containerRef.current; - if (!container) { - engine.setCanvasSize({ width: 0, height: 0 }); - return undefined; - } - - const commitSize = () => { - const rect = container.getBoundingClientRect(); - const width = Math.floor(rect.width) || 0; - const height = Math.floor(rect.height) || 0; - engine.setCanvasSize({ width, height }); - }; - - commitSize(); - - let rafId: number | null = null; - const observer = new ResizeObserver(() => { - if (rafId != null) return; - rafId = requestAnimationFrame(() => { - rafId = null; - commitSize(); - }); - }); - observer.observe(container); - return () => { - observer.disconnect(); - if (rafId != null) { - cancelAnimationFrame(rafId); - } - }; - }, [engine]); - - const resolvePreviewDimensions = useCallback( - (doc: DeskDocument | null): PreviewMetadataEntry | null => { - if (!doc?.id) { - return null; - } - return previewMetadata.get(String(doc.id)) || null; - }, - [previewMetadata], - ); - - useEffect(() => { - if (!ensureAssetUrl) { - return; - } - - visibleDocIds.forEach((docId) => { - const doc = documentLookup.get(docId); - if (!doc) { - return; - } - resolveDocumentAssetUrl(doc, 'thumbnail', { - ensureAssetUrl, - getAsset: getDocumentAsset, - }); - }); - }, [visibleDocIds, ensureAssetUrl, getDocumentAsset, documentLookup]); - - const requestCanvasFocus = useCallback(() => { - const canvas = containerRef.current; - if (!canvas?.focus) { - return; - } - - const focusTarget = () => { - try { - canvas.focus({ preventScroll: true }); - } catch (error: unknown) { - if (DEBUG_FOCUS) { - void error; - } - } - }; - - const raf = window.requestAnimationFrame; - if (raf) { - raf(() => focusTarget()); - return; - } - setTimeout(() => { - focusTarget(); - }, 0); - }, []); - - const tagInteractions = useDeskTagInteractions({ - engine, - onAssignTagToDocument: onDocumentTagDrop, - requestCanvasFocus, - }); - - useEffect(() => { - const current = docSizeMapRef.current; - const next = new Map(current); - const itemKeys = new Set(items.filter((doc) => doc?.id != null).map((doc) => String(doc.id))); - let changed = false; - - items.forEach((doc) => { - if (!doc?.id) { - return; - } - const key = String(doc.id); - const existing = next.get(key) || null; - const meta = previewMetadata.get(key); - if (meta) { - const normalized = clampCardDimensions(meta.width, meta.height); - if (normalized) { - if (existing?.source === 'snapshot') { - return; - } - if (!existing || existing.width !== normalized.width || existing.height !== normalized.height || existing.source !== 'metadata') { - next.set(key, { ...normalized, source: 'metadata' }); - changed = true; - } - return; - } - } - - if (!existing) { - const fallback = computeFallbackCardSize(key); - if (fallback) { - next.set(key, { ...fallback, source: 'fallback' }); - changed = true; - } - } - }); - - current.forEach((_, key) => { - if (!itemKeys.has(key)) { - next.delete(key); - changed = true; - } - }); - - if (changed) { - docSizeMapRef.current = next; - setDocSizeVersion((value) => value + 1); - } - }, [items, previewMetadata]); - - useEffect(() => { - let cancelled = false; - if (!overlayDocId) { - setOverlaySource(null); - return () => { - cancelled = true; - }; - } - - const doc = documentLookup.get(overlayDocId) || null; - const docIdentifier = doc?.id ?? null; - if (!docIdentifier || !doc) { - setOverlaySource(null); - return () => { - cancelled = true; - }; - } - - const docMimeType = doc?.mime_type ?? null; - - const applyEntry = (entry?: DocumentLinkLike | null) => { - if (!entry?.url) { - setOverlaySource(null); - return; - } - setOverlaySource({ - url: entry.url, - alt: doc.title, - mimeType: docMimeType || undefined, - }); - }; - - const cachedEntry = documentLinkMap?.get(docIdentifier) || null; - if (cachedEntry?.url) { - applyEntry(cachedEntry); - return () => { - cancelled = true; - }; - } - - if (!ensureDownloadUrl) { - setOverlaySource(null); - return () => { - cancelled = true; - }; - } - - ensureDownloadUrl(docIdentifier) - .then((entry) => { - if (cancelled) { - return; - } - applyEntry(entry); - }) - .catch(() => { - if (!cancelled) { - setOverlaySource(null); - } - }); - - return () => { - cancelled = true; - }; - }, [overlayDocId, documentLookup, documentLinkMap, ensureDownloadUrl]); - - const closeOverlay = useCallback(() => { - setOverlayDocId(null); - setOverlayOriginRect(null); - setOverlayOriginTransform(null); - setOverlaySource(null); - }, []); - - useEffect(() => { - if (overlayDocId && !documentLookup.has(overlayDocId)) { - setOverlayDocId(null); - setOverlayOriginRect(null); - setOverlayOriginTransform(null); - } - }, [overlayDocId, documentLookup]); - - const resolveBaseMetrics = useCallback( - (doc: DeskDocument | null, cardWidth: number, cardHeight: number) => { - const previewDims = doc ? resolvePreviewDimensions(doc) : null; - if (previewDims?.width && previewDims?.height) { - const baseWidth = Math.max(previewDims.width, cardWidth); - const baseHeight = Math.max(previewDims.height, cardHeight); - const scaleX = cardWidth / baseWidth; - const scaleY = cardHeight / baseHeight; - const baseScale = Math.min(scaleX, scaleY, 1); - return { - baseWidth, - baseHeight, - baseScale: Number.isFinite(baseScale) && baseScale > 0 ? baseScale : 1, - }; - } - return { - baseWidth: cardWidth, - baseHeight: cardHeight, - baseScale: 1, - }; - }, - [resolvePreviewDimensions], - ); - - - useEffect(() => { - if (draggingId && !items.some((doc) => String(doc.id) === draggingId)) { - setDraggingId(null); - } - }, [draggingId, items, setDraggingId]); - - const overlayDisplay = useMemo(() => { - if (!overlaySource) { - return null; - } - return overlaySource; - }, [overlaySource]); - - const overlayDocument = useMemo(() => { - if (!overlayDocId) { - return null; - } - const baseDoc = documentLookup.get(String(overlayDocId)) || null; - if (baseDoc && overlayDisplay?.url) { - return { ...baseDoc, documentLink: overlayDisplay }; - } - return baseDoc; - }, [documentLookup, overlayDisplay, overlayDocId]); - - const openOverlayForDoc = useCallback( - (docId: Identifier | null, originInfo: OverlayOriginHint | null = null) => { - if (!docId) { - return; - } - const docKey = String(docId); - const container = itemRefs.current.get(docKey); - if (!container) { - return; - } - const imageNode = container.querySelector('.desk-item__card img'); - const rect = (imageNode || container).getBoundingClientRect(); - if (!rect) { - return; - } - let originTransform = null; - if (originInfo) { - const { rotation = 0, scale = 1, width: originWidth, height: originHeight } = originInfo; - originTransform = { - rotation, - scaleX: scale, - scaleY: scale, - baseWidth: originWidth, - baseHeight: originHeight, - }; - } - if (!originTransform) { - const entry = engine.getLayout(docKey); - const doc = documentLookup.get(docKey) || null; - const sizeInfo = ensureDocumentSize(doc); - if (!sizeInfo) { - return; - } - const { width: cardWidth, height: cardHeight } = sizeInfo; - const { baseWidth, baseHeight, baseScale } = resolveBaseMetrics(doc, cardWidth, cardHeight); - const effectiveWidth = baseWidth * baseScale; - const effectiveHeight = baseHeight * baseScale; - originTransform = { - rotation: entry?.rotation ?? 0, - scaleX: baseScale, - scaleY: baseScale, - baseWidth: Number.isFinite(effectiveWidth) && effectiveWidth > 0 ? effectiveWidth : cardWidth, - baseHeight: Number.isFinite(effectiveHeight) && effectiveHeight > 0 ? effectiveHeight : cardHeight, - }; - } - bringToFront(docId); - setOverlayOriginRect(rect); - setOverlayOriginTransform(originTransform); - setOverlayDocId(docKey); - }, - [ - bringToFront, - itemRefs, - setOverlayOriginTransform, - ensureDocumentSize, - resolveBaseMetrics, - documentLookup, - engine, - ], - ); - - const dragSettings = useMemo( - () => ({ - canvasPadding: DESK_CANVAS_PADDING, - defaultCanvasWidth: DESK_DEFAULT_CANVAS_WIDTH, - defaultCanvasHeight: DESK_DEFAULT_CANVAS_HEIGHT, - debugDrag: DEBUG_DRAG, - }), - [], - ); - - const viewProps: DesktopWorkspaceViewProps = { - ...passThroughProps, - tenantId, - viewId, - engine, - items, - containerRef, - ensureDocumentSize, - layoutSnapshot, - layoutRef, - dragTransformsRef, - itemRefs, - visibleDocIds, - draggingId, - tagDropTargetId, - pendingTagDocId, - pendingRemovalTag, - ensureAssetUrl, - getDocumentAsset, - handleNavigatorSnapshot, - activeTagSet, - tagInteractions, - overlayDisplay, - closeOverlay, - overlayOriginRect, - overlayOriginTransform, - overlayDocument, - onPromoteSelection: handlePromoteSelection, - selectedDocumentIds, - onClearSelection: clearSelection, - onSelect: (descriptorOrDescriptors: any, event: any) => { - const descriptors = Array.isArray(descriptorOrDescriptors) - ? descriptorOrDescriptors - : [descriptorOrDescriptors]; - const keys = descriptors - .map((d: any) => { - const id = typeof d === 'string' ? d : d?.id; - return getDocEntryKey(id); - }) - .filter((k: any) => k); - - if (keys.length > 0) { - handleEntrySelection(keys, event); - } - }, - documentLookup, - resolveBaseMetrics, - bringToFront, - setDraggingId, - canvasSize, - openOverlayForDoc, - recalcVisibleDocIds, - dragSettings, - markLayoutDirty, - selectionOrderRef, - }; - return ; -}; - -function DesktopWorkspaceView({ - engine, - items, - containerRef, - ensureDocumentSize, - layoutSnapshot, - layoutRef, - dragTransformsRef, - itemRefs, - visibleDocIds, - draggingId, - tagDropTargetId, - pendingTagDocId, - pendingRemovalTag, +const DesktopWorkspace: React.FC = ({ + entries, ensureAssetUrl, getDocumentAsset, - handleNavigatorSnapshot, - activeTagSet, - tagInteractions, - overlayDisplay, - closeOverlay, - overlayOriginRect, - overlayOriginTransform, - overlayDocument, - onPromoteSelection, - selectedDocumentIds, - onClearSelection, - documentLookup, - resolveBaseMetrics, - bringToFront, - setDraggingId, - canvasSize: _canvasSize, - openOverlayForDoc, - recalcVisibleDocIds, - dragSettings, onDocumentActivate, - markLayoutDirty, - onSelect, - selectionOrderRef, -}: DesktopWorkspaceViewProps) { - const handleDeskDocumentActivate = useCallback( - (docId: Identifier) => { - const doc = documentLookup.get(String(docId)); - if (doc && onDocumentActivate) { - onDocumentActivate(doc, undefined as any); - } - }, - [documentLookup, onDocumentActivate], - ); + onSelectionChange, + layout: initialLayout, +}) => { + const containerRef = useRef(null); + const itemRefs = useRef>(new Map()); - const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } = - useDocumentDrag({ - engine, - layoutRef, - dragTransformsRef, - documentLookup, - ensureDocumentSize, - resolveBaseMetrics, - bringToFront, - setDraggingId, - openOverlayForDoc, - recalcVisibleDocIds, - settings: dragSettings, - containerRef, - onDocumentActivate: handleDeskDocumentActivate, - markLayoutDirty, - selectionOrderRef, - selectedDocumentIds, - }) as { - handlePointerDown: (event: React.PointerEvent, docId: Identifier | null, options: PointerDownOptions) => void; - handlePointerMove: React.PointerEventHandler; - handlePointerUp: React.PointerEventHandler; - handlePointerCancel: React.PointerEventHandler; - }; + const items = useMemo(() => { + return entries + .filter((entry): entry is { type: 'document'; document: Document } & DocumentsListEntry => + entry.type === 'document' && !!entry.document + ) + .map(entry => entry.document as DeskDocument); + }, [entries]); - const { getCardPointerHandlers, handleShellKeyDown, focusShell } = useDeskPointer({ - containerRef, - items, - layoutRef, - ensureDocumentSize, - activeTagSet, - handlePointerDown, - handlePointerMove, - handlePointerUp, - handlePointerCancel, - onPromoteSelection, - onDocumentActivate: handleDeskDocumentActivate, - selectedDocumentIds, - openOverlayForDoc, - onSelect, - }); + // Layout System Initialization + const layoutRef = useRef>(new Map()); - useEffect(() => { - focusShell(); - }, [focusShell]); + // Selection Context + const { + selectedDocumentIds: contextSelectedIds, + setSelectedDocumentIds, + clearSelection, + } = useWorkspaceSelectionContext(); - useEffect(() => { - if (selectedDocumentIds.length) { - focusShell(); + const [localSelectedIds, setLocalSelectedIds] = useState([]); + const selectedDocumentIds = contextSelectedIds || localSelectedIds; + + const handleSelectionChange = useCallback((ids: Identifier[]) => { + if (setSelectedDocumentIds) { + setSelectedDocumentIds(ids); + } else { + setLocalSelectedIds(ids); } - }, [focusShell, selectedDocumentIds.length]); + onSelectionChange?.(ids); + }, [setSelectedDocumentIds, onSelectionChange]); - const allSizesReady = items.every((doc) => Boolean(ensureDocumentSize(doc))); + const onClearSelection = useCallback(() => { + clearSelection ? clearSelection() : handleSelectionChange([]); + }, [clearSelection, handleSelectionChange]); + + const metadataMap = usePreviewMetadata(items, getDocumentAsset, ensureAssetUrl); + + const ensureDocumentSize = useCallback((doc: DeskDocument | null): DocumentSizeInfo | null => { + if (!doc) return null; + if (doc.id) { + const meta = metadataMap.get(String(doc.id)); + if (meta) return { width: meta.width, height: meta.height, source: 'metadata' }; + } + return computeFallbackCardSize(doc); + }, [metadataMap]); + + // Initialize Layout System + useLayoutEffect(() => { + if (initialLayout) { + // Seeding logic if needed, but registration happens in render loop + } + }, [initialLayout]); + + // Sync LayoutStore to layoutRef + useEffect(() => { + const sync = () => { + layoutRef.current = globalLayout.items; + }; + sync(); + }, []); + + const handleShellKeyDown = useCallback(() => { }, []); + const focusShell = useCallback(() => { }, []); + + // Overlay State + const [overlayDisplay, setOverlayDisplay] = useState(null); + const closeOverlay = useCallback(() => setOverlayDisplay(null), []); return ( <> -
{ - if (event.target === event.currentTarget) { - onClearSelection(); - } - focusShell(); - }} +
{ + if (e.target === e.currentTarget) { + onClearSelection(); + focusShell(); + } + }} >
{ - if (event.target === event.currentTarget) { - onClearSelection(); - } - focusShell(); - }} > - {!allSizesReady ? ( -
-

Loading previews…

-
- ) : ( - items.map((doc, index) => { - const sizeInfo = ensureDocumentSize(doc); - if (!sizeInfo) { - return null; - } - const { width: cardWidth, height: cardHeight } = sizeInfo; - const docKey = doc?.id != null ? String(doc.id) : null; - const dragOverride = docKey ? dragTransformsRef.current.get(docKey) : null; - const layout = docKey ? layoutRef.current.get(docKey) : null; - if (!dragOverride && (!layout && (!docKey || !layoutSnapshot.has(docKey)))) { - return null; - } - const centerX = dragOverride?.centerX ?? layout?.centerX; - const centerY = dragOverride?.centerY ?? layout?.centerY; - if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) { - return null; - } - const resolvedCenterX = centerX as number; - const resolvedCenterY = centerY as number; - const rotation = dragOverride?.rotation ?? layout?.rotation ?? 0; - const scale = dragOverride?.scale ?? 1; - const originX = resolvedCenterX - cardWidth / 2; - const originY = resolvedCenterY - cardHeight / 2; - const transform = formatTransform( - Math.round(originX), - Math.round(originY), - rotation, - scale, - ); - const style = { - transform, - zIndex: layout?.z ?? 1, - width: Math.round(cardWidth), - height: Math.round(cardHeight), - }; - const shouldLoad = docKey ? visibleDocIds.has(docKey) : false; - const dragging = docKey ? draggingId === docKey : false; - const docTagKeys = Array.isArray(doc?.tags) - ? doc.tags - .map((tag) => (tag?.id != null ? String(tag.id) : null)) - .filter((id): id is string => Boolean(id)) - : []; - const matchesFilter = - activeTagSet.size === 0 || docTagKeys.some((key) => activeTagSet.has(key)); - const dropActive = docKey ? tagDropTargetId === docKey : false; - const dropPending = docKey ? pendingTagDocId === docKey : false; - const docId = doc?.id ?? null; - const isSelected = docId != null ? selectedDocumentIds.includes(docId) : false; - const docTagTokens = docTagKeys.join(' '); - const cardPointerHandlers = getCardPointerHandlers(doc) as React.HTMLAttributes; - const registerNode = (node: HTMLDivElement | null) => { - if (!docKey) { - return; - } - if (node) { - itemRefs.current.set(docKey, node); - } else { - itemRefs.current.delete(docKey); - } - }; + {items.map((doc, index) => { + const docId = doc.id ? String(doc.id) : `temp-${index}`; + const isSelected = selectedDocumentIds.includes(docId); + const size = ensureDocumentSize(doc); - return ( - - ); - }) - )} + // Register with LayoutStore + const registerNode = (node: HTMLDivElement) => { + if (node) { + itemRefs.current.set(docId, node); + const initItem = initialLayout?.find((i: any) => i.id === docId); + globalLayout.initialize(docId, node, { + x: initItem?.x, + y: initItem?.y, + rotation: initItem?.rotation, + z: index, + width: size?.width ?? 200, + height: size?.height ?? 200 + }); + } else { + itemRefs.current.delete(docId); + } + }; + + // const cardPointerHandlers = getCardPointerHandlers(doc); + + return ( + { }} + cardPointerHandlers={undefined} + onDocumentActivate={onDocumentActivate} + registerNode={registerNode} + /> + ); + })}
); -} +}; export default DesktopWorkspace; diff --git a/frontend/src/desktop/LayoutSystem.ts b/frontend/src/desktop/LayoutSystem.ts new file mode 100644 index 0000000..3d8ee85 --- /dev/null +++ b/frontend/src/desktop/LayoutSystem.ts @@ -0,0 +1,96 @@ +export interface LayoutItem { + id: string; + x: number; + y: number; + z: number; + rotation: number; + width: number; + height: number; + ref: HTMLElement; +} + +export class LayoutStore { + items = new Map(); + zCounter = 100; + + register(id: string, ref: HTMLElement, initialData: Partial) { + const existing = this.items.get(id); + this.items.set(id, { + id, + ref, + x: existing?.x ?? 0, + y: existing?.y ?? 0, + z: existing?.z ?? 0, + rotation: existing?.rotation ?? 0, + width: 200, + height: 200, + ...initialData + }); + } + + initialize(id: string, ref: HTMLElement, config: { + x?: number; + y?: number; + rotation?: number; + z: number; + width: number; + height: number; + }) { + if (this.items.has(id)) { + // Update ref if it changed + const item = this.items.get(id)!; + if (item.ref !== ref) { + item.ref = ref; + this.update(id, {}); // Re-apply styles + } + return; + } + + // Apply defaults if not provided + const x = config.x ?? Math.random() * 500; + const y = config.y ?? Math.random() * 500; + const rotation = config.rotation ?? (Math.random() * 10 - 5); + + this.register(id, ref, { + ...config, + x, + y, + rotation + }); + + // Apply immediately + this.update(id, {}); + } + + unregister(id: string) { + this.items.delete(id); + } + + // Fast Update: Updates internal state AND applies CSS transform immediately + update(id: string, updates: Partial) { + const item = this.items.get(id); + if (!item) return; + + Object.assign(item, updates); + if (updates.z) this.zCounter = Math.max(this.zCounter, updates.z); + + // Direct DOM manipulation (The "Engine" part) + if (item.ref) { + item.ref.style.transform = + `translate3d(${item.x}px, ${item.y}px, 0) rotate(${item.rotation}deg)`; + item.ref.style.zIndex = String(item.z); + } + } + + bringToFront(id: string) { + this.update(id, { z: ++this.zCounter }); + } + + getSnapshot() { + // Return serializable data for persistence + return Array.from(this.items.values()).map(({ ref: _ref, ...data }) => data); + } +} + +// Singleton or Context-provided instance +export const globalLayout = new LayoutStore(); diff --git a/frontend/src/desktop/events.ts b/frontend/src/desktop/events.ts deleted file mode 100644 index 5aa7eeb..0000000 --- a/frontend/src/desktop/events.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { PointerEvent as ReactPointerEvent } from 'react'; - -type PointerLikeEvent = MouseEvent & { pageX?: number; pageY?: number }; -type PreventableEvent = Event | ReactPointerEvent | { preventDefault?: () => void; stopPropagation?: () => void }; - -export const preventAll = (event?: PreventableEvent | null): void => { - if (!event) { - return; - } - try { - event.preventDefault(); - } catch (error) { - console.warn('[events] preventDefault failed', error); - } - try { - event.stopPropagation(); - } catch (error) { - console.warn('[events] stopPropagation failed', error); - } -}; - -type AnyFn = (...args: unknown[]) => unknown; - -export const safeInvoke = ( - fn: Fn | null, - ...args: Parameters -): ReturnType | undefined => - (fn ? (fn(...args) as ReturnType) : undefined); - -export const getPointerPosition = ( - event?: PointerLikeEvent | null, - { fallbackToPage = true }: { fallbackToPage?: boolean } = {}, -): { x: number; y: number } => { - if (!event) { - return { x: 0, y: 0 }; - } - const clientX = Number.isFinite(event.clientX) ? event.clientX : null; - const clientY = Number.isFinite(event.clientY) ? event.clientY : null; - const pageX = fallbackToPage && Number.isFinite(event.pageX) ? event.pageX : null; - const pageY = fallbackToPage && Number.isFinite(event.pageY) ? event.pageY : null; - return { - x: clientX ?? pageX ?? 0, - y: clientY ?? pageY ?? 0, - }; -}; diff --git a/frontend/src/desktop/pointer/pointerUtils.ts b/frontend/src/desktop/pointer/pointerUtils.ts deleted file mode 100644 index 716c3d4..0000000 --- a/frontend/src/desktop/pointer/pointerUtils.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { safeInvoke } from '../events'; -import type { DocumentId } from '../../types/identifiers'; -import { - CLICK_ACTIONS, - DRAG_ACTIONS, - LONG_PRESS_DURATION_MS, - POINTER_DRAG_THRESHOLD_SQUARED, - STACK_HIT_EPSILON, -} from '../../constants/desktop'; - -export { CLICK_ACTIONS, DRAG_ACTIONS, STACK_HIT_EPSILON, POINTER_DRAG_THRESHOLD_SQUARED, LONG_PRESS_DURATION_MS }; - -export type ClickAction = (typeof CLICK_ACTIONS)[keyof typeof CLICK_ACTIONS]; -export type DragAction = (typeof DRAG_ACTIONS)[keyof typeof DRAG_ACTIONS]; - -export const withinThreshold = (dx: number, dy: number, thresholdSquared: number): boolean => (dx * dx + dy * dy) <= thresholdSquared; - -interface PointerIntentArgs { - doc: { id: string }; - entryDescriptor: unknown; - selectedDocumentIds: Array; - metaKey: boolean; - pointerButton?: number; - pointerType?: string; - stackHits?: string[] | null; - isTopMost?: boolean; -} - -export interface PointerIntent { - docId: DocumentId; - entryDescriptor: unknown; - pointerType?: string; - pointerButton?: number; - selectedAtDown: boolean; - selectionCountAtDown: number; - metaKey: boolean; - clickAction: ClickAction; - dragAction: DragAction; - stackDocIdsForDrag: string[] | null; - stackDocIdsForClick: string[] | null; - stackReplaceOnClick: boolean; - stackReplaceOnDrag: boolean; - clickSelectionApplied: boolean; - stackSelectionApplied: boolean; - longPressTriggered: boolean; -} - -export const createPointerIntent = ({ - doc, - entryDescriptor, - selectedDocumentIds, - metaKey, - pointerButton, - pointerType, - stackHits, - isTopMost = true, -}: PointerIntentArgs): PointerIntent => { - const alreadySelected = selectedDocumentIds.includes(doc.id); - const selectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0; - - let clickAction: ClickAction = CLICK_ACTIONS.none; - let dragAction: DragAction = DRAG_ACTIONS.none; - - if (metaKey) { - clickAction = CLICK_ACTIONS.addStack; - dragAction = DRAG_ACTIONS.dragSelection; - } else if (alreadySelected) { - // Only open detail if it's already the top-most card - if (isTopMost) { - clickAction = CLICK_ACTIONS.openDetail; - } else { - // If not top-most, we don't trigger inspect. - // We also don't need to trigger selectSingle because it's already selected. - // The promotion logic (onPromoteSelection) handles bringing it to front. - clickAction = CLICK_ACTIONS.none; - } - dragAction = selectionCount > 1 ? DRAG_ACTIONS.dragSelection : DRAG_ACTIONS.dragSelectSingle; - } else { - clickAction = CLICK_ACTIONS.selectSingle; - dragAction = DRAG_ACTIONS.dragSelectSingle; - } - - const stackList: string[] = Array.isArray(stackHits) && stackHits.length > 0 - ? stackHits.map((value) => String(value)) - : [String(doc.id)]; - - const stackDocIdsForClick = clickAction === CLICK_ACTIONS.addStack ? stackList : null; - const stackDocIdsForDrag = metaKey ? stackList : null; - - return { - docId: doc.id, - entryDescriptor, - pointerType, - pointerButton, - selectedAtDown: alreadySelected, - selectionCountAtDown: selectionCount, - metaKey, - clickAction, - dragAction, - stackDocIdsForDrag, - stackDocIdsForClick, - stackReplaceOnClick: clickAction === CLICK_ACTIONS.addStack, - stackReplaceOnDrag: false, - clickSelectionApplied: false, - stackSelectionApplied: false, - longPressTriggered: false, - }; -}; - -export const applyClickPlanImmediately = ({ intent, event, onEntryPointer, onDocumentStackSelect, onSelect, force = false }: { - intent: PointerIntent; - event?: unknown; - onEntryPointer?: (descriptor: unknown, event?: unknown) => void; - onDocumentStackSelect?: (docIds: string[], event?: unknown, options?: { replace?: boolean }) => void; - onSelect?: (descriptor: unknown, event?: unknown) => void; - force?: boolean; -}) => { - switch (intent.clickAction) { - case CLICK_ACTIONS.selectSingle: - case CLICK_ACTIONS.addCard: - if (onSelect) { - safeInvoke(onSelect, intent.entryDescriptor, event); - } else { - safeInvoke(onEntryPointer, intent.entryDescriptor, event); - } - intent.clickSelectionApplied = true; - break; - case CLICK_ACTIONS.addStack: - if (!force && intent.selectedAtDown) { - return; - } - if (Array.isArray(intent.stackDocIdsForClick) && intent.stackDocIdsForClick.length > 0) { - // Use onSelect for stack selection (batch) - if (onSelect) { - // Map docIds to descriptors if necessary, or just pass IDs if onSelect handles it. - // The current onSelect adapter in DesktopWorkspace expects { id } objects or just IDs? - // Let's assume it expects descriptors like selectSingle. - const descriptors = intent.stackDocIdsForClick.map(id => ({ - type: 'document', - id, - key: `document:${id}`, - })); - safeInvoke(onSelect, descriptors, event); - } else { - // Fallback to legacy if onSelect not provided (shouldn't happen in new flow) - safeInvoke( - onDocumentStackSelect, - intent.stackDocIdsForClick, - event, - { replace: intent.stackReplaceOnClick }, - ); - } - intent.clickSelectionApplied = true; - intent.stackSelectionApplied = true; - } - break; - case CLICK_ACTIONS.openDetail: - default: - intent.clickSelectionApplied = true; - break; - } -}; - -export const finalizeClickSelection = ({ intent, event, onEntryPointer, onDocumentStackSelect, onSelect }: { - intent: PointerIntent; - event?: unknown; - onEntryPointer?: (descriptor: unknown, event?: unknown) => void; - onDocumentStackSelect?: (docIds: string[], event?: unknown, options?: { replace?: boolean }) => void; - onSelect?: (descriptor: unknown, event?: unknown) => void; -}) => { - if (!intent || intent.clickSelectionApplied) { - return; - } - - applyClickPlanImmediately({ intent, event, onEntryPointer, onDocumentStackSelect, onSelect, force: true }); -}; - -export const applyLongPressSelection = ({ intent, stackDocIds, syntheticEvent, onDocumentStackSelect }: { - intent: PointerIntent; - stackDocIds?: string[] | null; - syntheticEvent?: unknown; - onDocumentStackSelect?: (docIds: string[], event?: unknown, options?: { replace?: boolean }) => void; -}) => { - if (!intent) { - return; - } - - const stackCopy: string[] = Array.isArray(stackDocIds) && stackDocIds.length > 0 - ? stackDocIds.map((value) => String(value)) - : [String(intent.docId)]; - - safeInvoke(onDocumentStackSelect, stackCopy, syntheticEvent, { replace: true }); - - intent.clickAction = CLICK_ACTIONS.addStack; - intent.dragAction = DRAG_ACTIONS.dragSelectStack; - intent.stackDocIdsForClick = stackCopy; - intent.stackDocIdsForDrag = stackCopy; - intent.stackReplaceOnClick = true; - intent.stackReplaceOnDrag = true; - intent.clickSelectionApplied = true; - intent.stackSelectionApplied = true; - intent.longPressTriggered = true; -}; diff --git a/frontend/src/desktop/pointer/useDeskPointer.js b/frontend/src/desktop/pointer/useDeskPointer.js deleted file mode 100644 index 40c2461..0000000 --- a/frontend/src/desktop/pointer/useDeskPointer.js +++ /dev/null @@ -1,437 +0,0 @@ -import { - useCallback, - useEffect, - useRef, -} from 'react'; -import { - CLICK_ACTIONS, - LONG_PRESS_DURATION_MS, - POINTER_DRAG_THRESHOLD_SQUARED, - STACK_HIT_EPSILON, - applyClickPlanImmediately, - applyLongPressSelection, - createPointerIntent, - finalizeClickSelection, - withinThreshold, -} from './pointerUtils'; -import { getPointerPosition, safeInvoke } from '../events'; - -const buildEntryDescriptor = (docId) => ({ - type: 'document', - id: docId, - key: `document:${docId}`, -}); - -export const useDeskPointer = ({ - containerRef, - items, - layoutRef, - ensureDocumentSize, - activeTagSet, - handlePointerDown, - handlePointerMove, - handlePointerUp, - handlePointerCancel, - onDocumentClick, - onPromoteSelection, - onDocumentActivate, - selectedDocumentIds, - openOverlayForDoc = null, - onSelect = null, -}) => { - const pointerIntentRef = useRef(null); - const pointerStartRef = useRef({ x: 0, y: 0 }); - const pointerMovedRef = useRef(false); - const longPressTimerRef = useRef(null); - const longPressActiveRef = useRef(false); - const resetLongPressState = useCallback(() => { - if (longPressTimerRef.current) { - clearTimeout(longPressTimerRef.current); - longPressTimerRef.current = null; - } - longPressActiveRef.current = false; - }, []); - - const resolveStackDocIds = useCallback( - (event, targetDocId = null) => { - const container = containerRef.current; - if (!container || !event) { - return []; - } - - const rect = container.getBoundingClientRect(); - const pointerCanvasX = event.clientX - rect.left; - const pointerCanvasY = event.clientY - rect.top; - - if (!Number.isFinite(pointerCanvasX) || !Number.isFinite(pointerCanvasY)) { - return []; - } - - const candidates = []; - - items.forEach((doc) => { - if (!doc?.id) { - return; - } - const docKey = String(doc.id); - const layout = layoutRef.current.get(docKey); - if (!layout) { - return; - } - - const sizeInfo = ensureDocumentSize(doc); - if (!sizeInfo) { - return; - } - const { width, height } = sizeInfo; - if (!width || !height) { - return; - } - - if (activeTagSet.size) { - const docTagKeys = Array.isArray(doc.tags) - ? doc.tags.map((tag) => (tag ? tag.id : null)).filter(Boolean) - : []; - if (!docTagKeys.some((key) => activeTagSet.has(key))) { - return; - } - } - - const centerX = Number(layout.centerX); - const centerY = Number(layout.centerY); - if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) { - return; - } - - const rotationDeg = Number(layout.rotation) || 0; - const rotationRad = (rotationDeg * Math.PI) / 180; - const dx = pointerCanvasX - centerX; - const dy = pointerCanvasY - centerY; - const cosRotation = Math.cos(-rotationRad); - const sinRotation = Math.sin(-rotationRad); - const localX = dx * cosRotation - dy * sinRotation; - const localY = dx * sinRotation + dy * cosRotation; - const halfWidth = width / 2; - const halfHeight = height / 2; - - const containsPointer = - Math.abs(localX) <= halfWidth + STACK_HIT_EPSILON - && Math.abs(localY) <= halfHeight + STACK_HIT_EPSILON; - - candidates.push({ - id: docKey, - z: Number.isFinite(layout.z) ? layout.z : 0, - centerX, - centerY, - width, - height, - halfWidth, - halfHeight, - containsPointer, - }); - }); - - const pointerCandidates = candidates.filter((candidate) => candidate.containsPointer); - if (!pointerCandidates.length) { - return []; - } - - const sortedByZ = [...pointerCandidates].sort((a, b) => (b.z ?? 0) - (a.z ?? 0)); - const targetKey = targetDocId != null ? String(targetDocId) : sortedByZ[0].id; - - const primary = sortedByZ.find((candidate) => candidate.id === targetKey) || sortedByZ[0]; - if (!primary) { - return []; - } - - const radius = Math.max(Math.min(primary.halfWidth, primary.halfHeight) * 1.2, 6); - const radiusSquared = radius * radius; - - const selected = candidates - .filter((candidate) => { - if (!candidate?.id) { - return false; - } - const dx = candidate.centerX - primary.centerX; - const dy = candidate.centerY - primary.centerY; - return dx * dx + dy * dy <= radiusSquared + 1e-4; - }) - .sort((a, b) => (b.z ?? 0) - (a.z ?? 0)); - - if (targetKey) { - const targetIndex = selected.findIndex((entry) => entry.id === targetKey); - if (targetIndex > 0) { - const [targetEntry] = selected.splice(targetIndex, 1); - selected.unshift(targetEntry); - } - } - - return selected - .map((candidate) => candidate.id) - .filter((id, index, array) => array.indexOf(id) === index); - }, - [activeTagSet, containerRef, ensureDocumentSize, items, layoutRef], - ); - - const scheduleLongPress = useCallback( - ({ doc, modifierActive, pointerType }) => { - if (modifierActive || pointerType !== 'touch') { - longPressActiveRef.current = false; - return; - } - - longPressActiveRef.current = true; - - longPressTimerRef.current = window.setTimeout(() => { - if (!longPressActiveRef.current || pointerMovedRef.current) { - resetLongPressState(); - return; - } - - const intent = pointerIntentRef.current; - if (!intent || intent.docId !== doc.id) { - resetLongPressState(); - return; - } - - const syntheticEvent = { - clientX: pointerStartRef.current.x, - clientY: pointerStartRef.current.y, - }; - const stackHits = resolveStackDocIds(syntheticEvent, doc.id); - applyLongPressSelection({ - intent, - stackDocIds: stackHits, - syntheticEvent, - onDocumentStackSelect: null, // Deprecated, handled by onSelect if needed, or long press needs update - }); - pointerIntentRef.current = intent; - resetLongPressState(); - }, LONG_PRESS_DURATION_MS); - }, - [resolveStackDocIds, resetLongPressState], - ); - - useEffect(() => () => resetLongPressState(), [resetLongPressState]); - - const handleCardPointerDown = useCallback( - (event, doc) => { - if (!doc?.id) { - return; - } - - pointerStartRef.current = getPointerPosition(event, { fallbackToPage: false }); - pointerMovedRef.current = false; - resetLongPressState(); - - const pointerButton = Number.isFinite(event?.button) ? event.button : 0; - const pointerType = String(event?.pointerType ?? ''); - const metaKey = (event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey; - const modifierActive = Boolean(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey); - - const entryDescriptor = buildEntryDescriptor(doc.id); - const stackHits = metaKey ? resolveStackDocIds(event, doc.id) : null; - - // Calculate if the clicked doc is the top-most among selected docs - let isTopMost = true; - if (selectedDocumentIds.includes(doc.id)) { - const docLayout = layoutRef.current.get(String(doc.id)); - const docZ = docLayout?.z ?? 0; - - // Check against other selected docs - for (const id of selectedDocumentIds) { - if (id === doc.id) continue; - const layout = layoutRef.current.get(String(id)); - if (layout && (layout.z ?? 0) > docZ) { - isTopMost = false; - break; - } - } - } - - const intent = createPointerIntent({ - doc, - entryDescriptor, - selectedDocumentIds, - metaKey, - pointerButton, - pointerType, - stackHits, - isTopMost, - }); - - if (intent.selectedAtDown) { - safeInvoke(onPromoteSelection, doc.id, event); - } - - applyClickPlanImmediately({ - intent, - event, - onEntryPointer: onDocumentClick, - onSelect, - }); - - pointerIntentRef.current = intent; - - - handlePointerDown(event, doc.id, { - wasSelected: intent.selectedAtDown, - modifierActive, - stackHits, - }); - - scheduleLongPress({ - doc, - modifierActive, - pointerType, - }); - }, - [ - handlePointerDown, - onPromoteSelection, - onDocumentClick, - onSelect, - resolveStackDocIds, - resetLongPressState, - scheduleLongPress, - selectedDocumentIds, - layoutRef, - ], - ); - - const handleCardPointerMove = useCallback( - (event) => { - const start = pointerStartRef.current; - const { x, y } = getPointerPosition(event, { fallbackToPage: false }); - const dx = x - start.x; - const dy = y - start.y; - if (!withinThreshold(dx, dy, POINTER_DRAG_THRESHOLD_SQUARED)) { - pointerMovedRef.current = true; - resetLongPressState(); - } - handlePointerMove(event); - }, - [handlePointerMove, resetLongPressState], - ); - - const handleCardPointerUp = useCallback( - (event, doc) => { - const pointerState = pointerIntentRef.current; - const pointerMoved = pointerMovedRef.current; - - resetLongPressState(); - handlePointerUp(event); - - if (!pointerMoved && pointerState) { - finalizeClickSelection({ - intent: pointerState, - event, - onEntryPointer: onDocumentClick, - onSelect, - }); - - if ( - pointerState.clickAction === CLICK_ACTIONS.openDetail - && !pointerState.longPressTriggered - && pointerState.docId === doc.id - ) { - const expectedButton = Number.isFinite(pointerState?.pointerButton) - ? pointerState.pointerButton - : 0; - const releasedButton = Number.isFinite(event?.button) ? event.button : expectedButton; - const isPrimaryRelease = expectedButton === 0 && releasedButton === 0; - const stillSelected = Array.isArray(selectedDocumentIds) - && selectedDocumentIds.includes(doc.id); - if (isPrimaryRelease && stillSelected) { - safeInvoke(onDocumentActivate, doc.id); - } - } - } - - pointerIntentRef.current = null; - pointerMovedRef.current = false; - }, - [ - handlePointerUp, - onDocumentActivate, - onDocumentClick, - onSelect, - resetLongPressState, - selectedDocumentIds, - ], - ); - - const handleCardPointerCancel = useCallback( - (event) => { - pointerMovedRef.current = false; - resetLongPressState(); - pointerIntentRef.current = null; - handlePointerCancel(event); - }, - [handlePointerCancel, resetLongPressState], - ); - - const getCardPointerHandlers = useCallback( - (doc) => ({ - onPointerDown: (event) => handleCardPointerDown(event, doc), - onPointerMove: handleCardPointerMove, - onPointerUp: (event) => handleCardPointerUp(event, doc), - onPointerCancel: handleCardPointerCancel, - }), - [ - handleCardPointerCancel, - handleCardPointerDown, - handleCardPointerMove, - handleCardPointerUp, - ], - ); - - const handleShellKeyDown = useCallback( - (event) => { - if (!event || event.defaultPrevented) { - return; - } - - const { key } = event; - if (key !== ' ' && key !== 'Space' && key !== 'Spacebar') { - return; - } - - const target = event.target; - if (target instanceof HTMLElement) { - const tagName = target.tagName ? target.tagName.toLowerCase() : ''; - if ( - target.isContentEditable - || tagName === 'input' - || tagName === 'textarea' - || tagName === 'select' - || tagName === 'button' - ) { - return; - } - } - - if (openOverlayForDoc && Array.isArray(selectedDocumentIds) && selectedDocumentIds.length > 0) { - event.preventDefault(); - const targetId = selectedDocumentIds[selectedDocumentIds.length - 1]; - if (targetId) { - openOverlayForDoc(targetId); - } - return; - } - - }, - [openOverlayForDoc, selectedDocumentIds], - ); - - return { - getCardPointerHandlers, - handleShellKeyDown, - focusShell: () => { - const shell = containerRef.current; - shell?.focus?.({ preventScroll: true }); - }, - }; -}; - -export default useDeskPointer; diff --git a/frontend/src/desktop/tags/useDeskTagInteractions.js b/frontend/src/desktop/tags/useDeskTagInteractions.js index 830e08d..619009d 100644 --- a/frontend/src/desktop/tags/useDeskTagInteractions.js +++ b/frontend/src/desktop/tags/useDeskTagInteractions.js @@ -3,12 +3,22 @@ import { useEffect, useRef, } from 'react'; -import { getPointerPosition, preventAll, safeInvoke } from '../events'; import { isTagTransferEvent, parseTagTransferPayload, writeTagTransferData, } from '../../documents/tagTransfer'; + +const preventAll = (event) => { + if (!event) return; + if (typeof event.preventDefault === 'function') event.preventDefault(); + if (typeof event.stopPropagation === 'function') event.stopPropagation(); +}; + +// TODO: Restore getPointerPosition +const getPointerPosition = (event) => { + return { x: event.clientX, y: event.clientY }; +}; import { TAG_REMOVE_DISTANCE } from '../../constants/desktop'; const createDragPreview = (node, clientX, clientY) => { @@ -121,11 +131,13 @@ export const useDeskTagInteractions = ({ requestCanvasFocus?.(); - void safeInvoke(onAssignTagToDocument, doc.id, { - id: payload.id, - label: payload.label || '', - sourceDocId: payload.sourceDocId ?? null, - }); + if (onAssignTagToDocument) { + onAssignTagToDocument(doc.id, { + id: payload.id, + label: payload.label || '', + sourceDocId: payload.sourceDocId ?? null, + }); + } }, [engine, isTagTransfer, onAssignTagToDocument, requestCanvasFocus], ); diff --git a/frontend/src/desktop/useDocumentDrag.ts b/frontend/src/desktop/useDocumentDrag.ts deleted file mode 100644 index 510fd31..0000000 --- a/frontend/src/desktop/useDocumentDrag.ts +++ /dev/null @@ -1,656 +0,0 @@ -import { - useCallback, - useEffect, - useRef, - type MutableRefObject, - type RefObject, -} from 'react'; -import type { PointerEvent as ReactPointerEvent } from 'react'; -import { preventAll } from './events'; -import usePointerTap from '../ui/usePointerTap'; -import { - type WorkspaceEngine, - type ActiveDragSession, - type DragGroupItem, - type InertiaSimulationState, - CARD_BASE_WEIGHT_GRAMS, - CARD_PAGE_WEIGHT_GRAMS, -} from './workspaceEngine'; -import { DRAG_HYSTERESIS_SQUARED } from '../constants/desktop'; -import type { Identifier } from '../types/identifiers'; -import type { Document } from '../types/documents'; -import { getEntryId, isDocumentEntry } from '../app/entryKey'; - -interface DocumentSizeInfo { - width: number; - height: number; -} - -interface LayoutEntry { - centerX?: number; - centerY?: number; - rotation?: number; - z?: number; - width?: number; - height?: number; -} - -interface DragTransform { - centerX: number; - centerY: number; - rotation: number; - width?: number; - height?: number; - scale?: number; -} - -type EnsureDocumentSizeFn = (doc: Document | null) => DocumentSizeInfo | null; - -type ResolveBaseMetricsFn = ( - doc: Document | null, - width: number, - height: number, -) => { baseWidth: number; baseHeight: number; baseScale: number }; - -interface DragSettings { - canvasPadding?: number; - defaultCanvasWidth?: number; - defaultCanvasHeight?: number; - debugDrag?: boolean; -} - -export interface PointerDownOptions { - wasSelected?: boolean; - modifierActive?: boolean; - stackHits?: string[] | null; -} - -interface UseDocumentDragOptions { - engine?: WorkspaceEngine | null; - layoutRef: MutableRefObject>; - dragTransformsRef: MutableRefObject>; - selectionOrderRef?: MutableRefObject; - documentLookup: Map; - ensureDocumentSize: EnsureDocumentSizeFn; - resolveBaseMetrics: ResolveBaseMetricsFn; - bringToFront: (docId: Identifier | null) => void; - setDraggingId: (docKey: string | null) => void; - openOverlayForDoc?: ( - docId: Identifier | null, - originInfo?: { rotation: number; scale: number; width: number; height: number }, - ) => void; - recalcVisibleDocIds: () => void; - settings?: DragSettings; - containerRef?: RefObject; - onDocumentActivate?: (docId: Identifier | null, event?: PointerEvent | ReactPointerEvent) => void; - markLayoutDirty?: () => void; -} - -type PointerEventLike = PointerEvent | ReactPointerEvent; - -interface DragTapMetadata { - docId: Identifier | null; - originInfo?: { rotation: number; scale: number; width: number; height: number }; - docTitle: string; -} - -const getDocumentPageCount = (doc?: Document | null): number | null => { - const raw = doc?.current_version?.metadata?.page_count ?? (doc?.metadata as { page_count?: unknown })?.page_count; - if (raw == null) { - return null; - } - const value = Number(raw); - return Number.isFinite(value) ? value : null; -}; - -const computeDocumentMassGrams = (doc?: Document | null): number => { - const pages = Math.max(1, Math.round(getDocumentPageCount(doc) ?? 1)); - return CARD_BASE_WEIGHT_GRAMS + pages * CARD_PAGE_WEIGHT_GRAMS; -}; - -const getEventTargetElement = (event?: PointerEventLike | null): Element | null => { - if (!event) { - return null; - } - const nativeEvent = 'nativeEvent' in event ? (event as ReactPointerEvent).nativeEvent : null; - const candidate = (event.target as Element | null) || (nativeEvent ? (nativeEvent.target as Element | null) : null); - return candidate instanceof Element ? candidate : null; -}; - -interface PendingDrag { - pointerId: number; - startX: number; - startY: number; - docId: Identifier; - modifierActive: boolean; - stackHits?: string[] | null; - wasSelected: boolean; -} - -const useDocumentDrag = (options: UseDocumentDragOptions & { selectedDocumentIds: Identifier[] }) => { - const { - engine, - layoutRef, - dragTransformsRef, - documentLookup, - ensureDocumentSize, - resolveBaseMetrics, - bringToFront, - setDraggingId, - openOverlayForDoc, - recalcVisibleDocIds, - settings, - containerRef: providedContainerRef, - onDocumentActivate, - markLayoutDirty, - selectionOrderRef, - selectedDocumentIds, - } = options; - - const fallbackContainerRef = useRef(null); - const containerRef = providedContainerRef ?? fallbackContainerRef; - - const { - canvasPadding = 24, - debugDrag = false, - } = settings || {}; - - useEffect( - () => () => { - engine?.disposeInertiaAnimations?.(); - }, - [engine], - ); - - const tapHandler = usePointerTap({ - delay: 220, - onSingle: () => { }, - onDouble: ({ data, event }) => { - if (!data?.docId) { - return; - } - if (event?.altKey) { - openOverlayForDoc?.(data.docId, data.originInfo); - return; - } - onDocumentActivate?.(data.docId, event); - }, - }); - const dragStateRef = useRef(null); - const pendingDragRef = useRef(null); - - const clearDragTransforms = useCallback(() => { - const map = dragTransformsRef?.current; - if (!map?.clear) { - return; - } - map.clear(); - }, [dragTransformsRef]); - - const commitActiveDragTransforms = useCallback((docIds: Array | null = null) => { - const map = dragTransformsRef?.current; - if (!map || !map.size) { - return; - } - const keys = Array.isArray(docIds) && docIds.length - ? docIds - .map((id) => (id != null ? String(id) : null)) - .filter((value): value is string => Boolean(value)) - : Array.from(map.keys()); - keys.forEach((key) => { - const transform = map.get(key); - if (!transform) { - return; - } - const previous = layoutRef.current.get(key) || {}; - layoutRef.current.set(key, { - ...previous, - centerX: transform.centerX, - centerY: transform.centerY, - rotation: transform.rotation ?? previous.rotation ?? 0, - }); - }); - markLayoutDirty?.(); - }, [dragTransformsRef, layoutRef, markLayoutDirty]); - - const finishDrag = useCallback( - (pointerId: number, { clearTransforms = true }: { clearTransforms?: boolean } = {}) => { - const state = dragStateRef.current; - if (state && state.pointerId === pointerId) { - // Release capture if we have it (stored in a way we can access? - // ActiveDragSession doesn't store capturedTarget element reference because it's not serializable/safe for engine? - // Actually engine doesn't need it. But we might need it here. - // We can keep a local ref for capture or just let it go. - // For now, let's assume implicit release or we can store it in a separate ref if needed. - // But wait, ActiveDragSession in engine doesn't have capturedTarget. - // I should probably keep capturedTarget in a local ref or just ignore it as pointer capture is usually released automatically on up. - // Explicit release is better. - } - dragStateRef.current = null; - setDraggingId(null); - engine?.finalizeGroupDrag?.(); - if (clearTransforms) { - clearDragTransforms(); - } - }, - [clearDragTransforms, engine, setDraggingId], - ); - - const startDragSession = useCallback((pending: PendingDrag, event: PointerEventLike) => { - const { docId: docIdInput, modifierActive } = pending; - - const selectionFromRef: string[] = Array.isArray(selectionOrderRef?.current) - ? selectionOrderRef.current - .map((key) => (isDocumentEntry(key) ? getEntryId(key) : null)) - .filter((id): id is string => Boolean(id)) - .map(String) - : []; - - // 1. Get current global selection (prefer ref for immediate updates) - let selectionIds: string[] = selectionFromRef.length - ? selectionFromRef - : (selectedDocumentIds || []).map(String); - - // 3. Filter for valid documents - selectionIds = selectionIds.filter((id, index, array) => array.indexOf(id) === index && documentLookup.has(id)); - - if (!selectionIds.length) { - return; - } - - // 3. Sort by Z-index (ascending) - const layout = layoutRef.current; - const sortedSelectionIds = [...selectionIds] - .sort((a, b) => { - const aZ = layout.get(a)?.z ?? 0; - const bZ = layout.get(b)?.z ?? 0; - return aZ - bZ; - }); - - // 4. Determine Anchor - let anchorId = sortedSelectionIds[sortedSelectionIds.length - 1]; - if (docIdInput && sortedSelectionIds.includes(String(docIdInput)) && layout.has(String(docIdInput))) { - anchorId = String(docIdInput); - } else { - for (let i = sortedSelectionIds.length - 1; i >= 0; i--) { - if (layout.has(sortedSelectionIds[i])) { - anchorId = sortedSelectionIds[i]; - break; - } - } - } - - // 5. Promote Anchor to Top (End of List) - const finalSelectionIds = sortedSelectionIds.filter(id => id !== anchorId); - finalSelectionIds.push(anchorId); - - const doc = documentLookup.get(anchorId); - if (!doc) { - return; - } - - engine?.cancelInertiaAnimation?.(anchorId); - - const isGroupDrag = finalSelectionIds.length > 1; - - if (isGroupDrag) { - finalSelectionIds.forEach((id) => { - if (id !== anchorId) { - engine?.cancelInertiaAnimation?.(id); - } - }); - } - - const sizeInfo = ensureDocumentSize(doc) || { width: 0, height: 0 }; - const docWidth = sizeInfo.width || 320; - const docHeight = sizeInfo.height || 240; - const { baseScale } = resolveBaseMetrics(doc, docWidth, docHeight); - const normalizedBaseScale = - Number.isFinite(baseScale) && baseScale > 0 ? baseScale : 1; - - const entry = layoutRef.current.get(anchorId) || null; - const defaultCenterX = canvasPadding + docWidth / 2; - const defaultCenterY = canvasPadding + docHeight / 2; - const centerX = Number.isFinite(entry?.centerX) ? entry.centerX : defaultCenterX; - const centerY = Number.isFinite(entry?.centerY) ? entry.centerY : defaultCenterY; - - const initialCenter = { - x: centerX, - y: centerY, - }; - - if (!modifierActive) { - if (isGroupDrag) { - finalSelectionIds.forEach((id) => { - bringToFront(id); - }); - } else { - bringToFront(anchorId); - } - } - - if (entry && (entry.centerX !== centerX || entry.centerY !== centerY)) { - layoutRef.current.set(anchorId, { ...entry, centerX, centerY }); - } - - const containerRect = containerRef.current?.getBoundingClientRect?.() || null; - const containerLeft = containerRect?.left || 0; - const containerTop = containerRect?.top || 0; - const pointerCanvasX = event.clientX - containerLeft; - const pointerCanvasY = event.clientY - containerTop; - const pointerOffsetX = pointerCanvasX - centerX; - const pointerOffsetY = pointerCanvasY - centerY; - const initialRotationDeg = entry?.rotation ?? 0; - const initialRotationRad = (initialRotationDeg * Math.PI) / 180; - const cosInitial = Math.cos(-initialRotationRad); - const sinInitial = Math.sin(-initialRotationRad); - const localPointerOffsetX = pointerOffsetX * cosInitial - pointerOffsetY * sinInitial; - const localPointerOffsetY = pointerOffsetX * sinInitial + pointerOffsetY * cosInitial; - - const groupItems: DragGroupItem[] = finalSelectionIds.map((id) => { - const itemDoc = documentLookup.get(id); - const itemSize = ensureDocumentSize(itemDoc) || sizeInfo; - const itemWidth = itemSize.width || docWidth; - const itemHeight = itemSize.height || docHeight; - const itemEntry = layoutRef.current.get(id) || null; - const itemCenterX = - Number.isFinite(itemEntry?.centerX) ? itemEntry.centerX : canvasPadding + itemWidth / 2; - const itemCenterY = - Number.isFinite(itemEntry?.centerY) ? itemEntry.centerY : canvasPadding + itemHeight / 2; - - const baseOffsetX = itemCenterX - initialCenter.x; - const baseOffsetY = itemCenterY - initialCenter.y; - - const initialRotation = itemEntry?.rotation ?? 0; - const itemMass = computeDocumentMassGrams(itemDoc); - - return { - docId: id, - width: itemWidth, - height: itemHeight, - currentCenterX: itemCenterX, - currentCenterY: itemCenterY, - baseOffsetX, - baseOffsetY, - initialRotation: initialRotation, - targetRotation: initialRotation, - displayRotation: initialRotation, - angularVelocity: 0, - dynamicRotation: 0, - massGrams: itemMass, - }; - }); - - const eventTimestamp = - (Number.isFinite(event?.timeStamp)) - ? event.timeStamp - : performance?.now - ? performance.now() - : Date.now(); - - const massGrams = computeDocumentMassGrams(doc); - - const session: ActiveDragSession = { - pointerId: event.pointerId, - startX: pending.startX, - startY: pending.startY, - lastClientX: event.clientX, - lastClientY: event.clientY, - docKey: anchorId, - isGroup: true, - activeDocIds: finalSelectionIds, - originCenterX: initialCenter.x, - originCenterY: initialCenter.y, - currentCenterX: initialCenter.x, - currentCenterY: initialCenter.y, - rotation: entry?.rotation ?? 0, - restRotation: entry?.rotation ?? 0, - dynamicRotation: 0, - angularVelocity: 0, - moved: true, // It's moving now - width: docWidth, - height: docHeight, - dragScale: 1, - baseScale: normalizedBaseScale, - lastTimestamp: eventTimestamp, - localPointerOffsetX, - localPointerOffsetY, - containerRectLeft: containerLeft, - containerRectTop: containerTop, - groupItems, - groupElevated: !isGroupDrag, - stackSelectionApplied: true, - massGrams, - pointerRadiusScale: 1, - lastPointerCanvasX: pointerCanvasX, - lastPointerCanvasY: pointerCanvasY, - }; - - dragStateRef.current = session; - clearDragTransforms(); - engine?.startDragSession(session); - setDraggingId(anchorId); - - }, [ - selectedDocumentIds, - selectionOrderRef, - documentLookup, - layoutRef, - ensureDocumentSize, - resolveBaseMetrics, - canvasPadding, - bringToFront, - containerRef, - clearDragTransforms, - engine, - setDraggingId - ]); - - const handlePointerDown = useCallback( - (event: PointerEventLike, docIdInput: Identifier | null, options: PointerDownOptions) => { - const targetElement = getEventTargetElement(event); - if (targetElement?.closest && targetElement.closest('[data-desk-tag-chip="true"]')) { - return; - } - preventAll(event); - - if (!docIdInput) return; - - const capturedTarget = event.currentTarget instanceof HTMLElement ? event.currentTarget : null; - if (capturedTarget?.setPointerCapture) { - try { - capturedTarget.setPointerCapture(event.pointerId); - } catch (error) { - if (debugDrag) { - void error; - } - } - } - - pendingDragRef.current = { - pointerId: event.pointerId, - startX: event.clientX, - startY: event.startY || event.clientY, - docId: docIdInput, - modifierActive: Boolean(options.modifierActive), - stackHits: options.stackHits, - wasSelected: Boolean(options.wasSelected), - }; - }, [debugDrag]); - - const handlePointerMove = useCallback( - (event: PointerEventLike) => { - // Check for pending drag start - if (pendingDragRef.current && pendingDragRef.current.pointerId === event.pointerId) { - const pending = pendingDragRef.current; - const dx = event.clientX - pending.startX; - const dy = event.clientY - pending.startY; - const distSquared = dx * dx + dy * dy; - - if (distSquared > DRAG_HYSTERESIS_SQUARED) { - // Threshold exceeded, start actual drag session - startDragSession(pending, event); - pendingDragRef.current = null; - } - } - - const state = dragStateRef.current; - if (!state) { - return; - } - if (state.pointerId !== event.pointerId) { - return; - } - preventAll(event); - - const currentTimestamp = - (Number.isFinite(event?.timeStamp)) - ? event.timeStamp - : performance?.now - ? performance.now() - : Date.now(); - - engine?.updateDragSession(event.pointerId, event.clientX, event.clientY, currentTimestamp); - }, - [engine, startDragSession], - ); - - - const handlePointerUp = useCallback( - (event: PointerEventLike) => { - // Handle pending drag (click without drag) - if (pendingDragRef.current && pendingDragRef.current.pointerId === event.pointerId) { - const pending = pendingDragRef.current; - pendingDragRef.current = null; - - // This was just a click/tap - const docId = pending.docId; - const metaPressed = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey; - if (!metaPressed) { - bringToFront(docId); - } - - // Trigger tap handler - const docKey = String(docId); - const doc = documentLookup.get(docKey); - const sizeInfo = ensureDocumentSize(doc); - const entry = layoutRef.current.get(docKey); - - const originInfo = { - rotation: entry?.rotation || 0, - scale: 1, - width: sizeInfo?.width || 0, - height: sizeInfo?.height || 0, - }; - - tapHandler(event, { - docId, - originInfo, - docTitle: doc?.title || 'document', - }); - - finishDrag(event.pointerId); - return; - } - - const state = dragStateRef.current; - if (!state || state.pointerId !== event.pointerId) { - finishDrag(event.pointerId); - return; - } - - if (state.isGroup) { - engine?.finalizeGroupDrag?.(); - commitActiveDragTransforms(state.activeDocIds); - finishDrag(event.pointerId); - recalcVisibleDocIds(); - return; - } - - if (state.moved) { - commitActiveDragTransforms([state.docKey]); - const finalRotation = state.rotation ?? state.restRotation; - const inertiaState: InertiaSimulationState = { - docId: state.docKey, - restRotation: finalRotation, - dynamicRotation: 0, - angularVelocity: state.angularVelocity, - rotation: finalRotation, - width: state.width, - height: state.height, - dragScale: state.dragScale || 1, - lastTimestamp: state.lastTimestamp, - massGrams: state.massGrams, - }; - const docId = state.docKey; - finishDrag(event.pointerId); - engine?.startInertiaAnimation?.(docId, inertiaState); - return; - } - - finishDrag(event.pointerId); - }, - [ - bringToFront, - commitActiveDragTransforms, - documentLookup, - engine, - finishDrag, - recalcVisibleDocIds, - tapHandler, - ensureDocumentSize, - layoutRef - ], - ); - - const handlePointerCancel = useCallback( - (event: PointerEventLike) => { - if (pendingDragRef.current && pendingDragRef.current.pointerId === event.pointerId) { - pendingDragRef.current = null; - finishDrag(event.pointerId); - return; - } - - const state = dragStateRef.current; - if (state && state.pointerId === event.pointerId && state.moved) { - if (state.isGroup) { - engine?.finalizeGroupDrag?.(); - commitActiveDragTransforms(state.activeDocIds); - finishDrag(event.pointerId); - recalcVisibleDocIds(); - return; - } - - commitActiveDragTransforms([state.docKey]); - const finalRotation = state.rotation ?? state.restRotation; - const inertiaState: InertiaSimulationState = { - docId: state.docKey, - restRotation: finalRotation, - dynamicRotation: 0, - angularVelocity: state.angularVelocity, - rotation: finalRotation, - width: state.width, - height: state.height, - dragScale: state.dragScale || 1, - lastTimestamp: state.lastTimestamp, - massGrams: state.massGrams, - }; - const docId = state.docKey; - finishDrag(event.pointerId); - engine?.startInertiaAnimation?.(docId, inertiaState); - return; - } - finishDrag(event.pointerId); - }, - [commitActiveDragTransforms, engine, finishDrag, recalcVisibleDocIds], - ); - - return { - handlePointerDown, - handlePointerMove, - handlePointerUp, - handlePointerCancel, - }; -}; - -export default useDocumentDrag; diff --git a/frontend/src/desktop/workspaceEngine.ts b/frontend/src/desktop/workspaceEngine.ts deleted file mode 100644 index ce92fd3..0000000 --- a/frontend/src/desktop/workspaceEngine.ts +++ /dev/null @@ -1,1624 +0,0 @@ -import { clamp, formatTransform } from '../utils/math'; -import { - Polygon, - clipPolygon, - isPointInsideConvex, - polygonCentroid, -} from './utils/geometry'; -import { computeCardBounds } from './utils/layoutUtils'; -import { fetchLayoutRecords, upsertLayoutRecords } from './db'; -import { - ANGULAR_DAMPING, - CARD_BASE_WEIGHT_GRAMS, - CARD_PAGE_WEIGHT_GRAMS, - DEFAULT_Z_START, - DESK_CANVAS_PADDING, - DESK_CARD_MAX, - DESK_CARD_MIN, - DESK_DEFAULT_CANVAS_HEIGHT, - DESK_DEFAULT_CANVAS_WIDTH, - DESK_ROTATION_RANGE, - MAX_ANGULAR_VELOCITY, - MAX_DYNAMIC_ROTATION, - MAX_TIMESTEP, - MIN_TIMESTEP, - SETTLE_ANGULAR_VELOCITY, - TORQUE_TO_ACCELERATION, -} from '../constants/desktop'; -import type { DocumentId } from '../types/identifiers'; -type TenantId = import('../types/identifiers').TenantId; - -export { - ANGULAR_DAMPING, - CARD_BASE_WEIGHT_GRAMS, - CARD_PAGE_WEIGHT_GRAMS, - DESK_CANVAS_PADDING, - DESK_CARD_MAX, - DESK_CARD_MIN, - DESK_DEFAULT_CANVAS_HEIGHT, - DESK_DEFAULT_CANVAS_WIDTH, - DESK_ROTATION_RANGE, - MAX_ANGULAR_VELOCITY, - MAX_DYNAMIC_ROTATION, - MAX_TIMESTEP, - MIN_TIMESTEP, - SETTLE_ANGULAR_VELOCITY, - TORQUE_TO_ACCELERATION, -}; - - - -interface TransformOptions { - centerX?: number; - centerY?: number; - width?: number; - height?: number; - rotation?: number; - scale?: number; - zIndex?: number | null; -} - -interface CardDimensions { - width: number; - height: number; -} - -interface LayoutEntry { - centerX: number; - centerY: number; - rotation: number; - z: number; - width?: number; - height?: number; -} - -interface LayoutGenerationEntry { - id: string; - width: number; - height: number; - seedKey: string; -} - -interface LayoutGenerationOptions { - canvasWidth: number; - canvasHeight: number; - padding: number; - startZ?: number; - rotationRange?: number; - minSpacing?: number; - shelfWidth?: number; -} - -interface DocumentSize { - width: number; - height: number; -} - -interface BaseMetrics { - baseWidth: number; - baseHeight: number; - baseScale: number; -} - -export interface InertiaSimulationState { - docId: DocumentId; - restRotation: number; - rotation: number; - dynamicRotation: number; - angularVelocity: number; - width: number; - height: number; - dragScale?: number; - lastTimestamp: number; - frameId?: number; - massGrams?: number; -} - -export interface DragGroupItem { - docId: string; - width: number; - height: number; - currentCenterX: number; - currentCenterY: number; - baseOffsetX: number; - baseOffsetY: number; - initialRotation: number; - targetRotation: number; - displayRotation: number; - angularVelocity: number; - dynamicRotation: number; - massGrams: number; -} - -export interface ActiveDragSession { - pointerId: number; - startX: number; - startY: number; - lastClientX: number; - lastClientY: number; - docKey: string; - isGroup: boolean; - activeDocIds: string[]; - originCenterX: number; - originCenterY: number; - currentCenterX: number; - currentCenterY: number; - rotation: number; - restRotation: number; - dynamicRotation: number; - angularVelocity: number; - moved: boolean; - width: number; - height: number; - dragScale: number; - baseScale: number; - lastTimestamp: number; - localPointerOffsetX: number; - localPointerOffsetY: number; - containerRectLeft: number; - containerRectTop: number; - groupItems: DragGroupItem[]; - groupElevated: boolean; - stackSelectionApplied: boolean; - massGrams: number; - pointerRadiusScale: number; - lastPointerCanvasX: number; - lastPointerCanvasY: number; -} - -export type InteractionState = - | { type: 'idle' } - | { type: 'dragging'; session: ActiveDragSession }; - -interface WorkspaceSnapshot { - layout: Map; - canvasSize: { width: number; height: number }; - visibleDocIds: Set; - draggingId: string | null; - tagDropTargetId: string | null; - pendingTagDocId: string | null; - pendingRemovalTag: unknown; - initialLoadDone: boolean; -} - -type WorkspaceSubscriber = () => void; - -type DeskDocument = { id?: string | null } & Record; - -type EnsureDocumentSize = (doc: DeskDocument) => DocumentSize | null; - -type ResolveBaseMetrics = () => BaseMetrics; - -interface ItemRefs { - current: Map; -} - -interface WorkspaceEngineOptions { - allowLayoutPersistence?: boolean; - tenantId?: string | null; - viewId?: string | null; -} - -type UseSyncExternalStoreHook = ( - subscribe: (listener: () => void) => () => void, - getSnapshot: () => State, - getServerSnapshot: () => State, -) => State; - -const getMassScale = (massGrams?: number): number => { - if (!Number.isFinite(massGrams) || Number(massGrams) <= 0) { - return 1; - } - const normalized = Math.max(Number(massGrams), CARD_BASE_WEIGHT_GRAMS) / CARD_BASE_WEIGHT_GRAMS; - return Math.max(normalized, 1); -}; - -export const applyDomTransform = ( - node: HTMLElement | null, - { - centerX, - centerY, - width, - height, - rotation = 0, - scale = 1, - zIndex, - }: TransformOptions = {}, -): void => { - if (!node) { - return; - } - const w = Number(width) || 0; - const h = Number(height) || 0; - const cx = Number(centerX) || 0; - const cy = Number(centerY) || 0; - const originX = cx - w / 2; - const originY = cy - h / 2; - node.style.transform = formatTransform(originX, originY, rotation || 0, scale || 1); - if (zIndex != null && node.style.zIndex !== String(zIndex)) { - node.style.zIndex = String(zIndex); - } -}; - -export const clampCardDimensions = (width: number, height: number): CardDimensions | null => { - const w = Number(width); - const h = Number(height); - - if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) { - return null; - } - - const low = Math.max(DESK_CARD_MIN / w, DESK_CARD_MIN / h); - const high = Math.min(DESK_CARD_MAX / w, DESK_CARD_MAX / h); - - const candidates = []; - const addCandidate = (scale: number) => { - if (Number.isFinite(scale) && scale > 0) { - candidates.push(scale); - } - }; - - addCandidate(1); - addCandidate(low); - addCandidate(high); - - const best = candidates.reduce<{ scale: number; violation: number; deviation: number } | null>((acc, scale) => { - const scaledWidth = w * scale; - const scaledHeight = h * scale; - const violation = Math.max( - Math.max(DESK_CARD_MIN - scaledWidth, 0), - Math.max(scaledWidth - DESK_CARD_MAX, 0), - Math.max(DESK_CARD_MIN - scaledHeight, 0), - Math.max(scaledHeight - DESK_CARD_MAX, 0), - ); - const deviation = Math.abs(scale - 1); - if (!acc || violation < acc.violation || (violation === acc.violation && deviation < acc.deviation)) { - return { scale, violation, deviation }; - } - return acc; - }, null); - - const scale = best ? best.scale : 1; - return { - width: Math.round(w * scale), - height: Math.round(h * scale), - }; -}; - -export const computeFallbackCardSize = (docId: DocumentId): CardDimensions | null => { - const baseSeed = seededRandom(`${docId}:fallback-size`); - const aspectSeed = seededRandom(`${docId}:fallback-aspect`); - - const width = DESK_CARD_MIN + baseSeed * (DESK_CARD_MAX - DESK_CARD_MIN); - const isPortrait = aspectSeed < 0.5; - const normalizedSeed = isPortrait ? aspectSeed / 0.5 : (aspectSeed - 0.5) / 0.5; - const aspectRange = 0.75; - const aspect = isPortrait - ? 1 + normalizedSeed * aspectRange - : 1 / (1 + normalizedSeed * aspectRange); - const height = width * aspect; - - return clampCardDimensions(width, height); -}; - -function seededRandom(input: unknown): number { - const text = String(input); - let hash = 2166136261; - for (let index = 0; index < text.length; index += 1) { - hash ^= text.charCodeAt(index); - hash = Math.imul(hash, 16777619); - } - return (hash >>> 0) / 4294967295; -} - -function randomRangeFromSeed(seedKey: string, min: number, max: number): number { - const span = max - min; - if (span <= 0) return min; - const seed = seededRandom(seedKey); - return min + seed * span; -} - -function buildKey(docId: DocumentId, suffix: string): string { - return `${docId}::${suffix}`; -} - - -const generateInitialLayout = ( - entries: LayoutGenerationEntry[], - { - canvasWidth, - canvasHeight, - padding, - startZ = 0, - rotationRange = DESK_ROTATION_RANGE, - minSpacing = 48, - shelfWidth = 0, - }: LayoutGenerationOptions, -): { layout: Map; maxZ: number } => { - const layout = new Map(); - let currentZ = startZ; - let maxZ = startZ; - - if (!entries.length) { - return { layout, maxZ }; - } - - const shelfOffset = Math.max(shelfWidth, 0); - const spacingBuffer = Math.max(minSpacing, 0); - const placed: Array<{ x: number; y: number; radius: number }> = []; - - const resolveBounds = (width: number, height: number) => { - const halfWidth = width / 2; - const halfHeight = height / 2; - return { - minCenterX: padding + halfWidth, - maxCenterX: Math.max( - padding + halfWidth, - canvasWidth - shelfOffset - padding - halfWidth, - ), - minCenterY: padding + halfHeight, - maxCenterY: Math.max(padding + halfHeight, canvasHeight - padding - halfHeight), - }; - }; - - const evaluateCandidateSpacing = (x: number, y: number, radius: number) => { - if (!placed.length) { - return Number.POSITIVE_INFINITY; - } - let best = Number.POSITIVE_INFINITY; - for (let i = 0; i < placed.length; i += 1) { - const item = placed[i]; - const dx = item.x - x; - const dy = item.y - y; - const distance = Math.sqrt(dx * dx + dy * dy) - item.radius - radius - spacingBuffer; - if (distance < best) { - best = distance; - } - } - return best; - }; - - entries.forEach((entry) => { - const width = Number(entry.width) || 0; - const height = Number(entry.height) || 0; - if (!entry.id || width <= 0 || height <= 0) { - return; - } - - const { minCenterX, maxCenterX, minCenterY, maxCenterY } = resolveBounds(width, height); - - const radius = Math.sqrt(width * width + height * height) / 2; - - let bestScore = -Infinity; - let bestX = (minCenterX + maxCenterX) / 2; - let bestY = (minCenterY + maxCenterY) / 2; - const samplesPerAxis = 14; - for (let gx = 0; gx < samplesPerAxis; gx += 1) { - const fracX = (gx + 0.5) / samplesPerAxis; - for (let gy = 0; gy < samplesPerAxis; gy += 1) { - const fracY = (gy + 0.5) / samplesPerAxis; - const candidateX = minCenterX + fracX * (maxCenterX - minCenterX); - const candidateY = minCenterY + fracY * (maxCenterY - minCenterY); - const edgeSpacing = Math.min( - candidateX - minCenterX, - maxCenterX - candidateX, - candidateY - minCenterY, - maxCenterY - candidateY, - ) - spacingBuffer * 0.5; - if (edgeSpacing <= 0) { - continue; - } - const neighborSpacing = evaluateCandidateSpacing(candidateX, candidateY, radius); - const score = Math.min(edgeSpacing, neighborSpacing); - if (score > bestScore) { - bestScore = score; - bestX = candidateX; - bestY = candidateY; - } - } - } - const centerX = clamp(bestX, minCenterX, maxCenterX); - const centerY = clamp(bestY, minCenterY, maxCenterY); - - const rotation = randomRangeFromSeed( - buildKey(entry.id, 'rotation'), - -rotationRange, - rotationRange, - ); - - currentZ += 1; - layout.set(entry.id, { - centerX, - centerY, - rotation, - z: currentZ, - width, - height, - }); - maxZ = Math.max(maxZ, currentZ); - - placed.push({ x: centerX, y: centerY, radius }); - }); - - return { layout, maxZ }; -}; - -export class WorkspaceEngine { - allowLayoutPersistence: boolean; - tenantId: TenantId | null; - viewId: string | null; - layout: Map; - layoutSnapshot: Map; - persistedLayout: Map; - layoutDirty: boolean; - zCounter: number; - canvasSize: { width: number; height: number }; - visibleDocIds: Set; - draggingId: string | null; - tagDropTargetId: string | null; - pendingTagDocId: string | null; - pendingRemovalTag: unknown; - - activeDragDocIds: Set; - pendingSnapshotSync: boolean; - pendingPersistSync: boolean; - persistDebounceId: number | null; - items: DeskDocument[]; - documentLookup: Map; - ensureDocumentSize: EnsureDocumentSize; - resolveBaseMetrics: ResolveBaseMetrics; - snapshotCache: WorkspaceSnapshot; - subscribers: Set; - loadingPersisted: boolean; - pendingPersistence: unknown; - itemRefs: ItemRefs; - inertiaAnimations: Map; - - state: InteractionState; - initialLoadDone: boolean; - - constructor({ - allowLayoutPersistence = false, - tenantId = null, - viewId = null, - }: WorkspaceEngineOptions = {}) { - this.allowLayoutPersistence = allowLayoutPersistence; - this.tenantId = tenantId; - this.viewId = viewId; - - this.layout = new Map(); - this.layoutSnapshot = new Map(); - this.persistedLayout = new Map(); - this.layoutDirty = false; - this.zCounter = DEFAULT_Z_START; - this.canvasSize = { width: 0, height: 0 }; - this.visibleDocIds = new Set(); - this.draggingId = null; - this.tagDropTargetId = null; - this.pendingTagDocId = null; - this.pendingRemovalTag = null; - - this.activeDragDocIds = new Set(); - this.pendingSnapshotSync = false; - this.pendingPersistSync = false; - this.persistDebounceId = null; - this.pendingSnapshotSync = false; - this.pendingPersistSync = false; - - this.items = []; - this.documentLookup = new Map(); - this.ensureDocumentSize = () => null; - this.resolveBaseMetrics = () => ({ baseWidth: 0, baseHeight: 0, baseScale: 1 }); - - this.snapshotCache = this.buildSnapshot(); - this.subscribers = new Set(); - - this.loadingPersisted = false; - this.pendingPersistence = null; - this.itemRefs = { current: new Map() }; - this.inertiaAnimations = new Map(); - - this.state = { type: 'idle' }; - this.initialLoadDone = false; - } - - updateConfig({ allowLayoutPersistence, tenantId, viewId }: WorkspaceEngineOptions): void { - const allowChanged = - allowLayoutPersistence !== undefined - && allowLayoutPersistence !== this.allowLayoutPersistence; - const tenantChanged = tenantId !== undefined && tenantId !== this.tenantId; - const viewChanged = viewId !== undefined && viewId !== this.viewId; - - if (!allowChanged && !tenantChanged && !viewChanged) { - if ( - this.allowLayoutPersistence - && this.tenantId - && this.viewId - && !this.initialLoadDone - && !this.loadingPersisted - ) { - this.loadPersistedLayout(); - } - return; - } - - if (allowChanged) { - this.allowLayoutPersistence = allowLayoutPersistence; - } - if (tenantChanged) { - this.tenantId = tenantId; - } - if (viewChanged) { - this.viewId = viewId; - } - - if (!this.allowLayoutPersistence) { - this.persistedLayout = new Map(); - this.layoutDirty = false; - this.emit(); - return; - } - - if (!this.tenantId || !this.viewId) { - return; - } - - if (!this.initialLoadDone) { - this.loadPersistedLayout(); - } - } - - setItems(items: DeskDocument[] | null): void { - const normalized = Array.isArray(items) ? items : []; - this.items = normalized; - const canGenerateLayoutImmediately = - !this.allowLayoutPersistence - || !this.tenantId - || !this.viewId - || this.initialLoadDone; - if (canGenerateLayoutImmediately) { - this.ensureLayoutForItems(); - } - this.recalcVisibleDocIds(); - } - - setDocumentLookup(map: Map): void { - this.documentLookup = map instanceof Map ? map : new Map(); - this.recalcVisibleDocIds(); - } - - setEnsureDocumentSize(fn: EnsureDocumentSize): void { - this.ensureDocumentSize = fn; - } - - setResolveBaseMetrics(fn: ResolveBaseMetrics): void { - this.resolveBaseMetrics = fn; - } - - setItemRefs(ref: ItemRefs | null): void { - this.itemRefs = ref || { current: new Map() }; - } - - setCanvasSize(size: { width?: number | null; height?: number | null }): void { - const width = Number(size?.width) || 0; - const height = Number(size?.height) || 0; - if (this.canvasSize.width === width && this.canvasSize.height === height) { - return; - } - this.canvasSize = { width, height }; - this.ensureLayoutForItems(); - this.recalcVisibleDocIds(); - this.emit(); - } - - setDraggingId(docId: DocumentId | null): void { - const normalized = docId != null ? String(docId) : null; - if (this.draggingId === normalized) { - return; - } - this.draggingId = normalized; - this.emit(); - } - - get dragInProgress(): boolean { - return this.state.type === 'dragging'; - } - - get activeDragSession(): ActiveDragSession | null { - return this.state.type === 'dragging' ? this.state.session : null; - } - - beginDrag(_docIds: Array = []): void { - // Legacy method support or internal helper - // If we are starting a drag, we should transition state - // But this method was used to set flags. - // We'll keep it for now but it might be redundant if startDragSession handles everything. - // Let's make it a no-op or just update activeDragDocIds if we were keeping them separate, - // but we are trying to move to state machine. - // If called externally, it might be an issue. - // Assuming startDragSession is the main entry point now. - } - - endDrag(): void { - if (this.state.type === 'dragging') { - this.state = { type: 'idle' }; - this.activeDragDocIds.clear(); // Keep this for now if used elsewhere - this.setDraggingId(null); - this.flushPendingLayoutOps(); - } - } - - startDragSession(session: ActiveDragSession): void { - this.state = { type: 'dragging', session }; - - // Update legacy/derived state if needed - if (Array.isArray(session.activeDocIds)) { - this.activeDragDocIds = new Set(session.activeDocIds.map((id) => (id != null ? String(id) : null)).filter(Boolean)); - } else { - this.activeDragDocIds.clear(); - } - this.setDraggingId(session.docKey); - - // Initial transform application - session.groupItems.forEach((item) => { - if (item.docId === session.docKey) { - return; - } - const node = this.itemRefs.current.get(item.docId); - if (node) { - const itemEntry = this.layout.get(item.docId) || null; - applyDomTransform(node, { - centerX: item.currentCenterX, - centerY: item.currentCenterY, - width: item.width, - height: item.height, - rotation: item.displayRotation ?? 0, - scale: 1, - zIndex: itemEntry?.z, - }); - } - }); - } - - updateDragSession( - pointerId: number, - clientX: number, - clientY: number, - timestamp: number - ): void { - if (this.state.type !== 'dragging') { - return; - } - const state = this.state.session; - if (state.pointerId !== pointerId) { - return; - } - - const previousTimestamp = state.lastTimestamp ?? timestamp; - let dt = (timestamp - previousTimestamp) / 1000; - if (!Number.isFinite(dt) || dt <= 0) { - dt = MIN_TIMESTEP; - } - dt = clamp(dt, MIN_TIMESTEP, MAX_TIMESTEP); - - state.lastClientX = clientX; - state.lastClientY = clientY; - state.lastTimestamp = timestamp; - - const pointerCanvasX = clientX - state.containerRectLeft; - const pointerCanvasY = clientY - state.containerRectTop; - - // Helper for updating angular velocity based on pointer movement - const updatePointerAngularVelocity = ( - pX: number, - pY: number, - cX: number, - cY: number, - dtSec: number, - targetState: ActiveDragSession | DragGroupItem = state - ) => { - if (!Number.isFinite(dtSec) || dtSec <= 0) { - return; - } - const leverX = pX - cX; - const leverY = pY - cY; - if (!Number.isFinite(leverX) || !Number.isFinite(leverY)) { - return; - } - - const prevCanvasX = Number.isFinite(state.lastPointerCanvasX) - ? state.lastPointerCanvasX - : pX; - const prevCanvasY = Number.isFinite(state.lastPointerCanvasY) - ? state.lastPointerCanvasY - : pY; - - const velocityCanvasX = (pX - prevCanvasX) / dtSec; - const velocityCanvasY = (pY - prevCanvasY) / dtSec; - - if (!Number.isFinite(velocityCanvasX) || !Number.isFinite(velocityCanvasY)) { - return; - } - - const torque = leverX * velocityCanvasY - leverY * velocityCanvasX; - const influenceRadius = Math.max(targetState.width, targetState.height) / 2 || 1; - const radiusScale = clamp(Math.hypot(leverX, leverY) / influenceRadius, 0.2, 2.5); - - if (targetState === state) { - state.pointerRadiusScale = radiusScale; - } - - const torqueResponse = 0.0025 * radiusScale; - const angularVelocityDeg = clamp( - torque * torqueResponse, - -MAX_ANGULAR_VELOCITY, - MAX_ANGULAR_VELOCITY, - ); - - const mass = Math.max(targetState.massGrams || CARD_BASE_WEIGHT_GRAMS, CARD_BASE_WEIGHT_GRAMS); - const massScale = Math.max(mass / CARD_BASE_WEIGHT_GRAMS, 1); - targetState.angularVelocity = angularVelocityDeg / massScale; - }; - - // Helper for applying dynamic rotation - const applyDynamicRotation = ( - dtSec: number, - targetState: ActiveDragSession | DragGroupItem = state, - dampingFactor = 0.94 - ) => { - if (!Number.isFinite(dtSec) || dtSec <= 0) { - return; - } - const radiusInfluence = clamp(state.pointerRadiusScale || 1, 0.3, 3); - const response = 1.1 * radiusInfluence; - - let nextDynamic = (targetState.dynamicRotation || 0) + (targetState.angularVelocity || 0) * dtSec * response; - nextDynamic = clamp(nextDynamic, -MAX_DYNAMIC_ROTATION, MAX_DYNAMIC_ROTATION); - const adjustedDamping = Math.pow(dampingFactor, 1 / Math.max(radiusInfluence, 0.8)); - targetState.dynamicRotation = nextDynamic * adjustedDamping; - - if (targetState === state) { - state.rotation = state.restRotation + state.dynamicRotation; - } else { - const item = targetState as DragGroupItem; - item.displayRotation = (item.initialRotation || 0) + item.dynamicRotation; - } - }; - - const deltaX = clientX - state.startX; - const deltaY = clientY - state.startY; - - if (!state.moved) { - const distanceSquared = deltaX * deltaX + deltaY * deltaY; - // We need DRAG_HYSTERESIS_SQUARED here, but it's not imported. - // Assuming 4*4 = 16 for now or we should import it. - // Let's use a safe default if not available, but ideally we import it. - // Checking imports... it was in useDocumentDrag.ts import from constants. - // I should add it to imports in workspaceEngine.ts if not present. - // For now I'll use 16. - if (distanceSquared < 16) { - return; - } - state.moved = true; - if (!state.stackSelectionApplied) { - state.stackSelectionApplied = true; - } - if (!state.groupElevated) { - state.activeDocIds.forEach((id) => this.bringToFront(id)); - state.groupElevated = true; - } - } - - const canvasWidth = this.canvasSize.width || DESK_DEFAULT_CANVAS_WIDTH; - const canvasHeight = this.canvasSize.height || DESK_DEFAULT_CANVAS_HEIGHT; - - state.groupItems.forEach((item) => { - updatePointerAngularVelocity( - pointerCanvasX, - pointerCanvasY, - item.currentCenterX, - item.currentCenterY, - dt, - item - ); - - applyDynamicRotation(dt, item, 0.96); - - const gravitationDecay = 0.92; - item.baseOffsetX = (item.baseOffsetX || 0) * gravitationDecay; - item.baseOffsetY = (item.baseOffsetY || 0) * gravitationDecay; - - if (Math.abs(item.baseOffsetX) < 0.5) item.baseOffsetX = 0; - if (Math.abs(item.baseOffsetY) < 0.5) item.baseOffsetY = 0; - - const targetX = pointerCanvasX - state.localPointerOffsetX + (item.baseOffsetX || 0); - const targetY = pointerCanvasY - state.localPointerOffsetY + (item.baseOffsetY || 0); - - const smoothing = 0.18; - const stackFriction = 0.85; - const effectiveSmoothing = smoothing * stackFriction; - - item.currentCenterX += (targetX - item.currentCenterX) * effectiveSmoothing; - item.currentCenterY += (targetY - item.currentCenterY) * effectiveSmoothing; - - const bounds = computeCardBounds({ - width: item.width, - height: item.height, - canvasWidth, - canvasHeight, - padding: DESK_CANVAS_PADDING, - }); - - item.currentCenterX = clamp(item.currentCenterX, bounds.minX, bounds.maxX); - item.currentCenterY = clamp(item.currentCenterY, bounds.minY, bounds.maxY); - - const entry = this.layout.get(item.docId) || null; - const payload = { - centerX: item.currentCenterX, - centerY: item.currentCenterY, - rotation: item.displayRotation ?? 0, - width: item.width, - height: item.height, - scale: item.docId === state.docKey ? state.dragScale || 1 : 1, - zIndex: entry?.z, - }; - - this.applyTransform( - item.docId, - payload.centerX, - payload.centerY, - payload.width, - payload.height, - payload.rotation, - payload.scale, - payload.zIndex - ); - }); - - state.lastPointerCanvasX = pointerCanvasX; - state.lastPointerCanvasY = pointerCanvasY; - } - - flushPendingLayoutOps(): void { - if (this.pendingSnapshotSync) { - this.syncLayoutSnapshot(); - } - if (this.pendingPersistSync) { - this.persistLayoutSnapshot(); - } - } - - setTagDropTargetId(docId: DocumentId | null): void { - const normalized = docId != null ? String(docId) : null; - if (this.tagDropTargetId === normalized) { - return; - } - this.tagDropTargetId = normalized; - this.emit(); - } - - setPendingTagDocId(docId: DocumentId | null): void { - const normalized = docId != null ? String(docId) : null; - if (this.pendingTagDocId === normalized) { - return; - } - this.pendingTagDocId = normalized; - this.emit(); - } - - setPendingRemovalTag(payload: unknown): void { - if (payload === this.pendingRemovalTag) { - return; - } - this.pendingRemovalTag = payload; - this.emit(); - } - - markLayoutDirty(): void { - this.layoutDirty = true; - } - - getLayout(docId: DocumentId | null): LayoutEntry | null { - if (docId == null) { - return null; - } - const key = String(docId); - return this.layout.get(key) || null; - } - - updateLayoutEntry( - docId: DocumentId | null, - updater: (previous: LayoutEntry | null) => LayoutEntry | null, - ): void { - if (docId == null) { - return; - } - const key = String(docId); - const previous = this.layout.get(key) || null; - const next = updater(previous); - if (!next) { - this.layout.delete(key); - } else { - this.layout.set(key, next); - } - this.markLayoutDirty(); - this.syncLayoutSnapshot(); - this.persistLayoutSnapshot(); - } - - bringToFront(docId: DocumentId | null): void { - if (docId == null) { - return; - } - const key = String(docId); - const entry = this.layout.get(key); - if (!entry) { - return; - } - this.zCounter += 1; - this.layout.set(key, { ...entry, z: this.zCounter }); - this.markLayoutDirty(); - this.syncLayoutSnapshot(); - this.persistLayoutSnapshot(); - this.recalcVisibleDocIds(); - } - - applyTransform( - docId: DocumentId | null, - centerX: number, - centerY: number, - width: number, - height: number, - rotation: number, - scale = 1, - zIndex: number | null = null, - ): void { - const key = docId != null ? String(docId) : null; - if (!key) { - return; - } - const node = this.itemRefs.current.get(key); - if (node) { - applyDomTransform(node, { - centerX, - centerY, - width, - height, - rotation, - scale, - zIndex, - }); - } - } - - finalizeGroupDrag(): void { - if (this.state.type !== 'dragging') { - return; - } - const dragState = this.state.session; - if (!dragState?.groupItems) { - return; - } - - dragState.groupItems.forEach((item) => { - if (!item) { - return; - } - const key = item.docId != null ? String(item.docId) : null; - if (!key) { - return; - } - const entry = this.layout.get(key); - const centerX = item.currentCenterX ?? entry?.centerX ?? dragState.originCenterX ?? 0; - const centerY = item.currentCenterY ?? entry?.centerY ?? dragState.originCenterY ?? 0; - const rotation = item.displayRotation ?? entry?.rotation ?? 0; - - const nextEntry: LayoutEntry = { - centerX, - centerY, - rotation, - z: entry?.z ?? this.zCounter, - width: entry?.width ?? item.width, - height: entry?.height ?? item.height, - }; - - this.layout.set(key, nextEntry); - - this.applyTransform( - key, - centerX, - centerY, - item.width, - item.height, - rotation, - key === dragState.docKey ? dragState.dragScale || 1 : 1, - nextEntry.z, - ); - }); - - this.markLayoutDirty(); - this.syncLayoutSnapshot(); - this.persistLayoutSnapshot(); - this.endDrag(); - } - - cancelInertiaAnimation(docId: DocumentId | null): void { - const key = docId != null ? String(docId) : null; - if (!key) { - return; - } - const existing = this.inertiaAnimations.get(key); - if (existing?.frameId != null) { - window.cancelAnimationFrame(existing.frameId); - } - this.inertiaAnimations.delete(key); - } - - disposeInertiaAnimations(): void { - this.inertiaAnimations.forEach((animation) => { - if (animation?.frameId != null) { - window.cancelAnimationFrame(animation.frameId); - } - }); - this.inertiaAnimations.clear(); - } - - integrateRotation( - simulationState: InertiaSimulationState, - dt: number, - torque = 0, - dampingOverride: number | null = null, - ): boolean { - const key = simulationState.docId != null ? String(simulationState.docId) : null; - if (!key) { - return true; - } - const entry = this.layout.get(key); - if (!entry) { - return true; - } - - const centerX = Number(entry.centerX); - const centerY = Number(entry.centerY); - if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) { - return true; - } - - const massScale = getMassScale(simulationState.massGrams); - const torqueAcceleration = torque * TORQUE_TO_ACCELERATION; - let angularVelocity = simulationState.angularVelocity + torqueAcceleration * dt; - const maxAngularVelocity = MAX_ANGULAR_VELOCITY / massScale; - angularVelocity = clamp(angularVelocity, -maxAngularVelocity, maxAngularVelocity); - - const dampingConstant = Number.isFinite(dampingOverride) - ? Number(dampingOverride) - : ANGULAR_DAMPING; - const dampingFactor = Math.exp(-dampingConstant * dt); - angularVelocity *= dampingFactor; - - let dynamicRotation = simulationState.dynamicRotation + angularVelocity * dt; - const dynamicLimit = MAX_DYNAMIC_ROTATION / Math.sqrt(massScale); - if (dynamicRotation > dynamicLimit) { - dynamicRotation = dynamicLimit; - angularVelocity = Math.min(angularVelocity, 0); - } else if (dynamicRotation < -dynamicLimit) { - dynamicRotation = -dynamicLimit; - angularVelocity = Math.max(angularVelocity, 0); - } - - const isSettled = Math.abs(angularVelocity) < (SETTLE_ANGULAR_VELOCITY * 0.6) - || Math.abs(dynamicRotation) < (MAX_DYNAMIC_ROTATION * 0.05); - - if (isSettled) { - simulationState.angularVelocity = 0; - simulationState.dynamicRotation = 0; - simulationState.rotation = simulationState.restRotation; - } else { - simulationState.angularVelocity = angularVelocity; - simulationState.dynamicRotation = dynamicRotation; - simulationState.rotation = simulationState.restRotation + dynamicRotation; - if (Math.sign(simulationState.angularVelocity) !== Math.sign(angularVelocity)) { - simulationState.angularVelocity = angularVelocity; - } - } - - const rotation = simulationState.rotation; - const nextEntry = { ...entry, rotation }; - this.layout.set(key, nextEntry); - this.markLayoutDirty(); - - this.applyTransform( - key, - centerX, - centerY, - simulationState.width, - simulationState.height, - rotation, - simulationState.dragScale || 1, - nextEntry.z, - ); - - return isSettled; - } - - startInertiaAnimation(docId: DocumentId | null, baseState: InertiaSimulationState): void { - const raf = window.requestAnimationFrame; - if (!raf) { - return; - } - const key = docId != null ? String(docId) : null; - if (!key) { - return; - } - - this.cancelInertiaAnimation(key); - - const now = performance?.now ? performance.now() : Date.now(); - - const simulationState = { - ...baseState, - docId: key, - dragScale: baseState.dragScale || 1, - lastTimestamp: now, - massGrams: Number.isFinite(baseState.massGrams) - ? Math.max(Number(baseState.massGrams), CARD_BASE_WEIGHT_GRAMS) - : CARD_BASE_WEIGHT_GRAMS, - }; - - const step = (timestamp: number) => { - const safeTimestamp = Number.isFinite(timestamp) ? timestamp : now + 16; - const previous = simulationState.lastTimestamp; - let dt = (safeTimestamp - previous) / 1000; - if (!Number.isFinite(dt) || dt <= 0) { - dt = MIN_TIMESTEP; - } - dt = clamp(dt, MIN_TIMESTEP, MAX_TIMESTEP); - simulationState.lastTimestamp = safeTimestamp; - - const settled = this.integrateRotation(simulationState, dt, 0); - if (settled) { - this.inertiaAnimations.delete(key); - this.syncLayoutSnapshot(); - this.persistLayoutSnapshot(); - return; - } - simulationState.frameId = raf(step); - }; - - simulationState.frameId = raf(step); - this.inertiaAnimations.set(key, simulationState); - } - - syncLayoutSnapshot(): void { - if (this.dragInProgress) { - this.pendingSnapshotSync = true; - return; - } - this.pendingSnapshotSync = false; - this.layoutSnapshot = new Map(this.layout); - this.emit(); - } - - async persistLayoutSnapshot(): Promise { - if (this.dragInProgress) { - this.pendingPersistSync = true; - return; - } - if (!this.allowLayoutPersistence || !this.tenantId || !this.viewId) { - this.pendingPersistSync = false; - return; - } - if (!this.layoutDirty && !this.pendingPersistSync) { - return; - } - this.pendingPersistSync = false; - this.layoutDirty = false; - if (this.persistDebounceId) { - clearTimeout(this.persistDebounceId); - this.persistDebounceId = null; - } - const snapshotSource = this.layoutSnapshot && this.layoutSnapshot.size - ? this.layoutSnapshot - : this.layout; - const snapshot = new Map(snapshotSource); - const merged = new Map(this.persistedLayout); - snapshot.forEach((entry, docId) => { - if (!docId || !entry) { - return; - } - const centerX = Number(entry.centerX); - const centerY = Number(entry.centerY); - if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) { - return; - } - const rotation = Number.isFinite(Number(entry.rotation)) ? Number(entry.rotation) : 0; - const z = Number.isFinite(Number(entry.z)) ? Number(entry.z) : undefined; - merged.set(docId, { centerX, centerY, rotation, z }); - }); - - this.persistedLayout = merged; - - const records = []; - merged.forEach((entry, docId) => { - if (!docId || !entry) { - return; - } - records.push({ - documentId: docId, - centerX: entry.centerX, - centerY: entry.centerY, - rotation: entry.rotation ?? 0, - zIndex: entry.z ?? 0, - }); - }); - - const persistTask = async () => { - try { - await upsertLayoutRecords({ tenantId: this.tenantId, viewId: this.viewId, entries: records }); - } catch (error) { - console.warn('[desk] Failed to persist layout snapshot', error); - } - }; - - this.persistDebounceId = window.setTimeout(() => { - this.persistDebounceId = null; - void persistTask(); - }, 100); - } - - ensureLayoutForItems(): void { - const persistenceReady = !this.allowLayoutPersistence || !this.tenantId || !this.viewId || this.initialLoadDone; - const canvasReady = Boolean(this.canvasSize.width && this.canvasSize.height); - const sizesReady = !this.items.some((doc) => !this.ensureDocumentSize(doc)); - - if (!persistenceReady) { - return; - } - - if (!canvasReady) { - return; - } - if (!this.items.length) { - if (this.layout.size) { - this.layout = new Map(); - this.syncLayoutSnapshot(); - } - return; - } - - if (!sizesReady) { - return; - } - - const next = new Map(); - let maxZ = this.zCounter; - const canvasWidth = this.canvasSize.width || DESK_DEFAULT_CANVAS_WIDTH; - const canvasHeight = this.canvasSize.height || DESK_DEFAULT_CANVAS_HEIGHT; - const docsNeedingLayout: LayoutGenerationEntry[] = []; - - const currentEntries = new Map(this.layout); - - this.items.forEach((doc) => { - if (!doc?.id) { - return; - } - const docKey = String(doc.id); - const sizeInfo = this.ensureDocumentSize(doc); - if (!sizeInfo) { - return; - } - const { width: docWidth, height: docHeight } = sizeInfo; - const halfWidth = docWidth / 2; - const halfHeight = docHeight / 2; - - const minCenterX = DESK_CANVAS_PADDING + halfWidth; - const maxCenterX = Math.max(minCenterX, canvasWidth - DESK_CANVAS_PADDING - halfWidth); - const minCenterY = DESK_CANVAS_PADDING + halfHeight; - const maxCenterY = Math.max(minCenterY, canvasHeight - DESK_CANVAS_PADDING - halfHeight); - - const persisted = this.persistedLayout.get(docKey); - const currentEntry = currentEntries.get(docKey) || null; - let existing = persisted || currentEntry; - if (existing) { - const defaultCenterX = (minCenterX + maxCenterX) / 2; - const defaultCenterY = (minCenterY + maxCenterY) / 2; - const prevCenterX = Number.isFinite(existing.centerX) - ? Number(existing.centerX) - : defaultCenterX; - const prevCenterY = Number.isFinite(existing.centerY) - ? Number(existing.centerY) - : defaultCenterY; - const centerX = clamp(prevCenterX, minCenterX, maxCenterX); - const centerY = clamp(prevCenterY, minCenterY, maxCenterY); - const rotation = existing.rotation ?? 0; - const z = existing.z ?? maxZ; - maxZ = Math.max(maxZ, z); - next.set(docKey, { centerX, centerY, rotation, z, width: docWidth, height: docHeight }); - return; - } - - docsNeedingLayout.push({ - id: docKey, - width: docWidth, - height: docHeight, - seedKey: docKey, - }); - }); - - if (docsNeedingLayout.length) { - const { layout: generatedLayout, maxZ: updatedMaxZ } = generateInitialLayout( - docsNeedingLayout, - { - canvasWidth, - canvasHeight, - padding: DESK_CANVAS_PADDING, - startZ: maxZ, - rotationRange: DESK_ROTATION_RANGE, - minSpacing: 48, - shelfWidth: 0, - }, - ); - generatedLayout.forEach((entry, docId) => { - next.set(docId, entry); - }); - maxZ = Math.max(maxZ, updatedMaxZ); - } - - this.layout = next; - this.zCounter = Math.max(this.zCounter, maxZ); - this.syncLayoutSnapshot(); - this.persistLayoutSnapshot(); - this.recalcVisibleDocIds(); - } - - recalcVisibleDocIds(): void { - const ensureSize = this.ensureDocumentSize; - - const layoutMap = this.layout; - const canvasWidth = this.canvasSize.width || DESK_DEFAULT_CANVAS_WIDTH; - const canvasHeight = this.canvasSize.height || DESK_DEFAULT_CANVAS_HEIGHT; - - if (!layoutMap.size || canvasWidth <= 0 || canvasHeight <= 0) { - if (this.visibleDocIds.size) { - this.visibleDocIds = new Set(); - this.emit(); - } - return; - } - - const viewport = [ - { x: 0, y: 0 }, - { x: canvasWidth, y: 0 }, - { x: canvasWidth, y: canvasHeight }, - { x: 0, y: canvasHeight }, - ]; - - const entries: Array<{ key: string; z: number; polygon: Polygon }> = []; - layoutMap.forEach((entry, docKey) => { - if (!docKey) { - return; - } - const doc = this.documentLookup.get(docKey); - if (!doc) { - return; - } - const sizeInfo = ensureSize(doc); - if (!sizeInfo) { - return; - } - const { width: cardWidth, height: cardHeight } = sizeInfo; - const rotationDeg = Number(entry?.rotation) || 0; - const rotationRad = (rotationDeg * Math.PI) / 180; - const cosRot = Math.cos(rotationRad); - const sinRot = Math.sin(rotationRad); - const halfWidth = cardWidth / 2; - const halfHeight = cardHeight / 2; - const localCorners = [ - { x: -halfWidth, y: -halfHeight }, - { x: halfWidth, y: -halfHeight }, - { x: halfWidth, y: halfHeight }, - { x: -halfWidth, y: halfHeight }, - ]; - const centerX = entry?.centerX ?? DESK_CANVAS_PADDING + cardWidth / 2; - const centerY = entry?.centerY ?? DESK_CANVAS_PADDING + cardHeight / 2; - const corners = localCorners.map(({ x, y }) => ({ - x: centerX + x * cosRot - y * sinRot, - y: centerY + x * sinRot + y * cosRot, - })); - const clipped = clipPolygon(corners, viewport); - if (!clipped.length) { - return; - } - entries.push({ - key: docKey, - z: entry?.z ?? 0, - polygon: clipped, - }); - }); - - if (!entries.length) { - if (this.visibleDocIds.size) { - this.visibleDocIds = new Set(); - this.emit(); - } - return; - } - - entries.sort((a, b) => (b.z || 0) - (a.z || 0)); - - const visiblePolygons: Polygon[] = []; - const result = new Set(); - - entries.forEach(({ key, polygon }) => { - if (polygon.length < 3) { - return; - } - - let fullyCovered = true; - for (let i = 0; i < polygon.length; i += 1) { - const point = polygon[i]; - const inside = visiblePolygons.some((poly) => isPointInsideConvex(point, poly)); - if (!inside) { - fullyCovered = false; - break; - } - } - - if (fullyCovered) { - const centroid = polygonCentroid(polygon); - if (!visiblePolygons.some((poly) => isPointInsideConvex(centroid, poly))) { - fullyCovered = false; - } - } - - if (!fullyCovered) { - result.add(key); - visiblePolygons.push(polygon); - } - }); - - const sameSize = result.size === this.visibleDocIds.size; - if (sameSize) { - let identical = true; - result.forEach((id) => { - if (!this.visibleDocIds.has(id)) { - identical = false; - } - }); - if (identical) { - this.visibleDocIds.forEach((id) => { - if (!result.has(id)) { - identical = false; - } - }); - } - if (identical) { - return; - } - } - - this.visibleDocIds = result; - this.emit(); - } - - subscribe(listener: WorkspaceSubscriber): () => void { - this.subscribers.add(listener); - return () => { - this.subscribers.delete(listener); - }; - } - - getSnapshot = (): WorkspaceSnapshot => this.snapshotCache; - - buildSnapshot(): WorkspaceSnapshot { - return { - layout: this.layoutSnapshot, - canvasSize: this.canvasSize, - visibleDocIds: this.visibleDocIds, - draggingId: this.draggingId, - tagDropTargetId: this.tagDropTargetId, - pendingTagDocId: this.pendingTagDocId, - pendingRemovalTag: this.pendingRemovalTag, - initialLoadDone: this.initialLoadDone, - }; - } - - emit(): void { - this.snapshotCache = this.buildSnapshot(); - this.subscribers.forEach((listener) => { - try { - listener(); - } catch (error) { - console.error('WorkspaceEngine listener failed', error); - } - }); - } - - async loadPersistedLayout(): Promise { - if (!this.allowLayoutPersistence || !this.tenantId || !this.viewId) { - return; - } - if (this.loadingPersisted || this.initialLoadDone) { - return; - } - this.loadingPersisted = true; - try { - const records = await fetchLayoutRecords({ tenantId: this.tenantId, viewId: this.viewId }); - const map = new Map(); - records.forEach((record) => { - if (!record || !record.documentId) { - return; - } - map.set(String(record.documentId), { - centerX: Number(record.centerX) || 0, - centerY: Number(record.centerY) || 0, - rotation: Number(record.rotation) || 0, - z: Number(record.zIndex) || 0, - }); - }); - this.persistedLayout = map; - this.layoutDirty = false; - if (records.length) { - const maxZ = records.reduce((acc, record) => Math.max(acc, Number(record.zIndex) || 0), DEFAULT_Z_START); - this.zCounter = Math.max(this.zCounter, maxZ); - } - this.layout = new Map(map); - this.layoutSnapshot = new Map(this.layout); - this.ensureLayoutForItems(); - this.initialLoadDone = true; - this.emit(); - } catch (error) { - console.warn('[desk] Failed to load persisted layout', error); - } finally { - this.loadingPersisted = false; - if (!this.initialLoadDone) { - this.initialLoadDone = true; - if (!this.layout.size) { - this.ensureLayoutForItems(); - } - this.emit(); - } - } - } -} - -export const useWorkspaceSnapshot = ( - engine: WorkspaceEngine, - useSyncExternalStoreHook: UseSyncExternalStoreHook, -): WorkspaceSnapshot => { - const useSyncExternalStore = useSyncExternalStoreHook; - if (!useSyncExternalStore) { - throw new Error('useWorkspaceSnapshot requires useSyncExternalStore hook'); - } - return useSyncExternalStore( - (listener) => engine.subscribe(listener), - () => engine.getSnapshot(), - () => engine.getSnapshot(), - ); -}; - -/* istanbul ignore next */ -const commonJsModule = (globalThis as typeof globalThis & { - module?: { exports?: Record }; -}).module; -if (commonJsModule?.exports) { - commonJsModule.exports = { - WorkspaceEngine, - DESK_CANVAS_PADDING, - DESK_ROTATION_RANGE, - DESK_DEFAULT_CANVAS_WIDTH, - DESK_DEFAULT_CANVAS_HEIGHT, - DESK_CARD_MIN, - DESK_CARD_MAX, - MIN_TIMESTEP, - MAX_TIMESTEP, - MAX_DYNAMIC_ROTATION, - MAX_ANGULAR_VELOCITY, - ANGULAR_DAMPING, - TORQUE_TO_ACCELERATION, - SETTLE_ANGULAR_VELOCITY, - clampCardDimensions, - computeFallbackCardSize, - useWorkspaceSnapshot, - }; -} diff --git a/frontend/src/styles/workspace/workspace-items.css b/frontend/src/styles/workspace/workspace-items.css index 0a764eb..9476696 100644 --- a/frontend/src/styles/workspace/workspace-items.css +++ b/frontend/src/styles/workspace/workspace-items.css @@ -30,10 +30,7 @@ outline-offset: 4px; } -.desk-item.is-dragging { - cursor: grabbing; - transition: none; -} + .desk-item.is-tag-target .desk-item__card { outline: 0.35rem dashed var(--accent); @@ -136,4 +133,4 @@ .desk-item__tags .tag-chip--tear-pending { opacity: 0.35; -} +} \ No newline at end of file