This commit is contained in:
2025-11-02 15:03:23 +01:00
parent 8f86a71c39
commit d6a6d1233c
10 changed files with 630 additions and 19 deletions
+368 -3
View File
@@ -6,9 +6,10 @@ import React, {
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import { resolveDocumentAssetUrl, createAssetView } from './asset_manager';
import { useAssetNavigator } from './hooks/useAssetNavigator';
import { ArrowLeftIcon, ArrowRightIcon } from './ui/icons';
import { ArrowLeftIcon, ArrowRightIcon, CloseIcon } from './ui/icons';
import { createDocumentsTableHeaderActions } from './documents/DocumentsPanel';
import createWorkspaceSurfaceConfig from './documents/workspaceHeader';
import DetailPanel from './detail/DetailPanel';
@@ -33,6 +34,10 @@ const DEFAULT_CANVAS_HEIGHT = 680;
const CARD_MIN = 240;
const CARD_MAX = 340;
const TAG_REMOVE_DISTANCE = 160;
const STACK_HIT_EPSILON = 4;
const STACK_CENTER_TOLERANCE = 0.35;
const STACK_CENTER_MIN = 32;
const STACK_ROTATION_TOLERANCE = 15;
const DEBUG_DRAG = false;
const DEBUG_FOCUS = true;
@@ -568,6 +573,7 @@ const DesktopWorkspace = ({
onDocumentOpen,
onInspectDocument = null,
onDocumentPointerSelect = null,
onDocumentStackSelect = null,
onAssignTagToDocument = null,
onRemoveTagFromDocument = null,
ensureAssetUrl = null,
@@ -575,6 +581,8 @@ const DesktopWorkspace = ({
activeTagIds = [],
selectedDocumentIds = [],
onClearSelection = null,
helpOpen = false,
onHelpClose = null,
}) => {
const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]);
@@ -1715,6 +1723,7 @@ const syncLayoutSnapshot = useCallback(() => {
onDocumentOpen,
onInspectDocument,
onDocumentPointerSelect,
onDocumentStackSelect,
ensureAssetUrl,
getDocumentAsset,
handleNavigatorSnapshot,
@@ -1779,12 +1788,14 @@ const syncLayoutSnapshot = useCallback(() => {
documentLookup,
selectedDocumentIds,
onClearSelection,
onDocumentStackSelect,
],
);
return (
<DesktopProvider value={contextValue}>
<DesktopWorkspaceView />
<DesktopHelpOverlay open={helpOpen} onClose={onHelpClose} />
</DesktopProvider>
);
};
@@ -1823,15 +1834,245 @@ const DesktopWorkspaceView = () => {
overlayOriginRect,
overlayOriginTransform,
onDocumentPointerSelect,
onDocumentStackSelect,
selectedDocumentIds,
onClearSelection,
documentLookup,
} = useDesktopContext();
const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } =
useDocumentDrag();
const resolveStackDocIds = useCallback(
(event, targetDocId = null) => {
const container = containerRef.current;
if (!container || !event) {
return [];
}
const rect = container.getBoundingClientRect();
const pointerCanvasX = event.clientX - rect.left;
const pointerCanvasY = event.clientY - rect.top;
if (!Number.isFinite(pointerCanvasX) || !Number.isFinite(pointerCanvasY)) {
return [];
}
const hits = [];
items.forEach((doc) => {
if (!doc?.id) {
return;
}
const layout = layoutSnapshot.get(doc.id) ?? layoutRef.current.get(doc.id);
if (!layout) {
return;
}
const sizeInfo = ensureDocumentSize(doc);
if (!sizeInfo) {
return;
}
const { width, height } = sizeInfo;
if (!width || !height) {
return;
}
if (activeTagSet.size) {
const docTagKeys = Array.isArray(doc.tags)
? doc.tags.map((tag) => (tag ? tag.id : null)).filter(Boolean)
: [];
if (!docTagKeys.some((key) => activeTagSet.has(key))) {
return;
}
}
const centerX = Number(layout.centerX);
const centerY = Number(layout.centerY);
if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) {
return;
}
const rotationDeg = Number(layout.rotation) || 0;
const rotationRad = (rotationDeg * Math.PI) / 180;
const dx = pointerCanvasX - centerX;
const dy = pointerCanvasY - centerY;
const cosRotation = Math.cos(-rotationRad);
const sinRotation = Math.sin(-rotationRad);
const localX = dx * cosRotation - dy * sinRotation;
const localY = dx * sinRotation + dy * cosRotation;
const halfWidth = width / 2;
const halfHeight = height / 2;
if (
Math.abs(localX) <= halfWidth + STACK_HIT_EPSILON
&& Math.abs(localY) <= halfHeight + STACK_HIT_EPSILON
) {
const docKey = String(doc.id);
if (!hits.some((entry) => entry.id === docKey)) {
hits.push({
id: docKey,
z: Number.isFinite(layout.z) ? layout.z : 0,
});
}
}
});
if (!hits.length) {
return [];
}
hits.sort((a, b) => (b.z ?? 0) - (a.z ?? 0));
const targetKey = targetDocId != null ? String(targetDocId) : hits[0].id;
const orderedIds = hits.map((entry) => entry.id);
if (targetKey) {
const targetIndex = orderedIds.indexOf(targetKey);
if (targetIndex > 0) {
const [targetEntry] = orderedIds.splice(targetIndex, 1);
orderedIds.unshift(targetEntry);
}
}
const primaryKey = orderedIds[0];
if (!primaryKey) {
return orderedIds;
}
const primaryDoc = documentLookup.get(primaryKey) || null;
const primaryLayout = primaryDoc
? layoutSnapshot.get(primaryDoc.id) ?? layoutRef.current.get(primaryKey)
: null;
const primarySize = primaryDoc ? ensureDocumentSize(primaryDoc) : null;
if (!primaryLayout || !primarySize) {
return orderedIds;
}
const primaryCenterX = Number(primaryLayout.centerX);
const primaryCenterY = Number(primaryLayout.centerY);
const primaryRotation = Number(primaryLayout.rotation) || 0;
if (!Number.isFinite(primaryCenterX) || !Number.isFinite(primaryCenterY)) {
return orderedIds;
}
const centerTolX = Math.max(primarySize.width * STACK_CENTER_TOLERANCE, STACK_CENTER_MIN);
const centerTolY = Math.max(primarySize.height * STACK_CENTER_TOLERANCE, STACK_CENTER_MIN);
const filteredIds = [];
orderedIds.forEach((docKey, index) => {
if (!docKey) {
return;
}
if (index === 0 || docKey === targetKey) {
filteredIds.push(docKey);
return;
}
const candidateDoc = documentLookup.get(docKey) || null;
if (!candidateDoc) {
return;
}
const candidateLayout = layoutSnapshot.get(candidateDoc.id) ?? layoutRef.current.get(docKey);
if (!candidateLayout) {
return;
}
const candidateSize = ensureDocumentSize(candidateDoc);
if (!candidateSize) {
return;
}
const candidateCenterX = Number(candidateLayout.centerX);
const candidateCenterY = Number(candidateLayout.centerY);
if (!Number.isFinite(candidateCenterX) || !Number.isFinite(candidateCenterY)) {
return;
}
const dx = Math.abs(candidateCenterX - primaryCenterX);
const dy = Math.abs(candidateCenterY - primaryCenterY);
if (dx > centerTolX || dy > centerTolY) {
return;
}
const candidateRotation = Number(candidateLayout.rotation) || 0;
const rotationDiffRaw = Math.abs(candidateRotation - primaryRotation) % 360;
const rotationDiff = rotationDiffRaw > 180 ? 360 - rotationDiffRaw : rotationDiffRaw;
if (rotationDiff > STACK_ROTATION_TOLERANCE) {
return;
}
const sizeRatio = candidateSize.width && primarySize.width
? Math.min(candidateSize.width, primarySize.width) / Math.max(candidateSize.width, primarySize.width)
: 1;
const heightRatio = candidateSize.height && primarySize.height
? Math.min(candidateSize.height, primarySize.height) / Math.max(candidateSize.height, primarySize.height)
: 1;
if (sizeRatio < 0.55 || heightRatio < 0.55) {
return;
}
filteredIds.push(docKey);
});
return filteredIds;
},
[
activeTagSet,
ensureDocumentSize,
items,
layoutRef,
layoutSnapshot,
containerRef,
documentLookup,
],
);
const allSizesReady = items.every((doc) => ensureDocumentSize(doc));
useEffect(() => {
if (typeof window === 'undefined' || typeof onClearSelection !== 'function') {
return undefined;
}
const handleKeyDown = (event) => {
if (!event || event.defaultPrevented) {
return;
}
const key = event.key;
if (!(key === ' ' || key === 'Space' || key === 'Spacebar')) {
return;
}
if (!selectedDocumentIds || selectedDocumentIds.length === 0) {
return;
}
const target = event.target;
if (target instanceof HTMLElement) {
const tag = target.tagName ? target.tagName.toLowerCase() : '';
if (
target.isContentEditable
|| tag === 'input'
|| tag === 'textarea'
|| tag === 'select'
|| tag === 'button'
) {
return;
}
}
event.preventDefault();
onClearSelection();
};
window.addEventListener('keydown', handleKeyDown, true);
return () => window.removeEventListener('keydown', handleKeyDown, true);
}, [onClearSelection, selectedDocumentIds]);
return (
<>
<div
@@ -1931,8 +2172,27 @@ const DesktopWorkspaceView = () => {
}}
onPointerDown={(event) => {
const alreadySelected = selectedDocumentIds.includes(doc.id);
const metaOrCtrlOnly =
(event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey;
let stackDocIds = null;
if (
typeof onDocumentPointerSelect === 'function'
metaOrCtrlOnly
&& selectedDocumentIds.length === 0
&& typeof onDocumentStackSelect === 'function'
) {
const hits = resolveStackDocIds(event, doc.id);
if (Array.isArray(hits) && hits.length > 0) {
stackDocIds = hits;
onDocumentStackSelect(hits, event);
}
}
const stackHandled = Array.isArray(stackDocIds) && stackDocIds.length > 0;
if (
!stackHandled
&& typeof onDocumentPointerSelect === 'function'
&& (!alreadySelected
|| event.metaKey
|| event.ctrlKey
@@ -1941,7 +2201,7 @@ const DesktopWorkspaceView = () => {
) {
onDocumentPointerSelect(doc.id, event);
}
handlePointerDown(event, doc.id);
handlePointerDown(event, doc.id, { stackDocIds });
}}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
@@ -2021,6 +2281,110 @@ const DesktopWorkspaceView = () => {
export default DesktopWorkspace;
const DesktopHelpOverlay = ({ open = false, onClose = null }) => {
const portalTarget = typeof document !== 'undefined' ? document.body : null;
const closeButtonRef = useRef(null);
const previousFocusRef = useRef(null);
const handleClose = useCallback(() => {
if (typeof onClose === 'function') {
onClose();
}
}, [onClose]);
useEffect(() => {
if (!open || typeof window === 'undefined') {
return undefined;
}
const handleKeyDown = (event) => {
if (!event) {
return;
}
if (event.key === 'Escape') {
event.preventDefault();
handleClose();
}
};
window.addEventListener('keydown', handleKeyDown, true);
return () => window.removeEventListener('keydown', handleKeyDown, true);
}, [open, handleClose]);
useEffect(() => {
if (!open) {
const previous = previousFocusRef.current;
if (previous && typeof previous.focus === 'function') {
previous.focus();
}
previousFocusRef.current = null;
return;
}
if (typeof document !== 'undefined') {
previousFocusRef.current = document.activeElement instanceof HTMLElement
? document.activeElement
: null;
}
if (closeButtonRef.current && typeof closeButtonRef.current.focus === 'function') {
closeButtonRef.current.focus();
}
}, [open]);
if (!open || !portalTarget) {
return null;
}
return createPortal(
<div className="desk-help-overlay" role="dialog" aria-modal="true" aria-labelledby="desk-help-title">
<div className="desk-help-overlay__backdrop" onClick={handleClose} />
<div className="desk-help-overlay__content">
<div className="desk-help-overlay__header">
<h2 id="desk-help-title">Desk view tips</h2>
<button
type="button"
className="icon-button"
onClick={handleClose}
aria-label="Close desk view tips"
ref={closeButtonRef}
>
<CloseIcon />
</button>
</div>
<div className="desk-help-overlay__body">
<p>Use the desk as a freeform workspace for triage and quick comparisons.</p>
<ul className="desk-help-overlay__list">
<li><strong>Single-click</strong> a document to open it in the detail panel.</li>
<li><strong>Double-click</strong> to open the zoomed preview.</li>
<li>
<strong>Drag</strong> selected cards to reposition them; build a selection with
{' '}
<kbd>Cmd</kbd>/<kbd>Ctrl</kbd>
{' '}+ click or Shift-click.
</li>
<li>
<strong>Cmd/Ctrl + click</strong> with an empty selection scoops up the stack under
{' '}the pointer.
</li>
<li><strong>Space</strong> clears the current selection.</li>
<li>
<strong>Drag tags</strong> from the sidebar onto a card to assign them, or fling a
{' '}tag away to remove it.
</li>
</ul>
</div>
<div className="desk-help-overlay__footer">
<button type="button" className="button primary" onClick={handleClose}>
Got it
</button>
</div>
</div>
</div>,
portalTarget,
);
};
export const createDesktopSurface = ({
workspaceProps,
renderSidebarToggle,
@@ -2049,6 +2413,7 @@ export const createDesktopSurface = ({
viewMode: viewMode || 'desk',
onViewModeChange,
onRefresh,
onShowDeskHelp: viewMode === 'desk' ? workspaceProps?.onOpenHelp : null,
});
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;