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