Files
papercrate/frontend/src/desktop/DesktopWorkspace.tsx
T
2025-11-13 01:07:55 +01:00

1066 lines
32 KiB
TypeScript

import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from 'react';
import { resolveDocumentAssetUrl } from '../asset_manager';
import { formatTransform } from './math';
import useDocumentDrag from './useDocumentDrag';
import PreviewZoomOverlay from '../detail/PreviewZoomOverlay';
import {
WorkspaceEngine,
DESK_CANVAS_PADDING,
DESK_DEFAULT_CANVAS_HEIGHT,
DESK_DEFAULT_CANVAS_WIDTH,
clampCardDimensions,
computeFallbackCardSize,
useWorkspaceSnapshot,
} from './workspaceEngine';
import useDeskPointer from './pointer/useDeskPointer';
import useDeskTagInteractions from './tags/useDeskTagInteractions';
import DesktopDocumentCard from './DesktopDocumentCard';
import usePreviewMetadata from './hooks/usePreviewMetadata';
import '../styles/workspace/workspace-layout.css';
import '../styles/workspace/workspace-items.css';
import '../styles/workspace/workspace-cards.css';
type Identifier = string | number;
type TagLike = { id?: Identifier | null; label?: string; color?: string | null } | null | undefined;
export interface DeskDocument {
id?: Identifier | null;
title?: string;
tags?: TagLike[] | null;
[key: string]: unknown;
}
interface NavigatorSnapshot {
url: string | null;
alt?: string | null;
canGoPrev?: boolean;
canGoNext?: boolean;
goPrev?: () => void;
goNext?: () => void;
ordinal?: number | null;
width?: number | null;
height?: number | null;
}
interface OverlayOriginTransform {
rotation: number;
scaleX: number;
scaleY: number;
baseWidth: number;
baseHeight: number;
}
interface OverlayDisplay extends NavigatorSnapshot {
url: string;
}
interface DocumentSizeInfo {
width: number;
height: number;
source?: 'snapshot' | 'metadata' | 'fallback';
}
interface PreviewMetadataEntry {
docId: string;
width: number;
height: number;
}
interface DragTransformOverride {
centerX?: number;
centerY?: number;
rotation?: number;
scale?: number;
}
interface DragSettings {
canvasPadding: number;
defaultCanvasWidth: number;
defaultCanvasHeight: number;
debugDrag?: boolean;
}
interface LayoutEntry {
centerX: number;
centerY: number;
rotation: number;
z: number;
width?: number;
height?: number;
}
type WorkspaceSnapshotState = {
layout: Map<string, LayoutEntry>;
canvasSize: { width: number; height: number };
visibleDocIds: Set<string>;
draggingId: string | null;
tagDropTargetId: string | null;
pendingTagDocId: string | null;
pendingRemovalTag: unknown;
initialLoadDone: boolean;
};
interface DesktopWorkspaceProps {
documents?: DeskDocument[];
searchResults?: DeskDocument[] | null;
onDocumentOpen?: (docId: Identifier | null, options?: Record<string, unknown>) => void;
onInspectDocument?: (...args: unknown[]) => void;
onEntryPointer?: (...args: unknown[]) => void;
onDocumentStackSelect?: (docIds: Identifier[]) => void;
onPromoteSelection?: (...args: unknown[]) => void;
onAssignTagToDocument?: (...args: unknown[]) => void;
onRemoveTagFromDocument?: (...args: unknown[]) => void;
ensureAssetUrl?: (...args: unknown[]) => Promise<unknown> | unknown;
getDocumentAsset?: (...args: unknown[]) => unknown;
activeTagIds?: Array<Identifier | null | undefined>;
selectedDocumentIds?: Identifier[];
onClearSelection?: () => void;
detailPanelOpen?: boolean;
onCloseDetailPanel?: () => void;
tenantId?: Identifier | null;
viewId?: string | null;
}
interface DesktopWorkspaceViewProps {
engine: WorkspaceEngine;
items: DeskDocument[];
containerRef: React.RefObject<HTMLDivElement>;
handleCanvasDragOver: (event: React.DragEvent<HTMLDivElement>) => void;
handleCanvasDragLeave: (event: React.DragEvent<HTMLDivElement>) => void;
handleCanvasDrop: (event: React.DragEvent<HTMLDivElement>) => void;
ensureDocumentSize: (doc: DeskDocument | null | undefined) => DocumentSizeInfo | null;
layoutSnapshot: Map<string, LayoutEntry>;
layoutRef: React.MutableRefObject<Map<string, LayoutEntry>>;
dragTransformsRef: React.MutableRefObject<Map<string, DragTransformOverride>>;
itemRefs: React.MutableRefObject<Map<string, HTMLDivElement | null>>;
visibleDocIds: Set<string>;
draggingId: string | null;
tagDropTargetId: string | null;
pendingTagDocId: string | null;
pendingRemovalTag: unknown;
onDocumentOpen?: DesktopWorkspaceProps['onDocumentOpen'];
ensureAssetUrl?: DesktopWorkspaceProps['ensureAssetUrl'];
getDocumentAsset?: DesktopWorkspaceProps['getDocumentAsset'];
handleNavigatorSnapshot: (docId: Identifier | null, snapshot: NavigatorSnapshot | null) => void;
activeTagSet: Set<string>;
handleTagDragEnterDoc: (...args: unknown[]) => void;
handleTagDragOverDoc: (...args: unknown[]) => void;
handleTagDragLeaveDoc: (...args: unknown[]) => void;
handleTagDropOnDoc: (...args: unknown[]) => void;
handleDocTagPointerDown: (...args: unknown[]) => void;
handleDocTagDragStart: (...args: unknown[]) => void;
handleDocTagDrag: (...args: unknown[]) => void;
handleDocTagDragEnd: (...args: unknown[]) => void;
overlayDisplay: OverlayDisplay | null;
closeOverlay: () => void;
overlayOriginRect: DOMRect | null;
overlayOriginTransform: OverlayOriginTransform | null;
onEntryPointer?: DesktopWorkspaceProps['onEntryPointer'];
onDocumentStackSelect?: DesktopWorkspaceProps['onDocumentStackSelect'];
onPromoteSelection?: DesktopWorkspaceProps['onPromoteSelection'];
selectedDocumentIds: Identifier[];
onClearSelection?: DesktopWorkspaceProps['onClearSelection'];
detailPanelOpen: boolean;
onCloseDetailPanel?: DesktopWorkspaceProps['onCloseDetailPanel'];
documentLookup: Map<string, DeskDocument>;
resolveBaseMetrics: (doc: DeskDocument | null | undefined, cardWidth: number, cardHeight: number) => {
baseWidth: number;
baseHeight: number;
baseScale: number;
};
bringToFront: (docId: Identifier | null | undefined) => void;
setDraggingId: (value: string | null) => void;
canvasSize: { width: number; height: number };
openOverlayForDoc: (docId: Identifier | null | undefined, originInfo?: OverlayOriginTransform | null) => void;
recalcVisibleDocIds: () => void;
dragSettings: DragSettings;
onInspectDocument?: DesktopWorkspaceProps['onInspectDocument'];
markLayoutDirty: () => void;
}
const DEBUG_DRAG = false;
const DEBUG_FOCUS = false;
const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
documents = [],
searchResults = null,
onDocumentOpen,
onInspectDocument = null,
onEntryPointer = null,
onDocumentStackSelect = null,
onPromoteSelection = null,
onAssignTagToDocument = null,
onRemoveTagFromDocument = null,
ensureAssetUrl = null,
getDocumentAsset = () => null,
activeTagIds = [],
selectedDocumentIds = [],
onClearSelection = null,
detailPanelOpen = false,
onCloseDetailPanel = null,
tenantId = null,
viewId = 'default',
}) => {
const items = useMemo<DeskDocument[]>(
() => (searchResults ? searchResults : documents),
[documents, searchResults],
);
const allowLayoutPersistence = Boolean(tenantId && viewId && viewId.startsWith('folder:'));
const containerRef = useRef<HTMLDivElement | null>(null);
const itemRefs = useRef<Map<string, HTMLDivElement | null>>(new Map());
const dragTransformsRef = useRef<Map<string, DragTransformOverride>>(new Map());
const [overlayDocId, setOverlayDocId] = useState<string | null>(null);
const [overlayOriginRect, setOverlayOriginRect] = useState<DOMRect | null>(null);
const [overlayOriginTransform, setOverlayOriginTransform] = useState<OverlayOriginTransform | null>(
null,
);
const [previewSnapshots, setPreviewSnapshots] = useState<Map<string, NavigatorSnapshot>>(
() => new Map(),
);
const [docSizeVersion, setDocSizeVersion] = useState(0);
const docSizeMapRef = useRef<Map<string, DocumentSizeInfo>>(new Map());
const ensureDocumentSize = useCallback((doc: DeskDocument | null | undefined): DocumentSizeInfo | null => {
if (!doc?.id) {
return null;
}
return docSizeMapRef.current.get(String(doc.id)) || null;
}, []);
const previewMetadata = usePreviewMetadata(items, getDocumentAsset, ensureAssetUrl);
const documentLookup = useMemo<Map<string, DeskDocument>>(() => {
const map = new Map<string, DeskDocument>();
items.forEach((doc) => {
const key = doc?.id != null ? String(doc.id) : null;
if (key) {
map.set(key, doc);
}
});
return map;
}, [items]);
const engineRef = useRef<WorkspaceEngine | null>(null);
if (!engineRef.current) {
engineRef.current = new WorkspaceEngine({
allowLayoutPersistence,
tenantId,
viewId,
});
}
const engine = engineRef.current as WorkspaceEngine;
useEffect(() => {
engine.updateConfig({ allowLayoutPersistence, tenantId, viewId });
}, [engine, allowLayoutPersistence, tenantId, viewId]);
useEffect(() => {
const nextItems = searchResults ? searchResults : documents;
engine.setItems(nextItems || []);
}, [engine, documents, searchResults]);
useEffect(() => {
const map = new Map();
items.forEach((doc) => {
const key = doc?.id != null ? String(doc.id) : null;
if (key) {
map.set(key, doc);
}
});
engine.setDocumentLookup(map);
}, [engine, items]);
useEffect(() => {
engine.setEnsureDocumentSize(ensureDocumentSize);
}, [engine, ensureDocumentSize]);
const workspaceSnapshot = useWorkspaceSnapshot(engine, useSyncExternalStore) as WorkspaceSnapshotState;
const {
layout: layoutSnapshot,
canvasSize,
visibleDocIds,
draggingId,
tagDropTargetId,
pendingTagDocId,
pendingRemovalTag,
initialLoadDone,
} = workspaceSnapshot;
useEffect(() => {
const shouldWaitForPersisted = allowLayoutPersistence && !initialLoadDone;
if (shouldWaitForPersisted) {
return;
}
engine.ensureLayoutForItems();
}, [engine, docSizeVersion, initialLoadDone, items.length, allowLayoutPersistence]);
useEffect(() => {
engine.setItemRefs(itemRefs);
}, [engine, itemRefs]);
const layoutRef = useRef<Map<string, LayoutEntry>>(layoutSnapshot);
layoutRef.current = engine.layout as Map<string, LayoutEntry>;
const bringToFront = useCallback((docId: Identifier | null | undefined) => {
engine.bringToFront(docId);
}, [engine]);
const markLayoutDirty = useCallback(() => {
engine.markLayoutDirty();
}, [engine]);
const recalcVisibleDocIds = useCallback(() => {
engine.recalcVisibleDocIds();
}, [engine]);
const setDraggingId = useCallback((value: string | number | null) => {
engine.setDraggingId(value);
}, [engine]);
const applySnapshotDimensions = useCallback((docKey: string, snapshot: NavigatorSnapshot | null) => {
const width = Number(snapshot?.width);
const height = Number(snapshot?.height);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return;
}
const normalized = clampCardDimensions(width, height);
if (!normalized) {
return;
}
const existing = docSizeMapRef.current.get(docKey);
if (existing && existing.width === normalized.width && existing.height === normalized.height) {
return;
}
const next = new Map(docSizeMapRef.current);
next.set(docKey, { ...normalized, source: 'snapshot' });
docSizeMapRef.current = next;
setDocSizeVersion((value) => value + 1);
}, []);
const handleNavigatorSnapshot = useCallback(
(docId: Identifier | null, snapshot: NavigatorSnapshot | null) => {
const docKey = docId != null ? String(docId) : null;
if (!docKey) {
return;
}
setPreviewSnapshots((previous) => {
const prevSnapshot = previous.get(docKey);
if (!snapshot) {
if (!previous.has(docKey)) {
return previous;
}
const next = new Map(previous);
next.delete(docKey);
return next;
}
const next = new Map(previous);
const sameSnapshot =
prevSnapshot &&
prevSnapshot.url === snapshot.url &&
prevSnapshot.alt === snapshot.alt &&
prevSnapshot.canGoPrev === snapshot.canGoPrev &&
prevSnapshot.canGoNext === snapshot.canGoNext &&
prevSnapshot.goPrev === snapshot.goPrev &&
prevSnapshot.goNext === snapshot.goNext &&
prevSnapshot.ordinal === snapshot.ordinal &&
prevSnapshot.width === snapshot.width &&
prevSnapshot.height === snapshot.height;
if (sameSnapshot) {
return previous;
}
next.set(docKey, snapshot);
return next;
});
if (snapshot) {
applySnapshotDimensions(docKey, snapshot);
}
},
[applySnapshotDimensions],
);
const activeTagSet = useMemo<Set<string>>(() => {
if (!Array.isArray(activeTagIds) || activeTagIds.length === 0) {
return new Set();
}
const set = new Set<string>();
activeTagIds.forEach((id) => {
if (id != null) {
set.add(String(id));
}
});
return set;
}, [activeTagIds]);
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) {
engine.setCanvasSize({ width: 0, height: 0 });
return undefined;
}
const commitSize = () => {
const rect = container.getBoundingClientRect();
const width = Math.floor(rect.width) || 0;
const height = Math.floor(rect.height) || 0;
engine.setCanvasSize({ width, height });
};
commitSize();
if (typeof window.ResizeObserver === 'undefined') {
window.addEventListener('resize', commitSize);
return () => {
window.removeEventListener('resize', commitSize);
};
}
const observer = new window.ResizeObserver(commitSize);
observer.observe(container);
return () => observer.disconnect();
}, [engine]);
const resolvePreviewDimensions = useCallback(
(doc: DeskDocument | null | undefined): PreviewMetadataEntry | null => {
if (!doc?.id) {
return null;
}
return previewMetadata.get(String(doc.id)) || null;
},
[previewMetadata],
);
useEffect(() => {
if (!ensureAssetUrl) {
return;
}
visibleDocIds.forEach((docId) => {
const doc = documentLookup.get(docId);
if (!doc) {
return;
}
resolveDocumentAssetUrl(doc, 'preview', {
ensureAssetUrl,
getAsset: getDocumentAsset,
});
});
}, [visibleDocIds, ensureAssetUrl, getDocumentAsset, documentLookup]);
const requestCanvasFocus = useCallback(() => {
const canvas = containerRef.current;
if (!canvas?.focus) {
return;
}
const focusTarget = () => {
try {
canvas.focus({ preventScroll: true });
} catch (error: unknown) {
if (DEBUG_FOCUS) {
void error;
}
}
};
const raf = window.requestAnimationFrame;
if (raf) {
raf(() => focusTarget());
return;
}
setTimeout(() => {
focusTarget();
}, 0);
}, []);
const tagInteractions = useDeskTagInteractions({
engine,
onAssignTagToDocument,
onRemoveTagFromDocument,
requestCanvasFocus,
});
const {
handleTagDragEnterDoc,
handleTagDragOverDoc,
handleTagDragLeaveDoc,
handleTagDropOnDoc,
handleCanvasDragOver,
handleCanvasDragLeave,
handleCanvasDrop,
handleDocTagPointerDown,
handleDocTagDragStart,
handleDocTagDrag,
handleDocTagDragEnd,
} = tagInteractions;
useEffect(() => {
const current = docSizeMapRef.current;
const next = new Map(current);
const itemKeys = new Set(items.filter((doc) => doc?.id != null).map((doc) => String(doc.id)));
let changed = false;
items.forEach((doc) => {
if (!doc?.id) {
return;
}
const key = String(doc.id);
const existing = next.get(key) || null;
const meta = previewMetadata.get(key);
if (meta) {
const normalized = clampCardDimensions(meta.width, meta.height);
if (normalized) {
if (existing?.source === 'snapshot') {
return;
}
if (!existing || existing.width !== normalized.width || existing.height !== normalized.height || existing.source !== 'metadata') {
next.set(key, { ...normalized, source: 'metadata' });
changed = true;
}
return;
}
}
if (!existing) {
const fallback = computeFallbackCardSize(key);
if (fallback) {
next.set(key, { ...fallback, source: 'fallback' });
changed = true;
}
}
});
current.forEach((_, key) => {
if (!itemKeys.has(key)) {
next.delete(key);
changed = true;
}
});
if (changed) {
docSizeMapRef.current = next;
setDocSizeVersion((value) => value + 1);
}
}, [items, previewMetadata]);
const overlayDisplay = useMemo<OverlayDisplay | null>(() => {
if (!overlayDocId) {
return null;
}
const snapshot = previewSnapshots.get(overlayDocId);
if (!snapshot || !snapshot.url) {
return null;
}
const doc = documentLookup.get(overlayDocId);
const alt = snapshot.alt || (doc?.title as string | undefined);
return {
url: snapshot.url,
alt,
canGoPrev: snapshot.canGoPrev,
canGoNext: snapshot.canGoNext,
goPrev: snapshot.goPrev,
goNext: snapshot.goNext,
};
}, [overlayDocId, previewSnapshots, documentLookup]);
const closeOverlay = useCallback(() => {
setOverlayDocId(null);
setOverlayOriginRect(null);
setOverlayOriginTransform(null);
}, []);
useEffect(() => {
if (overlayDocId && !documentLookup.has(overlayDocId)) {
setOverlayDocId(null);
setOverlayOriginRect(null);
setOverlayOriginTransform(null);
}
}, [overlayDocId, documentLookup]);
const resolveBaseMetrics = useCallback(
(doc: DeskDocument | null | undefined, cardWidth: number, cardHeight: number) => {
const previewDims = doc ? resolvePreviewDimensions(doc) : null;
if (previewDims?.width && previewDims?.height) {
const baseWidth = Math.max(previewDims.width, cardWidth);
const baseHeight = Math.max(previewDims.height, cardHeight);
const scaleX = cardWidth / baseWidth;
const scaleY = cardHeight / baseHeight;
const baseScale = Math.min(scaleX, scaleY, 1);
return {
baseWidth,
baseHeight,
baseScale: Number.isFinite(baseScale) && baseScale > 0 ? baseScale : 1,
};
}
return {
baseWidth: cardWidth,
baseHeight: cardHeight,
baseScale: 1,
};
},
[resolvePreviewDimensions],
);
useEffect(() => {
if (draggingId && !items.some((doc) => String(doc.id) === draggingId)) {
setDraggingId(null);
}
}, [draggingId, items, setDraggingId]);
const openOverlayForDoc = useCallback(
(docId: Identifier | null | undefined, originInfo: OverlayOriginTransform | null = null) => {
if (!docId) {
return;
}
const docKey = String(docId);
const snapshot = previewSnapshots.get(docKey);
if (!snapshot || !snapshot.url) {
return;
}
const container = itemRefs.current.get(docKey);
const imageNode = container
? container.querySelector<HTMLImageElement>('.desk-item__card img')
: null;
if (!container || !imageNode) {
return;
}
const rect = imageNode.getBoundingClientRect();
let originTransform = null;
if (originInfo) {
const { rotation = 0, scale = 1, width: originWidth, height: originHeight } = originInfo;
originTransform = {
rotation,
scaleX: scale,
scaleY: scale,
baseWidth: originWidth,
baseHeight: originHeight,
};
}
if (!originTransform) {
const entry = engine.getLayout(docKey);
const doc = documentLookup.get(docKey) || null;
const sizeInfo = ensureDocumentSize(doc);
if (!sizeInfo) {
return;
}
const { width: cardWidth, height: cardHeight } = sizeInfo;
const { baseWidth, baseHeight, baseScale } = resolveBaseMetrics(doc, cardWidth, cardHeight);
const effectiveWidth = baseWidth * baseScale;
const effectiveHeight = baseHeight * baseScale;
originTransform = {
rotation: entry?.rotation ?? 0,
scaleX: baseScale,
scaleY: baseScale,
baseWidth: Number.isFinite(effectiveWidth) && effectiveWidth > 0 ? effectiveWidth : cardWidth,
baseHeight: Number.isFinite(effectiveHeight) && effectiveHeight > 0 ? effectiveHeight : cardHeight,
};
}
bringToFront(docId);
setOverlayOriginRect(rect);
setOverlayOriginTransform(originTransform);
setOverlayDocId(docKey);
},
[
bringToFront,
previewSnapshots,
itemRefs,
setOverlayOriginTransform,
ensureDocumentSize,
resolveBaseMetrics,
documentLookup,
engine,
],
);
const dragSettings = useMemo<DragSettings>(
() => ({
canvasPadding: DESK_CANVAS_PADDING,
defaultCanvasWidth: DESK_DEFAULT_CANVAS_WIDTH,
defaultCanvasHeight: DESK_DEFAULT_CANVAS_HEIGHT,
debugDrag: DEBUG_DRAG,
}),
[],
);
const viewProps = useMemo<DesktopWorkspaceViewProps>(
() => ({
engine,
items,
containerRef,
handleCanvasDragOver,
handleCanvasDragLeave,
handleCanvasDrop,
ensureDocumentSize,
layoutSnapshot,
layoutRef,
dragTransformsRef,
itemRefs,
visibleDocIds,
draggingId,
tagDropTargetId,
pendingTagDocId,
pendingRemovalTag,
onDocumentOpen,
ensureAssetUrl,
getDocumentAsset,
handleNavigatorSnapshot,
activeTagSet,
handleTagDragEnterDoc,
handleTagDragOverDoc,
handleTagDragLeaveDoc,
handleTagDropOnDoc,
handleDocTagPointerDown,
handleDocTagDragStart,
handleDocTagDrag,
handleDocTagDragEnd,
overlayDisplay,
closeOverlay,
overlayOriginRect,
overlayOriginTransform,
onEntryPointer,
onDocumentStackSelect,
onPromoteSelection,
selectedDocumentIds,
onClearSelection,
detailPanelOpen,
onCloseDetailPanel,
documentLookup,
resolveBaseMetrics,
bringToFront,
setDraggingId,
canvasSize,
openOverlayForDoc,
recalcVisibleDocIds,
dragSettings,
onInspectDocument,
markLayoutDirty,
}),
[
activeTagSet,
bringToFront,
canvasSize,
closeOverlay,
containerRef,
draggingId,
dragSettings,
engine,
ensureAssetUrl,
ensureDocumentSize,
getDocumentAsset,
dragTransformsRef,
handleCanvasDragLeave,
handleCanvasDragOver,
handleCanvasDrop,
handleDocTagDrag,
handleDocTagDragEnd,
handleDocTagDragStart,
handleDocTagPointerDown,
handleNavigatorSnapshot,
handleTagDragEnterDoc,
handleTagDragLeaveDoc,
handleTagDragOverDoc,
handleTagDropOnDoc,
itemRefs,
items,
layoutRef,
layoutSnapshot,
onClearSelection,
onCloseDetailPanel,
onDocumentOpen,
onDocumentStackSelect,
onEntryPointer,
onPromoteSelection,
openOverlayForDoc,
overlayDisplay,
overlayOriginRect,
overlayOriginTransform,
documentLookup,
pendingRemovalTag,
pendingTagDocId,
recalcVisibleDocIds,
resolveBaseMetrics,
setDraggingId,
selectedDocumentIds,
detailPanelOpen,
onInspectDocument,
markLayoutDirty,
tagDropTargetId,
visibleDocIds,
],
);
return <DesktopWorkspaceView {...viewProps} />;
};
function DesktopWorkspaceView({
engine,
items,
containerRef,
handleCanvasDragOver,
handleCanvasDragLeave,
handleCanvasDrop,
ensureDocumentSize,
layoutSnapshot,
layoutRef,
itemRefs,
visibleDocIds,
draggingId,
tagDropTargetId,
pendingTagDocId,
pendingRemovalTag,
onDocumentOpen,
ensureAssetUrl,
getDocumentAsset,
handleNavigatorSnapshot,
activeTagSet,
handleTagDragEnterDoc,
handleTagDragOverDoc,
handleTagDragLeaveDoc,
handleTagDropOnDoc,
handleDocTagPointerDown,
handleDocTagDragStart,
handleDocTagDrag,
handleDocTagDragEnd,
overlayDisplay,
closeOverlay,
overlayOriginRect,
overlayOriginTransform,
onEntryPointer,
onDocumentStackSelect,
onPromoteSelection,
selectedDocumentIds,
onClearSelection,
detailPanelOpen,
onCloseDetailPanel,
documentLookup,
resolveBaseMetrics,
bringToFront,
setDraggingId,
canvasSize,
openOverlayForDoc,
recalcVisibleDocIds,
dragSettings,
onInspectDocument,
markLayoutDirty,
dragTransformsRef,
}: DesktopWorkspaceViewProps) {
const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } =
useDocumentDrag({
engine,
layoutRef,
dragTransformsRef,
itemRefs,
documentLookup,
ensureDocumentSize,
resolveBaseMetrics,
bringToFront,
setDraggingId,
canvasSize,
openOverlayForDoc,
recalcVisibleDocIds,
settings: dragSettings,
containerRef,
onInspectDocument,
onDocumentStackSelect,
selectedDocumentIds,
markLayoutDirty,
}) as {
handlePointerDown: React.PointerEventHandler<HTMLElement>;
handlePointerMove: React.PointerEventHandler<HTMLElement>;
handlePointerUp: React.PointerEventHandler<HTMLElement>;
handlePointerCancel: React.PointerEventHandler<HTMLElement>;
};
const { getCardPointerHandlers, handleShellKeyDown, focusShell } = useDeskPointer({
containerRef,
items,
layoutRef,
ensureDocumentSize,
activeTagSet,
handlePointerDown,
handlePointerMove,
handlePointerUp,
handlePointerCancel,
onEntryPointer,
onDocumentStackSelect,
onPromoteSelection,
onDocumentOpen,
selectedDocumentIds,
detailPanelOpen,
onCloseDetailPanel,
openOverlayForDoc,
}) as {
getCardPointerHandlers: (doc: DeskDocument) => React.HTMLAttributes<HTMLDivElement>;
handleShellKeyDown: React.KeyboardEventHandler<HTMLDivElement>;
focusShell: () => void;
};
useEffect(() => {
focusShell();
}, [focusShell]);
useEffect(() => {
if (selectedDocumentIds.length) {
focusShell();
}
}, [focusShell, selectedDocumentIds.length]);
useEffect(() => {
if (!detailPanelOpen) {
focusShell();
}
}, [detailPanelOpen, focusShell]);
const allSizesReady = items.every((doc) => Boolean(ensureDocumentSize(doc)));
return (
<>
<div className="desk-shell" onPointerDown={(event) => {
if (event.target === event.currentTarget) {
onClearSelection?.();
}
focusShell();
}}
>
<div
className="desk-canvas"
ref={containerRef}
tabIndex={0}
onKeyDown={handleShellKeyDown}
onDragOver={handleCanvasDragOver}
onDragLeave={handleCanvasDragLeave}
onDrop={handleCanvasDrop}
onPointerDown={(event) => {
if (event.target === event.currentTarget) {
onClearSelection?.();
}
focusShell();
}}
>
{!allSizesReady ? (
<div className="desk-empty">
<p>Loading previews</p>
</div>
) : items.length === 0 ? (
<div className="desk-empty">
<p>No documents to show here yet. Drop files to make this space come alive.</p>
</div>
) : (
items.map((doc, index) => {
const sizeInfo = ensureDocumentSize(doc);
if (!sizeInfo) {
return null;
}
const { width: cardWidth, height: cardHeight } = sizeInfo;
const docKey = doc?.id != null ? String(doc.id) : null;
const dragOverride = docKey ? dragTransformsRef.current.get(docKey) : null;
const layout = docKey ? layoutRef.current.get(docKey) : null;
if (!dragOverride && (!layout && (!docKey || !layoutSnapshot.has(docKey)))) {
return null;
}
const centerX = dragOverride?.centerX ?? layout?.centerX;
const centerY = dragOverride?.centerY ?? layout?.centerY;
if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) {
return null;
}
const resolvedCenterX = centerX as number;
const resolvedCenterY = centerY as number;
const rotation = dragOverride?.rotation ?? layout?.rotation ?? 0;
const scale = dragOverride?.scale ?? 1;
const originX = resolvedCenterX - cardWidth / 2;
const originY = resolvedCenterY - cardHeight / 2;
const transform = formatTransform(
Math.round(originX),
Math.round(originY),
rotation,
scale,
);
const style = {
transform,
zIndex: layout?.z ?? 1,
width: Math.round(cardWidth),
height: Math.round(cardHeight),
};
const shouldLoad = docKey ? visibleDocIds.has(docKey) : false;
const dragging = docKey ? draggingId === docKey : false;
const docTagKeys = Array.isArray(doc?.tags)
? doc.tags
.map((tag) => (tag?.id != null ? String(tag.id) : null))
.filter((id): id is string => Boolean(id))
: [];
const matchesFilter =
activeTagSet.size === 0 || docTagKeys.some((key) => activeTagSet.has(key));
const dropActive = docKey ? tagDropTargetId === docKey : false;
const dropPending = docKey ? pendingTagDocId === docKey : false;
const docId = doc?.id ?? null;
const isSelected = docId != null ? selectedDocumentIds.includes(docId) : false;
const docTagTokens = docTagKeys.join(' ');
const cardPointerHandlers = getCardPointerHandlers(doc) as React.HTMLAttributes<HTMLDivElement>;
const registerNode = (node: HTMLDivElement | null) => {
if (!docKey) {
return;
}
if (node) {
itemRefs.current.set(docKey, node);
} else {
itemRefs.current.delete(docKey);
}
};
return (
<DesktopDocumentCard
key={docKey ?? `desk-doc-${index}`}
doc={doc}
style={style}
shouldLoad={shouldLoad}
dragging={dragging}
matchesFilter={matchesFilter}
tagTargetActive={dropActive}
tagTargetPending={dropPending}
selected={isSelected}
docTagTokens={docTagTokens}
ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset}
handleNavigatorSnapshot={handleNavigatorSnapshot}
cardPointerHandlers={cardPointerHandlers}
onDocumentOpen={onDocumentOpen}
onTagDragEnter={handleTagDragEnterDoc}
onTagDragOver={handleTagDragOverDoc}
onTagDragLeave={handleTagDragLeaveDoc}
onTagDrop={handleTagDropOnDoc}
onDocTagPointerDown={handleDocTagPointerDown}
onDocTagDragStart={handleDocTagDragStart}
onDocTagDrag={handleDocTagDrag}
onDocTagDragEnd={handleDocTagDragEnd}
pendingRemovalTag={pendingRemovalTag}
registerNode={registerNode}
/>
);
})
)}
</div>
</div>
<PreviewZoomOverlay
open={Boolean(overlayDisplay?.url)}
display={overlayDisplay}
onClose={closeOverlay}
originRect={overlayOriginRect}
originTransform={overlayOriginTransform}
/>
</>
);
}
export default DesktopWorkspace;