1066 lines
32 KiB
TypeScript
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;
|