This commit is contained in:
2025-11-16 11:36:11 +01:00
parent 7e0768a09a
commit dae66703f2
17 changed files with 1110 additions and 160 deletions
+284
View File
@@ -11,6 +11,7 @@
"@fontsource/inter": "^5.2.8", "@fontsource/inter": "^5.2.8",
"@tabler/icons-react": "^3.35.0", "@tabler/icons-react": "^3.35.0",
"axios": "^1.13.2", "axios": "^1.13.2",
"pdfjs-dist": "^5.4.394",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-router-dom": "^7.9.5" "react-router-dom": "^7.9.5"
@@ -24,6 +25,7 @@
"@typescript-eslint/eslint-plugin": "^8.46.4", "@typescript-eslint/eslint-plugin": "^8.46.4",
"@typescript-eslint/parser": "^8.18.1", "@typescript-eslint/parser": "^8.18.1",
"babel-loader": "^10.0.0", "babel-loader": "^10.0.0",
"copy-webpack-plugin": "^13.0.1",
"css-loader": "^7.1.2", "css-loader": "^7.1.2",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"eslint": "^9.39.1", "eslint": "^9.39.1",
@@ -2307,6 +2309,191 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@napi-rs/canvas": {
"version": "0.1.82",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.82.tgz",
"integrity": "sha512-FGjyUBoF0sl1EenSiE4UV2WYu76q6F9GSYedq5EiOCOyGYoQ/Owulcv6rd7v/tWOpljDDtefXXIaOCJrVKem4w==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.82",
"@napi-rs/canvas-darwin-arm64": "0.1.82",
"@napi-rs/canvas-darwin-x64": "0.1.82",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.82",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.82",
"@napi-rs/canvas-linux-arm64-musl": "0.1.82",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.82",
"@napi-rs/canvas-linux-x64-gnu": "0.1.82",
"@napi-rs/canvas-linux-x64-musl": "0.1.82",
"@napi-rs/canvas-win32-x64-msvc": "0.1.82"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.82",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.82.tgz",
"integrity": "sha512-bvZhN0iI54ouaQOrgJV96H2q7J3ZoufnHf4E1fUaERwW29Rz4rgicohnAg4venwBJZYjGl5Yl3CGmlAl1LZowQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.82",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.82.tgz",
"integrity": "sha512-InuBHKCyuFqhNwNr4gpqazo5Xp6ltKflqOLiROn4hqAS8u21xAHyYCJRgHwd+a5NKmutFTaRWeUIT/vxWbU/iw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.82",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.82.tgz",
"integrity": "sha512-aQGV5Ynn96onSXcuvYb2y7TRXD/t4CL2EGmnGqvLyeJX1JLSNisKQlWN/1bPDDXymZYSdUqbXehj5qzBlOx+RQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.82",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.82.tgz",
"integrity": "sha512-YIUpmHWeHGGRhWitT1KJkgj/JPXPfc9ox8oUoyaGPxolLGPp5AxJkq8wIg8CdFGtutget968dtwmx71m8o3h5g==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.82",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.82.tgz",
"integrity": "sha512-AwLzwLBgmvk7kWeUgItOUor/QyG31xqtD26w1tLpf4yE0hiXTGp23yc669aawjB6FzgIkjh1NKaNS52B7/qEBQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.82",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.82.tgz",
"integrity": "sha512-moZWuqepAwWBffdF4JDadt8TgBD02iMhG6I1FHZf8xO20AsIp9rB+p0B8Zma2h2vAF/YMjeFCDmW5un6+zZz9g==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.82",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.82.tgz",
"integrity": "sha512-w9++2df2kG9eC9LWYIHIlMLuhIrKGQYfUxs97CwgxYjITeFakIRazI9LYWgVzEc98QZ9x9GQvlicFsrROV59MQ==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.82",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.82.tgz",
"integrity": "sha512-lZulOPwrRi6hEg/17CaqdwWEUfOlIJuhXxincx1aVzsVOCmyHf+xFq4i6liJl1P+x2v6Iz2Z/H5zHvXJCC7Bwg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.82",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.82.tgz",
"integrity": "sha512-Be9Wf5RTv1w6GXlTph55K3PH3vsAh1Ax4T1FQY1UYM0QfD0yrwGdnJ8/fhqw7dEgMjd59zIbjJQC8C3msbGn5g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.82",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.82.tgz",
"integrity": "sha512-LN/i8VrvxTDmEEK1c10z2cdOTkWT76LlTGtyZe5Kr1sqoSomKeExAjbilnu1+oee5lZUgS5yfZ2LNlVhCeARuw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@nodelib/fs.scandir": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -4524,6 +4711,43 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/copy-webpack-plugin": {
"version": "13.0.1",
"resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-13.0.1.tgz",
"integrity": "sha512-J+YV3WfhY6W/Xf9h+J1znYuqTye2xkBUIGyTPWuBAT27qajBa5mR4f8WBmfDY3YjRftT2kqZZiLi1qf0H+UOFw==",
"dev": true,
"license": "MIT",
"dependencies": {
"glob-parent": "^6.0.1",
"normalize-path": "^3.0.0",
"schema-utils": "^4.2.0",
"serialize-javascript": "^6.0.2",
"tinyglobby": "^0.2.12"
},
"engines": {
"node": ">= 18.12.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
},
"peerDependencies": {
"webpack": "^5.1.0"
}
},
"node_modules/copy-webpack-plugin/node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.3"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/core-js-compat": { "node_modules/core-js-compat": {
"version": "3.46.0", "version": "3.46.0",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.46.0.tgz", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.46.0.tgz",
@@ -8159,6 +8383,18 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/pdfjs-dist": {
"version": "5.4.394",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.394.tgz",
"integrity": "sha512-9ariAYGqUJzx+V/1W4jHyiyCep6IZALmDzoaTLZ6VNu8q9LWi1/ukhzHgE2Xsx96AZi0mbZuK4/ttIbqSbLypg==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.16.0 || >=22.3.0"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.81"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -9905,6 +10141,54 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyglobby/node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/to-regex-range": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+2
View File
@@ -13,6 +13,7 @@
"@fontsource/inter": "^5.2.8", "@fontsource/inter": "^5.2.8",
"@tabler/icons-react": "^3.35.0", "@tabler/icons-react": "^3.35.0",
"axios": "^1.13.2", "axios": "^1.13.2",
"pdfjs-dist": "^5.4.394",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-router-dom": "^7.9.5" "react-router-dom": "^7.9.5"
@@ -26,6 +27,7 @@
"@typescript-eslint/eslint-plugin": "^8.46.4", "@typescript-eslint/eslint-plugin": "^8.46.4",
"@typescript-eslint/parser": "^8.18.1", "@typescript-eslint/parser": "^8.18.1",
"babel-loader": "^10.0.0", "babel-loader": "^10.0.0",
"copy-webpack-plugin": "^13.0.1",
"css-loader": "^7.1.2", "css-loader": "^7.1.2",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"eslint": "^9.39.1", "eslint": "^9.39.1",
+5 -5
View File
@@ -60,7 +60,7 @@ interface UseDocumentPreviewArgs {
interface UseDocumentPreviewResult { interface UseDocumentPreviewResult {
previewEntries: Map<DocumentId, PreviewEntry>; previewEntries: Map<DocumentId, PreviewEntry>;
previewDocuments: Map<DocumentId, DocumentLike>; previewDocuments: Map<DocumentId, DocumentLike>;
ensurePreviewUrl: (documentId: DocumentId, options?: { force?: boolean }) => Promise<PreviewEntry | null>; ensureDownloadUrl: (documentId: DocumentId, options?: { force?: boolean }) => Promise<PreviewEntry | null>;
ensurePreviewData: (documentId: DocumentId) => Promise<DocumentLike | null>; ensurePreviewData: (documentId: DocumentId) => Promise<DocumentLike | null>;
openDocumentPreview: (documentId: DocumentId, options?: { replace?: boolean }) => void; openDocumentPreview: (documentId: DocumentId, options?: { replace?: boolean }) => void;
closeDocumentPreview: (folderId?: FolderId) => void; closeDocumentPreview: (folderId?: FolderId) => void;
@@ -143,7 +143,7 @@ const useDocumentPreview = ({
}); });
}, []); }, []);
const ensurePreviewUrl = useCallback( const ensureDownloadUrl = useCallback(
async (documentId: DocumentId, { force = false }: { force?: boolean } = {}): Promise<PreviewEntry | null> => { async (documentId: DocumentId, { force = false }: { force?: boolean } = {}): Promise<PreviewEntry | null> => {
if (!documentId) return null; if (!documentId) return null;
@@ -231,7 +231,7 @@ const useDocumentPreview = ({
fallbackFolderId === 'root' ? '/documents' : `/documents/folder/${fallbackFolderId}`; fallbackFolderId === 'root' ? '/documents' : `/documents/folder/${fallbackFolderId}`;
} }
await ensurePreviewUrl(documentId, { force: false }); await ensureDownloadUrl(documentId, { force: false });
setActivePreviewId(documentId); setActivePreviewId(documentId);
return doc; return doc;
}, },
@@ -239,7 +239,7 @@ const useDocumentPreview = ({
searchResults, searchResults,
documents, documents,
assetManager, assetManager,
ensurePreviewUrl, ensureDownloadUrl,
setActivePreviewId, setActivePreviewId,
api, api,
cachePreviewDocument, cachePreviewDocument,
@@ -336,7 +336,7 @@ const useDocumentPreview = ({
return { return {
previewEntries, previewEntries,
previewDocuments, previewDocuments,
ensurePreviewUrl, ensureDownloadUrl,
ensurePreviewData, ensurePreviewData,
openDocumentPreview, openDocumentPreview,
closeDocumentPreview, closeDocumentPreview,
+1 -1
View File
@@ -62,7 +62,7 @@ const DesktopPreviewCard = ({
}: DesktopPreviewCardProps): JSX.Element => { }: DesktopPreviewCardProps): JSX.Element => {
const navigator = useAssetNavigator({ const navigator = useAssetNavigator({
document: doc, document: doc,
assetType: 'preview', assetType: 'thumbnail',
ensureAssetUrl: shouldLoad ? ensureAssetUrl : null, ensureAssetUrl: shouldLoad ? ensureAssetUrl : null,
getAsset: getDocumentAsset, getAsset: getDocumentAsset,
prefetch, prefetch,
+96 -34
View File
@@ -8,6 +8,7 @@ import React, {
useSyncExternalStore, useSyncExternalStore,
} from 'react'; } from 'react';
import { resolveDocumentAssetUrl } from '../asset_manager'; import { resolveDocumentAssetUrl } from '../asset_manager';
import type { EnsureAssetUrl, GetAsset } from '../asset_manager';
import { formatTransform } from './math'; import { formatTransform } from './math';
import useDocumentDrag from './useDocumentDrag'; import useDocumentDrag from './useDocumentDrag';
import PreviewZoomOverlay from '../detail/PreviewZoomOverlay'; import PreviewZoomOverlay from '../detail/PreviewZoomOverlay';
@@ -31,6 +32,8 @@ import '../styles/workspace/workspace-cards.css';
type Identifier = string | number; type Identifier = string | number;
type TagLike = { id?: Identifier | null; label?: string; color?: string | null } | null; type TagLike = { id?: Identifier | null; label?: string; color?: string | null } | null;
type PreviewEntryLike = { url?: string | null; contentType?: string | null };
type OverlaySource = { url: string; alt?: string | null; contentType?: string | null };
export interface DeskDocument { export interface DeskDocument {
id?: Identifier | null; id?: Identifier | null;
@@ -49,6 +52,7 @@ interface NavigatorSnapshot {
ordinal?: number | null; ordinal?: number | null;
width?: number | null; width?: number | null;
height?: number | null; height?: number | null;
contentType?: string | null;
} }
type OverlayOriginHint = { type OverlayOriginHint = {
@@ -124,8 +128,8 @@ interface DesktopWorkspaceProps {
onDocumentStackSelect?: (docIds: Identifier[]) => void; onDocumentStackSelect?: (docIds: Identifier[]) => void;
onPromoteSelection?: (...args: unknown[]) => void; onPromoteSelection?: (...args: unknown[]) => void;
onAssignTagToDocument?: (...args: unknown[]) => void; onAssignTagToDocument?: (...args: unknown[]) => void;
ensureAssetUrl?: (...args: unknown[]) => Promise<unknown> | unknown; ensureAssetUrl?: EnsureAssetUrl;
getDocumentAsset?: (...args: unknown[]) => unknown; getDocumentAsset?: GetAsset;
activeTagIds?: Array<Identifier | null>; activeTagIds?: Array<Identifier | null>;
selectedDocumentIds?: Identifier[]; selectedDocumentIds?: Identifier[];
onClearSelection?: () => void; onClearSelection?: () => void;
@@ -193,6 +197,8 @@ interface DesktopWorkspaceViewProps {
const DEBUG_DRAG = false; const DEBUG_DRAG = false;
const DEBUG_FOCUS = false; const DEBUG_FOCUS = false;
const defaultGetDocumentAsset: GetAsset = () => null;
const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
documents = [], documents = [],
searchResults = null, searchResults = null,
@@ -202,7 +208,7 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
onPromoteSelection = null, onPromoteSelection = null,
onAssignTagToDocument = null, onAssignTagToDocument = null,
ensureAssetUrl = null, ensureAssetUrl = null,
getDocumentAsset = () => null, getDocumentAsset = defaultGetDocumentAsset,
activeTagIds = [], activeTagIds = [],
selectedDocumentIds = [], selectedDocumentIds = [],
onClearSelection = null, onClearSelection = null,
@@ -210,6 +216,8 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
onCloseDetailPanel = null, onCloseDetailPanel = null,
tenantId = null, tenantId = null,
viewId = 'default', viewId = 'default',
previewEntries,
ensureDownloadUrl,
}) => { }) => {
const items = useMemo<DeskDocument[]>( const items = useMemo<DeskDocument[]>(
() => (searchResults ? searchResults : documents), () => (searchResults ? searchResults : documents),
@@ -217,6 +225,7 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
); );
const allowLayoutPersistence = Boolean(tenantId && viewId && viewId.startsWith('folder:')); const allowLayoutPersistence = Boolean(tenantId && viewId && viewId.startsWith('folder:'));
const previewEntryMap = previewEntries instanceof Map ? previewEntries : null;
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const itemRefs = useRef<Map<string, HTMLDivElement | null>>(new Map()); const itemRefs = useRef<Map<string, HTMLDivElement | null>>(new Map());
@@ -226,9 +235,8 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
const [overlayOriginTransform, setOverlayOriginTransform] = useState<OverlayOriginTransform | null>( const [overlayOriginTransform, setOverlayOriginTransform] = useState<OverlayOriginTransform | null>(
null, null,
); );
const [previewSnapshots, setPreviewSnapshots] = useState<Map<string, NavigatorSnapshot>>( const [overlaySource, setOverlaySource] = useState<OverlaySource | null>(null);
() => new Map(), const [, setPreviewSnapshots] = useState<Map<string, NavigatorSnapshot>>(() => new Map());
);
const [docSizeVersion, setDocSizeVersion] = useState(0); const [docSizeVersion, setDocSizeVersion] = useState(0);
const docSizeMapRef = useRef<Map<string, DocumentSizeInfo>>(new Map()); const docSizeMapRef = useRef<Map<string, DocumentSizeInfo>>(new Map());
const ensureDocumentSize = useCallback((doc: DeskDocument | null): DocumentSizeInfo | null => { const ensureDocumentSize = useCallback((doc: DeskDocument | null): DocumentSizeInfo | null => {
@@ -444,7 +452,7 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
if (!doc) { if (!doc) {
return; return;
} }
resolveDocumentAssetUrl(doc, 'preview', { resolveDocumentAssetUrl(doc, 'thumbnail', {
ensureAssetUrl, ensureAssetUrl,
getAsset: getDocumentAsset, getAsset: getDocumentAsset,
}); });
@@ -546,30 +554,77 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
} }
}, [items, previewMetadata]); }, [items, previewMetadata]);
const overlayDisplay = useMemo<OverlayDisplay | null>(() => { useEffect(() => {
let cancelled = false;
if (!overlayDocId) { if (!overlayDocId) {
return null; setOverlaySource(null);
} return () => {
const snapshot = previewSnapshots.get(overlayDocId); cancelled = true;
if (!snapshot || !snapshot.url) {
return null;
}
const doc = documentLookup.get(overlayDocId);
const alt = snapshot.alt || (doc?.title as string | undefined);
return {
url: snapshot.url,
alt,
canGoPrev: snapshot.canGoPrev,
canGoNext: snapshot.canGoNext,
goPrev: snapshot.goPrev,
goNext: snapshot.goNext,
}; };
}, [overlayDocId, previewSnapshots, documentLookup]); }
const doc = documentLookup.get(overlayDocId) || null;
const docIdentifier = doc?.id ?? null;
if (!docIdentifier || !doc) {
setOverlaySource(null);
return () => {
cancelled = true;
};
}
const docContentType = doc?.content_type ?? null;
const versionContentType = (doc?.current_version as { version?: { content_type?: string | null } } | null)?.version?.content_type ?? null;
const applyEntry = (entry?: PreviewEntryLike | null) => {
if (!entry?.url) {
setOverlaySource(null);
return;
}
setOverlaySource({
url: entry.url,
alt: doc.title as string | undefined,
contentType: entry.contentType || docContentType || versionContentType || undefined,
});
};
const cachedEntry = previewEntryMap?.get(docIdentifier) || null;
if (cachedEntry?.url) {
applyEntry(cachedEntry);
return () => {
cancelled = true;
};
}
if (!ensureDownloadUrl) {
setOverlaySource(null);
return () => {
cancelled = true;
};
}
ensureDownloadUrl(docIdentifier)
.then((entry) => {
if (cancelled) {
return;
}
applyEntry(entry);
})
.catch(() => {
if (!cancelled) {
setOverlaySource(null);
}
});
return () => {
cancelled = true;
};
}, [overlayDocId, documentLookup, previewEntryMap, ensureDownloadUrl]);
const closeOverlay = useCallback(() => { const closeOverlay = useCallback(() => {
setOverlayDocId(null); setOverlayDocId(null);
setOverlayOriginRect(null); setOverlayOriginRect(null);
setOverlayOriginTransform(null); setOverlayOriginTransform(null);
setOverlaySource(null);
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -611,24 +666,32 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
} }
}, [draggingId, items, setDraggingId]); }, [draggingId, items, setDraggingId]);
const overlayDisplay = useMemo<OverlayDisplay | null>(() => {
if (!overlaySource) {
return null;
}
return {
...overlaySource,
canGoPrev: false,
canGoNext: false,
};
}, [overlaySource]);
const openOverlayForDoc = useCallback( const openOverlayForDoc = useCallback(
(docId: Identifier | null, originInfo: OverlayOriginHint | null = null) => { (docId: Identifier | null, originInfo: OverlayOriginHint | null = null) => {
if (!docId) { if (!docId) {
return; return;
} }
const docKey = String(docId); const docKey = String(docId);
const snapshot = previewSnapshots.get(docKey);
if (!snapshot || !snapshot.url) {
return;
}
const container = itemRefs.current.get(docKey); const container = itemRefs.current.get(docKey);
const imageNode = container if (!container) {
? container.querySelector<HTMLImageElement>('.desk-item__card img') return;
: null; }
if (!container || !imageNode) { const imageNode = container.querySelector<HTMLImageElement>('.desk-item__card img');
const rect = (imageNode || container).getBoundingClientRect();
if (!rect) {
return; return;
} }
const rect = imageNode.getBoundingClientRect();
let originTransform = null; let originTransform = null;
if (originInfo) { if (originInfo) {
const { rotation = 0, scale = 1, width: originWidth, height: originHeight } = originInfo; const { rotation = 0, scale = 1, width: originWidth, height: originHeight } = originInfo;
@@ -666,7 +729,6 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
}, },
[ [
bringToFront, bringToFront,
previewSnapshots,
itemRefs, itemRefs,
setOverlayOriginTransform, setOverlayOriginTransform,
ensureDocumentSize, ensureDocumentSize,
@@ -61,6 +61,8 @@ interface UseDeskWorkspacePropsArgs {
searchQuery?: string; searchQuery?: string;
activeCorrespondentFilters?: Identifier[]; activeCorrespondentFilters?: Identifier[];
selectedFolder?: Identifier | string | null; selectedFolder?: Identifier | string | null;
previewEntries?: Map<Identifier, { url?: string | null; contentType?: string | null }>;
ensureDownloadUrl?: (documentId: Identifier, options?: { force?: boolean }) => Promise<{ url?: string | null; contentType?: string | null } | null>;
} }
const useDeskWorkspaceProps = ({ const useDeskWorkspaceProps = ({
@@ -108,6 +110,8 @@ const useDeskWorkspaceProps = ({
searchQuery = '', searchQuery = '',
activeCorrespondentFilters = [], activeCorrespondentFilters = [],
selectedFolder, selectedFolder,
previewEntries,
ensureDownloadUrl,
}: UseDeskWorkspacePropsArgs) => { }: UseDeskWorkspacePropsArgs) => {
const handleDeskDocumentStackSelect: DeskDocumentStackSelectHandler = useCallback( const handleDeskDocumentStackSelect: DeskDocumentStackSelectHandler = useCallback(
(docIds) => { (docIds) => {
@@ -200,6 +204,8 @@ const useDeskWorkspaceProps = ({
onMoveDocumentsToFolder: moveDocumentsToFolder, onMoveDocumentsToFolder: moveDocumentsToFolder,
searchIncludeDescendants, searchIncludeDescendants,
onToggleSearchIncludeDescendants: toggleSearchIncludeDescendants, onToggleSearchIncludeDescendants: toggleSearchIncludeDescendants,
previewEntries,
ensureDownloadUrl,
}), }),
[ [
documents, documents,
@@ -240,6 +246,8 @@ const useDeskWorkspaceProps = ({
moveDocumentsToFolder, moveDocumentsToFolder,
searchIncludeDescendants, searchIncludeDescendants,
toggleSearchIncludeDescendants, toggleSearchIncludeDescendants,
previewEntries,
ensureDownloadUrl,
], ],
); );
}; };
+81 -26
View File
@@ -1,11 +1,14 @@
import React, { CSSProperties, useEffect, useRef, useState } from 'react'; import React, { CSSProperties, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons'; import { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons';
import { clamp } from '../utils/math'; import { clamp } from '../utils/math';
import PdfViewer from '../preview/PdfViewer';
type PreviewNavigator = { type PreviewNavigator = {
url: string; url: string;
alt?: string; alt?: string;
contentType?: string | null;
pageNumber?: number | null;
canGoPrev?: boolean; canGoPrev?: boolean;
canGoNext?: boolean; canGoNext?: boolean;
goPrev?: () => void; goPrev?: () => void;
@@ -20,6 +23,22 @@ interface PreviewZoomOverlayProps {
type NaturalSize = { width: number | null; height: number | null }; type NaturalSize = { width: number | null; height: number | null };
type FocusPoint = { xRatio: number; yRatio: number } | null; type FocusPoint = { xRatio: number; yRatio: number } | null;
type DisplayKind = 'image' | 'pdf';
const determineDisplayKind = (entry?: PreviewNavigator | null): DisplayKind => {
const type = entry?.contentType?.toLowerCase?.() || '';
if (type.includes('pdf')) {
return 'pdf';
}
if (type.startsWith('image/')) {
return 'image';
}
const url = entry?.url?.toLowerCase?.() || '';
if (url.endsWith('.pdf')) {
return 'pdf';
}
return 'image';
};
const noop = () => {}; const noop = () => {};
@@ -35,7 +54,7 @@ const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
const [isBackdropVisible, setBackdropVisible] = useState(false); const [isBackdropVisible, setBackdropVisible] = useState(false);
const [displaySnapshot, setDisplaySnapshot] = useState<PreviewNavigator | null>(null); const [displaySnapshot, setDisplaySnapshot] = useState<PreviewNavigator | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null); const scrollRef = useRef<HTMLDivElement | null>(null);
const imageRef = useRef<HTMLImageElement | null>(null); const mediaRef = useRef<HTMLImageElement | null>(null);
const focusRef = useRef<FocusPoint>(null); const focusRef = useRef<FocusPoint>(null);
const previouslyFocusedRef = useRef<HTMLElement | null>(null); const previouslyFocusedRef = useRef<HTMLElement | null>(null);
const visibilityTimerRef = useRef<number | null>(null); const visibilityTimerRef = useRef<number | null>(null);
@@ -47,6 +66,10 @@ const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
} }
}, [display]); }, [display]);
const activeDisplay = open && display?.url ? display : displaySnapshot;
const displayKind = useMemo(() => determineDisplayKind(activeDisplay), [activeDisplay]);
const isPdfDisplay = displayKind === 'pdf';
useEffect(() => { useEffect(() => {
if (visibilityTimerRef.current) { if (visibilityTimerRef.current) {
clearTimeout(visibilityTimerRef.current); clearTimeout(visibilityTimerRef.current);
@@ -94,6 +117,9 @@ const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
} }
}, []); }, []);
useEffect(() => {
}, [isPdfDisplay, open, activeDisplay?.url]);
useEffect(() => { useEffect(() => {
setIsNativeScale(false); setIsNativeScale(false);
setNaturalSize({ width: null, height: null }); setNaturalSize({ width: null, height: null });
@@ -103,7 +129,7 @@ const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
scrollEl.scrollLeft = 0; scrollEl.scrollLeft = 0;
scrollEl.scrollTop = 0; scrollEl.scrollTop = 0;
} }
}, [open]); }, [open, activeDisplay?.url, displayKind]);
useEffect(() => { useEffect(() => {
@@ -112,13 +138,13 @@ const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
} }
const scrollEl = scrollRef.current; const scrollEl = scrollRef.current;
const imageEl = imageRef.current; const mediaEl = mediaRef.current;
if (!scrollEl || !imageEl) { if (!scrollEl || !mediaEl) {
return; return;
} }
const imageWidth = imageEl.naturalWidth || imageEl.clientWidth; const imageWidth = naturalSize.width || mediaEl.clientWidth;
const imageHeight = imageEl.naturalHeight || imageEl.clientHeight; const imageHeight = naturalSize.height || mediaEl.clientHeight;
if (!(imageWidth > 0 && imageHeight > 0)) { if (!(imageWidth > 0 && imageHeight > 0)) {
return; return;
} }
@@ -145,8 +171,6 @@ const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
previouslyFocusedRef.current = active instanceof HTMLElement ? active : null; previouslyFocusedRef.current = active instanceof HTMLElement ? active : null;
}, [open]); }, [open]);
const activeDisplay = open && display?.url ? display : displaySnapshot;
useEffect(() => { useEffect(() => {
if (!activeDisplay?.url) { if (!activeDisplay?.url) {
return; return;
@@ -222,10 +246,13 @@ const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
}; };
const toggleZoomAtPoint = (clientX: number, clientY: number) => { const toggleZoomAtPoint = (clientX: number, clientY: number) => {
const img = imageRef.current; if (isPdfDisplay) {
return;
}
const media = mediaRef.current;
setIsNativeScale((current) => { setIsNativeScale((current) => {
if (!current && img) { if (!current && media) {
const rect = img.getBoundingClientRect(); const rect = media.getBoundingClientRect();
const xRatio = rect.width > 0 ? (clientX - rect.left) / rect.width : 0.5; const xRatio = rect.width > 0 ? (clientX - rect.left) / rect.width : 0.5;
const yRatio = rect.height > 0 ? (clientY - rect.top) / rect.height : 0.5; const yRatio = rect.height > 0 ? (clientY - rect.top) / rect.height : 0.5;
focusRef.current = { focusRef.current = {
@@ -239,22 +266,24 @@ const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
}); });
}; };
const handleImageClick = (event: React.MouseEvent<HTMLImageElement>) => { const handleContentClick = (event: React.MouseEvent<HTMLElement>) => {
event.stopPropagation(); event.stopPropagation();
if (isPdfDisplay) {
return;
}
toggleZoomAtPoint(event.clientX ?? 0, event.clientY ?? 0); toggleZoomAtPoint(event.clientX ?? 0, event.clientY ?? 0);
}; };
if (!renderBackdrop || !activeDisplay?.url) { const shouldRender = renderBackdrop && Boolean(activeDisplay?.url);
return null;
}
const effectiveDisplay = activeDisplay; const stageClassName = isPdfDisplay
const navVisible = Boolean(effectiveDisplay?.canGoPrev || effectiveDisplay?.canGoNext); ? 'preview-zoom__stage preview-zoom__stage--pdf'
const stageClassName = 'preview-zoom__stage'; : 'preview-zoom__stage';
const containerClassName = [ const containerClassName = [
'preview-zoom__scroll', 'preview-zoom__scroll',
isNativeScale ? 'preview-zoom__scroll--native' : '', isNativeScale ? 'preview-zoom__scroll--native' : null,
isPdfDisplay ? 'preview-zoom__scroll--pdf' : null,
] ]
.filter(Boolean) .filter(Boolean)
.join(' '); .join(' ');
@@ -266,7 +295,7 @@ const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
.filter(Boolean) .filter(Boolean)
.join(' '); .join(' ');
const imageStyle: CSSProperties = isNativeScale const contentStyle: CSSProperties = isNativeScale
? { ? {
cursor: 'zoom-out', cursor: 'zoom-out',
width: naturalSize.width ? `${naturalSize.width}px` : 'auto', width: naturalSize.width ? `${naturalSize.width}px` : 'auto',
@@ -282,6 +311,13 @@ const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
touchAction: 'manipulation', touchAction: 'manipulation',
}; };
if (!shouldRender) {
return null;
}
const effectiveDisplay = activeDisplay;
const navVisible = Boolean(effectiveDisplay?.canGoPrev || effectiveDisplay?.canGoNext);
return createPortal( return createPortal(
( (
<div <div
@@ -290,19 +326,37 @@ const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
aria-modal="true" aria-modal="true"
aria-label="Enlarged document preview" aria-label="Enlarged document preview"
onClick={onClose} onClick={onClose}
onKeyDown={handleKeyDown}
>
<div
className={stageClassName}
onClick={(event) => {
if (event.target === event.currentTarget) {
onClose();
}
}}
> >
<div className={stageClassName} onKeyDown={handleKeyDown}>
<div <div
className={containerClassName} className={containerClassName}
ref={scrollRef} ref={scrollRef}
tabIndex={-1} tabIndex={-1}
onClick={(event) => event.stopPropagation()}
> >
{isPdfDisplay ? (
<div className="preview-zoom__pdf">
<PdfViewer
src={effectiveDisplay.url}
title={effectiveDisplay.alt || 'Document preview'}
className="preview-zoom__pdf-viewer"
/>
</div>
) : (
<img <img
src={effectiveDisplay.url} src={effectiveDisplay.url}
alt={effectiveDisplay.alt || 'Document preview'} alt={effectiveDisplay.alt || 'Document preview'}
className="preview-zoom__image" className="preview-zoom__image"
ref={imageRef} ref={(node) => {
mediaRef.current = node;
}}
draggable={false} draggable={false}
onLoad={(event) => { onLoad={(event) => {
setNaturalSize({ setNaturalSize({
@@ -310,9 +364,10 @@ const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
height: event.currentTarget.naturalHeight || null, height: event.currentTarget.naturalHeight || null,
}); });
}} }}
onClick={handleImageClick} onClick={handleContentClick}
style={imageStyle} style={contentStyle}
/> />
)}
</div> </div>
{navVisible ? ( {navVisible ? (
<div className="preview-zoom__nav"> <div className="preview-zoom__nav">
@@ -2,6 +2,11 @@ import { useMemo } from 'react';
type Identifier = string | number; type Identifier = string | number;
interface PreviewEntryLike {
url?: string | null;
contentType?: string | null;
}
export interface Breadcrumb { export interface Breadcrumb {
id?: Identifier; id?: Identifier;
name?: string; name?: string;
@@ -65,6 +70,8 @@ export interface UseDocumentsPanelPropsArgs {
handleBulkSelectionReanalyze?: (...args: unknown[]) => void; handleBulkSelectionReanalyze?: (...args: unknown[]) => void;
folderOptions?: unknown[]; folderOptions?: unknown[];
moveDocumentsToFolder?: (...args: unknown[]) => void; moveDocumentsToFolder?: (...args: unknown[]) => void;
previewEntries?: Map<Identifier, PreviewEntryLike>;
ensureDownloadUrl?: (documentId: Identifier, options?: { force?: boolean }) => Promise<PreviewEntryLike | null>;
} }
export type DocumentsPanelProps = ReturnType<typeof useDocumentsPanelProps>; export type DocumentsPanelProps = ReturnType<typeof useDocumentsPanelProps>;
@@ -119,6 +126,8 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => {
handleBulkSelectionReanalyze, handleBulkSelectionReanalyze,
folderOptions, folderOptions,
moveDocumentsToFolder, moveDocumentsToFolder,
previewEntries,
ensureDownloadUrl,
} = props; } = props;
return useMemo( return useMemo(
@@ -173,6 +182,8 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => {
onBulkReanalyze: handleBulkSelectionReanalyze, onBulkReanalyze: handleBulkSelectionReanalyze,
folderOptions, folderOptions,
onMoveDocumentsToFolder: moveDocumentsToFolder, onMoveDocumentsToFolder: moveDocumentsToFolder,
previewEntries,
ensureDownloadUrl,
}), }),
[ [
activeCorrespondentFilters, activeCorrespondentFilters,
@@ -223,6 +234,8 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => {
ensureAssetUrl, ensureAssetUrl,
getDocumentAsset, getDocumentAsset,
folderOptions, folderOptions,
previewEntries,
ensureDownloadUrl,
], ],
); );
}; };
+70 -33
View File
@@ -5,7 +5,6 @@ import DocumentsGrid from '../DocumentsGrid';
import DocumentsList from '../DocumentsList'; import DocumentsList from '../DocumentsList';
import { isTagTransferEvent } from '../tagTransfer'; import { isTagTransferEvent } from '../tagTransfer';
import PreviewZoomOverlay from '../../detail/PreviewZoomOverlay'; import PreviewZoomOverlay from '../../detail/PreviewZoomOverlay';
import { useAssetNavigator } from '../../hooks/useAssetNavigator';
import { isPointerModifierEvent, isPrimaryPointerEvent } from '../useEntryPointer'; import { isPointerModifierEvent, isPrimaryPointerEvent } from '../useEntryPointer';
import { useWorkspaceSelectionContext } from '../../app/WorkspaceSelectionContext'; import { useWorkspaceSelectionContext } from '../../app/WorkspaceSelectionContext';
@@ -22,6 +21,8 @@ interface DocumentsPanelProps {
const defaultGetDocumentAsset = (_doc?: unknown, _type?: string) => null; const defaultGetDocumentAsset = (_doc?: unknown, _type?: string) => null;
export type PreviewEntryLike = { url?: string | null; contentType?: string | null };
const DocumentsPanel: React.FC<DocumentsPanelProps> = ({ const DocumentsPanel: React.FC<DocumentsPanelProps> = ({
currentFolderName, currentFolderName,
breadcrumbs, breadcrumbs,
@@ -55,6 +56,8 @@ const DocumentsPanel: React.FC<DocumentsPanelProps> = ({
viewMode = 'list', viewMode = 'list',
onViewModeChange, onViewModeChange,
showHeader = true, showHeader = true,
previewEntries,
ensureDownloadUrl,
}) => { }) => {
const { const {
selectedEntries, selectedEntries,
@@ -65,6 +68,7 @@ const DocumentsPanel: React.FC<DocumentsPanelProps> = ({
} = useWorkspaceSelectionContext(); } = useWorkspaceSelectionContext();
const showingSearchResults = searchResults !== null; const showingSearchResults = searchResults !== null;
const rows = showingSearchResults ? searchResults : documents; const rows = showingSearchResults ? searchResults : documents;
const previewEntryMap = previewEntries instanceof Map ? previewEntries : null;
const currentFolderId = useMemo(() => { const currentFolderId = useMemo(() => {
if (showingSearchResults) { if (showingSearchResults) {
@@ -138,7 +142,10 @@ const DocumentsPanel: React.FC<DocumentsPanelProps> = ({
const isGridView = viewMode === 'grid'; const isGridView = viewMode === 'grid';
const isDeskView = viewMode === 'desk'; const isDeskView = viewMode === 'desk';
const [previewDocId, setPreviewDocId] = useState(null); type Identifier = string | number;
type ZoomSource = { url: string; alt?: string | null; contentType?: string | null };
const [previewDocId, setPreviewDocId] = useState<Identifier | null>(null);
const previewDoc = useMemo(() => { const previewDoc = useMemo(() => {
if (!previewDocId) { if (!previewDocId) {
@@ -153,38 +160,69 @@ const DocumentsPanel: React.FC<DocumentsPanelProps> = ({
} }
}, [previewDocId, previewDoc]); }, [previewDocId, previewDoc]);
const previewNavigator = useAssetNavigator({ const [previewZoomSource, setPreviewZoomSource] = useState<ZoomSource | null>(null);
document: previewDoc, const zoomDisplay = previewZoomSource;
assetType: 'preview',
ensureAssetUrl, useEffect(() => {
getAsset: getDocumentAsset, let cancelled = false;
prefetch: 3, if (!previewDocId || !previewDoc) {
setPreviewZoomSource(null);
return () => {
cancelled = true;
};
}
const docContentType = previewDoc.content_type;
const versionContentType = previewDoc.current_version?.version?.content_type;
const contentFallback = docContentType || versionContentType || null;
const applyEntry = (entry?: PreviewEntryLike | null) => {
if (!entry?.url) {
setPreviewZoomSource(null);
return;
}
setPreviewZoomSource({
url: entry.url,
alt: previewDoc.title,
contentType: entry.contentType || contentFallback || undefined,
});
};
const cachedEntry = previewEntryMap?.get(previewDocId) || null;
if (cachedEntry?.url) {
applyEntry(cachedEntry);
return () => {
cancelled = true;
};
}
if (!ensureDownloadUrl) {
setPreviewZoomSource(null);
return () => {
cancelled = true;
};
}
ensureDownloadUrl(previewDocId)
.then((entry) => {
if (cancelled) {
return;
}
applyEntry(entry);
})
.catch(() => {
if (!cancelled) {
setPreviewZoomSource(null);
}
}); });
const { return () => {
currentUrl: previewUrl, cancelled = true;
canGoPrev: previewCanGoPrev,
canGoNext: previewCanGoNext,
goPrev: previewGoPrev,
goNext: previewGoNext,
} = previewNavigator;
const previewDisplay = useMemo(() => {
if (!previewDoc || !previewUrl) {
return null;
}
return {
url: previewUrl,
alt: previewDoc.title,
canGoPrev: Boolean(previewCanGoPrev),
canGoNext: Boolean(previewCanGoNext),
goPrev: previewGoPrev,
goNext: previewGoNext,
}; };
}, [previewDoc, previewUrl, previewCanGoPrev, previewCanGoNext, previewGoPrev, previewGoNext]); }, [previewDocId, previewDoc, previewEntryMap, ensureDownloadUrl]);
const closePreviewOverlay = useCallback(() => { const closePreviewOverlay = useCallback(() => {
setPreviewDocId(null); setPreviewDocId(null);
setPreviewZoomSource(null);
}, []); }, []);
const handleDocumentPreviewZoom = useCallback( const handleDocumentPreviewZoom = useCallback(
@@ -192,13 +230,12 @@ const DocumentsPanel: React.FC<DocumentsPanelProps> = ({
if (!doc || !doc.id) { if (!doc || !doc.id) {
return; return;
} }
const previewAsset = getDocumentAsset(doc, 'preview'); if (!ensureDownloadUrl && !(previewEntryMap?.get(doc.id)?.url)) {
if (!previewAsset) {
return; return;
} }
setPreviewDocId(doc.id); setPreviewDocId(doc.id);
}, },
[getDocumentAsset], [ensureDownloadUrl, previewEntryMap],
); );
const handleDocumentActivate = useCallback( const handleDocumentActivate = useCallback(
@@ -724,8 +761,8 @@ const DocumentsPanel: React.FC<DocumentsPanelProps> = ({
)} )}
</section> </section>
<PreviewZoomOverlay <PreviewZoomOverlay
open={Boolean(previewDocId)} open={Boolean(zoomDisplay?.url)}
display={previewDisplay} display={zoomDisplay}
onClose={closePreviewOverlay} onClose={closePreviewOverlay}
/> />
</> </>
@@ -382,6 +382,7 @@ const useDocumentsWorkspace = ({
previewEntries, previewEntries,
previewDocuments, previewDocuments,
ensurePreviewData, ensurePreviewData,
ensureDownloadUrl,
openDocumentPreview, openDocumentPreview,
closeDocumentPreview, closeDocumentPreview,
resetPreviewState, resetPreviewState,
@@ -1452,6 +1453,8 @@ const useDocumentsWorkspace = ({
folderOptions, folderOptions,
moveDocumentsToFolder, moveDocumentsToFolder,
selectFolder, selectFolder,
previewEntries,
ensureDownloadUrl,
}); });
const sidebarProps = useSidebarProps({ const sidebarProps = useSidebarProps({
@@ -1539,6 +1542,8 @@ const useDocumentsWorkspace = ({
searchQuery, searchQuery,
activeCorrespondentFilters, activeCorrespondentFilters,
selectedFolder, selectedFolder,
previewEntries,
ensureDownloadUrl,
}); });
const contextValue = useMemo( const contextValue = useMemo(
@@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react';
import type { JSX } from 'react'; import type { JSX } from 'react';
import DocumentInfoPanel from '../documents/DocumentInfoPanel'; import DocumentInfoPanel from '../documents/DocumentInfoPanel';
import { DownloadIcon } from '../ui/icons'; import { DownloadIcon } from '../ui/icons';
import PdfViewer from './PdfViewer';
interface DocumentLike { interface DocumentLike {
id?: string | number; id?: string | number;
@@ -87,11 +88,13 @@ const DocumentViewerLayout = ({
} }
if (isPdf) { if (isPdf) {
const documentTitle = document.title
|| document.filename
|| document.original_name;
return ( return (
<iframe <PdfViewer
src={previewEntry.url} src={previewEntry.url}
title={`Preview of ${document.title}`} title={documentTitle ? `Preview of ${documentTitle}` : undefined}
className="document-viewer__object"
/> />
); );
} }
+12 -36
View File
@@ -25,7 +25,6 @@ import { createDocumentActionState } from '../documents/documentActions';
import { resolveDocumentAssetUrl } from '../asset_manager'; import { resolveDocumentAssetUrl } from '../asset_manager';
import PanelHeader from '../ui/PanelHeader'; import PanelHeader from '../ui/PanelHeader';
import BreadcrumbTrail from '../ui/BreadcrumbTrail'; import BreadcrumbTrail from '../ui/BreadcrumbTrail';
import { useAssetNavigator } from '../hooks/useAssetNavigator';
import DocumentViewerLayout from './DocumentViewerLayout'; import DocumentViewerLayout from './DocumentViewerLayout';
import useViewerLayoutMode from './useViewerLayoutMode'; import useViewerLayoutMode from './useViewerLayoutMode';
import { usePanelResizeBindings } from '../app/PanelManagerContext'; import { usePanelResizeBindings } from '../app/PanelManagerContext';
@@ -33,8 +32,13 @@ import { usePanelResizeBindings } from '../app/PanelManagerContext';
interface DocumentLike { interface DocumentLike {
id?: string | number; id?: string | number;
title?: string; title?: string;
content_type?: string | null;
issued_at?: string | null; issued_at?: string | null;
correspondents?: Array<{ id?: string | number; name?: string }>; correspondents?: Array<{ id?: string | number; name?: string }>;
current_version?: {
version_number?: number;
version?: { content_type?: string | null } | null;
} | null;
[key: string]: unknown; [key: string]: unknown;
} }
@@ -245,18 +249,6 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
); );
const [zoomOverlayOpen, setZoomOverlayOpen] = useState(false); 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(() => { const handleZoomOpen = useCallback(() => {
if (!previewEntry?.url) { if (!previewEntry?.url) {
@@ -360,34 +352,18 @@ const panelRef = useRef<HTMLDivElement | HTMLFormElement | HTMLElement | null>(n
}, [breadcrumbs, handleBreadcrumbNavigate]); }, [breadcrumbs, handleBreadcrumbNavigate]);
const zoomDisplay = useMemo(() => { const zoomDisplay = useMemo(() => {
if (navigatorUrl && document) { if (!previewEntry?.url || !document) {
return { return null;
url: navigatorUrl,
alt: document.title,
canGoPrev: navigatorCanGoPrev,
canGoNext: navigatorCanGoNext,
goPrev: navigatorCanGoPrev ? navigatorGoPrev : undefined,
goNext: navigatorCanGoNext ? navigatorGoNext : undefined,
};
} }
if (previewEntry?.url && document) { const docContentType = document.content_type;
const versionContentType = document.current_version?.version?.content_type;
const normalizedContentType = previewEntry.contentType || docContentType || versionContentType || null;
return { return {
url: previewEntry.url, url: previewEntry.url,
alt: document.title, alt: document.title,
canGoPrev: false, contentType: normalizedContentType || undefined,
canGoNext: false,
}; };
} }, [previewEntry?.url, previewEntry?.contentType, document]);
return null;
}, [
navigatorUrl,
navigatorCanGoPrev,
navigatorCanGoNext,
navigatorGoPrev,
navigatorGoNext,
previewEntry?.url,
document,
]);
const headerActions = createDocumentViewerHeaderActions({ const headerActions = createDocumentViewerHeaderActions({
document, document,
+378
View File
@@ -0,0 +1,378 @@
import React, {
Fragment,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { JSX, MutableRefObject, RefObject } from 'react';
import {
GlobalWorkerOptions,
getDocument,
} from 'pdfjs-dist';
import type {
PDFDocumentLoadingTask,
PDFDocumentProxy,
RenderTask,
} from 'pdfjs-dist/types/src/display/api';
import { TextLayerBuilder } from 'pdfjs-dist/web/pdf_viewer.mjs';
GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url,
).toString();
interface PdfViewerProps {
src: string;
title?: string;
className?: string;
}
type RenderStatus = 'idle' | 'loading' | 'ready' | 'error';
interface PageDescriptor {
number: number;
width: number;
height: number;
scale: number;
}
const MIN_RENDER_WIDTH = 1200;
const MAX_RENDER_WIDTH = 2200;
const RERENDER_DELTA = 48;
const resolvePdfWasmBaseUrl = (): string => {
if (typeof document !== 'undefined') {
const base = document.baseURI || (typeof window !== 'undefined' ? window.location.href : '/');
return new URL('./pdfjs/wasm/', base).toString();
}
if (typeof window !== 'undefined' && window.location) {
return new URL('./pdfjs/wasm/', window.location.href).toString();
}
return '/pdfjs/wasm/';
};
const PdfViewer = ({ src, title, className }: PdfViewerProps): JSX.Element => {
const containerRef = useRef<HTMLDivElement>(null);
const [viewportWidth, setViewportWidth] = useState(0);
const [renderWidth, setRenderWidth] = useState(0);
const [status, setStatus] = useState<RenderStatus>('idle');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [pages, setPages] = useState<PageDescriptor[]>([]);
const [pixelRatio, setPixelRatio] = useState(1);
const pdfRef = useRef<PDFDocumentProxy | null>(null);
const wasmUrlRef = useRef<string | null>(null);
const ensureWasmUrl = useCallback(() => {
if (!wasmUrlRef.current) {
wasmUrlRef.current = resolvePdfWasmBaseUrl();
}
return wasmUrlRef.current;
}, []);
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) {
return undefined;
}
const updateWidth = () => {
const measurementTarget = (
container.closest('.document-viewer__viewport') as HTMLElement | null
) || container.parentElement || container;
const nextWidth = Math.max(1, Math.round(measurementTarget.clientWidth));
setViewportWidth((prev) => (prev === nextWidth ? prev : nextWidth));
};
updateWidth();
const observer = new ResizeObserver(() => {
updateWidth();
});
observer.observe(container);
const parent = container.parentElement;
if (parent) {
observer.observe(parent);
}
const viewportNode = container.closest('.document-viewer__viewport') as HTMLElement | null;
if (viewportNode) {
observer.observe(viewportNode);
}
return () => observer.disconnect();
}, []);
useEffect(() => {
if (viewportWidth <= 0) {
return;
}
const desiredWidth = Math.min(
MAX_RENDER_WIDTH,
Math.max(viewportWidth, MIN_RENDER_WIDTH),
);
setRenderWidth((current) => {
if (current === 0 || desiredWidth > current + RERENDER_DELTA) {
return desiredWidth;
}
return current;
});
}, [viewportWidth]);
useEffect(() => {
if (!src || renderWidth <= 0) {
return undefined;
}
let cancelled = false;
let loadingTask: PDFDocumentLoadingTask | null = null;
let activePdf: PDFDocumentProxy | null = null;
const wasmUrl = ensureWasmUrl();
const disposeActivePdf = () => {
if (activePdf) {
void activePdf.destroy();
if (pdfRef.current === activePdf) {
pdfRef.current = null;
}
activePdf = null;
}
};
if (pdfRef.current) {
void pdfRef.current.destroy();
pdfRef.current = null;
}
setPages([]);
setStatus('loading');
setErrorMessage(null);
const renderDocument = async () => {
try {
loadingTask = getDocument({
url: src,
withCredentials: true,
wasmUrl,
});
const pdf = await loadingTask.promise;
if (cancelled) {
await pdf.destroy();
return;
}
const ratio = window.devicePixelRatio || 1;
const descriptors: PageDescriptor[] = [];
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {
if (cancelled) {
break;
}
const page = await pdf.getPage(pageNumber);
if (cancelled) {
break;
}
const baseViewport = page.getViewport({ scale: 1 });
const desiredScale = Math.max(0.5, renderWidth / baseViewport.width);
const viewport = page.getViewport({ scale: desiredScale });
descriptors.push({
number: pageNumber,
width: viewport.width,
height: viewport.height,
scale: desiredScale,
});
}
if (cancelled) {
await pdf.destroy();
return;
}
activePdf = pdf;
pdfRef.current = pdf;
setPixelRatio(ratio);
setPages(descriptors);
setStatus('ready');
} catch (error) {
if (!cancelled) {
console.error('Failed to prepare PDF preview', error);
setStatus('error');
setErrorMessage('Unable to render PDF preview.');
}
}
};
renderDocument().catch((error) => {
console.error('Unhandled PDF render error', error);
setStatus('error');
setErrorMessage('Unable to render PDF preview.');
});
return () => {
cancelled = true;
loadingTask?.destroy();
disposeActivePdf();
};
}, [ensureWasmUrl, renderWidth, src]);
const viewerClasses = ['document-viewer__object', 'document-viewer__object--pdf', className]
.filter(Boolean)
.join(' ');
const statusMessage = status === 'error'
? (errorMessage || 'Unable to render PDF preview.')
: 'Preparing preview…';
const statusClasses = ['pdf-viewer__status', status === 'error' ? 'pdf-viewer__status--error' : null]
.filter(Boolean)
.join(' ');
const showStatus = status !== 'ready' || pages.length === 0;
const pageElements = useMemo(() => (
pages.map((page) => (
<Fragment key={`${page.number}-${Math.round(page.scale * 100)}-${Math.round(pixelRatio * 100)}`}>
<PdfPageCanvas
descriptor={page}
pdfRef={pdfRef}
pixelRatio={pixelRatio}
rootRef={containerRef}
/>
</Fragment>
))
), [pages, pixelRatio]);
const handleCanvasStackClick = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
const target = event.target as HTMLElement;
if (target.closest('.pdf-viewer__page-wrapper')) {
event.stopPropagation();
}
}, []);
return (
<div className={viewerClasses}>
<div
ref={containerRef}
className="pdf-viewer__canvas-stack"
role="document"
aria-label={title || 'PDF document'}
onClick={handleCanvasStackClick}
>
{pageElements}
</div>
{showStatus ? (
<div className={statusClasses}>
<div className="document-viewer__message">
{statusMessage}
</div>
</div>
) : null}
</div>
);
};
interface PdfPageCanvasProps {
descriptor: PageDescriptor;
pdfRef: MutableRefObject<PDFDocumentProxy | null>;
pixelRatio: number;
rootRef: RefObject<HTMLDivElement>;
}
function PdfPageCanvas({
descriptor,
pdfRef,
pixelRatio,
rootRef,
}: PdfPageCanvasProps): JSX.Element {
const wrapperRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const renderTaskRef = useRef<RenderTask | null>(null);
const hasRenderedRef = useRef(false);
const textLayerBuilderRef = useRef<TextLayerBuilder | null>(null);
const renderPage = useCallback(async () => {
if (renderTaskRef.current || hasRenderedRef.current) {
return;
}
const pdf = pdfRef.current;
const canvas = canvasRef.current;
if (!pdf || !canvas) {
return;
}
try {
const page = await pdf.getPage(descriptor.number);
const viewport = page.getViewport({ scale: descriptor.scale });
const context = canvas.getContext('2d');
if (!context) {
return;
}
canvas.width = viewport.width * pixelRatio;
canvas.height = viewport.height * pixelRatio;
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
const renderTask = page.render({ canvasContext: context, viewport, canvas });
renderTaskRef.current = renderTask;
await renderTask.promise;
hasRenderedRef.current = true;
const wrapper = wrapperRef.current;
if (wrapper) {
textLayerBuilderRef.current?.cancel();
const textLayerBuilder = new TextLayerBuilder({
pdfPage: page,
});
textLayerBuilderRef.current = textLayerBuilder;
wrapper.querySelector('.textLayer')?.remove();
wrapper.appendChild(textLayerBuilder.div);
await textLayerBuilder.render({ viewport });
}
} catch (error) {
console.error('Failed to render PDF page', descriptor.number, error);
} finally {
renderTaskRef.current = null;
}
}, [descriptor.number, descriptor.scale, pdfRef, pixelRatio]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) {
return undefined;
}
canvas.style.width = '100%';
canvas.style.height = 'auto';
canvas.style.setProperty('aspect-ratio', `${descriptor.width} / ${descriptor.height}`);
const wrapperNode = wrapperRef.current;
return () => {
renderTaskRef.current?.cancel();
textLayerBuilderRef.current?.cancel();
wrapperNode?.querySelector('.textLayer')?.remove();
};
}, [descriptor.height, descriptor.width]);
useEffect(() => {
const node = canvasRef.current;
if (!node) {
return undefined;
}
const root = rootRef.current || undefined;
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
void renderPage();
}
});
}, {
root,
rootMargin: '200px 0px',
threshold: 0.1,
});
observer.observe(node);
return () => observer.disconnect();
}, [renderPage, rootRef]);
return (
<div ref={wrapperRef} className="pdf-viewer__page-wrapper">
<canvas
ref={canvasRef}
className="pdf-viewer__page"
aria-label={`Page ${descriptor.number}`}
/>
</div>
);
}
export default PdfViewer;
+71 -1
View File
@@ -376,11 +376,13 @@
min-width: 0; min-width: 0;
display: flex; display: flex;
position: relative; position: relative;
overflow: hidden; overflow: auto;
align-items: stretch; align-items: stretch;
justify-content: flex-start; justify-content: flex-start;
max-height: 100%; max-height: 100%;
grid-area: viewport; grid-area: viewport;
background: var(--surface-subtle);
padding: 0.75rem;
} }
.document-viewer__object { .document-viewer__object {
@@ -388,6 +390,13 @@
border: none; border: none;
} }
.document-viewer__object--pdf {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
}
.document-viewer__object:not(.document-viewer__object--image) { .document-viewer__object:not(.document-viewer__object--image) {
height: 100%; height: 100%;
} }
@@ -398,6 +407,7 @@
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
object-fit: contain; object-fit: contain;
margin: auto;
} }
.document-viewer--stacked .document-viewer__object:not(.document-viewer__object--image) { .document-viewer--stacked .document-viewer__object:not(.document-viewer__object--image) {
@@ -421,6 +431,66 @@
background: var(--surface-subtle); background: var(--surface-subtle);
} }
.pdf-viewer__canvas-stack {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: center;
}
.pdf-viewer__page-wrapper {
position: relative;
width: min(100%, 48rem);
}
.pdf-viewer__page {
display: block;
width: 100%;
height: auto;
box-shadow: 0 1.25rem 2.5rem -1.25rem var(--shadow-medium);
background: var(--surface);
}
.pdf-viewer__page-wrapper .textLayer {
position: absolute;
inset: 0;
overflow: hidden;
line-height: 1;
transform-origin: 0 0;
pointer-events: auto;
color: transparent;
}
.pdf-viewer__page-wrapper .textLayer span,
.pdf-viewer__page-wrapper .textLayer br {
position: absolute;
white-space: pre;
transform-origin: 0 0;
cursor: text;
}
.pdf-viewer__page-wrapper .textLayer .endOfContent {
position: absolute;
inset: 100% 0 0;
pointer-events: none;
}
.pdf-viewer__status {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: color-mix(in oklch, var(--surface) 85%, transparent);
pointer-events: none;
}
.pdf-viewer__status--error {
color: var(--danger);
}
.document-viewer__unsupported-filename { .document-viewer__unsupported-filename {
font-size: 0.9rem; font-size: 0.9rem;
color: var(--muted); color: var(--muted);
+39 -2
View File
@@ -6,7 +6,6 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 2rem;
z-index: 2000000; z-index: 2000000;
cursor: zoom-out; cursor: zoom-out;
opacity: 0; opacity: 0;
@@ -29,6 +28,13 @@
z-index: 3000000; z-index: 3000000;
} }
.preview-zoom__stage--pdf {
width: 100%;
height: 100%;
max-width: none;
max-height: none;
}
.preview-zoom__image { .preview-zoom__image {
max-width: 95vw; max-width: 95vw;
max-height: 95vh; max-height: 95vh;
@@ -42,7 +48,13 @@
justify-content: center; justify-content: center;
max-width: 95vw; max-width: 95vw;
max-height: 95vh; max-height: 95vh;
box-shadow: 0 32px 120px var(--shadow-deep); }
.preview-zoom__scroll--pdf {
width: 100%;
height: 100%;
max-width: none;
max-height: none;
} }
.preview-zoom__scroll:focus { .preview-zoom__scroll:focus {
@@ -111,3 +123,28 @@
cursor: default; cursor: default;
} }
.preview-zoom__pdf {
width: 100%;
height: 100%;
overflow: auto;
}
.preview-zoom__pdf-viewer {
width: 100%;
height: 100%;
}
.preview-zoom__stage .pdf-viewer__page-wrapper {
position: relative;
overflow: visible;
}
.preview-zoom__stage .pdf-viewer__page-wrapper::after {
content: '';
position: absolute;
inset: 0;
box-shadow: 0 32px 120px var(--shadow-deep);
z-index: -1;
pointer-events: none;
border-radius: inherit;
}
+4
View File
@@ -0,0 +1,4 @@
declare module '*?url' {
const url: string;
export default url;
}
+17 -1
View File
@@ -1,5 +1,6 @@
const path = require('path'); const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin'); const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
module.exports = { module.exports = {
entry: './src/index.tsx', entry: './src/index.tsx',
output: { output: {
@@ -37,11 +38,26 @@ module.exports = {
template: path.resolve(__dirname, 'src/index.html'), template: path.resolve(__dirname, 'src/index.html'),
favicon: false, favicon: false,
}), }),
new CopyWebpackPlugin({
patterns: [
{
from: path.resolve(__dirname, 'node_modules/pdfjs-dist/wasm'),
to: 'pdfjs/wasm',
},
],
}),
], ],
devServer: { devServer: {
static: { static: [
{
directory: path.join(__dirname, 'public'), directory: path.join(__dirname, 'public'),
}, },
{
directory: path.resolve(__dirname, 'node_modules/pdfjs-dist/wasm'),
publicPath: '/pdfjs/wasm',
watch: false,
},
],
compress: true, compress: true,
port: 5173, port: 5173,
historyApiFallback: true, historyApiFallback: true,