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

329 lines
11 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 { usePreviewContext } from '../preview/PreviewContext';
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): DocumentSizeInfo => {
return { width: 200, height: 200, source: 'fallback' };
};
export interface DesktopWorkspaceProps {
entries: DocumentsListEntry[];
ensureAssetUrl?: (...args: any[]) => Promise<unknown>;
getDocumentAsset?: (...args: any[]) => unknown;
onDocumentActivate?: (doc: DeskDocument, event?: unknown) => void;
onSelectionChange?: (selectedIds: Identifier[]) => void;
onDocumentTagDrop?: (docId: Identifier, tag: any) => void;
tenantId?: Identifier | null;
viewId?: string | null;
}
// 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) => void;
selection: string[];
}> = React.memo((props) => {
const { layoutCard, selected, onSelect, onDeselect, onDocumentActivate, selection } = props;
// We assume layoutCard is always present in this context
const cardPointerHandlers = useCardPointer(layoutCard!, !!selected, selection, onSelect, onDeselect, onDocumentActivate);
return (
<DesktopDocumentCard
{...props}
cardPointerHandlers={cardPointerHandlers}
/>
);
});
DesktopDocumentContainer.displayName = 'DesktopDocumentContainer';
const DesktopWorkspaceContent: React.FC<DesktopWorkspaceProps> = ({
entries,
ensureAssetUrl,
getDocumentAsset,
onDocumentActivate,
onSelectionChange,
onDocumentTagDrop,
tenantId,
viewId,
}) => {
const { openPreview } = usePreviewContext();
const { addPointer, removePointer } = usePointerTracking();
const containerRef = useRef<HTMLDivElement>(null);
const [isLayoutReady, setIsLayoutReady] = 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) {
setIsLayoutReady(true);
}
}
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, [layoutStore]);
useEffect(() => {
if (tenantId && viewId) {
// Clear store when switching views to prevent stale items
layoutStore.clear();
layoutStore.loadLayout(String(tenantId), viewId);
}
}, [layoutStore, tenantId, viewId]);
// 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[]) => {
if (setSelectedEntries) {
const keys = ids.map(id => createDocumentEntryKey(id));
setSelectedEntries(keys);
}
onSelectionChange?.(ids);
}, [setSelectedEntries, onSelectionChange]);
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);
}, [metadataMap]);
// Sync LayoutStore to layoutRef
useEffect(() => {
const sync = () => {
layoutRef.current = layoutStore.items;
};
sync();
}, [layoutStore.items]);
const handleShellKeyDown = useCallback(() => { }, []);
const focusShell = useCallback(() => { }, []);
// Tag Interactions
const [pendingRemovalTag, setPendingRemovalTag] = useState<{ docId?: string; tagId?: string } | null>(null);
const [tagDropTargetId, setTagDropTargetId] = useState<string | null>(null);
const [pendingTagDocId, setPendingTagDocId] = useState<string | null>(null);
const tagEngine = useMemo(() => ({
setPendingRemovalTag,
setTagDropTargetId,
setPendingTagDocId,
}), []);
const tagInteractions = useDeskTagInteractions({
engine: tagEngine,
onAssignTagToDocument: (docId: string, tag: any) => {
onDocumentTagDrop?.(docId, tag);
},
requestCanvasFocus: focusShell,
});
useEffect(() => {
const handleWindowKeyDown = (e: KeyboardEvent) => {
if (e.code === 'Space' && selectedDocumentIds.length > 0) {
// Preview the last selected document
const lastId = selectedDocumentIds[selectedDocumentIds.length - 1];
const doc = items.find(i => String(i.id) === lastId);
if (doc) {
e.preventDefault();
openPreview(doc);
}
}
};
window.addEventListener('keydown', handleWindowKeyDown);
return () => window.removeEventListener('keydown', handleWindowKeyDown);
}, [selectedDocumentIds, items, openPreview]);
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);
}
}}
>
{isLayoutReady && items.map((doc, index) => {
const docId = doc.id ? String(doc.id) : `temp-${index}`;
const isSelected = selectedDocumentIds.includes(docId);
const size = ensureDocumentSize(doc);
const layoutCard = layoutStore.initialize(docId, null, {
width: Number.isFinite(size.width) && size.width > 0 ? size.width : 200,
height: Number.isFinite(size.height) && size.height > 0 ? size.height : 200
});
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) => { onDocumentActivate?.({ id } as DeskDocument) }}
layoutCard={layoutCard}
onTagDragEnter={tagInteractions.handleTagDragEnterDoc}
onTagDragOver={tagInteractions.handleTagDragOverDoc}
onTagDragLeave={tagInteractions.handleTagDragLeaveDoc}
onTagDrop={tagInteractions.handleTagDropOnDoc}
onDocTagPointerDown={tagInteractions.handleDocTagPointerDown}
onDocTagDragStart={tagInteractions.handleDocTagDragStart}
onDocTagDrag={tagInteractions.handleDocTagDrag}
onDocTagDragEnd={tagInteractions.handleDocTagDragEnd}
tagTargetActive={tagDropTargetId === docId}
tagTargetPending={pendingTagDocId === docId}
pendingRemovalTag={pendingRemovalTag}
onSelect={(ids, extend = false) => {
if (!extend) {
handleSelectionChange(ids);
} else {
const newSelection = new Set(selectedDocumentIds);
ids.forEach(id => newSelection.add(id));
handleSelectionChange(Array.from(newSelection));
}
}}
onDeselect={(ids) => {
const newSelection = new Set(selectedDocumentIds);
ids.forEach(id => newSelection.delete(id));
handleSelectionChange(Array.from(newSelection));
}}
selection={selectedDocumentIds}
/>
);
})}
</div>
</div>
</>
);
};
const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = (props) => {
return (
<PointerTrackingProvider>
<DesktopWorkspaceContent {...props} />
</PointerTrackingProvider>
);
};
export default DesktopWorkspace;