desktop redo
This commit is contained in:
+230
-120
@@ -39,52 +39,139 @@ const CARD_MAX = 340;
|
|||||||
const TAG_REMOVE_DISTANCE = 160;
|
const TAG_REMOVE_DISTANCE = 160;
|
||||||
const STACK_HIT_EPSILON = 4;
|
const STACK_HIT_EPSILON = 4;
|
||||||
const POINTER_DRAG_THRESHOLD_SQUARED = 16;
|
const POINTER_DRAG_THRESHOLD_SQUARED = 16;
|
||||||
|
const LONG_PRESS_DURATION_MS = 450;
|
||||||
|
|
||||||
const DEBUG_DRAG = false;
|
const DEBUG_DRAG = false;
|
||||||
const DEBUG_FOCUS = true;
|
const DEBUG_FOCUS = true;
|
||||||
const DEBUG_DROP = true;
|
const DEBUG_DROP = true;
|
||||||
|
|
||||||
const resolveDeskPointerIntent = ({
|
const CLICK_ACTIONS = {
|
||||||
alreadySelected = false,
|
selectSingle: 'selectSingle',
|
||||||
selectedCount = 0,
|
openDetail: 'openDetail',
|
||||||
stackDocIds = null,
|
addCard: 'addCard',
|
||||||
metaOrCtrl = false,
|
addStack: 'addStack',
|
||||||
pointerButton = 0,
|
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) {
|
let clickAction = CLICK_ACTIONS.none;
|
||||||
return {
|
let dragAction = DRAG_ACTIONS.none;
|
||||||
callEntryPointer: true,
|
|
||||||
skipSelection: false,
|
if (metaKey) {
|
||||||
stackDragDocIds: null,
|
clickAction = CLICK_ACTIONS.addStack;
|
||||||
stackClickDocIds: null,
|
dragAction = DRAG_ACTIONS.dragSelectStack;
|
||||||
stackReplace: false,
|
} else if (alreadySelected) {
|
||||||
openDetailOnRelease: alreadySelected && pointerButton === 0 && selectedCount > 0,
|
clickAction = CLICK_ACTIONS.openDetail;
|
||||||
};
|
dragAction = selectionCount > 1 ? DRAG_ACTIONS.dragSelection : DRAG_ACTIONS.dragSelectSingle;
|
||||||
|
} else {
|
||||||
|
clickAction = CLICK_ACTIONS.selectSingle;
|
||||||
|
dragAction = DRAG_ACTIONS.dragSelectSingle;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (alreadySelected) {
|
const stackList = Array.isArray(stackHits) && stackHits.length > 0
|
||||||
return {
|
? stackHits.slice()
|
||||||
callEntryPointer: false,
|
: [String(doc.id)];
|
||||||
skipSelection: true,
|
|
||||||
stackDragDocIds: stackList,
|
const stackDocIdsForDrag = dragAction === DRAG_ACTIONS.dragSelectStack ? stackList : null;
|
||||||
stackClickDocIds: stackList,
|
const stackDocIdsForClick = clickAction === CLICK_ACTIONS.addStack ? stackList : null;
|
||||||
stackReplace: Boolean(stackList),
|
|
||||||
openDetailOnRelease: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
callEntryPointer: true,
|
docId: doc.id,
|
||||||
skipSelection: false,
|
entryDescriptor,
|
||||||
stackDragDocIds: stackList,
|
pointerType,
|
||||||
stackClickDocIds: null,
|
pointerButton,
|
||||||
stackReplace: Boolean(stackList),
|
selectedAtDown: alreadySelected,
|
||||||
openDetailOnRelease: false,
|
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 signedDistance = (ax, ay, bx, by, px, py) => (bx - ax) * (py - ay) - (by - ay) * (px - ax);
|
||||||
|
|
||||||
const clipPolygonWithEdge = (subject, edgeStart, edgeEnd) => {
|
const clipPolygonWithEdge = (subject, edgeStart, edgeEnd) => {
|
||||||
@@ -2006,10 +2093,11 @@ const DesktopWorkspaceView = () => {
|
|||||||
const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } =
|
const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } =
|
||||||
useDocumentDrag();
|
useDocumentDrag();
|
||||||
|
|
||||||
const deferredSelectionRef = useRef(null);
|
|
||||||
const pointerIntentRef = useRef(null);
|
const pointerIntentRef = useRef(null);
|
||||||
const pointerStartRef = useRef({ x: 0, y: 0 });
|
const pointerStartRef = useRef({ x: 0, y: 0 });
|
||||||
const pointerMovedRef = useRef(false);
|
const pointerMovedRef = useRef(false);
|
||||||
|
const longPressTimerRef = useRef(null);
|
||||||
|
const longPressActiveRef = useRef(false);
|
||||||
|
|
||||||
const resolveStackDocIds = useCallback(
|
const resolveStackDocIds = useCallback(
|
||||||
(event, targetDocId = null) => {
|
(event, targetDocId = null) => {
|
||||||
@@ -2145,6 +2233,58 @@ const DesktopWorkspaceView = () => {
|
|||||||
|
|
||||||
const allSizesReady = items.every((doc) => ensureDocumentSize(doc));
|
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(
|
const handleShellKeyDown = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
if (!event || event.defaultPrevented) {
|
if (!event || event.defaultPrevented) {
|
||||||
@@ -2293,75 +2433,56 @@ const DesktopWorkspaceView = () => {
|
|||||||
y: Number.isFinite(event.clientY) ? event.clientY : 0,
|
y: Number.isFinite(event.clientY) ? event.clientY : 0,
|
||||||
};
|
};
|
||||||
pointerMovedRef.current = false;
|
pointerMovedRef.current = false;
|
||||||
const selectionCountAtDown = Array.isArray(selectedDocumentIds)
|
resetLongPressState();
|
||||||
? selectedDocumentIds.length
|
|
||||||
: 0;
|
|
||||||
const alreadySelected = selectedDocumentIds.includes(doc.id);
|
|
||||||
const metaOrCtrlOnly =
|
|
||||||
(event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey;
|
|
||||||
|
|
||||||
const modifierActive =
|
|
||||||
Boolean(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey);
|
|
||||||
const pointerButton = typeof event.button === 'number' ? event.button : 0;
|
const pointerButton = typeof event.button === 'number' ? event.button : 0;
|
||||||
const stackHits = metaOrCtrlOnly ? resolveStackDocIds(event, doc.id) : null;
|
const pointerType = typeof event.pointerType === 'string' ? event.pointerType : '';
|
||||||
const pointerIntent = resolveDeskPointerIntent({
|
const metaKey = (event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey;
|
||||||
alreadySelected,
|
const modifierActive = Boolean(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey);
|
||||||
selectedCount: selectionCountAtDown,
|
|
||||||
stackDocIds: stackHits,
|
const entryDescriptor = {
|
||||||
metaOrCtrl: metaOrCtrlOnly,
|
type: 'document',
|
||||||
|
id: doc.id,
|
||||||
|
key: `document:${doc.id}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const stackHits = metaKey ? resolveStackDocIds(event, doc.id) : null;
|
||||||
|
|
||||||
|
const intent = createPointerIntent({
|
||||||
|
doc,
|
||||||
|
entryDescriptor,
|
||||||
|
selectedDocumentIds,
|
||||||
|
metaKey,
|
||||||
pointerButton,
|
pointerButton,
|
||||||
|
pointerType,
|
||||||
|
stackHits,
|
||||||
});
|
});
|
||||||
|
|
||||||
const stackDragDocIds = pointerIntent.stackDragDocIds;
|
if (intent.selectedAtDown && typeof onPromoteSelection === 'function') {
|
||||||
const stackClickDocIds = pointerIntent.stackClickDocIds;
|
|
||||||
|
|
||||||
if (alreadySelected && typeof onPromoteSelection === 'function') {
|
|
||||||
onPromoteSelection(doc.id, event);
|
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 deferSelection =
|
applyClickPlanImmediately({
|
||||||
!modifierActive
|
intent,
|
||||||
&& alreadySelected
|
|
||||||
&& Array.isArray(selectedDocumentIds)
|
|
||||||
&& selectedDocumentIds.length > 1;
|
|
||||||
|
|
||||||
const skipPointerSelection = pointerIntent.skipSelection;
|
|
||||||
|
|
||||||
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,
|
event,
|
||||||
);
|
onEntryPointer,
|
||||||
}
|
onDocumentStackSelect,
|
||||||
}
|
});
|
||||||
|
|
||||||
|
pointerIntentRef.current = intent;
|
||||||
|
|
||||||
handlePointerDown(event, doc.id, {
|
handlePointerDown(event, doc.id, {
|
||||||
stackDocIds: stackDragDocIds,
|
stackDocIds: intent.stackDocIdsForDrag,
|
||||||
stackSelectionApplied: false,
|
stackSelectionApplied: intent.stackSelectionApplied,
|
||||||
wasSelected: alreadySelected,
|
wasSelected: intent.selectedAtDown,
|
||||||
modifierActive,
|
modifierActive,
|
||||||
stackReplace: pointerIntent.stackReplace,
|
stackReplace: intent.stackReplaceOnDrag,
|
||||||
|
});
|
||||||
|
|
||||||
|
scheduleLongPress({
|
||||||
|
doc,
|
||||||
|
modifierActive,
|
||||||
|
pointerType,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onPointerMove={(event) => {
|
onPointerMove={(event) => {
|
||||||
@@ -2370,66 +2491,55 @@ const DesktopWorkspaceView = () => {
|
|||||||
const dy = Number.isFinite(event.clientY) ? event.clientY - start.y : 0;
|
const dy = Number.isFinite(event.clientY) ? event.clientY - start.y : 0;
|
||||||
if (dx * dx + dy * dy > POINTER_DRAG_THRESHOLD_SQUARED) {
|
if (dx * dx + dy * dy > POINTER_DRAG_THRESHOLD_SQUARED) {
|
||||||
pointerMovedRef.current = true;
|
pointerMovedRef.current = true;
|
||||||
|
resetLongPressState();
|
||||||
}
|
}
|
||||||
handlePointerMove(event);
|
handlePointerMove(event);
|
||||||
}}
|
}}
|
||||||
onPointerUp={(event) => {
|
onPointerUp={(event) => {
|
||||||
const deferredInfo = deferredSelectionRef.current;
|
|
||||||
const pointerState = pointerIntentRef.current;
|
const pointerState = pointerIntentRef.current;
|
||||||
const pointerMoved = pointerMovedRef.current;
|
const pointerMoved = pointerMovedRef.current;
|
||||||
|
|
||||||
|
resetLongPressState();
|
||||||
handlePointerUp(event);
|
handlePointerUp(event);
|
||||||
|
|
||||||
if (!pointerMoved && deferredInfo && typeof onEntryPointer === 'function') {
|
if (!pointerMoved && pointerState) {
|
||||||
const entry = deferredInfo.entry || deferredInfo;
|
finalizeClickSelection({
|
||||||
const applySelection = deferredInfo.applySelection !== false;
|
intent: pointerState,
|
||||||
if (applySelection && entry) {
|
event,
|
||||||
onEntryPointer(entry, event);
|
onEntryPointer,
|
||||||
}
|
onDocumentStackSelect,
|
||||||
}
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!pointerMoved
|
pointerState.clickAction === CLICK_ACTIONS.openDetail
|
||||||
&& pointerState
|
&& !pointerState.longPressTriggered
|
||||||
&& 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'
|
&& typeof onDocumentOpen === 'function'
|
||||||
|
&& pointerState.docId === doc.id
|
||||||
) {
|
) {
|
||||||
const expectedButton =
|
const expectedButton =
|
||||||
typeof pointerState.pointerButton === 'number'
|
typeof pointerState.pointerButton === 'number'
|
||||||
? pointerState.pointerButton
|
? pointerState.pointerButton
|
||||||
: 0;
|
: 0;
|
||||||
const releasedButton = typeof event.button === 'number' ? event.button : expectedButton;
|
const releasedButton = typeof event.button === 'number'
|
||||||
|
? event.button
|
||||||
|
: expectedButton;
|
||||||
const isPrimaryRelease = expectedButton === 0 && releasedButton === 0;
|
const isPrimaryRelease = expectedButton === 0 && releasedButton === 0;
|
||||||
const stillSelected = Array.isArray(selectedDocumentIds)
|
const stillSelected = Array.isArray(selectedDocumentIds)
|
||||||
&& selectedDocumentIds.includes(doc.id);
|
&& selectedDocumentIds.includes(doc.id);
|
||||||
if (isPrimaryRelease && stillSelected) {
|
if (isPrimaryRelease && stillSelected) {
|
||||||
const useSelection =
|
const useSelection = pointerState.selectedAtDown
|
||||||
pointerState.selectedAtDown && pointerState.selectionCountAtDown > 0;
|
&& pointerState.selectionCountAtDown > 0;
|
||||||
onDocumentOpen(doc.id, { useSelection });
|
onDocumentOpen(doc.id, { useSelection });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
deferredSelectionRef.current = null;
|
|
||||||
pointerIntentRef.current = null;
|
pointerIntentRef.current = null;
|
||||||
pointerMovedRef.current = false;
|
pointerMovedRef.current = false;
|
||||||
}}
|
}}
|
||||||
onPointerCancel={(event) => {
|
onPointerCancel={(event) => {
|
||||||
deferredSelectionRef.current = null;
|
|
||||||
pointerMovedRef.current = false;
|
pointerMovedRef.current = false;
|
||||||
|
resetLongPressState();
|
||||||
pointerIntentRef.current = null;
|
pointerIntentRef.current = null;
|
||||||
handlePointerCancel(event);
|
handlePointerCancel(event);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import { useAssetNavigator } from '../hooks/useAssetNavigator';
|
|||||||
import { describeDocumentSummary } from '../documents/documentSummary';
|
import { describeDocumentSummary } from '../documents/documentSummary';
|
||||||
import { createDocumentActionState } from '../documents/documentActions';
|
import { createDocumentActionState } from '../documents/documentActions';
|
||||||
import PreviewZoomOverlay from './PreviewZoomOverlay';
|
import PreviewZoomOverlay from './PreviewZoomOverlay';
|
||||||
import DocumentSummarySection, {
|
import DocumentInfoPanel from '../documents/DocumentInfoPanel';
|
||||||
|
import {
|
||||||
TagSection,
|
TagSection,
|
||||||
CorrespondentSection,
|
CorrespondentSection,
|
||||||
sortCorrespondents,
|
sortCorrespondents,
|
||||||
@@ -407,6 +408,94 @@ const DetailPanel = ({
|
|||||||
return sortCorrespondents(singleDoc.correspondents || []);
|
return sortCorrespondents(singleDoc.correspondents || []);
|
||||||
}, [singleDoc]);
|
}, [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(() => {
|
const bulkCorrespondents = useMemo(() => {
|
||||||
if (selectedDocuments.length <= 1) {
|
if (selectedDocuments.length <= 1) {
|
||||||
const doc = selectedDocuments[0];
|
const doc = selectedDocuments[0];
|
||||||
@@ -766,19 +855,17 @@ const DetailPanel = ({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<DocumentSummarySection
|
<div className="document-viewer__details">
|
||||||
|
<DocumentInfoPanel
|
||||||
document={singleDoc}
|
document={singleDoc}
|
||||||
tagLookupById={tagLookupById}
|
summaryProps={singleSummaryProps}
|
||||||
tagOptions={tags}
|
contentConfig={singleContentConfig}
|
||||||
onTagAdd={(doc, value, extras) => onTagAdd(doc, value, extras)}
|
classNamePrefix="document-viewer"
|
||||||
onTagRemove={(docId, tagId) => onTagRemove(docId, tagId)}
|
defaultTabId="details"
|
||||||
correspondents={singleCorrespondents}
|
resetKey={singleDocId}
|
||||||
correspondentOptions={correspondentOptions}
|
hideTabNavWhenSingle={false}
|
||||||
onCorrespondentAdd={onCorrespondentAdd}
|
|
||||||
onCorrespondentRemove={onCorrespondentRemove}
|
|
||||||
onUpdateTitle={onUpdateTitle}
|
|
||||||
onUpdateIssued={onUpdateIssued}
|
|
||||||
/>
|
/>
|
||||||
|
</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 (
|
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">
|
<div className="view-toggle" role="group" aria-label="Change view">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -796,17 +812,6 @@ export const createDocumentsTableHeaderActions = ({
|
|||||||
>
|
>
|
||||||
<RefreshIcon />
|
<RefreshIcon />
|
||||||
</button>
|
</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 { DownloadIcon, CloseIcon } from '../ui/icons';
|
||||||
import DocumentSummarySection, {
|
import {
|
||||||
buildCorrespondentOptions,
|
buildCorrespondentOptions,
|
||||||
sortCorrespondents,
|
sortCorrespondents,
|
||||||
} from '../documents/DocumentSummarySection';
|
} from '../documents/DocumentSummarySection';
|
||||||
|
import DocumentInfoPanel from '../documents/DocumentInfoPanel';
|
||||||
|
import { extractDocumentMetadataPayload } from '../documents/documentMetadata';
|
||||||
import { createDocumentActionState } from '../documents/documentActions';
|
import { createDocumentActionState } from '../documents/documentActions';
|
||||||
import { resolveDocumentAssetUrl } from '../asset_manager';
|
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 = ({
|
const DocumentViewerPanel = ({
|
||||||
document,
|
document,
|
||||||
documentId,
|
documentId,
|
||||||
@@ -42,32 +36,6 @@ const DocumentViewerPanel = ({
|
|||||||
[correspondents],
|
[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(() => {
|
const previewContent = useMemo(() => {
|
||||||
if (!document || !previewEntry?.url) {
|
if (!document || !previewEntry?.url) {
|
||||||
return null;
|
return null;
|
||||||
@@ -128,31 +96,41 @@ const DocumentViewerPanel = ({
|
|||||||
);
|
);
|
||||||
}, [previewEntry, document]);
|
}, [previewEntry, document]);
|
||||||
|
|
||||||
const metadataPayload = useMemo(() => {
|
const metadataPayload = useMemo(
|
||||||
if (!document || !document.metadata || Object.keys(document.metadata).length === 0) {
|
() => extractDocumentMetadataPayload(document),
|
||||||
return null;
|
[document],
|
||||||
}
|
);
|
||||||
return document.metadata;
|
|
||||||
}, [document]);
|
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState('details');
|
const summaryProps = useMemo(
|
||||||
useEffect(() => {
|
() => ({
|
||||||
setActiveTab('details');
|
tagLookupById,
|
||||||
}, [document?.id, hasOcr, metadataPayload]);
|
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 loadOcrContent = useCallback(async ({ signal } = {}) => {
|
||||||
const [ocrLoading, setOcrLoading] = useState(false);
|
|
||||||
const [ocrError, setOcrError] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
if (!document || !hasOcr || typeof getDocumentAsset !== 'function') {
|
if (!document || !hasOcr || typeof getDocumentAsset !== 'function') {
|
||||||
setOcrContent(null);
|
return '';
|
||||||
setOcrLoading(false);
|
|
||||||
setOcrError(null);
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateUrl = () =>
|
const updateUrl = () =>
|
||||||
@@ -162,68 +140,47 @@ const DocumentViewerPanel = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const asset = getDocumentAsset(document, 'ocr-text');
|
const asset = getDocumentAsset(document, 'ocr-text');
|
||||||
|
|
||||||
const ensureAndUpdate = async () => {
|
|
||||||
setOcrLoading(true);
|
|
||||||
setOcrError(null);
|
|
||||||
|
|
||||||
let url = updateUrl();
|
let url = updateUrl();
|
||||||
|
|
||||||
if (!url && document.id && asset?.id && typeof ensureAssetUrl === 'function') {
|
if (!url && document.id && asset?.id && typeof ensureAssetUrl === 'function') {
|
||||||
try {
|
|
||||||
await ensureAssetUrl(document.id, asset, { start: 1, limit: 1 });
|
await ensureAssetUrl(document.id, asset, { start: 1, limit: 1 });
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new DOMException('Aborted', 'AbortError');
|
||||||
|
}
|
||||||
url = updateUrl();
|
url = updateUrl();
|
||||||
} catch (error) {
|
|
||||||
if (!cancelled) {
|
|
||||||
setOcrError('Unable to load OCR content.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let textContent = null;
|
if (!url) {
|
||||||
if (!cancelled && url) {
|
return '';
|
||||||
const controller = new AbortController();
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
mode: 'cors',
|
mode: 'cors',
|
||||||
credentials: 'omit',
|
credentials: 'omit',
|
||||||
signal: controller.signal,
|
signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Unexpected status: ${response.status}`);
|
throw new Error(`Unexpected status: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
textContent = await response.text();
|
return response.text();
|
||||||
} catch (error) {
|
}, [document, hasOcr, getDocumentAsset, ensureAssetUrl]);
|
||||||
if (!cancelled) {
|
|
||||||
console.error('[OCR] Failed to fetch text', error);
|
|
||||||
setOcrError('Unable to load OCR content.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cancelled) {
|
const contentTabConfig = useMemo(
|
||||||
setOcrContent(textContent);
|
() => ({
|
||||||
}
|
enabled: hasOcr,
|
||||||
|
id: 'content',
|
||||||
controller.abort();
|
label: 'Content',
|
||||||
}
|
loadContent: loadOcrContent,
|
||||||
|
loadingMessage: 'Loading OCR content…',
|
||||||
if (!cancelled) {
|
emptyMessage: 'No OCR content available.',
|
||||||
if (!textContent) {
|
unavailableMessage: 'No OCR content available.',
|
||||||
setOcrContent(null);
|
errorMessage: 'Failed to load OCR content.',
|
||||||
}
|
}),
|
||||||
setOcrLoading(false);
|
[hasOcr, loadOcrContent],
|
||||||
}
|
);
|
||||||
};
|
|
||||||
|
|
||||||
ensureAndUpdate();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [document, hasOcr, ensureAssetUrl, getDocumentAsset]);
|
|
||||||
|
|
||||||
if (!document) {
|
if (!document) {
|
||||||
return (
|
return (
|
||||||
@@ -243,96 +200,16 @@ const DocumentViewerPanel = ({
|
|||||||
return (
|
return (
|
||||||
<section className="document-viewer">
|
<section className="document-viewer">
|
||||||
<div className="document-viewer__details">
|
<div className="document-viewer__details">
|
||||||
<DocumentSummarySection
|
<DocumentInfoPanel
|
||||||
document={document}
|
document={document}
|
||||||
tagLookupById={tagLookupById}
|
summaryProps={summaryProps}
|
||||||
tagOptions={tagOptions}
|
metadataPayload={metadataPayload}
|
||||||
onTagAdd={onTagAdd}
|
contentConfig={contentTabConfig}
|
||||||
onTagRemove={onTagRemove}
|
defaultTabId="details"
|
||||||
correspondents={sortedCorrespondents}
|
classNamePrefix="document-viewer"
|
||||||
correspondentOptions={correspondentOptions}
|
hideTabNavWhenSingle={false}
|
||||||
onCorrespondentAdd={onCorrespondentAdd}
|
resetKey={document?.id}
|
||||||
onCorrespondentRemove={onCorrespondentRemove}
|
|
||||||
onUpdateTitle={onUpdateTitle}
|
|
||||||
onUpdateIssued={onUpdateIssued}
|
|
||||||
/>
|
/>
|
||||||
<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>
|
||||||
<div className="document-viewer__viewport">
|
<div className="document-viewer__viewport">
|
||||||
{!previewEntry?.url ? (
|
{!previewEntry?.url ? (
|
||||||
|
|||||||
@@ -1011,6 +1011,11 @@ button.danger:hover:not([disabled]) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.document-viewer__tabpanes--single {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.document-viewer__tabpanel {
|
.document-viewer__tabpanel {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user