From a835e68ac2447921f3a5e5f2bbcf1c8d144fc0c5 Mon Sep 17 00:00:00 2001 From: "posthog-eu[bot]" <226701856+posthog-eu[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:17:55 +0000 Subject: [PATCH] Recover from PDF engine load failures instead of spinning forever MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The viewer gated on `isLoading || !engine || !pdfUrl` and only rendered an error when the `usePdfiumEngine` hook rejected. When the WASM engine load hung (never resolving or rejecting), the center pane stayed on "Loading PDF Engine..." indefinitely with no error and no way to recover, so no selected/dropped PDF ever rendered for the whole session. - Add `PdfEngineBoundary`, which owns the engine load, times it out, and surfaces an error state with a Retry button (nudging the user to update the app) instead of an infinite spinner. Retry remounts the boundary so the load re-runs from scratch. - Harden `wasmPrecompiler` to fall back to fetch + `arrayBuffer()` + `WebAssembly.compile` when `compileStreaming` is unavailable or fails (e.g. wrong MIME type / content-encoding under the `tauri://` protocol). - Add component tests reproducing the hang → error + retry transition. Generated-By: PostHog Code Task-Id: 3aa5a9d0-e661-4860-8e51-4610b2599780 --- .../public/locales/en-US/translation.toml | 3 + .../core/components/viewer/LocalEmbedPDF.tsx | 1508 +++++++++-------- .../viewer/PdfEngineBoundary.test.tsx | 107 ++ .../components/viewer/PdfEngineBoundary.tsx | 82 + .../src/core/services/wasmPrecompiler.ts | 43 +- 5 files changed, 990 insertions(+), 753 deletions(-) create mode 100644 frontend/editor/src/core/components/viewer/PdfEngineBoundary.test.tsx create mode 100644 frontend/editor/src/core/components/viewer/PdfEngineBoundary.tsx diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index c182ce2b89..0b07242f3b 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -10412,6 +10412,9 @@ disableColorFilter = "Disable Color Filter" dualPageView = "Dual Page View" enableDarkFilter = "Enable Dark Filter" enableSepiaFilter = "Enable Sepia Filter" +engineLoadErrorBody = "The PDF engine couldn't be loaded. If this keeps happening, make sure the app is up to date, then try again." +engineLoadErrorRetry = "Retry" +engineLoadErrorTitle = "Couldn't load the PDF viewer" firstPage = "First Page" lastPage = "Last Page" moreOptions = "More" diff --git a/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx b/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx index f9f8d9794a..d876fac633 100644 --- a/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx +++ b/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx @@ -8,7 +8,7 @@ import React, { import { createPluginRegistration } from "@embedpdf/core"; import type { PluginRegistry } from "@embedpdf/core"; import { EmbedPDF } from "@embedpdf/core/react"; -import { usePdfiumEngine } from "@embedpdf/engines/react"; +import { PdfEngineBoundary } from "@app/components/viewer/PdfEngineBoundary"; import { PrivateContent } from "@app/components/shared/PrivateContent"; import { useAppConfig } from "@app/contexts/AppConfigContext"; @@ -177,6 +177,9 @@ export function LocalEmbedPDF({ const { t } = useTranslation(); const { config } = useAppConfig(); const [pdfUrl, setPdfUrl] = useState(null); + // Bumped to remount PdfEngineBoundary and re-run the WASM engine load after a + // failed or hung initialisation (the user pressing "Retry"). + const [engineAttempt, setEngineAttempt] = useState(0); const [, setAnnotations] = useState< Array<{ id: string; pageIndex: number; rect: Rect }> >([]); @@ -378,11 +381,6 @@ export function LocalEmbedPDF({ ]; }, [pdfUrl, enableAnnotations, exportFileName]); - // Initialize the engine with the React hook - use local WASM for offline support - const { engine, isLoading, error } = usePdfiumEngine({ - wasmUrl: pdfiumWasmUrl, - }); - // Early return if no file or URL provided if (!file && !url) { return ( @@ -424,23 +422,10 @@ export function LocalEmbedPDF({ ); } - if (isLoading || !engine || !pdfUrl) { + if (!pdfUrl) { return ; } - if (error) { - return ( -
- -
❌
- - Error loading PDF engine: {error.message} - -
-
- ); - } - // Wrap your UI with the provider return ( @@ -454,747 +439,778 @@ export function LocalEmbedPDF({ minWidth: 0, }} > - { - // v2.0: Use registry.getPlugin() to access plugin APIs - const annotationPlugin = registry.getPlugin("annotation"); - if (!annotationPlugin || !annotationPlugin.provides) return; - - const annotationApi = annotationPlugin.provides(); - if (!annotationApi) return; - - if (enableAnnotations) { - // LooseAnnotationTool bypasses strict Partial defaults typing from the library — - // EmbedPDF accepts extra runtime properties (borderWidth, textColor, finishOnDoubleClick, - // etc.) that aren't reflected in the TypeScript model types. - type LooseAnnotationTool = { - id: string; - name: string; - interaction?: { - exclusive: boolean; - cursor: string; - textSelection?: boolean; - isRotatable?: boolean; - }; - matchScore?: (annotation: PdfAnnotationObject) => number; - defaults?: Record; - clickBehavior?: Record; - behavior?: { - deactivateToolAfterCreate?: boolean; - selectAfterCreate?: boolean; - }; - }; - const ensureTool = (tool: LooseAnnotationTool) => { - const existing = annotationApi.getTool?.(tool.id); - if (!existing) { - annotationApi.addTool(tool as unknown as AnnotationTool); - } - }; - - ensureTool({ - id: "highlight", - name: "Highlight", - interaction: { - exclusive: true, - cursor: "text", - textSelection: true, - }, - matchScore: (annotation: PdfAnnotationObject) => - annotation.type === PdfAnnotationSubtype.HIGHLIGHT ? 10 : 0, - defaults: { - type: PdfAnnotationSubtype.HIGHLIGHT, - strokeColor: "#ffd54f", - color: "#ffd54f", - opacity: 0.6, - }, - behavior: { - deactivateToolAfterCreate: false, - selectAfterCreate: true, - }, - }); - - ensureTool({ - id: "underline", - name: "Underline", - interaction: { - exclusive: true, - cursor: "text", - textSelection: true, - }, - matchScore: (annotation: PdfAnnotationObject) => - annotation.type === PdfAnnotationSubtype.UNDERLINE ? 10 : 0, - defaults: { - type: PdfAnnotationSubtype.UNDERLINE, - strokeColor: "#ffb300", - color: "#ffb300", - opacity: 1, - }, - behavior: { - deactivateToolAfterCreate: false, - selectAfterCreate: true, - }, - }); - - ensureTool({ - id: "strikeout", - name: "Strikeout", - interaction: { - exclusive: true, - cursor: "text", - textSelection: true, - }, - matchScore: (annotation: PdfAnnotationObject) => - annotation.type === PdfAnnotationSubtype.STRIKEOUT ? 10 : 0, - defaults: { - type: PdfAnnotationSubtype.STRIKEOUT, - strokeColor: "#e53935", - color: "#e53935", - opacity: 1, - }, - behavior: { - deactivateToolAfterCreate: false, - selectAfterCreate: true, - }, - }); - - ensureTool({ - id: "squiggly", - name: "Squiggly", - interaction: { - exclusive: true, - cursor: "text", - textSelection: true, - }, - matchScore: (annotation: PdfAnnotationObject) => - annotation.type === PdfAnnotationSubtype.SQUIGGLY ? 10 : 0, - defaults: { - type: PdfAnnotationSubtype.SQUIGGLY, - strokeColor: "#00acc1", - color: "#00acc1", - opacity: 1, - }, - behavior: { - deactivateToolAfterCreate: false, - selectAfterCreate: true, - }, - }); - - ensureTool({ - id: "ink", - name: "Pen", - interaction: { exclusive: true, cursor: "crosshair" }, - matchScore: (annotation: PdfAnnotationObject) => - annotation.type === PdfAnnotationSubtype.INK ? 10 : 0, - defaults: { - type: PdfAnnotationSubtype.INK, - strokeColor: "#1f2933", - color: "#1f2933", - opacity: 1, - borderWidth: 2, - lineWidth: 2, - strokeWidth: 2, - }, - behavior: { - deactivateToolAfterCreate: false, - selectAfterCreate: true, - }, - }); - - ensureTool({ - id: "inkHighlighter", - name: "Ink Highlighter", - interaction: { exclusive: true, cursor: "crosshair" }, - matchScore: (annotation: PdfAnnotationObject) => - annotation.type === PdfAnnotationSubtype.INK && - (annotation.strokeColor === "#ffd54f" || - annotation.color === "#ffd54f") - ? 8 - : 0, - defaults: { - type: PdfAnnotationSubtype.INK, - strokeColor: "#ffd54f", - color: "#ffd54f", - opacity: 0.5, - borderWidth: 6, - lineWidth: 6, - strokeWidth: 6, - }, - behavior: { - deactivateToolAfterCreate: false, - selectAfterCreate: true, - }, - }); - - ensureTool({ - id: "square", - name: "Square", - interaction: { exclusive: true, cursor: "crosshair" }, - matchScore: (annotation: PdfAnnotationObject) => - annotation.type === PdfAnnotationSubtype.SQUARE ? 10 : 0, - defaults: { - type: PdfAnnotationSubtype.SQUARE, - color: "#0000ff", // fill color (blue) - strokeColor: "#cf5b5b", // border color (reddish pink) - opacity: 0.5, - borderWidth: 1, - strokeWidth: 1, - lineWidth: 1, - }, - clickBehavior: { - enabled: true, - defaultSize: { width: 120, height: 90 }, - }, - behavior: { - deactivateToolAfterCreate: true, - selectAfterCreate: true, - }, - }); - - ensureTool({ - id: "circle", - name: "Circle", - interaction: { exclusive: true, cursor: "crosshair" }, - matchScore: (annotation: PdfAnnotationObject) => - annotation.type === PdfAnnotationSubtype.CIRCLE ? 10 : 0, - defaults: { - type: PdfAnnotationSubtype.CIRCLE, - color: "#0000ff", // fill color (blue) - strokeColor: "#cf5b5b", // border color (reddish pink) - opacity: 0.5, - borderWidth: 1, - strokeWidth: 1, - lineWidth: 1, - }, - clickBehavior: { - enabled: true, - defaultSize: { width: 100, height: 100 }, - }, - behavior: { - deactivateToolAfterCreate: true, - selectAfterCreate: true, - }, - }); - - ensureTool({ - id: "line", - name: "Line", - interaction: { exclusive: true, cursor: "crosshair" }, - matchScore: (annotation: PdfAnnotationObject) => - annotation.type === PdfAnnotationSubtype.LINE ? 10 : 0, - defaults: { - type: PdfAnnotationSubtype.LINE, - color: "#1565c0", - opacity: 1, - borderWidth: 2, - strokeWidth: 2, - lineWidth: 2, - }, - clickBehavior: { - enabled: true, - defaultLength: 120, - defaultAngle: 0, - }, - behavior: { - deactivateToolAfterCreate: true, - selectAfterCreate: true, - }, - }); - - ensureTool({ - id: "lineArrow", - name: "Arrow", - interaction: { exclusive: true, cursor: "crosshair" }, - matchScore: (annotation: PdfAnnotationObject) => { - if (annotation.type !== PdfAnnotationSubtype.LINE) return 0; - // EmbedPDF stores endStyle/lineEndingStyles at runtime; library types use lineEndings - const ann = annotation as PdfAnnotationObject & { - endStyle?: string; - lineEndingStyles?: { end?: string }; - }; - return ann.endStyle === "ClosedArrow" || - ann.lineEndingStyles?.end === "ClosedArrow" - ? 9 - : 0; - }, - defaults: { - type: PdfAnnotationSubtype.LINE, - color: "#1565c0", - opacity: 1, - borderWidth: 2, - startStyle: "None", - endStyle: "ClosedArrow", - lineEndingStyles: { start: "None", end: "ClosedArrow" }, - }, - clickBehavior: { - enabled: true, - defaultLength: 120, - defaultAngle: 0, - }, - behavior: { - deactivateToolAfterCreate: true, - selectAfterCreate: true, - }, - }); - - ensureTool({ - id: "polyline", - name: "Polyline", - interaction: { exclusive: true, cursor: "crosshair" }, - matchScore: (annotation: PdfAnnotationObject) => - annotation.type === PdfAnnotationSubtype.POLYLINE ? 10 : 0, - defaults: { - type: PdfAnnotationSubtype.POLYLINE, - color: "#1565c0", - opacity: 1, - borderWidth: 2, - }, - clickBehavior: { - enabled: true, - finishOnDoubleClick: true, - }, - behavior: { - deactivateToolAfterCreate: true, - selectAfterCreate: true, - }, - }); - - ensureTool({ - id: "polygon", - name: "Polygon", - interaction: { exclusive: true, cursor: "crosshair" }, - matchScore: (annotation: PdfAnnotationObject) => - annotation.type === PdfAnnotationSubtype.POLYGON ? 10 : 0, - defaults: { - type: PdfAnnotationSubtype.POLYGON, - color: "#0000ff", // fill color (blue) - strokeColor: "#cf5b5b", // border color (reddish pink) - opacity: 0.5, - borderWidth: 1, - }, - clickBehavior: { - enabled: true, - finishOnDoubleClick: true, - defaultSize: { width: 140, height: 100 }, - }, - behavior: { - deactivateToolAfterCreate: true, - selectAfterCreate: true, - }, - }); - - ensureTool({ - id: "text", - name: "Text", - interaction: { - exclusive: true, - cursor: "text", - isRotatable: false, - }, - matchScore: (annotation: PdfAnnotationObject) => - annotation.type === PdfAnnotationSubtype.FREETEXT ? 10 : 0, - defaults: { - type: PdfAnnotationSubtype.FREETEXT, - textColor: "#111111", - fontSize: 14, - fontFamily: "Helvetica", - opacity: 1, - interiorColor: "#fffef7", - contents: "Text", - }, - behavior: { - deactivateToolAfterCreate: true, - selectAfterCreate: true, - }, - }); - - ensureTool({ - id: "note", - name: "Note", - interaction: { - exclusive: true, - cursor: "pointer", - isRotatable: false, - }, - matchScore: (annotation: PdfAnnotationObject) => - annotation.type === PdfAnnotationSubtype.FREETEXT ? 8 : 0, - defaults: { - type: PdfAnnotationSubtype.FREETEXT, - textColor: "#1b1b1b", - color: "#ffa000", - interiorColor: "#fff8e1", - opacity: 1, - contents: "Note", - fontSize: 12, - }, - clickBehavior: { - enabled: true, - defaultSize: { width: 160, height: 100 }, - }, - behavior: { - deactivateToolAfterCreate: true, - selectAfterCreate: true, - }, - }); - - ensureTool({ - id: "stamp", - name: "Image Stamp", - interaction: { exclusive: false, cursor: "copy" }, - matchScore: (annotation: PdfAnnotationObject) => - annotation.type === PdfAnnotationSubtype.STAMP ? 5 : 0, - defaults: { - type: PdfAnnotationSubtype.STAMP, - }, - behavior: { - deactivateToolAfterCreate: true, - selectAfterCreate: true, - }, - }); - - ensureTool({ - id: "signatureStamp", - name: "Digital Signature", - interaction: { exclusive: false, cursor: "copy" }, - matchScore: () => 0, - defaults: { - type: PdfAnnotationSubtype.STAMP, - }, - }); - - ensureTool({ - id: "signatureInk", - name: "Signature Draw", - interaction: { exclusive: true, cursor: "crosshair" }, - matchScore: () => 0, - defaults: { - type: PdfAnnotationSubtype.INK, - strokeColor: "#000000", - color: "#000000", - opacity: 1.0, - borderWidth: 2, - }, - }); - - annotationApi.onAnnotationEvent((event: AnnotationEvent) => { - if (event.type === "create" && event.committed) { - setAnnotations((prev) => [ - ...prev, - { - id: event.annotation.id, - pageIndex: event.pageIndex, - rect: event.annotation.rect, - }, - ]); - - // If the annotation doesn't have customData.toolId, patch it from the active tool. - // EmbedPDF doesn't always persist customData from setToolDefaults into created annotations. - const annotationId = event.annotation.id; - const existingCustomData = ( - event.annotation as unknown as { - customData?: Record; - } - ).customData; - if (annotationId && !existingCustomData?.toolId) { - const activeTool = ( - annotationApi as unknown as { - getActiveTool?: () => { id: string } | null; - } - ).getActiveTool?.(); - if (activeTool?.id && activeTool.id !== "select") { - ( - annotationApi as unknown as { - updateAnnotation?: ( - page: number, - id: string, - patch: Record, - ) => void; - } - ).updateAnnotation?.(event.pageIndex, annotationId, { - customData: { - ...(existingCustomData ?? {}), - toolId: activeTool.id, - }, - }); - } - } - - // Auto-select the annotation after creation so the selection menu appears immediately, - // letting users discover the editing options before they click away. - if (annotationId) { - ( - annotationApi as unknown as { - selectAnnotation?: ( - pageIndex: number, - id: string, - ) => void; - } - ).selectAnnotation?.(event.pageIndex, annotationId); - } - - if (onSignatureAdded) { - onSignatureAdded(event.annotation); - } - } else if (event.type === "delete" && event.committed) { - setAnnotations((prev) => - prev.filter((ann) => ann.id !== event.annotation.id), - ); - } - }); - } - }} + setEngineAttempt((n) => n + 1)} > - - - - - - - - - - {(enableAnnotations || - enableRedaction || - isManualRedactionMode) && ( - - )} - {/* Always render RedactionAPIBridge when in manual redaction mode so buttons can switch from annotation mode */} - {(enableRedaction || isManualRedactionMode) && ( - - )} - {/* Always render SignatureAPIBridge so annotation tools (draw) can be activated even when starting in redaction mode */} - {(enableAnnotations || - enableRedaction || - isManualRedactionMode) && ( - - )} - {(enableRedaction || isManualRedactionMode) && ( - - )} - {enableAnnotations && ( - - )} + {(engine) => ( + { + // v2.0: Use registry.getPlugin() to access plugin APIs + const annotationPlugin = registry.getPlugin("annotation"); + if (!annotationPlugin || !annotationPlugin.provides) return; - - - - - - - - - } + const annotationApi = annotationPlugin.provides(); + if (!annotationApi) return; + + if (enableAnnotations) { + // LooseAnnotationTool bypasses strict Partial defaults typing from the library — + // EmbedPDF accepts extra runtime properties (borderWidth, textColor, finishOnDoubleClick, + // etc.) that aren't reflected in the TypeScript model types. + type LooseAnnotationTool = { + id: string; + name: string; + interaction?: { + exclusive: boolean; + cursor: string; + textSelection?: boolean; + isRotatable?: boolean; + }; + matchScore?: (annotation: PdfAnnotationObject) => number; + defaults?: Record; + clickBehavior?: Record; + behavior?: { + deactivateToolAfterCreate?: boolean; + selectAfterCreate?: boolean; + }; + }; + const ensureTool = (tool: LooseAnnotationTool) => { + const existing = annotationApi.getTool?.(tool.id); + if (!existing) { + annotationApi.addTool(tool as unknown as AnnotationTool); + } + }; + + ensureTool({ + id: "highlight", + name: "Highlight", + interaction: { + exclusive: true, + cursor: "text", + textSelection: true, + }, + matchScore: (annotation: PdfAnnotationObject) => + annotation.type === PdfAnnotationSubtype.HIGHLIGHT + ? 10 + : 0, + defaults: { + type: PdfAnnotationSubtype.HIGHLIGHT, + strokeColor: "#ffd54f", + color: "#ffd54f", + opacity: 0.6, + }, + behavior: { + deactivateToolAfterCreate: false, + selectAfterCreate: true, + }, + }); + + ensureTool({ + id: "underline", + name: "Underline", + interaction: { + exclusive: true, + cursor: "text", + textSelection: true, + }, + matchScore: (annotation: PdfAnnotationObject) => + annotation.type === PdfAnnotationSubtype.UNDERLINE + ? 10 + : 0, + defaults: { + type: PdfAnnotationSubtype.UNDERLINE, + strokeColor: "#ffb300", + color: "#ffb300", + opacity: 1, + }, + behavior: { + deactivateToolAfterCreate: false, + selectAfterCreate: true, + }, + }); + + ensureTool({ + id: "strikeout", + name: "Strikeout", + interaction: { + exclusive: true, + cursor: "text", + textSelection: true, + }, + matchScore: (annotation: PdfAnnotationObject) => + annotation.type === PdfAnnotationSubtype.STRIKEOUT + ? 10 + : 0, + defaults: { + type: PdfAnnotationSubtype.STRIKEOUT, + strokeColor: "#e53935", + color: "#e53935", + opacity: 1, + }, + behavior: { + deactivateToolAfterCreate: false, + selectAfterCreate: true, + }, + }); + + ensureTool({ + id: "squiggly", + name: "Squiggly", + interaction: { + exclusive: true, + cursor: "text", + textSelection: true, + }, + matchScore: (annotation: PdfAnnotationObject) => + annotation.type === PdfAnnotationSubtype.SQUIGGLY + ? 10 + : 0, + defaults: { + type: PdfAnnotationSubtype.SQUIGGLY, + strokeColor: "#00acc1", + color: "#00acc1", + opacity: 1, + }, + behavior: { + deactivateToolAfterCreate: false, + selectAfterCreate: true, + }, + }); + + ensureTool({ + id: "ink", + name: "Pen", + interaction: { exclusive: true, cursor: "crosshair" }, + matchScore: (annotation: PdfAnnotationObject) => + annotation.type === PdfAnnotationSubtype.INK ? 10 : 0, + defaults: { + type: PdfAnnotationSubtype.INK, + strokeColor: "#1f2933", + color: "#1f2933", + opacity: 1, + borderWidth: 2, + lineWidth: 2, + strokeWidth: 2, + }, + behavior: { + deactivateToolAfterCreate: false, + selectAfterCreate: true, + }, + }); + + ensureTool({ + id: "inkHighlighter", + name: "Ink Highlighter", + interaction: { exclusive: true, cursor: "crosshair" }, + matchScore: (annotation: PdfAnnotationObject) => + annotation.type === PdfAnnotationSubtype.INK && + (annotation.strokeColor === "#ffd54f" || + annotation.color === "#ffd54f") + ? 8 + : 0, + defaults: { + type: PdfAnnotationSubtype.INK, + strokeColor: "#ffd54f", + color: "#ffd54f", + opacity: 0.5, + borderWidth: 6, + lineWidth: 6, + strokeWidth: 6, + }, + behavior: { + deactivateToolAfterCreate: false, + selectAfterCreate: true, + }, + }); + + ensureTool({ + id: "square", + name: "Square", + interaction: { exclusive: true, cursor: "crosshair" }, + matchScore: (annotation: PdfAnnotationObject) => + annotation.type === PdfAnnotationSubtype.SQUARE ? 10 : 0, + defaults: { + type: PdfAnnotationSubtype.SQUARE, + color: "#0000ff", // fill color (blue) + strokeColor: "#cf5b5b", // border color (reddish pink) + opacity: 0.5, + borderWidth: 1, + strokeWidth: 1, + lineWidth: 1, + }, + clickBehavior: { + enabled: true, + defaultSize: { width: 120, height: 90 }, + }, + behavior: { + deactivateToolAfterCreate: true, + selectAfterCreate: true, + }, + }); + + ensureTool({ + id: "circle", + name: "Circle", + interaction: { exclusive: true, cursor: "crosshair" }, + matchScore: (annotation: PdfAnnotationObject) => + annotation.type === PdfAnnotationSubtype.CIRCLE ? 10 : 0, + defaults: { + type: PdfAnnotationSubtype.CIRCLE, + color: "#0000ff", // fill color (blue) + strokeColor: "#cf5b5b", // border color (reddish pink) + opacity: 0.5, + borderWidth: 1, + strokeWidth: 1, + lineWidth: 1, + }, + clickBehavior: { + enabled: true, + defaultSize: { width: 100, height: 100 }, + }, + behavior: { + deactivateToolAfterCreate: true, + selectAfterCreate: true, + }, + }); + + ensureTool({ + id: "line", + name: "Line", + interaction: { exclusive: true, cursor: "crosshair" }, + matchScore: (annotation: PdfAnnotationObject) => + annotation.type === PdfAnnotationSubtype.LINE ? 10 : 0, + defaults: { + type: PdfAnnotationSubtype.LINE, + color: "#1565c0", + opacity: 1, + borderWidth: 2, + strokeWidth: 2, + lineWidth: 2, + }, + clickBehavior: { + enabled: true, + defaultLength: 120, + defaultAngle: 0, + }, + behavior: { + deactivateToolAfterCreate: true, + selectAfterCreate: true, + }, + }); + + ensureTool({ + id: "lineArrow", + name: "Arrow", + interaction: { exclusive: true, cursor: "crosshair" }, + matchScore: (annotation: PdfAnnotationObject) => { + if (annotation.type !== PdfAnnotationSubtype.LINE) + return 0; + // EmbedPDF stores endStyle/lineEndingStyles at runtime; library types use lineEndings + const ann = annotation as PdfAnnotationObject & { + endStyle?: string; + lineEndingStyles?: { end?: string }; + }; + return ann.endStyle === "ClosedArrow" || + ann.lineEndingStyles?.end === "ClosedArrow" + ? 9 + : 0; + }, + defaults: { + type: PdfAnnotationSubtype.LINE, + color: "#1565c0", + opacity: 1, + borderWidth: 2, + startStyle: "None", + endStyle: "ClosedArrow", + lineEndingStyles: { start: "None", end: "ClosedArrow" }, + }, + clickBehavior: { + enabled: true, + defaultLength: 120, + defaultAngle: 0, + }, + behavior: { + deactivateToolAfterCreate: true, + selectAfterCreate: true, + }, + }); + + ensureTool({ + id: "polyline", + name: "Polyline", + interaction: { exclusive: true, cursor: "crosshair" }, + matchScore: (annotation: PdfAnnotationObject) => + annotation.type === PdfAnnotationSubtype.POLYLINE + ? 10 + : 0, + defaults: { + type: PdfAnnotationSubtype.POLYLINE, + color: "#1565c0", + opacity: 1, + borderWidth: 2, + }, + clickBehavior: { + enabled: true, + finishOnDoubleClick: true, + }, + behavior: { + deactivateToolAfterCreate: true, + selectAfterCreate: true, + }, + }); + + ensureTool({ + id: "polygon", + name: "Polygon", + interaction: { exclusive: true, cursor: "crosshair" }, + matchScore: (annotation: PdfAnnotationObject) => + annotation.type === PdfAnnotationSubtype.POLYGON ? 10 : 0, + defaults: { + type: PdfAnnotationSubtype.POLYGON, + color: "#0000ff", // fill color (blue) + strokeColor: "#cf5b5b", // border color (reddish pink) + opacity: 0.5, + borderWidth: 1, + }, + clickBehavior: { + enabled: true, + finishOnDoubleClick: true, + defaultSize: { width: 140, height: 100 }, + }, + behavior: { + deactivateToolAfterCreate: true, + selectAfterCreate: true, + }, + }); + + ensureTool({ + id: "text", + name: "Text", + interaction: { + exclusive: true, + cursor: "text", + isRotatable: false, + }, + matchScore: (annotation: PdfAnnotationObject) => + annotation.type === PdfAnnotationSubtype.FREETEXT + ? 10 + : 0, + defaults: { + type: PdfAnnotationSubtype.FREETEXT, + textColor: "#111111", + fontSize: 14, + fontFamily: "Helvetica", + opacity: 1, + interiorColor: "#fffef7", + contents: "Text", + }, + behavior: { + deactivateToolAfterCreate: true, + selectAfterCreate: true, + }, + }); + + ensureTool({ + id: "note", + name: "Note", + interaction: { + exclusive: true, + cursor: "pointer", + isRotatable: false, + }, + matchScore: (annotation: PdfAnnotationObject) => + annotation.type === PdfAnnotationSubtype.FREETEXT ? 8 : 0, + defaults: { + type: PdfAnnotationSubtype.FREETEXT, + textColor: "#1b1b1b", + color: "#ffa000", + interiorColor: "#fff8e1", + opacity: 1, + contents: "Note", + fontSize: 12, + }, + clickBehavior: { + enabled: true, + defaultSize: { width: 160, height: 100 }, + }, + behavior: { + deactivateToolAfterCreate: true, + selectAfterCreate: true, + }, + }); + + ensureTool({ + id: "stamp", + name: "Image Stamp", + interaction: { exclusive: false, cursor: "copy" }, + matchScore: (annotation: PdfAnnotationObject) => + annotation.type === PdfAnnotationSubtype.STAMP ? 5 : 0, + defaults: { + type: PdfAnnotationSubtype.STAMP, + }, + behavior: { + deactivateToolAfterCreate: true, + selectAfterCreate: true, + }, + }); + + ensureTool({ + id: "signatureStamp", + name: "Digital Signature", + interaction: { exclusive: false, cursor: "copy" }, + matchScore: () => 0, + defaults: { + type: PdfAnnotationSubtype.STAMP, + }, + }); + + ensureTool({ + id: "signatureInk", + name: "Signature Draw", + interaction: { exclusive: true, cursor: "crosshair" }, + matchScore: () => 0, + defaults: { + type: PdfAnnotationSubtype.INK, + strokeColor: "#000000", + color: "#000000", + opacity: 1.0, + borderWidth: 2, + }, + }); + + annotationApi.onAnnotationEvent((event: AnnotationEvent) => { + if (event.type === "create" && event.committed) { + setAnnotations((prev) => [ + ...prev, + { + id: event.annotation.id, + pageIndex: event.pageIndex, + rect: event.annotation.rect, + }, + ]); + + // If the annotation doesn't have customData.toolId, patch it from the active tool. + // EmbedPDF doesn't always persist customData from setToolDefaults into created annotations. + const annotationId = event.annotation.id; + const existingCustomData = ( + event.annotation as unknown as { + customData?: Record; + } + ).customData; + if (annotationId && !existingCustomData?.toolId) { + const activeTool = ( + annotationApi as unknown as { + getActiveTool?: () => { id: string } | null; + } + ).getActiveTool?.(); + if (activeTool?.id && activeTool.id !== "select") { + ( + annotationApi as unknown as { + updateAnnotation?: ( + page: number, + id: string, + patch: Record, + ) => void; + } + ).updateAnnotation?.(event.pageIndex, annotationId, { + customData: { + ...(existingCustomData ?? {}), + toolId: activeTool.id, + }, + }); + } + } + + // Auto-select the annotation after creation so the selection menu appears immediately, + // letting users discover the editing options before they click away. + if (annotationId) { + ( + annotationApi as unknown as { + selectAnnotation?: ( + pageIndex: number, + id: string, + ) => void; + } + ).selectAnnotation?.(event.pageIndex, annotationId); + } + + if (onSignatureAdded) { + onSignatureAdded(event.annotation); + } + } else if (event.type === "delete" && event.committed) { + setAnnotations((prev) => + prev.filter((ann) => ann.id !== event.annotation.id), + ); + } + }); + } + }} > - {(documentId) => ( - <> - - - { - return ( - - -
e.preventDefault()} - onDrop={(e) => e.preventDefault()} - onDragOver={(e) => e.preventDefault()} + + + + + + + + + + {(enableAnnotations || + enableRedaction || + isManualRedactionMode) && ( + + )} + {/* Always render RedactionAPIBridge when in manual redaction mode so buttons can switch from annotation mode */} + {(enableRedaction || isManualRedactionMode) && ( + + )} + {/* Always render SignatureAPIBridge so annotation tools (draw) can be activated even when starting in redaction mode */} + {(enableAnnotations || + enableRedaction || + isManualRedactionMode) && ( + + )} + {(enableRedaction || isManualRedactionMode) && ( + + )} + {enableAnnotations && ( + + )} + + + + + + + + + + } + > + {(documentId) => ( + <> + + + { + return ( + -
- -
+
e.preventDefault()} + onDrop={(e) => e.preventDefault()} + onDragOver={(e) => e.preventDefault()} + > +
+ +
- + -
- ( - +
+ ( + + )} + /> +
+ + + {/* ButtonAppearanceOverlay — renders PDF-native button visuals as bitmaps */} + {enableFormFill && file && ( + )} - /> -
- - {/* ButtonAppearanceOverlay — renders PDF-native button visuals as bitmaps */} - {enableFormFill && file && ( - - )} - - {/* FormFieldOverlay for interactive form filling */} - {enableFormFill && ( - - )} - - {/* SignatureFieldOverlay — bitmaps of digital-signature appearances */} - {file && ( - - )} - - {/* AnnotationLayer for annotation editing and annotation-based redactions */} - {(enableAnnotations || enableRedaction) && ( - ( - + {/* FormFieldOverlay for interactive form filling */} + {enableFormFill && ( + )} - style={ - !showBakedAnnotations - ? { - opacity: 0, - pointerEvents: "none", - } - : undefined - } - /> - )} - {enableRedaction && ( - ( - + {/* SignatureFieldOverlay — bitmaps of digital-signature appearances */} + {file && ( + )} - /> - )} - {/* LinkLayer – uses EmbedPDF annotation state for link rendering */} - + {/* AnnotationLayer for annotation editing and annotation-based redactions */} + {(enableAnnotations || + enableRedaction) && ( + ( + + )} + style={ + !showBakedAnnotations + ? { + opacity: 0, + pointerEvents: "none", + } + : undefined + } + /> + )} - {/* Signature preview overlay (opt-in; off by default) */} - {signatureOverlayEnabled && ( - - )} -
- -
- ); - }} - /> -
-
- {enableAnnotations && ( - - - + {enableRedaction && ( + ( + + )} + /> + )} + + {/* LinkLayer – uses EmbedPDF annotation state for link rendering */} + + + {/* Signature preview overlay (opt-in; off by default) */} + {signatureOverlayEnabled && ( + + )} +
+
+
+ ); + }} + /> +
+
+ {enableAnnotations && ( + + + + )} + )} - - )} -
-
-
+ + +
+ )} + ); diff --git a/frontend/editor/src/core/components/viewer/PdfEngineBoundary.test.tsx b/frontend/editor/src/core/components/viewer/PdfEngineBoundary.test.tsx new file mode 100644 index 0000000000..f91425079b --- /dev/null +++ b/frontend/editor/src/core/components/viewer/PdfEngineBoundary.test.tsx @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, act, fireEvent } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import type { ReactNode } from "react"; + +// Controllable stand-in for the library engine hook so we can simulate the +// three states that matter: still loading, loaded, and errored. +let engineState: { engine: unknown; isLoading: boolean; error: Error | null } = + { + engine: null, + isLoading: true, + error: null, + }; + +vi.mock("@embedpdf/engines/react", () => ({ + usePdfiumEngine: () => engineState, +})); + +import { PdfEngineBoundary } from "@app/components/viewer/PdfEngineBoundary"; + +const renderBoundary = (onRetry = vi.fn(), timeoutMs = 1000) => { + const utils = render( + + + {() =>
PDF CONTENT
} +
+
, + ); + return { onRetry, ...utils }; +}; + +const wrap = (node: ReactNode) => ( + {node} +); + +describe("PdfEngineBoundary", () => { + beforeEach(() => { + engineState = { engine: null, isLoading: true, error: null }; + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("shows the loading fallback while the engine is initialising", () => { + renderBoundary(); + expect(screen.getByText("Loading PDF Engine...")).toBeInTheDocument(); + expect(screen.queryByText("viewer.engineLoadErrorTitle")).toBeNull(); + }); + + it("surfaces an error with a retry after the load times out (no infinite spinner)", () => { + const { onRetry } = renderBoundary(vi.fn(), 1000); + + // Still spinning before the timeout elapses. + expect(screen.getByText("Loading PDF Engine...")).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + // The spinner is replaced by an actionable error state. + expect(screen.queryByText("Loading PDF Engine...")).toBeNull(); + expect( + screen.getByText("viewer.engineLoadErrorTitle"), + ).toBeInTheDocument(); + + const retry = screen.getByText("viewer.engineLoadErrorRetry"); + fireEvent.click(retry); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it("surfaces an error immediately when the engine load rejects", () => { + engineState = { + engine: null, + isLoading: false, + error: new Error("boom"), + }; + render( + wrap( + + {() =>
PDF CONTENT
} +
, + ), + ); + expect( + screen.getByText("viewer.engineLoadErrorTitle"), + ).toBeInTheDocument(); + }); + + it("renders children once the engine is ready", () => { + engineState = { engine: {}, isLoading: false, error: null }; + render( + wrap( + + {() =>
PDF CONTENT
} +
, + ), + ); + expect(screen.getByText("PDF CONTENT")).toBeInTheDocument(); + expect(screen.queryByText("Loading PDF Engine...")).toBeNull(); + }); +}); diff --git a/frontend/editor/src/core/components/viewer/PdfEngineBoundary.tsx b/frontend/editor/src/core/components/viewer/PdfEngineBoundary.tsx new file mode 100644 index 0000000000..abfa102228 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/PdfEngineBoundary.tsx @@ -0,0 +1,82 @@ +import React, { useEffect, useState } from "react"; +import { usePdfiumEngine } from "@embedpdf/engines/react"; +import { Center, Stack, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui/Button"; +import ToolLoadingFallback from "@app/components/tools/ToolLoadingFallback"; + +/** Engine instance produced by usePdfiumEngine once initialisation succeeds. */ +type PdfiumEngine = NonNullable["engine"]>; + +/** + * How long to wait for the PDFium WASM engine to initialise before treating the + * load as failed. The engine normally initialises in well under a second; if it + * has not resolved after this window it has almost certainly hung (e.g. the WASM + * fetch stalled or the worker never reported back), so we surface an error with + * a retry instead of spinning forever. + */ +const DEFAULT_ENGINE_LOAD_TIMEOUT_MS = 30_000; + +interface PdfEngineBoundaryProps { + /** Absolute URL of the pdfium.wasm binary to load. */ + wasmUrl: string; + /** Invoked when the user asks to retry after a failed/hung load. */ + onRetry: () => void; + /** Override the load timeout (mainly for tests). */ + timeoutMs?: number; + /** Rendered once the engine is ready. */ + children: (engine: PdfiumEngine) => React.ReactNode; +} + +/** + * Loads the PDFium WASM engine and gates its children on success. + * + * The underlying `usePdfiumEngine` hook only re-initialises when its `wasmUrl` + * changes, so retrying is handled by the parent remounting this component via a + * `key`. Because the boundary owns the hook, a fresh mount runs the load again + * from scratch. + * + * Without this boundary a failed or hung WASM load left the viewer showing an + * infinite "Loading PDF Engine..." spinner with no error and no way to recover. + */ +export function PdfEngineBoundary({ + wasmUrl, + onRetry, + timeoutMs = DEFAULT_ENGINE_LOAD_TIMEOUT_MS, + children, +}: PdfEngineBoundaryProps) { + const { t } = useTranslation(); + const { engine, isLoading, error } = usePdfiumEngine({ wasmUrl }); + const [timedOut, setTimedOut] = useState(false); + + useEffect(() => { + if (engine || error) return; + const timer = setTimeout(() => setTimedOut(true), timeoutMs); + return () => clearTimeout(timer); + }, [engine, error, isLoading, timeoutMs]); + + if (engine) { + return <>{children(engine)}; + } + + if (error || timedOut) { + return ( +
+ +
⚠️
+ + {t("viewer.engineLoadErrorTitle")} + + + {t("viewer.engineLoadErrorBody")} + + +
+
+ ); + } + + return ; +} diff --git a/frontend/editor/src/core/services/wasmPrecompiler.ts b/frontend/editor/src/core/services/wasmPrecompiler.ts index dccd0f12e2..fe67da6f4e 100644 --- a/frontend/editor/src/core/services/wasmPrecompiler.ts +++ b/frontend/editor/src/core/services/wasmPrecompiler.ts @@ -28,24 +28,53 @@ export const pdfiumWasmModulePromise = new Promise( }, ); +/** + * Compile the WASM without streaming by fetching the whole binary first. + * + * `compileStreaming` requires the response to be served with the + * `application/wasm` MIME type and no incompatible `Content-Encoding`. Under the + * `tauri://` asset protocol (and behind some proxies) those headers aren't + * guaranteed, which makes streaming compilation throw. Fetching the bytes and + * compiling them directly sidesteps the MIME/encoding requirement entirely. + */ +async function compileFromArrayBuffer(): Promise { + try { + const response = await fetch(pdfiumWasmUrl); + if (!response.ok) { + throw new Error(`Unexpected response ${response.status} for pdfium.wasm`); + } + const bytes = await response.arrayBuffer(); + return await WebAssembly.compile(bytes); + } catch (err) { + console.warn("Eager WASM ArrayBuffer compilation failed:", err); + return null; + } +} + export function startEagerWasmCompilation(): void { if (compilationStarted) return; compilationStarted = true; - if ( - typeof WebAssembly === "object" && - typeof WebAssembly.compileStreaming === "function" - ) { + if (typeof WebAssembly !== "object") { + resolvePromise(null); + return; + } + + // Prefer streaming compilation, but fall back to fetching the bytes and + // compiling them directly when streaming isn't available or fails (e.g. wrong + // MIME type / content-encoding under the tauri:// protocol). Resolving null on + // total failure lets pdfiumService fall back to its own instantiation path. + if (typeof WebAssembly.compileStreaming === "function") { WebAssembly.compileStreaming(fetch(pdfiumWasmUrl)) .then(resolvePromise) .catch((err) => { console.warn( - "Eager WASM compilation failed or not supported in this environment:", + "Eager WASM streaming compilation failed, falling back to ArrayBuffer:", err, ); - resolvePromise(null); + compileFromArrayBuffer().then(resolvePromise); }); } else { - resolvePromise(null); + compileFromArrayBuffer().then(resolvePromise); } }