329 lines
11 KiB
TypeScript
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;
|