refactor: dedupe form-editor overlays and panels into shared hooks/components
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user