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",
"@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",
+2
View File
@@ -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",
+5 -5
View File
@@ -60,7 +60,7 @@ interface UseDocumentPreviewArgs {
interface UseDocumentPreviewResult {
previewEntries: Map<DocumentId, PreviewEntry>;
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>;
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<PreviewEntry | null> => {
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,
+1 -1
View File
@@ -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,
+94 -32
View File
@@ -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> | unknown;
getDocumentAsset?: (...args: unknown[]) => unknown;
ensureAssetUrl?: EnsureAssetUrl;
getDocumentAsset?: GetAsset;
activeTagIds?: Array<Identifier | null>;
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<DesktopWorkspaceProps> = ({
documents = [],
searchResults = null,
@@ -202,7 +208,7 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
onPromoteSelection = null,
onAssignTagToDocument = null,
ensureAssetUrl = null,
getDocumentAsset = () => null,
getDocumentAsset = defaultGetDocumentAsset,
activeTagIds = [],
selectedDocumentIds = [],
onClearSelection = null,
@@ -210,6 +216,8 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
onCloseDetailPanel = null,
tenantId = null,
viewId = 'default',
previewEntries,
ensureDownloadUrl,
}) => {
const items = useMemo<DeskDocument[]>(
() => (searchResults ? searchResults : documents),
@@ -217,6 +225,7 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
);
const allowLayoutPersistence = Boolean(tenantId && viewId && viewId.startsWith('folder:'));
const previewEntryMap = previewEntries instanceof Map ? previewEntries : null;
const containerRef = useRef<HTMLDivElement | null>(null);
const itemRefs = useRef<Map<string, HTMLDivElement | null>>(new Map());
@@ -226,9 +235,8 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
const [overlayOriginTransform, setOverlayOriginTransform] = useState<OverlayOriginTransform | null>(
null,
);
const [previewSnapshots, setPreviewSnapshots] = useState<Map<string, NavigatorSnapshot>>(
() => new Map(),
);
const [overlaySource, setOverlaySource] = useState<OverlaySource | null>(null);
const [, setPreviewSnapshots] = useState<Map<string, NavigatorSnapshot>>(() => new Map());
const [docSizeVersion, setDocSizeVersion] = useState(0);
const docSizeMapRef = useRef<Map<string, DocumentSizeInfo>>(new Map());
const ensureDocumentSize = useCallback((doc: DeskDocument | null): DocumentSizeInfo | null => {
@@ -444,7 +452,7 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
if (!doc) {
return;
}
resolveDocumentAssetUrl(doc, 'preview', {
resolveDocumentAssetUrl(doc, 'thumbnail', {
ensureAssetUrl,
getAsset: getDocumentAsset,
});
@@ -546,30 +554,77 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
}
}, [items, previewMetadata]);
const overlayDisplay = useMemo<OverlayDisplay | null>(() => {
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<DesktopWorkspaceProps> = ({
}
}, [draggingId, items, setDraggingId]);
const overlayDisplay = useMemo<OverlayDisplay | null>(() => {
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<HTMLImageElement>('.desk-item__card img')
: null;
if (!container || !imageNode) {
if (!container) {
return;
}
const imageNode = container.querySelector<HTMLImageElement>('.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<DesktopWorkspaceProps> = ({
},
[
bringToFront,
previewSnapshots,
itemRefs,
setOverlayOriginTransform,
ensureDocumentSize,
@@ -61,6 +61,8 @@ interface UseDeskWorkspacePropsArgs {
searchQuery?: string;
activeCorrespondentFilters?: Identifier[];
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 = ({
@@ -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,
],
);
};
+93 -38
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 { 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<PreviewZoomOverlayProps> = ({
const [isBackdropVisible, setBackdropVisible] = useState(false);
const [displaySnapshot, setDisplaySnapshot] = useState<PreviewNavigator | 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 previouslyFocusedRef = useRef<HTMLElement | null>(null);
const visibilityTimerRef = useRef<number | null>(null);
@@ -47,6 +66,10 @@ const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
}
}, [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<PreviewZoomOverlayProps> = ({
}
}, []);
useEffect(() => {
}, [isPdfDisplay, open, activeDisplay?.url]);
useEffect(() => {
setIsNativeScale(false);
setNaturalSize({ width: null, height: null });
@@ -103,7 +129,7 @@ const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
scrollEl.scrollLeft = 0;
scrollEl.scrollTop = 0;
}
}, [open]);
}, [open, activeDisplay?.url, displayKind]);
useEffect(() => {
@@ -112,13 +138,13 @@ const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
}
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<PreviewZoomOverlayProps> = ({
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<PreviewZoomOverlayProps> = ({
};
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<PreviewZoomOverlayProps> = ({
});
};
const handleImageClick = (event: React.MouseEvent<HTMLImageElement>) => {
const handleContentClick = (event: React.MouseEvent<HTMLElement>) => {
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<PreviewZoomOverlayProps> = ({
.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<PreviewZoomOverlayProps> = ({
touchAction: 'manipulation',
};
if (!shouldRender) {
return null;
}
const effectiveDisplay = activeDisplay;
const navVisible = Boolean(effectiveDisplay?.canGoPrev || effectiveDisplay?.canGoNext);
return createPortal(
(
<div
@@ -290,29 +326,48 @@ const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
aria-modal="true"
aria-label="Enlarged document preview"
onClick={onClose}
onKeyDown={handleKeyDown}
>
<div className={stageClassName} onKeyDown={handleKeyDown}>
<div
className={stageClassName}
onClick={(event) => {
if (event.target === event.currentTarget) {
onClose();
}
}}
>
<div
className={containerClassName}
ref={scrollRef}
tabIndex={-1}
onClick={(event) => event.stopPropagation()}
>
<img
src={effectiveDisplay.url}
alt={effectiveDisplay.alt || 'Document preview'}
className="preview-zoom__image"
ref={imageRef}
draggable={false}
onLoad={(event) => {
setNaturalSize({
width: event.currentTarget.naturalWidth || null,
height: event.currentTarget.naturalHeight || null,
});
}}
onClick={handleImageClick}
style={imageStyle}
/>
{isPdfDisplay ? (
<div className="preview-zoom__pdf">
<PdfViewer
src={effectiveDisplay.url}
title={effectiveDisplay.alt || 'Document preview'}
className="preview-zoom__pdf-viewer"
/>
</div>
) : (
<img
src={effectiveDisplay.url}
alt={effectiveDisplay.alt || 'Document preview'}
className="preview-zoom__image"
ref={(node) => {
mediaRef.current = node;
}}
draggable={false}
onLoad={(event) => {
setNaturalSize({
width: event.currentTarget.naturalWidth || null,
height: event.currentTarget.naturalHeight || null,
});
}}
onClick={handleContentClick}
style={contentStyle}
/>
)}
</div>
{navVisible ? (
<div className="preview-zoom__nav">
@@ -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<Identifier, PreviewEntryLike>;
ensureDownloadUrl?: (documentId: Identifier, options?: { force?: boolean }) => Promise<PreviewEntryLike | null>;
}
export type DocumentsPanelProps = ReturnType<typeof useDocumentsPanelProps>;
@@ -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,
],
);
};
+70 -33
View File
@@ -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<DocumentsPanelProps> = ({
currentFolderName,
breadcrumbs,
@@ -55,6 +56,8 @@ const DocumentsPanel: React.FC<DocumentsPanelProps> = ({
viewMode = 'list',
onViewModeChange,
showHeader = true,
previewEntries,
ensureDownloadUrl,
}) => {
const {
selectedEntries,
@@ -65,6 +68,7 @@ const DocumentsPanel: React.FC<DocumentsPanelProps> = ({
} = 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<DocumentsPanelProps> = ({
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<Identifier | null>(null);
const previewDoc = useMemo(() => {
if (!previewDocId) {
@@ -153,38 +160,69 @@ const DocumentsPanel: React.FC<DocumentsPanelProps> = ({
}
}, [previewDocId, previewDoc]);
const previewNavigator = useAssetNavigator({
document: previewDoc,
assetType: 'preview',
ensureAssetUrl,
getAsset: getDocumentAsset,
prefetch: 3,
});
const [previewZoomSource, setPreviewZoomSource] = useState<ZoomSource | null>(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<DocumentsPanelProps> = ({
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<DocumentsPanelProps> = ({
)}
</section>
<PreviewZoomOverlay
open={Boolean(previewDocId)}
display={previewDisplay}
open={Boolean(zoomDisplay?.url)}
display={zoomDisplay}
onClose={closePreviewOverlay}
/>
</>
@@ -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(
@@ -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 (
<iframe
<PdfViewer
src={previewEntry.url}
title={`Preview of ${document.title}`}
className="document-viewer__object"
title={documentTitle ? `Preview of ${documentTitle}` : undefined}
/>
);
}
+16 -40
View File
@@ -25,7 +25,6 @@ import { createDocumentActionState } from '../documents/documentActions';
import { resolveDocumentAssetUrl } from '../asset_manager';
import PanelHeader from '../ui/PanelHeader';
import BreadcrumbTrail from '../ui/BreadcrumbTrail';
import { useAssetNavigator } from '../hooks/useAssetNavigator';
import DocumentViewerLayout from './DocumentViewerLayout';
import useViewerLayoutMode from './useViewerLayoutMode';
import { usePanelResizeBindings } from '../app/PanelManagerContext';
@@ -33,8 +32,13 @@ import { usePanelResizeBindings } from '../app/PanelManagerContext';
interface DocumentLike {
id?: string | number;
title?: string;
content_type?: string | null;
issued_at?: string | null;
correspondents?: Array<{ id?: string | number; name?: string }>;
current_version?: {
version_number?: number;
version?: { content_type?: string | null } | null;
} | null;
[key: string]: unknown;
}
@@ -245,18 +249,6 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
);
const [zoomOverlayOpen, setZoomOverlayOpen] = useState(false);
const previewNavigator = useAssetNavigator({
document,
assetType: 'preview',
ensureAssetUrl,
getAsset: getDocumentAsset,
prefetch: 3,
});
const navigatorUrl = previewNavigator?.currentUrl;
const navigatorCanGoPrev = Boolean(previewNavigator?.canGoPrev);
const navigatorCanGoNext = Boolean(previewNavigator?.canGoNext);
const navigatorGoPrev = previewNavigator?.goPrev;
const navigatorGoNext = previewNavigator?.goNext;
const handleZoomOpen = useCallback(() => {
if (!previewEntry?.url) {
@@ -360,34 +352,18 @@ const panelRef = useRef<HTMLDivElement | HTMLFormElement | HTMLElement | null>(n
}, [breadcrumbs, handleBreadcrumbNavigate]);
const zoomDisplay = useMemo(() => {
if (navigatorUrl && document) {
return {
url: navigatorUrl,
alt: document.title,
canGoPrev: navigatorCanGoPrev,
canGoNext: navigatorCanGoNext,
goPrev: navigatorCanGoPrev ? navigatorGoPrev : undefined,
goNext: navigatorCanGoNext ? navigatorGoNext : undefined,
};
if (!previewEntry?.url || !document) {
return null;
}
if (previewEntry?.url && document) {
return {
url: previewEntry.url,
alt: document.title,
canGoPrev: false,
canGoNext: false,
};
}
return null;
}, [
navigatorUrl,
navigatorCanGoPrev,
navigatorCanGoNext,
navigatorGoPrev,
navigatorGoNext,
previewEntry?.url,
document,
]);
const docContentType = document.content_type;
const versionContentType = document.current_version?.version?.content_type;
const normalizedContentType = previewEntry.contentType || docContentType || versionContentType || null;
return {
url: previewEntry.url,
alt: document.title,
contentType: normalizedContentType || undefined,
};
}, [previewEntry?.url, previewEntry?.contentType, document]);
const headerActions = createDocumentViewerHeaderActions({
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;
display: flex;
position: relative;
overflow: hidden;
overflow: auto;
align-items: stretch;
justify-content: flex-start;
max-height: 100%;
grid-area: viewport;
background: var(--surface-subtle);
padding: 0.75rem;
}
.document-viewer__object {
@@ -388,6 +390,13 @@
border: none;
}
.document-viewer__object--pdf {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
}
.document-viewer__object:not(.document-viewer__object--image) {
height: 100%;
}
@@ -398,6 +407,7 @@
max-width: 100%;
max-height: 100%;
object-fit: contain;
margin: auto;
}
.document-viewer--stacked .document-viewer__object:not(.document-viewer__object--image) {
@@ -421,6 +431,66 @@
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 {
font-size: 0.9rem;
color: var(--muted);
+39 -2
View File
@@ -6,7 +6,6 @@
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
z-index: 2000000;
cursor: zoom-out;
opacity: 0;
@@ -29,6 +28,13 @@
z-index: 3000000;
}
.preview-zoom__stage--pdf {
width: 100%;
height: 100%;
max-width: none;
max-height: none;
}
.preview-zoom__image {
max-width: 95vw;
max-height: 95vh;
@@ -42,7 +48,13 @@
justify-content: center;
max-width: 95vw;
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 {
@@ -111,3 +123,28 @@
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;
}
+19 -3
View File
@@ -1,5 +1,6 @@
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
module.exports = {
entry: './src/index.tsx',
output: {
@@ -37,11 +38,26 @@ module.exports = {
template: path.resolve(__dirname, 'src/index.html'),
favicon: false,
}),
new CopyWebpackPlugin({
patterns: [
{
from: path.resolve(__dirname, 'node_modules/pdfjs-dist/wasm'),
to: 'pdfjs/wasm',
},
],
}),
],
devServer: {
static: {
directory: path.join(__dirname, 'public'),
},
static: [
{
directory: path.join(__dirname, 'public'),
},
{
directory: path.resolve(__dirname, 'node_modules/pdfjs-dist/wasm'),
publicPath: '/pdfjs/wasm',
watch: false,
},
],
compress: true,
port: 5173,
historyApiFallback: true,