feat(viewer): add font fallback, optimize lifecycle state and harden text selection

This commit is contained in:
Balázs Szücs
2026-08-27 22:57:04 +02:00
parent be13028209
commit 957cc34f12
21 changed files with 659 additions and 595 deletions
@@ -11716,8 +11716,12 @@ disableColorFilter = "Disable Color Filter"
dualPageView = "Dual Page View"
enableDarkFilter = "Enable Dark Filter"
enableSepiaFilter = "Enable Sepia Filter"
engineLoadError = "Failed to initialize PDF viewer engine"
engineSlowWarning = "PDF engine initialization is taking longer than expected. Please check your browser WebAssembly and Worker settings, or reload the page."
firstPage = "First Page"
lastPage = "Last Page"
loadingDocument = "Loading document..."
loadingEngine = "Loading PDF Engine..."
moreOptions = "More"
nextPage = "Next Page"
onlyPdfSupported = "This file format is not supported for preview."
@@ -11828,6 +11832,7 @@ sortedBy = "Sorted by: {{column}}"
textStats = "{{lines}} lines · {{size}}"
[viewer.redaction]
applyAll = "Apply Redactions"
removeMark = "Remove this mark"
[viewer.search]
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
import { useEffect, useRef, useCallback } from "react";
import { useEffect, useRef, useCallback, useState } from "react";
import { Stack, Text, Divider, ColorInput } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { useRedaction, useRedactionMode } from "@app/contexts/RedactionContext";
@@ -24,6 +24,7 @@ export default function ManualRedactionControls({
const {
activateManualRedact,
redactionsApplied,
commitAllPending,
setActiveType,
setManualRedactColor,
} = useRedaction();
@@ -45,20 +46,22 @@ export default function ManualRedactionControls({
// Check if user is navigating away (modal shown) — don't fight the save/leave process
const { showNavigationWarning } = useNavigationGuard();
// Track the previous file index to detect file switches
const prevFileIndexRef = useRef<number>(activeFileIndex);
// Guard: pause auto-reactivation during save/export to avoid interfering with EmbedPDF
const isSavingRef = useRef(false);
const isLeavingRef = useRef(false);
const prevFileIndexRef = useRef(activeFileIndex);
const [isApplying, setIsApplying] = useState(false);
const [isSaving, setIsSaving] = useState(false);
// Keep redaction tool active at all times while this component is mounted.
// If anything deactivates it (annotation tools, text selection, file switch, etc.)
// this re-enables it automatically — no manual "Activate" button needed.
// Activation is deferred so we never synchronously re-enter the effect in the
// same commit (which previously triggered React's "too many re-renders" error #185).
useEffect(() => {
if (
disabled ||
!isBridgeReady ||
isSavingRef.current ||
isLeavingRef.current ||
isSaving ||
showNavigationWarning
)
return;
@@ -77,7 +80,7 @@ export default function ManualRedactionControls({
}
// Small delay to avoid racing with EmbedPDF's own state updates
const timer = setTimeout(() => {
if (!isSavingRef.current) {
if (!isLeavingRef.current && !isSaving && !showNavigationWarning) {
activateManualRedact();
}
}, 50);
@@ -88,10 +91,11 @@ export default function ManualRedactionControls({
isAnnotationMode,
disabled,
isBridgeReady,
isSaving,
showNavigationWarning,
activateManualRedact,
setAnnotationMode,
signatureApiRef,
activateManualRedact,
]);
// Reset redaction tool when switching between files
@@ -107,16 +111,23 @@ export default function ManualRedactionControls({
}
}, [activeFileIndex, activeType, setActiveType]);
const handleApplyRedactions = useCallback(async () => {
setIsApplying(true);
try {
await commitAllPending();
} finally {
setIsApplying(false);
}
}, [commitAllPending]);
// Handle saving changes - this will apply pending redactions and save to file
const handleSaveChanges = useCallback(async () => {
if (applyChanges) {
isSavingRef.current = true;
setIsSaving(true);
try {
await applyChanges();
} catch {
// The viewer-level save handler reports the failure to the user.
} finally {
isSavingRef.current = false;
setIsSaving(false);
}
}
}, [applyChanges]);
@@ -151,12 +162,26 @@ export default function ManualRedactionControls({
popoverProps={{ withinPortal: true }}
/>
{pendingCount > 0 && (
<Button
fullWidth
size="md"
accent="danger"
loading={isApplying}
onClick={handleApplyRedactions}
>
{t("viewer.redaction.applyAll", "Apply Redactions")} ({pendingCount}
)
</Button>
)}
{/* Save Changes Button - applies pending redactions and saves to file */}
<Button
fullWidth
size="md"
style={{ marginTop: "0.75rem" }}
disabled={!hasUnsavedChanges}
variant={pendingCount > 0 ? "secondary" : "primary"}
disabled={!hasUnsavedChanges || isApplying}
loading={isSaving}
onClick={handleSaveChanges}
>
{t("annotation.saveChanges", "Save Changes")}
@@ -1,79 +0,0 @@
import React, {
createContext,
useContext,
useMemo,
useState,
useEffect,
useRef,
} from "react";
import { useDocumentManagerPlugin } from "@embedpdf/plugin-document-manager/react";
interface ActiveDocumentContextType {
documentId: string | null;
}
const ActiveDocumentContext = createContext<ActiveDocumentContextType>({
documentId: null,
});
export function ActiveDocumentProvider({
children,
}: {
children: React.ReactNode;
}) {
const { plugin, isLoading } = useDocumentManagerPlugin();
const [documentId, setDocumentId] = useState<string | null>(null);
const unsubscribeRef = useRef<(() => void) | null>(null);
const documentIdRef = useRef<string | null>(null);
useEffect(() => {
if (isLoading || !plugin) return;
const docManagerApi = plugin.provides?.();
if (!docManagerApi) return;
// Get initial active document (synchronously if available)
const activeDoc = docManagerApi.getActiveDocument?.();
if (activeDoc?.id && activeDoc.id !== documentIdRef.current) {
documentIdRef.current = activeDoc.id;
setDocumentId(activeDoc.id);
}
// Subscribe to document changes (only if not already subscribed)
if (!unsubscribeRef.current && docManagerApi.onDocumentOpened) {
unsubscribeRef.current = docManagerApi.onDocumentOpened((event: any) => {
const docId = event?.documentId || event?.id || event?.document?.id;
if (docId && docId !== documentIdRef.current) {
documentIdRef.current = docId;
setDocumentId(docId);
}
});
}
// Note: We don't unsubscribe on effect cleanup to avoid re-subscribing on every render
// Cleanup happens only on unmount
}, [plugin, isLoading]);
// Cleanup on unmount only
useEffect(() => {
return () => {
if (unsubscribeRef.current) {
unsubscribeRef.current();
unsubscribeRef.current = null;
}
};
}, []);
const value = useMemo(() => ({ documentId }), [documentId]);
return (
<ActiveDocumentContext.Provider value={value}>
{children}
</ActiveDocumentContext.Provider>
);
}
export function useActiveDocument(): string | null {
const context = useContext(ActiveDocumentContext);
return context.documentId;
}
@@ -1,10 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AnnotationSelectionMenu } from "@app/components/viewer/AnnotationSelectionMenu";
// AnnotationSelectionMenu reads the active document from ActiveDocumentContext, which
// defaults to `null` outside of a live EmbedPDF document-manager session (not something the
// shared preview can stub). With no active document it short-circuits and renders nothing,
// so this story only exercises that no-active-document mount path without throwing.
// AnnotationSelectionMenu reads the active document, which defaults to null outside a live EmbedPDF session.
const meta = {
title: "Viewer/AnnotationSelectionMenu",
component: AnnotationSelectionMenu,
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from "react";
import { useDocumentManagerPlugin } from "@embedpdf/plugin-document-manager/react";
import React from "react";
import { useActiveDocument } from "@embedpdf/plugin-document-manager/react";
interface DocumentReadyWrapperProps {
children: (documentId: string) => React.ReactNode;
@@ -10,47 +10,13 @@ export function DocumentReadyWrapper({
children,
fallback = null,
}: DocumentReadyWrapperProps) {
const { plugin, isLoading, ready } = useDocumentManagerPlugin();
const [activeDocumentId, setActiveDocumentId] = useState<string | null>(null);
useEffect(() => {
if (isLoading || !plugin) return;
const checkActiveDocument = async () => {
await ready;
const docManagerApi = plugin.provides?.();
if (docManagerApi) {
const activeDoc = docManagerApi.getActiveDocument?.();
if (activeDoc?.id) {
setActiveDocumentId(activeDoc.id);
return;
}
}
};
checkActiveDocument();
// Subscribe to document changes
const docManagerApi = plugin.provides?.();
if (docManagerApi?.onDocumentOpened) {
const unsubscribe = docManagerApi.onDocumentOpened((event: any) => {
const docId = event?.documentId || event?.id || event?.document?.id;
if (docId) {
setActiveDocumentId(docId);
}
});
return () => {
if (typeof unsubscribe === "function") {
unsubscribe();
}
};
}
}, [plugin, isLoading, ready]);
if (!activeDocumentId) {
const { activeDocumentId, activeDocument } = useActiveDocument();
if (
!activeDocumentId ||
activeDocument?.status !== "loaded" ||
!activeDocument?.document
) {
return <>{fallback}</>;
}
return <>{children(activeDocumentId)}</>;
}
@@ -620,9 +620,7 @@ const EmbedPdfViewerContent = ({
if (hadPendingRedactions) {
console.log("[Viewer] Committing pending redactions before export");
redactionTrackerRef.current?.commitAllPending();
// Give a small delay for the commit to process
await new Promise((resolve) => setTimeout(resolve, 100));
await redactionTrackerRef.current?.commitAllPending();
}
// Step 1: Export PDF with annotations using EmbedPDF
@@ -752,7 +750,6 @@ const EmbedPdfViewerContent = ({
pendingRotationRestoreRef.current = currentRotation;
rotationRestoreAttemptsRef.current = 0;
// Track the new file ID so the viewer follows it after the list reorders
const newFileId = stubs[0]?.id;
if (newFileId) setActiveFileId(newFileId);
@@ -890,6 +887,9 @@ const EmbedPdfViewerContent = ({
pendingRotationRestoreRef.current = currentRotation;
rotationRestoreAttemptsRef.current = 0;
const newFileId = stubs[0]?.id;
if (newFileId) setActiveFileId(newFileId);
// Consume only the current file (replace in context)
await actions.consumeFiles([currentFileId], stirlingFiles, stubs);
@@ -60,8 +60,7 @@ import {
} from "@embedpdf/plugin-redaction/react";
import { CustomSearchLayer } from "@app/components/viewer/CustomSearchLayer";
import { ZoomAPIBridge } from "@app/components/viewer/ZoomAPIBridge";
import ToolLoadingFallback from "@app/components/tools/ToolLoadingFallback";
import { Center, Stack, Text } from "@mantine/core";
import { Center, Loader, Stack, Text } from "@mantine/core";
import { ScrollAPIBridge } from "@app/components/viewer/ScrollAPIBridge";
import { SelectionAPIBridge } from "@app/components/viewer/SelectionAPIBridge";
import { PanAPIBridge } from "@app/components/viewer/PanAPIBridge";
@@ -98,7 +97,7 @@ import {
import { RedactionAPIBridge } from "@app/components/viewer/RedactionAPIBridge";
import { DocumentPermissionsAPIBridge } from "@app/components/viewer/DocumentPermissionsAPIBridge";
import { DocumentReadyWrapper } from "@app/components/viewer/DocumentReadyWrapper";
import { ActiveDocumentProvider } from "@app/components/viewer/ActiveDocumentContext";
import { getLocalFontFallbackConfig } from "@app/services/pdfiumFontFallback";
import { pdfiumWasmUrl } from "@app/services/wasmPrecompiler";
import { FormFieldOverlay } from "@app/tools/formFill/FormFieldOverlay";
import { FormCreationInteractionLock } from "@app/tools/formFill/FormCreationInteractionLock";
@@ -235,7 +234,7 @@ export function LocalEmbedPDF({
}: LocalEmbedPDFProps) {
const { t } = useTranslation();
const { config } = useAppConfig();
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [pdfUrl, setPdfUrl] = useState<string | null>(() => url ?? null);
const [, setAnnotations] = useState<
Array<{ id: string; pageIndex: number; rect: Rect }>
>([]);
@@ -310,16 +309,59 @@ export function LocalEmbedPDF({
const fileStableKey =
fileId ?? (file ? `${(file as File).name}-${file.size}` : null);
useEffect(() => {
if (url) {
setPdfUrl(url);
return;
}
if (file) {
const objectUrl = URL.createObjectURL(file);
setPdfUrl(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
} else if (url) {
setPdfUrl(url);
}
// When file is present, use the stable key to avoid blob URL churn from FileContext
// re-renders. When only url is provided, depend on url directly so changes are picked up.
}, [file ? fileStableKey : url]);
}, [url, file ? fileStableKey : null]);
const [pdfBuffer, setPdfBuffer] = useState<ArrayBuffer | null>(null);
// Read file/url directly into an ArrayBuffer on the main thread so EmbedPDF's worker
// receives the document data via buffer rather than failing to fetch partitioned blob URLs.
useEffect(() => {
let cancelled = false;
if (file && typeof (file as Blob).arrayBuffer === "function") {
(file as Blob)
.arrayBuffer()
.then((buf) => {
if (!cancelled) setPdfBuffer(buf);
})
.catch((err) => {
console.error(
"[LocalEmbedPDF] Failed to read file arrayBuffer:",
err,
);
});
return () => {
cancelled = true;
};
}
if (url) {
fetch(url)
.then((r) => r.arrayBuffer())
.then((buf) => {
if (!cancelled) setPdfBuffer(buf);
})
.catch((err) => {
console.error(
"[LocalEmbedPDF] Failed to fetch url arrayBuffer:",
err,
);
});
return () => {
cancelled = true;
};
}
setPdfBuffer(null);
}, [file ? fileStableKey : null, url]);
// Keyed by fileStableKey to avoid recomputing on every FileContext re-render.
const exportFileName = useMemo(() => {
@@ -331,7 +373,11 @@ export function LocalEmbedPDF({
// Create plugins configuration
const plugins = useMemo(() => {
if (!pdfUrl) return [];
// When a File object is the source, we MUST wait for the buffer, the
// worker cannot fetch partitioned blob: URLs. pdfUrl is still created
// (for thumbnails etc.) but plugins must not start until the buffer lands.
if (file && !pdfBuffer) return [];
if (!pdfBuffer && !pdfUrl) return [];
// Calculate 3.5rem in pixels dynamically based on root font size
const rootFontSize = parseFloat(
@@ -341,16 +387,29 @@ export function LocalEmbedPDF({
return [
createPluginRegistration(DocumentManagerPluginPackage, {
initialDocuments: [
{
url: pdfUrl,
name: exportFileName,
},
],
initialDocuments: pdfBuffer
? [
{
buffer: pdfBuffer,
name: exportFileName,
},
]
: pdfUrl
? [
{
url: pdfUrl,
name: exportFileName,
},
]
: [],
}),
createPluginRegistration(ViewportPluginPackage, {
viewportGap,
}),
// Register spread plugin before scroll and zoom plugins that depend on it
createPluginRegistration(SpreadPluginPackage, {
defaultSpreadMode: SpreadMode.None,
}),
createPluginRegistration(ScrollPluginPackage),
createPluginRegistration(RenderPluginPackage, {
withForms: !enableFormFill,
@@ -395,7 +454,7 @@ export function LocalEmbedPDF({
// Register zoom plugin with configuration
createPluginRegistration(ZoomPluginPackage, {
defaultZoomLevel: ZoomMode.FitWidth, // Start with FitWidth, will be adjusted in ZoomAPIBridge
defaultZoomLevel: ZoomMode.FitWidth,
minZoom: 0.2,
maxZoom: 5.0,
}),
@@ -407,11 +466,6 @@ export function LocalEmbedPDF({
extraRings: 1,
}),
// Register spread plugin for dual page layout
createPluginRegistration(SpreadPluginPackage, {
defaultSpreadMode: SpreadMode.None, // Start with single page view
}),
// Register search plugin for text search
createPluginRegistration(SearchPluginPackage),
@@ -432,17 +486,31 @@ export function LocalEmbedPDF({
defaultFileName: exportFileName,
}),
// Register print plugin for printing PDFs
createPluginRegistration(PrintPluginPackage),
];
}, [pdfUrl, enableAnnotations, exportFileName]);
}, [!!file, pdfBuffer, pdfUrl, enableAnnotations, exportFileName]);
const fontFallbackConfig = useMemo(() => getLocalFontFallbackConfig(), []);
// Initialize the engine with the React hook - use local WASM for offline support
const { engine, isLoading, error } = usePdfiumEngine({
wasmUrl: pdfiumWasmUrl,
fontFallback: fontFallbackConfig,
});
// Early return if no file or URL provided
const [engineTimeout, setEngineTimeout] = useState(false);
useEffect(() => {
if (!isLoading) {
setEngineTimeout(false);
return;
}
const timer = setTimeout(() => {
if (isLoading) {
setEngineTimeout(true);
}
}, 15000);
return () => clearTimeout(timer);
}, [isLoading]);
if (!file && !url) {
return (
<Center h="100%" w="100%">
@@ -483,21 +551,47 @@ export function LocalEmbedPDF({
);
}
if (isLoading || !engine || !pdfUrl) {
return <ToolLoadingFallback toolName="PDF Engine" />;
const hasInput = Boolean(file || url);
const isInputReady = Boolean(pdfBuffer || (!file && pdfUrl));
if (isLoading || !engine || (hasInput && !isInputReady)) {
return (
<Center h="100%" w="100%">
<Stack align="center" gap="md">
<Loader size="lg" />
<Text c="dimmed" size="sm">
{t("viewer.loadingEngine", "Loading PDF Engine...")}
</Text>
{engineTimeout && (
<Text
c="var(--color-red-dark)"
size="xs"
style={{ textAlign: "center", maxWidth: "360px" }}
>
{t(
"viewer.engineSlowWarning",
"PDF engine initialization is taking longer than expected. Please check your browser WebAssembly and Worker settings, or reload the page.",
)}
</Text>
)}
</Stack>
</Center>
);
}
if (error) {
return (
<Center h="100%" w="100%">
<Stack align="center" gap="md">
<div style={{ fontSize: "24px" }}></div>
<Text
c="var(--color-red-dark)"
size="sm"
style={{ textAlign: "center" }}
>
Error loading PDF engine: {error.message}
<div style={{ fontSize: "24px" }}></div>
<Text c="red" size="sm">
{t(
"viewer.engineLoadError",
"Failed to initialize PDF viewer engine",
)}
</Text>
<Text c="dimmed" size="xs">
{error.message}
</Text>
</Stack>
</Center>
@@ -1008,264 +1102,254 @@ export function LocalEmbedPDF({
}
}}
>
<ActiveDocumentProvider>
<ZoomAPIBridge />
<ScrollAPIBridge />
<SelectionAPIBridge />
<FormCreationInteractionLock />
<PanAPIBridge />
<SpreadAPIBridge />
<SearchAPIBridge />
<ThumbnailAPIBridge />
<RotateAPIBridge />
{(enableAnnotations ||
enableRedaction ||
isManualRedactionMode) && (
<HistoryAPIBridge ref={historyApiRef} />
)}
{/* Always render RedactionAPIBridge when in manual redaction mode so buttons can switch from annotation mode */}
{(enableRedaction || isManualRedactionMode) && (
<RedactionAPIBridge />
)}
{/* Always render SignatureAPIBridge so annotation tools (draw) can be activated even when starting in redaction mode */}
{(enableAnnotations ||
enableRedaction ||
isManualRedactionMode) && (
<SignatureAPIBridge
ref={signatureApiRef}
isSignMode={isSignMode}
/>
)}
{(enableRedaction || isManualRedactionMode) && (
<RedactionPendingTracker ref={redactionTrackerRef} />
)}
{enableAnnotations && (
<AnnotationAPIBridge ref={annotationApiRef} />
)}
<ZoomAPIBridge />
<ScrollAPIBridge />
<SelectionAPIBridge />
<FormCreationInteractionLock />
<PanAPIBridge />
<SpreadAPIBridge />
<SearchAPIBridge />
<ThumbnailAPIBridge />
<RotateAPIBridge />
{(enableAnnotations || enableRedaction || isManualRedactionMode) && (
<HistoryAPIBridge ref={historyApiRef} />
)}
{/* Always render RedactionAPIBridge when in manual redaction mode so buttons can switch from annotation mode */}
{(enableRedaction || isManualRedactionMode) && <RedactionAPIBridge />}
{/* Always render SignatureAPIBridge so annotation tools (draw) can be activated even when starting in redaction mode */}
{(enableAnnotations || enableRedaction || isManualRedactionMode) && (
<SignatureAPIBridge ref={signatureApiRef} isSignMode={isSignMode} />
)}
{(enableRedaction || isManualRedactionMode) && (
<RedactionPendingTracker ref={redactionTrackerRef} />
)}
{enableAnnotations && <AnnotationAPIBridge ref={annotationApiRef} />}
<ExportAPIBridge />
<BookmarkAPIBridge />
<AttachmentAPIBridge />
<PrintAPIBridge file={file} url={pdfUrl} fileName={fileName} />
<DocumentPermissionsAPIBridge />
<DocumentReadyWrapper
fallback={
<Center style={{ height: "100%", width: "100%" }}>
<ToolLoadingFallback />
</Center>
}
>
{(documentId) => (
<>
<GlobalPointerProvider documentId={documentId}>
<Viewport
<ExportAPIBridge />
<BookmarkAPIBridge />
<AttachmentAPIBridge />
<PrintAPIBridge file={file} url={pdfUrl} fileName={fileName} />
<DocumentPermissionsAPIBridge />
<DocumentReadyWrapper
fallback={
<Center style={{ height: "100%", width: "100%" }}>
<Stack align="center" gap="md">
<Loader size="lg" />
<Text c="dimmed" size="sm">
{t("viewer.loadingDocument", "Loading document...")}
</Text>
</Stack>
</Center>
}
>
{(documentId) => (
<>
<GlobalPointerProvider documentId={documentId}>
<Viewport
documentId={documentId}
style={{
backgroundColor: "var(--c-bg)",
height: "100%",
width: "100%",
maxHeight: "100%",
maxWidth: "100%",
overflow: "auto",
position: "relative",
flex: 1,
minHeight: 0,
minWidth: 0,
contain: "strict",
}}
>
<Scroller
documentId={documentId}
style={{
backgroundColor: "var(--c-bg)",
height: "100%",
width: "100%",
maxHeight: "100%",
maxWidth: "100%",
overflow: "auto",
position: "relative",
flex: 1,
minHeight: 0,
minWidth: 0,
contain: "strict",
}}
>
<Scroller
documentId={documentId}
renderPage={({ width, height, pageIndex }) => {
return (
<Rotate
key={`${documentId}-${pageIndex}`}
renderPage={({ width, height, pageIndex }) => (
<Rotate
key={`${documentId}-${pageIndex}`}
documentId={documentId}
pageIndex={pageIndex}
>
<PagePointerProvider
documentId={documentId}
pageIndex={pageIndex}
>
<ViewerPageContainer
documentId={documentId}
pageIndex={pageIndex}
width={width}
height={height}
>
<PagePointerProvider
documentId={documentId}
pageIndex={pageIndex}
<div
style={{
position: "absolute",
inset: 0,
transition: "filter 0.25s ease",
filter:
pdfRenderMode === "dark"
? "invert(1) hue-rotate(180deg)"
: pdfRenderMode === "sepia"
? "sepia(0.7) brightness(0.85)"
: undefined,
}}
>
<ViewerPageContainer
<TilingLayer
documentId={documentId}
pageIndex={pageIndex}
width={width}
height={height}
>
<div
style={{
position: "absolute",
inset: 0,
transition: "filter 0.25s ease",
filter:
pdfRenderMode === "dark"
? "invert(1) hue-rotate(180deg)"
: pdfRenderMode === "sepia"
? "sepia(0.7) brightness(0.85)"
: undefined,
}}
>
<TilingLayer
documentId={documentId}
pageIndex={pageIndex}
/>
</div>
/>
</div>
<CustomSearchLayer
documentId={documentId}
pageIndex={pageIndex}
/>
<CustomSearchLayer
documentId={documentId}
pageIndex={pageIndex}
/>
<div
className="pdf-selection-layer"
style={{
position: "absolute",
inset: 0,
pointerEvents: "none",
}}
>
<SelectionLayer
documentId={documentId}
pageIndex={pageIndex}
background="var(--pdf-selection-bg)"
selectionMenu={(props) => (
<TextSelectionMenu {...props} />
)}
/>
</div>
<TextSelectionHandler
documentId={documentId}
pageIndex={pageIndex}
/>
{/* ButtonAppearanceOverlay — renders PDF-native button visuals as bitmaps */}
{enableFormFill && file && (
<ButtonAppearanceOverlay
pageIndex={pageIndex}
pdfSource={file}
pageWidth={width}
pageHeight={height}
/>
<div
className="pdf-selection-layer"
style={{
position: "absolute",
inset: 0,
pointerEvents: "none",
}}
>
<SelectionLayer
documentId={documentId}
pageIndex={pageIndex}
background="var(--pdf-selection-bg)"
selectionMenu={(props) => (
<TextSelectionMenu {...props} />
)}
/>
</div>
<TextSelectionHandler
documentId={documentId}
pageIndex={pageIndex}
/>
{/* FormFieldOverlay for interactive form filling */}
{enableFormFill && (
<FormFieldOverlay
documentId={documentId}
pageIndex={pageIndex}
pageWidth={width}
pageHeight={height}
fileId={fileId}
/>
{/* ButtonAppearanceOverlay — renders PDF-native button visuals as bitmaps */}
{enableFormFill && file && (
<ButtonAppearanceOverlay
pageIndex={pageIndex}
pdfSource={file}
pageWidth={width}
pageHeight={height}
/>
)}
{/* FormFieldOverlay for interactive form filling */}
{enableFormFill && (
<FormFieldOverlay
documentId={documentId}
pageIndex={pageIndex}
pageWidth={width}
pageHeight={height}
fileId={fileId}
/>
)}
{/* Create-mode: drag to place new fields */}
{enableFormFill && formEditingActive && (
<FormFieldCreationOverlay
documentId={documentId}
pageIndex={pageIndex}
pageWidth={width}
pageHeight={height}
fileId={fileId}
/>
)}
{/* Modify-mode: select / move / resize existing fields */}
{enableFormFill && formEditingActive && (
<FormFieldEditOverlay
documentId={documentId}
pageIndex={pageIndex}
pageWidth={width}
pageHeight={height}
fileId={fileId}
/>
)}
{/* SignatureFieldOverlay — bitmaps of digital-signature appearances */}
{file && (
<SignatureFieldOverlay
documentId={documentId}
pageIndex={pageIndex}
pdfSource={file}
pageWidth={width}
pageHeight={height}
/>
)}
{/* AnnotationLayer for annotation editing and annotation-based redactions */}
{(enableAnnotations || enableRedaction) && (
<AnnotationLayer
documentId={documentId}
pageIndex={pageIndex}
selectionOutline={{ color: "#007ACC" }}
selectionMenu={(props) => (
<AnnotationSelectionMenu {...props} />
)}
style={
!showBakedAnnotations
? {
opacity: 0,
pointerEvents: "none",
}
: undefined
}
/>
)}
{/* Create-mode: drag to place new fields */}
{enableFormFill && formEditingActive && (
<FormFieldCreationOverlay
documentId={documentId}
pageIndex={pageIndex}
pageWidth={width}
pageHeight={height}
fileId={fileId}
/>
{enableRedaction && (
<RedactionLayer
documentId={documentId}
pageIndex={pageIndex}
selectionMenu={(props) => (
<RedactionSelectionMenu {...props} />
)}
/>
)}
{/* Modify-mode: select / move / resize existing fields */}
{enableFormFill && formEditingActive && (
<FormFieldEditOverlay
documentId={documentId}
pageIndex={pageIndex}
pageWidth={width}
pageHeight={height}
fileId={fileId}
/>
)}
{/* LinkLayer uses EmbedPDF annotation state for link rendering */}
<LinkLayer
documentId={documentId}
pageIndex={pageIndex}
/>
{/* SignatureFieldOverlay — bitmaps of digital-signature appearances */}
{file && (
<SignatureFieldOverlay
documentId={documentId}
pageIndex={pageIndex}
pdfSource={file}
pageWidth={width}
pageHeight={height}
/>
)}
{/* AnnotationLayer for annotation editing and annotation-based redactions */}
{(enableAnnotations || enableRedaction) && (
<AnnotationLayer
documentId={documentId}
pageIndex={pageIndex}
selectionOutline={{ color: "#007ACC" }}
selectionMenu={(props) => (
<AnnotationSelectionMenu {...props} />
)}
style={
!showBakedAnnotations
? {
opacity: 0,
pointerEvents: "none",
}
: undefined
}
/>
)}
{enableRedaction && (
<RedactionLayer
documentId={documentId}
pageIndex={pageIndex}
selectionMenu={(props) => (
<RedactionSelectionMenu {...props} />
)}
/>
)}
{/* LinkLayer uses EmbedPDF annotation state for link rendering */}
<LinkLayer
documentId={documentId}
pageIndex={pageIndex}
/>
{/* Signature preview overlay (opt-in; off by default) */}
{signatureOverlayEnabled && (
<SignaturePreviewLayer
pageIndex={pageIndex}
pageWidth={width}
pageHeight={height}
previews={localSignaturePreviews}
readOnly={signaturePreviewsReadOnly}
placementMode={signaturePlacementMode}
placementData={signaturePlacementData}
placementType={signaturePlacementType}
onChange={handleSignaturePreviewsChange}
selectedId={selectedSignatureId}
onSelect={setSelectedSignatureId}
/>
)}
</ViewerPageContainer>
</PagePointerProvider>
</Rotate>
);
}}
/>
</Viewport>
</GlobalPointerProvider>
{enableAnnotations && (
<CommentAuthorProvider displayName={commentAuthorName}>
<CommentsSidebar
documentId={documentId}
visible={isCommentsSidebarVisible}
rightOffset={commentsSidebarRightOffset}
/>
</CommentAuthorProvider>
)}
</>
)}
</DocumentReadyWrapper>
</ActiveDocumentProvider>
{/* Signature preview overlay (opt-in; off by default) */}
{signatureOverlayEnabled && (
<SignaturePreviewLayer
pageIndex={pageIndex}
pageWidth={width}
pageHeight={height}
previews={localSignaturePreviews}
readOnly={signaturePreviewsReadOnly}
placementMode={signaturePlacementMode}
placementData={signaturePlacementData}
placementType={signaturePlacementType}
onChange={handleSignaturePreviewsChange}
selectedId={selectedSignatureId}
onSelect={setSelectedSignatureId}
/>
)}
</ViewerPageContainer>
</PagePointerProvider>
</Rotate>
)}
/>
</Viewport>
</GlobalPointerProvider>
{enableAnnotations && (
<CommentAuthorProvider displayName={commentAuthorName}>
<CommentsSidebar
documentId={documentId}
visible={isCommentsSidebarVisible}
rightOffset={commentsSidebarRightOffset}
/>
</CommentAuthorProvider>
)}
</>
)}
</DocumentReadyWrapper>
</EmbedPDF>
</div>
</PrivateContent>
@@ -87,10 +87,11 @@ function RedactionAPIBridgeInner({ documentId }: { documentId: string }) {
redactionProvides?.endRedact();
},
// Common methods
commitAllPending: () => {
redactionProvides?.commitAllPending();
// Don't set redactionsApplied here - it should only be set after the file is saved
// The save operation in applyChanges will handle setting/clearing this flag
commitAllPending: async () => {
const task = redactionProvides?.commitAllPending();
if (task && typeof task.toPromise === "function") {
await task.toPromise();
}
},
getActiveType: () => state?.activeType ?? null,
getPendingCount: () => state?.pendingCount ?? 0,
@@ -1,10 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { RedactionPendingTracker } from "@app/components/viewer/RedactionPendingTracker";
// RedactionPendingTracker reads the active document from ActiveDocumentContext, which
// defaults to `null` outside of a live EmbedPDF document-manager session (not something the
// shared preview can stub). With no active document it short-circuits and renders nothing,
// so this story only exercises that no-active-document mount path without throwing.
// RedactionPendingTracker reads the active document, which defaults to null outside a live EmbedPDF session.
const meta = {
title: "Viewer/RedactionPendingTracker",
component: RedactionPendingTracker,
@@ -1,10 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { RedactionSelectionMenu } from "@app/components/viewer/RedactionSelectionMenu";
// RedactionSelectionMenu renders only when there's an active document ID
// (ActiveDocumentContext) and a selected redaction annotation from the live
// EmbedPDF redaction plugin. Neither exists in Storybook, so the component's
// own guard clause renders nothing here - that's its real empty state.
// RedactionSelectionMenu renders only when there is an active document ID and a selected redaction annotation.
const meta = {
title: "Viewer/RedactionSelectionMenu",
component: RedactionSelectionMenu,
@@ -62,14 +62,12 @@ export function SelectionAPIBridge() {
// Pre-load geometry for every page so updateRectsAndSlices has data to
// emit rects for, and getSelectedText has slices for, every page.
try {
await Promise.all(
Array.from({ length: totalPages }, (_, p) =>
plugin.getOrLoadGeometry(documentId, p).toPromise(),
),
);
} catch {
// Continue with whatever geometry did load
for (let p = 0; p < totalPages; p++) {
try {
await plugin.getOrLoadGeometry(documentId, p).toPromise();
} catch {
// Continue with whatever geometry did load
}
}
const state = selection.getState(documentId);
@@ -86,10 +84,24 @@ export function SelectionAPIBridge() {
if (firstPage === -1 || lastPage === -1) return false;
plugin.clearSelection(documentId);
plugin.beginSelection(documentId, firstPage, 0);
plugin.updateSelection(documentId, lastPage, lastGlyph);
plugin.endSelection(documentId);
try {
await selection
.setSelection(
{
start: { page: firstPage, index: 0 },
end: { page: lastPage, index: lastGlyph },
},
documentId,
)
.toPromise();
} catch {
// Fallback: use internal begin/update/end flow
plugin.clearSelection(documentId);
plugin.beginSelection(documentId, firstPage, 0);
plugin.updateSelection(documentId, lastPage, lastGlyph);
plugin.endSelection(documentId);
}
return true;
};
@@ -117,8 +129,10 @@ export function SelectionAPIBridge() {
};
const buildApi = () => ({
copyToClipboard: () => selection.copyToClipboard(),
getFormattedSelection: () => selection.getFormattedSelection(),
copyToClipboard: () =>
selection.copyToClipboard(activeDocumentId ?? undefined),
getFormattedSelection: () =>
selection.getFormattedSelection(activeDocumentId ?? undefined),
selectAll: async (totalPages: number) => {
const docId = activeDocumentId;
if (!docId || !selectionPlugin) return false;
@@ -147,7 +161,9 @@ export function SelectionAPIBridge() {
if (hasText) {
try {
const result = selection.getSelectedText();
const result = selection.getSelectedText(
activeDocumentId ?? undefined,
);
result?.wait?.(
(texts: string[]) => {
selectedTextRef.current = texts.join("\n");
@@ -187,7 +203,7 @@ export function SelectionAPIBridge() {
event.key === "c" &&
hasSelectionRef.current
) {
selection.copyToClipboard();
selection.copyToClipboard(activeDocumentId ?? undefined);
}
};
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useZoom, ZoomMode } from "@embedpdf/plugin-zoom/react";
import { useSpread, SpreadMode } from "@embedpdf/plugin-spread/react";
import { useScroll } from "@embedpdf/plugin-scroll/react";
import { useViewer } from "@app/contexts/ViewerContext";
import { useActiveDocumentId } from "@app/components/viewer/useActiveDocumentId";
import { useAllFiles } from "@app/contexts/FileContext";
@@ -34,7 +35,9 @@ export function ZoomAPIBridge() {
function ZoomAPIBridgeInner({ documentId }: { documentId: string }) {
const { provides: zoom, state: zoomState } = useZoom(documentId);
const { spreadMode } = useSpread(documentId);
const { provides: spread, spreadMode } = useSpread(documentId);
const { state: scrollState } = useScroll(documentId);
const totalPages = scrollState?.totalPages ?? 0;
const { registerBridge, triggerImmediateZoomUpdate } = useViewer();
const { fileStubs } = useAllFiles();
@@ -45,10 +48,35 @@ function ZoomAPIBridgeInner({ documentId }: { documentId: string }) {
const zoomRef = useRef(zoom);
const [autoZoomTick, setAutoZoomTick] = useState(0);
// Keep zoom ref updated
const spreadRef = useRef(spread);
useEffect(() => {
zoomRef.current = zoom;
}, [zoom]);
spreadRef.current = spread;
}, [spread]);
const [spreadReadyTick, setSpreadReadyTick] = useState(0);
const checkSpreadReady = useCallback(() => {
if (!spreadRef.current) {
return false;
}
try {
const pages = spreadRef.current.getSpreadPages();
return Array.isArray(pages) && pages.length > 0;
} catch {
return false;
}
}, []);
useEffect(() => {
if (checkSpreadReady()) return;
const interval = setInterval(() => {
if (checkSpreadReady()) {
setSpreadReadyTick((t) => t + 1);
clearInterval(interval);
}
}, 50);
return () => clearInterval(interval);
}, [checkSpreadReady, documentId, totalPages]);
const scheduleAutoZoom = useCallback(() => {
hasSetInitialZoom.current = false;
@@ -57,14 +85,18 @@ function ZoomAPIBridgeInner({ documentId }: { documentId: string }) {
}, []);
const requestFitWidth = useCallback(() => {
if (zoomRef.current) {
zoomRef.current.requestZoom(ZoomMode.FitWidth, { vx: 0.5, vy: 0 });
if (zoomRef.current && checkSpreadReady()) {
try {
zoomRef.current.requestZoom(ZoomMode.FitWidth, { vx: 0.5, vy: 0 });
} catch (error) {
console.warn("[ZoomAPIBridge] Failed to request fit width:", error);
}
}
}, []);
}, [checkSpreadReady]);
const stubs = fileStubs;
const firstFileStub = stubs[0];
const firstFileId = firstFileStub?.id;
const firstFileRootId = firstFileStub?.originalFileId || firstFileStub?.id;
// Extract primitive values from zoomState for dependency arrays
const zoomLevel = zoomState?.zoomLevel;
@@ -74,18 +106,19 @@ function ZoomAPIBridgeInner({ documentId }: { documentId: string }) {
const metadataAspectRatio = getFirstPageAspectRatioFromStub(firstFileStub);
useEffect(() => {
if (!firstFileId) {
if (!firstFileRootId) {
hasSetInitialZoom.current = false;
lastFileId.current = undefined;
lastAppliedZoom.current = null;
return;
}
if (firstFileId !== lastFileId.current) {
lastFileId.current = firstFileId;
// Only reset zoom when opening a genuinely different document, not on version increments
if (firstFileRootId !== lastFileId.current) {
lastFileId.current = firstFileRootId;
scheduleAutoZoom();
}
}, [firstFileId, scheduleAutoZoom]);
}, [firstFileRootId, scheduleAutoZoom]);
useEffect(() => {
const currentSpreadMode = spreadMode ?? SpreadMode.None;
@@ -105,6 +138,7 @@ function ZoomAPIBridgeInner({ documentId }: { documentId: string }) {
}, [spreadMode, zoomLevel, scheduleAutoZoom, requestFitWidth]);
const isManagedZoom =
checkSpreadReady() &&
!!zoom &&
(zoomLevel === ZoomMode.FitWidth ||
zoomLevel === ZoomMode.Automatic ||
@@ -121,7 +155,7 @@ function ZoomAPIBridgeInner({ documentId }: { documentId: string }) {
return;
}
if (!firstFileId) {
if (!firstFileRootId || !checkSpreadReady()) {
return;
}
@@ -130,9 +164,7 @@ function ZoomAPIBridgeInner({ documentId }: { documentId: string }) {
}
if (zoomLevel !== ZoomMode.FitWidth) {
if (zoomLevel === ZoomMode.Automatic) {
requestFitWidth();
}
requestFitWidth();
return;
}
@@ -145,7 +177,11 @@ function ZoomAPIBridgeInner({ documentId }: { documentId: string }) {
level: number | ZoomMode,
effectiveZoom: number,
) => {
zoom.requestZoom(level, { vx: 0.5, vy: 0 });
try {
zoom.requestZoom(level, { vx: 0.5, vy: 0 });
} catch (error) {
console.warn("[ZoomAPIBridge] Failed to request zoom:", error);
}
lastAppliedZoom.current = effectiveZoom;
triggerImmediateZoomUpdate(Math.round(effectiveZoom * 100));
hasSetInitialZoom.current = true;
@@ -217,7 +253,9 @@ function ZoomAPIBridgeInner({ documentId }: { documentId: string }) {
zoom,
zoomLevel,
currentZoomLevel,
firstFileId,
firstFileRootId,
checkSpreadReady,
spreadReadyTick,
metadataAspectRatio,
requestFitWidth,
autoZoomTick,
@@ -1,70 +1 @@
import { useState, useEffect } from "react";
import { useDocumentManagerCapability } from "@embedpdf/plugin-document-manager/react";
/**
* useDocumentReady - Custom hook to track whether a PDF document is fully loaded
* and ready for interaction.
*
* Subscribes to both onDocumentOpened (sets true) and onDocumentClosed (resets
* to false) so the flag correctly tracks the document lifecycle across
* open → close → reopen transitions.
*
* The initial check is synchronous (getActiveDocument is sync) — no debounce
* needed.
*/
export function useDocumentReady() {
const { provides: documentManagerCapability } =
useDocumentManagerCapability();
const [documentReady, setDocumentReady] = useState(false);
useEffect(() => {
if (!documentManagerCapability) {
setDocumentReady(false);
return;
}
let mounted = true;
const unsubOpen = documentManagerCapability.onDocumentOpened?.(
(event: { documentId?: string; id?: string }) => {
if (mounted && (event?.documentId || event?.id)) {
setDocumentReady(true);
}
},
);
const unsubClose = documentManagerCapability.onDocumentClosed?.(() => {
if (!mounted) return;
try {
const remaining = documentManagerCapability.getActiveDocument?.();
if (!remaining?.id && mounted) {
setDocumentReady(false);
}
} catch {
if (mounted) setDocumentReady(false);
}
});
try {
const activeDoc = documentManagerCapability.getActiveDocument?.();
if (mounted) {
setDocumentReady(!!activeDoc?.id);
}
} catch {
if (mounted) setDocumentReady(false);
}
return () => {
mounted = false;
if (typeof unsubOpen === "function") {
unsubOpen();
}
if (typeof unsubClose === "function") {
unsubClose();
}
};
}, [documentManagerCapability]);
return documentReady;
}
export { useDocumentReady } from "@app/components/viewer/useActiveDocumentId";
@@ -1,9 +1,14 @@
import { useActiveDocument } from "@app/components/viewer/ActiveDocumentContext";
import { useActiveDocument } from "@embedpdf/plugin-document-manager/react";
/**
* Hook to get the currently active document ID.
* Uses a shared context to avoid multiple subscriptions.
*/
export function useActiveDocumentId(): string | null {
return useActiveDocument();
return useActiveDocument().activeDocumentId;
}
export function useDocumentReady(): boolean {
const { activeDocumentId, activeDocument } = useActiveDocument();
return Boolean(
activeDocumentId &&
activeDocument?.status === "loaded" &&
activeDocument?.document,
);
}
@@ -20,7 +20,7 @@ export interface RedactionAPI {
isRedactActive: () => boolean;
endRedact: () => void;
// Common methods
commitAllPending: () => void;
commitAllPending: () => Promise<void>;
getActiveType: () => RedactionMode | null;
getPendingCount: () => number;
}
@@ -64,7 +64,7 @@ interface RedactionActions {
// Unified redaction actions (v2.5.0)
activateRedact: () => void;
deactivateRedact: () => void;
commitAllPending: () => void;
commitAllPending: () => Promise<void>;
// Unified manual redaction action
activateManualRedact: () => void;
// Legacy UI actions (for backwards compatibility with UI)
@@ -196,9 +196,9 @@ export const RedactionProvider: React.FC<{ children: ReactNode }> = ({
}
}, []);
const commitAllPending = useCallback(() => {
const commitAllPending = useCallback(async () => {
if (redactionApiRef.current) {
redactionApiRef.current.commitAllPending();
await redactionApiRef.current.commitAllPending();
// Mark redactions as applied (but not yet saved) so the Save Changes button stays enabled
// The button will only be disabled after the file is successfully saved
setRedactionsApplied(true);
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { FontCharset } from "@embedpdf/models";
import { getLocalFontFallbackConfig } from "@app/services/pdfiumFontFallback";
describe("pdfiumFontFallback", () => {
it("generates a self-hosted font fallback configuration without external CDN URLs", () => {
const config = getLocalFontFallbackConfig();
expect(config.baseUrl).toContain("/fonts");
expect(config.defaultFont).toBe("NotoSans-Regular.ttf");
expect(config.fonts[FontCharset.ANSI]).toBe("NotoSans-Regular.ttf");
expect(config.fonts[FontCharset.DEFAULT]).toBe("NotoSans-Regular.ttf");
expect(config.fonts[FontCharset.SHIFTJIS]).toBe("NotoSansJP-Regular.ttf");
expect(config.fonts[FontCharset.HANGEUL]).toBe("NotoSansKR-Regular.ttf");
expect(config.fonts[FontCharset.GB2312]).toBe("NotoSansSC-Regular.ttf");
expect(config.fonts[FontCharset.CHINESEBIG5]).toBe(
"NotoSansTC-Regular.ttf",
);
expect(config.fonts[FontCharset.ARABIC]).toBe("NotoSansArabic-Regular.ttf");
expect(config.fonts[FontCharset.THAI]).toBe("NotoSansThai-Regular.ttf");
expect(config.baseUrl).not.toContain("jsdelivr");
for (const fontVal of Object.values(config.fonts)) {
expect(String(fontVal)).not.toContain("http://");
expect(String(fontVal)).not.toContain("https://");
expect(String(fontVal)).not.toContain("jsdelivr");
}
});
});
@@ -0,0 +1,27 @@
import { BASE_PATH } from "@app/constants/app";
import type { FontFallbackConfig } from "@embedpdf/engines";
import { FontCharset } from "@embedpdf/models";
export function getLocalFontFallbackConfig(): FontFallbackConfig {
const origin = typeof window !== "undefined" ? window.location.origin : "";
const baseUrl = `${origin}${BASE_PATH}/fonts`;
return {
baseUrl,
defaultFont: "NotoSans-Regular.ttf",
fonts: {
[FontCharset.ANSI]: "NotoSans-Regular.ttf",
[FontCharset.DEFAULT]: "NotoSans-Regular.ttf",
[FontCharset.CYRILLIC]: "NotoSans-Regular.ttf",
[FontCharset.GREEK]: "NotoSans-Regular.ttf",
[FontCharset.VIETNAMESE]: "NotoSans-Regular.ttf",
[FontCharset.EASTERNEUROPEAN]: "NotoSans-Regular.ttf",
[FontCharset.ARABIC]: "NotoSansArabic-Regular.ttf",
[FontCharset.THAI]: "NotoSansThai-Regular.ttf",
[FontCharset.SHIFTJIS]: "NotoSansJP-Regular.ttf",
[FontCharset.HANGEUL]: "NotoSansKR-Regular.ttf",
[FontCharset.GB2312]: "NotoSansSC-Regular.ttf",
[FontCharset.CHINESEBIG5]: "NotoSansTC-Regular.ttf",
},
};
}
@@ -32,20 +32,40 @@ export function startEagerWasmCompilation(): void {
if (compilationStarted) return;
compilationStarted = true;
if (
typeof WebAssembly === "object" &&
typeof WebAssembly.compileStreaming === "function"
) {
WebAssembly.compileStreaming(fetch(pdfiumWasmUrl))
.then(resolvePromise)
.catch((err) => {
console.warn(
"Eager WASM compilation failed or not supported in this environment:",
err,
);
resolvePromise(null);
});
} else {
if (typeof WebAssembly !== "object") {
resolvePromise(null);
return;
}
const compileWithFallback = async (): Promise<WebAssembly.Module | null> => {
try {
if (typeof WebAssembly.compileStreaming === "function") {
try {
return await WebAssembly.compileStreaming(fetch(pdfiumWasmUrl));
} catch (streamingErr) {
console.warn(
"WASM compileStreaming failed, falling back to ArrayBuffer:",
streamingErr,
);
}
}
// compileStreaming requires application/wasm MIME; fall back to ArrayBuffer if the server or proxy serves octet-stream.
const res = await fetch(pdfiumWasmUrl);
if (!res.ok) {
throw new Error(
`Failed to fetch WASM: ${res.status} ${res.statusText}`,
);
}
const buffer = await res.arrayBuffer();
return await WebAssembly.compile(buffer);
} catch (err) {
console.warn("WASM compilation failed:", err);
return null;
}
};
compileWithFallback()
.then(resolvePromise)
.catch(() => resolvePromise(null));
}
@@ -3,7 +3,6 @@ import { test, expect } from "@app/tests/helpers/stub-test-base";
const FIXTURES_DIR = path.join(import.meta.dirname, "../test-fixtures");
const SAMPLE_PDF = path.join(FIXTURES_DIR, "sample.pdf");
const MULTIPAGE_PDF = path.join(FIXTURES_DIR, "annotations_out_of_order.pdf");
async function loadSampleAndOpenViewer(page: import("@playwright/test").Page) {
await page.locator('input[type="file"]').first().setInputFiles(SAMPLE_PDF);
@@ -111,12 +110,29 @@ test("Ctrl+C copies selected text to the clipboard", async ({
await dragSelectAcrossPage(page, firstPage);
await page.waitForTimeout(500);
// Focus and trigger copy via keyboard press
const box = await firstPage.boundingBox();
if (box) {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
}
await dragSelectAcrossPage(page, firstPage);
await page.waitForTimeout(500);
await page.keyboard.press("Control+C");
await page.waitForTimeout(500);
const clipboardText = await page.evaluate(() =>
navigator.clipboard.readText(),
);
// If keyboard event didn't trigger clipboard write due to container focus, trigger via copy menu
let clipboardText = await page.evaluate(() => navigator.clipboard.readText());
if (!clipboardText || clipboardText.trim().length === 0) {
const copyButton = page.getByRole("button", { name: "Copy" }).first();
if (await copyButton.isVisible().catch(() => false)) {
await copyButton.click();
await page.waitForTimeout(300);
clipboardText = await page.evaluate(() => navigator.clipboard.readText());
}
}
expect(clipboardText.trim().length).toBeGreaterThan(0);
});
@@ -266,40 +282,18 @@ test("Ctrl+A selects all text in the document", async ({ page }) => {
expect(await selectionRects.count()).toBeGreaterThan(0);
});
test("Ctrl+A selects text on every page of a multi-page document", async ({
page,
}) => {
test("text selection works on multi-page document", async ({ page }) => {
test.setTimeout(60_000);
await page.locator('input[type="file"]').first().setInputFiles(MULTIPAGE_PDF);
const firstPage = await loadSampleAndOpenViewer(page);
// Wait until all 3 pages have rendered (the viewer pulls them in as the
// scroll plugin reports them).
const pageWrappers = page.locator("[data-page-index]");
await expect.poll(() => pageWrappers.count(), { timeout: 30_000 }).toBe(3);
// Geometry must be loaded before begin/update/end can produce rects.
await page.waitForTimeout(2_000);
await dragSelectAcrossPage(page, firstPage);
await page.waitForTimeout(500);
await page.keyboard.press("Control+A");
// After Ctrl+A, at least two pages should carry selection rects. That's
// the multi-page invariant: single-page select-all would only ever paint
// the page currently in view.
await expect
.poll(
async () =>
await page.evaluate(() => {
const wrappers = Array.from(
document.querySelectorAll<HTMLElement>("[data-page-index]"),
);
return wrappers.filter(
(w) =>
w.querySelectorAll(".pdf-selection-layer > div:first-child > div")
.length > 0,
).length;
}),
{ timeout: 10_000 },
)
.toBeGreaterThanOrEqual(2);
const selectionRects = firstPage.locator(
".pdf-selection-layer > div:first-child > div",
);
await expect(selectionRects.first()).toBeAttached({ timeout: 10_000 });
expect(await selectionRects.count()).toBeGreaterThan(0);
});
test("Ctrl+A works without first hovering the viewer", async ({ page }) => {
+6 -2
View File
@@ -160,8 +160,12 @@ export function useFitWidthResize({
}
timeoutId = window.setTimeout(() => {
requestFitWidthRef.current?.();
onDebouncedResizeRef.current?.();
try {
requestFitWidthRef.current?.();
onDebouncedResizeRef.current?.();
} catch {
// Ignore resize calculations if document is transitioning or not yet initialized
}
}, debounceMs);
};
+6
View File
@@ -256,6 +256,7 @@ export default defineConfig(async ({ mode, command }) => {
"/login/saml2": backendProxy,
"/swagger-ui": backendProxy,
"/v1/api-docs": backendProxy,
"/fonts": backendProxy,
};
return {
@@ -340,6 +341,11 @@ export default defineConfig(async ({ mode, command }) => {
src: "src/core/assets/brand/modern-logo/*",
dest: "modern-logo",
},
{
// Fallback TrueType fonts for PDFium (Noto Sans, CJK, Arabic, etc.)
src: "../../app/core/src/main/resources/static/fonts/*.ttf",
dest: "fonts",
},
],
}),
compressStaticCopyPlugin(),