1135 lines
33 KiB
TypeScript
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;
|