Files
papercrate/frontend/src/desktop/DesktopWorkspace.tsx
T
2025-11-24 20:19:44 +01:00

1135 lines
33 KiB
TypeScript

import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from 'react';
import { resolveDocumentAssetUrl } from '../asset_manager';
import type { EnsureAssetUrl, GetAsset } 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';
import { useWorkspaceSelectionContext } from '../app/WorkspaceSelectionContext';
type Identifier = string | number;
type TagLike = { id?: Identifier | null; label?: string; color?: string | null } | null;
type DocumentLinkLike = { url?: string | null; mimeType?: string | 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 NavigatorSnapshot {
url: string | null;
alt?: string | null;
width?: number | null;
height?: number | null;
}
type OverlayOriginHint = {
rotation?: number;
scale?: number;
width?: number;
height?: number;
};
interface OverlayOriginTransform {
rotation: number;
scaleX: number;
scaleY: number;
baseWidth: number;
baseHeight: number;
}
interface OverlayDisplay {
url: string;
alt?: string | null;
mimeType?: string | null;
}
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 {
entries?: DeskDocument[];
onDocumentActivate?: (...args: unknown[]) => void;
onDocumentClick?: (...args: unknown[]) => void;
onDocumentTagDrop?: (...args: unknown[]) => void;
ensureAssetUrl?: EnsureAssetUrl;
getDocumentAsset?: GetAsset;
activeTagFilters?: Array<Identifier | null>;
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) => 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;
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;
overlayDocument: DeskDocument | null;
onDocumentClick?: DesktopWorkspaceProps['onDocumentClick'];
onDocumentStackSelect?: (docIds: Identifier[], event?: PointerEvent | MouseEvent | null) => void;
onPromoteSelection?: (docId: Identifier | null) => void;
documentLookup: Map<string, DeskDocument>;
selectedDocumentIds: Identifier[];
onClearSelection: () => void;
resolveBaseMetrics: (doc: DeskDocument | null, cardWidth: number, cardHeight: number) => {
baseWidth: number;
baseHeight: number;
baseScale: number;
};
bringToFront: (docId: Identifier | null) => void;
setDraggingId: (value: string | null) => void;
canvasSize: { width: number; height: number };
openOverlayForDoc: (docId: Identifier | null, originInfo?: OverlayOriginHint | null) => void;
recalcVisibleDocIds: () => void;
dragSettings: DragSettings;
onDocumentActivate?: DesktopWorkspaceProps['onDocumentActivate'];
markLayoutDirty: () => void;
}
const DEBUG_DRAG = false;
const DEBUG_FOCUS = false;
const defaultGetDocumentAsset: GetAsset = () => null;
const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
entries = [],
onDocumentActivate = null,
onDocumentClick = null,
onDocumentTagDrop = null,
ensureAssetUrl = null,
getDocumentAsset = defaultGetDocumentAsset,
activeTagFilters = [],
tenantId = null,
viewId = 'default',
documentLinks,
ensureDownloadUrl,
}) => {
const {
selectedDocumentIds,
clearSelection,
handleEntrySelection,
promoteSelectionOrder,
} = useWorkspaceSelectionContext();
const items = useMemo<DeskDocument[]>(
() => (Array.isArray(entries) ? entries.filter((doc): doc is DeskDocument => Boolean(doc)) : []),
[entries],
);
const getDocRowKey = useCallback((id: Identifier | null) => (id != null ? `document:${id}` : null), []);
const handleStackSelect = useCallback(
(docIds: Identifier[], event?: PointerEvent | MouseEvent | null) => {
if (!Array.isArray(docIds) || docIds.length === 0) {
return;
}
const syntheticEvent = event || ({
metaKey: true,
ctrlKey: true,
preventDefault: () => {},
} as unknown as PointerEvent);
docIds.forEach((id) => {
const key = getDocRowKey(id);
if (key) {
handleEntrySelection(key, syntheticEvent);
}
});
},
[getDocRowKey, handleEntrySelection],
);
const handlePromoteSelection = useCallback(
(docId: Identifier | null) => {
const key = getDocRowKey(docId);
if (key && promoteSelectionOrder) {
promoteSelectionOrder(key);
}
},
[getDocRowKey, promoteSelectionOrder],
);
const allowLayoutPersistence = Boolean(tenantId && viewId && viewId.startsWith('folder:'));
const documentLinkMap = documentLinks instanceof Map ? documentLinks : null;
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 [overlaySource, setOverlaySource] = useState<OverlaySource | null>(null);
const [, 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): 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(() => {
engine.setItems(items || []);
}, [engine, items]);
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) => {
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<string, DocumentSizeInfo>(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.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(activeTagFilters) || activeTagFilters.length === 0) {
return new Set();
}
const set = new Set<string>();
activeTagFilters.forEach((id) => {
if (id != null) {
set.add(String(id));
}
});
return set;
}, [activeTagFilters]);
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();
let rafId: number | null = null;
const observer = new ResizeObserver(() => {
if (rafId != null) return;
rafId = requestAnimationFrame(() => {
rafId = null;
commitSize();
});
});
observer.observe(container);
return () => {
observer.disconnect();
if (rafId != null) {
cancelAnimationFrame(rafId);
}
};
}, [engine]);
const resolvePreviewDimensions = useCallback(
(doc: DeskDocument | null): 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, 'thumbnail', {
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: onDocumentTagDrop,
requestCanvasFocus,
});
const {
handleTagDragEnterDoc,
handleTagDragOverDoc,
handleTagDragLeaveDoc,
handleTagDropOnDoc,
handleCanvasDragOver,
handleCanvasDragLeave,
handleCanvasDrop,
handleDocTagPointerDown,
handleDocTagDragStart,
handleDocTagDrag,
handleDocTagDragEnd,
} = tagInteractions;
useEffect(() => {
const current = docSizeMapRef.current;
const next = new Map<string, DocumentSizeInfo>(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]);
useEffect(() => {
let cancelled = false;
if (!overlayDocId) {
setOverlaySource(null);
return () => {
cancelled = true;
};
}
const doc = documentLookup.get(overlayDocId) || null;
const docIdentifier = doc?.id ?? null;
if (!docIdentifier || !doc) {
setOverlaySource(null);
return () => {
cancelled = true;
};
}
const docMimeType = doc?.mime_type ?? null;
const applyEntry = (entry?: DocumentLinkLike | null) => {
if (!entry?.url) {
setOverlaySource(null);
return;
}
setOverlaySource({
url: entry.url,
alt: doc.title,
mimeType: docMimeType || undefined,
});
};
const cachedEntry = documentLinkMap?.get(docIdentifier) || null;
if (cachedEntry?.url) {
applyEntry(cachedEntry);
return () => {
cancelled = true;
};
}
if (!ensureDownloadUrl) {
setOverlaySource(null);
return () => {
cancelled = true;
};
}
ensureDownloadUrl(docIdentifier)
.then((entry) => {
if (cancelled) {
return;
}
applyEntry(entry);
})
.catch(() => {
if (!cancelled) {
setOverlaySource(null);
}
});
return () => {
cancelled = true;
};
}, [overlayDocId, documentLookup, documentLinkMap, ensureDownloadUrl]);
const closeOverlay = useCallback(() => {
setOverlayDocId(null);
setOverlayOriginRect(null);
setOverlayOriginTransform(null);
setOverlaySource(null);
}, []);
useEffect(() => {
if (overlayDocId && !documentLookup.has(overlayDocId)) {
setOverlayDocId(null);
setOverlayOriginRect(null);
setOverlayOriginTransform(null);
}
}, [overlayDocId, documentLookup]);
const resolveBaseMetrics = useCallback(
(doc: DeskDocument | null, 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 overlayDisplay = useMemo<OverlayDisplay | null>(() => {
if (!overlaySource) {
return null;
}
return overlaySource;
}, [overlaySource]);
const overlayDocument = useMemo<DeskDocument | null>(() => {
if (!overlayDocId) {
return null;
}
const baseDoc = documentLookup.get(String(overlayDocId)) || null;
if (baseDoc && overlayDisplay?.url) {
return { ...baseDoc, documentLink: overlayDisplay };
}
return baseDoc;
}, [documentLookup, overlayDisplay, overlayDocId]);
const openOverlayForDoc = useCallback(
(docId: Identifier | null, originInfo: OverlayOriginHint | null = null) => {
if (!docId) {
return;
}
const docKey = String(docId);
const container = itemRefs.current.get(docKey);
if (!container) {
return;
}
const imageNode = container.querySelector<HTMLImageElement>('.desk-item__card img');
const rect = (imageNode || container).getBoundingClientRect();
if (!rect) {
return;
}
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,
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,
ensureAssetUrl,
getDocumentAsset,
handleNavigatorSnapshot,
activeTagSet,
handleTagDragEnterDoc,
handleTagDragOverDoc,
handleTagDragLeaveDoc,
handleTagDropOnDoc,
handleDocTagPointerDown,
handleDocTagDragStart,
handleDocTagDrag,
handleDocTagDragEnd,
overlayDisplay,
closeOverlay,
overlayOriginRect,
overlayOriginTransform,
overlayDocument,
onDocumentClick,
onDocumentStackSelect: handleStackSelect,
onPromoteSelection: handlePromoteSelection,
selectedDocumentIds,
onClearSelection: clearSelection,
documentLookup,
resolveBaseMetrics,
bringToFront,
setDraggingId,
canvasSize,
openOverlayForDoc,
recalcVisibleDocIds,
dragSettings,
onDocumentActivate,
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,
onDocumentClick,
openOverlayForDoc,
overlayDisplay,
overlayOriginRect,
overlayOriginTransform,
overlayDocument,
documentLookup,
pendingRemovalTag,
pendingTagDocId,
recalcVisibleDocIds,
resolveBaseMetrics,
setDraggingId,
selectedDocumentIds,
onDocumentActivate,
markLayoutDirty,
tagDropTargetId,
visibleDocIds,
],
);
return <DesktopWorkspaceView {...viewProps} />;
};
function DesktopWorkspaceView({
engine,
items,
containerRef,
handleCanvasDragOver,
handleCanvasDragLeave,
handleCanvasDrop,
ensureDocumentSize,
layoutSnapshot,
layoutRef,
itemRefs,
visibleDocIds,
draggingId,
tagDropTargetId,
pendingTagDocId,
pendingRemovalTag,
ensureAssetUrl,
getDocumentAsset,
handleNavigatorSnapshot,
activeTagSet,
handleTagDragEnterDoc,
handleTagDragOverDoc,
handleTagDragLeaveDoc,
handleTagDropOnDoc,
handleDocTagPointerDown,
handleDocTagDragStart,
handleDocTagDrag,
handleDocTagDragEnd,
overlayDisplay,
closeOverlay,
overlayOriginRect,
overlayOriginTransform,
overlayDocument,
onDocumentClick,
onDocumentStackSelect,
onPromoteSelection,
selectedDocumentIds,
onClearSelection,
documentLookup,
resolveBaseMetrics,
bringToFront,
setDraggingId,
canvasSize,
openOverlayForDoc,
recalcVisibleDocIds,
dragSettings,
onDocumentActivate,
markLayoutDirty,
dragTransformsRef,
}: DesktopWorkspaceViewProps) {
const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } =
useDocumentDrag({
engine,
layoutRef,
dragTransformsRef,
itemRefs,
documentLookup,
ensureDocumentSize,
resolveBaseMetrics,
bringToFront,
setDraggingId,
canvasSize,
openOverlayForDoc,
recalcVisibleDocIds,
settings: dragSettings,
containerRef,
onDocumentActivate,
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,
onDocumentClick,
onDocumentStackSelect,
onPromoteSelection,
onDocumentActivate,
selectedDocumentIds,
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]);
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}
onDocumentActivate={onDocumentActivate}
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)}
onClose={closeOverlay}
document={overlayDocument}
originRect={overlayOriginRect}
originTransform={overlayOriginTransform}
/>
</>
);
}
export default DesktopWorkspace;