Files
papercrate/frontend/src/desktop/DesktopWorkspace.tsx
T

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;