desktop redo

This commit is contained in:
2025-11-05 12:29:30 +01:00
parent 928753add8
commit 934fb6b689
7 changed files with 795 additions and 360 deletions
+244 -134
View File
@@ -39,52 +39,139 @@ const CARD_MAX = 340;
const TAG_REMOVE_DISTANCE = 160;
const STACK_HIT_EPSILON = 4;
const POINTER_DRAG_THRESHOLD_SQUARED = 16;
const LONG_PRESS_DURATION_MS = 450;
const DEBUG_DRAG = false;
const DEBUG_FOCUS = true;
const DEBUG_DROP = true;
const resolveDeskPointerIntent = ({
alreadySelected = false,
selectedCount = 0,
stackDocIds = null,
metaOrCtrl = false,
pointerButton = 0,
const CLICK_ACTIONS = {
selectSingle: 'selectSingle',
openDetail: 'openDetail',
addCard: 'addCard',
addStack: 'addStack',
none: 'none',
};
const DRAG_ACTIONS = {
dragSelectSingle: 'dragSelectSingle',
dragSelection: 'dragSelection',
dragSelectStack: 'dragSelectStack',
none: 'none',
};
const createPointerIntent = ({
doc,
entryDescriptor,
selectedDocumentIds,
metaKey,
pointerButton,
pointerType,
stackHits,
}) => {
const stackList = Array.isArray(stackDocIds) && stackDocIds.length > 0 ? [...stackDocIds] : null;
const alreadySelected = selectedDocumentIds.includes(doc.id);
const selectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0;
if (!metaOrCtrl) {
return {
callEntryPointer: true,
skipSelection: false,
stackDragDocIds: null,
stackClickDocIds: null,
stackReplace: false,
openDetailOnRelease: alreadySelected && pointerButton === 0 && selectedCount > 0,
};
let clickAction = CLICK_ACTIONS.none;
let dragAction = DRAG_ACTIONS.none;
if (metaKey) {
clickAction = CLICK_ACTIONS.addStack;
dragAction = DRAG_ACTIONS.dragSelectStack;
} else if (alreadySelected) {
clickAction = CLICK_ACTIONS.openDetail;
dragAction = selectionCount > 1 ? DRAG_ACTIONS.dragSelection : DRAG_ACTIONS.dragSelectSingle;
} else {
clickAction = CLICK_ACTIONS.selectSingle;
dragAction = DRAG_ACTIONS.dragSelectSingle;
}
if (alreadySelected) {
return {
callEntryPointer: false,
skipSelection: true,
stackDragDocIds: stackList,
stackClickDocIds: stackList,
stackReplace: Boolean(stackList),
openDetailOnRelease: false,
};
}
const stackList = Array.isArray(stackHits) && stackHits.length > 0
? stackHits.slice()
: [String(doc.id)];
const stackDocIdsForDrag = dragAction === DRAG_ACTIONS.dragSelectStack ? stackList : null;
const stackDocIdsForClick = clickAction === CLICK_ACTIONS.addStack ? stackList : null;
return {
callEntryPointer: true,
skipSelection: false,
stackDragDocIds: stackList,
stackClickDocIds: null,
stackReplace: Boolean(stackList),
openDetailOnRelease: false,
docId: doc.id,
entryDescriptor,
pointerType,
pointerButton,
selectedAtDown: alreadySelected,
selectionCountAtDown: selectionCount,
metaKey,
clickAction,
dragAction,
stackDocIdsForDrag,
stackDocIdsForClick,
stackReplaceOnClick: clickAction === CLICK_ACTIONS.addStack,
stackReplaceOnDrag: dragAction === DRAG_ACTIONS.dragSelectStack,
clickSelectionApplied: false,
stackSelectionApplied: false,
longPressTriggered: false,
};
};
const applyClickPlanImmediately = ({ intent, event, onEntryPointer, onDocumentStackSelect }) => {
switch (intent.clickAction) {
case CLICK_ACTIONS.selectSingle:
case CLICK_ACTIONS.addCard:
if (typeof onEntryPointer === 'function') {
onEntryPointer(intent.entryDescriptor, event);
}
intent.clickSelectionApplied = true;
break;
case CLICK_ACTIONS.addStack:
if (
Array.isArray(intent.stackDocIdsForClick)
&& intent.stackDocIdsForClick.length > 0
&& typeof onDocumentStackSelect === 'function'
) {
onDocumentStackSelect(intent.stackDocIdsForClick, event, { replace: intent.stackReplaceOnClick });
intent.clickSelectionApplied = true;
intent.stackSelectionApplied = true;
}
break;
case CLICK_ACTIONS.openDetail:
default:
intent.clickSelectionApplied = true;
break;
}
};
const finalizeClickSelection = ({ intent, event, onEntryPointer, onDocumentStackSelect }) => {
if (!intent || intent.clickSelectionApplied) {
return;
}
applyClickPlanImmediately({ intent, event, onEntryPointer, onDocumentStackSelect });
};
const applyLongPressSelection = ({ intent, stackDocIds, syntheticEvent, onDocumentStackSelect }) => {
if (!intent) {
return;
}
const stackCopy = Array.isArray(stackDocIds) && stackDocIds.length > 0
? stackDocIds.slice()
: [intent.docId];
if (typeof onDocumentStackSelect === 'function') {
onDocumentStackSelect(stackCopy, syntheticEvent, { replace: true });
}
intent.clickAction = CLICK_ACTIONS.addStack;
intent.dragAction = DRAG_ACTIONS.dragSelectStack;
intent.stackDocIdsForClick = stackCopy;
intent.stackDocIdsForDrag = stackCopy;
intent.stackReplaceOnClick = true;
intent.stackReplaceOnDrag = true;
intent.clickSelectionApplied = true;
intent.stackSelectionApplied = true;
intent.longPressTriggered = true;
};
const signedDistance = (ax, ay, bx, by, px, py) => (bx - ax) * (py - ay) - (by - ay) * (px - ax);
const clipPolygonWithEdge = (subject, edgeStart, edgeEnd) => {
@@ -2006,10 +2093,11 @@ const DesktopWorkspaceView = () => {
const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } =
useDocumentDrag();
const deferredSelectionRef = useRef(null);
const pointerIntentRef = useRef(null);
const pointerStartRef = useRef({ x: 0, y: 0 });
const pointerMovedRef = useRef(false);
const longPressTimerRef = useRef(null);
const longPressActiveRef = useRef(false);
const resolveStackDocIds = useCallback(
(event, targetDocId = null) => {
@@ -2145,6 +2233,58 @@ const DesktopWorkspaceView = () => {
const allSizesReady = items.every((doc) => ensureDocumentSize(doc));
const resetLongPressState = useCallback(() => {
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current);
longPressTimerRef.current = null;
}
longPressActiveRef.current = false;
}, []);
const scheduleLongPress = useCallback(
({ doc, modifierActive, pointerType }) => {
if (modifierActive || pointerType !== 'touch') {
longPressActiveRef.current = false;
return;
}
longPressActiveRef.current = true;
if (typeof window === 'undefined') {
return;
}
longPressTimerRef.current = window.setTimeout(() => {
if (!longPressActiveRef.current || pointerMovedRef.current) {
resetLongPressState();
return;
}
const intent = pointerIntentRef.current;
if (!intent || intent.docId !== doc.id) {
resetLongPressState();
return;
}
const syntheticEvent = {
clientX: pointerStartRef.current.x,
clientY: pointerStartRef.current.y,
};
const stackHits = resolveStackDocIds(syntheticEvent, doc.id);
applyLongPressSelection({
intent,
stackDocIds: stackHits,
syntheticEvent,
onDocumentStackSelect,
});
pointerIntentRef.current = intent;
resetLongPressState();
}, LONG_PRESS_DURATION_MS);
},
[onDocumentStackSelect, resetLongPressState, resolveStackDocIds],
);
useEffect(() => () => resetLongPressState(), [resetLongPressState]);
const handleShellKeyDown = useCallback(
(event) => {
if (!event || event.defaultPrevented) {
@@ -2293,75 +2433,56 @@ const DesktopWorkspaceView = () => {
y: Number.isFinite(event.clientY) ? event.clientY : 0,
};
pointerMovedRef.current = false;
const selectionCountAtDown = Array.isArray(selectedDocumentIds)
? selectedDocumentIds.length
: 0;
const alreadySelected = selectedDocumentIds.includes(doc.id);
const metaOrCtrlOnly =
(event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey;
resetLongPressState();
const modifierActive =
Boolean(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey);
const pointerButton = typeof event.button === 'number' ? event.button : 0;
const stackHits = metaOrCtrlOnly ? resolveStackDocIds(event, doc.id) : null;
const pointerIntent = resolveDeskPointerIntent({
alreadySelected,
selectedCount: selectionCountAtDown,
stackDocIds: stackHits,
metaOrCtrl: metaOrCtrlOnly,
pointerButton,
});
const pointerType = typeof event.pointerType === 'string' ? event.pointerType : '';
const metaKey = (event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey;
const modifierActive = Boolean(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey);
const stackDragDocIds = pointerIntent.stackDragDocIds;
const stackClickDocIds = pointerIntent.stackClickDocIds;
if (alreadySelected && typeof onPromoteSelection === 'function') {
onPromoteSelection(doc.id, event);
}
pointerIntentRef.current = {
docId: doc.id,
selectedAtDown: alreadySelected,
selectionCountAtDown,
modifierActive,
pointerButton,
openDetailOnRelease:
pointerIntent.openDetailOnRelease && typeof onDocumentOpen === 'function',
stackDragDocIds,
stackClickDocIds,
stackClickApplied: false,
const entryDescriptor = {
type: 'document',
id: doc.id,
key: `document:${doc.id}`,
};
const deferSelection =
!modifierActive
&& alreadySelected
&& Array.isArray(selectedDocumentIds)
&& selectedDocumentIds.length > 1;
const stackHits = metaKey ? resolveStackDocIds(event, doc.id) : null;
const skipPointerSelection = pointerIntent.skipSelection;
const intent = createPointerIntent({
doc,
entryDescriptor,
selectedDocumentIds,
metaKey,
pointerButton,
pointerType,
stackHits,
});
if (skipPointerSelection) {
deferredSelectionRef.current = null;
} else if (deferSelection) {
deferredSelectionRef.current = {
entry: { type: 'document', id: doc.id, key: `document:${doc.id}` },
applySelection: false,
};
} else {
deferredSelectionRef.current = null;
if (pointerIntent.callEntryPointer && typeof onEntryPointer === 'function') {
onEntryPointer(
{ type: 'document', id: doc.id, key: `document:${doc.id}` },
event,
);
}
if (intent.selectedAtDown && typeof onPromoteSelection === 'function') {
onPromoteSelection(doc.id, event);
}
applyClickPlanImmediately({
intent,
event,
onEntryPointer,
onDocumentStackSelect,
});
pointerIntentRef.current = intent;
handlePointerDown(event, doc.id, {
stackDocIds: stackDragDocIds,
stackSelectionApplied: false,
wasSelected: alreadySelected,
stackDocIds: intent.stackDocIdsForDrag,
stackSelectionApplied: intent.stackSelectionApplied,
wasSelected: intent.selectedAtDown,
modifierActive,
stackReplace: pointerIntent.stackReplace,
stackReplace: intent.stackReplaceOnDrag,
});
scheduleLongPress({
doc,
modifierActive,
pointerType,
});
}}
onPointerMove={(event) => {
@@ -2370,66 +2491,55 @@ const DesktopWorkspaceView = () => {
const dy = Number.isFinite(event.clientY) ? event.clientY - start.y : 0;
if (dx * dx + dy * dy > POINTER_DRAG_THRESHOLD_SQUARED) {
pointerMovedRef.current = true;
resetLongPressState();
}
handlePointerMove(event);
}}
onPointerUp={(event) => {
const deferredInfo = deferredSelectionRef.current;
const pointerState = pointerIntentRef.current;
const pointerMoved = pointerMovedRef.current;
resetLongPressState();
handlePointerUp(event);
if (!pointerMoved && deferredInfo && typeof onEntryPointer === 'function') {
const entry = deferredInfo.entry || deferredInfo;
const applySelection = deferredInfo.applySelection !== false;
if (applySelection && entry) {
onEntryPointer(entry, event);
if (!pointerMoved && pointerState) {
finalizeClickSelection({
intent: pointerState,
event,
onEntryPointer,
onDocumentStackSelect,
});
if (
pointerState.clickAction === CLICK_ACTIONS.openDetail
&& !pointerState.longPressTriggered
&& typeof onDocumentOpen === 'function'
&& pointerState.docId === doc.id
) {
const expectedButton =
typeof pointerState.pointerButton === 'number'
? pointerState.pointerButton
: 0;
const releasedButton = typeof event.button === 'number'
? event.button
: expectedButton;
const isPrimaryRelease = expectedButton === 0 && releasedButton === 0;
const stillSelected = Array.isArray(selectedDocumentIds)
&& selectedDocumentIds.includes(doc.id);
if (isPrimaryRelease && stillSelected) {
const useSelection = pointerState.selectedAtDown
&& pointerState.selectionCountAtDown > 0;
onDocumentOpen(doc.id, { useSelection });
}
}
}
if (
!pointerMoved
&& pointerState
&& pointerState.selectedAtDown
&& Array.isArray(pointerState.stackClickDocIds)
&& pointerState.stackClickDocIds.length > 0
&& !pointerState.stackClickApplied
&& typeof onDocumentStackSelect === 'function'
) {
onDocumentStackSelect(pointerState.stackClickDocIds, event, { replace: false });
pointerState.stackClickApplied = true;
}
if (
!pointerMoved
&& pointerState
&& pointerState.docId === doc.id
&& pointerState.openDetailOnRelease
&& typeof onDocumentOpen === 'function'
) {
const expectedButton =
typeof pointerState.pointerButton === 'number'
? pointerState.pointerButton
: 0;
const releasedButton = typeof event.button === 'number' ? event.button : expectedButton;
const isPrimaryRelease = expectedButton === 0 && releasedButton === 0;
const stillSelected = Array.isArray(selectedDocumentIds)
&& selectedDocumentIds.includes(doc.id);
if (isPrimaryRelease && stillSelected) {
const useSelection =
pointerState.selectedAtDown && pointerState.selectionCountAtDown > 0;
onDocumentOpen(doc.id, { useSelection });
}
}
deferredSelectionRef.current = null;
pointerIntentRef.current = null;
pointerMovedRef.current = false;
}}
onPointerCancel={(event) => {
deferredSelectionRef.current = null;
pointerMovedRef.current = false;
resetLongPressState();
pointerIntentRef.current = null;
handlePointerCancel(event);
}}
+101 -14
View File
@@ -13,7 +13,8 @@ import { useAssetNavigator } from '../hooks/useAssetNavigator';
import { describeDocumentSummary } from '../documents/documentSummary';
import { createDocumentActionState } from '../documents/documentActions';
import PreviewZoomOverlay from './PreviewZoomOverlay';
import DocumentSummarySection, {
import DocumentInfoPanel from '../documents/DocumentInfoPanel';
import {
TagSection,
CorrespondentSection,
sortCorrespondents,
@@ -407,6 +408,94 @@ const DetailPanel = ({
return sortCorrespondents(singleDoc.correspondents || []);
}, [singleDoc]);
const singleSummaryProps = useMemo(
() => ({
tagLookupById,
tagOptions: tags,
onTagAdd: (doc, value, extras) => onTagAdd(doc, value, extras),
onTagRemove: (docId, tagId) => onTagRemove(docId, tagId),
correspondents: singleCorrespondents,
correspondentOptions,
onCorrespondentAdd,
onCorrespondentRemove,
onUpdateTitle,
onUpdateIssued,
}),
[
tagLookupById,
tags,
onTagAdd,
onTagRemove,
singleCorrespondents,
correspondentOptions,
onCorrespondentAdd,
onCorrespondentRemove,
onUpdateTitle,
onUpdateIssued,
],
);
const singleHasOcr = useMemo(() => {
if (!singleDoc || typeof getDocumentAsset !== 'function') {
return false;
}
return Boolean(getDocumentAsset(singleDoc, 'ocr-text'));
}, [singleDoc, getDocumentAsset]);
const loadSingleOcrContent = useCallback(async ({ signal } = {}) => {
if (!singleDoc || !singleHasOcr || typeof getDocumentAsset !== 'function') {
return '';
}
const updateUrl = () =>
resolveDocumentAssetUrl(singleDoc, 'ocr-text', {
ensureAssetUrl,
getAsset: getDocumentAsset,
});
const asset = getDocumentAsset(singleDoc, 'ocr-text');
let url = updateUrl();
if (!url && singleDoc.id && asset?.id && typeof ensureAssetUrl === 'function') {
await ensureAssetUrl(singleDoc.id, asset, { start: 1, limit: 1 });
if (signal?.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
url = updateUrl();
}
if (!url) {
return '';
}
const response = await fetch(url, {
method: 'GET',
mode: 'cors',
credentials: 'omit',
signal,
});
if (!response.ok) {
throw new Error(`Unexpected status: ${response.status}`);
}
return response.text();
}, [singleDoc, singleHasOcr, getDocumentAsset, ensureAssetUrl]);
const singleContentConfig = useMemo(
() => ({
enabled: singleHasOcr,
id: 'content',
label: 'Content',
loadContent: loadSingleOcrContent,
loadingMessage: 'Loading OCR content…',
emptyMessage: 'No OCR content available.',
unavailableMessage: 'No OCR content available.',
errorMessage: 'Failed to load OCR content.',
}),
[singleHasOcr, loadSingleOcrContent],
);
const bulkCorrespondents = useMemo(() => {
if (selectedDocuments.length <= 1) {
const doc = selectedDocuments[0];
@@ -766,19 +855,17 @@ const DetailPanel = ({
</div>
) : null}
</div>
<DocumentSummarySection
document={singleDoc}
tagLookupById={tagLookupById}
tagOptions={tags}
onTagAdd={(doc, value, extras) => onTagAdd(doc, value, extras)}
onTagRemove={(docId, tagId) => onTagRemove(docId, tagId)}
correspondents={singleCorrespondents}
correspondentOptions={correspondentOptions}
onCorrespondentAdd={onCorrespondentAdd}
onCorrespondentRemove={onCorrespondentRemove}
onUpdateTitle={onUpdateTitle}
onUpdateIssued={onUpdateIssued}
/>
<div className="document-viewer__details">
<DocumentInfoPanel
document={singleDoc}
summaryProps={singleSummaryProps}
contentConfig={singleContentConfig}
classNamePrefix="document-viewer"
defaultTabId="details"
resetKey={singleDocId}
hideTabNavWhenSingle={false}
/>
</div>
</>
);
};
@@ -0,0 +1,302 @@
import React, { useEffect, useMemo, useState } from 'react';
import DocumentSummarySection from './DocumentSummarySection';
import { buildDocumentMetadataItems, extractDocumentMetadataPayload } from './documentMetadata';
const DocumentInfoPanel = ({
document,
summaryProps = {},
metadataItems: metadataItemsProp,
metadataPayload: metadataPayloadProp,
metadataTabLabel = 'Metadata',
detailsTabLabel = 'Details',
contentConfig: contentConfigProp = null,
activeTab: controlledActiveTab,
onTabChange,
defaultTabId = 'details',
resetKey = null,
classNamePrefix = 'document-info',
hideTabNavWhenSingle = true,
}) => {
const base = classNamePrefix;
const metadataItems = useMemo(() => {
if (Array.isArray(metadataItemsProp) && metadataItemsProp.length) {
return metadataItemsProp;
}
return buildDocumentMetadataItems(document);
}, [metadataItemsProp, document]);
const metadataPayload = useMemo(() => {
if (metadataPayloadProp !== undefined) {
return metadataPayloadProp;
}
return extractDocumentMetadataPayload(document);
}, [metadataPayloadProp, document]);
const contentConfig = contentConfigProp || null;
const contentEnabled = Boolean(contentConfig && (contentConfig.enabled ?? true));
const showContentTab = Boolean(contentConfig && ((contentConfig.forceDisplay ?? contentEnabled)));
const [contentState, setContentState] = useState(() => {
if (!contentConfig) {
return null;
}
if (!contentEnabled || typeof contentConfig.loadContent !== 'function') {
return { status: contentEnabled ? 'idle' : 'unavailable', data: null, error: null };
}
return { status: 'idle', data: null, error: null };
});
useEffect(() => {
if (!contentConfig || !showContentTab) {
setContentState(null);
return undefined;
}
if (!contentEnabled || typeof contentConfig.loadContent !== 'function') {
setContentState({ status: contentEnabled ? 'idle' : 'unavailable', data: null, error: null });
return undefined;
}
let cancelled = false;
const controller = new AbortController();
setContentState({ status: 'loading', data: null, error: null });
Promise.resolve(contentConfig.loadContent({ signal: controller.signal }))
.then((result) => {
if (cancelled) {
return;
}
if (result && result.length) {
setContentState({ status: 'loaded', data: result, error: null });
} else {
setContentState({ status: 'empty', data: '', error: null });
}
})
.catch((error) => {
if (cancelled || error?.name === 'AbortError') {
return;
}
setContentState({
status: 'error',
data: null,
error,
});
});
return () => {
cancelled = true;
controller.abort();
contentConfig.onCancel?.();
};
}, [contentConfig, contentEnabled, showContentTab, document?.id, resetKey]);
const visibleTabs = useMemo(() => {
const tabsList = [];
tabsList.push({
id: 'details',
label: detailsTabLabel,
render: () => (
<section className={`${base}__section`}>
{metadataItems.length ? (
<dl className={`${base}__section-list`}>
{metadataItems.map(({ label, value }) => (
<div className={`${base}__section-item`} key={label}>
<dt>{label}</dt>
<dd>{value || '—'}</dd>
</div>
))}
</dl>
) : (
<p className={`${base}__section-placeholder`}>No details available.</p>
)}
</section>
),
});
if (showContentTab && contentConfig) {
tabsList.push({
id: contentConfig.id || 'content',
label: contentConfig.label || 'Content',
render: () => {
const messageClass = `${base}__message`;
const errorClass = `${base}__message ${base}__message--error`;
const objectClass = `${base}__object ${base}__object--ocr-text`;
if (!contentEnabled || !contentConfig.loadContent) {
return (
<div className={messageClass}>
{contentConfig.unavailableMessage || 'Content not available.'}
</div>
);
}
if (!contentState) {
return (
<div className={messageClass}>
{contentConfig.emptyMessage || 'No content available.'}
</div>
);
}
switch (contentState.status) {
case 'loading':
return (
<div className={messageClass}>
{contentConfig.loadingMessage || 'Loading content…'}
</div>
);
case 'error': {
const errorMessage =
contentConfig.errorMessage
|| (contentState.error instanceof Error ? contentState.error.message : null)
|| 'Failed to load content.';
return <div className={errorClass}>{errorMessage}</div>;
}
case 'empty':
return (
<div className={messageClass}>
{contentConfig.emptyMessage || 'No content available.'}
</div>
);
case 'loaded':
return (
<pre className={objectClass}>{contentState.data}</pre>
);
case 'unavailable':
return (
<div className={messageClass}>
{contentConfig.unavailableMessage || 'Content not available.'}
</div>
);
default:
return (
<div className={messageClass}>
{contentConfig.emptyMessage || 'No content available.'}
</div>
);
}
},
});
}
if (metadataPayload) {
tabsList.push({
id: 'metadata',
label: metadataTabLabel,
render: () => (
<section className={`${base}__section ${base}__section--metadata-json`}>
<pre className={`${base}__metadata-json`}>
{JSON.stringify(metadataPayload, null, 2)}
</pre>
</section>
),
});
}
return tabsList;
}, [
base,
detailsTabLabel,
metadataItems,
contentConfig,
contentEnabled,
contentState,
metadataPayload,
metadataTabLabel,
showContentTab,
]);
const fallbackTabId = useMemo(() => {
if (!visibleTabs.length) {
return null;
}
if (defaultTabId && visibleTabs.some((tab) => tab.id === defaultTabId)) {
return defaultTabId;
}
return visibleTabs[0].id;
}, [visibleTabs, defaultTabId]);
const isControlled = controlledActiveTab !== undefined && controlledActiveTab !== null;
const [uncontrolledTab, setUncontrolledTab] = useState(
isControlled ? controlledActiveTab : fallbackTabId,
);
useEffect(() => {
if (!isControlled) {
setUncontrolledTab(fallbackTabId);
}
}, [fallbackTabId, resetKey, isControlled]);
useEffect(() => {
if (isControlled && controlledActiveTab && !visibleTabs.some((tab) => tab.id === controlledActiveTab)) {
const nextTab = fallbackTabId;
if (nextTab && nextTab !== controlledActiveTab) {
onTabChange?.(nextTab);
}
}
}, [isControlled, controlledActiveTab, visibleTabs, fallbackTabId, onTabChange]);
const activeTabId = isControlled ? controlledActiveTab : uncontrolledTab;
const handleTabSelect = (tabId) => {
if (!visibleTabs.some((tab) => tab.id === tabId)) {
return;
}
if (!isControlled) {
setUncontrolledTab(tabId);
}
if (tabId !== activeTabId) {
onTabChange?.(tabId);
}
};
const singleTab = visibleTabs.length === 1 ? visibleTabs[0] : null;
const shouldHideNav = hideTabNavWhenSingle && singleTab;
return (
<>
<DocumentSummarySection
document={document}
{...summaryProps}
/>
{shouldHideNav ? (
<div className={`${base}__tabpanes ${base}__tabpanes--single`}>
<div className={`${base}__tabpanel`}>
{renderTabContent(singleTab, { document })}
</div>
</div>
) : (
<div className={`${base}__tabs-wrapper`}>
<div className={`${base}__tabs`} role="tablist" aria-label="Document details">
{visibleTabs.map((tab) => (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={tab.id === activeTabId}
className={`${base}__tab${tab.id === activeTabId ? ' is-active' : ''}`}
onClick={() => handleTabSelect(tab.id)}
>
{tab.label}
</button>
))}
</div>
<div className={`${base}__tabpanes`}>
{visibleTabs.map((tab) => (
tab.id === activeTabId ? (
<div key={tab.id} role="tabpanel" className={`${base}__tabpanel`}>
{renderTabContent(tab, { document })}
</div>
) : null
))}
</div>
</div>
)}
</>
);
};
export default DocumentInfoPanel;
+16 -11
View File
@@ -755,6 +755,22 @@ export const createDocumentsTableHeaderActions = ({
return (
<>
{isDeskView && typeof onShowDeskHelp === 'function' ? (
<>
<button
type="button"
className="icon-button"
onClick={onShowDeskHelp}
aria-label="Show desk view tips"
title="Show desk view tips"
>
<InfoIcon />
</button>
<span className="main-content__actions-divider" aria-hidden="true">
<MinusVerticalIcon />
</span>
</>
) : null}
<div className="view-toggle" role="group" aria-label="Change view">
<button
type="button"
@@ -796,17 +812,6 @@ export const createDocumentsTableHeaderActions = ({
>
<RefreshIcon />
</button>
{isDeskView && typeof onShowDeskHelp === 'function' ? (
<button
type="button"
className="icon-button"
onClick={onShowDeskHelp}
aria-label="Show desk view tips"
title="Show desk view tips"
>
<InfoIcon />
</button>
) : null}
</>
);
};
@@ -0,0 +1,49 @@
const formatDateTime = (value) => {
if (!value) {
return '—';
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString();
};
export const buildDocumentMetadataItems = (document) => {
if (!document) {
return [];
}
const metadata = document.current_version || {};
return [
{ label: 'Created at', value: formatDateTime(document.created_at) },
{ label: 'Updated at', value: formatDateTime(document.updated_at) },
{
label: 'Filename',
value: document.filename,
},
{
label: 'Original filename',
value: document.original_name || '—',
},
{
label: 'SHA-256 checksum',
value: metadata.checksum || '—',
},
{
label: 'Content type',
value: document.content_type || '—',
},
];
};
export const extractDocumentMetadataPayload = (document) => {
if (!document || !document.metadata) {
return null;
}
const keys = Object.keys(document.metadata);
if (!keys.length) {
return null;
}
return document.metadata;
};
export default buildDocumentMetadataItems;
+78 -201
View File
@@ -1,20 +1,14 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useCallback, useMemo } from 'react';
import { DownloadIcon, CloseIcon } from '../ui/icons';
import DocumentSummarySection, {
import {
buildCorrespondentOptions,
sortCorrespondents,
} from '../documents/DocumentSummarySection';
import DocumentInfoPanel from '../documents/DocumentInfoPanel';
import { extractDocumentMetadataPayload } from '../documents/documentMetadata';
import { createDocumentActionState } from '../documents/documentActions';
import { resolveDocumentAssetUrl } from '../asset_manager';
const formatDateTime = (value) => {
if (!value) {
return '—';
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString();
};
const DocumentViewerPanel = ({
document,
documentId,
@@ -42,32 +36,6 @@ const DocumentViewerPanel = ({
[correspondents],
);
const metadataItems = useMemo(() => {
if (!document) {
return [];
}
return [
{ label: 'Created at', value: formatDateTime(document.created_at) },
{ label: 'Updated at', value: formatDateTime(document.updated_at) },
{
label: 'Filename',
value: document.filename,
},
{
label: 'Original filename',
value: document.original_name || '—',
},
{
label: 'SHA-256 checksum',
value: document.current_version?.checksum || '—',
},
{
label: 'Content type',
value: document.content_type || '—',
},
];
}, [document]);
const previewContent = useMemo(() => {
if (!document || !previewEntry?.url) {
return null;
@@ -128,31 +96,41 @@ const DocumentViewerPanel = ({
);
}, [previewEntry, document]);
const metadataPayload = useMemo(() => {
if (!document || !document.metadata || Object.keys(document.metadata).length === 0) {
return null;
}
return document.metadata;
}, [document]);
const metadataPayload = useMemo(
() => extractDocumentMetadataPayload(document),
[document],
);
const [activeTab, setActiveTab] = useState('details');
useEffect(() => {
setActiveTab('details');
}, [document?.id, hasOcr, metadataPayload]);
const summaryProps = useMemo(
() => ({
tagLookupById,
tagOptions,
onTagAdd,
onTagRemove,
correspondents: sortedCorrespondents,
correspondentOptions,
onCorrespondentAdd,
onCorrespondentRemove,
onUpdateTitle,
onUpdateIssued,
}),
[
tagLookupById,
tagOptions,
onTagAdd,
onTagRemove,
sortedCorrespondents,
correspondentOptions,
onCorrespondentAdd,
onCorrespondentRemove,
onUpdateTitle,
onUpdateIssued,
],
);
const [ocrContent, setOcrContent] = useState(null);
const [ocrLoading, setOcrLoading] = useState(false);
const [ocrError, setOcrError] = useState(null);
useEffect(() => {
let cancelled = false;
const loadOcrContent = useCallback(async ({ signal } = {}) => {
if (!document || !hasOcr || typeof getDocumentAsset !== 'function') {
setOcrContent(null);
setOcrLoading(false);
setOcrError(null);
return () => {
cancelled = true;
};
return '';
}
const updateUrl = () =>
@@ -162,68 +140,47 @@ const DocumentViewerPanel = ({
});
const asset = getDocumentAsset(document, 'ocr-text');
let url = updateUrl();
const ensureAndUpdate = async () => {
setOcrLoading(true);
setOcrError(null);
let url = updateUrl();
if (!url && document.id && asset?.id && typeof ensureAssetUrl === 'function') {
try {
await ensureAssetUrl(document.id, asset, { start: 1, limit: 1 });
url = updateUrl();
} catch (error) {
if (!cancelled) {
setOcrError('Unable to load OCR content.');
}
}
if (!url && document.id && asset?.id && typeof ensureAssetUrl === 'function') {
await ensureAssetUrl(document.id, asset, { start: 1, limit: 1 });
if (signal?.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
url = updateUrl();
}
let textContent = null;
if (!cancelled && url) {
const controller = new AbortController();
if (!url) {
return '';
}
try {
const response = await fetch(url, {
method: 'GET',
mode: 'cors',
credentials: 'omit',
signal: controller.signal,
});
const response = await fetch(url, {
method: 'GET',
mode: 'cors',
credentials: 'omit',
signal,
});
if (!response.ok) {
throw new Error(`Unexpected status: ${response.status}`);
}
if (!response.ok) {
throw new Error(`Unexpected status: ${response.status}`);
}
textContent = await response.text();
} catch (error) {
if (!cancelled) {
console.error('[OCR] Failed to fetch text', error);
setOcrError('Unable to load OCR content.');
}
}
return response.text();
}, [document, hasOcr, getDocumentAsset, ensureAssetUrl]);
if (!cancelled) {
setOcrContent(textContent);
}
controller.abort();
}
if (!cancelled) {
if (!textContent) {
setOcrContent(null);
}
setOcrLoading(false);
}
};
ensureAndUpdate();
return () => {
cancelled = true;
};
}, [document, hasOcr, ensureAssetUrl, getDocumentAsset]);
const contentTabConfig = useMemo(
() => ({
enabled: hasOcr,
id: 'content',
label: 'Content',
loadContent: loadOcrContent,
loadingMessage: 'Loading OCR content…',
emptyMessage: 'No OCR content available.',
unavailableMessage: 'No OCR content available.',
errorMessage: 'Failed to load OCR content.',
}),
[hasOcr, loadOcrContent],
);
if (!document) {
return (
@@ -243,96 +200,16 @@ const DocumentViewerPanel = ({
return (
<section className="document-viewer">
<div className="document-viewer__details">
<DocumentSummarySection
<DocumentInfoPanel
document={document}
tagLookupById={tagLookupById}
tagOptions={tagOptions}
onTagAdd={onTagAdd}
onTagRemove={onTagRemove}
correspondents={sortedCorrespondents}
correspondentOptions={correspondentOptions}
onCorrespondentAdd={onCorrespondentAdd}
onCorrespondentRemove={onCorrespondentRemove}
onUpdateTitle={onUpdateTitle}
onUpdateIssued={onUpdateIssued}
summaryProps={summaryProps}
metadataPayload={metadataPayload}
contentConfig={contentTabConfig}
defaultTabId="details"
classNamePrefix="document-viewer"
hideTabNavWhenSingle={false}
resetKey={document?.id}
/>
<div className="document-viewer__tabs-wrapper">
<div className="document-viewer__tabs" role="tablist" aria-label="Document details">
<button
type="button"
role="tab"
aria-selected={activeTab === 'details'}
className={`document-viewer__tab${activeTab === 'details' ? ' is-active' : ''}`}
onClick={() => setActiveTab('details')}
>
Details
</button>
{hasOcr ? (
<button
type="button"
role="tab"
aria-selected={activeTab === 'content'}
className={`document-viewer__tab${activeTab === 'content' ? ' is-active' : ''}`}
onClick={() => setActiveTab('content')}
>
Content
</button>
) : null}
{metadataPayload ? (
<button
type="button"
role="tab"
aria-selected={activeTab === 'metadata'}
className={`document-viewer__tab${activeTab === 'metadata' ? ' is-active' : ''}`}
onClick={() => setActiveTab('metadata')}
>
Metadata
</button>
) : null}
</div>
<div className="document-viewer__tabpanes">
{activeTab === 'details' ? (
<div role="tabpanel" className="document-viewer__tabpanel">
<section className="document-viewer__section">
<dl className="document-viewer__section-list">
{metadataItems.map(({ label, value }) => (
<div className="document-viewer__section-item" key={label}>
<dt>{label}</dt>
<dd>{value || '—'}</dd>
</div>
))}
</dl>
</section>
</div>
) : null}
{activeTab === 'content' && hasOcr ? (
<div role="tabpanel" className="document-viewer__tabpanel">
{ocrLoading ? (
<div className="document-viewer__message">Loading OCR content</div>
) : ocrError ? (
<div className="document-viewer__message document-viewer__message--error">
{ocrError}
</div>
) : ocrContent ? (
<pre className="document-viewer__object document-viewer__object--ocr-text">
{ocrContent}
</pre>
) : (
<div className="document-viewer__message">No OCR content available.</div>
)}
</div>
) : null}
{activeTab === 'metadata' && metadataPayload ? (
<div role="tabpanel" className="document-viewer__tabpanel">
<section className="document-viewer__section document-viewer__section--metadata-json">
<pre className="document-viewer__metadata-json">
{JSON.stringify(metadataPayload, null, 2)}
</pre>
</section>
</div>
) : null}
</div>
</div>
</div>
<div className="document-viewer__viewport">
{!previewEntry?.url ? (
+5
View File
@@ -1011,6 +1011,11 @@ button.danger:hover:not([disabled]) {
display: flex;
}
.document-viewer__tabpanes--single {
flex: 1;
min-height: 0;
}
.document-viewer__tabpanel {
flex: 1;
min-height: 0;