Add comprehensive form field editing capabilities

- Add form field creation overlay with visual drag-to-create
- Add form field modification panel for editing existing fields
- Add property editor for field attributes (name, value, formatting)
- Implement snap-to-grid and alignment utilities
- Fix coordinate system mapping between PDF and display
- Update FormFillContext for field CRUD operations
- Add support for text, checkbox, radio, and combo box fields
- Improve field positioning accuracy across save/load cycles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Denys Vitali
2026-02-22 16:28:19 +01:00
co-authored by Claude Sonnet 4.6
parent 30c258ce0b
commit a4c3beebdf
21 changed files with 2518 additions and 28 deletions
@@ -94,5 +94,8 @@ public class FormFieldWithCoordinates {
@Schema(description = "Font size in PDF points")
private Float fontSize;
@Schema(description = "CropBox height in PDF points (used for Y-flip)")
private Float cropBoxHeight;
}
}
@@ -59,6 +59,13 @@ public enum FormFieldTypeSupport {
List<String> options)
throws IOException {
PDTextField textField = (PDTextField) field;
if (definition.fontSize() != null && definition.fontSize() > 0) {
textField.setDefaultAppearance(
"/Helv " + definition.fontSize() + " Tf 0 g");
}
if (Boolean.TRUE.equals(definition.multiline())) {
textField.setMultiline(true);
}
String defaultValue = Optional.ofNullable(definition.defaultValue()).orElse("");
if (!defaultValue.isBlank()) {
FormUtils.setTextValue(textField, defaultValue);
@@ -455,6 +455,7 @@ public class FormUtils {
.height(finalH)
.exportValue(exportValue)
.fontSize(extractFontSize(field))
.cropBoxHeight(cropHeight)
.build();
}
@@ -1551,7 +1552,8 @@ public class FormUtils {
if (!typeChanging) {
try {
modifyFieldPropertiesInPlace(originalField, modification, desiredName);
modifyFieldPropertiesInPlace(
document, originalField, modification, desiredName);
log.debug("Successfully modified field '{}' in-place", lookupName);
continue; // Skip the remove-and-recreate process
} catch (Exception e) {
@@ -1578,7 +1580,10 @@ public class FormUtils {
modification.multiSelect(),
modification.options(),
modification.defaultValue(),
modification.tooltip());
modification.tooltip(),
modification.fontSize(),
modification.readOnly(),
modification.multiline());
List<String> sanitizedOptions = sanitizeOptions(modification.options());
@@ -1618,7 +1623,10 @@ public class FormUtils {
}
private void modifyFieldPropertiesInPlace(
PDField field, ModifyFormFieldDefinition modification, String newName)
PDDocument document,
PDField field,
ModifyFormFieldDefinition modification,
String newName)
throws IOException {
if (newName != null && !newName.equals(field.getPartialName())) {
field.setPartialName(newName);
@@ -1668,6 +1676,90 @@ public class FormUtils {
}
}
}
// Update readOnly flag
if (modification.readOnly() != null) {
field.setReadOnly(modification.readOnly());
}
// Update multiline flag (text fields only)
if (modification.multiline() != null && field instanceof PDTextField tf) {
tf.setMultiline(modification.multiline());
}
// Update font size (variable text fields only)
boolean fontSizeChanged = false;
if (modification.fontSize() != null
&& modification.fontSize() > 0
&& field instanceof PDVariableText vt) {
String da = vt.getDefaultAppearance();
if (da != null && !da.isBlank()) {
// Replace the size number before "Tf" in the DA string
String[] tokens = da.split("\\s+");
for (int i = 0; i < tokens.length; i++) {
if ("Tf".equals(tokens[i]) && i > 0) {
tokens[i - 1] = String.valueOf(modification.fontSize());
break;
}
}
vt.setDefaultAppearance(String.join(" ", tokens));
} else {
vt.setDefaultAppearance("/Helv " + modification.fontSize() + " Tf 0 g");
}
fontSizeChanged = true;
// Also clear appearance for font size changes so new font renders correctly
List<PDAnnotationWidget> widgets = field.getWidgets();
if (widgets != null && !widgets.isEmpty()) {
widgets.get(0).getCOSObject().removeItem(COSName.AP);
}
}
// Update widget coordinates if any coordinate field is non-null.
// The frontend sends CropBox-relative coordinates (matching what createWidgetCoordinates
// extracted). We must add back the CropBox offset to get absolute PDF coordinates.
if (modification.x() != null
|| modification.y() != null
|| modification.width() != null
|| modification.height() != null) {
List<PDAnnotationWidget> widgets = field.getWidgets();
if (widgets != null && !widgets.isEmpty()) {
PDAnnotationWidget widget = widgets.get(0);
PDRectangle rect = widget.getRectangle();
if (rect != null) {
// Resolve CropBox to reverse the extraction's coordinate transform
PDPage page = resolveWidgetPage(document, widget, null);
float offX = 0;
float offY = 0;
if (page != null) {
PDRectangle cropBox = page.getCropBox();
offX = cropBox.getLowerLeftX();
offY = cropBox.getLowerLeftY();
}
float newX =
modification.x() != null
? modification.x() + offX
: rect.getLowerLeftX();
float newY =
modification.y() != null
? modification.y() + offY
: rect.getLowerLeftY();
float newW =
modification.width() != null ? modification.width() : rect.getWidth();
float newH =
modification.height() != null
? modification.height()
: rect.getHeight();
widget.setRectangle(new PDRectangle(newX, newY, newW, newH));
// Remove stale appearance stream so ensureAppearances() generates
// a fresh one matching the new rectangle. Without this, PDF viewers
// stretch the old appearance (designed for the old BBox) to fit
// the new widget rectangle, causing fields to look distorted.
widget.getCOSObject().removeItem(COSName.AP);
}
}
}
}
private String fallbackLabelForType(String type, int typeIndex) {
@@ -1795,6 +1887,81 @@ public class FormUtils {
return -1;
}
/**
* Add new form fields to a document based on the supplied definitions. If the document does not
* already contain an AcroForm, one is created automatically.
*/
public void addNewFields(PDDocument document, List<NewFormFieldDefinition> definitions)
throws IOException {
if (document == null || definitions == null || definitions.isEmpty()) return;
PDAcroForm acroForm = getAcroFormSafely(document);
if (acroForm == null) {
// Create a new AcroForm for PDFs that don't have one yet
acroForm = new PDAcroForm(document);
document.getDocumentCatalog().setAcroForm(acroForm);
}
Set<String> existingNames = collectExistingFieldNames(acroForm);
int pageCount = document.getNumberOfPages();
for (NewFormFieldDefinition definition : definitions) {
if (definition == null) continue;
String resolvedType =
Optional.ofNullable(definition.type())
.map(FormUtils::normalizeFieldType)
.orElse(FIELD_TYPE_TEXT);
FormFieldTypeSupport handler = FormFieldTypeSupport.forTypeName(resolvedType);
if (handler == null || handler.doesNotsupportsDefinitionCreation()) {
log.warn(
"Unsupported or non-creatable field type '{}'; defaulting to text",
resolvedType);
handler = FormFieldTypeSupport.TEXT;
}
int pageIdx = definition.pageIndex() != null ? definition.pageIndex() : 0;
if (pageIdx < 0 || pageIdx >= pageCount) {
log.warn(
"Page index {} out of range (0-{}); clamping to last page",
pageIdx,
pageCount - 1);
pageIdx = Math.max(0, pageCount - 1);
}
PDPage page = document.getPage(pageIdx);
float x = definition.x() != null ? definition.x() : 0f;
float y = definition.y() != null ? definition.y() : 0f;
float w = definition.width() != null ? definition.width() : 150f;
float h = definition.height() != null ? definition.height() : 20f;
PDRectangle rectangle = new PDRectangle(x, y, w, h);
String baseName =
Optional.ofNullable(definition.name())
.map(String::trim)
.filter(s -> !s.isEmpty())
.orElse("field");
String uniqueName = generateUniqueFieldName(baseName, existingNames);
existingNames.add(uniqueName);
List<String> options = sanitizeOptions(definition.options());
try {
createNewField(handler, acroForm, page, rectangle, uniqueName, definition, options);
} catch (Exception e) {
log.warn(
"Failed to create field '{}' of type '{}': {}",
uniqueName,
resolvedType,
e.getMessage(),
e);
}
}
ensureAppearances(acroForm);
}
public void deleteFormFields(PDDocument document, List<String> fieldNames) {
if (document == null || fieldNames == null || fieldNames.isEmpty()) return;
@@ -2200,6 +2367,9 @@ public class FormUtils {
}
}
field.setRequired(Boolean.TRUE.equals(definition.required()));
if (Boolean.TRUE.equals(definition.readOnly())) {
field.setReadOnly(true);
}
PDAnnotationWidget widget =
existingWidget != null ? existingWidget : new PDAnnotationWidget();
@@ -2285,7 +2455,10 @@ public class FormUtils {
Boolean multiSelect,
List<String> options,
String defaultValue,
String tooltip) {}
String tooltip,
Float fontSize,
Boolean readOnly,
Boolean multiline) {}
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ModifyFormFieldDefinition(
@@ -2293,11 +2466,19 @@ public class FormUtils {
String name,
String label,
String type,
Integer pageIndex,
Float x,
Float y,
Float width,
Float height,
Boolean required,
Boolean multiSelect,
List<String> options,
String defaultValue,
String tooltip) {}
String tooltip,
Float fontSize,
Boolean readOnly,
Boolean multiline) {}
@JsonInclude(JsonInclude.Include.NON_NULL)
public record FormFieldInfo(
@@ -139,6 +139,44 @@ public class FormFillController {
}
}
@PostMapping(value = "/add-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Add new form fields",
description =
"Creates new form fields in the provided PDF and returns the updated file")
public ResponseEntity<byte[]> addFields(
@Parameter(
description = "The input PDF file",
required = true,
content =
@Content(
mediaType = MediaType.APPLICATION_PDF_VALUE,
schema = @Schema(type = "string", format = "binary")))
@RequestParam("file")
MultipartFile file,
@Parameter(
description = "JSON array of new field definitions",
example =
"[{\"name\":\"NewField\",\"type\":\"text\",\"pageIndex\":0,"
+ "\"x\":50,\"y\":700,\"width\":200,\"height\":20}]")
@RequestPart(value = "fields", required = false)
byte[] fieldsPayload)
throws IOException {
String rawFields = decodePart(fieldsPayload);
List<FormUtils.NewFormFieldDefinition> definitions =
FormPayloadParser.parseNewFieldDefinitions(objectMapper, rawFields);
if (definitions.isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.dataRequired",
"{0} must contain at least one definition",
"fields payload");
}
return processSingleFile(
file, "updated", document -> FormUtils.addNewFields(document, definitions));
}
@PostMapping(value = "/modify-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Modify existing form fields",
@@ -28,6 +28,8 @@ final class FormPayloadParser {
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {};
private static final TypeReference<List<FormUtils.ModifyFormFieldDefinition>>
MODIFY_FIELD_LIST_TYPE = new TypeReference<>() {};
private static final TypeReference<List<FormUtils.NewFormFieldDefinition>>
NEW_FIELD_LIST_TYPE = new TypeReference<>() {};
private static final TypeReference<List<String>> STRING_LIST_TYPE = new TypeReference<>() {};
private FormPayloadParser() {}
@@ -95,6 +97,14 @@ final class FormPayloadParser {
return objectMapper.readValue(json, MODIFY_FIELD_LIST_TYPE);
}
static List<FormUtils.NewFormFieldDefinition> parseNewFieldDefinitions(
ObjectMapper objectMapper, String json) throws IOException {
if (json == null || json.isBlank()) {
return List.of();
}
return objectMapper.readValue(json, NEW_FIELD_LIST_TYPE);
}
static List<String> parseNameList(ObjectMapper objectMapper, String json) throws IOException {
if (json == null || json.isBlank()) {
return List.of();
@@ -3338,6 +3338,36 @@ title = "Automate"
desc = "Fill PDF form fields interactively with a visual editor"
title = "Fill Form"
[formFill.createMode]
title = "Create Fields"
instruction = "Select a field type, then drag on the PDF to place it."
addButton = "Add {count} Field(s) to PDF"
emptyState = "No pending fields. Select a type above and drag on the PDF to create one."
[formFill.modifyMode]
title = "Modify Fields"
instruction = "Click a field below or on the PDF to select it. Drag to move, use handles to resize."
saveButton = "Save {count} Change(s)"
emptyState = "No form fields found. Use Create mode to add fields first."
modifiedBadge = "modified"
[formFill.propertyEditor]
name = "Name"
label = "Label"
type = "Type"
tooltip = "Tooltip"
defaultValue = "Default Value"
required = "Required"
readOnly = "Read-only"
multiline = "Multiline"
fontSize = "Font Size (pt)"
options = "Options"
addOption = "Add Option"
multiSelect = "Multi-select"
position = "Position"
positionAndSize = "Position & Size (PDF points)"
fieldProperties = "Field Properties"
[home.autoRename]
desc = "Auto renames a PDF file based on its detected header"
tags = "auto-detect,header-based,organize,relabel,auto rename,automatic rename,smart rename,rename by content,filename,file naming,detect title"
@@ -58,6 +58,8 @@ import { DocumentReadyWrapper } from '@app/components/viewer/DocumentReadyWrappe
import { ActiveDocumentProvider } from '@app/components/viewer/ActiveDocumentContext';
import { absoluteWithBasePath } from '@app/constants/app';
import { FormFieldOverlay } from '@app/tools/formFill/FormFieldOverlay';
import { FormFieldCreationOverlay } from '@app/tools/formFill/FormFieldCreationOverlay';
import { FormFieldEditOverlay } from '@app/tools/formFill/FormFieldEditOverlay';
const DOCUMENT_NAME = 'stirling-pdf-viewer';
@@ -747,6 +749,26 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
/>
)}
{/* FormFieldCreationOverlay for creating new fields */}
{enableFormFill && (
<FormFieldCreationOverlay
documentId={documentId}
pageIndex={pageIndex}
pageWidth={width}
pageHeight={height}
/>
)}
{/* FormFieldEditOverlay for moving/resizing existing fields */}
{enableFormFill && (
<FormFieldEditOverlay
documentId={documentId}
pageIndex={pageIndex}
pageWidth={width}
pageHeight={height}
/>
)}
{/* AnnotationLayer for annotation editing and annotation-based redactions */}
{(enableAnnotations || enableRedaction) && (
<AnnotationLayer
@@ -0,0 +1,197 @@
/**
* FormFieldCreatePanel — Left panel for "Create" mode.
*
* Contains:
* - Field type palette (click to start placing)
* - Pending fields list with property editing
* - "Add Fields to PDF" commit button
*/
import React, { useState, useCallback, useMemo } from 'react';
import {
Button,
Text,
ScrollArea,
ActionIcon,
Alert,
Tooltip,
UnstyledButton,
Collapse,
Loader,
} from '@mantine/core';
import SaveIcon from '@mui/icons-material/Save';
import DeleteIcon from '@mui/icons-material/Delete';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import { useFormFill } from '@app/tools/formFill/FormFillContext';
import { useFileState } from '@app/contexts/FileContext';
import { isStirlingFile } from '@app/types/fileContext';
import { FIELD_TYPE_ICON, FIELD_TYPE_COLOR } from '@app/tools/formFill/fieldMeta';
import { FormFieldPropertyEditor } from '@app/tools/formFill/FormFieldPropertyEditor';
import type { FormFieldType } from '@app/tools/formFill/types';
import styles from '@app/tools/formFill/FormFill.module.css';
const CREATABLE_TYPES: { type: FormFieldType; label: string }[] = [
{ type: 'text', label: 'Text' },
{ type: 'checkbox', label: 'Checkbox' },
{ type: 'combobox', label: 'Dropdown' },
{ type: 'listbox', label: 'List' },
];
export function FormFieldCreatePanel() {
const {
creationState,
setPlacingFieldType,
removePendingField,
updatePendingField,
commitNewFields,
fetchFields,
} = useFormFill();
const { selectors, state: fileState } = useFileState();
const activeFiles = selectors.getFiles();
const selectedFileIds = fileState.ui.selectedFileIds;
const currentFile = useMemo(() => {
if (activeFiles.length === 0) return null;
if (selectedFileIds.length > 0) {
const sel = activeFiles.find(
(f) => isStirlingFile(f) && selectedFileIds.includes(f.fileId)
);
if (sel) return sel;
}
return activeFiles[0];
}, [activeFiles, selectedFileIds]);
const [expandedIdx, setExpandedIdx] = useState<number | null>(null);
const [committing, setCommitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleCommit = useCallback(async () => {
if (!currentFile || !isStirlingFile(currentFile)) return;
if (creationState.pendingFields.length === 0) return;
setCommitting(true);
setError(null);
try {
const blob = await commitNewFields(currentFile);
// Apply the result to the viewer via custom event (same pattern as FormFill save)
const event = new CustomEvent('formfill:apply', { detail: { blob } });
window.dispatchEvent(event);
// Re-fetch fields to pick up the new ones
fetchFields(currentFile, currentFile.fileId);
} catch (err: any) {
setError(err?.message || 'Failed to add fields');
} finally {
setCommitting(false);
}
}, [currentFile, creationState.pendingFields, commitNewFields, fetchFields]);
const { pendingFields, placingFieldType } = creationState;
return (
<div className={styles.root}>
<div className={styles.header}>
<Text size="xs" fw={600}>Select a field type, then drag on the PDF to place it.</Text>
{/* Field type palette */}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{CREATABLE_TYPES.map(({ type, label }) => {
const isActive = placingFieldType === type;
return (
<Tooltip key={type} label={label} withArrow position="bottom">
<UnstyledButton
onClick={() => setPlacingFieldType(isActive ? null : type)}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 2,
padding: '6px 10px',
borderRadius: 'var(--radius-sm)',
border: `1.5px solid ${isActive ? `var(--mantine-color-${FIELD_TYPE_COLOR[type]}-5)` : 'var(--border-default, var(--mantine-color-default-border))'}`,
background: isActive ? `var(--mantine-color-${FIELD_TYPE_COLOR[type]}-light)` : 'transparent',
cursor: 'pointer',
transition: 'all 0.15s ease',
minWidth: 52,
}}
>
<span style={{ fontSize: '1.125rem', color: `var(--mantine-color-${FIELD_TYPE_COLOR[type]}-6)`, lineHeight: 1, display: 'flex' }}>
{FIELD_TYPE_ICON[type]}
</span>
<Text size="xs" fw={600} style={{ fontSize: '0.625rem', textTransform: 'uppercase' }}>
{label}
</Text>
</UnstyledButton>
</Tooltip>
);
})}
</div>
{pendingFields.length > 0 && (
<Button
leftSection={committing ? <Loader size={14} color="white" /> : <SaveIcon sx={{ fontSize: 14 }} />}
size="xs"
onClick={handleCommit}
loading={committing}
fullWidth
>
Add {pendingFields.length} Field{pendingFields.length !== 1 ? 's' : ''} to PDF
</Button>
)}
{error && (
<Alert icon={<WarningAmberIcon sx={{ fontSize: 16 }} />} color="red" variant="light" p="xs" radius="sm">
<Text size="xs">{error}</Text>
</Alert>
)}
</div>
{/* Pending fields list */}
<ScrollArea className={styles.fieldList}>
<div className={styles.fieldListInner}>
{pendingFields.length === 0 && (
<div className={styles.emptyState} style={{ padding: '2rem 1rem' }}>
<Text size="xs" c="dimmed" ta="center">
No pending fields. Select a type above and drag on the PDF to create one.
</Text>
</div>
)}
{pendingFields.map((field, idx) => (
<div key={idx} className={styles.fieldCard}>
<div
style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}
onClick={() => setExpandedIdx(expandedIdx === idx ? null : idx)}
>
<span style={{ fontSize: '0.875rem', color: `var(--mantine-color-${FIELD_TYPE_COLOR[field.type]}-6)`, display: 'flex' }}>
{FIELD_TYPE_ICON[field.type]}
</span>
<Text size="xs" fw={600} style={{ flex: 1 }}>
{field.name || `New ${field.type}`}
</Text>
<Text size="xs" c="dimmed">p.{field.pageIndex + 1}</Text>
<ActionIcon variant="subtle" color="red" size="sm" onClick={(e) => { e.stopPropagation(); removePendingField(idx); }}>
<DeleteIcon sx={{ fontSize: 14 }} />
</ActionIcon>
{expandedIdx === idx
? <ExpandLessIcon sx={{ fontSize: 16, opacity: 0.5 }} />
: <ExpandMoreIcon sx={{ fontSize: 16, opacity: 0.5 }} />
}
</div>
<Collapse in={expandedIdx === idx}>
<div style={{ paddingTop: 8 }}>
<FormFieldPropertyEditor
field={field}
onChange={(updated) => updatePendingField(idx, updated)}
showCoordinates
/>
</div>
</Collapse>
</div>
))}
</div>
</ScrollArea>
</div>
);
}
@@ -0,0 +1,326 @@
/**
* FormFieldCreationOverlay — Rendered on PDF pages in "Create" mode.
*
* - Shows crosshair cursor when a field type is selected for placement
* - Handles drag-to-define field rectangle (pointerdown → pointermove → pointerup)
* - Renders a preview rectangle during drag
* - Renders pending (uncommitted) fields as dashed outlines
* - Converts CSS coordinates to PDF coordinates on completion
* - Snaps to nearby field edges with visual guide lines
*/
import React, { useCallback, useRef, useMemo, useEffect, useState } from 'react';
import { useDocumentState } from '@embedpdf/core/react';
import { useFormFill } from '@app/tools/formFill/FormFillContext';
import { cssToPdfRect, pixelsToPdfPoints } from '@app/tools/formFill/formCoordinateUtils';
import { FIELD_TYPE_ICON, FIELD_TYPE_COLOR } from '@app/tools/formFill/fieldMeta';
import {
collectSnapTargets,
collectPendingFieldSnapTargets,
snapRect,
type SnapGuide,
} from '@app/tools/formFill/formSnapUtils';
import type { NewFieldDefinition } from '@app/tools/formFill/types';
const MIN_SIZE_PTS = 10;
interface FormFieldCreationOverlayProps {
documentId: string;
pageIndex: number;
pageWidth: number;
pageHeight: number;
}
export function FormFieldCreationOverlay({
documentId,
pageIndex,
pageWidth,
pageHeight,
}: FormFieldCreationOverlayProps) {
const {
mode,
creationState,
setCreationDragRect,
addPendingField,
setPlacingFieldType,
modifiedFields,
state: { fields: allFields },
} = useFormFill();
const documentState = useDocumentState(documentId);
const { scaleX, scaleY, pageWidthPts, pageHeightPts } = useMemo(() => {
const pdfPage = documentState?.document?.pages?.[pageIndex];
if (!pdfPage || !pdfPage.size || !pageWidth || !pageHeight) {
const s = documentState?.scale ?? 1;
return { scaleX: s, scaleY: s, pageWidthPts: pageWidth / s, pageHeightPts: pageHeight / s };
}
// Prefer CropBox height from backend (if available) for exact Y-flip consistency.
const firstWidget = allFields
.find(f => f.widgets?.some(w => w.pageIndex === pageIndex))
?.widgets?.find(w => w.pageIndex === pageIndex);
const cbHeight = firstWidget?.cropBoxHeight;
return {
scaleX: pageWidth / pdfPage.size.width,
scaleY: pageHeight / pdfPage.size.height,
pageWidthPts: pdfPage.size.width,
pageHeightPts: cbHeight ?? pdfPage.size.height,
};
}, [documentState, pageIndex, pageWidth, pageHeight, allFields]);
const dragging = useRef<{ startX: number; startY: number } | null>(null);
const overlayRef = useRef<HTMLDivElement>(null);
const [snapGuides, setSnapGuides] = useState<SnapGuide[]>([]);
const handlePointerDown = useCallback((e: React.PointerEvent) => {
if (!creationState.placingFieldType) return;
e.preventDefault();
e.stopPropagation();
const rect = overlayRef.current?.getBoundingClientRect();
if (!rect) return;
const pixelX = e.clientX - rect.left;
const pixelY = e.clientY - rect.top;
dragging.current = { startX: pixelX, startY: pixelY };
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
setCreationDragRect({
x: pixelX,
y: pixelY,
width: 0,
height: 0,
pageIndex,
});
}, [creationState.placingFieldType, pageIndex, setCreationDragRect]);
const handlePointerMove = useCallback((e: React.PointerEvent) => {
if (!dragging.current || !creationState.placingFieldType) return;
e.preventDefault();
e.stopPropagation();
const rect = overlayRef.current?.getBoundingClientRect();
if (!rect) return;
const pixelX = Math.max(0, Math.min(pageWidth, e.clientX - rect.left));
const pixelY = Math.max(0, Math.min(pageHeight, e.clientY - rect.top));
let x = Math.min(dragging.current.startX, pixelX);
let y = Math.min(dragging.current.startY, pixelY);
const width = Math.abs(pixelX - dragging.current.startX);
const height = Math.abs(pixelY - dragging.current.startY);
// Apply snapping to the drag rectangle
const existingTargets = collectSnapTargets(
allFields, null, pageIndex,
scaleX, scaleY, pageHeightPts, modifiedFields,
);
const pendingTargets = collectPendingFieldSnapTargets(
creationState.pendingFields, pageIndex,
scaleX, scaleY, pageHeightPts,
);
const targets = [...existingTargets, ...pendingTargets];
const snapped = snapRect(x, y, width, height, targets);
x = snapped.left;
y = snapped.top;
setSnapGuides(snapped.guides);
setCreationDragRect({ x, y, width, height, pageIndex });
}, [creationState.placingFieldType, creationState.pendingFields, pageWidth, pageHeight, pageIndex, allFields, scaleX, scaleY, pageHeightPts, modifiedFields, setCreationDragRect]);
const handlePointerUp = useCallback((e: React.PointerEvent) => {
if (!dragging.current || !creationState.placingFieldType) return;
e.preventDefault();
e.stopPropagation();
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
setSnapGuides([]);
const rect = overlayRef.current?.getBoundingClientRect();
if (!rect) {
dragging.current = null;
setCreationDragRect(null);
return;
}
const pixelX = Math.max(0, Math.min(pageWidth, e.clientX - rect.left));
const pixelY = Math.max(0, Math.min(pageHeight, e.clientY - rect.top));
// Compute CSS rect in PDF points
const startPts = pixelsToPdfPoints(
dragging.current.startX, dragging.current.startY,
pageWidth, pageHeight, pageWidthPts, pageHeightPts
);
const endPts = pixelsToPdfPoints(
pixelX, pixelY,
pageWidth, pageHeight, pageWidthPts, pageHeightPts
);
const cssX = Math.min(startPts.x, endPts.x);
const cssY = Math.min(startPts.y, endPts.y);
const cssW = Math.abs(endPts.x - startPts.x);
const cssH = Math.abs(endPts.y - startPts.y);
dragging.current = null;
setCreationDragRect(null);
// Enforce minimum size
if (cssW < MIN_SIZE_PTS || cssH < MIN_SIZE_PTS) return;
// Convert from CSS TL origin to PDF BL origin
const pdfRect = cssToPdfRect(
{ x: cssX, y: cssY, width: cssW, height: cssH },
pageHeightPts
);
const fieldType = creationState.placingFieldType;
const count = creationState.pendingFields.length;
const newField: NewFieldDefinition = {
name: `${fieldType}_${count + 1}`,
type: fieldType,
pageIndex,
x: pdfRect.x,
y: pdfRect.y,
width: pdfRect.width,
height: pdfRect.height,
};
addPendingField(newField);
}, [
creationState.placingFieldType,
creationState.pendingFields.length,
pageWidth, pageHeight, pageWidthPts, pageHeightPts,
pageIndex, setCreationDragRect, addPendingField,
]);
// Escape to cancel placement
useEffect(() => {
if (mode !== 'make') return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (creationState.placingFieldType) {
setPlacingFieldType(null);
setCreationDragRect(null);
dragging.current = null;
setSnapGuides([]);
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [mode, creationState.placingFieldType, setPlacingFieldType, setCreationDragRect]);
// Don't render if not in create mode
if (mode !== 'make') return null;
const isPlacing = !!creationState.placingFieldType;
const dragRect = creationState.dragRect;
const showDragRect = dragRect && dragRect.pageIndex === pageIndex;
// Render pending fields for this page
const pendingForPage = creationState.pendingFields.filter(f => f.pageIndex === pageIndex);
return (
<div
ref={overlayRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: 15,
cursor: isPlacing ? 'crosshair' : 'default',
pointerEvents: isPlacing ? 'auto' : 'none',
}}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
>
{/* Snap guide lines */}
{snapGuides.map((guide, i) =>
guide.axis === 'x' ? (
<div
key={`guide-${i}`}
style={{
position: 'absolute',
left: guide.position,
top: 0,
width: 0,
height: '100%',
borderLeft: '1px dashed rgba(255, 0, 100, 0.6)',
pointerEvents: 'none',
zIndex: 20,
}}
/>
) : (
<div
key={`guide-${i}`}
style={{
position: 'absolute',
left: 0,
top: guide.position,
width: '100%',
height: 0,
borderTop: '1px dashed rgba(255, 0, 100, 0.6)',
pointerEvents: 'none',
zIndex: 20,
}}
/>
)
)}
{/* Drag preview rectangle */}
{showDragRect && dragRect.width > 0 && dragRect.height > 0 && (
<div
style={{
position: 'absolute',
left: dragRect.x,
top: dragRect.y,
width: dragRect.width,
height: dragRect.height,
border: '2px dashed var(--mantine-color-blue-5)',
background: 'rgba(33, 150, 243, 0.1)',
borderRadius: 2,
pointerEvents: 'none',
}}
/>
)}
{/* Pending fields as dashed outlines */}
{pendingForPage.map((field, idx) => {
// Convert PDF coords back to CSS for display
const cssY = pageHeightPts - field.y - field.height;
const left = field.x * scaleX;
const top = cssY * scaleY;
const width = field.width * scaleX;
const height = field.height * scaleY;
const color = `var(--mantine-color-${FIELD_TYPE_COLOR[field.type]}-5)`;
return (
<div
key={`pending-${idx}`}
style={{
position: 'absolute',
left,
top,
width,
height,
border: `2px dashed ${color}`,
background: `var(--mantine-color-${FIELD_TYPE_COLOR[field.type]}-light)`,
borderRadius: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
opacity: 0.8,
}}
>
<span style={{ fontSize: Math.min(height * 0.6, 18), color, lineHeight: 1, display: 'flex' }}>
{FIELD_TYPE_ICON[field.type]}
</span>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,428 @@
/**
* FormFieldEditOverlay — Rendered on PDF pages in "Modify" mode.
*
* - Click to select an existing field
* - Drag field body to reposition
* - Drag corner/edge handles to resize
* - Updates modifiedFields in context on completion
* - Snaps to nearby field edges with visual guide lines
*/
import React, { useCallback, useRef, useMemo, useEffect, useState } from 'react';
import { useDocumentState } from '@embedpdf/core/react';
import { useFormFill } from '@app/tools/formFill/FormFillContext';
import { cssToPdfRect, pdfToCssRect } from '@app/tools/formFill/formCoordinateUtils';
import { FIELD_TYPE_COLOR } from '@app/tools/formFill/fieldMeta';
import { collectSnapTargets, snapRect, snapRectResize, type SnapGuide, type ResizeEdges } from '@app/tools/formFill/formSnapUtils';
import type { FormField, WidgetCoordinates } from '@app/tools/formFill/types';
const MIN_SIZE_PX = 8;
const HANDLE_SIZE = 8;
interface FormFieldEditOverlayProps {
documentId: string;
pageIndex: number;
pageWidth: number;
pageHeight: number;
}
type HandlePosition = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w';
interface DragState {
type: 'move' | 'resize';
handle?: HandlePosition;
startMouseX: number;
startMouseY: number;
startLeft: number;
startTop: number;
startWidth: number;
startHeight: number;
}
const HANDLE_POSITIONS: { pos: HandlePosition; cursor: string; top: string; left: string }[] = [
{ pos: 'nw', cursor: 'nwse-resize', top: '0%', left: '0%' },
{ pos: 'n', cursor: 'ns-resize', top: '0%', left: '50%' },
{ pos: 'ne', cursor: 'nesw-resize', top: '0%', left: '100%' },
{ pos: 'e', cursor: 'ew-resize', top: '50%', left: '100%' },
{ pos: 'se', cursor: 'nwse-resize', top: '100%', left: '100%' },
{ pos: 's', cursor: 'ns-resize', top: '100%', left: '50%' },
{ pos: 'sw', cursor: 'nesw-resize', top: '100%', left: '0%' },
{ pos: 'w', cursor: 'ew-resize', top: '50%', left: '0%' },
];
export function FormFieldEditOverlay({
documentId,
pageIndex,
pageWidth,
pageHeight,
}: FormFieldEditOverlayProps) {
const {
mode,
fieldsByPage,
editState,
setEditState,
modifiedFields,
updateFieldCoordinates,
state: { fields: allFields },
} = useFormFill();
const documentState = useDocumentState(documentId);
const pageFields = useMemo(
() => fieldsByPage.get(pageIndex) || [],
[fieldsByPage, pageIndex]
);
const { scaleX, scaleY, pageWidthPts, pageHeightPts } = useMemo(() => {
const pdfPage = documentState?.document?.pages?.[pageIndex];
if (!pdfPage || !pdfPage.size || !pageWidth || !pageHeight) {
const s = documentState?.scale ?? 1;
return { scaleX: s, scaleY: s, pageWidthPts: pageWidth / s, pageHeightPts: pageHeight / s };
}
// Prefer CropBox height from backend (if available) for exact Y-flip consistency.
const firstWidget = pageFields.find(f => f.widgets?.some(w => w.pageIndex === pageIndex))
?.widgets?.find(w => w.pageIndex === pageIndex);
const cbHeight = firstWidget?.cropBoxHeight;
return {
scaleX: pageWidth / pdfPage.size.width,
scaleY: pageHeight / pdfPage.size.height,
pageWidthPts: pdfPage.size.width,
pageHeightPts: cbHeight ?? pdfPage.size.height,
};
}, [documentState, pageIndex, pageWidth, pageHeight, pageFields]);
const dragRef = useRef<DragState | null>(null);
const dragFieldRef = useRef<string | null>(null);
const pendingRectRef = useRef<{ x: number; y: number; width: number; height: number } | null>(null);
const overlayRef = useRef<HTMLDivElement>(null);
const [snapGuides, setSnapGuides] = useState<SnapGuide[]>([]);
// Get the CSS rect for a field (considering pending modifications)
const getFieldCssRect = useCallback((field: FormField, widget: WidgetCoordinates) => {
const modified = modifiedFields.get(field.name);
if (modified && modified.x != null && modified.y != null && modified.width != null && modified.height != null) {
// modified coords are in PDF BL origin — convert to CSS using THIS widget's cropBoxHeight
const cropBoxHeight = widget.cropBoxHeight ?? pageHeightPts;
const css = pdfToCssRect(
{ x: modified.x, y: modified.y, width: modified.width, height: modified.height },
cropBoxHeight
);
return {
left: css.x * scaleX,
top: css.y * scaleY,
width: css.width * scaleX,
height: css.height * scaleY,
};
}
// Widget coords are already in CSS TL origin (y-flipped by backend)
return {
left: widget.x * scaleX,
top: widget.y * scaleY,
width: widget.width * scaleX,
height: widget.height * scaleY,
};
}, [modifiedFields, pageHeightPts, scaleX, scaleY]);
const handleFieldClick = useCallback((e: React.PointerEvent, fieldName: string) => {
e.stopPropagation();
setEditState({
selectedFieldName: fieldName,
interaction: 'idle',
pendingRect: null,
});
}, [setEditState]);
const handlePointerDown = useCallback((
e: React.PointerEvent,
fieldName: string,
type: 'move' | 'resize',
handle?: HandlePosition
) => {
e.preventDefault();
e.stopPropagation();
// Find current CSS rect for this field
const field = pageFields.find(f => f.name === fieldName);
if (!field) return;
const widget = field.widgets?.find(w => w.pageIndex === pageIndex);
if (!widget) return;
const rect = getFieldCssRect(field, widget);
dragRef.current = {
type,
handle,
startMouseX: e.clientX,
startMouseY: e.clientY,
startLeft: rect.left,
startTop: rect.top,
startWidth: rect.width,
startHeight: rect.height,
};
dragFieldRef.current = fieldName;
setEditState({
selectedFieldName: fieldName,
interaction: type === 'move' ? 'moving' : 'resizing',
pendingRect: { x: rect.left, y: rect.top, width: rect.width, height: rect.height },
});
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}, [pageFields, pageIndex, getFieldCssRect, setEditState]);
const handlePointerMove = useCallback((e: React.PointerEvent) => {
if (!dragRef.current || !dragFieldRef.current) return;
e.preventDefault();
const drag = dragRef.current;
const dx = e.clientX - drag.startMouseX;
const dy = e.clientY - drag.startMouseY;
let newLeft = drag.startLeft;
let newTop = drag.startTop;
let newWidth = drag.startWidth;
let newHeight = drag.startHeight;
if (drag.type === 'move') {
newLeft = Math.max(0, Math.min(pageWidth - drag.startWidth, drag.startLeft + dx));
newTop = Math.max(0, Math.min(pageHeight - drag.startHeight, drag.startTop + dy));
} else if (drag.type === 'resize' && drag.handle) {
const h = drag.handle;
// Adjust dimensions based on handle position
if (h.includes('e')) {
newWidth = Math.max(MIN_SIZE_PX, drag.startWidth + dx);
}
if (h.includes('w')) {
const dw = Math.min(dx, drag.startWidth - MIN_SIZE_PX);
newLeft = drag.startLeft + dw;
newWidth = drag.startWidth - dw;
}
if (h.includes('s')) {
newHeight = Math.max(MIN_SIZE_PX, drag.startHeight + dy);
}
if (h.includes('n')) {
const dh = Math.min(dy, drag.startHeight - MIN_SIZE_PX);
newTop = drag.startTop + dh;
newHeight = drag.startHeight - dh;
}
// Clamp to page bounds
newLeft = Math.max(0, newLeft);
newTop = Math.max(0, newTop);
if (newLeft + newWidth > pageWidth) newWidth = pageWidth - newLeft;
if (newTop + newHeight > pageHeight) newHeight = pageHeight - newTop;
}
// Apply snapping
const targets = collectSnapTargets(
allFields, dragFieldRef.current, pageIndex,
scaleX, scaleY, pageHeightPts, modifiedFields,
);
if (drag.type === 'move') {
const snapped = snapRect(newLeft, newTop, newWidth, newHeight, targets);
newLeft = snapped.left;
newTop = snapped.top;
setSnapGuides(snapped.guides);
} else if (drag.type === 'resize' && drag.handle) {
const h = drag.handle;
const resizeEdges: ResizeEdges = {
left: h.includes('w'),
right: h.includes('e'),
top: h.includes('n'),
bottom: h.includes('s'),
};
const snapped = snapRectResize(newLeft, newTop, newWidth, newHeight, resizeEdges, targets);
newLeft = snapped.left;
newTop = snapped.top;
newWidth = snapped.width;
newHeight = snapped.height;
setSnapGuides(snapped.guides);
}
const rect = { x: newLeft, y: newTop, width: newWidth, height: newHeight };
pendingRectRef.current = rect;
setEditState({
interaction: drag.type === 'move' ? 'moving' : 'resizing',
pendingRect: rect,
});
}, [pageWidth, pageHeight, allFields, pageIndex, scaleX, scaleY, pageHeightPts, modifiedFields, setEditState]);
const handlePointerUp = useCallback((e: React.PointerEvent) => {
if (!dragRef.current || !dragFieldRef.current) return;
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
const pendingRect = pendingRectRef.current;
const fieldName = dragFieldRef.current;
dragRef.current = null;
dragFieldRef.current = null;
pendingRectRef.current = null;
setSnapGuides([]);
if (!pendingRect) {
setEditState({ interaction: 'idle', pendingRect: null });
return;
}
// Get the specific widget's cropBoxHeight for correct Y-flip.
// Must match the height used during extraction to avoid coordinate mismatch.
const field = allFields.find(f => f.name === fieldName);
const widget = field?.widgets?.find(w => w.pageIndex === pageIndex);
const cropBoxHeight = widget?.cropBoxHeight ?? pageHeightPts;
// Convert pixel rect to PDF-point CSS rect, then to PDF BL origin
const cssPts = {
x: pendingRect.x / scaleX,
y: pendingRect.y / scaleY,
width: pendingRect.width / scaleX,
height: pendingRect.height / scaleY,
};
const pdfRect = cssToPdfRect(cssPts, cropBoxHeight);
updateFieldCoordinates(fieldName, {
x: pdfRect.x,
y: pdfRect.y,
width: pdfRect.width,
height: pdfRect.height,
});
setEditState({ interaction: 'idle', pendingRect: null });
}, [scaleX, scaleY, pageHeightPts, updateFieldCoordinates, setEditState, allFields, pageIndex]);
// Click on empty area deselects
const handleOverlayClick = useCallback((e: React.PointerEvent) => {
if (e.target === overlayRef.current) {
setEditState({ selectedFieldName: null, interaction: 'idle', pendingRect: null });
}
}, [setEditState]);
// Escape to deselect, Delete to remove selection
useEffect(() => {
if (mode !== 'modify') return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && editState.selectedFieldName) {
setEditState({ selectedFieldName: null, interaction: 'idle', pendingRect: null });
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [mode, editState.selectedFieldName, setEditState]);
if (mode !== 'modify') return null;
return (
<div
ref={overlayRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: 15,
pointerEvents: 'auto',
cursor: editState.interaction !== 'idle' ? 'grabbing' : 'default',
}}
onPointerDown={handleOverlayClick}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
>
{/* Snap guide lines */}
{snapGuides.map((guide, i) =>
guide.axis === 'x' ? (
<div
key={`guide-${i}`}
style={{
position: 'absolute',
left: guide.position,
top: 0,
width: 0,
height: '100%',
borderLeft: '1px dashed rgba(255, 0, 100, 0.6)',
pointerEvents: 'none',
zIndex: 20,
}}
/>
) : (
<div
key={`guide-${i}`}
style={{
position: 'absolute',
left: 0,
top: guide.position,
width: '100%',
height: 0,
borderTop: '1px dashed rgba(255, 0, 100, 0.6)',
pointerEvents: 'none',
zIndex: 20,
}}
/>
)
)}
{pageFields.map((field) => {
const widgets = (field.widgets || []).filter(w => w.pageIndex === pageIndex);
if (widgets.length === 0) return null;
const widget = widgets[0];
const isSelected = editState.selectedFieldName === field.name;
const isModified = modifiedFields.has(field.name);
// Use pending rect during drag for the selected field
const usesPending = isSelected && editState.pendingRect && editState.interaction !== 'idle';
const rect = usesPending
? { left: editState.pendingRect!.x, top: editState.pendingRect!.y, width: editState.pendingRect!.width, height: editState.pendingRect!.height }
: getFieldCssRect(field, widget);
const color = `var(--mantine-color-${FIELD_TYPE_COLOR[field.type]}-5)`;
return (
<div
key={field.name}
style={{
position: 'absolute',
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
border: isSelected
? `2px solid ${color}`
: `1px solid ${isModified ? color : 'rgba(33, 150, 243, 0.4)'}`,
borderRadius: 2,
background: isSelected ? 'rgba(33, 150, 243, 0.08)' : 'transparent',
cursor: isSelected ? 'grab' : 'pointer',
boxSizing: 'border-box',
transition: editState.interaction !== 'idle' ? 'none' : 'border-color 0.15s',
}}
onPointerDown={(e) => {
if (isSelected) {
handlePointerDown(e, field.name, 'move');
} else {
handleFieldClick(e, field.name);
}
}}
>
{/* Resize handles — only for selected field */}
{isSelected && editState.interaction === 'idle' && HANDLE_POSITIONS.map(({ pos, cursor, top, left }) => (
<div
key={pos}
style={{
position: 'absolute',
top,
left,
width: HANDLE_SIZE,
height: HANDLE_SIZE,
background: 'white',
border: `1.5px solid ${color}`,
borderRadius: 1,
transform: 'translate(-50%, -50%)',
cursor,
zIndex: 1,
}}
onPointerDown={(e) => handlePointerDown(e, field.name, 'resize', pos)}
/>
))}
</div>
);
})}
</div>
);
}
@@ -0,0 +1,299 @@
/**
* FormFieldModifyPanel — Left panel for "Modify" mode.
*
* Shows existing fields, allows selecting one for coordinate editing
* via the overlay, and batches all modifications into one backend call.
*/
import React, { useState, useCallback, useMemo } from 'react';
import {
Button,
Text,
ScrollArea,
Alert,
Loader,
} from '@mantine/core';
import SaveIcon from '@mui/icons-material/Save';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import { useFormFill } from '@app/tools/formFill/FormFillContext';
import { useFileState } from '@app/contexts/FileContext';
import { isStirlingFile } from '@app/types/fileContext';
import { FIELD_TYPE_ICON, FIELD_TYPE_COLOR } from '@app/tools/formFill/fieldMeta';
import { FormFieldPropertyEditor } from '@app/tools/formFill/FormFieldPropertyEditor';
import type { NewFieldDefinition, FormField } from '@app/tools/formFill/types';
import styles from '@app/tools/formFill/FormFill.module.css';
export function FormFieldModifyPanel() {
const {
state: formState,
editState,
setEditState,
modifiedFields,
updateFieldCoordinates,
updateFieldProperties,
commitFieldModifications,
fetchFields,
} = useFormFill();
const { selectors, state: fileState } = useFileState();
const activeFiles = selectors.getFiles();
const selectedFileIds = fileState.ui.selectedFileIds;
const currentFile = useMemo(() => {
if (activeFiles.length === 0) return null;
if (selectedFileIds.length > 0) {
const sel = activeFiles.find(
(f) => isStirlingFile(f) && selectedFileIds.includes(f.fileId)
);
if (sel) return sel;
}
return activeFiles[0];
}, [activeFiles, selectedFileIds]);
const [committing, setCommitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleCommit = useCallback(async () => {
if (!currentFile || !isStirlingFile(currentFile)) return;
if (modifiedFields.size === 0) return;
setCommitting(true);
setError(null);
try {
const blob = await commitFieldModifications(currentFile);
const event = new CustomEvent('formfill:apply', { detail: { blob } });
window.dispatchEvent(event);
fetchFields(currentFile, currentFile.fileId);
} catch (err: any) {
setError(err?.message || 'Failed to save modifications');
} finally {
setCommitting(false);
}
}, [currentFile, modifiedFields, commitFieldModifications, fetchFields]);
const handleSelectField = useCallback((fieldName: string) => {
setEditState({
selectedFieldName: editState.selectedFieldName === fieldName ? null : fieldName,
interaction: 'idle',
pendingRect: null,
});
}, [editState.selectedFieldName, setEditState]);
const { fields } = formState;
// Build a NewFieldDefinition-like object for the property editor from the selected field
const selectedField = useMemo<FormField | undefined>(
() => editState.selectedFieldName ? fields.find(f => f.name === editState.selectedFieldName) : undefined,
[fields, editState.selectedFieldName]
);
const selectedFieldEditorData = useMemo<NewFieldDefinition | null>(() => {
if (!selectedField) return null;
const widget = selectedField.widgets?.[0];
const pending = modifiedFields.get(selectedField.name) || {};
// Coords: modifiedFields stores PDF BL origin. Widget coords are CSS TL (y-flipped).
// Convert widget CSS TL → PDF BL for display consistency when no pending modification.
const cropH = widget?.cropBoxHeight ?? 0;
const widgetPdfY = cropH > 0 && widget
? cropH - widget.y - widget.height
: widget?.y ?? 0;
const x = pending.x ?? widget?.x ?? 0;
const y = pending.y ?? widgetPdfY;
const w = pending.width ?? widget?.width ?? 100;
const h = pending.height ?? widget?.height ?? 20;
return {
name: pending.name ?? selectedField.name,
label: pending.label ?? selectedField.label ?? undefined,
type: (pending.type ?? selectedField.type) as NewFieldDefinition['type'],
pageIndex: widget?.pageIndex ?? 0,
x,
y,
width: w,
height: h,
required: pending.required ?? selectedField.required,
tooltip: pending.tooltip ?? selectedField.tooltip ?? undefined,
defaultValue: pending.defaultValue ?? selectedField.value ?? undefined,
fontSize: pending.fontSize ?? widget?.fontSize ?? undefined,
readOnly: pending.readOnly ?? selectedField.readOnly,
multiline: pending.multiline ?? selectedField.multiline,
multiSelect: pending.multiSelect ?? selectedField.multiSelect,
options: pending.options ?? selectedField.options ?? undefined,
};
}, [selectedField, modifiedFields]);
const handlePropertyChange = useCallback((updated: NewFieldDefinition) => {
if (!selectedField) return;
const props: Record<string, unknown> = {};
if (updated.name !== selectedField.name) props.name = updated.name;
if (updated.label !== (selectedField.label ?? undefined)) props.label = updated.label ?? '';
if (updated.tooltip !== (selectedField.tooltip ?? undefined)) props.tooltip = updated.tooltip ?? '';
if (updated.defaultValue !== (selectedField.value ?? undefined)) props.defaultValue = updated.defaultValue ?? '';
if (updated.required !== selectedField.required) props.required = updated.required;
if (updated.readOnly !== selectedField.readOnly) props.readOnly = updated.readOnly;
if (updated.multiline !== selectedField.multiline) props.multiline = updated.multiline;
if (updated.multiSelect !== selectedField.multiSelect) props.multiSelect = updated.multiSelect;
if (updated.fontSize !== (selectedField.widgets?.[0]?.fontSize ?? undefined)) props.fontSize = updated.fontSize;
if (updated.options !== (selectedField.options ?? undefined)) props.options = updated.options;
if (updated.type !== selectedField.type) props.type = updated.type;
if (Object.keys(props).length > 0) {
updateFieldProperties(selectedField.name, props);
}
}, [selectedField, updateFieldProperties]);
const handleCoordsChange = useCallback((coords: { x: number; y: number; width: number; height: number }) => {
if (!selectedField) return;
updateFieldCoordinates(selectedField.name, coords);
}, [selectedField, updateFieldCoordinates]);
// Group fields by page
const fieldsByPage = useMemo(() => {
const byPage = new Map<number, typeof fields>();
for (const field of fields) {
const pageIndex = field.widgets?.[0]?.pageIndex ?? 0;
if (!byPage.has(pageIndex)) byPage.set(pageIndex, []);
byPage.get(pageIndex)!.push(field);
}
return byPage;
}, [fields]);
const sortedPages = useMemo(
() => Array.from(fieldsByPage.keys()).sort((a, b) => a - b),
[fieldsByPage]
);
return (
<div className={styles.root}>
<div className={styles.header}>
<Text size="xs" fw={600}>
Click a field below or on the PDF to select it. Drag to move, use handles to resize.
</Text>
{modifiedFields.size > 0 && (
<Button
leftSection={committing ? <Loader size={14} color="white" /> : <SaveIcon sx={{ fontSize: 14 }} />}
size="xs"
onClick={handleCommit}
loading={committing}
fullWidth
>
Save {modifiedFields.size} Change{modifiedFields.size !== 1 ? 's' : ''}
</Button>
)}
{error && (
<Alert icon={<WarningAmberIcon sx={{ fontSize: 16 }} />} color="red" variant="light" p="xs" radius="sm">
<Text size="xs">{error}</Text>
</Alert>
)}
</div>
<ScrollArea className={styles.fieldList}>
<div className={styles.fieldListInner}>
{formState.loading && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '1rem' }}>
<Loader size={14} />
<Text size="xs" c="dimmed">Loading fields...</Text>
</div>
)}
{!formState.loading && fields.length === 0 && (
<div className={styles.emptyState} style={{ padding: '2rem 1rem' }}>
<Text size="xs" c="dimmed" ta="center">
No form fields found. Use Create mode to add fields first.
</Text>
</div>
)}
{sortedPages.map((pageIdx, i) => (
<React.Fragment key={pageIdx}>
<div
className={styles.pageDivider}
style={i === 0 ? { marginTop: 0 } : undefined}
>
<Text className={styles.pageDividerLabel}>
Page {pageIdx + 1}
</Text>
</div>
{fieldsByPage.get(pageIdx)!.map((field) => {
const isSelected = editState.selectedFieldName === field.name;
const isModified = modifiedFields.has(field.name);
const coords = modifiedFields.get(field.name);
const widget = field.widgets?.[0];
return (
<div
key={field.name}
className={`${styles.fieldCard} ${isSelected ? styles.fieldCardActive : ''}`}
onClick={() => handleSelectField(field.name)}
>
<div className={styles.fieldHeader}>
<span
className={styles.fieldTypeIcon}
style={{
color: `var(--mantine-color-${FIELD_TYPE_COLOR[field.type]}-6)`,
fontSize: '0.875rem',
}}
>
{FIELD_TYPE_ICON[field.type]}
</span>
<span className={styles.fieldName}>
{field.label || field.name}
</span>
{isModified && (
<span style={{
fontSize: '0.5625rem',
padding: '0.0625rem 0.375rem',
borderRadius: 'var(--radius-xs)',
background: 'var(--mantine-color-yellow-light)',
color: 'var(--mantine-color-yellow-light-color)',
fontWeight: 800,
textTransform: 'uppercase',
}}>
modified
</span>
)}
</div>
{!isSelected && widget && (
<Text size="xs" c="dimmed" style={{ fontSize: '0.625rem' }}>
{coords && coords.x != null && coords.y != null && coords.width != null && coords.height != null
? `(${Math.round(coords.x)}, ${Math.round(coords.y)}) ${Math.round(coords.width)}×${Math.round(coords.height)} pt`
: `(${Math.round(widget.x)}, ${Math.round(widget.y)}) ${Math.round(widget.width)}×${Math.round(widget.height)} pt`
}
</Text>
)}
{/* Inline property editor for the selected field */}
{isSelected && selectedFieldEditorData && (
<div
style={{ borderTop: '1px solid var(--mantine-color-default-border)', marginTop: '0.375rem', paddingTop: '0.375rem' }}
onClick={(e) => e.stopPropagation()}
>
<FormFieldPropertyEditor
field={selectedFieldEditorData}
onChange={handlePropertyChange}
allowTypeChange
showCoordinates
onCoordsChange={handleCoordsChange}
/>
</div>
)}
</div>
);
})}
</React.Fragment>
))}
</div>
</ScrollArea>
{/* Status bar */}
{modifiedFields.size > 0 && (
<div className={styles.statusBar}>
<span>
<span className={styles.unsavedDot} />
{modifiedFields.size} unsaved change{modifiedFields.size !== 1 ? 's' : ''}
</span>
</div>
)}
</div>
);
}
@@ -382,7 +382,7 @@ export function FormFieldOverlay({
pageHeight,
fileId,
}: FormFieldOverlayProps) {
const { setValue, setActiveField, fieldsByPage, state, forFileId } = useFormFill();
const { setValue, setActiveField, fieldsByPage, state, forFileId, mode } = useFormFill();
const { activeFieldName, validationErrors } = state;
// Get scale from EmbedPDF document state — same pattern as LinkLayer
@@ -431,6 +431,9 @@ export function FormFieldOverlay({
if (pageFields.length === 0) return null;
// In modify or create mode, the edit/creation overlays handle interactions instead
const isEditMode = state.fields.length > 0 && (mode === 'modify' || mode === 'make');
return (
<div
style={{
@@ -444,7 +447,8 @@ export function FormFieldOverlay({
}}
data-form-overlay-page={pageIndex}
>
{pageFields.map((field: FormField) =>
{/* In edit modes, don't render interactive widgets — the edit/creation overlays handle that */}
{!isEditMode && pageFields.map((field: FormField) =>
(field.widgets || [])
.filter((w: WidgetCoordinates) => w.pageIndex === pageIndex)
.map((widget: WidgetCoordinates, widgetIdx: number) => {
@@ -0,0 +1,251 @@
/**
* FormFieldPropertyEditor — Shared property editor for form field definitions.
* Used by both FormFieldCreatePanel (create mode) and FormFieldModifyPanel (modify mode).
*/
import React from 'react';
import {
TextInput,
NumberInput,
Select,
Switch,
Text,
ActionIcon,
Button,
Stack,
SimpleGrid,
} from '@mantine/core';
import DeleteIcon from '@mui/icons-material/Delete';
import AddIcon from '@mui/icons-material/Add';
import type { FormFieldType, NewFieldDefinition } from '@app/tools/formFill/types';
interface FormFieldPropertyEditorProps {
field: NewFieldDefinition;
onChange: (updated: NewFieldDefinition) => void;
/** Whether to allow changing the field type (true in modify mode) */
allowTypeChange?: boolean;
/** Whether coordinates are read-only display */
showCoordinates?: boolean;
/** When provided, coordinate section renders editable NumberInputs */
onCoordsChange?: (coords: { x: number; y: number; width: number; height: number }) => void;
}
const FIELD_TYPE_OPTIONS: { value: FormFieldType; label: string }[] = [
{ value: 'text', label: 'Text Field' },
{ value: 'checkbox', label: 'Checkbox' },
{ value: 'combobox', label: 'Dropdown' },
{ value: 'listbox', label: 'List Box' },
];
export function FormFieldPropertyEditor({
field,
onChange,
allowTypeChange = false,
showCoordinates = false,
onCoordsChange,
}: FormFieldPropertyEditorProps) {
const hasOptions = field.type === 'combobox' || field.type === 'listbox';
return (
<Stack gap="xs">
<TextInput
label="Name"
size="xs"
value={field.name}
onChange={(e) => onChange({ ...field, name: e.currentTarget.value })}
placeholder="Field name"
/>
<TextInput
label="Label"
size="xs"
value={field.label || ''}
onChange={(e) => onChange({ ...field, label: e.currentTarget.value || undefined })}
placeholder="Display label"
/>
{allowTypeChange && (
<Select
label="Type"
size="xs"
data={FIELD_TYPE_OPTIONS}
value={field.type}
onChange={(val) => {
if (val) onChange({ ...field, type: val as FormFieldType });
}}
/>
)}
<TextInput
label="Tooltip"
size="xs"
value={field.tooltip || ''}
onChange={(e) => onChange({ ...field, tooltip: e.currentTarget.value || undefined })}
placeholder="Help text"
/>
<TextInput
label="Default Value"
size="xs"
value={field.defaultValue || ''}
onChange={(e) => onChange({ ...field, defaultValue: e.currentTarget.value || undefined })}
placeholder="Default value"
/>
<NumberInput
label="Font Size (pt)"
size="xs"
value={field.fontSize ?? ''}
onChange={(val) => onChange({ ...field, fontSize: typeof val === 'number' ? val : undefined })}
placeholder="12 (default)"
min={4}
max={72}
step={1}
/>
<Switch
label="Required"
size="xs"
checked={field.required || false}
onChange={(e) => onChange({ ...field, required: e.currentTarget.checked })}
styles={{ label: { fontSize: '0.75rem', cursor: 'pointer' } }}
/>
<Switch
label="Read-only"
size="xs"
checked={field.readOnly || false}
onChange={(e) => onChange({ ...field, readOnly: e.currentTarget.checked })}
styles={{ label: { fontSize: '0.75rem', cursor: 'pointer' } }}
/>
{field.type === 'text' && (
<Switch
label="Multiline"
size="xs"
checked={field.multiline || false}
onChange={(e) => onChange({ ...field, multiline: e.currentTarget.checked })}
styles={{ label: { fontSize: '0.75rem', cursor: 'pointer' } }}
/>
)}
{hasOptions && (
<div>
<Text size="xs" fw={500} mb={4}>Options</Text>
{(field.options || []).map((opt, idx) => (
<div key={idx} style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
<TextInput
size="xs"
value={opt}
onChange={(e) => {
const newOpts = [...(field.options || [])];
newOpts[idx] = e.currentTarget.value;
onChange({ ...field, options: newOpts });
}}
style={{ flex: 1 }}
placeholder={`Option ${idx + 1}`}
/>
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={() => {
const newOpts = (field.options || []).filter((_, i) => i !== idx);
onChange({ ...field, options: newOpts });
}}
>
<DeleteIcon sx={{ fontSize: 14 }} />
</ActionIcon>
</div>
))}
<Button
variant="subtle"
size="xs"
leftSection={<AddIcon sx={{ fontSize: 14 }} />}
onClick={() => {
onChange({ ...field, options: [...(field.options || []), ''] });
}}
>
Add Option
</Button>
</div>
)}
{field.type === 'listbox' && (
<Switch
label="Multi-select"
size="xs"
checked={field.multiSelect || false}
onChange={(e) => onChange({ ...field, multiSelect: e.currentTarget.checked })}
styles={{ label: { fontSize: '0.75rem', cursor: 'pointer' } }}
/>
)}
{showCoordinates && !onCoordsChange && (
<div>
<Text size="xs" fw={500} c="dimmed" mb={2}>Position</Text>
<Text size="xs" c="dimmed">
Page {field.pageIndex + 1} &middot; ({Math.round(field.x)}, {Math.round(field.y)}) &middot; {Math.round(field.width)} &times; {Math.round(field.height)} pt
</Text>
</div>
)}
{showCoordinates && onCoordsChange && (
<div>
<Text size="xs" fw={500} c="dimmed" mb={4}>Position &amp; Size (PDF points)</Text>
<SimpleGrid cols={2} spacing={4}>
<NumberInput
label="X"
size="xs"
value={field.x}
onChange={(val) => {
if (typeof val === 'number') {
onCoordsChange({ x: val, y: field.y, width: field.width, height: field.height });
}
}}
step={1}
decimalScale={1}
/>
<NumberInput
label="Y"
size="xs"
value={field.y}
onChange={(val) => {
if (typeof val === 'number') {
onCoordsChange({ x: field.x, y: val, width: field.width, height: field.height });
}
}}
step={1}
decimalScale={1}
/>
<NumberInput
label="Width"
size="xs"
value={field.width}
onChange={(val) => {
if (typeof val === 'number' && val > 0) {
onCoordsChange({ x: field.x, y: field.y, width: val, height: field.height });
}
}}
min={1}
step={1}
decimalScale={1}
/>
<NumberInput
label="Height"
size="xs"
value={field.height}
onChange={(val) => {
if (typeof val === 'number' && val > 0) {
onCoordsChange({ x: field.x, y: field.y, width: field.width, height: val });
}
}}
min={1}
step={1}
decimalScale={1}
/>
</SimpleGrid>
</div>
)}
</Stack>
);
}
+16 -17
View File
@@ -20,6 +20,7 @@ import {
Progress,
Tooltip,
ActionIcon,
SegmentedControl,
} from '@mantine/core';
import { useFormFill, useAllFormValues } from '@app/tools/formFill/FormFillContext';
import { useNavigation } from '@app/contexts/NavigationContext';
@@ -29,8 +30,11 @@ import { Skeleton } from '@mantine/core';
import { isStirlingFile } from '@app/types/fileContext';
import type { BaseToolProps } from '@app/types/tool';
import type { FormField } from '@app/tools/formFill/types';
import type { FormMode } from '@app/tools/formFill/types';
import { FieldInput } from '@app/tools/formFill/FieldInput';
import { FIELD_TYPE_ICON, FIELD_TYPE_COLOR } from '@app/tools/formFill/fieldMeta';
import { FormFieldCreatePanel } from '@app/tools/formFill/FormFieldCreatePanel';
import { FormFieldModifyPanel } from '@app/tools/formFill/FormFieldModifyPanel';
import SaveIcon from '@mui/icons-material/Save';
import RefreshIcon from '@mui/icons-material/Refresh';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
@@ -46,8 +50,6 @@ import styles from '@app/tools/formFill/FormFill.module.css';
// Mode tabs — extensible for future form tools
// ---------------------------------------------------------------------------
type FormMode = 'fill' | 'make' | 'batch' | 'modify';
interface ModeTabDef {
id: FormMode;
label: string;
@@ -55,11 +57,11 @@ interface ModeTabDef {
ready: boolean;
}
const _MODE_TABS: ModeTabDef[] = [
const MODE_TABS: ModeTabDef[] = [
{ id: 'fill', label: 'Fill', icon: <EditNoteIcon className={styles.modeTabIcon} />, ready: true },
{ id: 'make', label: 'Create', icon: <PostAddIcon className={styles.modeTabIcon} />, ready: false },
{ id: 'make', label: 'Create', icon: <PostAddIcon className={styles.modeTabIcon} />, ready: true },
{ id: 'batch', label: 'Batch', icon: <FileCopyIcon className={styles.modeTabIcon} />, ready: false },
{ id: 'modify', label: 'Modify', icon: <BuildCircleIcon className={styles.modeTabIcon} />, ready: false },
{ id: 'modify', label: 'Modify', icon: <BuildCircleIcon className={styles.modeTabIcon} />, ready: true },
];
// ---------------------------------------------------------------------------
@@ -94,6 +96,8 @@ const FormFill = (_props: BaseToolProps) => {
setValue,
setActiveField,
validateForm,
mode,
setMode,
} = useFormFill();
const allValues = useAllFormValues();
@@ -101,11 +105,6 @@ const FormFill = (_props: BaseToolProps) => {
const { scrollActions } = useViewer();
// Mode system is temporarily restricted to 'fill' only.
// Other modes (make, batch, modify) are defined above but not yet exposed in the UI.
// When ready, uncomment the SegmentedControl and mode state below.
// const [mode, setMode] = useState<FormMode>('fill');
const mode: FormMode = 'fill';
const [flatten, setFlatten] = useState(false);
const [saving, setSaving] = useState(false);
const [extracting, setExtracting] = useState(false);
@@ -297,16 +296,14 @@ const FormFill = (_props: BaseToolProps) => {
if (!isActive) return null;
// const currentModeDef = MODE_TABS.find((t) => t.id === mode)!;
return (
<div className={styles.root}>
{/* ---- Mode selection (commented out until additional modes are implemented) ----
{/* ---- Mode selection ---- */}
<div className={styles.modeTabs}>
<SegmentedControl
value={mode}
onChange={(val) => setMode(val as FormMode)}
data={MODE_TABS.map((tab) => ({
data={MODE_TABS.filter((tab) => tab.ready).map((tab) => ({
value: tab.id,
label: (
<div className={styles.segmentedLabel}>
@@ -326,10 +323,12 @@ const FormFill = (_props: BaseToolProps) => {
}}
/>
</div>
---- */}
{/* ---- Coming-soon for non-ready tabs (hidden while mode tabs are disabled) ---- */}
{/* !currentModeDef.ready && <ComingSoonPlaceholder mode={currentModeDef} /> */}
{/* ---- Create mode ---- */}
{mode === 'make' && <FormFieldCreatePanel />}
{/* ---- Modify mode ---- */}
{mode === 'modify' && <FormFieldModifyPanel />}
{/* ---- Fill Form content ---- */}
{mode === 'fill' && (
@@ -30,7 +30,16 @@ import React, {
useSyncExternalStore,
} from 'react';
import { useDebouncedCallback } from '@mantine/hooks';
import type { FormField, FormFillState, WidgetCoordinates } from '@app/tools/formFill/types';
import type {
FormField,
FormFillState,
FormMode,
WidgetCoordinates,
NewFieldDefinition,
ModifyFieldDefinition,
FieldCreationState,
FieldEditState,
} from '@app/tools/formFill/types';
import type { IFormDataProvider } from '@app/tools/formFill/providers/types';
import { PdfLibFormProvider } from '@app/tools/formFill/providers/PdfLibFormProvider';
import { PdfBoxFormProvider } from '@app/tools/formFill/providers/PdfBoxFormProvider';
@@ -203,6 +212,44 @@ export interface FormFillContextValue {
setProviderMode: (mode: 'pdflib' | 'pdfbox') => void;
/** The file ID that the current form fields belong to (null if no fields loaded) */
forFileId: string | null;
// --- Mode management (Create / Modify) ---
/** Current active mode */
mode: FormMode;
/** Switch the active mode */
setMode: (mode: FormMode) => void;
// --- Create mode state ---
/** Creation state (pending fields, placing type, drag rect) */
creationState: FieldCreationState;
/** Set the field type being placed (null to cancel placement) */
setPlacingFieldType: (type: import('@app/tools/formFill/types').FormFieldType | null) => void;
/** Add a pending field to the creation queue */
addPendingField: (field: NewFieldDefinition) => void;
/** Remove a pending field by index */
removePendingField: (index: number) => void;
/** Update a pending field by index */
updatePendingField: (index: number, field: NewFieldDefinition) => void;
/** Set the drag rectangle during creation */
setCreationDragRect: (rect: FieldCreationState['dragRect']) => void;
/** Commit all pending fields to the backend and refresh */
commitNewFields: (file: File | Blob) => Promise<Blob>;
// --- Modify mode state ---
/** Edit state (selected field, interaction, pending rect) */
editState: FieldEditState;
/** Update the edit state */
setEditState: (state: Partial<FieldEditState>) => void;
/** Pending field modifications keyed by field name (coordinates + properties) */
modifiedFields: Map<string, Partial<ModifyFieldDefinition>>;
/** Record a coordinate change for a field */
updateFieldCoordinates: (fieldName: string, coords: { x: number; y: number; width: number; height: number }) => void;
/** Record a property change for a field (fontSize, readOnly, multiline, etc.) */
updateFieldProperties: (fieldName: string, props: Partial<ModifyFieldDefinition>) => void;
/** Commit all field modifications to the backend and refresh */
commitFieldModifications: (file: File | Blob) => Promise<Blob>;
/** Whether there are uncommitted changes in create or modify mode */
hasUncommittedChanges: boolean;
}
const FormFillContext = createContext<FormFillContextValue | null>(null);
@@ -303,6 +350,118 @@ export function FormFillProvider({
// This prevents full context re-renders on every keystroke.
const [valuesStore] = useState(() => new FormValuesStore());
// --- Mode / Create / Modify state ---
const [formMode, setFormMode] = useState<FormMode>('fill');
const [creationState, setCreationState] = useState<FieldCreationState>({
pendingFields: [],
placingFieldType: null,
dragRect: null,
});
const [editState, setEditStateRaw] = useState<FieldEditState>({
selectedFieldName: null,
interaction: 'idle',
pendingRect: null,
});
const [modifiedFields, setModifiedFields] = useState<Map<string, Partial<ModifyFieldDefinition>>>(
() => new Map()
);
const setMode = useCallback((mode: FormMode) => {
setFormMode(mode);
// Reset mode-specific state when switching
setCreationState({ pendingFields: [], placingFieldType: null, dragRect: null });
setEditStateRaw({ selectedFieldName: null, interaction: 'idle', pendingRect: null });
setModifiedFields(new Map());
}, []);
const setPlacingFieldType = useCallback((type: import('@app/tools/formFill/types').FormFieldType | null) => {
setCreationState(prev => ({ ...prev, placingFieldType: type, dragRect: null }));
}, []);
const addPendingField = useCallback((field: NewFieldDefinition) => {
setCreationState(prev => ({
...prev,
pendingFields: [...prev.pendingFields, field],
placingFieldType: null,
dragRect: null,
}));
}, []);
const removePendingField = useCallback((index: number) => {
setCreationState(prev => ({
...prev,
pendingFields: prev.pendingFields.filter((_, i) => i !== index),
}));
}, []);
const updatePendingField = useCallback((index: number, field: NewFieldDefinition) => {
setCreationState(prev => ({
...prev,
pendingFields: prev.pendingFields.map((f, i) => i === index ? field : f),
}));
}, []);
const setCreationDragRect = useCallback((rect: FieldCreationState['dragRect']) => {
setCreationState(prev => ({ ...prev, dragRect: rect }));
}, []);
const commitNewFields = useCallback(async (file: File | Blob): Promise<Blob> => {
const provider = providerRef.current;
if (!provider.addFields) {
throw new Error('Current provider does not support adding fields');
}
const blob = await provider.addFields(file, creationState.pendingFields);
// Clear pending fields after successful commit
setCreationState(prev => ({ ...prev, pendingFields: [] }));
return blob;
}, [creationState.pendingFields]);
const setEditState = useCallback((partial: Partial<FieldEditState>) => {
setEditStateRaw(prev => ({ ...prev, ...partial }));
}, []);
const updateFieldCoordinates = useCallback((fieldName: string, coords: { x: number; y: number; width: number; height: number }) => {
setModifiedFields(prev => {
const next = new Map(prev);
const existing = next.get(fieldName) || {};
next.set(fieldName, { ...existing, x: coords.x, y: coords.y, width: coords.width, height: coords.height });
return next;
});
}, []);
const updateFieldProperties = useCallback((fieldName: string, props: Partial<ModifyFieldDefinition>) => {
setModifiedFields(prev => {
const next = new Map(prev);
const existing = next.get(fieldName) || {};
next.set(fieldName, { ...existing, ...props });
return next;
});
}, []);
const commitFieldModifications = useCallback(async (file: File | Blob): Promise<Blob> => {
const provider = providerRef.current;
if (!provider.modifyFields) {
throw new Error('Current provider does not support modifying fields');
}
const updates: ModifyFieldDefinition[] = [];
for (const [fieldName, partial] of modifiedFields) {
updates.push({
targetName: fieldName,
...partial,
});
}
if (updates.length === 0) {
throw new Error('No field modifications to commit');
}
const blob = await provider.modifyFields(file, updates);
// Clear modifications after successful commit
setModifiedFields(new Map());
setEditStateRaw({ selectedFieldName: null, interaction: 'idle', pendingRect: null });
return blob;
}, [modifiedFields]);
const hasUncommittedChanges = creationState.pendingFields.length > 0 || modifiedFields.size > 0;
const fetchFields = useCallback(async (file: File | Blob, fileId?: string) => {
// Increment version so any in-flight fetch for a previous file is discarded.
// NOTE: setProviderMode() also increments fetchVersionRef to invalidate
@@ -476,6 +635,25 @@ export function FormFillProvider({
activeProviderName: providerRef.current.name,
setProviderMode,
forFileId,
// Mode management
mode: formMode,
setMode,
// Create mode
creationState,
setPlacingFieldType,
addPendingField,
removePendingField,
updatePendingField,
setCreationDragRect,
commitNewFields,
// Modify mode
editState,
setEditState,
modifiedFields,
updateFieldCoordinates,
updateFieldProperties,
commitFieldModifications,
hasUncommittedChanges,
}),
[
state,
@@ -492,6 +670,22 @@ export function FormFillProvider({
providerMode,
setProviderMode,
forFileId,
formMode,
setMode,
creationState,
setPlacingFieldType,
addPendingField,
removePendingField,
updatePendingField,
setCreationDragRect,
commitNewFields,
editState,
setEditState,
modifiedFields,
updateFieldCoordinates,
updateFieldProperties,
commitFieldModifications,
hasUncommittedChanges,
]
);
+43 -1
View File
@@ -2,7 +2,7 @@
* API service for form-related backend calls.
*/
import apiClient from '@app/services/apiClient';
import type { FormField } from '@app/tools/formFill/types';
import type { FormField, NewFieldDefinition, ModifyFieldDefinition } from '@app/tools/formFill/types';
/**
* Fetch form fields with coordinates from the backend.
@@ -44,3 +44,45 @@ export async function fillFormFields(
return response.data;
}
/**
* Add new form fields to a PDF.
* Calls POST /api/v1/form/add-fields
*/
export async function addFormFields(
file: File | Blob,
fields: NewFieldDefinition[]
): Promise<Blob> {
const formData = new FormData();
formData.append('file', file);
formData.append(
'fields',
new Blob([JSON.stringify(fields)], { type: 'application/json' })
);
const response = await apiClient.post('/api/v1/form/add-fields', formData, {
responseType: 'blob',
});
return response.data;
}
/**
* Modify existing form fields in a PDF (properties and/or coordinates).
* Calls POST /api/v1/form/modify-fields
*/
export async function modifyFormFields(
file: File | Blob,
updates: ModifyFieldDefinition[]
): Promise<Blob> {
const formData = new FormData();
formData.append('file', file);
formData.append(
'updates',
new Blob([JSON.stringify(updates)], { type: 'application/json' })
);
const response = await apiClient.post('/api/v1/form/modify-fields', formData, {
responseType: 'blob',
});
return response.data;
}
@@ -0,0 +1,75 @@
/**
* Coordinate conversion utilities for form field creation/editing.
*
* The FormFieldOverlay renders widgets in CSS space (top-left origin, y goes down).
* The backend NewFormFieldDefinition expects PDF space (bottom-left origin, y goes up).
*
* Both coordinate systems use PDF points as units. The CSS overlay multiplies by
* scaleX/scaleY to get pixel positions; these utilities work in the pre-scaled
* PDF-point space.
*/
export interface CssRect {
x: number; // left, in PDF points (CSS TL origin)
y: number; // top, in PDF points (CSS TL origin)
width: number; // PDF points
height: number; // PDF points
}
export interface PdfRect {
x: number; // lower-left X in PDF points (PDF BL origin)
y: number; // lower-left Y in PDF points (PDF BL origin)
width: number; // PDF points
height: number; // PDF points
}
/**
* Convert CSS overlay coordinates (top-left origin) to PDF coordinates (bottom-left origin).
*
* @param css - Rectangle in CSS space (y = distance from top)
* @param pageHeightPts - The un-rotated page height in PDF points
*/
export function cssToPdfRect(css: CssRect, pageHeightPts: number): PdfRect {
// In CSS: y is distance from top edge
// In PDF: y is distance from bottom edge
// PDF lower-left Y = pageHeight - (css.y + css.height)
return {
x: css.x,
y: pageHeightPts - (css.y + css.height),
width: css.width,
height: css.height,
};
}
/**
* Convert PDF coordinates (bottom-left origin) to CSS overlay coordinates (top-left origin).
*
* @param pdf - Rectangle in PDF space (y = distance from bottom)
* @param pageHeightPts - The un-rotated page height in PDF points
*/
export function pdfToCssRect(pdf: PdfRect, pageHeightPts: number): CssRect {
return {
x: pdf.x,
y: pageHeightPts - pdf.y - pdf.height,
width: pdf.width,
height: pdf.height,
};
}
/**
* Convert pixel coordinates from a pointer event to PDF-point coordinates,
* given the container dimensions and the page's PDF-point dimensions.
*/
export function pixelsToPdfPoints(
pixelX: number,
pixelY: number,
containerWidth: number,
containerHeight: number,
pageWidthPts: number,
pageHeightPts: number,
): { x: number; y: number } {
return {
x: (pixelX / containerWidth) * pageWidthPts,
y: (pixelY / containerHeight) * pageHeightPts,
};
}
@@ -0,0 +1,285 @@
/**
* Snap-to-edge utilities for form field alignment.
*
* During drag (move/resize/create), candidate edges are compared to edges of
* all OTHER fields on the same page. If a candidate is within SNAP_THRESHOLD_PX
* pixels of a target edge, it snaps to that edge.
*/
import type { FormField, ModifyFieldDefinition } from '@app/tools/formFill/types';
import { pdfToCssRect } from '@app/tools/formFill/formCoordinateUtils';
const SNAP_THRESHOLD_PX = 6;
export type SnapAxis = 'x' | 'y';
export interface SnapResult {
value: number;
didSnap: boolean;
}
export interface SnapGuide {
axis: SnapAxis;
/** Position in pixels along the snapped axis */
position: number;
}
interface FieldEdges {
left: number;
right: number;
top: number;
bottom: number;
centerX: number;
centerY: number;
}
/**
* Collect pixel-space edges for all fields on the given page, excluding a
* specific field (the one being dragged).
*/
export function collectSnapTargets(
allFields: FormField[],
excludeFieldName: string | null,
pageIndex: number,
scaleX: number,
scaleY: number,
pageHeightPts: number,
modifiedFields: Map<string, Partial<ModifyFieldDefinition>>,
): FieldEdges[] {
const edges: FieldEdges[] = [];
for (const field of allFields) {
if (field.name === excludeFieldName) continue;
const widgets = (field.widgets || []).filter(w => w.pageIndex === pageIndex);
if (widgets.length === 0) continue;
const widget = widgets[0];
const modified = modifiedFields.get(field.name);
let left: number, top: number, width: number, height: number;
if (modified && modified.x != null && modified.y != null && modified.width != null && modified.height != null) {
const css = pdfToCssRect(
{ x: modified.x, y: modified.y, width: modified.width, height: modified.height },
pageHeightPts
);
left = css.x * scaleX;
top = css.y * scaleY;
width = css.width * scaleX;
height = css.height * scaleY;
} else {
left = widget.x * scaleX;
top = widget.y * scaleY;
width = widget.width * scaleX;
height = widget.height * scaleY;
}
edges.push({
left,
right: left + width,
top,
bottom: top + height,
centerX: left + width / 2,
centerY: top + height / 2,
});
}
return edges;
}
/**
* Given candidate edges for pending fields (not yet part of allFields),
* add their pixel-space edges to the targets array.
*/
export function collectPendingFieldSnapTargets(
pendingFields: { pageIndex: number; x: number; y: number; width: number; height: number }[],
pageIndex: number,
scaleX: number,
scaleY: number,
pageHeightPts: number,
): FieldEdges[] {
const edges: FieldEdges[] = [];
for (const field of pendingFields) {
if (field.pageIndex !== pageIndex) continue;
// Pending fields are in PDF BL origin
const cssY = pageHeightPts - field.y - field.height;
const left = field.x * scaleX;
const top = cssY * scaleY;
const width = field.width * scaleX;
const height = field.height * scaleY;
edges.push({
left,
right: left + width,
top,
bottom: top + height,
centerX: left + width / 2,
centerY: top + height / 2,
});
}
return edges;
}
/**
* Try to snap a single value to the nearest target within threshold.
*/
export function applySnap(value: number, targets: number[], threshold = SNAP_THRESHOLD_PX): SnapResult {
let best = value;
let bestDist = threshold + 1;
for (const t of targets) {
const dist = Math.abs(value - t);
if (dist < bestDist) {
bestDist = dist;
best = t;
}
}
return bestDist <= threshold
? { value: best, didSnap: true }
: { value, didSnap: false };
}
function collectEdgeTargets(targets: FieldEdges[]): { x: number[]; y: number[] } {
const x: number[] = [];
const y: number[] = [];
for (const t of targets) {
x.push(t.left, t.right, t.centerX);
y.push(t.top, t.bottom, t.centerY);
}
return { x, y };
}
/**
* Snap a rectangle during **move** — shifts position, keeps dimensions fixed.
* Snaps whichever edge/center is closest to a target.
*/
export function snapRect(
left: number,
top: number,
width: number,
height: number,
targets: FieldEdges[],
threshold = SNAP_THRESHOLD_PX,
): {
left: number;
top: number;
guides: SnapGuide[];
} {
const { x: xTargets, y: yTargets } = collectEdgeTargets(targets);
const guides: SnapGuide[] = [];
// --- X axis: pick best of left / right / centerX ---
const sL = applySnap(left, xTargets, threshold);
const sR = applySnap(left + width, xTargets, threshold);
const sCx = applySnap(left + width / 2, xTargets, threshold);
const xCandidates = [
{ offset: sL.value - left, didSnap: sL.didSnap, dist: Math.abs(sL.value - left), pos: sL.value },
{ offset: sR.value - (left + width), didSnap: sR.didSnap, dist: Math.abs(sR.value - (left + width)), pos: sR.value },
{ offset: sCx.value - (left + width / 2), didSnap: sCx.didSnap, dist: Math.abs(sCx.value - (left + width / 2)), pos: sCx.value },
].filter(c => c.didSnap);
let newLeft = left;
if (xCandidates.length > 0) {
xCandidates.sort((a, b) => a.dist - b.dist);
newLeft = left + xCandidates[0].offset;
guides.push({ axis: 'x', position: xCandidates[0].pos });
}
// --- Y axis: pick best of top / bottom / centerY ---
const sT = applySnap(top, yTargets, threshold);
const sB = applySnap(top + height, yTargets, threshold);
const sCy = applySnap(top + height / 2, yTargets, threshold);
const yCandidates = [
{ offset: sT.value - top, didSnap: sT.didSnap, dist: Math.abs(sT.value - top), pos: sT.value },
{ offset: sB.value - (top + height), didSnap: sB.didSnap, dist: Math.abs(sB.value - (top + height)), pos: sB.value },
{ offset: sCy.value - (top + height / 2), didSnap: sCy.didSnap, dist: Math.abs(sCy.value - (top + height / 2)), pos: sCy.value },
].filter(c => c.didSnap);
let newTop = top;
if (yCandidates.length > 0) {
yCandidates.sort((a, b) => a.dist - b.dist);
newTop = top + yCandidates[0].offset;
guides.push({ axis: 'y', position: yCandidates[0].pos });
}
return { left: newLeft, top: newTop, guides };
}
/** Which edges are free to move for a given resize handle. */
export type ResizeEdges = {
left?: boolean;
right?: boolean;
top?: boolean;
bottom?: boolean;
};
/**
* Snap a rectangle during **resize** — only the edges being dragged move;
* the opposite edges stay fixed (dimensions change instead of position).
*/
export function snapRectResize(
left: number,
top: number,
width: number,
height: number,
edges: ResizeEdges,
targets: FieldEdges[],
threshold = SNAP_THRESHOLD_PX,
): {
left: number;
top: number;
width: number;
height: number;
guides: SnapGuide[];
} {
const { x: xTargets, y: yTargets } = collectEdgeTargets(targets);
const guides: SnapGuide[] = [];
let newLeft = left;
let newWidth = width;
let newTop = top;
let newHeight = height;
// --- X axis ---
if (edges.left) {
const s = applySnap(left, xTargets, threshold);
if (s.didSnap) {
const delta = s.value - left;
newLeft = s.value;
newWidth = width - delta;
guides.push({ axis: 'x', position: s.value });
}
}
if (edges.right) {
const right = left + width;
const s = applySnap(right, xTargets, threshold);
if (s.didSnap) {
newWidth = s.value - newLeft;
guides.push({ axis: 'x', position: s.value });
}
}
// --- Y axis ---
if (edges.top) {
const s = applySnap(top, yTargets, threshold);
if (s.didSnap) {
const delta = s.value - top;
newTop = s.value;
newHeight = height - delta;
guides.push({ axis: 'y', position: s.value });
}
}
if (edges.bottom) {
const bottom = top + height;
const s = applySnap(bottom, yTargets, threshold);
if (s.didSnap) {
newHeight = s.value - newTop;
guides.push({ axis: 'y', position: s.value });
}
}
return { left: newLeft, top: newTop, width: newWidth, height: newHeight, guides };
}
@@ -8,11 +8,13 @@
*
* Used in the dedicated formFill tool mode.
*/
import type { FormField } from '@app/tools/formFill/types';
import type { FormField, NewFieldDefinition, ModifyFieldDefinition } from '@app/tools/formFill/types';
import type { IFormDataProvider } from '@app/tools/formFill/providers/types';
import {
fetchFormFieldsWithCoordinates,
fillFormFields,
addFormFields,
modifyFormFields,
} from '@app/tools/formFill/formApi';
export class PdfBoxFormProvider implements IFormDataProvider {
@@ -29,4 +31,18 @@ export class PdfBoxFormProvider implements IFormDataProvider {
): Promise<Blob> {
return fillFormFields(file, values, flatten);
}
async addFields(
file: File | Blob,
fields: NewFieldDefinition[],
): Promise<Blob> {
return addFormFields(file, fields);
}
async modifyFields(
file: File | Blob,
updates: ModifyFieldDefinition[],
): Promise<Blob> {
return modifyFormFields(file, updates);
}
}
@@ -10,7 +10,7 @@
* The UI components (FormFieldOverlay, FormFill, FormFieldSidebar) consume
* data through FormFillContext, which delegates to whichever provider is active.
*/
import type { FormField } from '@app/tools/formFill/types';
import type { FormField, NewFieldDefinition, ModifyFieldDefinition } from '@app/tools/formFill/types';
export interface IFormDataProvider {
/** Unique identifier for the provider (for debugging/logging) */
@@ -34,4 +34,22 @@ export interface IFormDataProvider {
values: Record<string, string>,
flatten: boolean,
): Promise<Blob>;
/**
* Add new form fields to a PDF.
* Optional — only supported by backend providers.
*/
addFields?(
file: File | Blob,
fields: NewFieldDefinition[],
): Promise<Blob>;
/**
* Modify existing form fields (properties and/or coordinates).
* Optional — only supported by backend providers.
*/
modifyFields?(
file: File | Blob,
updates: ModifyFieldDefinition[],
): Promise<Blob>;
}
+65
View File
@@ -13,6 +13,8 @@ export interface WidgetCoordinates {
exportValue?: string;
/** Font size in PDF points */
fontSize?: number;
/** CropBox height in PDF points (used for Y-flip) */
cropBoxHeight?: number;
}
export interface FormField {
@@ -41,6 +43,69 @@ export type FormFieldType =
| 'button'
| 'signature';
/** Mirrors backend NewFormFieldDefinition — used when creating fields */
export interface NewFieldDefinition {
name: string;
label?: string;
type: FormFieldType;
pageIndex: number;
x: number; // PDF lower-left origin
y: number; // PDF lower-left origin
width: number;
height: number;
required?: boolean;
multiSelect?: boolean;
options?: string[];
defaultValue?: string;
tooltip?: string;
fontSize?: number;
readOnly?: boolean;
multiline?: boolean;
}
/** Mirrors backend ModifyFormFieldDefinition — used when editing existing fields */
export interface ModifyFieldDefinition {
targetName: string;
name?: string;
label?: string;
type?: string;
pageIndex?: number;
x?: number; // PDF lower-left origin
y?: number; // PDF lower-left origin
width?: number;
height?: number;
required?: boolean;
multiSelect?: boolean;
options?: string[];
defaultValue?: string;
tooltip?: string;
fontSize?: number;
readOnly?: boolean;
multiline?: boolean;
}
export type FormMode = 'fill' | 'make' | 'batch' | 'modify';
/** Tracks the state of field creation (Create mode) */
export interface FieldCreationState {
/** Queue of fields pending backend commit */
pendingFields: NewFieldDefinition[];
/** The field type currently selected for placement (null = no placement active) */
placingFieldType: FormFieldType | null;
/** Rectangle being drawn (CSS coords, only non-null during drag) */
dragRect: { x: number; y: number; width: number; height: number; pageIndex: number } | null;
}
/** Tracks the state of field editing (Modify mode) */
export interface FieldEditState {
/** Name of the currently selected field */
selectedFieldName: string | null;
/** Interaction in progress */
interaction: 'idle' | 'moving' | 'resizing';
/** The pending rectangle during drag (CSS coords) — null when idle */
pendingRect: { x: number; y: number; width: number; height: number } | null;
}
export interface FormFillState {
/** Fields fetched from backend with coordinates */
fields: FormField[];