Merge remote-tracking branch 'ui/ui' into dev
This commit is contained in:
@@ -1,2 +0,0 @@
|
|||||||
dist
|
|
||||||
node_modules
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
];
|
||||||
Generated
+763
-1063
File diff suppressed because it is too large
Load Diff
+21
-20
@@ -11,27 +11,28 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
"@tabler/icons-react": "3.11.0",
|
"@tabler/icons-react": "^3.35.0",
|
||||||
"axios": "1.7.7",
|
"axios": "^1.13.2",
|
||||||
"react": "18.3.1",
|
"react": "^19.2.0",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "^19.2.0",
|
||||||
"react-router-dom": "6.27.0"
|
"react-router-dom": "^7.9.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.26.0",
|
"@babel/core": "^7.28.5",
|
||||||
"@babel/preset-env": "7.26.0",
|
"@babel/preset-env": "^7.28.5",
|
||||||
"@babel/preset-react": "7.26.3",
|
"@babel/preset-react": "^7.28.5",
|
||||||
"eslint": "8.57.0",
|
"@svgr/webpack": "^8.1.0",
|
||||||
"eslint-plugin-react": "7.37.1",
|
"babel-loader": "^10.0.0",
|
||||||
"eslint-plugin-react-hooks": "4.6.0",
|
"css-loader": "^7.1.2",
|
||||||
"@svgr/webpack": "8.1.0",
|
"dotenv": "^17.2.3",
|
||||||
"babel-loader": "9.2.1",
|
"eslint": "^9.39.1",
|
||||||
"css-loader": "7.1.2",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"dotenv": "16.4.5",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"html-webpack-plugin": "5.6.3",
|
"globals": "^16.5.0",
|
||||||
"style-loader": "4.0.0",
|
"html-webpack-plugin": "^5.6.4",
|
||||||
"webpack": "5.95.0",
|
"style-loader": "^4.0.0",
|
||||||
"webpack-cli": "5.1.4",
|
"webpack": "^5.102.1",
|
||||||
"webpack-dev-server": "5.1.0"
|
"webpack-cli": "^6.0.1",
|
||||||
|
"webpack-dev-server": "^5.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,6 @@ export const useWorkspaceSurface = ({
|
|||||||
onFolderNavigate,
|
onFolderNavigate,
|
||||||
} = detailExtras;
|
} = detailExtras;
|
||||||
return createDocumentViewerSurface({
|
return createDocumentViewerSurface({
|
||||||
documentId: previewDocumentId,
|
|
||||||
document: previewWorkspaceDocument,
|
document: previewWorkspaceDocument,
|
||||||
previewEntry: previewWorkspaceEntry,
|
previewEntry: previewWorkspaceEntry,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
@@ -108,7 +107,6 @@ export const useWorkspaceSurface = ({
|
|||||||
}, [
|
}, [
|
||||||
showPreviewWorkspace,
|
showPreviewWorkspace,
|
||||||
previewWorkspaceDocument,
|
previewWorkspaceDocument,
|
||||||
previewDocumentId,
|
|
||||||
previewWorkspaceEntry,
|
previewWorkspaceEntry,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
ensurePreviewData,
|
ensurePreviewData,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import SelectionFloatingActions from '../documents/SelectionFloatingActions';
|
import SelectionFloatingActions from '../documents/SelectionFloatingActions';
|
||||||
import { createDocumentsTableHeaderActions } from '../documents/DocumentsPanel';
|
import { createDocumentsTableHeaderActions } from '../documents/DocumentsPanel';
|
||||||
import createWorkspaceSurfaceConfig from '../documents/workspaceHeader';
|
import createWorkspaceSurfaceConfig from '../documents/workspaceHeader';
|
||||||
import DetailPanel from '../detail/DetailPanel';
|
import DocumentViewerPanel from '../preview/DocumentViewerPanel';
|
||||||
import DesktopWorkspace from './DesktopWorkspace';
|
import DesktopWorkspace from './DesktopWorkspace';
|
||||||
|
|
||||||
const createDesktopSurface = ({
|
const createDesktopSurface = ({
|
||||||
@@ -82,7 +82,19 @@ const createDesktopSurface = ({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : 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({
|
const surfaceConfig = createWorkspaceSurfaceConfig({
|
||||||
key: 'workspace',
|
key: 'workspace',
|
||||||
variant: 'workspace',
|
variant: 'workspace',
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ const withStore = async (mode, handler) => {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await done;
|
await done;
|
||||||
} catch (suppressed) {
|
} catch {
|
||||||
// noop – prefer original error
|
// noop – prefer original error
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -1223,7 +1223,6 @@ export const useWorkspaceSnapshot = (engine, useSyncExternalStoreHook) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/* istanbul ignore next */
|
/* istanbul ignore next */
|
||||||
/* eslint-disable no-undef */
|
|
||||||
if (typeof module !== 'undefined' && module && module.exports) {
|
if (typeof module !== 'undefined' && module && module.exports) {
|
||||||
module.exports = {
|
module.exports = {
|
||||||
WorkspaceEngine,
|
WorkspaceEngine,
|
||||||
@@ -1245,4 +1244,3 @@ if (typeof module !== 'undefined' && module && module.exports) {
|
|||||||
useWorkspaceSnapshot,
|
useWorkspaceSnapshot,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
/* eslint-enable no-undef */
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
const useDetailWorkspace = ({
|
const useDetailWorkspace = ({
|
||||||
documents,
|
documents,
|
||||||
searchResults,
|
searchResults,
|
||||||
focusedDocumentId,
|
|
||||||
selectionOrder,
|
selectionOrder,
|
||||||
selectedDocumentIds,
|
selectedDocumentIds,
|
||||||
documentLookup,
|
documentLookup,
|
||||||
@@ -22,7 +21,6 @@ const useDetailWorkspace = ({
|
|||||||
previewDocumentId,
|
previewDocumentId,
|
||||||
activePreviewId,
|
activePreviewId,
|
||||||
openDocumentPreview,
|
openDocumentPreview,
|
||||||
promoteSelectionOrder,
|
|
||||||
handleDocumentTitleUpdate,
|
handleDocumentTitleUpdate,
|
||||||
handleDocumentIssuedUpdate,
|
handleDocumentIssuedUpdate,
|
||||||
handleDocumentTagAdd,
|
handleDocumentTagAdd,
|
||||||
@@ -38,14 +36,6 @@ const useDetailWorkspace = ({
|
|||||||
tags,
|
tags,
|
||||||
tagLookupById,
|
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 orderedSelectedDocuments = useMemo(() => {
|
||||||
const ordered = [];
|
const ordered = [];
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
@@ -186,12 +176,12 @@ const useDetailWorkspace = ({
|
|||||||
[folderNodes],
|
[folderNodes],
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedPreviewEntry = useMemo(() => {
|
const detailPanelPreviewEntry = useMemo(() => {
|
||||||
if (!selectedDocument) {
|
if (!detailPanelDocument) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return previewEntries.get(selectedDocument.id) || null;
|
return previewEntries.get(detailPanelDocument.id) || null;
|
||||||
}, [selectedDocument, previewEntries]);
|
}, [detailPanelDocument, previewEntries]);
|
||||||
|
|
||||||
const previewWorkspaceEntry = useMemo(() => {
|
const previewWorkspaceEntry = useMemo(() => {
|
||||||
if (!previewDocumentId) {
|
if (!previewDocumentId) {
|
||||||
@@ -240,15 +230,14 @@ const useDetailWorkspace = ({
|
|||||||
tagLookupById,
|
tagLookupById,
|
||||||
onTagAdd: handleDocumentTagAdd,
|
onTagAdd: handleDocumentTagAdd,
|
||||||
onTagRemove: handleTagRemove,
|
onTagRemove: handleTagRemove,
|
||||||
previewEntry: selectedPreviewEntry,
|
previewEntry: detailPanelPreviewEntry,
|
||||||
onOpenPreview: openDocumentPreview,
|
onOpenPreview: openDocumentPreview,
|
||||||
onPromoteSelection: promoteSelectionOrder,
|
|
||||||
activePreviewId,
|
activePreviewId,
|
||||||
onUpdateTitle: handleDocumentTitleUpdate,
|
onUpdateTitle: handleDocumentTitleUpdate,
|
||||||
onUpdateIssued: handleDocumentIssuedUpdate,
|
onUpdateIssued: handleDocumentIssuedUpdate,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
ensurePreviewData,
|
hydrateDocument: ensurePreviewData,
|
||||||
correspondents,
|
correspondents,
|
||||||
onCorrespondentAdd: handleCorrespondentAdd,
|
onCorrespondentAdd: handleCorrespondentAdd,
|
||||||
onCorrespondentRemove: handleCorrespondentRemove,
|
onCorrespondentRemove: handleCorrespondentRemove,
|
||||||
@@ -272,11 +261,10 @@ const useDetailWorkspace = ({
|
|||||||
handleDocumentTitleUpdate,
|
handleDocumentTitleUpdate,
|
||||||
handleTagRemove,
|
handleTagRemove,
|
||||||
openDocumentPreview,
|
openDocumentPreview,
|
||||||
promoteSelectionOrder,
|
|
||||||
resolveApiPath,
|
resolveApiPath,
|
||||||
resolveFolderPath,
|
resolveFolderPath,
|
||||||
selectFolder,
|
selectFolder,
|
||||||
selectedPreviewEntry,
|
detailPanelPreviewEntry,
|
||||||
tags,
|
tags,
|
||||||
tagLookupById,
|
tagLookupById,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ const SelectionAssignmentMenu = ({
|
|||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
style={menuStyle || undefined}
|
style={menuStyle || undefined}
|
||||||
role="menu"
|
role="menu"
|
||||||
|
data-floating-position
|
||||||
>
|
>
|
||||||
<div className="selection-assignment__header">
|
<div className="selection-assignment__header">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const useBulkDocumentActions = ({
|
|||||||
if (!target) {
|
if (!target) {
|
||||||
try {
|
try {
|
||||||
target = await handleCorrespondentCreate({ name: trimmed });
|
target = await handleCorrespondentCreate({ name: trimmed });
|
||||||
} catch (error) {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,7 +160,6 @@ const useBulkDocumentActions = ({
|
|||||||
|
|
||||||
if (folderIds.length) {
|
if (folderIds.length) {
|
||||||
for (const folderId of folderIds) {
|
for (const folderId of folderIds) {
|
||||||
// eslint-disable-next-line no-await-in-loop
|
|
||||||
const success = await handleFolderDelete(folderId, { showMessage: false, manageLoading: false });
|
const success = await handleFolderDelete(folderId, { showMessage: false, manageLoading: false });
|
||||||
if (!success) {
|
if (!success) {
|
||||||
foldersOk = false;
|
foldersOk = false;
|
||||||
|
|||||||
@@ -196,7 +196,6 @@ const DocumentsPanel = ({
|
|||||||
[handleDocumentPreviewZoom, onInspectDocument],
|
[handleDocumentPreviewZoom, onInspectDocument],
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedRowKeySet = useMemo(() => new Set(selectedEntries || []), [selectedEntries]);
|
|
||||||
const navigableRows = useMemo(
|
const navigableRows = useMemo(
|
||||||
() => entries.map((entry) => ({ key: entry.key, type: entry.type, id: entry.id })),
|
() => entries.map((entry) => ({ key: entry.key, type: entry.type, id: entry.id })),
|
||||||
[entries],
|
[entries],
|
||||||
@@ -234,21 +233,12 @@ const DocumentsPanel = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
onFocusedRowChange?.(resolvedKey);
|
onFocusedRowChange?.(resolvedKey);
|
||||||
|
|
||||||
if (!selectedRowKeySet.has(resolvedKey) && typeof onEntrySelection === 'function') {
|
|
||||||
onEntrySelection(resolvedKey, {
|
|
||||||
shiftKey: false,
|
|
||||||
preventDefault: () => {},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [
|
}, [
|
||||||
focusedRowKey,
|
focusedRowKey,
|
||||||
navigableRowKeys,
|
navigableRowKeys,
|
||||||
navigableRows,
|
navigableRows,
|
||||||
onEntrySelection,
|
|
||||||
onFocusedRowChange,
|
onFocusedRowChange,
|
||||||
selectedEntries,
|
selectedEntries,
|
||||||
selectedRowKeySet,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handlePanelKeyDown = useCallback(
|
const handlePanelKeyDown = useCallback(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import DetailPanel from '../../detail/DetailPanel';
|
import DocumentViewerPanel from '../../preview/DocumentViewerPanel';
|
||||||
import SelectionFloatingActions from '../SelectionFloatingActions';
|
import SelectionFloatingActions from '../SelectionFloatingActions';
|
||||||
import createWorkspaceSurfaceConfig from '../workspaceHeader';
|
import createWorkspaceSurfaceConfig from '../workspaceHeader';
|
||||||
import DocumentsPanel from './DocumentsPanel';
|
import DocumentsPanel from './DocumentsPanel';
|
||||||
@@ -91,7 +91,19 @@ const createDocumentsSurface = ({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : 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({
|
return createWorkspaceSurfaceConfig({
|
||||||
key: 'documents',
|
key: 'documents',
|
||||||
|
|||||||
@@ -14,11 +14,15 @@ export const isPrimaryPointerEvent = (event) => {
|
|||||||
return type === 'click' || type === 'pointerdown' || type === 'pointerup';
|
return type === 'click' || type === 'pointerdown' || type === 'pointerup';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const EntryType = Object.freeze({
|
||||||
|
document: 'document',
|
||||||
|
folder: 'folder',
|
||||||
|
});
|
||||||
|
|
||||||
export const useEntryPointer = ({
|
export const useEntryPointer = ({
|
||||||
resolveDocumentRowKey,
|
resolveDocumentRowKey,
|
||||||
resolveFolderRowKey,
|
resolveFolderRowKey,
|
||||||
onSelectDocument,
|
onSelectEntry,
|
||||||
onSelectFolder,
|
|
||||||
onInspectDocument,
|
onInspectDocument,
|
||||||
}) =>
|
}) =>
|
||||||
useCallback(
|
useCallback(
|
||||||
@@ -28,41 +32,34 @@ export const useEntryPointer = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { type, id } = entry;
|
const { type, id } = entry;
|
||||||
if (type !== 'document' && type !== 'folder') {
|
if (type !== EntryType.document && type !== EntryType.folder) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rowKey = entry.key
|
const rowKey = entry.key
|
||||||
|| (type === 'document' ? resolveDocumentRowKey?.(id) : resolveFolderRowKey?.(id));
|
|| (type === EntryType.document ? resolveDocumentRowKey?.(id) : resolveFolderRowKey?.(id));
|
||||||
if (!rowKey) {
|
if (!rowKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const modifierClick = isPointerModifierEvent(event);
|
const modifierClick = isPointerModifierEvent(event);
|
||||||
const primaryClick = isPrimaryPointerEvent(event);
|
const primaryClick = isPrimaryPointerEvent(event);
|
||||||
const metadata = { modifierClick, primaryClick, rowKey };
|
const metadata = { modifierClick, primaryClick, rowKey, type, id };
|
||||||
|
|
||||||
if (type === 'document') {
|
if (typeof onSelectEntry === 'function') {
|
||||||
if (typeof onSelectDocument === 'function') {
|
onSelectEntry(entry, event, metadata);
|
||||||
onSelectDocument(id, event, metadata);
|
|
||||||
}
|
|
||||||
if (!modifierClick && primaryClick && typeof onInspectDocument === 'function') {
|
|
||||||
onInspectDocument(id, metadata);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof onSelectFolder === 'function') {
|
if (
|
||||||
onSelectFolder(id, event, metadata);
|
type === EntryType.document
|
||||||
|
&& !modifierClick
|
||||||
|
&& primaryClick
|
||||||
|
&& typeof onInspectDocument === 'function'
|
||||||
|
) {
|
||||||
|
onInspectDocument(id, metadata);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[resolveDocumentRowKey, resolveFolderRowKey, onSelectEntry, onInspectDocument],
|
||||||
resolveDocumentRowKey,
|
|
||||||
resolveFolderRowKey,
|
|
||||||
onSelectDocument,
|
|
||||||
onSelectFolder,
|
|
||||||
onInspectDocument,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export default useEntryPointer;
|
export default useEntryPointer;
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ const useInlineRename = (
|
|||||||
}
|
}
|
||||||
resetState();
|
resetState();
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setSavingId((current) => (current === entityId ? null : current));
|
setSavingId((current) => (current === entityId ? null : current));
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ const useDocumentCorrespondentActions = ({
|
|||||||
if (!target) {
|
if (!target) {
|
||||||
try {
|
try {
|
||||||
target = await handleCorrespondentCreate({ name: trimmed });
|
target = await handleCorrespondentCreate({ name: trimmed });
|
||||||
} catch (error) {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,4 +127,3 @@ const useDocumentCorrespondentActions = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default useDocumentCorrespondentActions;
|
export default useDocumentCorrespondentActions;
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ const useDocumentDragHandlers = ({
|
|||||||
({ documents = [], folders = [] } = {}) => {
|
({ documents = [], folders = [] } = {}) => {
|
||||||
destroyDragPreview();
|
destroyDragPreview();
|
||||||
|
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const docEntries = (documents || []).filter(Boolean);
|
const docEntries = (documents || []).filter(Boolean);
|
||||||
const folderEntries = (folders || []).filter(Boolean);
|
const folderEntries = (folders || []).filter(Boolean);
|
||||||
const totalCount = docEntries.length + folderEntries.length;
|
const totalCount = docEntries.length + folderEntries.length;
|
||||||
@@ -53,24 +57,100 @@ const useDocumentDragHandlers = ({
|
|||||||
|
|
||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
wrapper.className = 'document-drag-preview';
|
wrapper.className = 'document-drag-preview';
|
||||||
|
wrapper.style.setProperty('--drag-preview-size', `${canvasSize}px`);
|
||||||
wrapper.style.width = `${canvasSize}px`;
|
wrapper.style.width = `${canvasSize}px`;
|
||||||
wrapper.style.height = `${canvasSize}px`;
|
wrapper.style.height = `${canvasSize}px`;
|
||||||
|
|
||||||
visibleItems.forEach((item, index) => {
|
visibleItems.forEach((item, index) => {
|
||||||
const slot = document.createElement('div');
|
const layer = document.createElement('div');
|
||||||
slot.className = 'document-drag-preview__item';
|
layer.className = 'document-drag-preview__item';
|
||||||
slot.style.setProperty('--index', String(index));
|
layer.style.setProperty('--index', String(index));
|
||||||
slot.style.width = `${size}px`;
|
const rotationMagnitude = Math.random() * 8 + 2;
|
||||||
slot.style.height = `${size}px`;
|
const rotation = (index % 2 === 0 ? 1 : -1) * rotationMagnitude;
|
||||||
|
layer.style.setProperty('--rotation-deg', `${rotation}deg`);
|
||||||
|
|
||||||
if (item.type === 'document') {
|
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 {
|
} 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);
|
document.body.appendChild(wrapper);
|
||||||
dragPreviewRef.current = wrapper;
|
dragPreviewRef.current = wrapper;
|
||||||
return wrapper;
|
return wrapper;
|
||||||
@@ -97,7 +177,7 @@ const useDocumentDragHandlers = ({
|
|||||||
: isGridView
|
: isGridView
|
||||||
? [...selectedDocumentIds, documentId]
|
? [...selectedDocumentIds, documentId]
|
||||||
: [documentId];
|
: [documentId];
|
||||||
const folderSelection = selectedFolderIds.length ? [...selectedFolderIds] : [];
|
const folderSelection = [];
|
||||||
|
|
||||||
if (!isAlreadySelected && !isGridView) {
|
if (!isAlreadySelected && !isGridView) {
|
||||||
applySelection([documentKey], {
|
applySelection([documentKey], {
|
||||||
@@ -143,7 +223,6 @@ const useDocumentDragHandlers = ({
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
selectedDocumentIds,
|
selectedDocumentIds,
|
||||||
selectedFolderIds,
|
|
||||||
applySelection,
|
applySelection,
|
||||||
documentLookup,
|
documentLookup,
|
||||||
createDragPreview,
|
createDragPreview,
|
||||||
@@ -210,11 +289,19 @@ const useDocumentDragHandlers = ({
|
|||||||
console.warn('[documents] Failed to populate folder drag payload', error);
|
console.warn('[documents] Failed to populate folder drag payload', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const previewDocs = effectiveDocumentSelection
|
const previewNode = createDragPreview({
|
||||||
.map((id) => documentLookup.get(id) || null)
|
documents: effectiveDocumentSelection
|
||||||
.filter(Boolean);
|
.map((id) => documentLookup.get(id) || null)
|
||||||
createDragPreview({ documents: previewDocs, folders: uniqueFolders });
|
.filter(Boolean),
|
||||||
|
folders: uniqueFolders,
|
||||||
|
});
|
||||||
event.currentTarget.classList.add('dragging');
|
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,
|
selectedFolderIds,
|
||||||
|
|||||||
@@ -175,7 +175,6 @@ const useDocumentUploads = ({
|
|||||||
const entries = [];
|
const entries = [];
|
||||||
let batch = [];
|
let batch = [];
|
||||||
do {
|
do {
|
||||||
// eslint-disable-next-line no-await-in-loop
|
|
||||||
batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject));
|
batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject));
|
||||||
if (batch.length) {
|
if (batch.length) {
|
||||||
entries.push(...batch);
|
entries.push(...batch);
|
||||||
@@ -203,7 +202,6 @@ const useDocumentUploads = ({
|
|||||||
const reader = entry.createReader();
|
const reader = entry.createReader();
|
||||||
const entries = await readAllEntries(reader);
|
const entries = await readAllEntries(reader);
|
||||||
for (const child of entries) {
|
for (const child of entries) {
|
||||||
// eslint-disable-next-line no-await-in-loop
|
|
||||||
await walkEntry(child, nextAncestors);
|
await walkEntry(child, nextAncestors);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -306,7 +304,6 @@ const useDocumentUploads = ({
|
|||||||
updateQueueItem(queueItem.id, patch);
|
updateQueueItem(queueItem.id, patch);
|
||||||
Object.assign(queueItem, patch);
|
Object.assign(queueItem, patch);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line no-await-in-loop
|
|
||||||
const destinationId = segments.length
|
const destinationId = segments.length
|
||||||
? await ensureFolderPathOnServer(baseFolderId, segments)
|
? await ensureFolderPathOnServer(baseFolderId, segments)
|
||||||
: baseFolderId;
|
: baseFolderId;
|
||||||
@@ -316,7 +313,6 @@ const useDocumentUploads = ({
|
|||||||
(targetFolderId && targetFolderId !== 'root' ? targetFolderId : 'root');
|
(targetFolderId && targetFolderId !== 'root' ? targetFolderId : 'root');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line no-await-in-loop
|
|
||||||
const { duplicate, statusCode, document, conflictDocumentId } = await uploadFile(
|
const { duplicate, statusCode, document, conflictDocumentId } = await uploadFile(
|
||||||
file,
|
file,
|
||||||
uploadTarget,
|
uploadTarget,
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ import useDocumentDragHandlers from './useDocumentDragHandlers';
|
|||||||
import useDocumentMutations from './useDocumentMutations';
|
import useDocumentMutations from './useDocumentMutations';
|
||||||
import useDetailWorkspace from '../../detail/useDetailWorkspace';
|
import useDetailWorkspace from '../../detail/useDetailWorkspace';
|
||||||
|
|
||||||
|
const EntryType = Object.freeze({
|
||||||
|
document: 'document',
|
||||||
|
folder: 'folder',
|
||||||
|
});
|
||||||
|
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
|
|
||||||
const useDocumentsWorkspace = ({
|
const useDocumentsWorkspace = ({
|
||||||
@@ -1185,19 +1190,15 @@ const useDocumentsWorkspace = ({
|
|||||||
const handleEntryPointerCore = useEntryPointerCore({
|
const handleEntryPointerCore = useEntryPointerCore({
|
||||||
resolveDocumentRowKey,
|
resolveDocumentRowKey,
|
||||||
resolveFolderRowKey,
|
resolveFolderRowKey,
|
||||||
onSelectDocument: (documentId, event, { rowKey }) => {
|
onSelectEntry: (entry, event, { rowKey, modifierClick, primaryClick }) => {
|
||||||
const key = rowKey || resolveDocumentRowKey(documentId);
|
const { type, id } = entry;
|
||||||
|
const key = rowKey
|
||||||
|
|| (type === EntryType.document ? resolveDocumentRowKey(id) : resolveFolderRowKey(id));
|
||||||
if (key) {
|
if (key) {
|
||||||
handleEntrySelection(key, event);
|
handleEntrySelection(key, event);
|
||||||
}
|
}
|
||||||
},
|
if (type === EntryType.folder && !modifierClick && primaryClick) {
|
||||||
onSelectFolder: (folderId, event, { modifierClick, primaryClick, rowKey }) => {
|
selectFolder(id);
|
||||||
const key = rowKey || resolveFolderRowKey(folderId);
|
|
||||||
if (key) {
|
|
||||||
handleEntrySelection(key, event);
|
|
||||||
}
|
|
||||||
if (!modifierClick && primaryClick) {
|
|
||||||
selectFolder(folderId);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -85,9 +85,8 @@ const useFolderTree = ({
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
setFocusedDocumentId(nextFocus);
|
setFocusedDocumentId(nextFocus);
|
||||||
selectionAnchorRef.current = nextDocKeys.length
|
const nextAnchor = mergedSelection.length ? mergedSelection[mergedSelection.length - 1] : null;
|
||||||
? nextDocKeys[nextDocKeys.length - 1]
|
selectionAnchorRef.current = nextAnchor;
|
||||||
: null;
|
|
||||||
selectionOrderRef.current = mergedSelection;
|
selectionOrderRef.current = mergedSelection;
|
||||||
setSelectionOrder(mergedSelection);
|
setSelectionOrder(mergedSelection);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -96,7 +96,6 @@ const useFolderTreeActions = ({
|
|||||||
|
|
||||||
const refreshTargets = new Set([previousParentKey, targetKey]);
|
const refreshTargets = new Set([previousParentKey, targetKey]);
|
||||||
for (const key of refreshTargets) {
|
for (const key of refreshTargets) {
|
||||||
// eslint-disable-next-line no-await-in-loop
|
|
||||||
await ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 });
|
await ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +111,6 @@ const useFolderTreeActions = ({
|
|||||||
|
|
||||||
const refreshTargets = new Set([previousParentKey, targetKey]);
|
const refreshTargets = new Set([previousParentKey, targetKey]);
|
||||||
for (const key of refreshTargets) {
|
for (const key of refreshTargets) {
|
||||||
// eslint-disable-next-line no-await-in-loop
|
|
||||||
await ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 });
|
await ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -516,7 +514,6 @@ const useFolderTreeActions = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const sourceId of folderIds) {
|
for (const sourceId of folderIds) {
|
||||||
// eslint-disable-next-line no-await-in-loop
|
|
||||||
await moveFolder(sourceId, folderId);
|
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;
|
||||||
@@ -1,18 +1,81 @@
|
|||||||
import React, { useCallback, useMemo } from 'react';
|
import React, {
|
||||||
import { DownloadIcon, CloseIcon } from '../ui/icons';
|
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 {
|
import {
|
||||||
buildCorrespondentOptions,
|
buildCorrespondentOptions,
|
||||||
sortCorrespondents,
|
sortCorrespondents,
|
||||||
} from '../documents/DocumentSummarySection';
|
} from '../documents/DocumentSummarySection';
|
||||||
import DocumentInfoPanel from '../documents/DocumentInfoPanel';
|
|
||||||
import { extractDocumentMetadataPayload } from '../documents/documentMetadata';
|
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';
|
||||||
|
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 = ({
|
const DocumentViewerPanel = ({
|
||||||
document,
|
document,
|
||||||
documentId,
|
|
||||||
previewEntry,
|
previewEntry,
|
||||||
|
hydrateDocument,
|
||||||
tagLookupById,
|
tagLookupById,
|
||||||
tagOptions,
|
tagOptions,
|
||||||
onTagAdd,
|
onTagAdd,
|
||||||
@@ -22,10 +85,20 @@ const DocumentViewerPanel = ({
|
|||||||
onCorrespondentRemove,
|
onCorrespondentRemove,
|
||||||
onUpdateTitle,
|
onUpdateTitle,
|
||||||
onUpdateIssued,
|
onUpdateIssued,
|
||||||
hasOcr = false,
|
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
|
ensurePreviewData,
|
||||||
|
resolveApiPath,
|
||||||
|
notifyApiError,
|
||||||
|
sidebarToggle = null,
|
||||||
|
onClosePanel,
|
||||||
|
resolveFolderPath,
|
||||||
|
variant = 'viewer',
|
||||||
|
onCollapsePanel,
|
||||||
|
onMaximizePanel,
|
||||||
}) => {
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isSidebarVariant = variant === 'sidebar';
|
||||||
const sortedCorrespondents = useMemo(
|
const sortedCorrespondents = useMemo(
|
||||||
() => sortCorrespondents(document?.correspondents || []),
|
() => sortCorrespondents(document?.correspondents || []),
|
||||||
[document],
|
[document],
|
||||||
@@ -36,71 +109,18 @@ const DocumentViewerPanel = ({
|
|||||||
[correspondents],
|
[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(
|
const metadataPayload = useMemo(
|
||||||
() => extractDocumentMetadataPayload(document),
|
() => extractDocumentMetadataPayload(document),
|
||||||
[document],
|
[document],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasOcr = useMemo(() => {
|
||||||
|
if (!document || typeof getDocumentAsset !== 'function') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Boolean(getDocumentAsset(document, 'ocr-text'));
|
||||||
|
}, [document, getDocumentAsset]);
|
||||||
|
|
||||||
const summaryProps = useMemo(
|
const summaryProps = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
tagLookupById,
|
tagLookupById,
|
||||||
@@ -182,82 +202,296 @@ const DocumentViewerPanel = ({
|
|||||||
[hasOcr, loadOcrContent],
|
[hasOcr, loadOcrContent],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!document) {
|
const [zoomOverlayOpen, setZoomOverlayOpen] = useState(false);
|
||||||
return (
|
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">
|
<section className="document-viewer document-viewer--loading">
|
||||||
<div className="document-viewer__details-pane">
|
<div className="document-viewer__details-pane">
|
||||||
<div className="document-viewer__details">
|
<div className="document-viewer__details">
|
||||||
<div className="document-viewer__message">
|
<div className="document-viewer__message">Loading document…</div>
|
||||||
Loading document{documentId ? ` ${documentId}` : ''}…
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="document-viewer__viewport">
|
<div className="document-viewer__viewport">
|
||||||
<div className="document-viewer__message">Preparing preview…</div>
|
<div className="document-viewer__message">Preparing preview…</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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 (
|
return (
|
||||||
<section className="document-viewer">
|
<>
|
||||||
<div className="document-viewer__details-pane">
|
<section className="document-viewer-panel" ref={panelRef}>
|
||||||
<div className="document-viewer__details">
|
<PanelHeader
|
||||||
<DocumentInfoPanel
|
leading={headerLeadingContent}
|
||||||
document={document}
|
title={headerTitle}
|
||||||
summaryProps={summaryProps}
|
titleTag="h3"
|
||||||
metadataPayload={metadataPayload}
|
actions={headerActions}
|
||||||
contentConfig={contentTabConfig}
|
/>
|
||||||
defaultTabId="details"
|
{viewerSection}
|
||||||
classNamePrefix="document-viewer"
|
</section>
|
||||||
hideTabNavWhenSingle={false}
|
{overlay}
|
||||||
resetKey={document?.id}
|
</>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="document-viewer__viewport">
|
|
||||||
{!previewEntry?.url ? (
|
|
||||||
<div className="document-viewer__message">Loading preview…</div>
|
|
||||||
) : (
|
|
||||||
previewContent
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default DocumentViewerPanel;
|
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 = ({
|
export const createDocumentViewerSurface = ({
|
||||||
documentId,
|
|
||||||
document,
|
document,
|
||||||
previewEntry,
|
previewEntry,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
@@ -278,80 +512,23 @@ export const createDocumentViewerSurface = ({
|
|||||||
onUpdateIssued,
|
onUpdateIssued,
|
||||||
resolveFolderPath,
|
resolveFolderPath,
|
||||||
}) => {
|
}) => {
|
||||||
if (!documentId && !document) {
|
if (!document) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = document?.title || 'Document preview';
|
const sidebarToggle = typeof renderSidebarToggle === 'function'
|
||||||
const closeButton = onClose
|
? renderSidebarToggle()
|
||||||
? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button"
|
|
||||||
onClick={() => onClose?.()}
|
|
||||||
aria-label="Close preview"
|
|
||||||
title="Close preview"
|
|
||||||
>
|
|
||||||
<CloseIcon />
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
: null;
|
: 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 {
|
return {
|
||||||
key: 'preview',
|
key: 'preview',
|
||||||
variant: 'preview',
|
variant: 'preview',
|
||||||
header,
|
header: null,
|
||||||
content: (
|
content: (
|
||||||
<DocumentViewerPanel
|
<DocumentViewerPanel
|
||||||
document={document}
|
document={document}
|
||||||
documentId={documentId}
|
|
||||||
previewEntry={previewEntry}
|
previewEntry={previewEntry}
|
||||||
|
hydrateDocument={ensurePreviewData}
|
||||||
tagLookupById={tagLookupById}
|
tagLookupById={tagLookupById}
|
||||||
tagOptions={tagOptions}
|
tagOptions={tagOptions}
|
||||||
onTagAdd={onTagAdd}
|
onTagAdd={onTagAdd}
|
||||||
@@ -361,9 +538,14 @@ export const createDocumentViewerSurface = ({
|
|||||||
onCorrespondentRemove={onCorrespondentRemove}
|
onCorrespondentRemove={onCorrespondentRemove}
|
||||||
onUpdateTitle={onUpdateTitle}
|
onUpdateTitle={onUpdateTitle}
|
||||||
onUpdateIssued={onUpdateIssued}
|
onUpdateIssued={onUpdateIssued}
|
||||||
hasOcr={Boolean(actionState?.hasOcr)}
|
|
||||||
ensureAssetUrl={ensureAssetUrl}
|
ensureAssetUrl={ensureAssetUrl}
|
||||||
getDocumentAsset={getDocumentAsset}
|
getDocumentAsset={getDocumentAsset}
|
||||||
|
ensurePreviewData={ensurePreviewData}
|
||||||
|
resolveApiPath={resolveApiPath}
|
||||||
|
notifyApiError={notifyApiError}
|
||||||
|
sidebarToggle={sidebarToggle}
|
||||||
|
onClosePanel={onClose}
|
||||||
|
resolveFolderPath={resolveFolderPath}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
supportsDetail: false,
|
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"
|
role="menu"
|
||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
className="menu menu--floating capability-dropdown__menu"
|
className="menu menu--floating capability-dropdown__menu"
|
||||||
|
data-floating-position
|
||||||
>
|
>
|
||||||
{total ? (
|
{total ? (
|
||||||
options.map((option) => {
|
options.map((option) => {
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ const ApiTokensSection = ({
|
|||||||
await navigator.clipboard.writeText(createdToken);
|
await navigator.clipboard.writeText(createdToken);
|
||||||
showSuccess();
|
showSuccess();
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch {
|
||||||
// Some browsers expose writeText but still reject outside secure context
|
// Some browsers expose writeText but still reject outside secure context
|
||||||
setCanCopyToken(false);
|
setCanCopyToken(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -512,9 +512,9 @@ const Sidebar = ({
|
|||||||
style={tenantMenuStyle}
|
style={tenantMenuStyle}
|
||||||
>
|
>
|
||||||
{showTenantList ? (
|
{showTenantList ? (
|
||||||
<>
|
<div className="menu__section">
|
||||||
<div className="menu__heading">Switch tenant</div>
|
<div className="menu__heading">Switch tenant</div>
|
||||||
<div className="menu__list">
|
<div className="menu__wrapper">
|
||||||
{tenants.map((tenant) => {
|
{tenants.map((tenant) => {
|
||||||
const tenantId = tenant?.id || null;
|
const tenantId = tenant?.id || null;
|
||||||
const isActive = tenantId === activeTenantId;
|
const isActive = tenantId === activeTenantId;
|
||||||
@@ -523,7 +523,7 @@ const Sidebar = ({
|
|||||||
<button
|
<button
|
||||||
key={tenantId || tenantLabel}
|
key={tenantId || tenantLabel}
|
||||||
type="button"
|
type="button"
|
||||||
className={`menu__item${isActive ? ' active' : ''}`}
|
className={`menu__button${isActive ? ' active' : ''}`}
|
||||||
onClick={() => handleTenantSelect(tenant)}
|
onClick={() => handleTenantSelect(tenant)}
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
>
|
>
|
||||||
@@ -535,25 +535,27 @@ const Sidebar = ({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="menu__footer">
|
<div className="menu__section">
|
||||||
<button
|
<div className="menu__wrapper">
|
||||||
type="button"
|
<button
|
||||||
className="menu__button"
|
type="button"
|
||||||
onClick={handleSettingsFromMenu}
|
className="menu__button"
|
||||||
>
|
onClick={handleSettingsFromMenu}
|
||||||
<SettingsIcon size={16} />
|
>
|
||||||
Settings
|
<SettingsIcon size={16} />
|
||||||
</button>
|
Settings
|
||||||
<button
|
</button>
|
||||||
type="button"
|
<button
|
||||||
className="menu__button menu__button--danger"
|
type="button"
|
||||||
onClick={handleLogoutFromMenu}
|
className="menu__button menu__button--danger"
|
||||||
>
|
onClick={handleLogoutFromMenu}
|
||||||
<LogoutIcon size={16} />
|
>
|
||||||
Log out
|
<LogoutIcon size={16} />
|
||||||
</button>
|
Log out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{themeMenuSection}
|
{themeMenuSection}
|
||||||
</div>,
|
</div>,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
--surface-subtle: oklch(0.96 0.002 calc(var(--neutral-hue) + var(--surface-hue-shift)));
|
--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)));
|
--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: 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)));
|
--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)));
|
--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)));
|
--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));
|
--fg: oklch(0.89 0.015 var(--dark-foreground-hue));
|
||||||
--muted: oklch(0.72 0.02 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));
|
--sidebar-fg: oklch(0.78 0.02 var(--dark-foreground-hue));
|
||||||
--border: oklch(0.33 0.01 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)));
|
--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));
|
--fg: oklch(0.89 0.015 var(--dark-foreground-hue));
|
||||||
--muted: oklch(0.72 0.02 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));
|
--sidebar-fg: oklch(0.78 0.02 var(--dark-foreground-hue));
|
||||||
--border: oklch(0.33 0.01 var(--dark-foreground-hue));
|
--border: oklch(0.33 0.01 var(--dark-foreground-hue));
|
||||||
|
|
||||||
|
|||||||
@@ -60,11 +60,24 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow-y: auto;
|
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 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 {
|
.detail-section__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -80,6 +93,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.quick-add {
|
.quick-add {
|
||||||
|
--quick-add-menu-offset: 0.35rem;
|
||||||
|
--quick-add-menu-min-width: 220px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -128,10 +143,6 @@
|
|||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-add__menu {
|
|
||||||
padding: 0.25rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-add__form {
|
.quick-add__form {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -172,12 +183,15 @@
|
|||||||
.selection-assignment__menu {
|
.selection-assignment__menu {
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
padding: 0.4rem 0;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
padding: 0;
|
||||||
max-width: min(22rem, 90vw);
|
max-width: min(22rem, 90vw);
|
||||||
}
|
}
|
||||||
|
|
||||||
.selection-assignment__header {
|
.selection-assignment__header {
|
||||||
padding: 0.4rem 0.75rem 0.3rem;
|
padding: 0.5rem;
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,14 +207,14 @@
|
|||||||
|
|
||||||
.selection-assignment__header input:focus-visible {
|
.selection-assignment__header input:focus-visible {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--selection-border);
|
border-color: color-mix(in oklch, var(--accent) 60%, transparent);
|
||||||
box-shadow: 0 0 0 2px color-mix(in oklch, var(--selection) 25%, transparent);
|
box-shadow: 0 0 0 1px color-mix(in oklch, var(--accent) 35%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.selection-assignment__list {
|
.selection-assignment__list {
|
||||||
max-height: 240px;
|
max-height: max(240px, 50vh);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 0 0.75rem;
|
padding: 0 0.25rem 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selection-assignment__item {
|
.selection-assignment__item {
|
||||||
|
|||||||
@@ -44,6 +44,10 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.documents-sort__quickmenu {
|
||||||
|
--quick-add-menu-min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
.documents-sort__trigger {
|
.documents-sort__trigger {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -105,4 +109,3 @@
|
|||||||
width: 1.1rem;
|
width: 1.1rem;
|
||||||
height: 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));
|
||||||
|
}
|
||||||
@@ -13,8 +13,15 @@
|
|||||||
min-height: 0;
|
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;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,19 +84,36 @@
|
|||||||
cursor: default;
|
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) {
|
.breadcrumb-trail__link:not(.is-current) {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.breadcrumb-trail__link:not(.is-current):hover,
|
.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;
|
text-decoration: underline;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.breadcrumb-trail__separator {
|
.breadcrumb-trail__separator {
|
||||||
color: var(--muted);
|
color: var(--muted-subtle);
|
||||||
margin: 0 0.2rem;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.breadcrumb-trail__ellipsis {
|
.breadcrumb-trail__ellipsis {
|
||||||
@@ -130,4 +147,3 @@
|
|||||||
.panel-section__body.scrollable {
|
.panel-section__body.scrollable {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
.document-viewer {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: grid;
|
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;
|
gap: 1rem;
|
||||||
min-height: 0;
|
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 {
|
.document-drag-preview {
|
||||||
@@ -17,7 +74,7 @@
|
|||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-drag-preview__thumb {
|
.document-drag-preview__item {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
@@ -38,23 +95,23 @@
|
|||||||
transform-origin: center;
|
transform-origin: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-drag-preview__thumb--image {
|
.document-drag-preview__item--image {
|
||||||
background-color: #000;
|
background-color: #000;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
background-size: contain;
|
background-size: contain;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-drag-preview__thumb .document-thumbnail,
|
.document-drag-preview__item .document-thumbnail,
|
||||||
.document-drag-preview__thumb img {
|
.document-drag-preview__item img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-drag-preview__thumb .thumb-placeholder,
|
.document-drag-preview__item .thumb-placeholder,
|
||||||
.document-drag-preview__thumb .thumb-placeholder * {
|
.document-drag-preview__item .thumb-placeholder * {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -63,6 +120,12 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.document-drag-preview__item--folder {
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.document-drag-preview__folder-thumb {
|
.document-drag-preview__folder-thumb {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -119,6 +182,7 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
grid-area: details;
|
||||||
}
|
}
|
||||||
.document-viewer__tabs-wrapper {
|
.document-viewer__tabs-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -284,21 +348,35 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
justify-content: flex-start;
|
||||||
|
max-height: 100%;
|
||||||
|
grid-area: viewport;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-viewer__object {
|
.document-viewer__object {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.document-viewer__object:not(.document-viewer__object--image) {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.document-viewer__object--image {
|
.document-viewer__object--image {
|
||||||
width: auto;
|
width: auto;
|
||||||
height: auto;
|
height: auto;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
object-fit: contain;
|
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 {
|
.document-viewer__unsupported {
|
||||||
@@ -332,4 +410,3 @@
|
|||||||
width: 1rem;
|
width: 1rem;
|
||||||
height: 1rem;
|
height: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
@import './documents/controls.css';
|
@import './documents/controls.css';
|
||||||
@import './layout/structure.css';
|
@import './layout/structure.css';
|
||||||
@import './documents/viewer.css';
|
@import './documents/viewer.css';
|
||||||
|
@import './documents/drag-preview.css';
|
||||||
@import './documents/tags-correspondents.css';
|
@import './documents/tags-correspondents.css';
|
||||||
@import './documents/panel-sections.css';
|
@import './documents/panel-sections.css';
|
||||||
@import './sidebar/sidebar.css';
|
@import './sidebar/sidebar.css';
|
||||||
|
|||||||
@@ -98,7 +98,7 @@
|
|||||||
.main-content__actions-divider {
|
.main-content__actions-divider {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: var(--muted);
|
color: var(--muted-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-content__title {
|
.main-content__title {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.22rem;
|
gap: 0.22rem;
|
||||||
padding: 0.32rem 0.48rem;
|
padding: 0.32rem 0.48rem;
|
||||||
border-radius: 8px;
|
border-radius: 0.5rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
transition: background 0.12s ease, color 0.12s ease;
|
transition: background 0.12s ease, color 0.12s ease;
|
||||||
@@ -23,6 +23,11 @@
|
|||||||
-webkit-user-select: none;
|
-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 {
|
.folder-row .name-wrap {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -83,12 +88,12 @@
|
|||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
|
margin-right: 0.3rem;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: opacity 0.12s ease;
|
transition: opacity 0.12s ease;
|
||||||
background-color: var(--surface-overlay);
|
background-color: var(--surface-overlay);
|
||||||
border-radius: 8px;
|
border-radius: 0.2rem;
|
||||||
padding: 0 0.18rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.folder-row__actions .icon-button {
|
.folder-row__actions .icon-button {
|
||||||
@@ -99,6 +104,7 @@
|
|||||||
.folder-row:focus-within .folder-row__actions {
|
.folder-row:focus-within .folder-row__actions {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
|
background-color: var(--surface-overlay);
|
||||||
}
|
}
|
||||||
|
|
||||||
.folder-children {
|
.folder-children {
|
||||||
@@ -229,14 +235,19 @@
|
|||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
box-shadow: 0 12px 28px var(--shadow-strong);
|
box-shadow: 0 12px 28px var(--shadow-strong);
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
z-index: 20;
|
z-index: 2500000;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu[data-floating-position] {
|
||||||
|
top: auto;
|
||||||
|
left: auto;
|
||||||
|
min-width: var(--floating-min-width, 220px);
|
||||||
|
}
|
||||||
|
|
||||||
.menu__list {
|
.menu__list {
|
||||||
max-height: 260px;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 0.35rem 0.5rem;
|
padding: 0.25rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
@@ -263,9 +274,9 @@
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.45rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0.4rem;
|
border-radius: 0.25rem;
|
||||||
background: none;
|
background: none;
|
||||||
color: var(--sidebar-fg);
|
color: var(--sidebar-fg);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
@@ -299,6 +310,7 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu__check-slot {
|
.menu__check-slot {
|
||||||
@@ -321,15 +333,18 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu__footer {
|
.menu__wrapper {
|
||||||
border-top: 1px solid var(--border-muted, var(--border));
|
|
||||||
padding: 0.35rem 0.5rem;
|
padding: 0.35rem 0.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.25rem;
|
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;
|
border-top: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,6 +406,20 @@
|
|||||||
transition: background 0.15s ease, color 0.15s ease;
|
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:hover,
|
||||||
.menu__button:focus-visible {
|
.menu__button:focus-visible {
|
||||||
background: var(--sidebar-hover-bg);
|
background: var(--sidebar-hover-bg);
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ const normalizeEntries = (entries) =>
|
|||||||
|
|
||||||
const ELLIPSIS = { id: '__breadcrumbs_ellipsis__', label: '…', onClick: null, raw: null };
|
const ELLIPSIS = { id: '__breadcrumbs_ellipsis__', label: '…', onClick: null, raw: null };
|
||||||
const WIDTH_TOLERANCE = 1;
|
const WIDTH_TOLERANCE = 1;
|
||||||
const WIDTH_BUFFER_RATIO = 0.99;
|
const WIDTH_BUFFER_RATIO = 0.95;
|
||||||
|
const WIDTH_CHANGE_TOLERANCE = 0.25;
|
||||||
|
|
||||||
const BreadcrumbTrail = ({
|
const BreadcrumbTrail = ({
|
||||||
entries = [],
|
entries = [],
|
||||||
@@ -43,6 +44,7 @@ const BreadcrumbTrail = ({
|
|||||||
const ellipsisButtonRef = useRef(null);
|
const ellipsisButtonRef = useRef(null);
|
||||||
const [availableWidth, setAvailableWidth] = useState(null);
|
const [availableWidth, setAvailableWidth] = useState(null);
|
||||||
const [startIndex, setStartIndex] = useState(0);
|
const [startIndex, setStartIndex] = useState(0);
|
||||||
|
const measureRafRef = useRef(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isOpen: ellipsisMenuOpen,
|
isOpen: ellipsisMenuOpen,
|
||||||
@@ -58,18 +60,13 @@ const BreadcrumbTrail = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setStartIndex(0);
|
|
||||||
closeEllipsisMenu();
|
closeEllipsisMenu();
|
||||||
}, [normalized, shouldTruncateFromStart, closeEllipsisMenu]);
|
}, [normalized, shouldTruncateFromStart, closeEllipsisMenu]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const resolveHost = () => containerRef.current?.parentElement || containerRef.current;
|
||||||
if (!container || typeof ResizeObserver === 'undefined') {
|
const measure = () => {
|
||||||
return undefined;
|
const host = resolveHost();
|
||||||
}
|
|
||||||
|
|
||||||
const updateWidth = () => {
|
|
||||||
const host = containerRef.current;
|
|
||||||
if (!host) {
|
if (!host) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -77,14 +74,56 @@ const BreadcrumbTrail = ({
|
|||||||
if (!nextWidth) {
|
if (!nextWidth) {
|
||||||
return;
|
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);
|
scheduleMeasure();
|
||||||
observer.observe(container.parentElement || container);
|
|
||||||
return () => observer.disconnect();
|
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(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -94,7 +133,8 @@ const BreadcrumbTrail = ({
|
|||||||
|
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
const measurement = measurementRef.current;
|
const measurement = measurementRef.current;
|
||||||
if (!container || !measurement) {
|
const host = container?.parentElement || container;
|
||||||
|
if (!container || !measurement || !host) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,18 +155,15 @@ const BreadcrumbTrail = ({
|
|||||||
for (let start = 0; start < entryNodes.length; start += 1) {
|
for (let start = 0; start < entryNodes.length; start += 1) {
|
||||||
entryNodes.forEach((node, index) => {
|
entryNodes.forEach((node, index) => {
|
||||||
// Hide entries that fall before the visible window.
|
// Hide entries that fall before the visible window.
|
||||||
// eslint-disable-next-line no-param-reassign
|
|
||||||
node.style.display = index < start ? 'none' : '';
|
node.style.display = index < start ? 'none' : '';
|
||||||
});
|
});
|
||||||
|
|
||||||
separatorNodes.forEach((node) => {
|
separatorNodes.forEach((node) => {
|
||||||
const targetIndex = Number(node.getAttribute('data-target-index'));
|
const targetIndex = Number(node.getAttribute('data-target-index'));
|
||||||
// eslint-disable-next-line no-param-reassign
|
|
||||||
node.style.display = targetIndex < Math.max(start, 1) ? 'none' : '';
|
node.style.display = targetIndex < Math.max(start, 1) ? 'none' : '';
|
||||||
});
|
});
|
||||||
|
|
||||||
if (ellipsisNode) {
|
if (ellipsisNode) {
|
||||||
// eslint-disable-next-line no-param-reassign
|
|
||||||
ellipsisNode.style.display = start > 0 ? '' : 'none';
|
ellipsisNode.style.display = start > 0 ? '' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,21 +171,18 @@ const BreadcrumbTrail = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
entryNodes.forEach((node, index) => {
|
entryNodes.forEach((node, index) => {
|
||||||
// eslint-disable-next-line no-param-reassign
|
|
||||||
node.style.display = originalEntryDisplay[index] ?? '';
|
node.style.display = originalEntryDisplay[index] ?? '';
|
||||||
});
|
});
|
||||||
|
|
||||||
separatorNodes.forEach((node, index) => {
|
separatorNodes.forEach((node, index) => {
|
||||||
// eslint-disable-next-line no-param-reassign
|
|
||||||
node.style.display = originalSeparatorDisplay[index] ?? '';
|
node.style.display = originalSeparatorDisplay[index] ?? '';
|
||||||
});
|
});
|
||||||
|
|
||||||
if (ellipsisNode) {
|
if (ellipsisNode) {
|
||||||
// eslint-disable-next-line no-param-reassign
|
|
||||||
ellipsisNode.style.display = originalEllipsisDisplay ?? 'none';
|
ellipsisNode.style.display = originalEllipsisDisplay ?? 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
const available = availableWidth ?? container.getBoundingClientRect().width;
|
const available = availableWidth ?? host.getBoundingClientRect().width;
|
||||||
if (!available || !widths.length) {
|
if (!available || !widths.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -289,33 +323,43 @@ const BreadcrumbTrail = ({
|
|||||||
className="breadcrumb-trail breadcrumb-trail--measure"
|
className="breadcrumb-trail breadcrumb-trail--measure"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<span
|
<button
|
||||||
|
type="button"
|
||||||
className="breadcrumb-trail__link breadcrumb-trail__ellipsis-button"
|
className="breadcrumb-trail__link breadcrumb-trail__ellipsis-button"
|
||||||
data-item-type="ellipsis"
|
data-item-type="ellipsis"
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
{ELLIPSIS.label}
|
{ELLIPSIS.label}
|
||||||
</span>
|
</button>
|
||||||
{measurementEntries.map((entry, index) => (
|
{measurementEntries.map((entry, index) => {
|
||||||
<React.Fragment key={`measure-${entry.id || index}`}>
|
const isLast = index === measurementEntries.length - 1;
|
||||||
{index > 0 ? (
|
const isInteractive = Boolean(entry.onClick) && !isLast;
|
||||||
<span
|
const MeasurementTag = isInteractive ? 'button' : 'span';
|
||||||
className="breadcrumb-trail__separator"
|
|
||||||
data-item-type="separator"
|
return (
|
||||||
data-target-index={index}
|
<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}
|
{entry.label}
|
||||||
</span>
|
</MeasurementTag>
|
||||||
) : null}
|
</React.Fragment>
|
||||||
<span
|
);
|
||||||
className="breadcrumb-trail__link"
|
})}
|
||||||
data-item-type="entry"
|
|
||||||
data-entry-index={index}
|
|
||||||
>
|
|
||||||
{entry.label}
|
|
||||||
</span>
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</span>
|
</span>
|
||||||
{ellipsisMenuOpen
|
{ellipsisMenuOpen
|
||||||
&& hasHiddenEntries
|
&& hasHiddenEntries
|
||||||
@@ -327,6 +371,7 @@ const BreadcrumbTrail = ({
|
|||||||
role="menu"
|
role="menu"
|
||||||
ref={ellipsisMenuRef}
|
ref={ellipsisMenuRef}
|
||||||
style={ellipsisMenuStyle}
|
style={ellipsisMenuStyle}
|
||||||
|
data-floating-position
|
||||||
>
|
>
|
||||||
<div className="menu__list">
|
<div className="menu__list">
|
||||||
{hiddenEntries.map((hiddenEntry) => (
|
{hiddenEntries.map((hiddenEntry) => (
|
||||||
|
|||||||
@@ -144,6 +144,23 @@ const QuickAddMenu = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const canCreate = Boolean(onCreate);
|
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 (
|
return (
|
||||||
<div className={className ? `quick-add ${className}` : 'quick-add'}>
|
<div className={className ? `quick-add ${className}` : 'quick-add'}>
|
||||||
@@ -162,10 +179,11 @@ const QuickAddMenu = ({
|
|||||||
</button>
|
</button>
|
||||||
{isOpen ? (
|
{isOpen ? (
|
||||||
<div
|
<div
|
||||||
className="menu menu--floating quick-add__menu"
|
className={menuClassName}
|
||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
style={menuStyle || undefined}
|
style={menuStyleWithVar}
|
||||||
role="menu"
|
role="menu"
|
||||||
|
data-floating-position
|
||||||
>
|
>
|
||||||
{canCreate ? (
|
{canCreate ? (
|
||||||
<form className="quick-add__form" onSubmit={handleCreate}>
|
<form className="quick-add__form" onSubmit={handleCreate}>
|
||||||
@@ -183,7 +201,7 @@ const QuickAddMenu = ({
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="menu__list quick-add__list" role="presentation">
|
<div className="menu__list" role="presentation">
|
||||||
{filteredOptions.length ? (
|
{filteredOptions.length ? (
|
||||||
filteredOptions.map((option) => {
|
filteredOptions.map((option) => {
|
||||||
const key = option.id ?? option.index;
|
const key = option.id ?? option.index;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
IconChevronRight as TablerChevronRight,
|
IconChevronRight as TablerChevronRight,
|
||||||
IconDownload as TablerDownload,
|
IconDownload as TablerDownload,
|
||||||
|
IconZoomInArea as TablerZoomInArea,
|
||||||
IconPencil,
|
IconPencil,
|
||||||
IconTagFilled,
|
IconTagFilled,
|
||||||
IconUserFilled,
|
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 }) => (
|
export const ViewListIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
<IconLayoutList
|
<IconLayoutList
|
||||||
className={composeClassName('icon', className)}
|
className={composeClassName('icon', className)}
|
||||||
|
|||||||
@@ -28,8 +28,10 @@ const formatStyle = (metrics) => {
|
|||||||
position: metrics.strategy === 'absolute' ? 'absolute' : 'fixed',
|
position: metrics.strategy === 'absolute' ? 'absolute' : 'fixed',
|
||||||
top: metrics.top,
|
top: metrics.top,
|
||||||
left: metrics.left,
|
left: metrics.left,
|
||||||
minWidth: metrics.minWidth,
|
|
||||||
};
|
};
|
||||||
|
if (typeof metrics.minWidth === 'number') {
|
||||||
|
style['--floating-min-width'] = `${Math.max(metrics.minWidth, 0)}px`;
|
||||||
|
}
|
||||||
if (metrics.width) {
|
if (metrics.width) {
|
||||||
style.width = metrics.width;
|
style.width = metrics.width;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export async function openOcrTextInNewTab(options) {
|
|||||||
if (popupAvailable) {
|
if (popupAvailable) {
|
||||||
try {
|
try {
|
||||||
popup.opener = null;
|
popup.opener = null;
|
||||||
} catch (error) {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,7 +83,7 @@ export async function openOcrTextInNewTab(options) {
|
|||||||
if (popupAvailable) {
|
if (popupAvailable) {
|
||||||
try {
|
try {
|
||||||
popup.location.replace(url);
|
popup.location.replace(url);
|
||||||
} catch (navigationError) {
|
} catch {
|
||||||
try {
|
try {
|
||||||
popup.location.href = url;
|
popup.location.href = url;
|
||||||
} catch (secondError) {
|
} catch (secondError) {
|
||||||
@@ -96,7 +96,7 @@ export async function openOcrTextInNewTab(options) {
|
|||||||
if (finalWindow) {
|
if (finalWindow) {
|
||||||
try {
|
try {
|
||||||
finalWindow.opener = null;
|
finalWindow.opener = null;
|
||||||
} catch (error) {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user