diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5769f8a..7fa0ea4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "@fontsource/inter": "^5.2.8", "@tabler/icons-react": "^3.35.0", "axios": "^1.13.2", + "pdfjs-dist": "^5.4.394", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.9.5" @@ -24,6 +25,7 @@ "@typescript-eslint/eslint-plugin": "^8.46.4", "@typescript-eslint/parser": "^8.18.1", "babel-loader": "^10.0.0", + "copy-webpack-plugin": "^13.0.1", "css-loader": "^7.1.2", "dotenv": "^17.2.3", "eslint": "^9.39.1", @@ -2307,6 +2309,191 @@ "dev": true, "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": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -4524,6 +4711,43 @@ "dev": true, "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": { "version": "3.46.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.46.0.tgz", @@ -8159,6 +8383,18 @@ "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": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -9905,6 +10141,54 @@ "dev": true, "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": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 9c76e55..3b2c8cf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "@fontsource/inter": "^5.2.8", "@tabler/icons-react": "^3.35.0", "axios": "^1.13.2", + "pdfjs-dist": "^5.4.394", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.9.5" @@ -26,6 +27,7 @@ "@typescript-eslint/eslint-plugin": "^8.46.4", "@typescript-eslint/parser": "^8.18.1", "babel-loader": "^10.0.0", + "copy-webpack-plugin": "^13.0.1", "css-loader": "^7.1.2", "dotenv": "^17.2.3", "eslint": "^9.39.1", diff --git a/frontend/src/app/useDocumentPreview.ts b/frontend/src/app/useDocumentPreview.ts index 2e4bb23..d55fcf4 100644 --- a/frontend/src/app/useDocumentPreview.ts +++ b/frontend/src/app/useDocumentPreview.ts @@ -60,7 +60,7 @@ interface UseDocumentPreviewArgs { interface UseDocumentPreviewResult { previewEntries: Map; previewDocuments: Map; - ensurePreviewUrl: (documentId: DocumentId, options?: { force?: boolean }) => Promise; + ensureDownloadUrl: (documentId: DocumentId, options?: { force?: boolean }) => Promise; ensurePreviewData: (documentId: DocumentId) => Promise; openDocumentPreview: (documentId: DocumentId, options?: { replace?: boolean }) => 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 => { if (!documentId) return null; @@ -231,7 +231,7 @@ const useDocumentPreview = ({ fallbackFolderId === 'root' ? '/documents' : `/documents/folder/${fallbackFolderId}`; } - await ensurePreviewUrl(documentId, { force: false }); + await ensureDownloadUrl(documentId, { force: false }); setActivePreviewId(documentId); return doc; }, @@ -239,7 +239,7 @@ const useDocumentPreview = ({ searchResults, documents, assetManager, - ensurePreviewUrl, + ensureDownloadUrl, setActivePreviewId, api, cachePreviewDocument, @@ -336,7 +336,7 @@ const useDocumentPreview = ({ return { previewEntries, previewDocuments, - ensurePreviewUrl, + ensureDownloadUrl, ensurePreviewData, openDocumentPreview, closeDocumentPreview, diff --git a/frontend/src/desktop/DesktopPreviewCard.tsx b/frontend/src/desktop/DesktopPreviewCard.tsx index 1ed7491..1488cfd 100644 --- a/frontend/src/desktop/DesktopPreviewCard.tsx +++ b/frontend/src/desktop/DesktopPreviewCard.tsx @@ -62,7 +62,7 @@ const DesktopPreviewCard = ({ }: DesktopPreviewCardProps): JSX.Element => { const navigator = useAssetNavigator({ document: doc, - assetType: 'preview', + assetType: 'thumbnail', ensureAssetUrl: shouldLoad ? ensureAssetUrl : null, getAsset: getDocumentAsset, prefetch, diff --git a/frontend/src/desktop/DesktopWorkspace.tsx b/frontend/src/desktop/DesktopWorkspace.tsx index f78fb15..95ce53a 100644 --- a/frontend/src/desktop/DesktopWorkspace.tsx +++ b/frontend/src/desktop/DesktopWorkspace.tsx @@ -8,6 +8,7 @@ import React, { useSyncExternalStore, } from 'react'; import { resolveDocumentAssetUrl } from '../asset_manager'; +import type { EnsureAssetUrl, GetAsset } from '../asset_manager'; import { formatTransform } from './math'; import useDocumentDrag from './useDocumentDrag'; import PreviewZoomOverlay from '../detail/PreviewZoomOverlay'; @@ -31,6 +32,8 @@ import '../styles/workspace/workspace-cards.css'; type Identifier = string | number; 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 { id?: Identifier | null; @@ -49,6 +52,7 @@ interface NavigatorSnapshot { ordinal?: number | null; width?: number | null; height?: number | null; + contentType?: string | null; } type OverlayOriginHint = { @@ -124,8 +128,8 @@ interface DesktopWorkspaceProps { onDocumentStackSelect?: (docIds: Identifier[]) => void; onPromoteSelection?: (...args: unknown[]) => void; onAssignTagToDocument?: (...args: unknown[]) => void; - ensureAssetUrl?: (...args: unknown[]) => Promise | unknown; - getDocumentAsset?: (...args: unknown[]) => unknown; + ensureAssetUrl?: EnsureAssetUrl; + getDocumentAsset?: GetAsset; activeTagIds?: Array; selectedDocumentIds?: Identifier[]; onClearSelection?: () => void; @@ -193,6 +197,8 @@ interface DesktopWorkspaceViewProps { const DEBUG_DRAG = false; const DEBUG_FOCUS = false; +const defaultGetDocumentAsset: GetAsset = () => null; + const DesktopWorkspace: React.FC = ({ documents = [], searchResults = null, @@ -202,7 +208,7 @@ const DesktopWorkspace: React.FC = ({ onPromoteSelection = null, onAssignTagToDocument = null, ensureAssetUrl = null, - getDocumentAsset = () => null, + getDocumentAsset = defaultGetDocumentAsset, activeTagIds = [], selectedDocumentIds = [], onClearSelection = null, @@ -210,6 +216,8 @@ const DesktopWorkspace: React.FC = ({ onCloseDetailPanel = null, tenantId = null, viewId = 'default', + previewEntries, + ensureDownloadUrl, }) => { const items = useMemo( () => (searchResults ? searchResults : documents), @@ -217,6 +225,7 @@ const DesktopWorkspace: React.FC = ({ ); const allowLayoutPersistence = Boolean(tenantId && viewId && viewId.startsWith('folder:')); + const previewEntryMap = previewEntries instanceof Map ? previewEntries : null; const containerRef = useRef(null); const itemRefs = useRef>(new Map()); @@ -226,9 +235,8 @@ const DesktopWorkspace: React.FC = ({ const [overlayOriginTransform, setOverlayOriginTransform] = useState( null, ); - const [previewSnapshots, setPreviewSnapshots] = useState>( - () => new Map(), - ); + const [overlaySource, setOverlaySource] = useState(null); + const [, setPreviewSnapshots] = useState>(() => new Map()); const [docSizeVersion, setDocSizeVersion] = useState(0); const docSizeMapRef = useRef>(new Map()); const ensureDocumentSize = useCallback((doc: DeskDocument | null): DocumentSizeInfo | null => { @@ -444,7 +452,7 @@ const DesktopWorkspace: React.FC = ({ if (!doc) { return; } - resolveDocumentAssetUrl(doc, 'preview', { + resolveDocumentAssetUrl(doc, 'thumbnail', { ensureAssetUrl, getAsset: getDocumentAsset, }); @@ -546,30 +554,77 @@ const DesktopWorkspace: React.FC = ({ } }, [items, previewMetadata]); - const overlayDisplay = useMemo(() => { + useEffect(() => { + let cancelled = false; if (!overlayDocId) { - return null; + setOverlaySource(null); + return () => { + cancelled = true; + }; } - const snapshot = previewSnapshots.get(overlayDocId); - if (!snapshot || !snapshot.url) { - return null; + + const doc = documentLookup.get(overlayDocId) || null; + const docIdentifier = doc?.id ?? null; + if (!docIdentifier || !doc) { + setOverlaySource(null); + return () => { + cancelled = true; + }; } - 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, + + 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, + }); }; - }, [overlayDocId, previewSnapshots, documentLookup]); + + 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(() => { setOverlayDocId(null); setOverlayOriginRect(null); setOverlayOriginTransform(null); + setOverlaySource(null); }, []); useEffect(() => { @@ -611,24 +666,32 @@ const DesktopWorkspace: React.FC = ({ } }, [draggingId, items, setDraggingId]); + const overlayDisplay = useMemo(() => { + if (!overlaySource) { + return null; + } + return { + ...overlaySource, + canGoPrev: false, + canGoNext: false, + }; + }, [overlaySource]); + const openOverlayForDoc = useCallback( (docId: Identifier | null, originInfo: OverlayOriginHint | null = null) => { if (!docId) { return; } const docKey = String(docId); - const snapshot = previewSnapshots.get(docKey); - if (!snapshot || !snapshot.url) { - return; - } const container = itemRefs.current.get(docKey); - const imageNode = container - ? container.querySelector('.desk-item__card img') - : null; - if (!container || !imageNode) { + if (!container) { + return; + } + const imageNode = container.querySelector('.desk-item__card img'); + const rect = (imageNode || container).getBoundingClientRect(); + if (!rect) { return; } - const rect = imageNode.getBoundingClientRect(); let originTransform = null; if (originInfo) { const { rotation = 0, scale = 1, width: originWidth, height: originHeight } = originInfo; @@ -666,7 +729,6 @@ const DesktopWorkspace: React.FC = ({ }, [ bringToFront, - previewSnapshots, itemRefs, setOverlayOriginTransform, ensureDocumentSize, diff --git a/frontend/src/desktop/useDeskWorkspaceProps.ts b/frontend/src/desktop/useDeskWorkspaceProps.ts index 4520749..de330a9 100644 --- a/frontend/src/desktop/useDeskWorkspaceProps.ts +++ b/frontend/src/desktop/useDeskWorkspaceProps.ts @@ -61,6 +61,8 @@ interface UseDeskWorkspacePropsArgs { searchQuery?: string; activeCorrespondentFilters?: Identifier[]; selectedFolder?: Identifier | string | null; + previewEntries?: Map; + ensureDownloadUrl?: (documentId: Identifier, options?: { force?: boolean }) => Promise<{ url?: string | null; contentType?: string | null } | null>; } const useDeskWorkspaceProps = ({ @@ -108,6 +110,8 @@ const useDeskWorkspaceProps = ({ searchQuery = '', activeCorrespondentFilters = [], selectedFolder, + previewEntries, + ensureDownloadUrl, }: UseDeskWorkspacePropsArgs) => { const handleDeskDocumentStackSelect: DeskDocumentStackSelectHandler = useCallback( (docIds) => { @@ -200,6 +204,8 @@ const useDeskWorkspaceProps = ({ onMoveDocumentsToFolder: moveDocumentsToFolder, searchIncludeDescendants, onToggleSearchIncludeDescendants: toggleSearchIncludeDescendants, + previewEntries, + ensureDownloadUrl, }), [ documents, @@ -240,6 +246,8 @@ const useDeskWorkspaceProps = ({ moveDocumentsToFolder, searchIncludeDescendants, toggleSearchIncludeDescendants, + previewEntries, + ensureDownloadUrl, ], ); }; diff --git a/frontend/src/detail/PreviewZoomOverlay.tsx b/frontend/src/detail/PreviewZoomOverlay.tsx index c5a70f3..7fa1f47 100644 --- a/frontend/src/detail/PreviewZoomOverlay.tsx +++ b/frontend/src/detail/PreviewZoomOverlay.tsx @@ -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 { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons'; import { clamp } from '../utils/math'; +import PdfViewer from '../preview/PdfViewer'; type PreviewNavigator = { url: string; alt?: string; + contentType?: string | null; + pageNumber?: number | null; canGoPrev?: boolean; canGoNext?: boolean; goPrev?: () => void; @@ -20,6 +23,22 @@ interface PreviewZoomOverlayProps { type NaturalSize = { width: number | null; height: 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 = () => {}; @@ -35,7 +54,7 @@ const PreviewZoomOverlay: React.FC = ({ const [isBackdropVisible, setBackdropVisible] = useState(false); const [displaySnapshot, setDisplaySnapshot] = useState(null); const scrollRef = useRef(null); - const imageRef = useRef(null); + const mediaRef = useRef(null); const focusRef = useRef(null); const previouslyFocusedRef = useRef(null); const visibilityTimerRef = useRef(null); @@ -47,6 +66,10 @@ const PreviewZoomOverlay: React.FC = ({ } }, [display]); + const activeDisplay = open && display?.url ? display : displaySnapshot; + const displayKind = useMemo(() => determineDisplayKind(activeDisplay), [activeDisplay]); + const isPdfDisplay = displayKind === 'pdf'; + useEffect(() => { if (visibilityTimerRef.current) { clearTimeout(visibilityTimerRef.current); @@ -94,6 +117,9 @@ const PreviewZoomOverlay: React.FC = ({ } }, []); + useEffect(() => { + }, [isPdfDisplay, open, activeDisplay?.url]); + useEffect(() => { setIsNativeScale(false); setNaturalSize({ width: null, height: null }); @@ -103,7 +129,7 @@ const PreviewZoomOverlay: React.FC = ({ scrollEl.scrollLeft = 0; scrollEl.scrollTop = 0; } - }, [open]); + }, [open, activeDisplay?.url, displayKind]); useEffect(() => { @@ -112,13 +138,13 @@ const PreviewZoomOverlay: React.FC = ({ } const scrollEl = scrollRef.current; - const imageEl = imageRef.current; - if (!scrollEl || !imageEl) { + const mediaEl = mediaRef.current; + if (!scrollEl || !mediaEl) { return; } - const imageWidth = imageEl.naturalWidth || imageEl.clientWidth; - const imageHeight = imageEl.naturalHeight || imageEl.clientHeight; + const imageWidth = naturalSize.width || mediaEl.clientWidth; + const imageHeight = naturalSize.height || mediaEl.clientHeight; if (!(imageWidth > 0 && imageHeight > 0)) { return; } @@ -145,8 +171,6 @@ const PreviewZoomOverlay: React.FC = ({ previouslyFocusedRef.current = active instanceof HTMLElement ? active : null; }, [open]); - const activeDisplay = open && display?.url ? display : displaySnapshot; - useEffect(() => { if (!activeDisplay?.url) { return; @@ -222,10 +246,13 @@ const PreviewZoomOverlay: React.FC = ({ }; const toggleZoomAtPoint = (clientX: number, clientY: number) => { - const img = imageRef.current; + if (isPdfDisplay) { + return; + } + const media = mediaRef.current; setIsNativeScale((current) => { - if (!current && img) { - const rect = img.getBoundingClientRect(); + if (!current && media) { + const rect = media.getBoundingClientRect(); const xRatio = rect.width > 0 ? (clientX - rect.left) / rect.width : 0.5; const yRatio = rect.height > 0 ? (clientY - rect.top) / rect.height : 0.5; focusRef.current = { @@ -239,22 +266,24 @@ const PreviewZoomOverlay: React.FC = ({ }); }; - const handleImageClick = (event: React.MouseEvent) => { + const handleContentClick = (event: React.MouseEvent) => { event.stopPropagation(); + if (isPdfDisplay) { + return; + } toggleZoomAtPoint(event.clientX ?? 0, event.clientY ?? 0); }; - if (!renderBackdrop || !activeDisplay?.url) { - return null; - } + const shouldRender = renderBackdrop && Boolean(activeDisplay?.url); - const effectiveDisplay = activeDisplay; - const navVisible = Boolean(effectiveDisplay?.canGoPrev || effectiveDisplay?.canGoNext); - const stageClassName = 'preview-zoom__stage'; + const stageClassName = isPdfDisplay + ? 'preview-zoom__stage preview-zoom__stage--pdf' + : 'preview-zoom__stage'; const containerClassName = [ 'preview-zoom__scroll', - isNativeScale ? 'preview-zoom__scroll--native' : '', + isNativeScale ? 'preview-zoom__scroll--native' : null, + isPdfDisplay ? 'preview-zoom__scroll--pdf' : null, ] .filter(Boolean) .join(' '); @@ -266,7 +295,7 @@ const PreviewZoomOverlay: React.FC = ({ .filter(Boolean) .join(' '); - const imageStyle: CSSProperties = isNativeScale + const contentStyle: CSSProperties = isNativeScale ? { cursor: 'zoom-out', width: naturalSize.width ? `${naturalSize.width}px` : 'auto', @@ -282,6 +311,13 @@ const PreviewZoomOverlay: React.FC = ({ touchAction: 'manipulation', }; + if (!shouldRender) { + return null; + } + + const effectiveDisplay = activeDisplay; + const navVisible = Boolean(effectiveDisplay?.canGoPrev || effectiveDisplay?.canGoNext); + return createPortal( (
= ({ aria-modal="true" aria-label="Enlarged document preview" onClick={onClose} + onKeyDown={handleKeyDown} > -
+
{ + if (event.target === event.currentTarget) { + onClose(); + } + }} + >
event.stopPropagation()} > - {effectiveDisplay.alt { - setNaturalSize({ - width: event.currentTarget.naturalWidth || null, - height: event.currentTarget.naturalHeight || null, - }); - }} - onClick={handleImageClick} - style={imageStyle} - /> + {isPdfDisplay ? ( +
+ +
+ ) : ( + {effectiveDisplay.alt { + mediaRef.current = node; + }} + draggable={false} + onLoad={(event) => { + setNaturalSize({ + width: event.currentTarget.naturalWidth || null, + height: event.currentTarget.naturalHeight || null, + }); + }} + onClick={handleContentClick} + style={contentStyle} + /> + )}
{navVisible ? (
diff --git a/frontend/src/documents/hooks/useDocumentsPanelProps.ts b/frontend/src/documents/hooks/useDocumentsPanelProps.ts index 58f7a48..9b36327 100644 --- a/frontend/src/documents/hooks/useDocumentsPanelProps.ts +++ b/frontend/src/documents/hooks/useDocumentsPanelProps.ts @@ -2,6 +2,11 @@ import { useMemo } from 'react'; type Identifier = string | number; +interface PreviewEntryLike { + url?: string | null; + contentType?: string | null; +} + export interface Breadcrumb { id?: Identifier; name?: string; @@ -65,6 +70,8 @@ export interface UseDocumentsPanelPropsArgs { handleBulkSelectionReanalyze?: (...args: unknown[]) => void; folderOptions?: unknown[]; moveDocumentsToFolder?: (...args: unknown[]) => void; + previewEntries?: Map; + ensureDownloadUrl?: (documentId: Identifier, options?: { force?: boolean }) => Promise; } export type DocumentsPanelProps = ReturnType; @@ -93,8 +100,8 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => { searchLoading, tagLookupById, activeCorrespondentFilters, - ensureAssetUrl, - getDocumentAsset, + ensureAssetUrl, + getDocumentAsset, toggleTagFilter, toggleCorrespondentFilter, handleDocumentTagDrop, @@ -119,6 +126,8 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => { handleBulkSelectionReanalyze, folderOptions, moveDocumentsToFolder, + previewEntries, + ensureDownloadUrl, } = props; return useMemo( @@ -173,6 +182,8 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => { onBulkReanalyze: handleBulkSelectionReanalyze, folderOptions, onMoveDocumentsToFolder: moveDocumentsToFolder, + previewEntries, + ensureDownloadUrl, }), [ activeCorrespondentFilters, @@ -223,6 +234,8 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => { ensureAssetUrl, getDocumentAsset, folderOptions, + previewEntries, + ensureDownloadUrl, ], ); }; diff --git a/frontend/src/documents/panel/DocumentsPanel.tsx b/frontend/src/documents/panel/DocumentsPanel.tsx index 9d3e22e..ddddaee 100644 --- a/frontend/src/documents/panel/DocumentsPanel.tsx +++ b/frontend/src/documents/panel/DocumentsPanel.tsx @@ -5,7 +5,6 @@ import DocumentsGrid from '../DocumentsGrid'; import DocumentsList from '../DocumentsList'; import { isTagTransferEvent } from '../tagTransfer'; import PreviewZoomOverlay from '../../detail/PreviewZoomOverlay'; -import { useAssetNavigator } from '../../hooks/useAssetNavigator'; import { isPointerModifierEvent, isPrimaryPointerEvent } from '../useEntryPointer'; import { useWorkspaceSelectionContext } from '../../app/WorkspaceSelectionContext'; @@ -22,6 +21,8 @@ interface DocumentsPanelProps { const defaultGetDocumentAsset = (_doc?: unknown, _type?: string) => null; +export type PreviewEntryLike = { url?: string | null; contentType?: string | null }; + const DocumentsPanel: React.FC = ({ currentFolderName, breadcrumbs, @@ -55,6 +56,8 @@ const DocumentsPanel: React.FC = ({ viewMode = 'list', onViewModeChange, showHeader = true, + previewEntries, + ensureDownloadUrl, }) => { const { selectedEntries, @@ -65,6 +68,7 @@ const DocumentsPanel: React.FC = ({ } = useWorkspaceSelectionContext(); const showingSearchResults = searchResults !== null; const rows = showingSearchResults ? searchResults : documents; + const previewEntryMap = previewEntries instanceof Map ? previewEntries : null; const currentFolderId = useMemo(() => { if (showingSearchResults) { @@ -138,7 +142,10 @@ const DocumentsPanel: React.FC = ({ const isGridView = viewMode === 'grid'; 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(null); const previewDoc = useMemo(() => { if (!previewDocId) { @@ -153,38 +160,69 @@ const DocumentsPanel: React.FC = ({ } }, [previewDocId, previewDoc]); - const previewNavigator = useAssetNavigator({ - document: previewDoc, - assetType: 'preview', - ensureAssetUrl, - getAsset: getDocumentAsset, - prefetch: 3, - }); + const [previewZoomSource, setPreviewZoomSource] = useState(null); + const zoomDisplay = previewZoomSource; - const { - currentUrl: previewUrl, - canGoPrev: previewCanGoPrev, - canGoNext: previewCanGoNext, - goPrev: previewGoPrev, - goNext: previewGoNext, - } = previewNavigator; - - const previewDisplay = useMemo(() => { - if (!previewDoc || !previewUrl) { - return null; + useEffect(() => { + let cancelled = false; + if (!previewDocId || !previewDoc) { + setPreviewZoomSource(null); + return () => { + cancelled = true; + }; } - return { - url: previewUrl, - alt: previewDoc.title, - canGoPrev: Boolean(previewCanGoPrev), - canGoNext: Boolean(previewCanGoNext), - goPrev: previewGoPrev, - goNext: previewGoNext, + 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, + }); }; - }, [previewDoc, previewUrl, previewCanGoPrev, previewCanGoNext, previewGoPrev, previewGoNext]); + + 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); + } + }); + + return () => { + cancelled = true; + }; + }, [previewDocId, previewDoc, previewEntryMap, ensureDownloadUrl]); const closePreviewOverlay = useCallback(() => { setPreviewDocId(null); + setPreviewZoomSource(null); }, []); const handleDocumentPreviewZoom = useCallback( @@ -192,13 +230,12 @@ const DocumentsPanel: React.FC = ({ if (!doc || !doc.id) { return; } - const previewAsset = getDocumentAsset(doc, 'preview'); - if (!previewAsset) { + if (!ensureDownloadUrl && !(previewEntryMap?.get(doc.id)?.url)) { return; } setPreviewDocId(doc.id); }, - [getDocumentAsset], + [ensureDownloadUrl, previewEntryMap], ); const handleDocumentActivate = useCallback( @@ -724,8 +761,8 @@ const DocumentsPanel: React.FC = ({ )} diff --git a/frontend/src/hooks/documents/useDocumentsWorkspace.ts b/frontend/src/hooks/documents/useDocumentsWorkspace.ts index 9e54761..c3faf8d 100644 --- a/frontend/src/hooks/documents/useDocumentsWorkspace.ts +++ b/frontend/src/hooks/documents/useDocumentsWorkspace.ts @@ -382,6 +382,7 @@ const useDocumentsWorkspace = ({ previewEntries, previewDocuments, ensurePreviewData, + ensureDownloadUrl, openDocumentPreview, closeDocumentPreview, resetPreviewState, @@ -1452,6 +1453,8 @@ const useDocumentsWorkspace = ({ folderOptions, moveDocumentsToFolder, selectFolder, + previewEntries, + ensureDownloadUrl, }); const sidebarProps = useSidebarProps({ @@ -1539,6 +1542,8 @@ const useDocumentsWorkspace = ({ searchQuery, activeCorrespondentFilters, selectedFolder, + previewEntries, + ensureDownloadUrl, }); const contextValue = useMemo( diff --git a/frontend/src/preview/DocumentViewerLayout.tsx b/frontend/src/preview/DocumentViewerLayout.tsx index ccf38f4..f841b05 100644 --- a/frontend/src/preview/DocumentViewerLayout.tsx +++ b/frontend/src/preview/DocumentViewerLayout.tsx @@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react'; import type { JSX } from 'react'; import DocumentInfoPanel from '../documents/DocumentInfoPanel'; import { DownloadIcon } from '../ui/icons'; +import PdfViewer from './PdfViewer'; interface DocumentLike { id?: string | number; @@ -87,11 +88,13 @@ const DocumentViewerLayout = ({ } if (isPdf) { + const documentTitle = document.title + || document.filename + || document.original_name; return ( -