Merge remote-tracking branch 'ui/ui' into dev

This commit is contained in:
2025-11-11 15:29:07 +01:00
44 changed files with 1998 additions and 2148 deletions
-2
View File
@@ -1,2 +0,0 @@
dist
node_modules
-31
View File
@@ -1,31 +0,0 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended"
],
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": "latest",
"sourceType": "module"
},
"settings": {
"react": {
"version": "detect"
}
},
"rules": {
"no-use-before-define": [
"error",
{ "functions": false, "classes": true, "variables": true }
],
"react/react-in-jsx-scope": "off",
"react/prop-types": "off"
}
}
+53
View File
@@ -0,0 +1,53 @@
import js from '@eslint/js';
import pluginReact from 'eslint-plugin-react';
import pluginReactHooks from 'eslint-plugin-react-hooks';
import globals from 'globals';
const sharedRules = {
...js.configs.recommended.rules,
...pluginReact.configs.recommended.rules,
...pluginReactHooks.configs.recommended.rules,
'no-use-before-define': [
'error',
{ functions: false, classes: true, variables: true },
],
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
'react-hooks/set-state-in-effect': 'off',
'react-hooks/refs': 'off',
'react-hooks/preserve-manual-memoization': 'off',
};
const sharedLanguageOptions = {
ecmaVersion: 'latest',
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
globals: {
...globals.browser,
...globals.node,
},
};
export default [
{
ignores: ['dist', 'node_modules'],
},
{
files: ['src/**/*.{js,jsx}', 'tests/**/*.{js,jsx}'],
languageOptions: sharedLanguageOptions,
plugins: {
react: pluginReact,
'react-hooks': pluginReactHooks,
},
settings: {
react: {
version: 'detect',
},
},
rules: sharedRules,
},
];
+763 -1063
View File
File diff suppressed because it is too large Load Diff
+21 -20
View File
@@ -11,27 +11,28 @@
},
"dependencies": {
"@fontsource/inter": "^5.2.8",
"@tabler/icons-react": "3.11.0",
"axios": "1.7.7",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router-dom": "6.27.0"
"@tabler/icons-react": "^3.35.0",
"axios": "^1.13.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.9.5"
},
"devDependencies": {
"@babel/core": "7.26.0",
"@babel/preset-env": "7.26.0",
"@babel/preset-react": "7.26.3",
"eslint": "8.57.0",
"eslint-plugin-react": "7.37.1",
"eslint-plugin-react-hooks": "4.6.0",
"@svgr/webpack": "8.1.0",
"babel-loader": "9.2.1",
"css-loader": "7.1.2",
"dotenv": "16.4.5",
"html-webpack-plugin": "5.6.3",
"style-loader": "4.0.0",
"webpack": "5.95.0",
"webpack-cli": "5.1.4",
"webpack-dev-server": "5.1.0"
"@babel/core": "^7.28.5",
"@babel/preset-env": "^7.28.5",
"@babel/preset-react": "^7.28.5",
"@svgr/webpack": "^8.1.0",
"babel-loader": "^10.0.0",
"css-loader": "^7.1.2",
"dotenv": "^17.2.3",
"eslint": "^9.39.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"globals": "^16.5.0",
"html-webpack-plugin": "^5.6.4",
"style-loader": "^4.0.0",
"webpack": "^5.102.1",
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.2"
}
}
-2
View File
@@ -83,7 +83,6 @@ export const useWorkspaceSurface = ({
onFolderNavigate,
} = detailExtras;
return createDocumentViewerSurface({
documentId: previewDocumentId,
document: previewWorkspaceDocument,
previewEntry: previewWorkspaceEntry,
ensureAssetUrl,
@@ -108,7 +107,6 @@ export const useWorkspaceSurface = ({
}, [
showPreviewWorkspace,
previewWorkspaceDocument,
previewDocumentId,
previewWorkspaceEntry,
ensureAssetUrl,
ensurePreviewData,
+14 -2
View File
@@ -2,7 +2,7 @@ import React from 'react';
import SelectionFloatingActions from '../documents/SelectionFloatingActions';
import { createDocumentsTableHeaderActions } from '../documents/DocumentsPanel';
import createWorkspaceSurfaceConfig from '../documents/workspaceHeader';
import DetailPanel from '../detail/DetailPanel';
import DocumentViewerPanel from '../preview/DocumentViewerPanel';
import DesktopWorkspace from './DesktopWorkspace';
const createDesktopSurface = ({
@@ -82,7 +82,19 @@ const createDesktopSurface = ({
: null;
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
const detail = detailOpen && detailProps ? <DetailPanel {...detailProps} /> : null;
const detail = detailOpen && detailProps
? (() => {
const { onClose, onOpenPreview, ...restDetailProps } = detailProps;
return (
<DocumentViewerPanel
variant="sidebar"
onCollapsePanel={onClose}
onMaximizePanel={onOpenPreview}
{...restDetailProps}
/>
);
})()
: null;
const surfaceConfig = createWorkspaceSurfaceConfig({
key: 'workspace',
variant: 'workspace',
+1 -1
View File
@@ -99,7 +99,7 @@ const withStore = async (mode, handler) => {
}
try {
await done;
} catch (suppressed) {
} catch {
// noop prefer original error
}
throw error;
-2
View File
@@ -1223,7 +1223,6 @@ export const useWorkspaceSnapshot = (engine, useSyncExternalStoreHook) => {
};
/* istanbul ignore next */
/* eslint-disable no-undef */
if (typeof module !== 'undefined' && module && module.exports) {
module.exports = {
WorkspaceEngine,
@@ -1245,4 +1244,3 @@ if (typeof module !== 'undefined' && module && module.exports) {
useWorkspaceSnapshot,
};
}
/* eslint-enable no-undef */
-635
View File
@@ -1,635 +0,0 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
DownloadIcon,
ArrowLeftIcon,
ArrowRightIcon,
DetailPanelCollapseIcon,
WindowMaximizeIcon,
} from '../ui/icons';
import PanelHeader from '../ui/PanelHeader';
import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
import { useAssetNavigator } from '../hooks/useAssetNavigator';
import { describeDocumentSummary } from '../documents/documentSummary';
import { createDocumentActionState } from '../documents/documentActions';
import PreviewZoomOverlay from './PreviewZoomOverlay';
import DocumentInfoPanel from '../documents/DocumentInfoPanel';
import { sortCorrespondents, buildCorrespondentOptions } from '../documents/DocumentSummarySection';
import BreadcrumbTrail from '../ui/BreadcrumbTrail';
const derivePreviewOrientation = (metadata) => {
const width = Number(metadata?.width);
const height = Number(metadata?.height);
if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
return width >= height ? 'landscape' : 'portrait';
}
return 'landscape';
};
const PreviewImage = ({
item,
emptyMessage = 'Preview unavailable',
emptyContent = null,
onActivate,
onOpenPreview,
onZoomPreview,
showNav = false,
canGoPrev = false,
canGoNext = false,
onGoPrev = null,
onGoNext = null,
}) => {
if (!item) {
return (
<div className="preview-image preview-image--empty">
{emptyContent || <span className="meta">{emptyMessage}</span>}
</div>
);
}
const handleActivate = (event) => {
event.stopPropagation();
if (onZoomPreview) {
onZoomPreview(item);
} else if (onOpenPreview) {
onOpenPreview(item.id);
} else if (onActivate) {
onActivate(item.id);
}
};
const interceptNavPointer = (event) => {
event.preventDefault();
event.stopPropagation();
};
return (
<div className="preview-image">
<img
src={item.url}
alt={item.alt}
className={`preview-image__content orientation-${item.orientation || 'landscape'}`}
onClick={handleActivate}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
handleActivate(event);
}
}}
/>
{showNav ? (
<div className="preview-pane__nav preview-pane__nav--overlay">
<button
type="button"
className="preview-pane__nav-button preview-pane__nav-button--prev"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onGoPrev?.();
}}
onPointerDown={interceptNavPointer}
onPointerUp={interceptNavPointer}
onMouseDown={interceptNavPointer}
onMouseUp={interceptNavPointer}
disabled={!canGoPrev}
aria-label="Previous preview"
>
<ArrowLeftIcon />
</button>
<button
type="button"
className="preview-pane__nav-button preview-pane__nav-button--next"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onGoNext?.();
}}
onPointerDown={interceptNavPointer}
onPointerUp={interceptNavPointer}
onMouseDown={interceptNavPointer}
onMouseUp={interceptNavPointer}
disabled={!canGoNext}
aria-label="Next preview"
>
<ArrowRightIcon />
</button>
</div>
) : null}
</div>
);
};
const DetailPanel = ({
document = null,
tags = [],
tagLookupById = new Map(),
onTagAdd,
onTagRemove,
onOpenPreview,
onPromoteSelection,
onUpdateTitle = async () => false,
onUpdateIssued = async () => false,
ensureAssetUrl = null,
getDocumentAsset = () => null,
ensurePreviewData = () => Promise.resolve(),
correspondents = [],
onCorrespondentAdd,
onCorrespondentRemove,
resolveApiPath,
onFolderNavigate = null,
resolveFolderPath = null,
onClose = () => {},
}) => {
const singleDoc = document || null;
const singleDocId = singleDoc?.id || null;
const selectionKey = singleDocId || 'none';
const { downloadHref: singleDownloadHref } = useMemo(
() =>
createDocumentActionState({
document: singleDoc,
resolveApiPath,
ensurePreviewData,
ensureAssetUrl,
getDocumentAsset,
}),
[singleDoc, resolveApiPath, ensurePreviewData, ensureAssetUrl, getDocumentAsset],
);
const detailSummary = useMemo(() => describeDocumentSummary(singleDoc), [singleDoc]);
const headerTitle = singleDoc ? detailSummary.title : 'Document details';
const headerBreadcrumbs = useMemo(() => {
if (!singleDoc || typeof resolveFolderPath !== 'function') {
return null;
}
const handleNavigate = (folderId) => {
if (!folderId || typeof onFolderNavigate !== 'function') {
return;
}
onFolderNavigate(folderId);
};
const folderSegments = resolveFolderPath(singleDoc.folder_id);
const normalizedSegments = Array.isArray(folderSegments)
? folderSegments
.filter((segment) => segment && segment.id && segment.name)
.map((segment) => ({
id: segment.id,
label: segment.name,
onClick: segment.id ? () => handleNavigate(segment.id) : null,
}))
: [];
return [
...normalizedSegments,
{
id: singleDoc.id || 'current-document',
label: detailSummary.title,
},
];
}, [singleDoc, resolveFolderPath, detailSummary, onFolderNavigate]);
const [zoomedPreview, setZoomedPreview] = useState(null);
useEffect(() => {
setZoomedPreview(null);
}, [selectionKey]);
const handlePreviewActivate = useCallback(
(docId) => {
if (!docId) return;
onPromoteSelection?.(docId);
},
[onPromoteSelection],
);
const singlePreviewNavigator = useAssetNavigator({
document: singleDoc,
assetType: 'preview',
ensureAssetUrl,
getAsset: getDocumentAsset,
prefetch: 3,
});
const makePreviewItem = useCallback(
(doc, ordinal = 1) => {
if (!doc) return null;
const asset = getDocumentAsset(doc, 'preview');
const assetView = createAssetView(asset);
const object = assetView.getObject(ordinal);
let url = object?.url || null;
if (!url) {
url = resolveDocumentAssetUrl(doc, 'preview', {
ensureAssetUrl,
getAsset: getDocumentAsset,
ensureOptions: { start: ordinal, limit: 1 },
objectOrdinal: ordinal,
});
}
if (!url) {
return null;
}
const metadata = object?.metadata || assetView.getPrimaryMetadata() || {};
const orientation = derivePreviewOrientation(metadata);
return {
id: doc.id,
url,
orientation,
alt: doc.title,
};
},
[ensureAssetUrl, getDocumentAsset],
);
const singlePreviewItem = useMemo(() => {
if (!singleDoc) return null;
const url = singlePreviewNavigator.currentUrl;
if (url) {
return {
id: singleDoc.id,
url,
orientation: derivePreviewOrientation(singlePreviewNavigator.currentMetadata),
alt: singleDoc.title,
};
}
return makePreviewItem(singleDoc, 1);
}, [
singleDoc,
singlePreviewNavigator.currentUrl,
singlePreviewNavigator.currentMetadata,
makePreviewItem,
]);
const singleCardinality = singlePreviewNavigator.cardinality;
const singleEffectiveCardinality = singleCardinality || (singlePreviewNavigator.currentUrl ? 1 : 0);
const singleHasPreview = Boolean(singlePreviewNavigator.currentUrl);
const correspondentOptions = useMemo(
() => buildCorrespondentOptions(Array.isArray(correspondents) ? correspondents : []),
[correspondents],
);
const singleCorrespondents = useMemo(() => {
if (!singleDoc) return [];
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 openZoomPreview = useCallback((docId) => {
if (!docId) return;
setZoomedPreview({ docId });
}, []);
const closeZoomPreview = useCallback(() => {
setZoomedPreview(null);
}, []);
const handleSingleZoom = useCallback(
(entry) => {
if (!singleHasPreview) return;
const targetId = entry?.id ?? singleDocId;
if (!targetId) return;
openZoomPreview(targetId);
},
[openZoomPreview, singleHasPreview, singleDocId],
);
const zoomDisplay = useMemo(() => {
if (
!zoomedPreview
|| !singleDoc
|| !singleDocId
|| zoomedPreview.docId !== singleDocId
|| !singleHasPreview
) {
return null;
}
return {
url: singlePreviewNavigator.currentUrl,
alt: singleDoc.title,
canGoPrev:
singleEffectiveCardinality > 1 && Boolean(singlePreviewNavigator.canGoPrev),
canGoNext:
singleEffectiveCardinality > 1 && Boolean(singlePreviewNavigator.canGoNext),
goPrev: singlePreviewNavigator.goPrev,
goNext: singlePreviewNavigator.goNext,
};
}, [
zoomedPreview,
singleDoc,
singleDocId,
singleHasPreview,
singlePreviewNavigator.currentUrl,
singlePreviewNavigator.canGoPrev,
singlePreviewNavigator.canGoNext,
singlePreviewNavigator.goPrev,
singlePreviewNavigator.goNext,
singleEffectiveCardinality,
]);
useEffect(() => {
if (zoomedPreview && !zoomDisplay) {
setZoomedPreview(null);
}
}, [zoomedPreview, zoomDisplay]);
const {
documentId: singleNavigatorDocId,
asset: singleNavigatorAsset,
ordinal: singleNavigatorOrdinal,
canGoPrev: singleNavigatorCanGoPrev,
canGoNext: singleNavigatorCanGoNext,
cardinality: singleNavigatorCardinality,
} = singlePreviewNavigator;
useEffect(() => {
if (typeof ensureAssetUrl !== 'function') {
return;
}
if (!singleNavigatorDocId || !singleNavigatorAsset || !Number.isFinite(singleNavigatorOrdinal)) {
return;
}
const requests = [];
if (singleNavigatorCanGoPrev) {
const prevOrdinal = Math.max(1, singleNavigatorOrdinal - 1);
if (!singleNavigatorCardinality || prevOrdinal <= singleNavigatorCardinality) {
requests.push(
ensureAssetUrl(singleNavigatorDocId, singleNavigatorAsset, {
start: prevOrdinal,
limit: 1,
objectOrdinal: prevOrdinal,
}),
);
}
}
if (singleNavigatorCanGoNext) {
const nextOrdinal = singleNavigatorOrdinal + 1;
if (!singleNavigatorCardinality || nextOrdinal <= singleNavigatorCardinality) {
requests.push(
ensureAssetUrl(singleNavigatorDocId, singleNavigatorAsset, {
start: nextOrdinal,
limit: 1,
objectOrdinal: nextOrdinal,
}),
);
}
}
requests.forEach((promise) => promise?.catch?.(() => {}));
}, [
ensureAssetUrl,
singleNavigatorDocId,
singleNavigatorAsset,
singleNavigatorOrdinal,
singleNavigatorCanGoPrev,
singleNavigatorCanGoNext,
singleNavigatorCardinality,
]);
const renderContent = () => {
if (!singleDoc) {
return <p className="meta">Select a document to view metadata, tags and actions.</p>;
}
const effectiveCardinality = singleEffectiveCardinality;
const navCanGoPrev = Boolean(singlePreviewNavigator.canGoPrev);
const navCanGoNext = Boolean(singlePreviewNavigator.canGoNext);
const hasPreviewImage = Boolean(singlePreviewNavigator.currentUrl);
const previewMissingAsset = !singlePreviewNavigator.currentUrl;
const displayContentType = singleDoc.content_type || 'this file type';
const displayFilename =
singleDoc.filename || singleDoc.original_name || singleDoc.title || 'download';
const previewFallback = previewMissingAsset ? (
<div className="preview-pane__unsupported">
<div className="preview-pane__unsupported-message">
Preview not available for {displayContentType} files.
</div>
<div className="preview-pane__unsupported-filename">{displayFilename}</div>
{singleDownloadHref ? (
<a
className="button-link preview-pane__unsupported-download"
href={singleDownloadHref}
target="_blank"
rel="noopener noreferrer"
>
<DownloadIcon />
<span>Download</span>
</a>
) : null}
</div>
) : null;
const emptyMessage = previewMissingAsset ? 'Preview unavailable' : 'Preview loading…';
const showNav = hasPreviewImage && (effectiveCardinality > 1 || navCanGoPrev || navCanGoNext);
return (
<div className="preview-pane">
<div className="preview-pane__media">
<PreviewImage
item={singlePreviewItem}
emptyMessage={emptyMessage}
emptyContent={previewFallback}
onActivate={handlePreviewActivate}
onOpenPreview={onOpenPreview}
onZoomPreview={handleSingleZoom}
showNav={showNav}
canGoPrev={navCanGoPrev}
canGoNext={navCanGoNext}
onGoPrev={navCanGoPrev ? singlePreviewNavigator.goPrev : undefined}
onGoNext={navCanGoNext ? singlePreviewNavigator.goNext : undefined}
/>
</div>
<div className="document-viewer__details">
<DocumentInfoPanel
document={singleDoc}
summaryProps={singleSummaryProps}
contentConfig={singleContentConfig}
classNamePrefix="document-viewer"
defaultTabId="details"
resetKey={singleDocId}
hideTabNavWhenSingle={false}
/>
</div>
</div>
);
};
const headerLeading = [
(
<button
key="close"
type="button"
className="icon-button"
onClick={onClose}
aria-label="Close detail panel"
title="Close detail panel"
>
<DetailPanelCollapseIcon />
</button>
),
];
if (singleDoc) {
headerLeading.push(
<button
key="preview"
type="button"
className="icon-button"
onClick={(event) => {
event.stopPropagation();
onOpenPreview(singleDoc.id);
}}
aria-label="Maximize"
title="Maximize"
>
<WindowMaximizeIcon className="icon--flip-y" />
</button>,
);
}
const headerActions = [];
if (singleDoc && singleDownloadHref) {
headerActions.push(
<a
key="download"
className="icon-button"
href={singleDownloadHref}
target="_blank"
rel="noopener noreferrer"
aria-label="Download document"
title="Download document"
onClick={(event) => event.stopPropagation()}
>
<DownloadIcon />
</a>,
);
}
return (
<>
<aside className="detail-panel panel">
<PanelHeader
leading={headerLeading}
title={
headerBreadcrumbs ? (
<BreadcrumbTrail
entries={headerBreadcrumbs}
separator="/"
className="panel-header__breadcrumbs"
truncateFromStart
/>
) : (
headerTitle
)
}
titleTag="h3"
actions={headerActions.length ? headerActions : null}
/>
<div className="panel-body">
{renderContent()}
</div>
</aside>
{singleDoc && (
<PreviewZoomOverlay
open={Boolean(zoomDisplay)}
display={zoomDisplay}
onClose={closeZoomPreview}
/>
)}
</>
);
};
export default DetailPanel;
+7 -19
View File
@@ -10,7 +10,6 @@ import {
const useDetailWorkspace = ({
documents,
searchResults,
focusedDocumentId,
selectionOrder,
selectedDocumentIds,
documentLookup,
@@ -22,7 +21,6 @@ const useDetailWorkspace = ({
previewDocumentId,
activePreviewId,
openDocumentPreview,
promoteSelectionOrder,
handleDocumentTitleUpdate,
handleDocumentIssuedUpdate,
handleDocumentTagAdd,
@@ -38,14 +36,6 @@ const useDetailWorkspace = ({
tags,
tagLookupById,
}) => {
const selectedDocument = useMemo(() => {
if (!focusedDocumentId) {
return null;
}
const pool = searchResults ?? documents;
return pool.find((doc) => doc.id === focusedDocumentId) || null;
}, [focusedDocumentId, searchResults, documents]);
const orderedSelectedDocuments = useMemo(() => {
const ordered = [];
const seen = new Set();
@@ -186,12 +176,12 @@ const useDetailWorkspace = ({
[folderNodes],
);
const selectedPreviewEntry = useMemo(() => {
if (!selectedDocument) {
const detailPanelPreviewEntry = useMemo(() => {
if (!detailPanelDocument) {
return null;
}
return previewEntries.get(selectedDocument.id) || null;
}, [selectedDocument, previewEntries]);
return previewEntries.get(detailPanelDocument.id) || null;
}, [detailPanelDocument, previewEntries]);
const previewWorkspaceEntry = useMemo(() => {
if (!previewDocumentId) {
@@ -240,15 +230,14 @@ const useDetailWorkspace = ({
tagLookupById,
onTagAdd: handleDocumentTagAdd,
onTagRemove: handleTagRemove,
previewEntry: selectedPreviewEntry,
previewEntry: detailPanelPreviewEntry,
onOpenPreview: openDocumentPreview,
onPromoteSelection: promoteSelectionOrder,
activePreviewId,
onUpdateTitle: handleDocumentTitleUpdate,
onUpdateIssued: handleDocumentIssuedUpdate,
ensureAssetUrl,
getDocumentAsset,
ensurePreviewData,
hydrateDocument: ensurePreviewData,
correspondents,
onCorrespondentAdd: handleCorrespondentAdd,
onCorrespondentRemove: handleCorrespondentRemove,
@@ -272,11 +261,10 @@ const useDetailWorkspace = ({
handleDocumentTitleUpdate,
handleTagRemove,
openDocumentPreview,
promoteSelectionOrder,
resolveApiPath,
resolveFolderPath,
selectFolder,
selectedPreviewEntry,
detailPanelPreviewEntry,
tags,
tagLookupById,
],
@@ -181,6 +181,7 @@ const SelectionAssignmentMenu = ({
ref={menuRef}
style={menuStyle || undefined}
role="menu"
data-floating-position
>
<div className="selection-assignment__header">
<input
@@ -31,7 +31,7 @@ const useBulkDocumentActions = ({
if (!target) {
try {
target = await handleCorrespondentCreate({ name: trimmed });
} catch (error) {
} catch {
return;
}
}
@@ -160,7 +160,6 @@ const useBulkDocumentActions = ({
if (folderIds.length) {
for (const folderId of folderIds) {
// eslint-disable-next-line no-await-in-loop
const success = await handleFolderDelete(folderId, { showMessage: false, manageLoading: false });
if (!success) {
foldersOk = false;
@@ -196,7 +196,6 @@ const DocumentsPanel = ({
[handleDocumentPreviewZoom, onInspectDocument],
);
const selectedRowKeySet = useMemo(() => new Set(selectedEntries || []), [selectedEntries]);
const navigableRows = useMemo(
() => entries.map((entry) => ({ key: entry.key, type: entry.type, id: entry.id })),
[entries],
@@ -234,21 +233,12 @@ const DocumentsPanel = ({
}
onFocusedRowChange?.(resolvedKey);
if (!selectedRowKeySet.has(resolvedKey) && typeof onEntrySelection === 'function') {
onEntrySelection(resolvedKey, {
shiftKey: false,
preventDefault: () => {},
});
}
}, [
focusedRowKey,
navigableRowKeys,
navigableRows,
onEntrySelection,
onFocusedRowChange,
selectedEntries,
selectedRowKeySet,
]);
const handlePanelKeyDown = useCallback(
@@ -1,5 +1,5 @@
import React from 'react';
import DetailPanel from '../../detail/DetailPanel';
import DocumentViewerPanel from '../../preview/DocumentViewerPanel';
import SelectionFloatingActions from '../SelectionFloatingActions';
import createWorkspaceSurfaceConfig from '../workspaceHeader';
import DocumentsPanel from './DocumentsPanel';
@@ -91,7 +91,19 @@ const createDocumentsSurface = ({
: null;
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
const detail = detailOpen && detailProps ? <DetailPanel {...detailProps} /> : null;
const detail = detailOpen && detailProps
? (() => {
const { onClose, onOpenPreview, ...restDetailProps } = detailProps;
return (
<DocumentViewerPanel
variant="sidebar"
onCollapsePanel={onClose}
onMaximizePanel={onOpenPreview}
{...restDetailProps}
/>
);
})()
: null;
return createWorkspaceSurfaceConfig({
key: 'documents',
+19 -22
View File
@@ -14,11 +14,15 @@ export const isPrimaryPointerEvent = (event) => {
return type === 'click' || type === 'pointerdown' || type === 'pointerup';
};
const EntryType = Object.freeze({
document: 'document',
folder: 'folder',
});
export const useEntryPointer = ({
resolveDocumentRowKey,
resolveFolderRowKey,
onSelectDocument,
onSelectFolder,
onSelectEntry,
onInspectDocument,
}) =>
useCallback(
@@ -28,41 +32,34 @@ export const useEntryPointer = ({
}
const { type, id } = entry;
if (type !== 'document' && type !== 'folder') {
if (type !== EntryType.document && type !== EntryType.folder) {
return;
}
const rowKey = entry.key
|| (type === 'document' ? resolveDocumentRowKey?.(id) : resolveFolderRowKey?.(id));
|| (type === EntryType.document ? resolveDocumentRowKey?.(id) : resolveFolderRowKey?.(id));
if (!rowKey) {
return;
}
const modifierClick = isPointerModifierEvent(event);
const primaryClick = isPrimaryPointerEvent(event);
const metadata = { modifierClick, primaryClick, rowKey };
const metadata = { modifierClick, primaryClick, rowKey, type, id };
if (type === 'document') {
if (typeof onSelectDocument === 'function') {
onSelectDocument(id, event, metadata);
}
if (!modifierClick && primaryClick && typeof onInspectDocument === 'function') {
onInspectDocument(id, metadata);
}
return;
if (typeof onSelectEntry === 'function') {
onSelectEntry(entry, event, metadata);
}
if (typeof onSelectFolder === 'function') {
onSelectFolder(id, event, metadata);
if (
type === EntryType.document
&& !modifierClick
&& primaryClick
&& typeof onInspectDocument === 'function'
) {
onInspectDocument(id, metadata);
}
},
[
resolveDocumentRowKey,
resolveFolderRowKey,
onSelectDocument,
onSelectFolder,
onInspectDocument,
],
[resolveDocumentRowKey, resolveFolderRowKey, onSelectEntry, onInspectDocument],
);
export default useEntryPointer;
+1 -1
View File
@@ -97,7 +97,7 @@ const useInlineRename = (
}
resetState();
return true;
} catch (error) {
} catch {
return false;
} finally {
setSavingId((current) => (current === entityId ? null : current));
@@ -87,7 +87,7 @@ const useDocumentCorrespondentActions = ({
if (!target) {
try {
target = await handleCorrespondentCreate({ name: trimmed });
} catch (error) {
} catch {
return;
}
}
@@ -127,4 +127,3 @@ const useDocumentCorrespondentActions = ({
};
export default useDocumentCorrespondentActions;
@@ -29,6 +29,10 @@ const useDocumentDragHandlers = ({
({ documents = [], folders = [] } = {}) => {
destroyDragPreview();
if (typeof document === 'undefined') {
return null;
}
const docEntries = (documents || []).filter(Boolean);
const folderEntries = (folders || []).filter(Boolean);
const totalCount = docEntries.length + folderEntries.length;
@@ -53,24 +57,100 @@ const useDocumentDragHandlers = ({
const wrapper = document.createElement('div');
wrapper.className = 'document-drag-preview';
wrapper.style.setProperty('--drag-preview-size', `${canvasSize}px`);
wrapper.style.width = `${canvasSize}px`;
wrapper.style.height = `${canvasSize}px`;
visibleItems.forEach((item, index) => {
const slot = document.createElement('div');
slot.className = 'document-drag-preview__item';
slot.style.setProperty('--index', String(index));
slot.style.width = `${size}px`;
slot.style.height = `${size}px`;
const layer = document.createElement('div');
layer.className = 'document-drag-preview__item';
layer.style.setProperty('--index', String(index));
const rotationMagnitude = Math.random() * 8 + 2;
const rotation = (index % 2 === 0 ? 1 : -1) * rotationMagnitude;
layer.style.setProperty('--rotation-deg', `${rotation}deg`);
if (item.type === 'document') {
slot.textContent = item.payload?.title || 'Document';
const doc = item.payload;
const rowEl = doc?.id
? document.getElementById(`document-row-${doc.id}`)
|| document.getElementById(`document-card-${doc.id}`)
: null;
const wrapperEl = rowEl?.querySelector('.document-thumbnail-wrapper');
const thumbnailEl = rowEl?.querySelector('.document-thumbnail');
const placeholderEl = rowEl?.querySelector('.thumb-placeholder');
const aspectAttr = wrapperEl?.dataset?.thumbnailAspect;
const aspectRatio = aspectAttr ? parseFloat(aspectAttr) : null;
let thumbWidth = size;
let thumbHeight = size;
if (Number.isFinite(aspectRatio) && aspectRatio > 0) {
if (aspectRatio >= 1) {
thumbWidth = size;
thumbHeight = Math.max(size / aspectRatio, size * 0.5);
} else {
thumbHeight = size;
thumbWidth = Math.max(size * aspectRatio, size * 0.5);
}
}
layer.style.width = `${Math.round(thumbWidth)}px`;
layer.style.height = `${Math.round(thumbHeight)}px`;
const thumbSrc = thumbnailEl?.currentSrc || thumbnailEl?.src || null;
if (thumbSrc) {
layer.classList.add('document-drag-preview__item--image');
layer.style.backgroundImage = `url("${thumbSrc}")`;
} else if (placeholderEl instanceof HTMLElement) {
const clone = placeholderEl.cloneNode(true);
clone.style.pointerEvents = 'none';
layer.appendChild(clone);
} else {
layer.textContent = doc?.title || 'Document';
}
} else {
slot.textContent = 'Folder';
const payload = item.payload;
const folderId = typeof payload === 'string' ? payload : payload?.id;
const rowEl = folderId
? document.getElementById(`folder-row-${folderId}`)
|| document.getElementById(`folder-card-${folderId}`)
: null;
const iconEl = rowEl?.querySelector('.thumb-icon, .folder-card__icon');
layer.style.width = `${size}px`;
layer.style.height = `${size}px`;
layer.classList.add('document-drag-preview__item--folder');
let content = null;
if (iconEl instanceof HTMLElement) {
const cloneSource = iconEl.classList.contains('folder-card__icon')
? iconEl.querySelector('svg') || iconEl
: iconEl;
content = cloneSource.cloneNode(true);
content.classList.add('document-drag-preview__folder-thumb');
const svg = content.querySelector('svg');
if (svg) {
svg.setAttribute('width', '48');
svg.setAttribute('height', '48');
}
}
if (!content) {
content = document.createElement('div');
content.className = 'document-drag-preview__folder-placeholder';
content.textContent = 'Folder';
}
layer.appendChild(content);
}
wrapper.appendChild(slot);
wrapper.appendChild(layer);
});
if (totalCount > 1) {
const badge = document.createElement('div');
badge.className = 'document-drag-preview__count';
badge.textContent = `${totalCount}`;
wrapper.appendChild(badge);
}
document.body.appendChild(wrapper);
dragPreviewRef.current = wrapper;
return wrapper;
@@ -97,7 +177,7 @@ const useDocumentDragHandlers = ({
: isGridView
? [...selectedDocumentIds, documentId]
: [documentId];
const folderSelection = selectedFolderIds.length ? [...selectedFolderIds] : [];
const folderSelection = [];
if (!isAlreadySelected && !isGridView) {
applySelection([documentKey], {
@@ -143,7 +223,6 @@ const useDocumentDragHandlers = ({
},
[
selectedDocumentIds,
selectedFolderIds,
applySelection,
documentLookup,
createDragPreview,
@@ -210,11 +289,19 @@ const useDocumentDragHandlers = ({
console.warn('[documents] Failed to populate folder drag payload', error);
}
const previewDocs = effectiveDocumentSelection
.map((id) => documentLookup.get(id) || null)
.filter(Boolean);
createDragPreview({ documents: previewDocs, folders: uniqueFolders });
const previewNode = createDragPreview({
documents: effectiveDocumentSelection
.map((id) => documentLookup.get(id) || null)
.filter(Boolean),
folders: uniqueFolders,
});
event.currentTarget.classList.add('dragging');
if (previewNode) {
const width = previewNode.offsetWidth || 96;
const height = previewNode.offsetHeight || 96;
event.dataTransfer.setDragImage(previewNode, width / 2, height / 2);
}
},
[
selectedFolderIds,
@@ -175,7 +175,6 @@ const useDocumentUploads = ({
const entries = [];
let batch = [];
do {
// eslint-disable-next-line no-await-in-loop
batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject));
if (batch.length) {
entries.push(...batch);
@@ -203,7 +202,6 @@ const useDocumentUploads = ({
const reader = entry.createReader();
const entries = await readAllEntries(reader);
for (const child of entries) {
// eslint-disable-next-line no-await-in-loop
await walkEntry(child, nextAncestors);
}
}
@@ -306,7 +304,6 @@ const useDocumentUploads = ({
updateQueueItem(queueItem.id, patch);
Object.assign(queueItem, patch);
}
// eslint-disable-next-line no-await-in-loop
const destinationId = segments.length
? await ensureFolderPathOnServer(baseFolderId, segments)
: baseFolderId;
@@ -316,7 +313,6 @@ const useDocumentUploads = ({
(targetFolderId && targetFolderId !== 'root' ? targetFolderId : 'root');
try {
// eslint-disable-next-line no-await-in-loop
const { duplicate, statusCode, document, conflictDocumentId } = await uploadFile(
file,
uploadTarget,
@@ -50,6 +50,11 @@ import useDocumentDragHandlers from './useDocumentDragHandlers';
import useDocumentMutations from './useDocumentMutations';
import useDetailWorkspace from '../../detail/useDetailWorkspace';
const EntryType = Object.freeze({
document: 'document',
folder: 'folder',
});
const noop = () => {};
const useDocumentsWorkspace = ({
@@ -1185,19 +1190,15 @@ const useDocumentsWorkspace = ({
const handleEntryPointerCore = useEntryPointerCore({
resolveDocumentRowKey,
resolveFolderRowKey,
onSelectDocument: (documentId, event, { rowKey }) => {
const key = rowKey || resolveDocumentRowKey(documentId);
onSelectEntry: (entry, event, { rowKey, modifierClick, primaryClick }) => {
const { type, id } = entry;
const key = rowKey
|| (type === EntryType.document ? resolveDocumentRowKey(id) : resolveFolderRowKey(id));
if (key) {
handleEntrySelection(key, event);
}
},
onSelectFolder: (folderId, event, { modifierClick, primaryClick, rowKey }) => {
const key = rowKey || resolveFolderRowKey(folderId);
if (key) {
handleEntrySelection(key, event);
}
if (!modifierClick && primaryClick) {
selectFolder(folderId);
if (type === EntryType.folder && !modifierClick && primaryClick) {
selectFolder(id);
}
},
});
@@ -85,9 +85,8 @@ const useFolderTree = ({
})();
setFocusedDocumentId(nextFocus);
selectionAnchorRef.current = nextDocKeys.length
? nextDocKeys[nextDocKeys.length - 1]
: null;
const nextAnchor = mergedSelection.length ? mergedSelection[mergedSelection.length - 1] : null;
selectionAnchorRef.current = nextAnchor;
selectionOrderRef.current = mergedSelection;
setSelectionOrder(mergedSelection);
},
@@ -96,7 +96,6 @@ const useFolderTreeActions = ({
const refreshTargets = new Set([previousParentKey, targetKey]);
for (const key of refreshTargets) {
// eslint-disable-next-line no-await-in-loop
await ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 });
}
@@ -112,7 +111,6 @@ const useFolderTreeActions = ({
const refreshTargets = new Set([previousParentKey, targetKey]);
for (const key of refreshTargets) {
// eslint-disable-next-line no-await-in-loop
await ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 });
}
}
@@ -516,7 +514,6 @@ const useFolderTreeActions = ({
}
for (const sourceId of folderIds) {
// eslint-disable-next-line no-await-in-loop
await moveFolder(sourceId, folderId);
}
}
@@ -0,0 +1,105 @@
import React, { useMemo } from 'react';
import DocumentInfoPanel from '../documents/DocumentInfoPanel';
import { DownloadIcon } from '../ui/icons';
const DocumentViewerLayout = ({
document,
previewEntry,
summaryProps,
metadataPayload,
contentTabConfig,
resetKey,
classNamePrefix = 'document-viewer',
defaultTabId = 'details',
infoPanelProps = {},
previewLoadingMessage = 'Preparing preview…',
}) => {
const previewContent = useMemo(() => {
if (!document || !previewEntry?.url) {
return null;
}
const normalizedContentType = (previewEntry.contentType
|| document.content_type
|| '')
.toLowerCase();
const isImage = normalizedContentType.startsWith('image/');
const isPdf = normalizedContentType === 'application/pdf'
|| normalizedContentType === 'application/x-pdf';
if (isImage) {
return (
<img
src={previewEntry.url}
alt={`Preview of ${document.title}`}
className="document-viewer__object document-viewer__object--image"
draggable={false}
/>
);
}
if (isPdf) {
return (
<iframe
src={previewEntry.url}
title={`Preview of ${document.title}`}
className="document-viewer__object"
/>
);
}
const displayContentType = document.content_type || previewEntry.contentType || 'this file type';
const displayFilename = previewEntry.filename
|| document.filename
|| document.original_name
|| 'download';
return (
<div className="document-viewer__unsupported">
<div className="document-viewer__unsupported-message">
Preview is not available for {displayContentType} files.
</div>
<div className="document-viewer__unsupported-filename">{displayFilename}</div>
<a
className="button-link document-viewer__unsupported-download"
href={previewEntry.url}
download={displayFilename}
target="_blank"
rel="noopener noreferrer"
>
<DownloadIcon />
<span>Download</span>
</a>
</div>
);
}, [previewEntry, document]);
return (
<>
<div className="document-viewer__details-pane">
<div className="document-viewer__details">
<DocumentInfoPanel
document={document}
summaryProps={summaryProps}
metadataPayload={metadataPayload}
contentConfig={contentTabConfig}
defaultTabId={defaultTabId}
classNamePrefix={classNamePrefix}
hideTabNavWhenSingle={false}
resetKey={resetKey || document?.id}
{...infoPanelProps}
/>
</div>
</div>
<div className="document-viewer__viewport">
{!previewEntry?.url ? (
<div className="document-viewer__message">{previewLoadingMessage}</div>
) : (
previewContent
)}
</div>
</>
);
};
export default DocumentViewerLayout;
+367 -185
View File
@@ -1,18 +1,81 @@
import React, { useCallback, useMemo } from 'react';
import { DownloadIcon, CloseIcon } from '../ui/icons';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useNavigate } from 'react-router-dom';
import {
DownloadIcon,
CloseIcon,
IconZoomInArea,
DetailPanelCollapseIcon,
WindowMaximizeIcon,
} from '../ui/icons';
import PreviewZoomOverlay from '../detail/PreviewZoomOverlay';
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';
import PanelHeader from '../ui/PanelHeader';
import BreadcrumbTrail from '../ui/BreadcrumbTrail';
import { useAssetNavigator } from '../hooks/useAssetNavigator';
import DocumentViewerLayout from './DocumentViewerLayout';
import useViewerLayoutMode from './useViewerLayoutMode';
export const createDocumentViewerHeaderActions = ({
document,
actionState,
previewEntry,
onZoom,
canZoom = false,
}) => {
if (!document) {
return null;
}
const downloadHref = actionState?.downloadHref || previewEntry?.url;
if (!downloadHref && !(canZoom && onZoom)) {
return null;
}
return (
<>
{downloadHref ? (
<a
className="icon-button"
href={downloadHref}
target="_blank"
rel="noopener noreferrer"
aria-label="Download document"
title="Download document"
>
<DownloadIcon />
</a>
) : null}
{canZoom && onZoom ? (
<button
type="button"
className="icon-button"
onClick={onZoom}
aria-label="Open zoom preview"
title="Open zoom preview"
>
<IconZoomInArea />
</button>
) : null}
</>
);
};
const DocumentViewerPanel = ({
document,
documentId,
previewEntry,
hydrateDocument,
tagLookupById,
tagOptions,
onTagAdd,
@@ -22,10 +85,20 @@ const DocumentViewerPanel = ({
onCorrespondentRemove,
onUpdateTitle,
onUpdateIssued,
hasOcr = false,
ensureAssetUrl,
getDocumentAsset,
ensurePreviewData,
resolveApiPath,
notifyApiError,
sidebarToggle = null,
onClosePanel,
resolveFolderPath,
variant = 'viewer',
onCollapsePanel,
onMaximizePanel,
}) => {
const navigate = useNavigate();
const isSidebarVariant = variant === 'sidebar';
const sortedCorrespondents = useMemo(
() => sortCorrespondents(document?.correspondents || []),
[document],
@@ -36,71 +109,18 @@ const DocumentViewerPanel = ({
[correspondents],
);
const previewContent = useMemo(() => {
if (!document || !previewEntry?.url) {
return null;
}
const normalizedContentType = (previewEntry.contentType
|| document.content_type
|| '')
.toLowerCase();
const isImage = normalizedContentType.startsWith('image/');
const isPdf = normalizedContentType === 'application/pdf'
|| normalizedContentType === 'application/x-pdf';
if (isImage) {
return (
<img
src={previewEntry.url}
alt={`Preview of ${document.title}`}
className="document-viewer__object document-viewer__object--image"
draggable={false}
/>
);
}
if (isPdf) {
return (
<iframe
src={previewEntry.url}
title={`Preview of ${document.title}`}
className="document-viewer__object"
/>
);
}
const displayContentType = document.content_type || previewEntry.contentType || 'this file type';
const displayFilename = previewEntry.filename
|| document.filename
|| document.original_name
|| 'download';
return (
<div className="document-viewer__unsupported">
<div className="document-viewer__unsupported-message">
Preview is not available for {displayContentType} files.
</div>
<div className="document-viewer__unsupported-filename">{displayFilename}</div>
<a
className="button-link document-viewer__unsupported-download"
href={previewEntry.url}
download={displayFilename}
target="_blank"
rel="noopener noreferrer"
>
<DownloadIcon />
<span>Download</span>
</a>
</div>
);
}, [previewEntry, document]);
const metadataPayload = useMemo(
() => extractDocumentMetadataPayload(document),
[document],
);
const hasOcr = useMemo(() => {
if (!document || typeof getDocumentAsset !== 'function') {
return false;
}
return Boolean(getDocumentAsset(document, 'ocr-text'));
}, [document, getDocumentAsset]);
const summaryProps = useMemo(
() => ({
tagLookupById,
@@ -182,82 +202,296 @@ const DocumentViewerPanel = ({
[hasOcr, loadOcrContent],
);
if (!document) {
return (
const [zoomOverlayOpen, setZoomOverlayOpen] = useState(false);
const previewNavigator = useAssetNavigator({
document,
assetType: 'preview',
ensureAssetUrl,
getAsset: getDocumentAsset,
prefetch: 3,
});
const navigatorUrl = previewNavigator?.currentUrl;
const navigatorCanGoPrev = Boolean(previewNavigator?.canGoPrev);
const navigatorCanGoNext = Boolean(previewNavigator?.canGoNext);
const navigatorGoPrev = previewNavigator?.goPrev;
const navigatorGoNext = previewNavigator?.goNext;
const handleZoomOpen = useCallback(() => {
if (!previewEntry?.url) {
return;
}
setZoomOverlayOpen(true);
}, [previewEntry?.url]);
const handleZoomClose = useCallback(() => {
setZoomOverlayOpen(false);
}, []);
useEffect(() => {
setZoomOverlayOpen(false);
}, [previewEntry?.url, document?.id]);
useEffect(() => {
if (typeof hydrateDocument === 'function' && document?.id) {
hydrateDocument(document.id);
}
}, [hydrateDocument, document?.id]);
const panelRef = useRef(null);
const isStackedLayout = useViewerLayoutMode(panelRef, document?.id);
const viewerClassName = isStackedLayout
? 'document-viewer document-viewer--stacked'
: 'document-viewer';
const actionState = useMemo(
() =>
document
? createDocumentActionState({
document,
resolveApiPath,
ensurePreviewData,
ensureAssetUrl,
getDocumentAsset,
notifyApiError,
ocrErrorMessage: 'Unable to open OCR text.',
})
: null,
[
document,
resolveApiPath,
ensurePreviewData,
ensureAssetUrl,
getDocumentAsset,
notifyApiError,
],
);
const breadcrumbs = useMemo(() => {
if (!document || typeof resolveFolderPath !== 'function') {
return [];
}
const folderSegments = resolveFolderPath(document.folder_id);
const normalizedSegments = Array.isArray(folderSegments)
? folderSegments
.filter((segment) => segment && segment.id && segment.name)
.map((segment) => ({ id: segment.id, name: segment.name }))
: [];
return [
...normalizedSegments,
{ id: document.id, name: document.title },
];
}, [document, resolveFolderPath]);
const handleBreadcrumbNavigate = useCallback(
(crumb) => {
if (!crumb?.id) {
return;
}
const target = crumb.id === 'root'
? '/documents'
: `/documents/folder/${crumb.id}`;
navigate(target);
},
[navigate],
);
const breadcrumbTrailEntries = useMemo(() => {
if (!breadcrumbs.length) {
return [];
}
const lastIndex = breadcrumbs.length - 1;
return breadcrumbs.map((crumb, index) => ({
id: crumb.id,
label: crumb.name,
onClick: index < lastIndex ? () => handleBreadcrumbNavigate(crumb) : null,
}));
}, [breadcrumbs, handleBreadcrumbNavigate]);
const zoomDisplay = useMemo(() => {
if (navigatorUrl && document) {
return {
url: navigatorUrl,
alt: document.title,
canGoPrev: navigatorCanGoPrev,
canGoNext: navigatorCanGoNext,
goPrev: navigatorCanGoPrev ? navigatorGoPrev : undefined,
goNext: navigatorCanGoNext ? navigatorGoNext : undefined,
};
}
if (previewEntry?.url && document) {
return {
url: previewEntry.url,
alt: document.title,
canGoPrev: false,
canGoNext: false,
};
}
return null;
}, [
navigatorUrl,
navigatorCanGoPrev,
navigatorCanGoNext,
navigatorGoPrev,
navigatorGoNext,
previewEntry?.url,
document,
]);
const headerActions = createDocumentViewerHeaderActions({
document,
actionState,
previewEntry,
onZoom: zoomDisplay ? handleZoomOpen : null,
canZoom: Boolean(zoomDisplay),
});
const collapseButton = isSidebarVariant && typeof onCollapsePanel === 'function'
? (
<button
type="button"
className="icon-button"
onClick={() => onCollapsePanel?.()}
aria-label="Close detail panel"
title="Close detail panel"
>
<DetailPanelCollapseIcon />
</button>
)
: null;
const maximizeButton = isSidebarVariant && typeof onMaximizePanel === 'function'
? (
<button
type="button"
className="icon-button"
onClick={(event) => {
event.stopPropagation();
onMaximizePanel?.(document?.id);
}}
aria-label="Maximize"
title="Maximize"
>
<WindowMaximizeIcon className="icon--flip-y" />
</button>
)
: null;
const closeButton = !isSidebarVariant && onClosePanel
? (
<button
type="button"
className="icon-button"
onClick={() => onClosePanel?.()}
aria-label="Close preview"
title="Close preview"
>
<CloseIcon />
</button>
)
: null;
const headerLeadingButtons = isSidebarVariant
? [collapseButton, maximizeButton].filter(Boolean)
: [sidebarToggle, closeButton].filter(Boolean);
const headerLeadingContent = headerLeadingButtons.length
? (
<>
{headerLeadingButtons}
</>
)
: null;
const loadingSection = (
<div className="document-viewer-panel__body">
<section className="document-viewer document-viewer--loading">
<div className="document-viewer__details-pane">
<div className="document-viewer__details">
<div className="document-viewer__message">
Loading document{documentId ? ` ${documentId}` : ''}
</div>
<div className="document-viewer__message">Loading document</div>
</div>
</div>
<div className="document-viewer__viewport">
<div className="document-viewer__message">Preparing preview</div>
</div>
</section>
</div>
);
const viewerSection = document ? (
<div
className={isStackedLayout
? 'document-viewer-panel__body document-viewer-panel__body--stacked'
: 'document-viewer-panel__body'}
>
<section className={viewerClassName}>
<DocumentViewerLayout
document={document}
previewEntry={previewEntry}
summaryProps={summaryProps}
metadataPayload={metadataPayload}
contentTabConfig={contentTabConfig}
previewLoadingMessage="Loading preview…"
/>
</section>
</div>
) : loadingSection;
const headerTitle = breadcrumbTrailEntries.length ? (
<BreadcrumbTrail
entries={breadcrumbTrailEntries}
separator="/"
className="panel-header__breadcrumbs"
truncateFromStart={isSidebarVariant}
/>
) : (
document?.title || 'Document preview'
);
const overlay = (
<PreviewZoomOverlay
open={Boolean(zoomOverlayOpen && zoomDisplay)}
display={zoomDisplay}
onClose={handleZoomClose}
/>
);
if (isSidebarVariant) {
return (
<>
<aside className="detail-panel panel" ref={panelRef}>
<PanelHeader
leading={headerLeadingContent}
title={headerTitle}
titleTag="h3"
actions={headerActions}
/>
<div className="panel-body detail-panel__content">{viewerSection}</div>
</aside>
{overlay}
</>
);
}
return (
<section className="document-viewer">
<div className="document-viewer__details-pane">
<div className="document-viewer__details">
<DocumentInfoPanel
document={document}
summaryProps={summaryProps}
metadataPayload={metadataPayload}
contentConfig={contentTabConfig}
defaultTabId="details"
classNamePrefix="document-viewer"
hideTabNavWhenSingle={false}
resetKey={document?.id}
/>
</div>
</div>
<div className="document-viewer__viewport">
{!previewEntry?.url ? (
<div className="document-viewer__message">Loading preview</div>
) : (
previewContent
)}
</div>
</section>
<>
<section className="document-viewer-panel" ref={panelRef}>
<PanelHeader
leading={headerLeadingContent}
title={headerTitle}
titleTag="h3"
actions={headerActions}
/>
{viewerSection}
</section>
{overlay}
</>
);
};
export default DocumentViewerPanel;
export const createDocumentViewerHeaderActions = ({
document,
actionState,
}) => {
if (!document || !actionState) {
return null;
}
const { downloadHref } = actionState;
return (
<>
{downloadHref ? (
<a
className="icon-button"
href={downloadHref}
target="_blank"
rel="noopener noreferrer"
aria-label="Download document"
title="Download document"
>
<DownloadIcon />
</a>
) : null}
</>
);
};
export const createDocumentViewerSurface = ({
documentId,
document,
previewEntry,
ensureAssetUrl,
@@ -278,80 +512,23 @@ export const createDocumentViewerSurface = ({
onUpdateIssued,
resolveFolderPath,
}) => {
if (!documentId && !document) {
if (!document) {
return null;
}
const title = document?.title || 'Document preview';
const closeButton = onClose
? (
<button
type="button"
className="icon-button"
onClick={() => onClose?.()}
aria-label="Close preview"
title="Close preview"
>
<CloseIcon />
</button>
)
const sidebarToggle = typeof renderSidebarToggle === 'function'
? renderSidebarToggle()
: null;
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
const leading = closeButton || sidebarToggle
? (
<>
{sidebarToggle}
{closeButton}
</>
)
: null;
let breadcrumbs = null;
if (document && typeof resolveFolderPath === 'function') {
const folderSegments = resolveFolderPath(document.folder_id);
const normalizedSegments = Array.isArray(folderSegments)
? folderSegments
.filter((segment) => segment && segment.id && segment.name)
.map((segment) => ({ id: segment.id, name: segment.name }))
: [];
breadcrumbs = [
...normalizedSegments,
{ id: document.id, name: document.title },
];
}
const actionState = document
? createDocumentActionState({
document,
resolveApiPath,
ensurePreviewData,
ensureAssetUrl,
getDocumentAsset,
notifyApiError,
ocrErrorMessage: 'Unable to open OCR text.',
})
: null;
const header = {
title,
subtitle: null,
leading,
actions: createDocumentViewerHeaderActions({
document,
actionState,
}),
breadcrumbs,
};
return {
key: 'preview',
variant: 'preview',
header,
header: null,
content: (
<DocumentViewerPanel
document={document}
documentId={documentId}
previewEntry={previewEntry}
hydrateDocument={ensurePreviewData}
tagLookupById={tagLookupById}
tagOptions={tagOptions}
onTagAdd={onTagAdd}
@@ -361,9 +538,14 @@ export const createDocumentViewerSurface = ({
onCorrespondentRemove={onCorrespondentRemove}
onUpdateTitle={onUpdateTitle}
onUpdateIssued={onUpdateIssued}
hasOcr={Boolean(actionState?.hasOcr)}
ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset}
ensurePreviewData={ensurePreviewData}
resolveApiPath={resolveApiPath}
notifyApiError={notifyApiError}
sidebarToggle={sidebarToggle}
onClosePanel={onClose}
resolveFolderPath={resolveFolderPath}
/>
),
supportsDetail: false,
@@ -0,0 +1,99 @@
import { useLayoutEffect, useState } from 'react';
const PORTRAIT_WIDTH_TO_HEIGHT = 1 / Math.sqrt(2); // ≈0.707 (A-series aspect ratio)
export const DOCUMENT_VIEWER_PORTRAIT_HEIGHT_RATIO = 0.4;
const PORTRAIT_RATIO_STYLE_ID = 'document-viewer-portrait-ratio-style';
const ensurePortraitRatioStyle = () => {
if (typeof document === 'undefined') {
return;
}
const cssValue = String(DOCUMENT_VIEWER_PORTRAIT_HEIGHT_RATIO);
const cssText = `:root { --document-viewer-portrait-height-ratio: ${cssValue}; }`;
let styleEl = document.getElementById(PORTRAIT_RATIO_STYLE_ID);
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = PORTRAIT_RATIO_STYLE_ID;
document.head.appendChild(styleEl);
}
if (styleEl.textContent !== cssText) {
styleEl.textContent = cssText;
}
};
const computeStackedLayoutBreakpoint = () => {
const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 900;
const portraitViewportWidth = viewportHeight
* DOCUMENT_VIEWER_PORTRAIT_HEIGHT_RATIO
* PORTRAIT_WIDTH_TO_HEIGHT;
const detailsColumnWidth = 320; // px ~ 20rem for metadata & tabs
const gutterAllowance = 48; // padding + grid gap
return portraitViewportWidth + detailsColumnWidth + gutterAllowance;
};
export const useViewerLayoutMode = (ref, dependency) => {
const [isStacked, setIsStacked] = useState(false);
useLayoutEffect(() => {
ensurePortraitRatioStyle();
}, []);
useLayoutEffect(() => {
const node = ref?.current;
if (!node) {
setIsStacked(false);
return undefined;
}
let frame = null;
const commitMeasure = (width) => {
if (frame) {
cancelAnimationFrame(frame);
}
frame = requestAnimationFrame(() => {
const breakpoint = computeStackedLayoutBreakpoint();
setIsStacked(width < breakpoint);
});
};
const measure = () => {
commitMeasure(node.getBoundingClientRect().width);
};
measure();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', measure);
return () => {
if (frame) {
cancelAnimationFrame(frame);
}
window.removeEventListener('resize', measure);
};
}
const observer = new ResizeObserver((entries) => {
if (!entries.length) {
return;
}
commitMeasure(entries[0].contentRect.width);
});
observer.observe(node);
return () => {
observer.disconnect();
if (frame) {
cancelAnimationFrame(frame);
}
};
}, [ref, dependency]);
return isStacked;
};
export default useViewerLayoutMode;
@@ -111,6 +111,7 @@ const CapabilityDropdown = ({
role="menu"
ref={menuRef}
className="menu menu--floating capability-dropdown__menu"
data-floating-position
>
{total ? (
options.map((option) => {
@@ -132,7 +132,7 @@ const ApiTokensSection = ({
await navigator.clipboard.writeText(createdToken);
showSuccess();
return;
} catch (error) {
} catch {
// Some browsers expose writeText but still reject outside secure context
setCanCopyToken(false);
}
+23 -21
View File
@@ -512,9 +512,9 @@ const Sidebar = ({
style={tenantMenuStyle}
>
{showTenantList ? (
<>
<div className="menu__section">
<div className="menu__heading">Switch tenant</div>
<div className="menu__list">
<div className="menu__wrapper">
{tenants.map((tenant) => {
const tenantId = tenant?.id || null;
const isActive = tenantId === activeTenantId;
@@ -523,7 +523,7 @@ const Sidebar = ({
<button
key={tenantId || tenantLabel}
type="button"
className={`menu__item${isActive ? ' active' : ''}`}
className={`menu__button${isActive ? ' active' : ''}`}
onClick={() => handleTenantSelect(tenant)}
role="menuitem"
>
@@ -535,25 +535,27 @@ const Sidebar = ({
);
})}
</div>
</>
</div>
) : null}
<div className="menu__footer">
<button
type="button"
className="menu__button"
onClick={handleSettingsFromMenu}
>
<SettingsIcon size={16} />
Settings
</button>
<button
type="button"
className="menu__button menu__button--danger"
onClick={handleLogoutFromMenu}
>
<LogoutIcon size={16} />
Log out
</button>
<div className="menu__section">
<div className="menu__wrapper">
<button
type="button"
className="menu__button"
onClick={handleSettingsFromMenu}
>
<SettingsIcon size={16} />
Settings
</button>
<button
type="button"
className="menu__button menu__button--danger"
onClick={handleLogoutFromMenu}
>
<LogoutIcon size={16} />
Log out
</button>
</div>
</div>
{themeMenuSection}
</div>,
+3
View File
@@ -14,6 +14,7 @@
--surface-subtle: oklch(0.96 0.002 calc(var(--neutral-hue) + var(--surface-hue-shift)));
--fg: oklch(0.32 0.005 calc(var(--neutral-hue) + var(--foreground-hue-offset)));
--muted: oklch(0.54 0.008 calc(var(--neutral-hue) + var(--foreground-hue-offset)));
--muted-subtle: color-mix(in oklch, var(--muted) 55%, var(--border));
--sidebar-fg: oklch(0.56 0.007 calc(var(--neutral-hue) + var(--foreground-hue-offset)));
--border: oklch(0.92 0.002 calc(var(--neutral-hue) + var(--foreground-hue-offset)));
@@ -112,6 +113,7 @@
--surface-subtle: oklch(0.23 0.012 calc(var(--dark-neutral-hue) + var(--surface-hue-shift)));
--fg: oklch(0.89 0.015 var(--dark-foreground-hue));
--muted: oklch(0.72 0.02 var(--dark-foreground-hue));
--muted-subtle: color-mix(in oklch, var(--muted) 45%, var(--border));
--sidebar-fg: oklch(0.78 0.02 var(--dark-foreground-hue));
--border: oklch(0.33 0.01 var(--dark-foreground-hue));
@@ -189,6 +191,7 @@
--surface-subtle: oklch(0.23 0.012 calc(var(--dark-neutral-hue) + var(--surface-hue-shift)));
--fg: oklch(0.89 0.015 var(--dark-foreground-hue));
--muted: oklch(0.72 0.02 var(--dark-foreground-hue));
--muted-subtle: color-mix(in oklch, var(--muted) 45%, var(--border));
--sidebar-fg: oklch(0.78 0.02 var(--dark-foreground-hue));
--border: oklch(0.33 0.01 var(--dark-foreground-hue));
+25 -11
View File
@@ -60,11 +60,24 @@
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
min-height: 0;
padding: 0;
}
.detail-panel__content {
flex: 1;
min-height: 0;
overflow: auto;
}
.detail-panel__content .document-viewer-panel {
flex: 1;
}
.detail-panel__content .document-viewer-panel__body {
padding: 0.5rem 0.5rem 0;
}
.detail-section__header {
display: flex;
align-items: center;
@@ -80,6 +93,8 @@
}
.quick-add {
--quick-add-menu-offset: 0.35rem;
--quick-add-menu-min-width: 220px;
display: inline-flex;
align-items: center;
position: relative;
@@ -128,10 +143,6 @@
gap: 0.25rem;
}
.quick-add__menu {
padding: 0.25rem 0;
}
.quick-add__form {
display: flex;
align-items: center;
@@ -172,12 +183,15 @@
.selection-assignment__menu {
font-size: 0.95rem;
font-weight: 400;
padding: 0.4rem 0;
display: flex;
flex-direction: column;
gap: 0;
padding: 0;
max-width: min(22rem, 90vw);
}
.selection-assignment__header {
padding: 0.4rem 0.75rem 0.3rem;
padding: 0.5rem;
border-bottom: 1px solid var(--border-subtle);
}
@@ -193,14 +207,14 @@
.selection-assignment__header input:focus-visible {
outline: none;
border-color: var(--selection-border);
box-shadow: 0 0 0 2px color-mix(in oklch, var(--selection) 25%, transparent);
border-color: color-mix(in oklch, var(--accent) 60%, transparent);
box-shadow: 0 0 0 1px color-mix(in oklch, var(--accent) 35%, transparent);
}
.selection-assignment__list {
max-height: 240px;
max-height: max(240px, 50vh);
overflow-y: auto;
padding: 0 0.75rem;
padding: 0 0.25rem 0.25rem;
}
.selection-assignment__item {
+4 -1
View File
@@ -44,6 +44,10 @@
position: relative;
}
.documents-sort__quickmenu {
--quick-add-menu-min-width: 200px;
}
.documents-sort__trigger {
display: inline-flex;
align-items: center;
@@ -105,4 +109,3 @@
width: 1.1rem;
height: 1.1rem;
}
@@ -0,0 +1,76 @@
.document-drag-preview {
position: fixed;
pointer-events: none;
top: -9999px;
left: -9999px;
width: var(--drag-preview-size, 96px);
height: var(--drag-preview-size, 96px);
z-index: 9999;
}
.document-drag-preview__item {
position: absolute;
top: 50%;
left: 50%;
width: 64px;
height: 64px;
border-radius: 6px;
box-shadow: 0 6px 12px var(--shadow-pop);
overflow: hidden;
background-color: var(--overlay-dim);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 600;
font-size: 0.8rem;
text-transform: uppercase;
transform: translate(-50%, -50%) rotate(var(--rotation-deg, 0deg));
transform-origin: center;
}
.document-drag-preview__item--folder {
background: transparent;
box-shadow: none;
border-radius: 0;
}
.document-drag-preview__item--image {
background-color: #000;
background-repeat: no-repeat;
background-size: contain;
background-position: center;
}
.document-drag-preview__item .document-thumbnail,
.document-drag-preview__item img {
width: 100%;
height: 100%;
object-fit: cover;
pointer-events: none;
}
.document-drag-preview__item .thumb-placeholder,
.document-drag-preview__item .thumb-placeholder * {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.document-drag-preview__folder-thumb {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.document-drag-preview__folder-thumb svg {
width: 48px;
height: 48px;
color: var(--accent-strong, var(--accent));
}
+9 -2
View File
@@ -13,8 +13,15 @@
min-height: 0;
}
.folder-row.is-drop-target {
outline: 2px dashed var(--accent);
.documents-panel tr.folder.is-drop-target {
outline: 2px dashed var(--accent-strong, var(--accent));
outline-offset: -2px;
}
.documents-grid .folder-card.is-drop-target {
outline: 2px dashed var(--accent-strong, var(--accent));
outline-offset: 2px;
}
@@ -84,19 +84,36 @@
cursor: default;
}
.breadcrumb-trail--measure .breadcrumb-trail__link,
.breadcrumb-trail--measure .breadcrumb-trail__ellipsis-button {
max-width: none;
overflow: visible;
}
button.breadcrumb-trail__link,
.breadcrumb-trail__ellipsis-button {
padding: 0.25rem;
background: none;
border-radius: 0.25rem;
}
.breadcrumb-trail__link:not(.is-current) {
cursor: pointer;
color: var(--accent);
}
.breadcrumb-trail__link:not(.is-current):hover,
.breadcrumb-trail__link:not(.is-current):focus-visible {
.breadcrumb-trail__link:not(.is-current):focus-visible,
.panel-header .breadcrumb-trail__link:not(.is-current):hover,
.panel-header .breadcrumb-trail__link:not(.is-current):focus-visible {
text-decoration: underline;
background: var(--accent-soft);
color: var(--accent);
}
.breadcrumb-trail__separator {
color: var(--muted);
margin: 0 0.2rem;
color: var(--muted-subtle);
margin: 0;
}
.breadcrumb-trail__ellipsis {
@@ -130,4 +147,3 @@
.panel-section__body.scrollable {
overflow-y: auto;
}
+88 -11
View File
@@ -1,10 +1,67 @@
.document-viewer-panel {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
background: var(--bg);
}
.document-viewer-panel__body {
flex: 1;
min-height: 0;
display: flex;
padding: 0;
overflow: auto;
}
.document-viewer-panel__body--stacked {
flex-direction: column;
}
.document-viewer-panel__body--stacked .document-viewer,
.document-viewer-panel__body--stacked .document-viewer--stacked {
min-height: auto;
}
.document-viewer-panel__body > .document-viewer,
.document-viewer-panel__body > .document-viewer--loading {
flex: 1;
}
.document-viewer {
flex: 1;
display: grid;
grid-template-columns: minmax(0, 30em) minmax(0, 1fr);
grid-template-columns: minmax(0, clamp(18rem, 30vw, 26rem)) minmax(0, 1fr);
grid-template-areas: 'details viewport';
align-items: stretch;
gap: 1rem;
min-height: 0;
padding: 1rem 1rem;
padding: 0.25rem 1rem 1rem;
}
.document-viewer--stacked {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.5rem 1rem 1rem;
overflow: auto;
}
.document-viewer--stacked .document-viewer__viewport {
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
flex: 2 2 auto;
max-height: calc(var(--document-viewer-portrait-height-ratio) * 100vh);
order: -1;
}
.document-viewer--stacked .document-viewer__details-pane {
flex: 1 1 auto;
overflow: visible;
order: 0;
}
.document-drag-preview {
@@ -17,7 +74,7 @@
z-index: 9999;
}
.document-drag-preview__thumb {
.document-drag-preview__item {
position: absolute;
top: 50%;
left: 50%;
@@ -38,23 +95,23 @@
transform-origin: center;
}
.document-drag-preview__thumb--image {
.document-drag-preview__item--image {
background-color: #000;
background-repeat: no-repeat;
background-size: contain;
background-position: center;
}
.document-drag-preview__thumb .document-thumbnail,
.document-drag-preview__thumb img {
.document-drag-preview__item .document-thumbnail,
.document-drag-preview__item img {
width: 100%;
height: 100%;
object-fit: cover;
pointer-events: none;
}
.document-drag-preview__thumb .thumb-placeholder,
.document-drag-preview__thumb .thumb-placeholder * {
.document-drag-preview__item .thumb-placeholder,
.document-drag-preview__item .thumb-placeholder * {
width: 100%;
height: 100%;
display: flex;
@@ -63,6 +120,12 @@
pointer-events: none;
}
.document-drag-preview__item--folder {
background: transparent;
box-shadow: none;
border-radius: 0;
}
.document-drag-preview__folder-thumb {
width: 100%;
height: 100%;
@@ -119,6 +182,7 @@
min-height: 0;
overflow: auto;
flex: 1;
grid-area: details;
}
.document-viewer__tabs-wrapper {
display: flex;
@@ -284,21 +348,35 @@
position: relative;
overflow: hidden;
align-items: flex-start;
justify-content: flex-start;
max-height: 100%;
grid-area: viewport;
}
.document-viewer__object {
width: 100%;
height: 100%;
border: none;
}
.document-viewer__object:not(.document-viewer__object--image) {
height: 100%;
}
.document-viewer__object--image {
width: auto;
height: auto;
max-width: 100%;
max-height: 100%;
object-fit: contain;
align-self: flex-start;
}
.document-viewer--stacked .document-viewer__object:not(.document-viewer__object--image) {
height: calc(var(--document-viewer-portrait-height-ratio) * 100vh);
max-height: 100%;
}
.document-viewer--stacked .document-viewer__object--image {
height: auto;
}
.document-viewer__unsupported {
@@ -332,4 +410,3 @@
width: 1rem;
height: 1rem;
}
+1
View File
@@ -6,6 +6,7 @@
@import './documents/controls.css';
@import './layout/structure.css';
@import './documents/viewer.css';
@import './documents/drag-preview.css';
@import './documents/tags-correspondents.css';
@import './documents/panel-sections.css';
@import './sidebar/sidebar.css';
+1 -1
View File
@@ -98,7 +98,7 @@
.main-content__actions-divider {
display: inline-flex;
align-items: center;
color: var(--muted);
color: var(--muted-subtle);
}
.main-content__title {
+40 -11
View File
@@ -13,7 +13,7 @@
align-items: center;
gap: 0.22rem;
padding: 0.32rem 0.48rem;
border-radius: 8px;
border-radius: 0.5rem;
cursor: pointer;
color: inherit;
transition: background 0.12s ease, color 0.12s ease;
@@ -23,6 +23,11 @@
-webkit-user-select: none;
}
.sidebar .folder-row.is-drop-target {
outline: 2px dashed var(--accent-strong, var(--accent));
outline-offset: 2px;
}
.folder-row .name-wrap {
display: inline-flex;
align-items: center;
@@ -83,12 +88,12 @@
transform: translateY(-50%);
display: flex;
gap: 0;
margin-right: 0.3rem;
opacity: 0;
pointer-events: none;
transition: opacity 0.12s ease;
background-color: var(--surface-overlay);
border-radius: 8px;
padding: 0 0.18rem;
border-radius: 0.2rem;
}
.folder-row__actions .icon-button {
@@ -99,6 +104,7 @@
.folder-row:focus-within .folder-row__actions {
opacity: 1;
pointer-events: auto;
background-color: var(--surface-overlay);
}
.folder-children {
@@ -229,14 +235,19 @@
border-radius: 0.5rem;
box-shadow: 0 12px 28px var(--shadow-strong);
min-width: 220px;
z-index: 20;
z-index: 2500000;
overflow: hidden;
}
.menu[data-floating-position] {
top: auto;
left: auto;
min-width: var(--floating-min-width, 220px);
}
.menu__list {
max-height: 260px;
overflow-y: auto;
padding: 0.35rem 0.5rem;
padding: 0.25rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
@@ -263,9 +274,9 @@
justify-content: flex-start;
gap: 0.5rem;
width: 100%;
padding: 0.45rem 0.75rem;
padding: 0.5rem 0.75rem;
border: none;
border-radius: 0.4rem;
border-radius: 0.25rem;
background: none;
color: var(--sidebar-fg);
font: inherit;
@@ -299,6 +310,7 @@
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
text-align: left;
}
.menu__check-slot {
@@ -321,15 +333,18 @@
font-size: 0.85rem;
}
.menu__footer {
border-top: 1px solid var(--border-muted, var(--border));
.menu__wrapper {
padding: 0.35rem 0.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.menu--simple .menu__footer {
.menu__section {
border-top: 1px solid var(--border-muted, var(--border));
}
.menu__section:first-of-type {
border-top: none;
}
@@ -391,6 +406,20 @@
transition: background 0.15s ease, color 0.15s ease;
}
.menu__button .menu__check-slot {
width: 1rem;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--accent);
}
.menu__button.active {
font-weight: 600;
color: var(--accent);
background: var(--accent-soft);
}
.menu__button:hover,
.menu__button:focus-visible {
background: var(--sidebar-hover-bg);
+88 -43
View File
@@ -24,7 +24,8 @@ const normalizeEntries = (entries) =>
const ELLIPSIS = { id: '__breadcrumbs_ellipsis__', label: '…', onClick: null, raw: null };
const WIDTH_TOLERANCE = 1;
const WIDTH_BUFFER_RATIO = 0.99;
const WIDTH_BUFFER_RATIO = 0.95;
const WIDTH_CHANGE_TOLERANCE = 0.25;
const BreadcrumbTrail = ({
entries = [],
@@ -43,6 +44,7 @@ const BreadcrumbTrail = ({
const ellipsisButtonRef = useRef(null);
const [availableWidth, setAvailableWidth] = useState(null);
const [startIndex, setStartIndex] = useState(0);
const measureRafRef = useRef(null);
const {
isOpen: ellipsisMenuOpen,
@@ -58,18 +60,13 @@ const BreadcrumbTrail = ({
});
useEffect(() => {
setStartIndex(0);
closeEllipsisMenu();
}, [normalized, shouldTruncateFromStart, closeEllipsisMenu]);
useEffect(() => {
const container = containerRef.current;
if (!container || typeof ResizeObserver === 'undefined') {
return undefined;
}
const updateWidth = () => {
const host = containerRef.current;
const resolveHost = () => containerRef.current?.parentElement || containerRef.current;
const measure = () => {
const host = resolveHost();
if (!host) {
return;
}
@@ -77,14 +74,56 @@ const BreadcrumbTrail = ({
if (!nextWidth) {
return;
}
setAvailableWidth((prev) => (prev && Math.abs(prev - nextWidth) < 0.5 ? prev : nextWidth));
setAvailableWidth((prev) => (
prev && Math.abs(prev - nextWidth) < WIDTH_CHANGE_TOLERANCE ? prev : nextWidth
));
};
updateWidth();
const scheduleMeasure = () => {
if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') {
measure();
return;
}
if (measureRafRef.current) {
cancelAnimationFrame(measureRafRef.current);
}
measureRafRef.current = window.requestAnimationFrame(() => {
measureRafRef.current = null;
measure();
});
};
const observer = new ResizeObserver(updateWidth);
observer.observe(container.parentElement || container);
return () => observer.disconnect();
scheduleMeasure();
if (typeof ResizeObserver === 'undefined') {
return () => {
if (measureRafRef.current) {
cancelAnimationFrame(measureRafRef.current);
measureRafRef.current = null;
}
};
}
const host = resolveHost();
if (!host) {
return () => {
if (measureRafRef.current) {
cancelAnimationFrame(measureRafRef.current);
measureRafRef.current = null;
}
};
}
const observer = new ResizeObserver(scheduleMeasure);
observer.observe(host);
return () => {
observer.disconnect();
if (measureRafRef.current) {
cancelAnimationFrame(measureRafRef.current);
measureRafRef.current = null;
}
};
}, []);
useLayoutEffect(() => {
@@ -94,7 +133,8 @@ const BreadcrumbTrail = ({
const container = containerRef.current;
const measurement = measurementRef.current;
if (!container || !measurement) {
const host = container?.parentElement || container;
if (!container || !measurement || !host) {
return;
}
@@ -115,18 +155,15 @@ const BreadcrumbTrail = ({
for (let start = 0; start < entryNodes.length; start += 1) {
entryNodes.forEach((node, index) => {
// Hide entries that fall before the visible window.
// eslint-disable-next-line no-param-reassign
node.style.display = index < start ? 'none' : '';
});
separatorNodes.forEach((node) => {
const targetIndex = Number(node.getAttribute('data-target-index'));
// eslint-disable-next-line no-param-reassign
node.style.display = targetIndex < Math.max(start, 1) ? 'none' : '';
});
if (ellipsisNode) {
// eslint-disable-next-line no-param-reassign
ellipsisNode.style.display = start > 0 ? '' : 'none';
}
@@ -134,21 +171,18 @@ const BreadcrumbTrail = ({
}
entryNodes.forEach((node, index) => {
// eslint-disable-next-line no-param-reassign
node.style.display = originalEntryDisplay[index] ?? '';
});
separatorNodes.forEach((node, index) => {
// eslint-disable-next-line no-param-reassign
node.style.display = originalSeparatorDisplay[index] ?? '';
});
if (ellipsisNode) {
// eslint-disable-next-line no-param-reassign
ellipsisNode.style.display = originalEllipsisDisplay ?? 'none';
}
const available = availableWidth ?? container.getBoundingClientRect().width;
const available = availableWidth ?? host.getBoundingClientRect().width;
if (!available || !widths.length) {
return;
}
@@ -289,33 +323,43 @@ const BreadcrumbTrail = ({
className="breadcrumb-trail breadcrumb-trail--measure"
aria-hidden="true"
>
<span
<button
type="button"
className="breadcrumb-trail__link breadcrumb-trail__ellipsis-button"
data-item-type="ellipsis"
style={{ display: 'none' }}
tabIndex={-1}
>
{ELLIPSIS.label}
</span>
{measurementEntries.map((entry, index) => (
<React.Fragment key={`measure-${entry.id || index}`}>
{index > 0 ? (
<span
className="breadcrumb-trail__separator"
data-item-type="separator"
data-target-index={index}
</button>
{measurementEntries.map((entry, index) => {
const isLast = index === measurementEntries.length - 1;
const isInteractive = Boolean(entry.onClick) && !isLast;
const MeasurementTag = isInteractive ? 'button' : 'span';
return (
<React.Fragment key={`measure-${entry.id || index}`}>
{index > 0 ? (
<span
className="breadcrumb-trail__separator"
data-item-type="separator"
data-target-index={index}
>
{separator}
</span>
) : null}
<MeasurementTag
type={isInteractive ? 'button' : undefined}
className="breadcrumb-trail__link"
data-item-type="entry"
data-entry-index={index}
tabIndex={-1}
>
{separator}
</span>
) : null}
<span
className="breadcrumb-trail__link"
data-item-type="entry"
data-entry-index={index}
>
{entry.label}
</span>
</React.Fragment>
))}
{entry.label}
</MeasurementTag>
</React.Fragment>
);
})}
</span>
{ellipsisMenuOpen
&& hasHiddenEntries
@@ -327,6 +371,7 @@ const BreadcrumbTrail = ({
role="menu"
ref={ellipsisMenuRef}
style={ellipsisMenuStyle}
data-floating-position
>
<div className="menu__list">
{hiddenEntries.map((hiddenEntry) => (
+21 -3
View File
@@ -144,6 +144,23 @@ const QuickAddMenu = ({
);
const canCreate = Boolean(onCreate);
const isAnchoredMenu = positionStrategy === 'absolute' && align === 'start';
const menuClassName = 'menu menu--floating';
const anchoredMenuStyle = isAnchoredMenu && menuStyle
? {
top: menuStyle.top,
left: menuStyle.left,
...(menuStyle.width ? { width: menuStyle.width } : null),
}
: undefined;
const menuInlineStyle = isAnchoredMenu ? anchoredMenuStyle : menuStyle || undefined;
const hasFloatingWidthVar = Boolean(menuInlineStyle && Object.prototype.hasOwnProperty.call(menuInlineStyle, '--floating-min-width'));
const menuStyleWithVar = hasFloatingWidthVar
? menuInlineStyle
: {
...(menuInlineStyle || {}),
'--floating-min-width': `${Math.max(menuMinWidth, 0)}px`,
};
return (
<div className={className ? `quick-add ${className}` : 'quick-add'}>
@@ -162,10 +179,11 @@ const QuickAddMenu = ({
</button>
{isOpen ? (
<div
className="menu menu--floating quick-add__menu"
className={menuClassName}
ref={menuRef}
style={menuStyle || undefined}
style={menuStyleWithVar}
role="menu"
data-floating-position
>
{canCreate ? (
<form className="quick-add__form" onSubmit={handleCreate}>
@@ -183,7 +201,7 @@ const QuickAddMenu = ({
</button>
</form>
) : null}
<div className="menu__list quick-add__list" role="presentation">
<div className="menu__list" role="presentation">
{filteredOptions.length ? (
filteredOptions.map((option) => {
const key = option.id ?? option.index;
+10
View File
@@ -1,6 +1,7 @@
import {
IconChevronRight as TablerChevronRight,
IconDownload as TablerDownload,
IconZoomInArea as TablerZoomInArea,
IconPencil,
IconTagFilled,
IconUserFilled,
@@ -118,6 +119,15 @@ export const DownloadIcon = ({ className, size = '1em', stroke = 1.6, ...rest })
/>
);
export const IconZoomInArea = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<TablerZoomInArea
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const ViewListIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconLayoutList
className={composeClassName('icon', className)}
+3 -1
View File
@@ -28,8 +28,10 @@ const formatStyle = (metrics) => {
position: metrics.strategy === 'absolute' ? 'absolute' : 'fixed',
top: metrics.top,
left: metrics.left,
minWidth: metrics.minWidth,
};
if (typeof metrics.minWidth === 'number') {
style['--floating-min-width'] = `${Math.max(metrics.minWidth, 0)}px`;
}
if (metrics.width) {
style.width = metrics.width;
}
+3 -3
View File
@@ -66,7 +66,7 @@ export async function openOcrTextInNewTab(options) {
if (popupAvailable) {
try {
popup.opener = null;
} catch (error) {
} catch {
/* ignore */
}
}
@@ -83,7 +83,7 @@ export async function openOcrTextInNewTab(options) {
if (popupAvailable) {
try {
popup.location.replace(url);
} catch (navigationError) {
} catch {
try {
popup.location.href = url;
} catch (secondError) {
@@ -96,7 +96,7 @@ export async function openOcrTextInNewTab(options) {
if (finalWindow) {
try {
finalWindow.opener = null;
} catch (error) {
} catch {
/* ignore */
}
}