diff --git a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx index 75b893cb38..dd69308297 100644 --- a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx @@ -40,6 +40,7 @@ import type { PDFDict, PDFNumber } from "@cantoo/pdf-lib"; import { useWheelZoom } from "@app/hooks/useWheelZoom"; import { useFormFill } from "@app/tools/formFill/FormFillContext"; import { FormSaveBar } from "@app/tools/formFill/FormSaveBar"; +import { FORM_APPLY_EVENT } from "@app/tools/formFill/formFillEvents"; import { useViewerKeyCommand } from "@app/hooks/useViewerKeyCommand"; // ─── Measure dictionary extraction ──────────────────────────────────────────── @@ -787,8 +788,8 @@ const EmbedPdfViewerContent = ({ handleFormApply(blob); } }; - window.addEventListener("formfill:apply", handler); - return () => window.removeEventListener("formfill:apply", handler); + window.addEventListener(FORM_APPLY_EVENT, handler); + return () => window.removeEventListener(FORM_APPLY_EVENT, handler); }, [handleFormApply]); // Apply layer visibility changes - reload the modified PDF into the viewer diff --git a/frontend/editor/src/core/tools/formFill/FormFieldCreatePanel.tsx b/frontend/editor/src/core/tools/formFill/FormFieldCreatePanel.tsx index 24f14fccf6..871091e934 100644 --- a/frontend/editor/src/core/tools/formFill/FormFieldCreatePanel.tsx +++ b/frontend/editor/src/core/tools/formFill/FormFieldCreatePanel.tsx @@ -31,6 +31,7 @@ import { FIELD_TYPE_COLOR, } from "@app/tools/formFill/fieldMeta"; import { FormFieldPropertyEditor } from "@app/tools/formFill/FormFieldPropertyEditor"; +import { useFormCommit } from "@app/tools/formFill/useFormCommit"; import styles from "@app/tools/formFill/FormFill.module.css"; interface FormFieldCreatePanelProps { @@ -63,8 +64,7 @@ export function FormFieldCreatePanel({ } = useFormFill(); const [expandedId, setExpandedId] = useState(null); - const [committing, setCommitting] = useState(false); - const [error, setError] = useState(null); + const { committing, error, commit } = useFormCommit(onApplied); // Auto-expand the property editor of a freshly-drawn field so its settings // (especially options for choice/radio) are visible immediately. @@ -76,24 +76,14 @@ export function FormFieldCreatePanel({ prevCountRef.current = pendingFields.length; }, [pendingFields]); - const handleCommit = useCallback(async () => { + const handleCommit = useCallback(() => { if (!currentFile || pendingFields.length === 0) return; - setCommitting(true); - setError(null); - try { - const blob = await commitNewFields(currentFile); - const event = new CustomEvent("formfill:apply", { detail: { blob } }); - window.dispatchEvent(event); - onApplied?.(blob); - } catch (err: any) { - setError( - err?.message || t("formFill.create.failed", "Failed to add fields"), - ); - console.error("[FormFill] add-fields failed:", err); - } finally { - setCommitting(false); - } - }, [currentFile, pendingFields, commitNewFields, onApplied, t]); + commit( + () => commitNewFields(currentFile), + "formFill.create.failed", + "Failed to add fields", + ); + }, [currentFile, pendingFields, commitNewFields, commit]); return (
diff --git a/frontend/editor/src/core/tools/formFill/FormFieldCreationOverlay.tsx b/frontend/editor/src/core/tools/formFill/FormFieldCreationOverlay.tsx index 03a2a16734..cf888bf5b9 100644 --- a/frontend/editor/src/core/tools/formFill/FormFieldCreationOverlay.tsx +++ b/frontend/editor/src/core/tools/formFill/FormFieldCreationOverlay.tsx @@ -18,7 +18,6 @@ import React, { useState, useEffect, } from "react"; -import { useDocumentState } from "@embedpdf/core/react"; import { useFormFill } from "@app/tools/formFill/FormFillContext"; import type { CreatableFieldType } from "@app/tools/formFill/types"; import { @@ -33,6 +32,8 @@ import { snapMove, type SnapGuide, } from "@app/tools/formFill/formSnapUtils"; +import { usePageScale, getLocalPoint } from "@app/tools/formFill/usePageScale"; +import { SnapGuides } from "@app/tools/formFill/SnapGuides"; interface FormFieldCreationOverlayProps { documentId: string; @@ -83,25 +84,17 @@ export function FormFieldCreationOverlay({ forFileId, } = useFormFill(); - const documentState = useDocumentState(documentId); const rootRef = useRef(null); const [dragRect, setDragRect] = useState(null); const [guides, setGuides] = useState([]); const dragStartRef = useRef<{ x: number; y: number } | null>(null); - const { scaleX, scaleY, pageHeightPts, pageWidthPts } = useMemo(() => { - const pdfPage = documentState?.document?.pages?.[pageIndex]; - if (!pdfPage?.size || !pageWidth || !pageHeight) { - const s = documentState?.scale ?? 1; - return { scaleX: s, scaleY: s, pageHeightPts: 0, pageWidthPts: 0 }; - } - return { - scaleX: pageWidth / pdfPage.size.width, - scaleY: pageHeight / pdfPage.size.height, - pageHeightPts: pdfPage.size.height, - pageWidthPts: pdfPage.size.width, - }; - }, [documentState, pageIndex, pageWidth, pageHeight]); + const { scaleX, scaleY, pageHeightPts, pageWidthPts } = usePageScale( + documentId, + pageIndex, + pageWidth, + pageHeight, + ); // Pixel rects of the OTHER fields on this page, used as snap targets. const snapRects = useMemo(() => { @@ -133,11 +126,10 @@ export function FormFieldCreationOverlay({ const fileMismatch = fileId != null && forFileId != null && fileId !== forFileId; - const localPoint = useCallback((e: React.PointerEvent) => { - const rect = rootRef.current?.getBoundingClientRect(); - if (!rect) return { x: 0, y: 0 }; - return { x: e.clientX - rect.left, y: e.clientY - rect.top }; - }, []); + const localPoint = useCallback( + (e: React.PointerEvent) => getLocalPoint(e, rootRef.current), + [], + ); const handlePointerDown = useCallback( (e: React.PointerEvent) => { @@ -327,32 +319,7 @@ export function FormFieldCreationOverlay({ )} {/* Alignment guides */} - {guides.map((g, i) => ( -
- ))} +
); } diff --git a/frontend/editor/src/core/tools/formFill/FormFieldEditOverlay.tsx b/frontend/editor/src/core/tools/formFill/FormFieldEditOverlay.tsx index 01fd18b64c..7226b79d77 100644 --- a/frontend/editor/src/core/tools/formFill/FormFieldEditOverlay.tsx +++ b/frontend/editor/src/core/tools/formFill/FormFieldEditOverlay.tsx @@ -16,7 +16,6 @@ import React, { useState, useEffect, } from "react"; -import { useDocumentState } from "@embedpdf/core/react"; import { useFormFill } from "@app/tools/formFill/FormFillContext"; import type { FormField } from "@app/tools/formFill/types"; import { @@ -33,6 +32,8 @@ import { snapResize, type SnapGuide, } from "@app/tools/formFill/formSnapUtils"; +import { usePageScale, getLocalPoint } from "@app/tools/formFill/usePageScale"; +import { SnapGuides } from "@app/tools/formFill/SnapGuides"; interface FormFieldEditOverlayProps { documentId: string; @@ -109,25 +110,17 @@ export function FormFieldEditOverlay({ forFileId, } = useFormFill(); - const documentState = useDocumentState(documentId); const rootRef = useRef(null); const interactionRef = useRef(null); const [liveRect, setLiveRect] = useState(null); const [guides, setGuides] = useState([]); - const { scaleX, scaleY, pageHeightPts, pageWidthPts } = useMemo(() => { - const pdfPage = documentState?.document?.pages?.[pageIndex]; - if (!pdfPage?.size || !pageWidth || !pageHeight) { - const s = documentState?.scale ?? 1; - return { scaleX: s, scaleY: s, pageHeightPts: 0, pageWidthPts: 0 }; - } - return { - scaleX: pageWidth / pdfPage.size.width, - scaleY: pageHeight / pdfPage.size.height, - pageHeightPts: pdfPage.size.height, - pageWidthPts: pdfPage.size.width, - }; - }, [documentState, pageIndex, pageWidth, pageHeight]); + const { scaleX, scaleY, pageHeightPts, pageWidthPts } = usePageScale( + documentId, + pageIndex, + pageWidth, + pageHeight, + ); /** First-widget pixel rect for a field on this page, honouring staged geometry. */ const fieldRect = useCallback( @@ -188,11 +181,10 @@ export function FormFieldEditOverlay({ // Precompute snap edges once (not on every pointermove). const snapTargets = useMemo(() => collectSnapTargets(snapRects), [snapRects]); - const localPoint = useCallback((e: React.PointerEvent) => { - const rect = rootRef.current?.getBoundingClientRect(); - if (!rect) return { x: 0, y: 0 }; - return { x: e.clientX - rect.left, y: e.clientY - rect.top }; - }, []); + const localPoint = useCallback( + (e: React.PointerEvent) => getLocalPoint(e, rootRef.current), + [], + ); const beginInteraction = useCallback( (e: React.PointerEvent, kind: "move" | "resize", handle?: HandleId) => { @@ -506,32 +498,7 @@ export function FormFieldEditOverlay({ })} {/* Alignment guides */} - {guides.map((g, i) => ( -
- ))} +
); } diff --git a/frontend/editor/src/core/tools/formFill/FormFieldModifyPanel.tsx b/frontend/editor/src/core/tools/formFill/FormFieldModifyPanel.tsx index 4f563cdbb1..b2453d7f39 100644 --- a/frontend/editor/src/core/tools/formFill/FormFieldModifyPanel.tsx +++ b/frontend/editor/src/core/tools/formFill/FormFieldModifyPanel.tsx @@ -6,13 +6,7 @@ * X/Y/W/H inputs. Fields can be marked for deletion. All staged changes commit * in one round-trip. */ -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import React, { useCallback, useEffect, useMemo, useRef } from "react"; import { Text, Button, @@ -42,6 +36,7 @@ import { FormFieldPropertyEditor, type EditableFieldProps, } from "@app/tools/formFill/FormFieldPropertyEditor"; +import { useFormCommit } from "@app/tools/formFill/useFormCommit"; import styles from "@app/tools/formFill/FormFill.module.css"; interface FormFieldModifyPanelProps { @@ -93,8 +88,7 @@ export function FormFieldModifyPanel({ hasUncommittedChanges, } = useFormFill(); - const [committing, setCommitting] = useState(false); - const [error, setError] = useState(null); + const { committing, error, commit } = useFormCommit(onApplied); const selectedRowRef = useRef(null); // Group fields by their first widget's page. @@ -124,24 +118,14 @@ export function FormFieldModifyPanel({ const changeCount = Object.keys(modifiedFields).length + deletedFieldNames.length; - const handleCommit = useCallback(async () => { + const handleCommit = useCallback(() => { if (!currentFile || !hasUncommittedChanges) return; - setCommitting(true); - setError(null); - try { - const blob = await commitModifications(currentFile); - const event = new CustomEvent("formfill:apply", { detail: { blob } }); - window.dispatchEvent(event); - onApplied?.(blob); - } catch (err: any) { - setError( - err?.message || t("formFill.modify.failed", "Failed to save changes"), - ); - console.error("[FormFill] modify/delete failed:", err); - } finally { - setCommitting(false); - } - }, [currentFile, hasUncommittedChanges, commitModifications, onApplied, t]); + commit( + () => commitModifications(currentFile), + "formFill.modify.failed", + "Failed to save changes", + ); + }, [currentFile, hasUncommittedChanges, commitModifications, commit]); const editorValue = useCallback( (field: FormField): EditableFieldProps => { diff --git a/frontend/editor/src/core/tools/formFill/FormFill.tsx b/frontend/editor/src/core/tools/formFill/FormFill.tsx index 5a3bfca4a7..3508670839 100644 --- a/frontend/editor/src/core/tools/formFill/FormFill.tsx +++ b/frontend/editor/src/core/tools/formFill/FormFill.tsx @@ -58,6 +58,7 @@ import { import type { FormMode } from "@app/tools/formFill/types"; import { FormFieldCreatePanel } from "@app/tools/formFill/FormFieldCreatePanel"; import { FormFieldModifyPanel } from "@app/tools/formFill/FormFieldModifyPanel"; +import { dispatchFormApply } from "@app/tools/formFill/formFillEvents"; import styles from "@app/tools/formFill/FormFill.module.css"; // --------------------------------------------------------------------------- @@ -231,14 +232,11 @@ const FormFill = (_props: BaseToolProps) => { // Track the flatten value at save so toggling it later re-enables Save setLastSavedFlatten(flatten); - // Dispatch to the viewer's handleFormApply via custom event. - // This ensures the viewer tracks the new file ID, preserves - // scroll position and rotation — instead of our own consumeFiles - // call which would lose the viewer's file tracking context. - const event = new CustomEvent("formfill:apply", { - detail: { blob: filledBlob }, - }); - window.dispatchEvent(event); + // Hand the filled PDF to the viewer's handleFormApply via custom event. + // This ensures the viewer tracks the new file ID and preserves scroll + // position and rotation, instead of our own consumeFiles call which + // would lose the viewer's file tracking context. + dispatchFormApply(filledBlob); } catch (err: any) { const message = err?.response?.status === 413 diff --git a/frontend/editor/src/core/tools/formFill/SnapGuides.tsx b/frontend/editor/src/core/tools/formFill/SnapGuides.tsx new file mode 100644 index 0000000000..c1059cde1e --- /dev/null +++ b/frontend/editor/src/core/tools/formFill/SnapGuides.tsx @@ -0,0 +1,43 @@ +/** + * SnapGuides — renders the pink alignment guide lines shared by the create and + * edit overlays. Absolutely positioned within the page overlay. + */ +import React from "react"; +import type { SnapGuide } from "@app/tools/formFill/formSnapUtils"; + +const GUIDE_COLOR = "#ec4899"; + +export function SnapGuides({ guides }: { guides: SnapGuide[] }) { + return ( + <> + {guides.map((g, i) => ( +
+ ))} + + ); +} + +export default SnapGuides; diff --git a/frontend/editor/src/core/tools/formFill/formFillEvents.ts b/frontend/editor/src/core/tools/formFill/formFillEvents.ts new file mode 100644 index 0000000000..5ddc40ac94 --- /dev/null +++ b/frontend/editor/src/core/tools/formFill/formFillEvents.ts @@ -0,0 +1,18 @@ +/** + * The custom event the form tools use to hand a freshly-produced PDF blob to + * the viewer (EmbedPdfViewer listens for it and reloads the file, preserving + * scroll/rotation). Centralised so the event name isn't duplicated as a string + * across the fill/create/modify flows. + */ +export const FORM_APPLY_EVENT = "formfill:apply"; + +export interface FormApplyDetail { + blob: Blob; +} + +/** Dispatch a produced PDF blob to the viewer for reload + refresh. */ +export function dispatchFormApply(blob: Blob): void { + window.dispatchEvent( + new CustomEvent(FORM_APPLY_EVENT, { detail: { blob } }), + ); +} diff --git a/frontend/editor/src/core/tools/formFill/useFormCommit.ts b/frontend/editor/src/core/tools/formFill/useFormCommit.ts new file mode 100644 index 0000000000..4b01011f5a --- /dev/null +++ b/frontend/editor/src/core/tools/formFill/useFormCommit.ts @@ -0,0 +1,39 @@ +/** + * Shared commit boilerplate for the create and modify panels: run an async + * action that produces the edited PDF blob, hand it to the viewer, and track + * committing/error state. Keeps both panels from repeating the same + * try/catch/dispatch dance. + */ +import { useState, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { dispatchFormApply } from "@app/tools/formFill/formFillEvents"; + +export function useFormCommit(onApplied?: (blob: Blob) => void) { + const { t } = useTranslation(); + const [committing, setCommitting] = useState(false); + const [error, setError] = useState(null); + + const commit = useCallback( + async ( + run: () => Promise, + errorKey: string, + errorFallback: string, + ) => { + setCommitting(true); + setError(null); + try { + const blob = await run(); + dispatchFormApply(blob); + onApplied?.(blob); + } catch (err: any) { + setError(err?.message || t(errorKey, errorFallback)); + console.error("[FormFill] commit failed:", err); + } finally { + setCommitting(false); + } + }, + [onApplied, t], + ); + + return { committing, error, setError, commit }; +} diff --git a/frontend/editor/src/core/tools/formFill/usePageScale.ts b/frontend/editor/src/core/tools/formFill/usePageScale.ts new file mode 100644 index 0000000000..8dabbe7f53 --- /dev/null +++ b/frontend/editor/src/core/tools/formFill/usePageScale.ts @@ -0,0 +1,56 @@ +/** + * Shared page-scale helpers for the form overlays. + * + * Both the creation and edit overlays need the pixel<->PDF-point scale for a + * rendered page (and a pointer-to-local-pixel conversion). This centralises + * that so they stay in sync with FormFieldOverlay's basis and don't duplicate + * the documentState math. + */ +import { useMemo } from "react"; +import { useDocumentState } from "@embedpdf/core/react"; + +export interface PageScale { + scaleX: number; + scaleY: number; + /** CropBox height in PDF points; 0 until the page has rendered. */ + pageHeightPts: number; + /** CropBox width in PDF points; 0 until the page has rendered. */ + pageWidthPts: number; +} + +/** + * Pixel<->point scale for a page, derived from EmbedPDF's document state. + * `scaleX = pageWidthPx / pageWidthPts`. `pageWidthPts` is 0 until the page has + * rendered, so callers should guard on it before drawing. + */ +export function usePageScale( + documentId: string, + pageIndex: number, + pageWidth: number, + pageHeight: number, +): PageScale { + const documentState = useDocumentState(documentId); + return useMemo(() => { + const pdfPage = documentState?.document?.pages?.[pageIndex]; + if (!pdfPage?.size || !pageWidth || !pageHeight) { + const s = documentState?.scale ?? 1; + return { scaleX: s, scaleY: s, pageHeightPts: 0, pageWidthPts: 0 }; + } + return { + scaleX: pageWidth / pdfPage.size.width, + scaleY: pageHeight / pdfPage.size.height, + pageHeightPts: pdfPage.size.height, + pageWidthPts: pdfPage.size.width, + }; + }, [documentState, pageIndex, pageWidth, pageHeight]); +} + +/** Pointer position relative to an element's top-left, in CSS pixels. */ +export function getLocalPoint( + e: { clientX: number; clientY: number }, + el: HTMLElement | null, +): { x: number; y: number } { + const rect = el?.getBoundingClientRect(); + if (!rect) return { x: 0, y: 0 }; + return { x: e.clientX - rect.left, y: e.clientY - rect.top }; +}