refactor: dedupe form-editor overlays and panels into shared hooks/components

This commit is contained in:
Anthony Stirling
2026-06-13 18:03:13 +01:00
parent 62684ba706
commit 16c39c187d
10 changed files with 210 additions and 147 deletions
@@ -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
@@ -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<string | null>(null);
const [committing, setCommitting] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className={styles.header}>
@@ -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<HTMLDivElement>(null);
const [dragRect, setDragRect] = useState<PixelRect | null>(null);
const [guides, setGuides] = useState<SnapGuide[]>([]);
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<PixelRect[]>(() => {
@@ -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) => (
<div
key={i}
style={
g.orientation === "v"
? {
position: "absolute",
left: g.position,
top: 0,
width: 1,
height: "100%",
background: "#ec4899",
pointerEvents: "none",
}
: {
position: "absolute",
top: g.position,
left: 0,
height: 1,
width: "100%",
background: "#ec4899",
pointerEvents: "none",
}
}
/>
))}
<SnapGuides guides={guides} />
</div>
);
}
@@ -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<HTMLDivElement>(null);
const interactionRef = useRef<Interaction | null>(null);
const [liveRect, setLiveRect] = useState<PixelRect | null>(null);
const [guides, setGuides] = useState<SnapGuide[]>([]);
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) => (
<div
key={i}
style={
g.orientation === "v"
? {
position: "absolute",
left: g.position,
top: 0,
width: 1,
height: "100%",
background: "#ec4899",
pointerEvents: "none",
}
: {
position: "absolute",
top: g.position,
left: 0,
height: 1,
width: "100%",
background: "#ec4899",
pointerEvents: "none",
}
}
/>
))}
<SnapGuides guides={guides} />
</div>
);
}
@@ -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<string | null>(null);
const { committing, error, commit } = useFormCommit(onApplied);
const selectedRowRef = useRef<HTMLDivElement>(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 => {
@@ -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
@@ -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) => (
<div
key={i}
style={
g.orientation === "v"
? {
position: "absolute",
left: g.position,
top: 0,
width: 1,
height: "100%",
background: GUIDE_COLOR,
pointerEvents: "none",
}
: {
position: "absolute",
top: g.position,
left: 0,
height: 1,
width: "100%",
background: GUIDE_COLOR,
pointerEvents: "none",
}
}
/>
))}
</>
);
}
export default SnapGuides;
@@ -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<FormApplyDetail>(FORM_APPLY_EVENT, { detail: { blob } }),
);
}
@@ -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<string | null>(null);
const commit = useCallback(
async (
run: () => Promise<Blob>,
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 };
}
@@ -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 };
}