import React, { useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import { LayoutStore, LayoutCard } from './LayoutSystem'; import DesktopDocumentCard from './DesktopDocumentCard'; import usePreviewMetadata from './hooks/usePreviewMetadata'; import useDeskTagInteractions from './tags/useDeskTagInteractions'; import { useCardPointer } from './useCardPointer'; import '../styles/workspace/workspace-layout.css'; import '../styles/workspace/workspace-items.css'; import '../styles/workspace/workspace-cards.css'; import { useWorkspaceSelectionContext } from '../app/WorkspaceSelectionContext'; import { createDocumentEntryKey } from '../app/entryKey'; import { PointerTrackingProvider, usePointerTracking } from './PointerTrackingContext'; import type { Identifier } from '../types/identifiers'; import type { DocumentsListEntry, Document } from '../types/documents'; import { useAppState } from '../app/appState'; import { useDocumentOpen } from '../contexts/DocumentOpenContext'; type TagLike = { id?: Identifier | null; label?: string; color?: string | null } | null; type OverlaySource = { url: string; alt?: string | null; mimeType?: string | null; }; export interface DeskDocument { id?: Identifier | null; title?: string; tags?: TagLike[] | null; documentLink?: OverlaySource | null; [key: string]: unknown; } interface DocumentSizeInfo { width: number; height: number; source?: 'snapshot' | 'metadata' | 'fallback'; } // Fallback size computation const computeFallbackCardSize = (_doc: DeskDocument, defaultSize: number = 200): DocumentSizeInfo => { const size = Math.round(defaultSize * (1 / Math.SQRT2)); return { width: size, height: size, source: 'fallback' }; }; export interface DesktopWorkspaceProps { entries: DocumentsListEntry[]; ensureAssetUrl?: (...args: any[]) => Promise; getDocumentAsset?: (...args: any[]) => unknown; onSelectionChange?: (selectedIds: Identifier[]) => void; onDocumentTagAttach?: (docId: Identifier, tagId: Identifier) => void; onDocumentTagDetach?: (docId: Identifier, tagId: Identifier) => void; viewId?: string | null; defaultCardSize?: number; } // Wrapper to handle hooks per card const DesktopDocumentContainer: React.FC & { onSelect: (ids: string[], extend?: boolean) => void; onDeselect: (ids: string[]) => void; onDocumentActivate?: (id: string, event?: any) => void; selection: string[]; requestCanvasFocus?: () => void; }> = React.memo((props) => { const { layoutCard, selected, onSelect, onDeselect, onDocumentActivate, selection, requestCanvasFocus } = props; // We assume layoutCard is always present in this context const cardPointerHandlers = useCardPointer(layoutCard!, !!selected, selection, onSelect, onDeselect, onDocumentActivate, requestCanvasFocus); return ( ); }); DesktopDocumentContainer.displayName = 'DesktopDocumentContainer'; const DesktopWorkspaceContent: React.FC = ({ entries, ensureAssetUrl, getDocumentAsset, onSelectionChange, onDocumentTagAttach, onDocumentTagDetach, viewId, defaultCardSize = 200, }) => { const { openDocument } = useDocumentOpen(); const { tenant } = useAppState(); const tenantId = tenant?.id as Identifier; const { addPointer, removePointer } = usePointerTracking(); const containerRef = useRef(null); const [isLayoutReady, setIsLayoutReady] = useState(false); const [isLayoutLoaded, setIsLayoutLoaded] = useState(false); const [hasContainerSize, setHasContainerSize] = useState(false); 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]); // Layout System Initialization const layoutStore = useMemo(() => new LayoutStore(), []); const layoutRef = useRef>(new Map()); // Update container size in store useEffect(() => { if (!containerRef.current) return; const observer = new ResizeObserver((entries) => { for (const entry of entries) { const { width, height } = entry.contentRect; layoutStore.setContainerSize(width, height); if (width > 0 && height > 0) { setHasContainerSize(true); } } }); observer.observe(containerRef.current); return () => observer.disconnect(); }, [layoutStore]); useEffect(() => { if (tenantId && viewId) { // Clear store when switching views to prevent stale items layoutStore.clear(); setIsLayoutLoaded(false); setIsLayoutReady(false); // Immediately hide cards during transition layoutStore.loadLayout(String(tenantId), viewId).then(() => { setIsLayoutLoaded(true); }); } else { // No tenant/view ID means no saved layout to load - skip directly to loaded setIsLayoutLoaded(true); } }, [layoutStore, tenantId, viewId]); // Only set layout ready when both container has size AND layout is loaded useEffect(() => { setIsLayoutReady(hasContainerSize && isLayoutLoaded); }, [hasContainerSize, isLayoutLoaded]); // Sync LayoutStore items with current entries to remove stale items useEffect(() => { const currentIds = new Set(items.map((doc, index) => doc.id ? String(doc.id) : `temp-${index}`)); // Identify and remove items that are no longer present for (const id of layoutStore.items.keys()) { if (!currentIds.has(id)) { layoutStore.unregister(id); } } }, [items, layoutStore]); // Selection Context const { selectedDocumentIds, setSelectedEntries, clearSelection, } = useWorkspaceSelectionContext(); const handleSelectionChange = useCallback((ids: Identifier[]) => { // Sort IDs by Z-index (ascending) so the last item is the top-most const sortedIds = [...ids].sort((a, b) => { const cardA = layoutStore.items.get(String(a)); const cardB = layoutStore.items.get(String(b)); const zA = cardA ? cardA.z : -Infinity; const zB = cardB ? cardB.z : -Infinity; return zA - zB; }); if (setSelectedEntries) { const keys = sortedIds.map(id => createDocumentEntryKey(id)); setSelectedEntries(keys); } onSelectionChange?.(sortedIds); }, [setSelectedEntries, onSelectionChange, layoutStore]); const onClearSelection = useCallback(() => { clearSelection ? clearSelection() : handleSelectionChange([]); }, [clearSelection, handleSelectionChange]); const metadataMap = usePreviewMetadata(items, getDocumentAsset, ensureAssetUrl); const ensureDocumentSize = useCallback((doc: DeskDocument): DocumentSizeInfo => { if (doc.id) { const meta = metadataMap.get(String(doc.id)); if (meta && meta.width && meta.height) return { width: meta.width, height: meta.height, source: 'metadata' }; } return computeFallbackCardSize(doc, defaultCardSize); }, [metadataMap, defaultCardSize]); // Sync LayoutStore to layoutRef useEffect(() => { const sync = () => { layoutRef.current = layoutStore.items; }; sync(); }, [layoutStore.items]); const handleShellKeyDown = useCallback(() => { }, []); const focusShell = useCallback(() => { if (containerRef.current) { containerRef.current.focus(); } }, []); // Tag Interactions const tagInteractions = useDeskTagInteractions({ onAssignTagToDocument: (docId: string, tagId: string) => { onDocumentTagAttach?.(docId, tagId); }, onRemoveTagFromDocument: (docId: string, tagId: string) => { onDocumentTagDetach?.(docId, tagId); }, requestCanvasFocus: focusShell, }); useEffect(() => { const handleWindowKeyDown = (e: KeyboardEvent) => { // Only handle events if the container itself is the target (focused) if (e.target !== containerRef.current) { return; } // Space preview logic if (e.code === 'Space' && selectedDocumentIds.length > 0) { const lastId = selectedDocumentIds[selectedDocumentIds.length - 1]; const doc = items.find(i => String(i.id) === lastId); if (doc) { e.preventDefault(); openDocument(doc, 'preview'); return; } } // Navigation logic if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { e.preventDefault(); const layoutItems = Array.from(layoutStore.items.values()) as LayoutCard[]; if (layoutItems.length === 0) return; let activeCard = null; if (selectedDocumentIds.length > 0) { // Use the last selected item as the anchor const lastId = selectedDocumentIds[selectedDocumentIds.length - 1]; activeCard = layoutStore.items.get(lastId); } // If no selection or active card not found, select the top-most item if (!activeCard) { const topMost = layoutItems.reduce((prev, current) => (prev.z > current.z ? prev : current)); handleSelectionChange([topMost.id]); return; } const cx = activeCard.centerX; const cy = activeCard.centerY; let bestCandidate = null; let minScore = Infinity; for (const candidate of layoutItems) { if (candidate.id === activeCard.id) continue; const dx = candidate.centerX - cx; const dy = candidate.centerY - cy; let valid = false; let primaryDist = 0; let offAxisDist = 0; switch (e.key) { case 'ArrowRight': if (dx > 0 && dx > Math.abs(dy)) { valid = true; primaryDist = dx; offAxisDist = Math.abs(dy); } break; case 'ArrowLeft': if (dx < 0 && -dx > Math.abs(dy)) { valid = true; primaryDist = -dx; offAxisDist = Math.abs(dy); } break; case 'ArrowDown': if (dy > 0 && dy > Math.abs(dx)) { valid = true; primaryDist = dy; offAxisDist = Math.abs(dx); } break; case 'ArrowUp': if (dy < 0 && -dy > Math.abs(dx)) { valid = true; primaryDist = -dy; offAxisDist = Math.abs(dx); } break; } if (valid) { // Weighted score: favor items closer in the primary direction, penalize off-axis // We use a multiplier for off-axis distance to prefer "straighter" lines // Reduced off-axis weight to favor directional distance (grid-like behavior) let score = primaryDist + (offAxisDist * 0.2); // Z-Order Bonus: Subtract a small value based on Z-index to favor higher items // Assuming max Z is around 10000, 0.1 gives a max bonus of 1000, which is significant but less than primary distance usually score -= (candidate.z * 0.05); // Obstruction Penalty: Check if the candidate is obstructed // If less than 5% is visible, treat as obstructed if (candidate.getVisibleFraction() < 0.05) { score += 5000; // Huge penalty for obstructed items } if (score < minScore) { minScore = score; bestCandidate = candidate; } } } if (bestCandidate) { if (e.shiftKey) { // Additive selection const newSelection = new Set(selectedDocumentIds); newSelection.add(bestCandidate.id); handleSelectionChange(Array.from(newSelection)); } else { // Replace selection handleSelectionChange([bestCandidate.id]); } } } }; window.addEventListener('keydown', handleWindowKeyDown); return () => window.removeEventListener('keydown', handleWindowKeyDown); }, [selectedDocumentIds, items, openDocument, layoutStore, handleSelectionChange]); return ( <>
{ if (e.target === e.currentTarget) { // Register background pointer addPointer(e.pointerId); (e.target as Element).setPointerCapture(e.pointerId); onClearSelection(); focusShell(); } }} onPointerUp={(e) => { if (e.target === e.currentTarget) { removePointer(e.pointerId); (e.target as Element).releasePointerCapture(e.pointerId); } }} onPointerCancel={(e) => { if (e.target === e.currentTarget) { removePointer(e.pointerId); (e.target as Element).releasePointerCapture(e.pointerId); } }} onDrop={tagInteractions.handleCanvasDrop} onDragOver={tagInteractions.handleCanvasDragOver} > {isLayoutReady && items.map((doc, index) => { const docId = doc.id ? String(doc.id) : `temp-${index}`; const isSelected = selectedDocumentIds.includes(docId); const size = ensureDocumentSize(doc); // Extract page count const metadata = doc.current_version?.metadata as { page_count?: number } | undefined; const pageCount = metadata?.page_count ?? 1; const layoutCard = layoutStore.initialize(docId, null, { width: size.width, height: size.height, pageCount, maxSize: defaultCardSize }); return ( { }} onDocumentActivate={(_id, event) => { const isPreview = event && ((event as any).altKey || (event as any).button === 1); openDocument(doc, isPreview ? 'preview' : 'sidepanel'); }} layoutCard={layoutCard} onTagDragEnter={tagInteractions.handleTagDragEnterDoc} onTagDragOver={tagInteractions.handleTagDragOverDoc} onTagDragLeave={tagInteractions.handleTagDragLeaveDoc} onTagDrop={tagInteractions.handleTagDropOnDoc} onDocTagDragStart={tagInteractions.handleDocTagDragStart} onDocTagDragEnd={tagInteractions.handleDocTagDragEnd} onSelect={(ids, extend = false) => { if (!extend) { handleSelectionChange(ids); } else { const newSelection = new Set(selectedDocumentIds); ids.forEach(id => newSelection.add(id)); handleSelectionChange(Array.from(newSelection)); } }} onDeselect={(ids) => { const newSelection = new Set(selectedDocumentIds); ids.forEach(id => newSelection.delete(id)); handleSelectionChange(Array.from(newSelection)); }} selection={selectedDocumentIds} requestCanvasFocus={focusShell} /> ); })}
); }; const DesktopWorkspace: React.FC = (props) => { return ( ); }; export default DesktopWorkspace;