470 lines
16 KiB
TypeScript
470 lines
16 KiB
TypeScript
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<unknown>;
|
|
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<React.ComponentProps<typeof DesktopDocumentCard> & {
|
|
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 (
|
|
<DesktopDocumentCard
|
|
{...props}
|
|
cardPointerHandlers={cardPointerHandlers}
|
|
/>
|
|
);
|
|
});
|
|
|
|
DesktopDocumentContainer.displayName = 'DesktopDocumentContainer';
|
|
|
|
const DesktopWorkspaceContent: React.FC<DesktopWorkspaceProps> = ({
|
|
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<HTMLDivElement>(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<Map<string, LayoutCard>>(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 (
|
|
<>
|
|
<div
|
|
className="desk-shell"
|
|
>
|
|
<div
|
|
className="desk-canvas"
|
|
ref={containerRef}
|
|
tabIndex={0}
|
|
onKeyDown={handleShellKeyDown}
|
|
onPointerDown={(e) => {
|
|
if (e.target === e.currentTarget) {
|
|
// Register background pointer
|
|
addPointer(e.pointerId);
|
|
(e.target as Element).setPointerCapture(e.pointerId);
|
|
|
|
onClearSelection();
|
|
focusShell();
|
|
}
|
|
}}
|
|
onPointerUp={(e) => {
|
|
if (e.target === e.currentTarget) {
|
|
removePointer(e.pointerId);
|
|
(e.target as Element).releasePointerCapture(e.pointerId);
|
|
}
|
|
}}
|
|
onPointerCancel={(e) => {
|
|
if (e.target === e.currentTarget) {
|
|
removePointer(e.pointerId);
|
|
(e.target as Element).releasePointerCapture(e.pointerId);
|
|
}
|
|
}}
|
|
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 (
|
|
<DesktopDocumentContainer
|
|
key={docId}
|
|
doc={doc}
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
touchAction: 'none',
|
|
willChange: 'transform'
|
|
}}
|
|
shouldLoad={true}
|
|
matchesFilter={true}
|
|
|
|
selected={isSelected}
|
|
docTagTokens=""
|
|
ensureAssetUrl={ensureAssetUrl}
|
|
getDocumentAsset={getDocumentAsset}
|
|
handleNavigatorSnapshot={() => { }}
|
|
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}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = (props) => {
|
|
return (
|
|
<PointerTrackingProvider>
|
|
<DesktopWorkspaceContent {...props} />
|
|
</PointerTrackingProvider>
|
|
);
|
|
};
|
|
|
|
export default DesktopWorkspace;
|