From a4c3beebdfebc3d4fb8a82f09f1d82a9ab1d037e Mon Sep 17 00:00:00 2001 From: Denys Vitali Date: Sun, 22 Feb 2026 15:47:35 +0100 Subject: [PATCH 1/8] 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 --- .../model/FormFieldWithCoordinates.java | 3 + .../common/util/FormFieldTypeSupport.java | 7 + .../software/common/util/FormUtils.java | 191 +++++++- .../api/form/FormFillController.java | 38 ++ .../api/form/FormPayloadParser.java | 10 + .../public/locales/en-GB/translation.toml | 30 ++ .../core/components/viewer/LocalEmbedPDF.tsx | 22 + .../tools/formFill/FormFieldCreatePanel.tsx | 197 ++++++++ .../formFill/FormFieldCreationOverlay.tsx | 326 +++++++++++++ .../tools/formFill/FormFieldEditOverlay.tsx | 428 ++++++++++++++++++ .../tools/formFill/FormFieldModifyPanel.tsx | 299 ++++++++++++ .../core/tools/formFill/FormFieldOverlay.tsx | 8 +- .../formFill/FormFieldPropertyEditor.tsx | 251 ++++++++++ frontend/src/core/tools/formFill/FormFill.tsx | 33 +- .../core/tools/formFill/FormFillContext.tsx | 196 +++++++- frontend/src/core/tools/formFill/formApi.ts | 44 +- .../tools/formFill/formCoordinateUtils.ts | 75 +++ .../src/core/tools/formFill/formSnapUtils.ts | 285 ++++++++++++ .../formFill/providers/PdfBoxFormProvider.ts | 18 +- .../core/tools/formFill/providers/types.ts | 20 +- frontend/src/core/tools/formFill/types.ts | 65 +++ 21 files changed, 2518 insertions(+), 28 deletions(-) create mode 100644 frontend/src/core/tools/formFill/FormFieldCreatePanel.tsx create mode 100644 frontend/src/core/tools/formFill/FormFieldCreationOverlay.tsx create mode 100644 frontend/src/core/tools/formFill/FormFieldEditOverlay.tsx create mode 100644 frontend/src/core/tools/formFill/FormFieldModifyPanel.tsx create mode 100644 frontend/src/core/tools/formFill/FormFieldPropertyEditor.tsx create mode 100644 frontend/src/core/tools/formFill/formCoordinateUtils.ts create mode 100644 frontend/src/core/tools/formFill/formSnapUtils.ts diff --git a/app/common/src/main/java/stirling/software/common/model/FormFieldWithCoordinates.java b/app/common/src/main/java/stirling/software/common/model/FormFieldWithCoordinates.java index 54ccafd665..32b764730e 100644 --- a/app/common/src/main/java/stirling/software/common/model/FormFieldWithCoordinates.java +++ b/app/common/src/main/java/stirling/software/common/model/FormFieldWithCoordinates.java @@ -94,5 +94,8 @@ public static class WidgetCoordinates { @Schema(description = "Font size in PDF points") private Float fontSize; + + @Schema(description = "CropBox height in PDF points (used for Y-flip)") + private Float cropBoxHeight; } } diff --git a/app/common/src/main/java/stirling/software/common/util/FormFieldTypeSupport.java b/app/common/src/main/java/stirling/software/common/util/FormFieldTypeSupport.java index 3018a26fd9..ce5f5f7d61 100644 --- a/app/common/src/main/java/stirling/software/common/util/FormFieldTypeSupport.java +++ b/app/common/src/main/java/stirling/software/common/util/FormFieldTypeSupport.java @@ -59,6 +59,13 @@ void applyNewFieldDefinition( List 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); diff --git a/app/common/src/main/java/stirling/software/common/util/FormUtils.java b/app/common/src/main/java/stirling/software/common/util/FormUtils.java index fa8020a23b..1af482f6f2 100644 --- a/app/common/src/main/java/stirling/software/common/util/FormUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/FormUtils.java @@ -455,6 +455,7 @@ private FormFieldWithCoordinates.WidgetCoordinates createWidgetCoordinates( .height(finalH) .exportValue(exportValue) .fontSize(extractFontSize(field)) + .cropBoxHeight(cropHeight) .build(); } @@ -1551,7 +1552,8 @@ public void modifyFormFields( 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 void modifyFormFields( modification.multiSelect(), modification.options(), modification.defaultValue(), - modification.tooltip()); + modification.tooltip(), + modification.fontSize(), + modification.readOnly(), + modification.multiline()); List sanitizedOptions = sanitizeOptions(modification.options()); @@ -1618,7 +1623,10 @@ public void modifyFormFields( } 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 @@ private void modifyFieldPropertiesInPlace( } } } + + // 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 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 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 @@ private int resolveWidgetPageIndex( 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 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 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 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 fieldNames) { if (document == null || fieldNames == null || fieldNames.isEmpty()) return; @@ -2200,6 +2367,9 @@ private void registerNewField( } } 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 record NewFormFieldDefinition( Boolean multiSelect, List 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 record ModifyFormFieldDefinition( String name, String label, String type, + Integer pageIndex, + Float x, + Float y, + Float width, + Float height, Boolean required, Boolean multiSelect, List options, String defaultValue, - String tooltip) {} + String tooltip, + Float fontSize, + Boolean readOnly, + Boolean multiline) {} @JsonInclude(JsonInclude.Include.NON_NULL) public record FormFieldInfo( diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormFillController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormFillController.java index ce8f77faae..fe5ca0a2ee 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormFillController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormFillController.java @@ -139,6 +139,44 @@ public ResponseEntity> listFieldsWithCoordinates( } } + @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 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 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", diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java index f9cd978115..95cfecf6e6 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java @@ -28,6 +28,8 @@ final class FormPayloadParser { private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; private static final TypeReference> MODIFY_FIELD_LIST_TYPE = new TypeReference<>() {}; + private static final TypeReference> + NEW_FIELD_LIST_TYPE = new TypeReference<>() {}; private static final TypeReference> STRING_LIST_TYPE = new TypeReference<>() {}; private FormPayloadParser() {} @@ -95,6 +97,14 @@ static List parseModificationDefinitions( return objectMapper.readValue(json, MODIFY_FIELD_LIST_TYPE); } + static List 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 parseNameList(ObjectMapper objectMapper, String json) throws IOException { if (json == null || json.isBlank()) { return List.of(); diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 02f1681170..1fa0a305c9 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -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" diff --git a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx index 4fdf9fc632..70af89bc98 100644 --- a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx +++ b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx @@ -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 && ( + + )} + + {/* FormFieldEditOverlay for moving/resizing existing fields */} + {enableFormFill && ( + + )} + {/* AnnotationLayer for annotation editing and annotation-based redactions */} {(enableAnnotations || enableRedaction) && ( { + 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(null); + const [committing, setCommitting] = useState(false); + const [error, setError] = useState(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 ( +
+
+ Select a field type, then drag on the PDF to place it. + + {/* Field type palette */} +
+ {CREATABLE_TYPES.map(({ type, label }) => { + const isActive = placingFieldType === type; + return ( + + 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, + }} + > + + {FIELD_TYPE_ICON[type]} + + + {label} + + + + ); + })} +
+ + {pendingFields.length > 0 && ( + + )} + + {error && ( + } color="red" variant="light" p="xs" radius="sm"> + {error} + + )} +
+ + {/* Pending fields list */} + +
+ {pendingFields.length === 0 && ( +
+ + No pending fields. Select a type above and drag on the PDF to create one. + +
+ )} + + {pendingFields.map((field, idx) => ( +
+
setExpandedIdx(expandedIdx === idx ? null : idx)} + > + + {FIELD_TYPE_ICON[field.type]} + + + {field.name || `New ${field.type}`} + + p.{field.pageIndex + 1} + { e.stopPropagation(); removePendingField(idx); }}> + + + {expandedIdx === idx + ? + : + } +
+ + +
+ updatePendingField(idx, updated)} + showCoordinates + /> +
+
+
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/core/tools/formFill/FormFieldCreationOverlay.tsx b/frontend/src/core/tools/formFill/FormFieldCreationOverlay.tsx new file mode 100644 index 0000000000..6f8838d4f1 --- /dev/null +++ b/frontend/src/core/tools/formFill/FormFieldCreationOverlay.tsx @@ -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(null); + const [snapGuides, setSnapGuides] = useState([]); + + 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 ( +
+ {/* Snap guide lines */} + {snapGuides.map((guide, i) => + guide.axis === 'x' ? ( +
+ ) : ( +
+ ) + )} + + {/* Drag preview rectangle */} + {showDragRect && dragRect.width > 0 && dragRect.height > 0 && ( +
+ )} + + {/* 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 ( +
+ + {FIELD_TYPE_ICON[field.type]} + +
+ ); + })} +
+ ); +} diff --git a/frontend/src/core/tools/formFill/FormFieldEditOverlay.tsx b/frontend/src/core/tools/formFill/FormFieldEditOverlay.tsx new file mode 100644 index 0000000000..aa95eb954b --- /dev/null +++ b/frontend/src/core/tools/formFill/FormFieldEditOverlay.tsx @@ -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(null); + const dragFieldRef = useRef(null); + const pendingRectRef = useRef<{ x: number; y: number; width: number; height: number } | null>(null); + const overlayRef = useRef(null); + const [snapGuides, setSnapGuides] = useState([]); + + // 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 ( +
+ {/* Snap guide lines */} + {snapGuides.map((guide, i) => + guide.axis === 'x' ? ( +
+ ) : ( +
+ ) + )} + + {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 ( +
{ + 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 }) => ( +
handlePointerDown(e, field.name, 'resize', pos)} + /> + ))} +
+ ); + })} +
+ ); +} diff --git a/frontend/src/core/tools/formFill/FormFieldModifyPanel.tsx b/frontend/src/core/tools/formFill/FormFieldModifyPanel.tsx new file mode 100644 index 0000000000..fa89e4c544 --- /dev/null +++ b/frontend/src/core/tools/formFill/FormFieldModifyPanel.tsx @@ -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(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( + () => editState.selectedFieldName ? fields.find(f => f.name === editState.selectedFieldName) : undefined, + [fields, editState.selectedFieldName] + ); + + const selectedFieldEditorData = useMemo(() => { + 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 = {}; + 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(); + 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 ( +
+
+ + Click a field below or on the PDF to select it. Drag to move, use handles to resize. + + + {modifiedFields.size > 0 && ( + + )} + + {error && ( + } color="red" variant="light" p="xs" radius="sm"> + {error} + + )} +
+ + +
+ {formState.loading && ( +
+ + Loading fields... +
+ )} + + {!formState.loading && fields.length === 0 && ( +
+ + No form fields found. Use Create mode to add fields first. + +
+ )} + + {sortedPages.map((pageIdx, i) => ( + +
+ + Page {pageIdx + 1} + +
+ + {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 ( +
handleSelectField(field.name)} + > +
+ + {FIELD_TYPE_ICON[field.type]} + + + {field.label || field.name} + + {isModified && ( + + modified + + )} +
+ + {!isSelected && widget && ( + + {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` + } + + )} + + {/* Inline property editor for the selected field */} + {isSelected && selectedFieldEditorData && ( +
e.stopPropagation()} + > + +
+ )} +
+ ); + })} +
+ ))} +
+
+ + {/* Status bar */} + {modifiedFields.size > 0 && ( +
+ + + {modifiedFields.size} unsaved change{modifiedFields.size !== 1 ? 's' : ''} + +
+ )} +
+ ); +} diff --git a/frontend/src/core/tools/formFill/FormFieldOverlay.tsx b/frontend/src/core/tools/formFill/FormFieldOverlay.tsx index 22698b9050..f7d9b3a70e 100644 --- a/frontend/src/core/tools/formFill/FormFieldOverlay.tsx +++ b/frontend/src/core/tools/formFill/FormFieldOverlay.tsx @@ -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 (
- {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) => { diff --git a/frontend/src/core/tools/formFill/FormFieldPropertyEditor.tsx b/frontend/src/core/tools/formFill/FormFieldPropertyEditor.tsx new file mode 100644 index 0000000000..033bf95afc --- /dev/null +++ b/frontend/src/core/tools/formFill/FormFieldPropertyEditor.tsx @@ -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 ( + + onChange({ ...field, name: e.currentTarget.value })} + placeholder="Field name" + /> + + onChange({ ...field, label: e.currentTarget.value || undefined })} + placeholder="Display label" + /> + + {allowTypeChange && ( + ({ + value: tp, + label: TYPE_LABEL[tp], + }))} + disabled={!canRetype} + placeholder={canRetype ? undefined : TYPE_LABEL[value.type]} + onChange={(v) => v && onChange({ type: v })} + comboboxProps={{ withinPortal: true }} + /> + )} + + {hasOptions && ( + + + {t("formFill.editor.options", "Options")} + + {(value.options ?? []).length === 0 && ( + + {t("formFill.editor.optionsEmpty", "Add at least one option.")} + + )} + {(value.options ?? []).map((opt, i) => ( + + updateOption(i, e.currentTarget.value)} + /> + removeOption(i)} + > + + + + ))} + + + )} + + {isFillable && ( + onChange({ defaultValue: e.currentTarget.value })} + /> + )} + + onChange({ tooltip: e.currentTarget.value })} + /> + + {isButton && ( + <> +