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); } }