desktop redo
This commit is contained in:
+244
-134
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user