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..37ffea7559 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,12 @@ public static class WidgetCoordinates { @Schema(description = "Font size in PDF points") private Float fontSize; + + @Schema( + description = + "CropBox height in PDF points. Lets the frontend reverse the backend's" + + " Y-flip when sending new widget coordinates back for" + + " create/modify operations.") + 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..7f0faae50f 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 @@ -11,6 +11,10 @@ import org.apache.pdfbox.cos.COSName; import org.apache.pdfbox.pdmodel.graphics.color.PDColor; import org.apache.pdfbox.pdmodel.graphics.color.PDDeviceRGB; +import org.apache.pdfbox.pdmodel.interactive.action.PDActionNamed; +import org.apache.pdfbox.pdmodel.interactive.action.PDActionResetForm; +import org.apache.pdfbox.pdmodel.interactive.action.PDActionSubmitForm; +import org.apache.pdfbox.pdmodel.interactive.action.PDActionURI; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceCharacteristicsDictionary; import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; @@ -59,6 +63,24 @@ 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); + } + // Comb field: evenly spaced character cells (e.g. SSN, phone). Requires + // a positive MaxLen and is mutually exclusive with multiline. + if (definition.maxLength() != null && definition.maxLength() > 0) { + textField.setMaxLen(definition.maxLength()); + if (!Boolean.TRUE.equals(definition.multiline())) { + try { + textField.setComb(true); + } catch (Exception e) { + log.debug("Unable to set comb flag: {}", e.getMessage()); + } + } + } String defaultValue = Optional.ofNullable(definition.defaultValue()).orElse(""); if (!defaultValue.isBlank()) { FormUtils.setTextValue(textField, defaultValue); @@ -272,12 +294,81 @@ void applyNewFieldDefinition( PDTerminalField createField(PDAcroForm acroForm) { return new PDSignatureField(acroForm); } + + @Override + boolean doesNotsupportsDefinitionCreation() { + return false; + } + // Empty signature placeholder: no value to apply (signed later by a sign tool). }, BUTTON("button", "pushButton", PDPushButton.class) { @Override PDTerminalField createField(PDAcroForm acroForm) { return new PDPushButton(acroForm); } + + @Override + boolean doesNotsupportsDefinitionCreation() { + return false; + } + + @Override + void applyNewFieldDefinition( + PDTerminalField field, + FormUtils.NewFormFieldDefinition definition, + List options) + throws IOException { + if (field.getWidgets().isEmpty()) { + return; + } + PDAnnotationWidget widget = field.getWidgets().get(0); + + // Visible caption (/MK /CA). + String caption = definition.label(); + if (caption == null || caption.isBlank()) { + caption = definition.name(); + } + if (caption != null && !caption.isBlank()) { + PDAppearanceCharacteristicsDictionary mk = widget.getAppearanceCharacteristics(); + if (mk == null) { + mk = new PDAppearanceCharacteristicsDictionary(widget.getCOSObject()); + widget.setAppearanceCharacteristics(mk); + } + mk.setNormalCaption(caption); + } + widget.setPrinted(true); + + applyButtonAction(widget, definition.buttonAction()); + } + + private void applyButtonAction(PDAnnotationWidget widget, String action) { + if (action == null || action.isBlank()) { + return; + } + try { + String spec = action.trim(); + String lower = spec.toLowerCase(); + if (lower.equals("reset")) { + widget.getCOSObject() + .setItem(COSName.A, new PDActionResetForm().getCOSObject()); + } else if (lower.equals("print")) { + PDActionNamed named = new PDActionNamed(); + named.setN("Print"); + widget.getCOSObject().setItem(COSName.A, named.getCOSObject()); + } else if (lower.startsWith("uri:")) { + PDActionURI uri = new PDActionURI(); + uri.setURI(spec.substring(4)); + widget.getCOSObject().setItem(COSName.A, uri.getCOSObject()); + } else if (lower.startsWith("submit:")) { + PDActionSubmitForm submit = new PDActionSubmitForm(); + // Store the target URL on the action dictionary's /F entry. + submit.getCOSObject().setString(COSName.F, spec.substring(7)); + widget.getCOSObject().setItem(COSName.A, submit.getCOSObject()); + } + } catch (Exception e) { + log.debug("Unable to apply button action '{}': {}", action, e.getMessage()); + } + } }; private static final Map BY_TYPE = 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 25cb4b5825..f1429ea44f 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 @@ -460,6 +460,7 @@ private FormFieldWithCoordinates.WidgetCoordinates createWidgetCoordinates( .height(finalH) .exportValue(exportValue) .fontSize(extractFontSize(field)) + .cropBoxHeight(cropHeight) .build(); } @@ -1463,16 +1464,29 @@ private String deriveDisplayLabel( return tooltipLabel; } - // Only check options for choice-type fields (combobox, listbox, radio) - if (CHOICE_FIELD_TYPES.contains(type) && options != null && !options.isEmpty()) { + // A clearly meaningful field name describes the whole field and matches + // the name shown in the editor, so it is the best label. + String humanized = cleanLabel(humanizeName(name)); + if (humanized != null && !looksGeneric(humanized)) { + return humanized; + } + + // Only when the name is an auto-generated identifier ("Field_5", "t12", a + // UUID) is an option more descriptive than the name. A human-typed name - + // even a generic-sounding word like "Choice" or "Name" - is preferred just + // below so the viewer label stays in sync with the editor's field name. This + // stops a radio group named "Choice" with options Yes/No reading as "Yes". + if (CHOICE_FIELD_TYPES.contains(type) + && options != null + && !options.isEmpty() + && looksAutoGenerated(name)) { String optionCandidate = cleanLabel(options.get(0)); if (optionCandidate != null && !looksGeneric(optionCandidate)) { return optionCandidate; } } - String humanized = cleanLabel(humanizeName(name)); - if (humanized != null && !looksGeneric(humanized)) { + if (humanized != null && !looksAutoGenerated(name)) { return humanized; } @@ -1509,6 +1523,29 @@ private boolean looksGeneric(String value) { || patterns.getOptionalTNumericPattern().matcher(simplified).matches(); } + /** + * True only for structurally auto-generated identifiers - "Field_5", "F12", "t3", bare numbers, + * UUIDs. Unlike {@link #looksGeneric}, this does NOT reject human-typed placeholder words such + * as "Choice", "Name" or "Value"; those are still usable labels and must not be discarded in + * favour of an option value, so the viewer label keeps matching the editor's field name. + */ + private boolean looksAutoGenerated(String value) { + if (value == null) return true; + + RegexPatternUtils patterns = RegexPatternUtils.getInstance(); + String simplified = patterns.getPunctuationPattern().matcher(value).replaceAll(" ").trim(); + if (simplified.isEmpty()) return true; + + String nospaces = WHITESPACE_PATTERN.matcher(simplified).replaceAll(""); + if (nospaces.length() >= 32 && HEX_UUID_PATTERN.matcher(nospaces).matches()) return true; + + return patterns.getPattern("^field(\\s*\\d+)?$", Pattern.CASE_INSENSITIVE) + .matcher(simplified) + .matches() + || patterns.getSimpleFormFieldPattern().matcher(simplified).matches() + || patterns.getOptionalTNumericPattern().matcher(simplified).matches(); + } + private String humanizeName(String name) { if (name == null) return null; @@ -1598,7 +1635,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) { @@ -1625,7 +1663,12 @@ public void modifyFormFields( modification.multiSelect(), modification.options(), modification.defaultValue(), - modification.tooltip()); + modification.tooltip(), + modification.fontSize(), + modification.readOnly(), + modification.multiline(), + modification.maxLength(), + null); List sanitizedOptions = sanitizeOptions(modification.options()); @@ -1665,7 +1708,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); @@ -1715,6 +1761,124 @@ private void modifyFieldPropertiesInPlace( } } } + + // Update read-only 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 comb / max length (text fields only) + if (modification.maxLength() != null + && modification.maxLength() > 0 + && field instanceof PDTextField combTf) { + combTf.setMaxLen(modification.maxLength()); + if (!combTf.isMultiline()) { + try { + combTf.setComb(true); + } catch (Exception ignore) { + // comb is best-effort + } + } + } + + // Update font size (variable-text fields only: text/combo/list) + if (modification.fontSize() != null + && modification.fontSize() > 0 + && field instanceof PDVariableText vt) { + applyFontSizeToDefaultAppearance(vt, modification.fontSize()); + // Clear the cached appearance so ensureAppearances() regenerates it + // with the new font size; otherwise viewers keep the old glyph sizing. + removeWidgetAppearanceStreams(field); + } + + // Update widget geometry if any coordinate component was supplied. + // The frontend sends CropBox-relative, lower-left-origin coordinates + // (the reverse of what createWidgetCoordinates extracts). We add the + // CropBox offset back to recover absolute PDF user-space 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) { + 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 the stale appearance stream so ensureAppearances() + // generates a fresh one matching the new rectangle. Without + // this, PDF viewers (pdf.js, Evince) stretch the old appearance + // (built for the previous BBox) to fit the new widget rectangle, + // making fields look distorted/stretched. + widget.getCOSObject().removeItem(COSName.AP); + } + } + } + } + + /** + * Sets the font size inside a variable-text field's default appearance (DA) string while + * preserving the font name and colour operators. If no DA is present a Helvetica default is + * written. + */ + private void applyFontSizeToDefaultAppearance(PDVariableText field, float fontSize) { + String da = field.getDefaultAppearance(); + if (da != null && !da.isBlank()) { + // Replace the size token (the operand immediately before "Tf"). + String[] tokens = da.split("\\s+"); + boolean replaced = false; + for (int i = 0; i < tokens.length; i++) { + if ("Tf".equals(tokens[i]) && i > 0) { + tokens[i - 1] = String.valueOf(fontSize); + replaced = true; + break; + } + } + if (replaced) { + field.setDefaultAppearance(String.join(" ", tokens)); + return; + } + } + field.setDefaultAppearance("/Helv " + fontSize + " Tf 0 g"); + } + + /** Drops cached /AP appearance streams from every widget of a field. */ + private void removeWidgetAppearanceStreams(PDField field) { + List widgets = field.getWidgets(); + if (widgets == null) { + return; + } + for (PDAnnotationWidget widget : widgets) { + widget.getCOSObject().removeItem(COSName.AP); + } } private String fallbackLabelForType(String type, int typeIndex) { @@ -1842,6 +2006,220 @@ private int resolveWidgetPageIndex( return -1; } + /** + * Adds new form fields to a document from the supplied definitions. If the document has no + * AcroForm one is created automatically. Coordinates in each definition are interpreted as + * CropBox-relative, lower-left-origin PDF points (the reverse of what {@link + * #createWidgetCoordinates} extracts); the CropBox offset is added back to obtain absolute PDF + * user-space coordinates. + */ + 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); + + 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); + PDRectangle cropBox = page.getCropBox(); + + // CropBox-relative, lower-left-origin -> absolute PDF user space. + float x = (definition.x() != null ? definition.x() : 0f) + cropBox.getLowerLeftX(); + float y = (definition.y() != null ? definition.y() : 0f) + cropBox.getLowerLeftY(); + 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 { + if (FIELD_TYPE_RADIO.equals(resolvedType)) { + // Radio is a single field with one widget per option; it can't go + // through the single-widget createNewField path. + createRadioField(acroForm, page, rectangle, uniqueName, definition, options); + } else { + 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; + } + 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); + } + + /** + * Creates a radio-button group with one widget per option, stacked vertically starting at + * {@code baseRect}. Each widget's "on" state name is the sanitized export value, so the group + * behaves as a single selectable field. + */ + private void createRadioField( + PDAcroForm acroForm, + PDPage page, + PDRectangle baseRect, + String name, + NewFormFieldDefinition definition, + List options) + throws IOException { + + List values = (options == null || options.isEmpty()) ? List.of("1", "2") : options; + + PDRadioButton radio = new PDRadioButton(acroForm); + radio.setPartialName(name); + if (definition.label() != null && !definition.label().isBlank()) { + try { + radio.setAlternateFieldName(definition.label()); + } catch (Exception ignore) { + // alternate name is best-effort + } + } + radio.setRequired(Boolean.TRUE.equals(definition.required())); + if (Boolean.TRUE.equals(definition.readOnly())) { + radio.setReadOnly(true); + } + + float w = baseRect.getWidth(); + float h = baseRect.getHeight(); + float gap = Math.max(4f, h * 0.5f); + float topY = baseRect.getLowerLeftY(); + + List widgets = new ArrayList<>(); + List exportValues = new ArrayList<>(); + Set usedStates = new HashSet<>(); + for (int i = 0; i < values.size(); i++) { + String onState = sanitizeOnState(values.get(i), i, usedStates); + exportValues.add(onState); + + // Stack downwards from the drawn rectangle. + float wy = topY - i * (h + gap); + PDRectangle rect = new PDRectangle(baseRect.getLowerLeftX(), wy, w, h); + + PDAnnotationWidget widget = new PDAnnotationWidget(); + widget.setRectangle(rect); + widget.setPage(page); + widget.getCOSObject().setItem(COSName.P, page.getCOSObject()); + widget.getCOSObject().setItem(COSName.TYPE, COSName.getPDFName("Annot")); + widget.getCOSObject().setItem(COSName.SUBTYPE, COSName.getPDFName("Widget")); + widget.setParent(radio); + // The widget's appearance state is "Off" until the group value selects it. + widget.getCOSObject().setName(COSName.AS, "Off"); + widgets.add(widget); + + List annotations = page.getAnnotations(); + if (annotations == null) { + annotations = new ArrayList<>(); + page.setAnnotations(annotations); + } + annotations.add(widget); + } + + radio.setWidgets(widgets); + try { + radio.setExportValues(exportValues); + } catch (Exception e) { + log.debug("Unable to set radio export values for '{}': {}", name, e.getMessage()); + } + + String defaultValue = definition.defaultValue(); + if (defaultValue != null + && !defaultValue.isBlank() + && exportValues.contains(defaultValue)) { + try { + radio.setValue(defaultValue); + } catch (Exception e) { + log.debug("Unable to set radio default '{}': {}", defaultValue, e.getMessage()); + } + } + + acroForm.getFields().add(radio); + } + + /** Builds a unique, PDF-name-safe "on" state for a radio widget. */ + private String sanitizeOnState(String raw, int index, Set used) { + String base = + Optional.ofNullable(raw) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(s -> s.replaceAll("[^A-Za-z0-9_-]", "_")) + .orElse("Option" + (index + 1)); + if ("Off".equalsIgnoreCase(base)) { + base = "Option" + (index + 1); + } + String candidate = base; + int suffix = 1; + while (!used.add(candidate)) { + candidate = base + "_" + suffix++; + } + return candidate; + } + + /** + * Applies a mixed batch of field edits to a document in a single pass: existing fields are + * modified, then deleted, then new fields are added (so newly-generated names dedupe against + * the surviving set). Lets the UI commit create/modify/delete in one request. + */ + public void applyFieldEdits( + PDDocument document, + List adds, + List modifies, + List deletes) + throws IOException { + if (document == null) return; + if (modifies != null && !modifies.isEmpty()) { + modifyFormFields(document, modifies); + } + if (deletes != null && !deletes.isEmpty()) { + deleteFormFields(document, deletes); + } + if (adds != null && !adds.isEmpty()) { + addNewFields(document, adds); + } + } + public void deleteFormFields(PDDocument document, List fieldNames) { if (document == null || fieldNames == null || fieldNames.isEmpty()) return; @@ -2247,9 +2625,33 @@ private void registerNewField( } } field.setRequired(Boolean.TRUE.equals(definition.required())); - - PDAnnotationWidget widget = - existingWidget != null ? existingWidget : new PDAnnotationWidget(); + if (Boolean.TRUE.equals(definition.readOnly())) { + field.setReadOnly(true); + } + + // A brand-new terminal field with no /Kids is represented by a single + // "merged" widget that shares the field's own dictionary. Configure THAT + // widget so the /Rect, /P and appearance live on the field dictionary and + // survive serialization. Building a SEPARATE PDAnnotationWidget and adding + // it to getWidgets() does not link it via /Kids, so its /Rect is dropped on + // save and the field renders at 0x0 (the symptom reported on PR #5777). + boolean reuseFieldDict; + PDAnnotationWidget widget; + if (existingWidget != null) { + widget = existingWidget; + reuseFieldDict = false; + } else { + List current = field.getWidgets(); + if (current != null && !current.isEmpty()) { + widget = current.get(0); + } else { + widget = new PDAnnotationWidget(); + } + reuseFieldDict = widget.getCOSObject() == field.getCOSObject(); + // Make sure the shared dictionary is recognised as a widget annotation. + widget.getCOSObject().setItem(COSName.TYPE, COSName.getPDFName("Annot")); + widget.getCOSObject().setItem(COSName.SUBTYPE, COSName.getPDFName("Widget")); + } // Ensure rectangle is valid and set before any appearance-related operations // please note removal of this might cause **subtle** issues @@ -2262,10 +2664,10 @@ private void registerNewField( } widget.setRectangle(validRectangle); widget.setPage(page); - - if (existingWidget == null) { - widget.setPrinted(true); - } + // Explicitly set the /P entry so the widget keeps a valid page reference + // after save/reload (some viewers rely on it to resolve the widget page). + widget.getCOSObject().setItem(COSName.P, page.getCOSObject()); + widget.setPrinted(true); if (definition.tooltip() != null && !definition.tooltip().isBlank()) { widget.getCOSObject().setString(COSName.TU, definition.tooltip()); @@ -2277,13 +2679,25 @@ private void registerNewField( } } - field.getWidgets().add(widget); - widget.setParent(field); + // Only link a SEPARATE widget into the field; the merged widget IS the + // field dictionary and is already its own widget. + if (!reuseFieldDict) { + List widgets = new ArrayList<>(field.getWidgets()); + if (!widgets.contains(widget)) { + widgets.add(widget); + field.setWidgets(widgets); + } + widget.setParent(field); + } List annotations = page.getAnnotations(); if (annotations == null) { - page.getAnnotations().add(widget); - } else if (!annotations.contains(widget)) { + // page.getAnnotations() can return null; calling it again and adding + // would NPE. Initialise the list and attach it to the page first. + annotations = new ArrayList<>(); + page.setAnnotations(annotations); + } + if (!annotations.contains(widget)) { annotations.add(widget); } acroForm.getFields().add(field); @@ -2411,7 +2825,12 @@ public record NewFormFieldDefinition( Boolean multiSelect, List options, String defaultValue, - String tooltip) {} + String tooltip, + Float fontSize, + Boolean readOnly, + Boolean multiline, + Integer maxLength, + String buttonAction) {} @JsonInclude(JsonInclude.Include.NON_NULL) public record ModifyFormFieldDefinition( @@ -2419,11 +2838,27 @@ 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, + Integer maxLength) {} + + /** A mixed batch of field edits applied in one request via {@link #applyFieldEdits}. */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record FieldEditBatch( + List add, + List modify, + List delete) {} @JsonInclude(JsonInclude.Include.NON_NULL) public record FormFieldInfo( diff --git a/app/common/src/test/java/stirling/software/common/util/FormFieldTypeSupportTest.java b/app/common/src/test/java/stirling/software/common/util/FormFieldTypeSupportTest.java index 771ce89659..6924764a65 100644 --- a/app/common/src/test/java/stirling/software/common/util/FormFieldTypeSupportTest.java +++ b/app/common/src/test/java/stirling/software/common/util/FormFieldTypeSupportTest.java @@ -130,13 +130,15 @@ void doesNotSupportsDefinitionCreation_radioReturnsTrue() { } @Test - void doesNotSupportsDefinitionCreation_signatureReturnsTrue() { - assertTrue(FormFieldTypeSupport.SIGNATURE.doesNotsupportsDefinitionCreation()); + void doesNotSupportsDefinitionCreation_signatureReturnsFalse() { + // Signature placeholders are now creatable via the editor. + assertFalse(FormFieldTypeSupport.SIGNATURE.doesNotsupportsDefinitionCreation()); } @Test - void doesNotSupportsDefinitionCreation_buttonReturnsTrue() { - assertTrue(FormFieldTypeSupport.BUTTON.doesNotsupportsDefinitionCreation()); + void doesNotSupportsDefinitionCreation_buttonReturnsFalse() { + // Push buttons (with actions) are now creatable via the editor. + assertFalse(FormFieldTypeSupport.BUTTON.doesNotsupportsDefinitionCreation()); } @Test diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsEditingTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsEditingTest.java new file mode 100644 index 0000000000..c486f72406 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsEditingTest.java @@ -0,0 +1,397 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.List; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDResources; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDField; +import org.apache.pdfbox.pdmodel.interactive.form.PDPushButton; +import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton; +import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField; +import org.apache.pdfbox.pdmodel.interactive.form.PDTextField; +import org.apache.pdfbox.pdmodel.interactive.form.PDVariableText; +import org.junit.jupiter.api.Test; + +/** + * Round-trip coverage for the structural form-editing additions from PR #5777: {@link + * FormUtils#addNewFields}, geometry/font/flag changes in {@link FormUtils#modifyFormFields}, and + * the CropBox-offset coordinate handling. + * + *

Assertions are made after a save → reload cycle because PDFBox synthesises widgets for fields + * that have no explicit {@code /Kids}; only the serialised document authoritatively reflects what a + * viewer (or the next API call) sees. + */ +class FormUtilsEditingTest { + + private static PDAcroForm setupForm(PDDocument document, PDRectangle pageSize) { + PDPage page = new PDPage(pageSize); + document.addPage(page); + PDAcroForm acroForm = new PDAcroForm(document); + acroForm.setDefaultResources(new PDResources()); + document.getDocumentCatalog().setAcroForm(acroForm); + return acroForm; + } + + private static byte[] save(PDDocument document) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + document.save(baos); + return baos.toByteArray(); + } + + private static FormUtils.NewFormFieldDefinition newText( + String name, float x, float y, float w, float h) { + return new FormUtils.NewFormFieldDefinition( + name, null, "text", 0, x, y, w, h, null, null, null, null, null, null, null, null, + null, null); + } + + private static FormUtils.NewFormFieldDefinition newField( + String type, + String name, + float x, + float y, + float w, + float h, + List options, + Integer maxLength, + String buttonAction) { + return new FormUtils.NewFormFieldDefinition( + name, + null, + type, + 0, + x, + y, + w, + h, + null, + null, + options, + null, + null, + null, + null, + null, + maxLength, + buttonAction); + } + + private static PDRectangle firstWidgetRect(PDAcroForm acroForm, String name) { + PDField field = acroForm.getField(name); + assertNotNull(field, "field '" + name + "' should exist"); + assertTrue(!field.getWidgets().isEmpty(), "field should have at least one widget"); + return field.getWidgets().get(0).getRectangle(); + } + + @Test + void addNewFields_createsTextFieldAtRequestedRectangle() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields(document, List.of(newText("created", 50, 700, 200, 20))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertNotNull(acroForm, "AcroForm should exist after reload"); + assertTrue(acroForm.getField("created") instanceof PDTextField); + PDRectangle rect = firstWidgetRect(acroForm, "created"); + assertNotNull(rect, "created widget should keep its rectangle after reload"); + assertEquals(50f, rect.getLowerLeftX(), 0.5f); + assertEquals(700f, rect.getLowerLeftY(), 0.5f); + assertEquals(200f, rect.getWidth(), 0.5f); + assertEquals(20f, rect.getHeight(), 0.5f); + } + } + + @Test + void addNewFields_appliesCropBoxOffsetToCoordinates() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + // Shift the CropBox origin; the frontend sends CropBox-relative coords. + document.getPage(0).setCropBox(new PDRectangle(10, 20, 500, 700)); + FormUtils.addNewFields(document, List.of(newText("shifted", 5, 5, 100, 15))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDRectangle rect = firstWidgetRect(acroForm, "shifted"); + // Absolute = CropBox-relative + CropBox lower-left offset. + assertEquals(15f, rect.getLowerLeftX(), 0.5f); + assertEquals(25f, rect.getLowerLeftY(), 0.5f); + } + } + + @Test + void addNewFields_appliesReadOnlyFontSizeAndMultiline() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.NewFormFieldDefinition def = + new FormUtils.NewFormFieldDefinition( + "opts", + null, + "text", + 0, + 10f, + 10f, + 120f, + 18f, + null, + null, + null, + null, + null, + 18f, + Boolean.TRUE, + Boolean.TRUE, + null, + null); + FormUtils.addNewFields(document, List.of(def)); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDField field = acroForm.getField("opts"); + assertNotNull(field); + assertTrue(field.isReadOnly(), "read-only flag should survive reload"); + assertTrue(field instanceof PDTextField); + assertTrue(((PDTextField) field).isMultiline(), "multiline flag should survive reload"); + String da = ((PDVariableText) field).getDefaultAppearance(); + assertTrue(da.contains("18"), "default appearance should carry the font size: " + da); + } + } + + @Test + void modifyFormFields_movesAndResizesWidget() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields(document, List.of(newText("movable", 50, 700, 200, 20))); + + FormUtils.ModifyFormFieldDefinition mod = + new FormUtils.ModifyFormFieldDefinition( + "movable", null, null, null, 0, 100f, 600f, 150f, 30f, null, null, null, + null, null, null, null, null, null); + FormUtils.modifyFormFields(document, List.of(mod)); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDRectangle rect = firstWidgetRect(acroForm, "movable"); + assertEquals(100f, rect.getLowerLeftX(), 0.5f); + assertEquals(600f, rect.getLowerLeftY(), 0.5f); + assertEquals(150f, rect.getWidth(), 0.5f); + assertEquals(30f, rect.getHeight(), 0.5f); + } + } + + @Test + void modifyFormFields_setsReadOnlyAndFontSize() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields(document, List.of(newText("editable", 50, 700, 200, 20))); + + FormUtils.ModifyFormFieldDefinition mod = + new FormUtils.ModifyFormFieldDefinition( + "editable", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + 22f, + Boolean.TRUE, + null, + null); + FormUtils.modifyFormFields(document, List.of(mod)); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDField field = acroForm.getField("editable"); + assertNotNull(field); + assertTrue(field.isReadOnly(), "read-only flag should survive reload"); + String da = ((PDVariableText) field).getDefaultAppearance(); + assertTrue(da.contains("22"), "font size should be reflected in DA: " + da); + } + } + + @Test + void deleteFormFields_removesField() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + PDAcroForm acroForm = setupForm(document, PDRectangle.A4); + FormUtils.addNewFields(document, List.of(newText("temp", 50, 700, 200, 20))); + FormUtils.deleteFormFields(document, List.of("temp")); + // After delete the AcroForm may still exist; the field must be gone. + if (acroForm != null) { + assertNull(acroForm.getField("temp")); + } + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertTrue(acroForm == null || acroForm.getField("temp") == null); + } + } + + @Test + void addNewFields_createsRadioGroupWithOneWidgetPerOption() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields( + document, + List.of( + newField( + "radio", + "choice", + 60, + 700, + 16, + 16, + List.of("Yes", "No"), + null, + null))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDField field = acroForm.getField("choice"); + assertNotNull(field, "radio field should exist"); + assertTrue(field instanceof PDRadioButton, "should be a radio button group"); + assertEquals(2, field.getWidgets().size(), "one widget per option"); + assertTrue(((PDRadioButton) field).getExportValues().contains("Yes")); + assertTrue(((PDRadioButton) field).getExportValues().contains("No")); + } + } + + @Test + void extractFormFields_prefersFieldNameOverFirstOptionForChoiceLabel() throws IOException { + // A radio group named "Choice" with options Yes/No must be labelled + // "Choice" (its name), not "Yes" (its first option). Otherwise the label + // shown in the viewer disagrees with the field name shown in the editor. + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields( + document, + List.of( + newField( + "radio", + "Choice", + 60, + 700, + 16, + 16, + List.of("Yes", "No"), + null, + null))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + FormUtils.FormFieldInfo choice = + FormUtils.extractFormFields(reloaded).stream() + .filter(f -> "Choice".equals(f.name())) + .findFirst() + .orElse(null); + assertNotNull(choice, "radio field should be extracted"); + assertEquals( + "Choice", choice.label(), "field name should win over the first option value"); + } + } + + @Test + void addNewFields_createsCombTextField() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields( + document, List.of(newField("text", "ssn", 50, 700, 200, 20, null, 9, null))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDTextField field = (PDTextField) acroForm.getField("ssn"); + assertNotNull(field); + assertEquals(9, field.getMaxLen(), "comb max length should persist"); + assertTrue(field.isComb(), "comb flag should be set"); + } + } + + @Test + void addNewFields_createsSignatureAndButton() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields( + document, + List.of( + newField("signature", "sig", 50, 600, 200, 60, null, null, null), + newField("button", "btn", 50, 500, 120, 24, null, null, "reset"))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertTrue( + acroForm.getField("sig") instanceof PDSignatureField, + "signature placeholder should exist"); + assertTrue( + acroForm.getField("btn") instanceof PDPushButton, "push button should exist"); + } + } + + @Test + void applyFieldEdits_addsModifiesAndDeletesInOnePass() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields(document, List.of(newText("old", 50, 700, 200, 20))); + + FormUtils.applyFieldEdits( + document, + List.of(newText("fresh", 50, 600, 200, 20)), + List.of(), + List.of("old")); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertNotNull(acroForm.getField("fresh"), "added field should be present"); + assertNull(acroForm.getField("old"), "deleted field should be gone"); + } + } +} 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 d24d175f5c..a61472d62e 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 @@ -257,6 +257,87 @@ public ResponseEntity extractXlsx( } } + @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 = "/edit-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation( + summary = "Apply a batch of form field edits", + description = + "Adds, modifies, and deletes form fields in a single request (one document" + + " load/save) and returns the updated file") + public ResponseEntity editFields( + @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 object with optional 'add', 'modify' and 'delete'" + + " sections", + example = + "{\"add\":[{\"name\":\"f\",\"type\":\"text\",\"pageIndex\":0," + + "\"x\":50,\"y\":700,\"width\":200,\"height\":20}]," + + "\"modify\":[],\"delete\":[]}") + @RequestPart(value = "edits", required = false) + byte[] editsPayload) + throws IOException { + + String rawEdits = decodePart(editsPayload); + FormUtils.FieldEditBatch batch = FormPayloadParser.parseFieldEdits(objectMapper, rawEdits); + if (batch.add().isEmpty() && batch.modify().isEmpty() && batch.delete().isEmpty()) { + throw ExceptionUtils.createIllegalArgumentException( + "error.dataRequired", "{0} must contain at least one edit", "edits payload"); + } + + return processSingleFile( + file, + "updated", + document -> + FormUtils.applyFieldEdits( + document, batch.add(), batch.modify(), batch.delete())); + } + @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 6c0c40ef0a..dd7de5ff23 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() {} @@ -94,6 +96,43 @@ static List parseModificationDefinitions( return objectMapper.readValue(json, MODIFY_FIELD_LIST_TYPE); } + static List parseNewFieldDefinitions( + ObjectMapper objectMapper, String json) { + if (json == null || json.isBlank()) { + return List.of(); + } + return objectMapper.readValue(json, NEW_FIELD_LIST_TYPE); + } + + /** + * Parses a combined edit batch: {@code {"add":[...],"modify":[...],"delete":[...]}}. Each + * section is optional. The delete section accepts the same shapes as {@link #parseNameList}. + */ + static FormUtils.FieldEditBatch parseFieldEdits(ObjectMapper objectMapper, String json) { + if (json == null || json.isBlank()) { + return new FormUtils.FieldEditBatch(List.of(), List.of(), List.of()); + } + final JsonNode root = objectMapper.readTree(json); + List adds = List.of(); + List modifies = List.of(); + List deletes = List.of(); + if (root != null && root.isObject()) { + final JsonNode addNode = root.get("add"); + if (addNode != null && addNode.isArray()) { + adds = objectMapper.readValue(addNode.toString(), NEW_FIELD_LIST_TYPE); + } + final JsonNode modifyNode = root.get("modify"); + if (modifyNode != null && modifyNode.isArray()) { + modifies = objectMapper.readValue(modifyNode.toString(), MODIFY_FIELD_LIST_TYPE); + } + final JsonNode deleteNode = root.get("delete"); + if (deleteNode != null && !deleteNode.isNull()) { + deletes = parseNameList(objectMapper, deleteNode.toString()); + } + } + return new FormUtils.FieldEditBatch(adds, modifies, deletes); + } + static List parseNameList(ObjectMapper objectMapper, String json) { if (json == null || json.isBlank()) { return List.of(); diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/form/FormFillControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/form/FormFillControllerTest.java index 7b2bfc9ce0..1e0be2aab5 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/form/FormFillControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/form/FormFillControllerTest.java @@ -330,6 +330,85 @@ void validPayload() throws Exception { } } + // ── addFields ────────────────────────────────────────────────────── + + @Nested + @DisplayName("addFields") + class AddFields { + + @Test + @DisplayName("throws when fields payload is null") + void nullPayload() { + assertThatThrownBy(() -> controller.addFields(pdfFile(), null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("throws when fields payload is an empty list") + void emptyPayload() { + assertThatThrownBy(() -> controller.addFields(pdfFile(), "[]".getBytes())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("processes a valid new-field payload") + void validPayload() throws Exception { + MockMultipartFile file = pdfFile(); + PDDocument doc = createMinimalPdf(); + when(pdfDocumentFactory.load(eq(file))).thenReturn(doc); + + String json = + "[{\"name\":\"NewField\",\"type\":\"text\",\"pageIndex\":0," + + "\"x\":50,\"y\":700,\"width\":200,\"height\":20}]"; + ResponseEntity response = controller.addFields(file, json.getBytes()); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + } + } + + // ── editFields (combined) ────────────────────────────────────────── + + @Nested + @DisplayName("editFields") + class EditFields { + + @Test + @DisplayName("throws when edits payload is null") + void nullPayload() { + assertThatThrownBy(() -> controller.editFields(pdfFile(), null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("throws when all sections are empty") + void emptyBatch() { + assertThatThrownBy( + () -> + controller.editFields( + pdfFile(), + "{\"add\":[],\"modify\":[],\"delete\":[]}".getBytes())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("processes a combined add/delete batch") + void validBatch() throws Exception { + MockMultipartFile file = pdfFile(); + PDDocument doc = createMinimalPdf(); + when(pdfDocumentFactory.load(eq(file))).thenReturn(doc); + + String json = + "{\"add\":[{\"name\":\"f\",\"type\":\"text\",\"pageIndex\":0,\"x\":50," + + "\"y\":700,\"width\":200,\"height\":20}],\"modify\":[]," + + "\"delete\":[]}"; + ResponseEntity response = controller.editFields(file, json.getBytes()); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + } + } + // ── buildBaseName ────────────────────────────────────────────────── @Nested diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/form/FormPayloadParserTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/form/FormPayloadParserTest.java index b0c0411cb6..eacdbbcd57 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/form/FormPayloadParserTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/form/FormPayloadParserTest.java @@ -170,6 +170,94 @@ void validModifications() { } } + // ── parseNewFieldDefinitions ─────────────────────────────────────── + + @Nested + @DisplayName("parseNewFieldDefinitions") + class ParseNewFieldDefinitions { + + @Test + @DisplayName("returns empty list for null input") + void nullInput() { + List result = + FormPayloadParser.parseNewFieldDefinitions(objectMapper, null); + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("returns empty list for blank input") + void blankInput() { + List result = + FormPayloadParser.parseNewFieldDefinitions(objectMapper, " "); + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("parses a valid new-field list including geometry and flags") + void validNewFields() { + String json = + "[{\"name\":\"NewField\",\"type\":\"text\",\"pageIndex\":0," + + "\"x\":50,\"y\":700,\"width\":200,\"height\":20," + + "\"fontSize\":14,\"readOnly\":true,\"multiline\":true}]"; + List result = + FormPayloadParser.parseNewFieldDefinitions(objectMapper, json); + assertThat(result).hasSize(1); + FormUtils.NewFormFieldDefinition def = result.get(0); + assertThat(def.name()).isEqualTo("NewField"); + assertThat(def.type()).isEqualTo("text"); + assertThat(def.pageIndex()).isEqualTo(0); + assertThat(def.x()).isEqualTo(50f); + assertThat(def.y()).isEqualTo(700f); + assertThat(def.width()).isEqualTo(200f); + assertThat(def.height()).isEqualTo(20f); + assertThat(def.fontSize()).isEqualTo(14f); + assertThat(def.readOnly()).isTrue(); + assertThat(def.multiline()).isTrue(); + } + } + + // ── parseFieldEdits ──────────────────────────────────────────────── + + @Nested + @DisplayName("parseFieldEdits") + class ParseFieldEdits { + + @Test + @DisplayName("returns empty batch for null input") + void nullInput() { + FormUtils.FieldEditBatch batch = FormPayloadParser.parseFieldEdits(objectMapper, null); + assertThat(batch.add()).isEmpty(); + assertThat(batch.modify()).isEmpty(); + assertThat(batch.delete()).isEmpty(); + } + + @Test + @DisplayName("parses a combined add/modify/delete batch") + void combinedBatch() { + String json = + "{\"add\":[{\"name\":\"new1\",\"type\":\"text\",\"pageIndex\":0,\"x\":1," + + "\"y\":2,\"width\":3,\"height\":4}]," + + "\"modify\":[{\"targetName\":\"old1\",\"label\":\"L\"}]," + + "\"delete\":[\"gone1\",{\"name\":\"gone2\"}]}"; + FormUtils.FieldEditBatch batch = FormPayloadParser.parseFieldEdits(objectMapper, json); + assertThat(batch.add()).hasSize(1); + assertThat(batch.add().get(0).name()).isEqualTo("new1"); + assertThat(batch.modify()).hasSize(1); + assertThat(batch.modify().get(0).targetName()).isEqualTo("old1"); + assertThat(batch.delete()).containsExactly("gone1", "gone2"); + } + + @Test + @DisplayName("tolerates missing sections") + void missingSections() { + FormUtils.FieldEditBatch batch = + FormPayloadParser.parseFieldEdits(objectMapper, "{\"delete\":[\"x\"]}"); + assertThat(batch.add()).isEmpty(); + assertThat(batch.modify()).isEmpty(); + assertThat(batch.delete()).containsExactly("x"); + } + } + // ── parseNameList ────────────────────────────────────────────────── @Nested diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 373a253b05..31a0d19de2 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -3984,10 +3984,60 @@ issues = "GitHub" [formFill] flattenAfterFilling = "Flatten after filling" +page = "Page" rescanFields = "Re-scan fields" rescanFormFields = "Re-scan form fields" save = "Save" +[formFill.create] +commit = "Add {{count}} field(s) to PDF" +editField = "Edit field" +empty = "No fields drawn yet." +failed = "Failed to add fields" +hint = "Pick a field type, then draw it on the page." +placing = "Draw a {{type}} field on the page. Press Esc to stop." +removeField = "Remove field" + +[formFill.editor] +action = "Button action" +actionNone = "None" +actionPrint = "Print" +actionReset = "Reset form" +actionSubmit = "Submit to URL" +actionUri = "Open URL" +actionUrl = "URL" +addOption = "Add option" +caption = "Button caption" +defaultValue = "Default value" +fontSize = "Font size" +label = "Label" +maxLength = "Max length (comb)" +multiSelect = "Allow multiple selection" +multiline = "Multi-line" +name = "Field name" +optionPlaceholder = "Option {{n}}" +options = "Options" +optionsEmpty = "Add at least one option." +readOnly = "Read-only" +removeOption = "Remove option" +required = "Required" +signatureNote = "Placeholder only - you don't sign here. It marks where a signature belongs so a PDF signer (Adobe Acrobat, a signing service, etc.) places the signature in this spot when the document is signed." +tooltip = "Tooltip" +type = "Type" + +[formFill.mode] +create = "Create" +fill = "Fill" +modify = "Modify" + +[formFill.modify] +commit = "Save {{count}} change(s)" +delete = "Delete" +empty = "This PDF has no form fields yet." +failed = "Failed to save changes" +hint = "Select a field to edit its properties, drag it on the page, or delete it." +restore = "Restore" + [formFill.sidebar] close = "Close sidebar" @@ -4306,8 +4356,8 @@ tags = "simplify,remove,interactive,flatten,flatten form,remove form fields,make title = "Flatten" [home.formFill] -desc = "Fill PDF form fields interactively with a visual editor" -title = "Fill Form" +desc = "Fill, create, edit, and delete PDF form fields with a visual editor" +title = "Form Editor" [home.getPdfInfo] desc = "Grabs any and all information possible on PDFs" @@ -8089,7 +8139,7 @@ downloadAll = "Download All" exitRedaction = "Exit Redaction Mode" exportAll = "Export PDF" exportSelected = "Export Selected Pages" -formFill = "Fill Form" +formFill = "Form Editor" multiTool = "Multi-Tool" panMode = "Pan Mode" print = "Print PDF" diff --git a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx index 75b893cb38..dd69308297 100644 --- a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx @@ -40,6 +40,7 @@ import type { PDFDict, PDFNumber } from "@cantoo/pdf-lib"; import { useWheelZoom } from "@app/hooks/useWheelZoom"; import { useFormFill } from "@app/tools/formFill/FormFillContext"; import { FormSaveBar } from "@app/tools/formFill/FormSaveBar"; +import { FORM_APPLY_EVENT } from "@app/tools/formFill/formFillEvents"; import { useViewerKeyCommand } from "@app/hooks/useViewerKeyCommand"; // ─── Measure dictionary extraction ──────────────────────────────────────────── @@ -787,8 +788,8 @@ const EmbedPdfViewerContent = ({ handleFormApply(blob); } }; - window.addEventListener("formfill:apply", handler); - return () => window.removeEventListener("formfill:apply", handler); + window.addEventListener(FORM_APPLY_EVENT, handler); + return () => window.removeEventListener(FORM_APPLY_EVENT, handler); }, [handleFormApply]); // Apply layer visibility changes - reload the modified PDF into the viewer diff --git a/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx b/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx index 92f025efcb..71b81eadcb 100644 --- a/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx +++ b/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx @@ -91,6 +91,8 @@ import { DocumentReadyWrapper } from "@app/components/viewer/DocumentReadyWrappe import { ActiveDocumentProvider } from "@app/components/viewer/ActiveDocumentContext"; import { pdfiumWasmUrl } from "@app/services/wasmPrecompiler"; import { FormFieldOverlay } from "@app/tools/formFill/FormFieldOverlay"; +import { FormFieldCreationOverlay } from "@app/tools/formFill/FormFieldCreationOverlay"; +import { FormFieldEditOverlay } from "@app/tools/formFill/FormFieldEditOverlay"; import { ButtonAppearanceOverlay } from "@app/tools/formFill/ButtonAppearanceOverlay"; import SignatureFieldOverlay from "@app/components/viewer/SignatureFieldOverlay"; import { CommentsSidebar } from "@app/components/viewer/CommentsSidebar"; @@ -1023,6 +1025,28 @@ export function LocalEmbedPDF({ /> )} + {/* Create-mode: drag to place new fields */} + {enableFormFill && ( + + )} + + {/* Modify-mode: select / move / resize existing fields */} + {enableFormFill && ( + + )} + {/* SignatureFieldOverlay — bitmaps of digital-signature appearances */} {file && ( ), - name: t("home.formFill.title", "Fill Form"), + name: t("home.formFill.title", "Form Editor"), component: lazy(() => import("@app/tools/formFill/FormFill")), description: t( "home.formFill.desc", - "Fill PDF form fields interactively with a visual editor", + "Fill, create, edit, and delete PDF form fields with a visual editor", ), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.GENERAL, @@ -444,7 +444,19 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { endpoints: ["form-fill"], automationSettings: null, supportsAutomate: false, - synonyms: ["form", "fill", "fillable", "input", "field", "acroform"], + synonyms: [ + "form", + "fill", + "fillable", + "input", + "field", + "acroform", + "edit", + "create", + "editor", + "modify", + "builder", + ], }, changePermissions: { icon: , diff --git a/frontend/editor/src/core/tests/live/form-field-editing.spec.ts b/frontend/editor/src/core/tests/live/form-field-editing.spec.ts new file mode 100644 index 0000000000..838f8516f4 --- /dev/null +++ b/frontend/editor/src/core/tests/live/form-field-editing.spec.ts @@ -0,0 +1,128 @@ +import { test, expect } from "@app/tests/helpers/test-base"; +import { loginAndSetup } from "@app/tests/helpers/login"; +import { uploadFiles } from "@app/tests/helpers/ui-helpers"; +import type { Page } from "@playwright/test"; +import * as path from "path"; +import * as fs from "fs"; + +/** + * Full-stack round-trip for the form field editor (PR #5777). Runs against a + * real Spring Boot backend (the `live` Playwright project): a field drawn in + * the browser is created by PDFBox, the viewer reloads the produced PDF, and + * the new field is then visible in modify mode and removable again. + * + * The stubbed spec (`stubbed/form-field-editing.spec.ts`) covers the UI/API + * contract without a backend; this one proves the bytes actually round-trip. + */ + +function fixture(filename: string): string { + const candidates = [ + path.resolve( + process.cwd(), + "src", + "core", + "tests", + "test-fixtures", + filename, + ), + path.resolve( + process.cwd(), + "frontend", + "editor", + "src", + "core", + "tests", + "test-fixtures", + filename, + ), + ]; + for (const p of candidates) { + if (fs.existsSync(p)) return p; + } + throw new Error(`Test fixture not found: ${filename}`); +} + +function modeTab(page: Page, name: string) { + return page + .locator(".mantine-SegmentedControl-label") + .filter({ hasText: name }); +} + +test.describe("Form field editor — live round-trip", () => { + test.describe.configure({ timeout: 120000 }); + + test.beforeEach(async ({ page }) => { + await loginAndSetup(page); + }); + + test("creates a field on the backend and shows it in modify mode", async ({ + page, + }) => { + await page.goto("/form-fill"); + await page.waitForLoadState("domcontentloaded"); + await uploadFiles(page, [fixture("sample.pdf")]); + + // --- Create a text field by drawing on the rendered page --- + await modeTab(page, "Create").click(); + await page.getByTestId("form-create-type-text").click(); + + const overlay = page.getByTestId("form-create-overlay-0"); + await expect(overlay).toBeVisible({ timeout: 30_000 }); + const box = await overlay.boundingBox(); + expect(box).not.toBeNull(); + const x = box!.x + box!.width * 0.3; + const y = box!.y + box!.height * 0.3; + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.move(x + 140, y + 36, { steps: 8 }); + await page.mouse.up(); + + const commit = page.getByTestId("form-create-commit"); + await expect(commit).toBeEnabled(); + await commit.click(); + + // After the backend creates the field, the viewer reloads the PDF and the + // tool re-fetches fields. Modify mode should now list at least one field. + await modeTab(page, "Modify").click(); + const rows = page.locator('[data-testid^="form-modify-row-"]'); + await expect(rows.first()).toBeVisible({ timeout: 30_000 }); + expect(await rows.count()).toBeGreaterThanOrEqual(1); + }); + + test("deletes an existing field through the backend", async ({ page }) => { + await page.goto("/form-fill"); + await page.waitForLoadState("domcontentloaded"); + await uploadFiles(page, [fixture("sample.pdf")]); + + // Seed a field so there is something to delete (independent of fixtures). + await modeTab(page, "Create").click(); + await page.getByTestId("form-create-type-text").click(); + const overlay = page.getByTestId("form-create-overlay-0"); + await expect(overlay).toBeVisible({ timeout: 30_000 }); + const box = await overlay.boundingBox(); + const x = box!.x + box!.width * 0.3; + const y = box!.y + box!.height * 0.5; + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.move(x + 120, y + 30, { steps: 6 }); + await page.mouse.up(); + await page.getByTestId("form-create-commit").click(); + + // Switch to modify, mark every field for deletion, commit. + await modeTab(page, "Modify").click(); + const rows = page.locator('[data-testid^="form-modify-row-"]'); + await expect(rows.first()).toBeVisible({ timeout: 30_000 }); + const initialCount = await rows.count(); + expect(initialCount).toBeGreaterThanOrEqual(1); + + await page.locator('[data-testid^="form-modify-delete-"]').first().click(); + const commit = page.getByTestId("form-modify-commit"); + await expect(commit).toBeEnabled(); + await commit.click(); + + // The reloaded PDF should expose fewer fields than before. + await expect + .poll(async () => rows.count(), { timeout: 30_000 }) + .toBeLessThan(initialCount); + }); +}); diff --git a/frontend/editor/src/core/tests/stubbed/form-field-editing.spec.ts b/frontend/editor/src/core/tests/stubbed/form-field-editing.spec.ts new file mode 100644 index 0000000000..59d3a44b57 --- /dev/null +++ b/frontend/editor/src/core/tests/stubbed/form-field-editing.spec.ts @@ -0,0 +1,299 @@ +import { test, expect } from "@app/tests/helpers/stub-test-base"; +import { uploadFiles } from "@app/tests/helpers/ui-helpers"; +import { readFileSync } from "fs"; +import path from "path"; +import type { Page, Route } from "@playwright/test"; + +/** + * Stubbed coverage for the form field editor (PR #5777 — create / modify / + * delete fields, plus radio/button/signature/comb types). The backend + * `/api/v1/form/*` endpoints are mocked so these specs run without a Spring + * Boot server; they exercise the panel UI, the staged-change bookkeeping, and + * that committing fires the combined `/edit-fields` endpoint with the right + * payload. + * + * The real PDFBox round-trip is covered by the live spec and the backend + * JUnit tests. + */ + +const SAMPLE_PDF = path.join(__dirname, "../test-fixtures/sample.pdf"); +const PDF_BYTES = readFileSync(SAMPLE_PDF); + +/** Two text fields on page 0, in the shape the backend emits. */ +const STUB_FIELDS = [ + { + name: "firstName", + label: "First name", + type: "text", + value: "", + options: null, + displayOptions: null, + required: false, + readOnly: false, + multiSelect: false, + multiline: false, + tooltip: null, + widgets: [ + { + pageIndex: 0, + x: 100, + y: 100, + width: 180, + height: 20, + fontSize: 12, + cropBoxHeight: 792, + }, + ], + }, + { + name: "lastName", + label: "Last name", + type: "text", + value: "", + options: null, + displayOptions: null, + required: false, + readOnly: false, + multiSelect: false, + multiline: false, + tooltip: null, + widgets: [ + { + pageIndex: 0, + x: 100, + y: 140, + width: 180, + height: 20, + fontSize: 12, + cropBoxHeight: 792, + }, + ], + }, +]; + +/** + * Install form-endpoint stubs. `fields` is what the extraction endpoint returns + * (default: none, so create-mode drags land on an unobstructed overlay). + * Returns a record of captured request bodies. + */ +async function stubFormEndpoints(page: Page, fields: unknown[] = []) { + const captured: Record = {}; + + await page.route("**/api/v1/form/fields-with-coordinates", (route: Route) => + route.fulfill({ json: fields }), + ); + + // The UI routes create/modify/delete commits through the combined endpoint. + await page.route("**/api/v1/form/edit-fields", (route: Route) => { + captured["edit-fields"] = route.request().postData() ?? ""; + route.fulfill({ + status: 200, + contentType: "application/pdf", + body: PDF_BYTES, + }); + }); + + return captured; +} + +async function openFormTool(page: Page) { + await page.goto("/form-fill"); + await page.waitForLoadState("domcontentloaded"); + await uploadFiles(page, SAMPLE_PDF); +} + +/** The Mantine SegmentedControl hides the radio input; the label is the target. */ +function modeTab(page: Page, name: string) { + return page + .locator(".mantine-SegmentedControl-label") + .filter({ hasText: name }); +} + +async function selectMode(page: Page, name: string) { + await modeTab(page, name).click(); +} + +/** + * Draw a rectangle on the create overlay for the currently-armed type, retrying + * until a pending field registers (the commit button enables). Pointer drags + * over the WASM-rendered page can occasionally drop under parallel load. + */ +async function drawField(page: Page) { + const overlay = page.getByTestId("form-create-overlay-0"); + await expect(overlay).toBeVisible({ timeout: 30_000 }); + const commit = page.getByTestId("form-create-commit"); + for (let attempt = 0; attempt < 4; attempt++) { + const box = await overlay.boundingBox(); + if (!box) continue; + const startX = box.x + box.width * 0.25; + const startY = box.y + box.height * 0.25; + await page.mouse.move(startX, startY); + await page.mouse.down(); + await page.mouse.move(startX + 60, startY + 20, { steps: 4 }); + await page.mouse.move(startX + 120, startY + 40, { steps: 4 }); + await page.mouse.up(); + try { + await expect(commit).toBeEnabled({ timeout: 2000 }); + return; + } catch { + // drag dropped under load — try again + } + } +} + +test.describe("Form field editor", () => { + test("exposes Fill / Create / Modify modes", async ({ page }) => { + await stubFormEndpoints(page); + await openFormTool(page); + + await expect(modeTab(page, "Fill")).toBeVisible(); + await expect(modeTab(page, "Create")).toBeVisible(); + await expect(modeTab(page, "Modify")).toBeVisible(); + }); + + test("create mode: palette offers every creatable type", async ({ page }) => { + await stubFormEndpoints(page); + await openFormTool(page); + + await selectMode(page, "Create"); + + for (const type of [ + "text", + "checkbox", + "combobox", + "listbox", + "radio", + "button", + "signature", + ]) { + await expect(page.getByTestId(`form-create-type-${type}`)).toBeVisible(); + } + + // Commit disabled with nothing queued. + await expect(page.getByTestId("form-create-commit")).toBeDisabled(); + + // Arming a type reveals the "draw on the page" hint. + await page.getByTestId("form-create-type-text").click(); + await expect( + page.getByText(/Draw a Text field on the page/i), + ).toBeVisible(); + }); + + test("create mode: drawing a text field commits via /edit-fields", async ({ + page, + }) => { + const captured = await stubFormEndpoints(page); + await openFormTool(page); + + await selectMode(page, "Create"); + await page.getByTestId("form-create-type-text").click(); + await drawField(page); + + // A queued field appears with a commit affordance enabled. + await expect(page.getByTestId("form-create-commit")).toBeEnabled(); + + await page.getByTestId("form-create-commit").click(); + await expect.poll(() => captured["edit-fields"]).toBeTruthy(); + expect(captured["edit-fields"]).toContain('"add"'); + expect(captured["edit-fields"]).toContain('"type":"text"'); + }); + + test("create mode: drawing a radio field commits a radio definition", async ({ + page, + }) => { + const captured = await stubFormEndpoints(page); + await openFormTool(page); + + await selectMode(page, "Create"); + await page.getByTestId("form-create-type-radio").click(); + await drawField(page); + + await expect(page.getByTestId("form-create-commit")).toBeEnabled(); + await page.getByTestId("form-create-commit").click(); + + await expect.poll(() => captured["edit-fields"]).toBeTruthy(); + expect(captured["edit-fields"]).toContain('"type":"radio"'); + }); + + test("create mode: a choice field auto-shows seeded options", async ({ + page, + }) => { + await stubFormEndpoints(page); + await openFormTool(page); + + await selectMode(page, "Create"); + await page.getByTestId("form-create-type-listbox").click(); + await drawField(page); + + // The just-drawn field's property editor auto-expands and the Options + // section is visible immediately, pre-seeded with two options — no manual + // expand, no hunting at the bottom of the panel. + await expect(page.getByText("Options", { exact: true })).toBeVisible(); + await expect(page.getByPlaceholder("Option 1")).toHaveValue("Option 1"); + await expect(page.getByPlaceholder("Option 2")).toHaveValue("Option 2"); + }); + + test("create mode: signature field explains it is a placeholder", async ({ + page, + }) => { + await stubFormEndpoints(page); + await openFormTool(page); + + await selectMode(page, "Create"); + await page.getByTestId("form-create-type-signature").click(); + await drawField(page); + + // The editor makes clear you don't sign here - it's a placeholder a signer fills. + await expect(page.getByText(/Placeholder only/i)).toBeVisible(); + }); + + test("modify mode: lists fields and deletes one via /edit-fields", async ({ + page, + }) => { + const captured = await stubFormEndpoints(page, STUB_FIELDS); + await openFormTool(page); + + await selectMode(page, "Modify"); + + // Both stubbed fields render as rows. + await expect(page.getByTestId("form-modify-row-firstName")).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByTestId("form-modify-row-lastName")).toBeVisible(); + + // Mark one for deletion → commit count reflects it and button enables. + await page.getByTestId("form-modify-delete-firstName").click(); + const commit = page.getByTestId("form-modify-commit"); + await expect(commit).toContainText("1"); + await expect(commit).toBeEnabled(); + + await commit.click(); + await expect.poll(() => captured["edit-fields"]).toBeTruthy(); + expect(captured["edit-fields"]).toContain('"delete"'); + expect(captured["edit-fields"]).toContain("firstName"); + }); + + test("modify mode: editing a property commits via /edit-fields", async ({ + page, + }) => { + const captured = await stubFormEndpoints(page, STUB_FIELDS); + await openFormTool(page); + + await selectMode(page, "Modify"); + await page.getByTestId("form-modify-row-firstName").click(); + + // The property editor reveals the label input; change it. + const labelInput = page.getByLabel("Label").first(); + await expect(labelInput).toBeVisible(); + await labelInput.fill("Given name"); + + const commit = page.getByTestId("form-modify-commit"); + await expect(commit).toBeEnabled(); + await commit.click(); + + await expect.poll(() => captured["edit-fields"]).toBeTruthy(); + expect(captured["edit-fields"]).toContain('"modify"'); + expect(captured["edit-fields"]).toContain("firstName"); + expect(captured["edit-fields"]).toContain("Given name"); + }); +}); diff --git a/frontend/editor/src/core/tools/formFill/FormFieldCreatePanel.tsx b/frontend/editor/src/core/tools/formFill/FormFieldCreatePanel.tsx new file mode 100644 index 0000000000..7f44bae959 --- /dev/null +++ b/frontend/editor/src/core/tools/formFill/FormFieldCreatePanel.tsx @@ -0,0 +1,233 @@ +/** + * FormFieldCreatePanel — left-panel UI for "create" mode. + * + * Pick a field type to arm placement, draw fields on the page (handled by + * FormFieldCreationOverlay), tweak each queued field's properties, then commit + * them to the PDF in one request. + */ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { + Stack, + Text, + Button, + Group, + Alert, + Collapse, + ActionIcon, + Paper, +} from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlineRounded"; +import EditIcon from "@mui/icons-material/Edit"; +import WarningAmberIcon from "@mui/icons-material/WarningAmber"; +import { useFormFill } from "@app/tools/formFill/FormFillContext"; +import { + CREATABLE_FIELD_TYPES, + type CreatableFieldType, + type NewFieldDefinition, +} from "@app/tools/formFill/types"; +import { + FIELD_TYPE_ICON, + FIELD_TYPE_COLOR, +} from "@app/tools/formFill/fieldMeta"; +import { FormFieldPropertyEditor } from "@app/tools/formFill/FormFieldPropertyEditor"; +import { useFormCommit } from "@app/tools/formFill/useFormCommit"; +import styles from "@app/tools/formFill/FormFill.module.css"; + +interface FormFieldCreatePanelProps { + currentFile: File | Blob | null; + onApplied?: (blob: Blob) => void; +} + +const TYPE_LABEL: Record = { + text: "Text", + checkbox: "Checkbox", + combobox: "Dropdown", + listbox: "List box", + radio: "Radio", + button: "Button", + signature: "Signature", +}; + +export function FormFieldCreatePanel({ + currentFile, + onApplied, +}: FormFieldCreatePanelProps) { + const { t } = useTranslation(); + const { + creationType, + setCreationType, + pendingFields, + updatePendingField, + removePendingField, + commitNewFields, + } = useFormFill(); + + const [expandedId, setExpandedId] = useState(null); + const { committing, error, commit } = useFormCommit(onApplied); + + // Auto-expand the property editor of a freshly-drawn field so its settings + // (especially options for choice/radio) are visible immediately. + const prevCountRef = useRef(0); + useEffect(() => { + if (pendingFields.length > prevCountRef.current) { + setExpandedId(pendingFields[pendingFields.length - 1].id); + } + prevCountRef.current = pendingFields.length; + }, [pendingFields]); + + const handleCommit = useCallback(() => { + if (!currentFile || pendingFields.length === 0) return; + commit( + () => commitNewFields(currentFile), + "formFill.create.failed", + "Failed to add fields", + ); + }, [currentFile, pendingFields, commitNewFields, commit]); + + return ( +

+ + + {t( + "formFill.create.hint", + "Pick a field type, then draw it on the page.", + )} + + + {/* Type palette */} + + {CREATABLE_FIELD_TYPES.map((type) => { + const armed = creationType === type; + return ( + + ); + })} + + + {creationType && ( + + + {t( + "formFill.create.placing", + "Draw a {{type}} field on the page. Press Esc to stop.", + { type: TYPE_LABEL[creationType] }, + )} + + + )} + + {error && ( + } + color="red" + variant="light" + p="xs" + radius="sm" + > + {error} + + )} + + {/* Queued fields */} + {pendingFields.length === 0 ? ( + + {t("formFill.create.empty", "No fields drawn yet.")} + + ) : ( + + {pendingFields.map((pf) => { + const expanded = expandedId === pf.id; + return ( + + + + + {FIELD_TYPE_ICON[pf.type]} + + + {pf.name} + + + p{pf.pageIndex + 1} + + + + setExpandedId(expanded ? null : pf.id)} + data-testid={`form-pending-edit-${pf.id}`} + > + + + removePendingField(pf.id)} + data-testid={`form-pending-remove-${pf.id}`} + > + + + + + +
+ + updatePendingField( + pf.id, + patch as Partial, + ) + } + showName + /> +
+
+
+ ); + })} +
+ )} + + +
+
+ ); +} + +export default FormFieldCreatePanel; diff --git a/frontend/editor/src/core/tools/formFill/FormFieldCreationOverlay.tsx b/frontend/editor/src/core/tools/formFill/FormFieldCreationOverlay.tsx new file mode 100644 index 0000000000..aa434ff813 --- /dev/null +++ b/frontend/editor/src/core/tools/formFill/FormFieldCreationOverlay.tsx @@ -0,0 +1,319 @@ +/** + * FormFieldCreationOverlay — drag-to-place layer for "create" mode. + * + * Mounted per page alongside FormFieldOverlay. When a field type is armed in + * the create panel, dragging on the page draws a rectangle which becomes a + * pending field. A plain click (no drag) drops a default-sized field. Pending + * fields on this page are drawn as dashed outlines; alignment guides appear + * while dragging. + * + * Coordinates use the exact same scale basis as FormFieldOverlay + * (pageWidthPx / pdfPage.size.width), so a field placed at a pixel position + * round-trips back to the same position after save/reload. + */ +import React, { + useCallback, + useMemo, + useRef, + useState, + useEffect, +} from "react"; +import { useFormFill } from "@app/tools/formFill/FormFillContext"; +import type { CreatableFieldType } from "@app/tools/formFill/types"; +import { + pixelsToBackendRect, + backendRectToPixels, + clampPixelRect, + roundPdfRect, + type PixelRect, +} from "@app/tools/formFill/formCoordinateUtils"; +import { + collectSnapTargets, + snapMove, + type SnapGuide, +} from "@app/tools/formFill/formSnapUtils"; +import { usePageScale, getLocalPoint } from "@app/tools/formFill/usePageScale"; +import { SnapGuides } from "@app/tools/formFill/SnapGuides"; +import { FORM_COLORS } from "@app/tools/formFill/formFieldColors"; + +interface FormFieldCreationOverlayProps { + documentId: string; + pageIndex: number; + pageWidth: number; + pageHeight: number; + fileId?: string | null; +} + +/** Minimum drawn size (pixels) below which we treat the gesture as a click. */ +const MIN_DRAG_PX = 5; + +/** Default field size in PDF points, used for click-to-place. */ +const DEFAULT_SIZE_PTS: Record = { + text: { w: 150, h: 24 }, + checkbox: { w: 16, h: 16 }, + combobox: { w: 150, h: 24 }, + listbox: { w: 150, h: 64 }, + radio: { w: 16, h: 16 }, + button: { w: 120, h: 28 }, + signature: { w: 200, h: 60 }, +}; + +export function FormFieldCreationOverlay({ + documentId, + pageIndex, + pageWidth, + pageHeight, + fileId, +}: FormFieldCreationOverlayProps) { + const { + mode, + creationType, + setCreationType, + pendingFields, + addPendingField, + state, + forFileId, + } = useFormFill(); + + const rootRef = useRef(null); + const [dragRect, setDragRect] = useState(null); + const [guides, setGuides] = useState([]); + const dragStartRef = useRef<{ x: number; y: number } | null>(null); + + const { scaleX, scaleY, pageHeightPts, pageWidthPts } = usePageScale( + documentId, + pageIndex, + pageWidth, + pageHeight, + ); + + // Pixel rects of the OTHER fields on this page, used as snap targets. + const snapRects = useMemo(() => { + const rects: PixelRect[] = []; + for (const field of state.fields) { + for (const w of field.widgets ?? []) { + if (w.pageIndex !== pageIndex) continue; + rects.push({ + left: w.x * scaleX, + top: w.y * scaleY, + width: w.width * scaleX, + height: w.height * scaleY, + }); + } + } + for (const pf of pendingFields) { + if (pf.pageIndex !== pageIndex) continue; + rects.push(backendRectToPixels(pf, scaleX, scaleY, pageHeightPts)); + } + return rects; + }, [state.fields, pendingFields, pageIndex, scaleX, scaleY, pageHeightPts]); + + // Precompute snap edges once (not on every pointermove). + const snapTargets = useMemo(() => collectSnapTargets(snapRects), [snapRects]); + + const active = mode === "create" && creationType != null; + + // Stale-file guard: don't draw on a page whose fields belong to another file. + const fileMismatch = + fileId != null && forFileId != null && fileId !== forFileId; + + const localPoint = useCallback( + (e: React.PointerEvent) => getLocalPoint(e, rootRef.current), + [], + ); + + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + if (!active) return; + // Only start a drag on the bare overlay, never on a pending outline. + if (e.target !== rootRef.current) return; + e.preventDefault(); + rootRef.current?.setPointerCapture(e.pointerId); + const p = localPoint(e); + dragStartRef.current = p; + setDragRect({ left: p.x, top: p.y, width: 0, height: 0 }); + }, + [active, localPoint], + ); + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + if (!active || !dragStartRef.current) return; + const p = localPoint(e); + const start = dragStartRef.current; + let rect: PixelRect = { + left: Math.min(start.x, p.x), + top: Math.min(start.y, p.y), + width: Math.abs(p.x - start.x), + height: Math.abs(p.y - start.y), + }; + const snapped = snapMove(rect, snapTargets, 6); + rect = { ...rect, left: snapped.left, top: snapped.top }; + setGuides(snapped.guides); + setDragRect(rect); + }, + [active, localPoint, snapTargets], + ); + + const finishDrag = useCallback( + (e: React.PointerEvent) => { + if (!active || !dragStartRef.current || !creationType) return; + const start = dragStartRef.current; + dragStartRef.current = null; + setGuides([]); + try { + rootRef.current?.releasePointerCapture(e.pointerId); + } catch { + /* pointer capture may already be released */ + } + + const current = dragRect; + setDragRect(null); + if (!current) return; + + const dragged = + current.width >= MIN_DRAG_PX && current.height >= MIN_DRAG_PX; + + let pixelRect: PixelRect; + if (dragged) { + pixelRect = current; + } else { + // Click-to-place: default size centred on the click point. + const def = DEFAULT_SIZE_PTS[creationType]; + const wPx = def.w * scaleX; + const hPx = def.h * scaleY; + pixelRect = { + left: start.x, + top: start.y, + width: wPx, + height: hPx, + }; + } + + pixelRect = clampPixelRect(pixelRect, pageWidth, pageHeight); + const pdf = roundPdfRect( + pixelsToBackendRect(pixelRect, scaleX, scaleY, pageHeightPts), + ); + + addPendingField({ + type: creationType, + pageIndex, + x: pdf.x, + y: pdf.y, + width: pdf.width, + height: pdf.height, + }); + }, + [ + active, + creationType, + dragRect, + scaleX, + scaleY, + pageHeightPts, + pageWidth, + pageHeight, + pageIndex, + addPendingField, + ], + ); + + // Escape disarms placement / cancels the in-progress drag. + useEffect(() => { + if (!active) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + dragStartRef.current = null; + setDragRect(null); + setGuides([]); + setCreationType(null); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [active, setCreationType]); + + if (mode !== "create" || fileMismatch || !pageWidthPts) return null; + + const pendingOnPage = pendingFields.filter((p) => p.pageIndex === pageIndex); + + return ( +
+ {/* Already-queued fields on this page */} + {pendingOnPage.map((pf) => { + const r = backendRectToPixels(pf, scaleX, scaleY, pageHeightPts); + return ( +
+ + {pf.name} + +
+ ); + })} + + {/* Live drag preview */} + {dragRect && creationType && ( +
+ )} + + {/* Alignment guides */} + +
+ ); +} + +export default FormFieldCreationOverlay; diff --git a/frontend/editor/src/core/tools/formFill/FormFieldEditOverlay.tsx b/frontend/editor/src/core/tools/formFill/FormFieldEditOverlay.tsx new file mode 100644 index 0000000000..f491f98c2e --- /dev/null +++ b/frontend/editor/src/core/tools/formFill/FormFieldEditOverlay.tsx @@ -0,0 +1,535 @@ +/** + * FormFieldEditOverlay — select / move / resize layer for "modify" mode. + * + * Mounted per page. Renders every field's widget(s) on the page as an outline. + * Clicking selects a field (also reflected in the modify panel). The selected + * field, when it has a single widget, gets drag-to-move and resize handles; + * geometry changes are staged via stageModification() in CropBox-relative, + * lower-left-origin points. Fields marked for deletion render struck-through. + * + * Uses the same scale basis as FormFieldOverlay so edits round-trip exactly. + */ +import React, { + useCallback, + useMemo, + useRef, + useState, + useEffect, +} from "react"; +import { useFormFill } from "@app/tools/formFill/FormFillContext"; +import type { FormField } from "@app/tools/formFill/types"; +import { + pixelsToBackendRect, + backendRectToPixels, + widgetRectToPixels, + clampPixelRect, + roundPdfRect, + type PixelRect, +} from "@app/tools/formFill/formCoordinateUtils"; +import { + collectSnapTargets, + snapMove, + snapResize, + type SnapGuide, +} from "@app/tools/formFill/formSnapUtils"; +import { usePageScale, getLocalPoint } from "@app/tools/formFill/usePageScale"; +import { SnapGuides } from "@app/tools/formFill/SnapGuides"; +import { FORM_COLORS } from "@app/tools/formFill/formFieldColors"; + +interface FormFieldEditOverlayProps { + documentId: string; + pageIndex: number; + pageWidth: number; + pageHeight: number; + fileId?: string | null; +} + +type HandleId = "nw" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w"; + +const HANDLES: { id: HandleId; cursor: string }[] = [ + { id: "nw", cursor: "nwse-resize" }, + { id: "n", cursor: "ns-resize" }, + { id: "ne", cursor: "nesw-resize" }, + { id: "e", cursor: "ew-resize" }, + { id: "se", cursor: "nwse-resize" }, + { id: "s", cursor: "ns-resize" }, + { id: "sw", cursor: "nesw-resize" }, + { id: "w", cursor: "ew-resize" }, +]; + +const MIN_PX = 8; +const HANDLE_SIZE = 9; + +interface Interaction { + kind: "move" | "resize"; + handle?: HandleId; + fieldName: string; + startX: number; + startY: number; + startRect: PixelRect; +} + +function handleEdges(h: HandleId) { + return { + left: h === "nw" || h === "w" || h === "sw", + right: h === "ne" || h === "e" || h === "se", + top: h === "nw" || h === "n" || h === "ne", + bottom: h === "sw" || h === "s" || h === "se", + }; +} + +function handlePosition(h: HandleId, rect: PixelRect) { + const cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + const map: Record = { + nw: { x: rect.left, y: rect.top }, + n: { x: cx, y: rect.top }, + ne: { x: rect.left + rect.width, y: rect.top }, + e: { x: rect.left + rect.width, y: cy }, + se: { x: rect.left + rect.width, y: rect.top + rect.height }, + s: { x: cx, y: rect.top + rect.height }, + sw: { x: rect.left, y: rect.top + rect.height }, + w: { x: rect.left, y: cy }, + }; + return map[h]; +} + +export function FormFieldEditOverlay({ + documentId, + pageIndex, + pageWidth, + pageHeight, + fileId, +}: FormFieldEditOverlayProps) { + const { + mode, + state, + selectedFieldName, + setSelectedField, + modifiedFields, + stageModification, + deletedFieldNames, + forFileId, + } = useFormFill(); + + const rootRef = useRef(null); + const interactionRef = useRef(null); + const [liveRect, setLiveRect] = useState(null); + const [guides, setGuides] = useState([]); + + const { scaleX, scaleY, pageHeightPts, pageWidthPts } = usePageScale( + documentId, + pageIndex, + pageWidth, + pageHeight, + ); + + /** First-widget pixel rect for a field on this page, honouring staged geometry. */ + const fieldRect = useCallback( + (field: FormField): PixelRect | null => { + const widget = field.widgets?.find((w) => w.pageIndex === pageIndex); + if (!widget) return null; + const staged = modifiedFields[field.name]; + if ( + staged && + staged.x != null && + staged.y != null && + staged.width != null && + staged.height != null + ) { + return backendRectToPixels( + { + x: staged.x, + y: staged.y, + width: staged.width, + height: staged.height, + }, + scaleX, + scaleY, + pageHeightPts, + ); + } + return widgetRectToPixels(widget, scaleX, scaleY); + }, + [modifiedFields, pageIndex, scaleX, scaleY, pageHeightPts], + ); + + const fieldsOnPage = useMemo( + () => + state.fields.filter((f) => + f.widgets?.some((w) => w.pageIndex === pageIndex), + ), + [state.fields, pageIndex], + ); + + const selectedField = useMemo( + () => fieldsOnPage.find((f) => f.name === selectedFieldName) ?? null, + [fieldsOnPage, selectedFieldName], + ); + + const selectedSingleWidget = + !!selectedField && (selectedField.widgets?.length ?? 0) === 1; + + const snapRects = useMemo(() => { + const rects: PixelRect[] = []; + for (const f of fieldsOnPage) { + if (f.name === selectedFieldName) continue; + const r = fieldRect(f); + if (r) rects.push(r); + } + return rects; + }, [fieldsOnPage, selectedFieldName, fieldRect]); + + // Precompute snap edges once (not on every pointermove). + const snapTargets = useMemo(() => collectSnapTargets(snapRects), [snapRects]); + + const localPoint = useCallback( + (e: React.PointerEvent) => getLocalPoint(e, rootRef.current), + [], + ); + + const beginInteraction = useCallback( + ( + e: React.PointerEvent, + field: FormField, + kind: "move" | "resize", + handle?: HandleId, + ) => { + const rect = fieldRect(field); + if (!rect) return; + e.stopPropagation(); + e.preventDefault(); + rootRef.current?.setPointerCapture(e.pointerId); + const p = localPoint(e); + interactionRef.current = { + kind, + handle, + fieldName: field.name, + startX: p.x, + startY: p.y, + startRect: rect, + }; + setLiveRect(rect); + }, + [fieldRect, localPoint], + ); + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + const it = interactionRef.current; + if (!it) return; + const p = localPoint(e); + const dx = p.x - it.startX; + const dy = p.y - it.startY; + const targets = snapTargets; + + if (it.kind === "move") { + let rect: PixelRect = { + ...it.startRect, + left: it.startRect.left + dx, + top: it.startRect.top + dy, + }; + const snapped = snapMove(rect, targets, 6); + rect = { ...rect, left: snapped.left, top: snapped.top }; + rect = clampPixelRect(rect, pageWidth, pageHeight); + setGuides(snapped.guides); + setLiveRect(rect); + } else if (it.kind === "resize" && it.handle) { + const edges = handleEdges(it.handle); + let { left, top, width, height } = it.startRect; + if (edges.left) { + left = it.startRect.left + dx; + width = it.startRect.width - dx; + } + if (edges.right) { + width = it.startRect.width + dx; + } + if (edges.top) { + top = it.startRect.top + dy; + height = it.startRect.height - dy; + } + if (edges.bottom) { + height = it.startRect.height + dy; + } + // Keep a positive minimum, anchoring the opposite edge. + if (width < MIN_PX) { + if (edges.left) + left = it.startRect.left + it.startRect.width - MIN_PX; + width = MIN_PX; + } + if (height < MIN_PX) { + if (edges.top) top = it.startRect.top + it.startRect.height - MIN_PX; + height = MIN_PX; + } + let rect: PixelRect = { left, top, width, height }; + const snapped = snapResize(rect, edges, targets, 6); + rect = snapped.rect; + setGuides(snapped.guides); + setLiveRect(rect); + } + }, + [localPoint, snapTargets, pageWidth, pageHeight], + ); + + const endInteraction = useCallback( + (e: React.PointerEvent) => { + const it = interactionRef.current; + interactionRef.current = null; + setGuides([]); + try { + rootRef.current?.releasePointerCapture(e.pointerId); + } catch { + /* already released */ + } + const rect = liveRect; + setLiveRect(null); + if (!it || !rect) return; + // A plain click (select) produces no movement — don't stage a no-op change + // that would mark the field dirty. + const moved = + Math.abs(rect.left - it.startRect.left) > 0.5 || + Math.abs(rect.top - it.startRect.top) > 0.5 || + Math.abs(rect.width - it.startRect.width) > 0.5 || + Math.abs(rect.height - it.startRect.height) > 0.5; + if (!moved) return; + const clamped = clampPixelRect(rect, pageWidth, pageHeight); + const pdf = roundPdfRect( + pixelsToBackendRect(clamped, scaleX, scaleY, pageHeightPts), + ); + stageModification(it.fieldName, { + pageIndex, + x: pdf.x, + y: pdf.y, + width: pdf.width, + height: pdf.height, + }); + }, + [ + liveRect, + pageWidth, + pageHeight, + scaleX, + scaleY, + pageHeightPts, + pageIndex, + stageModification, + ], + ); + + // Escape clears the selection; arrow keys nudge the selected field. + // (Each visible page mounts this overlay, but only the page whose widget + // matches selectedField resolves a rect, so a nudge applies exactly once.) + useEffect(() => { + if (mode !== "modify") return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + interactionRef.current = null; + setLiveRect(null); + setGuides([]); + setSelectedField(null); + return; + } + + if ( + !selectedField || + !selectedSingleWidget || + deletedFieldNames.includes(selectedField.name) + ) { + return; + } + const step = e.shiftKey ? 10 : 1; + let dx = 0; + let dy = 0; + switch (e.key) { + case "ArrowLeft": + dx = -step; + break; + case "ArrowRight": + dx = step; + break; + case "ArrowUp": + dy = -step; + break; + case "ArrowDown": + dy = step; + break; + default: + return; + } + const base = fieldRect(selectedField); + if (!base) return; // selected field's widget isn't on this page + e.preventDefault(); + const moved = clampPixelRect( + { ...base, left: base.left + dx, top: base.top + dy }, + pageWidth, + pageHeight, + ); + const pdf = roundPdfRect( + pixelsToBackendRect(moved, scaleX, scaleY, pageHeightPts), + ); + stageModification(selectedField.name, { + pageIndex, + x: pdf.x, + y: pdf.y, + width: pdf.width, + height: pdf.height, + }); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [ + mode, + setSelectedField, + selectedField, + selectedSingleWidget, + deletedFieldNames, + fieldRect, + pageWidth, + pageHeight, + scaleX, + scaleY, + pageHeightPts, + pageIndex, + stageModification, + ]); + + const fileMismatch = + fileId != null && forFileId != null && fileId !== forFileId; + if (mode !== "modify" || fileMismatch || !pageWidthPts) return null; + + const selectedRect = selectedField + ? (liveRect ?? fieldRect(selectedField)) + : null; + + return ( +
{ + // Clicking empty space deselects. preventDefault stops the underlying + // PDF text layer from starting a text selection. + e.preventDefault(); + setSelectedField(null); + }} + onPointerMove={handlePointerMove} + onPointerUp={endInteraction} + style={{ + position: "absolute", + inset: 0, + pointerEvents: "auto", + userSelect: "none", + WebkitUserSelect: "none", + zIndex: 5, + }} + > + {fieldsOnPage.map((field) => { + const rect = + field.name === selectedFieldName && selectedRect + ? selectedRect + : fieldRect(field); + if (!rect) return null; + const isSelected = field.name === selectedFieldName; + const isDeleted = deletedFieldNames.includes(field.name); + return ( +
{ + if (isDeleted) return; + e.stopPropagation(); + const singleWidget = (field.widgets?.length ?? 0) === 1; + // Select and (for single-widget fields) start moving in one + // gesture — no need to click first then drag. A click without + // movement just selects (endInteraction ignores zero-delta). + if (field.name !== selectedFieldName) + setSelectedField(field.name); + if (singleWidget) beginInteraction(e, field, "move"); + }} + style={{ + position: "absolute", + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + border: isDeleted + ? `1.5px dashed ${FORM_COLORS.danger}` + : isSelected + ? `2px solid ${FORM_COLORS.accent}` + : `1.5px solid ${FORM_COLORS.neutralBorder}`, + background: isDeleted + ? FORM_COLORS.dangerFill + : isSelected + ? FORM_COLORS.accentFill + : FORM_COLORS.neutralFill, + borderRadius: 2, + boxSizing: "border-box", + cursor: isDeleted + ? "not-allowed" + : (field.widgets?.length ?? 0) === 1 + ? "move" + : "pointer", + textDecoration: isDeleted ? "line-through" : undefined, + }} + > + + {field.label || field.name} + +
+ ); + })} + + {/* Resize handles for the selected single-widget field */} + {selectedRect && + selectedSingleWidget && + !deletedFieldNames.includes(selectedFieldName ?? "") && + HANDLES.map((h) => { + const pos = handlePosition(h.id, selectedRect); + return ( +
+ selectedField && + beginInteraction(e, selectedField, "resize", h.id) + } + style={{ + position: "absolute", + left: pos.x - HANDLE_SIZE / 2, + top: pos.y - HANDLE_SIZE / 2, + width: HANDLE_SIZE, + height: HANDLE_SIZE, + background: "#fff", + border: `1.5px solid ${FORM_COLORS.accent}`, + borderRadius: 2, + cursor: h.cursor, + boxSizing: "border-box", + }} + /> + ); + })} + + {/* Alignment guides */} + +
+ ); +} + +export default FormFieldEditOverlay; diff --git a/frontend/editor/src/core/tools/formFill/FormFieldModifyPanel.tsx b/frontend/editor/src/core/tools/formFill/FormFieldModifyPanel.tsx new file mode 100644 index 0000000000..b2453d7f39 --- /dev/null +++ b/frontend/editor/src/core/tools/formFill/FormFieldModifyPanel.tsx @@ -0,0 +1,373 @@ +/** + * FormFieldModifyPanel — left-panel UI for "modify" mode. + * + * Lists existing fields grouped by page. Selecting one highlights it on the + * page (via FormFieldEditOverlay) and reveals a property editor plus precise + * X/Y/W/H inputs. Fields can be marked for deletion. All staged changes commit + * in one round-trip. + */ +import React, { useCallback, useEffect, useMemo, useRef } from "react"; +import { + Text, + Button, + Group, + Alert, + Collapse, + ActionIcon, + Paper, + NumberInput, + ScrollArea, + Tooltip, +} from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlineRounded"; +import RestoreIcon from "@mui/icons-material/Restore"; +import WarningAmberIcon from "@mui/icons-material/WarningAmber"; +import { useFormFill } from "@app/tools/formFill/FormFillContext"; +import type { + FormField, + ModifyFieldDefinition, +} from "@app/tools/formFill/types"; +import { + FIELD_TYPE_ICON, + FIELD_TYPE_COLOR, +} from "@app/tools/formFill/fieldMeta"; +import { + FormFieldPropertyEditor, + type EditableFieldProps, +} from "@app/tools/formFill/FormFieldPropertyEditor"; +import { useFormCommit } from "@app/tools/formFill/useFormCommit"; +import styles from "@app/tools/formFill/FormFill.module.css"; + +interface FormFieldModifyPanelProps { + currentFile: File | Blob | null; + onApplied?: (blob: Blob) => void; +} + +/** Current backend (lower-left origin) coords for a field's first widget. */ +function currentCoords(field: FormField, staged?: ModifyFieldDefinition) { + const w = field.widgets?.[0]; + if (!w) return null; + if ( + staged && + staged.x != null && + staged.y != null && + staged.width != null && + staged.height != null + ) { + return { + x: staged.x, + y: staged.y, + width: staged.width, + height: staged.height, + }; + } + const cropH = w.cropBoxHeight ?? 0; + return { + x: w.x, + y: cropH ? cropH - w.y - w.height : w.y, + width: w.width, + height: w.height, + }; +} + +export function FormFieldModifyPanel({ + currentFile, + onApplied, +}: FormFieldModifyPanelProps) { + const { t } = useTranslation(); + const { + state, + selectedFieldName, + setSelectedField, + modifiedFields, + stageModification, + deletedFieldNames, + toggleFieldDeleted, + commitModifications, + hasUncommittedChanges, + } = useFormFill(); + + const { committing, error, commit } = useFormCommit(onApplied); + const selectedRowRef = useRef(null); + + // Group fields by their first widget's page. + const { sortedPages, fieldsByPage } = useMemo(() => { + const byPage = new Map(); + for (const field of state.fields) { + const pageIndex = field.widgets?.[0]?.pageIndex ?? 0; + if (!byPage.has(pageIndex)) byPage.set(pageIndex, []); + byPage.get(pageIndex)!.push(field); + } + return { + sortedPages: Array.from(byPage.keys()).sort((a, b) => a - b), + fieldsByPage: byPage, + }; + }, [state.fields]); + + // Auto-scroll the list to the selected field (e.g. selected via the overlay). + useEffect(() => { + if (selectedFieldName && selectedRowRef.current) { + selectedRowRef.current.scrollIntoView({ + behavior: "smooth", + block: "nearest", + }); + } + }, [selectedFieldName]); + + const changeCount = + Object.keys(modifiedFields).length + deletedFieldNames.length; + + const handleCommit = useCallback(() => { + if (!currentFile || !hasUncommittedChanges) return; + commit( + () => commitModifications(currentFile), + "formFill.modify.failed", + "Failed to save changes", + ); + }, [currentFile, hasUncommittedChanges, commitModifications, commit]); + + const editorValue = useCallback( + (field: FormField): EditableFieldProps => { + const staged = modifiedFields[field.name]; + return { + name: staged?.name ?? field.name, + label: staged?.label ?? field.label, + type: staged?.type ?? field.type, + defaultValue: staged?.defaultValue ?? field.value, + tooltip: staged?.tooltip ?? field.tooltip ?? "", + fontSize: staged?.fontSize ?? field.widgets?.[0]?.fontSize, + required: staged?.required ?? field.required, + readOnly: staged?.readOnly ?? field.readOnly, + multiline: staged?.multiline ?? field.multiline, + multiSelect: staged?.multiSelect ?? field.multiSelect, + options: staged?.options ?? field.options ?? [], + }; + }, + [modifiedFields], + ); + + return ( +
+
+ + {t( + "formFill.modify.hint", + "Select a field to edit its properties, drag it on the page, or delete it.", + )} + + + {error && ( + } + color="red" + variant="light" + p="xs" + radius="sm" + > + {error} + + )} + + + + {state.fields.length === 0 && !state.loading && ( + + {t("formFill.modify.empty", "This PDF has no form fields yet.")} + + )} +
+ + +
+ {sortedPages.map((pageIdx, i) => ( + +
+ + {t("formFill.page", "Page")} {pageIdx + 1} + +
+ + {fieldsByPage.get(pageIdx)!.map((field) => { + const selected = selectedFieldName === field.name; + const deleted = deletedFieldNames.includes(field.name); + const coords = currentCoords(field, modifiedFields[field.name]); + return ( + + setSelectedField(selected ? null : field.name) + } + data-testid={`form-modify-row-${field.name}`} + > + + + + {FIELD_TYPE_ICON[field.type]} + + + {field.label || field.name} + + + + { + e.stopPropagation(); + toggleFieldDeleted(field.name); + }} + data-testid={`form-modify-delete-${field.name}`} + > + {deleted ? ( + + ) : ( + + )} + + + + + +
e.stopPropagation()} + > + + stageModification( + field.name, + patch as Partial, + ) + } + showName + allowTypeChange + /> + + {coords && ( + + + typeof v === "number" && + stageModification(field.name, { + pageIndex: pageIdx, + x: v, + y: coords.y, + width: coords.width, + height: coords.height, + }) + } + /> + + typeof v === "number" && + stageModification(field.name, { + pageIndex: pageIdx, + x: coords.x, + y: v, + width: coords.width, + height: coords.height, + }) + } + /> + + typeof v === "number" && + stageModification(field.name, { + pageIndex: pageIdx, + x: coords.x, + y: coords.y, + width: v, + height: coords.height, + }) + } + /> + + typeof v === "number" && + stageModification(field.name, { + pageIndex: pageIdx, + x: coords.x, + y: coords.y, + width: coords.width, + height: v, + }) + } + /> + + )} +
+
+
+ ); + })} +
+ ))} +
+
+
+ ); +} + +export default FormFieldModifyPanel; diff --git a/frontend/editor/src/core/tools/formFill/FormFieldPropertyEditor.tsx b/frontend/editor/src/core/tools/formFill/FormFieldPropertyEditor.tsx new file mode 100644 index 0000000000..71ceb880c3 --- /dev/null +++ b/frontend/editor/src/core/tools/formFill/FormFieldPropertyEditor.tsx @@ -0,0 +1,356 @@ +/** + * FormFieldPropertyEditor — shared property form used by both the create and + * modify panels. It edits the subset of attributes common to new and existing + * fields and reports changes as partial patches via onChange. + */ +import React from "react"; +import { + Stack, + TextInput, + NumberInput, + Select, + Switch, + Group, + ActionIcon, + Button, + Text, + Alert, +} from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import AddIcon from "@mui/icons-material/Add"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlineRounded"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; + +export interface EditableFieldProps { + name?: string; + label?: string; + type: string; + defaultValue?: string; + tooltip?: string; + fontSize?: number; + required?: boolean; + readOnly?: boolean; + multiline?: boolean; + multiSelect?: boolean; + options?: string[]; + maxLength?: number; + buttonAction?: string; +} + +interface FormFieldPropertyEditorProps { + value: EditableFieldProps; + onChange: (patch: Partial) => void; + /** Show the field-name input (create mode). */ + showName?: boolean; + /** Allow changing the field type (modify mode). */ + allowTypeChange?: boolean; +} + +const TYPE_LABEL: Record = { + text: "Text", + checkbox: "Checkbox", + combobox: "Dropdown", + listbox: "List box", + radio: "Radio group", + button: "Button", + signature: "Signature", +}; + +// Type-change is only safe between the "simple" single-widget types; retyping +// into radio/button/signature needs dedicated creation, so it's create-only. +const TYPE_CHANGE_OPTIONS = ["text", "checkbox", "combobox", "listbox"]; + +/** Split a stored buttonAction string into a kind + optional url. */ +function parseButtonAction(action: string | undefined): { + kind: string; + url: string; +} { + if (!action) return { kind: "none", url: "" }; + if (action === "reset") return { kind: "reset", url: "" }; + if (action === "print") return { kind: "print", url: "" }; + if (action.startsWith("uri:")) return { kind: "uri", url: action.slice(4) }; + if (action.startsWith("submit:")) + return { kind: "submit", url: action.slice(7) }; + return { kind: "none", url: "" }; +} + +function buildButtonAction(kind: string, url: string): string { + switch (kind) { + case "reset": + return "reset"; + case "print": + return "print"; + case "uri": + return `uri:${url}`; + case "submit": + return `submit:${url}`; + default: + return ""; + } +} + +export function FormFieldPropertyEditor({ + value, + onChange, + showName = true, + allowTypeChange = false, +}: FormFieldPropertyEditorProps) { + const { t } = useTranslation(); + const hasOptions = + value.type === "combobox" || + value.type === "listbox" || + value.type === "radio"; + const isVariableText = + value.type === "text" || + value.type === "combobox" || + value.type === "listbox"; + const isText = value.type === "text"; + const isButton = value.type === "button"; + const isSignature = value.type === "signature"; + const isFillable = !isButton && !isSignature; + const canRetype = TYPE_CHANGE_OPTIONS.includes(value.type); + + const updateOption = (index: number, next: string) => { + const options = [...(value.options ?? [])]; + options[index] = next; + onChange({ options }); + }; + const addOption = () => onChange({ options: [...(value.options ?? []), ""] }); + const removeOption = (index: number) => + onChange({ options: (value.options ?? []).filter((_, i) => i !== index) }); + + const action = parseButtonAction(value.buttonAction); + + return ( + + {isSignature && ( + } + > + + {t( + "formFill.editor.signatureNote", + "Placeholder only - you don't sign here. It marks where a signature belongs so a PDF signer (Adobe Acrobat, a signing service, etc.) places the signature in this spot when the document is signed.", + )} + + + )} + + {showName && ( + onChange({ name: e.currentTarget.value })} + /> + )} + + onChange({ label: e.currentTarget.value })} + /> + + {allowTypeChange && ( + + onChange({ + buttonAction: buildButtonAction(v ?? "none", action.url), + }) + } + comboboxProps={{ withinPortal: true }} + /> + {(action.kind === "uri" || action.kind === "submit") && ( + + onChange({ + buttonAction: buildButtonAction( + action.kind, + e.currentTarget.value, + ), + }) + } + /> + )} + + )} + + {isVariableText && ( + + onChange({ fontSize: typeof v === "number" ? v : undefined }) + } + /> + )} + + {isText && ( + <> + onChange({ multiline: e.currentTarget.checked })} + /> + + onChange({ maxLength: typeof v === "number" ? v : undefined }) + } + /> + + )} + + {value.type === "listbox" && ( + onChange({ multiSelect: e.currentTarget.checked })} + /> + )} + + {isFillable && ( + onChange({ required: e.currentTarget.checked })} + /> + )} + + {isFillable && ( + onChange({ readOnly: e.currentTarget.checked })} + /> + )} + + ); +} + +export default FormFieldPropertyEditor; diff --git a/frontend/editor/src/core/tools/formFill/FormFill.module.css b/frontend/editor/src/core/tools/formFill/FormFill.module.css index 4cffc284f5..7a00f7f070 100644 --- a/frontend/editor/src/core/tools/formFill/FormFill.module.css +++ b/frontend/editor/src/core/tools/formFill/FormFill.module.css @@ -49,12 +49,23 @@ line-height: 1; } +/* Selected tab: the indicator is a saturated blue in both colour schemes, so the + label and its icon must flip to white for legible contrast. Without this the + muted-grey text sat on the blue and read poorly, especially in dark mode. */ +.segmentedInnerLabel[data-active] { + color: var(--mantine-color-white); +} + .modeTabIcon { font-size: 1rem !important; margin-bottom: 0.125rem; opacity: 0.8; } +.segmentedInnerLabel[data-active] .modeTabIcon { + opacity: 1; +} + .header { flex-shrink: 0; padding: 0.75rem 1rem; diff --git a/frontend/editor/src/core/tools/formFill/FormFill.tsx b/frontend/editor/src/core/tools/formFill/FormFill.tsx index 846c1dcc83..3508670839 100644 --- a/frontend/editor/src/core/tools/formFill/FormFill.tsx +++ b/frontend/editor/src/core/tools/formFill/FormFill.tsx @@ -1,13 +1,11 @@ /** - * FormFill: The tool component that renders in the left ToolPanel - * when the "Fill Form" tool is selected. + * FormFill: The "Form Editor" tool component that renders in the left ToolPanel + * when the formFill tool is selected. * - * Redesigned with: - * - Mode tabs for future extensibility (Fill / Make / Batch / Modify) - * - Clean visual hierarchy with proper spacing - * - Shared FieldInput component (eliminates duplication) - * - CSS module for theme-consistent styling - * - Status bar at bottom for contextual info + * Modes: + * - Fill: enter values into existing fields + * - Create: draw new fields (text/checkbox/dropdown/list/radio/button/signature) + * - Modify: select, move/resize, edit properties, and delete existing fields */ import React, { useEffect, @@ -26,6 +24,7 @@ import { Progress, Tooltip, ActionIcon, + SegmentedControl, } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { @@ -49,7 +48,6 @@ import RefreshIcon from "@mui/icons-material/Refresh"; import WarningAmberIcon from "@mui/icons-material/WarningAmber"; import EditNoteIcon from "@mui/icons-material/EditNote"; import PostAddIcon from "@mui/icons-material/PostAdd"; -import FileCopyIcon from "@mui/icons-material/FileCopy"; import BuildCircleIcon from "@mui/icons-material/BuildCircle"; import DescriptionIcon from "@mui/icons-material/Description"; import FileDownloadIcon from "@mui/icons-material/FileDownload"; @@ -57,65 +55,22 @@ import { extractFormFieldsCsv, extractFormFieldsXlsx, } from "@app/tools/formFill/formApi"; +import type { FormMode } from "@app/tools/formFill/types"; +import { FormFieldCreatePanel } from "@app/tools/formFill/FormFieldCreatePanel"; +import { FormFieldModifyPanel } from "@app/tools/formFill/FormFieldModifyPanel"; +import { dispatchFormApply } from "@app/tools/formFill/formFillEvents"; import styles from "@app/tools/formFill/FormFill.module.css"; // --------------------------------------------------------------------------- // Mode tabs — extensible for future form tools // --------------------------------------------------------------------------- -type FormMode = "fill" | "make" | "batch" | "modify"; - interface ModeTabDef { id: FormMode; label: string; icon: React.ReactNode; - ready: boolean; } -const _MODE_TABS: ModeTabDef[] = [ - { - id: "fill", - label: "Fill", - icon: , - ready: true, - }, - { - id: "make", - label: "Create", - icon: , - ready: false, - }, - { - id: "batch", - label: "Batch", - icon: , - ready: false, - }, - { - id: "modify", - label: "Modify", - icon: , - ready: false, - }, -]; - -// --------------------------------------------------------------------------- -// Coming-soon placeholder for unimplemented tabs -// --------------------------------------------------------------------------- - -// ComingSoonPlaceholder — re-enable when mode tabs are exposed -// function ComingSoonPlaceholder({ mode }: { mode: ModeTabDef }) { -// return ( -//
-// -//
{mode.label} Forms
-//
-// This feature is coming soon. Stay tuned! -//
-//
-// ); -// } - // --------------------------------------------------------------------------- // Main FormFill component // --------------------------------------------------------------------------- @@ -132,18 +87,36 @@ const FormFill = (_props: BaseToolProps) => { setValue, setActiveField, validateForm, + mode, + setMode, } = useFormFill(); + const MODE_TABS: ModeTabDef[] = useMemo( + () => [ + { + id: "fill", + label: t("formFill.mode.fill", "Fill"), + icon: , + }, + { + id: "create", + label: t("formFill.mode.create", "Create"), + icon: , + }, + { + id: "modify", + label: t("formFill.mode.modify", "Modify"), + icon: , + }, + ], + [t], + ); + const allValues = useAllFormValues(); const { validationErrors } = formState; 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('fill'); - const mode: FormMode = "fill"; const [flatten, setFlatten] = useState(false); const [saving, setSaving] = useState(false); const [extracting, setExtracting] = useState(false); @@ -259,14 +232,11 @@ const FormFill = (_props: BaseToolProps) => { // Track the flatten value at save so toggling it later re-enables Save setLastSavedFlatten(flatten); - // Dispatch to the viewer's handleFormApply via custom event. - // This ensures the viewer tracks the new file ID, preserves - // scroll position and rotation — instead of our own consumeFiles - // call which would lose the viewer's file tracking context. - const event = new CustomEvent("formfill:apply", { - detail: { blob: filledBlob }, - }); - window.dispatchEvent(event); + // Hand the filled PDF to the viewer's handleFormApply via custom event. + // This ensures the viewer tracks the new file ID and preserves scroll + // position and rotation, instead of our own consumeFiles call which + // would lose the viewer's file tracking context. + dispatchFormApply(filledBlob); } catch (err: any) { const message = err?.response?.status === 413 @@ -384,7 +354,7 @@ const FormFill = (_props: BaseToolProps) => { return (
- {/* ---- Mode selection (commented out until additional modes are implemented) ---- + {/* ---- Mode selection ---- */}
{ }} />
- ---- */} - {/* ---- Coming-soon for non-ready tabs (hidden while mode tabs are disabled) ---- */} - {/* !currentModeDef.ready && */} + {/* ---- Create mode ---- */} + {mode === "create" && ( + + )} + + {/* ---- Modify mode ---- */} + {mode === "modify" && ( + + )} {/* ---- Fill Form content ---- */} {mode === "fill" && ( diff --git a/frontend/editor/src/core/tools/formFill/FormFillContext.tsx b/frontend/editor/src/core/tools/formFill/FormFillContext.tsx index 217b568bbc..e92ec4ae8b 100644 --- a/frontend/editor/src/core/tools/formFill/FormFillContext.tsx +++ b/frontend/editor/src/core/tools/formFill/FormFillContext.tsx @@ -34,11 +34,22 @@ import type { FormField, FormFillState, WidgetCoordinates, + FormMode, + CreatableFieldType, + NewFieldDefinition, + ModifyFieldDefinition, } from "@app/tools/formFill/types"; import type { IFormDataProvider } from "@app/tools/formFill/providers/types"; import { PdfBoxFormProvider } from "@app/tools/formFill/providers/PdfBoxFormProvider"; import { PdfiumFormProvider } from "@app/tools/formFill/providers/PdfiumFormProvider"; import { fetchSignatureFieldsWithAppearances } from "@app/services/pdfiumService"; +import { applyFieldEdits } from "@app/tools/formFill/formApi"; +import { mergeSignatureAppearances } from "@app/tools/formFill/formFieldMerge"; + +/** A field queued for creation, with a client-side id for list keys. */ +export interface PendingField extends NewFieldDefinition { + id: string; +} // --------------------------------------------------------------------------- // FormValuesStore — external store for field values (outside React state) @@ -205,6 +216,56 @@ 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; + + // ------------------------------------------------------------------------- + // Structural editing (create / modify modes) + // ------------------------------------------------------------------------- + + /** Current tool mode. */ + mode: FormMode; + /** Switch mode. Switching clears the other mode's uncommitted working state. */ + setMode: (mode: FormMode) => void; + + // --- Create mode --- + /** Field type currently armed for placement (null = not placing). */ + creationType: CreatableFieldType | null; + setCreationType: (type: CreatableFieldType | null) => void; + /** Fields drawn but not yet committed to the PDF. */ + pendingFields: PendingField[]; + /** Queue a new field (id + default name auto-assigned). Returns the new id. */ + addPendingField: ( + field: Omit & { name?: string }, + ) => string; + updatePendingField: (id: string, patch: Partial) => void; + removePendingField: (id: string) => void; + clearPendingFields: () => void; + /** POST queued fields to the backend; resolves to the updated PDF blob. */ + commitNewFields: (file: File | Blob) => Promise; + + // --- Modify mode --- + /** Field currently selected for editing in modify mode. */ + selectedFieldName: string | null; + setSelectedField: (name: string | null) => void; + /** Staged (uncommitted) property/geometry changes, keyed by original field name. */ + modifiedFields: Record; + /** Merge a partial change for a field into the staged set. */ + stageModification: ( + targetName: string, + patch: Partial, + ) => void; + /** Discard staged changes for a single field. */ + clearModification: (targetName: string) => void; + /** Field names marked for deletion. */ + deletedFieldNames: string[]; + /** Toggle a field's deletion mark. */ + toggleFieldDeleted: (name: string) => void; + /** Discard all staged modifications and deletions. */ + clearModifications: () => void; + /** POST staged modifications + deletions; resolves to the updated PDF blob. */ + commitModifications: (file: File | Blob) => Promise; + + /** True when create or modify mode has uncommitted work. */ + hasUncommittedChanges: boolean; } const FormFillContext = createContext(null); @@ -306,6 +367,28 @@ export function FormFillProvider({ // This prevents full context re-renders on every keystroke. const [valuesStore] = useState(() => new FormValuesStore()); + // --- Structural editing state (create / modify modes) --- + const [mode, setModeState] = useState("fill"); + const [creationType, setCreationType] = useState( + null, + ); + const [pendingFields, setPendingFields] = useState([]); + const [selectedFieldName, setSelectedField] = useState(null); + const [modifiedFields, setModifiedFields] = useState< + Record + >({}); + const [deletedFieldNames, setDeletedFieldNames] = useState([]); + // Monotonic counter for client-side pending-field ids and default names. + const pendingCounterRef = useRef(0); + + const clearEditingState = useCallback(() => { + setCreationType(null); + setPendingFields([]); + setSelectedField(null); + setModifiedFields({}); + setDeletedFieldNames([]); + }, []); + const fetchFields = useCallback( async (file: File | Blob, fileId?: string) => { // Increment version so any in-flight fetch for a previous file is discarded. @@ -321,6 +404,11 @@ export function FormFillProvider({ setForFileId(null); valuesStore.reset({}); dispatch({ type: "RESET" }); + // NOTE: deliberately do NOT clear create/modify editing state here. + // EmbedPdfViewer re-fetches fields on provider switch and file load, and + // those background fetches must not wipe a user's in-progress drawn + // fields or staged edits. Editing state is cleared on explicit mode + // switch (setMode) and reset() instead. dispatch({ type: "FETCH_START" }); try { let fields = await providerRef.current.fetchFields(file); @@ -332,8 +420,10 @@ export function FormFillProvider({ return; } - // When the pdfbox provider is active the backend doesn't return signature fields - // (they're not fillable). Fetch them via pdflib so their appearances still render. + // The pdfbox backend returns signature fields, but without a rendered + // appearance. Fetch the rendered signature appearances via pdfium and + // MERGE them by name — enrich an existing backend entry rather than + // appending a duplicate (otherwise a signature shows up twice). if (providerModeRef.current === "pdfbox") { try { // Convert File/Blob to ArrayBuffer for pdfiumService @@ -341,9 +431,7 @@ export function FormFillProvider({ const sigFields = await fetchSignatureFieldsWithAppearances(arrayBuffer); if (fetchVersionRef.current !== version) return; // stale check after async - if (sigFields.length > 0) { - fields = [...fields, ...sigFields]; - } + fields = mergeSignatureAppearances(fields, sigFields); } catch (e) { console.warn( "[FormFill] Failed to extract signature appearances for pdfbox mode:", @@ -478,7 +566,145 @@ export function FormFillProvider({ setForFileId(null); valuesStore.reset({}); dispatch({ type: "RESET" }); - }, [valuesStore]); + clearEditingState(); + }, [valuesStore, clearEditingState]); + + // --- Mode switching --- + const setMode = useCallback( + (next: FormMode) => { + setModeState((prev) => { + if (prev === next) return prev; + // Leaving a mode discards its uncommitted working state so the user + // doesn't carry half-drawn fields or staged edits between modes. + clearEditingState(); + return next; + }); + }, + [clearEditingState], + ); + + // --- Create mode --- + const addPendingField = useCallback( + (field: Omit & { name?: string }): string => { + const seq = ++pendingCounterRef.current; + const id = `pending-${seq}`; + // Friendly, readable default names that match how the viewer labels + // fields, instead of cryptic "Field_5". + const TYPE_DEFAULT_NAME: Record = { + text: "Text field", + checkbox: "Checkbox", + combobox: "Dropdown", + listbox: "List", + radio: "Radio group", + button: "Button", + signature: "Signature", + }; + const defaultName = + field.name?.trim() || + `${TYPE_DEFAULT_NAME[field.type] ?? "Field"} ${seq}`; + // Choice/radio fields need options to be useful — seed a sensible default + // so the field isn't empty and the options editor has something to show. + const needsOptions = + field.type === "combobox" || + field.type === "listbox" || + field.type === "radio"; + const options = + field.options ?? (needsOptions ? ["Option 1", "Option 2"] : undefined); + setPendingFields((prev) => [ + ...prev, + { ...field, name: defaultName, options, id } as PendingField, + ]); + return id; + }, + [], + ); + + const updatePendingField = useCallback( + (id: string, patch: Partial) => { + setPendingFields((prev) => + prev.map((f) => (f.id === id ? { ...f, ...patch } : f)), + ); + }, + [], + ); + + const removePendingField = useCallback((id: string) => { + setPendingFields((prev) => prev.filter((f) => f.id !== id)); + }, []); + + const clearPendingFields = useCallback(() => { + setPendingFields([]); + setCreationType(null); + }, []); + + const commitNewFields = useCallback( + async (file: File | Blob): Promise => { + // Strip the client-side id before sending to the backend. + const definitions: NewFieldDefinition[] = pendingFields.map( + ({ id: _id, ...rest }) => rest, + ); + const blob = await applyFieldEdits(file, { add: definitions }); + setPendingFields([]); + setCreationType(null); + return blob; + }, + [pendingFields], + ); + + // --- Modify mode --- + const stageModification = useCallback( + (targetName: string, patch: Partial) => { + setModifiedFields((prev) => ({ + ...prev, + [targetName]: { ...prev[targetName], targetName, ...patch }, + })); + }, + [], + ); + + const clearModification = useCallback((targetName: string) => { + setModifiedFields((prev) => { + if (!(targetName in prev)) return prev; + const { [targetName]: _removed, ...rest } = prev; + return rest; + }); + }, []); + + const toggleFieldDeleted = useCallback((name: string) => { + setDeletedFieldNames((prev) => + prev.includes(name) ? prev.filter((n) => n !== name) : [...prev, name], + ); + }, []); + + const clearModifications = useCallback(() => { + setModifiedFields({}); + setDeletedFieldNames([]); + setSelectedField(null); + }, []); + + const commitModifications = useCallback( + async (file: File | Blob): Promise => { + // Apply property/geometry changes (for fields not being deleted) and the + // deletions in a single backend round-trip. + const updates = Object.values(modifiedFields).filter( + (m) => !deletedFieldNames.includes(m.targetName), + ); + const blob = await applyFieldEdits(file, { + modify: updates, + delete: deletedFieldNames, + }); + setModifiedFields({}); + setDeletedFieldNames([]); + setSelectedField(null); + return blob; + }, + [modifiedFields, deletedFieldNames], + ); + + const hasUncommittedChanges = + pendingFields.length > 0 || + Object.keys(modifiedFields).length > 0 || + deletedFieldNames.length > 0; const fieldsByPage = useMemo(() => { const map = new Map(); @@ -508,6 +734,27 @@ export function FormFillProvider({ activeProviderName: providerRef.current.name, setProviderMode, forFileId, + // editing + mode, + setMode, + creationType, + setCreationType, + pendingFields, + addPendingField, + updatePendingField, + removePendingField, + clearPendingFields, + commitNewFields, + selectedFieldName, + setSelectedField, + modifiedFields, + stageModification, + clearModification, + deletedFieldNames, + toggleFieldDeleted, + clearModifications, + commitModifications, + hasUncommittedChanges, }), [ state, @@ -524,6 +771,24 @@ export function FormFillProvider({ providerMode, setProviderMode, forFileId, + mode, + setMode, + creationType, + pendingFields, + addPendingField, + updatePendingField, + removePendingField, + clearPendingFields, + commitNewFields, + selectedFieldName, + modifiedFields, + stageModification, + clearModification, + deletedFieldNames, + toggleFieldDeleted, + clearModifications, + commitModifications, + hasUncommittedChanges, ], ); diff --git a/frontend/editor/src/core/tools/formFill/SnapGuides.tsx b/frontend/editor/src/core/tools/formFill/SnapGuides.tsx new file mode 100644 index 0000000000..f539103580 --- /dev/null +++ b/frontend/editor/src/core/tools/formFill/SnapGuides.tsx @@ -0,0 +1,44 @@ +/** + * SnapGuides — renders the pink alignment guide lines shared by the create and + * edit overlays. Absolutely positioned within the page overlay. + */ +import React from "react"; +import type { SnapGuide } from "@app/tools/formFill/formSnapUtils"; +import { FORM_COLORS } from "@app/tools/formFill/formFieldColors"; + +const GUIDE_COLOR = FORM_COLORS.guide; + +export function SnapGuides({ guides }: { guides: SnapGuide[] }) { + return ( + <> + {guides.map((g, i) => ( +
+ ))} + + ); +} + +export default SnapGuides; diff --git a/frontend/editor/src/core/tools/formFill/formApi.ts b/frontend/editor/src/core/tools/formFill/formApi.ts index c678435035..de8a9673a4 100644 --- a/frontend/editor/src/core/tools/formFill/formApi.ts +++ b/frontend/editor/src/core/tools/formFill/formApi.ts @@ -2,7 +2,12 @@ * 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, + FieldEditBatch, +} from "@app/tools/formFill/types"; /** * Fetch form fields with coordinates from the backend. @@ -89,3 +94,93 @@ export async function extractFormFieldsXlsx( }); return response.data; } + +/** + * Create new form fields and get back the updated PDF blob. + * Calls POST /api/v1/form/add-fields + */ +export async function addFormFields( + file: File | Blob, + fields: NewFieldDefinition[], +): Promise { + 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 (rename, retype, reposition, resize, flags…) + * and get back the updated PDF blob. + * Calls POST /api/v1/form/modify-fields + */ +export async function modifyFormFields( + file: File | Blob, + updates: ModifyFieldDefinition[], +): Promise { + 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; +} + +/** + * Apply a combined batch of field edits (add + modify + delete) in a single + * request, returning the updated PDF blob. + * Calls POST /api/v1/form/edit-fields + */ +export async function applyFieldEdits( + file: File | Blob, + batch: FieldEditBatch, +): Promise { + const formData = new FormData(); + formData.append("file", file); + formData.append( + "edits", + new Blob([JSON.stringify(batch)], { type: "application/json" }), + ); + + const response = await apiClient.post("/api/v1/form/edit-fields", formData, { + responseType: "blob", + }); + return response.data; +} + +/** + * Delete form fields by name and get back the updated PDF blob. + * Calls POST /api/v1/form/delete-fields + */ +export async function deleteFormFields( + file: File | Blob, + names: string[], +): Promise { + const formData = new FormData(); + formData.append("file", file); + formData.append( + "names", + new Blob([JSON.stringify(names)], { type: "application/json" }), + ); + + const response = await apiClient.post( + "/api/v1/form/delete-fields", + formData, + { responseType: "blob" }, + ); + return response.data; +} diff --git a/frontend/editor/src/core/tools/formFill/formCoordinateUtils.ts b/frontend/editor/src/core/tools/formFill/formCoordinateUtils.ts new file mode 100644 index 0000000000..5296473c83 --- /dev/null +++ b/frontend/editor/src/core/tools/formFill/formCoordinateUtils.ts @@ -0,0 +1,120 @@ +/** + * Coordinate helpers for the form field editor. + * + * Three coordinate spaces are in play: + * + * - Pixel space: rendered
pixels inside a PDF page container, top-left + * origin, Y growing downward. This is what pointer events give us. + * - PDF point space (top-left origin): the space the backend already emits in + * WidgetCoordinates (CropBox-relative, Y already flipped). FormFieldOverlay + * renders these directly as `x * scaleX`, `y * scaleY`. + * - PDF point space (lower-left origin): native PDF user space, CropBox-relative. + * This is what /add-fields and /modify-fields expect; the backend adds the + * CropBox offset to recover absolute coordinates. + * + * `scaleX = pageWidthPx / pageWidthPts` and `scaleY = pageHeightPx / pageHeightPts`, + * computed exactly as FormFieldOverlay does, so a field placed at pixel (px, py) + * round-trips back to the same pixel after a save/reload cycle. `pageHeightPts` + * is the CropBox height in PDF points (EmbedPDF's `page.size.height`). + */ + +export interface PixelRect { + left: number; + top: number; + width: number; + height: number; +} + +/** CropBox-relative, lower-left-origin PDF points (backend create/modify space). */ +export interface PdfRect { + x: number; + y: number; + width: number; + height: number; +} + +/** Top-left-origin PDF points, as stored on a WidgetCoordinates. */ +export interface WidgetRect { + x: number; + y: number; + width: number; + height: number; +} + +/** Convert a widget's top-left-origin point rect to pixel space for rendering. */ +export function widgetRectToPixels( + widget: WidgetRect, + scaleX: number, + scaleY: number, +): PixelRect { + return { + left: widget.x * scaleX, + top: widget.y * scaleY, + width: widget.width * scaleX, + height: widget.height * scaleY, + }; +} + +/** + * Convert a pixel rect (top-left origin) to backend PDF coordinates + * (lower-left origin, CropBox-relative points). Inverse of + * {@link backendRectToPixels}. + */ +export function pixelsToBackendRect( + rect: PixelRect, + scaleX: number, + scaleY: number, + pageHeightPts: number, +): PdfRect { + const xPts = rect.left / scaleX; + const widthPts = rect.width / scaleX; + const heightPts = rect.height / scaleY; + const topPts = rect.top / scaleY; // distance from page top, in points + // Flip to lower-left origin: y is the distance from the page bottom to the + // field's bottom edge. + const yPts = pageHeightPts - topPts - heightPts; + return { x: xPts, y: yPts, width: widthPts, height: heightPts }; +} + +/** + * Convert backend PDF coordinates (lower-left origin, CropBox-relative points) + * to a pixel rect (top-left origin). Inverse of {@link pixelsToBackendRect}. + */ +export function backendRectToPixels( + rect: PdfRect, + scaleX: number, + scaleY: number, + pageHeightPts: number, +): PixelRect { + const topPts = pageHeightPts - rect.y - rect.height; + return { + left: rect.x * scaleX, + top: topPts * scaleY, + width: rect.width * scaleX, + height: rect.height * scaleY, + }; +} + +/** Clamp a pixel rect so it stays within the page bounds. */ +export function clampPixelRect( + rect: PixelRect, + pageWidthPx: number, + pageHeightPx: number, +): PixelRect { + const width = Math.min(rect.width, pageWidthPx); + const height = Math.min(rect.height, pageHeightPx); + const left = Math.max(0, Math.min(rect.left, pageWidthPx - width)); + const top = Math.max(0, Math.min(rect.top, pageHeightPx - height)); + return { left, top, width, height }; +} + +/** Round a PdfRect's components to a sane precision before sending to the API. */ +export function roundPdfRect(rect: PdfRect): PdfRect { + const r = (n: number) => Math.round(n * 100) / 100; + return { + x: r(rect.x), + y: r(rect.y), + width: r(rect.width), + height: r(rect.height), + }; +} diff --git a/frontend/editor/src/core/tools/formFill/formFieldColors.ts b/frontend/editor/src/core/tools/formFill/formFieldColors.ts new file mode 100644 index 0000000000..4e80413120 --- /dev/null +++ b/frontend/editor/src/core/tools/formFill/formFieldColors.ts @@ -0,0 +1,26 @@ +/** + * Restrained, professional palette for the form overlays. + * + * Deliberately NOT a per-type rainbow: existing fields read as neutral slate, + * the active/selected field and newly-drawn fields use a single blue accent, + * deletions are red. The field TYPE is conveyed by the small icon in the side + * panel, not by a saturated fill colour on the page. + */ +export const FORM_COLORS = { + /** Selected / active / newly-drawn fields. */ + accent: "#2563eb", + accentFillSoft: "rgba(37, 99, 235, 0.06)", + accentFill: "rgba(37, 99, 235, 0.10)", + + /** Existing (unselected) fields — quiet slate so the page stays readable. */ + neutralBorder: "rgba(71, 85, 105, 0.55)", + neutralFill: "rgba(71, 85, 105, 0.05)", + neutralChip: "#475569", + + /** Fields marked for deletion. */ + danger: "#dc2626", + dangerFill: "rgba(220, 38, 38, 0.08)", + + /** Alignment guides (thin lines, shown only while dragging). */ + guide: "#2563eb", +} as const; diff --git a/frontend/editor/src/core/tools/formFill/formFieldMerge.test.ts b/frontend/editor/src/core/tools/formFill/formFieldMerge.test.ts new file mode 100644 index 0000000000..7390952eb6 --- /dev/null +++ b/frontend/editor/src/core/tools/formFill/formFieldMerge.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest"; +import { mergeSignatureAppearances } from "@app/tools/formFill/formFieldMerge"; +import type { FormField } from "@app/tools/formFill/types"; + +function field(name: string, type: FormField["type"]): FormField { + return { + name, + label: name, + type, + value: "", + options: null, + displayOptions: null, + required: false, + readOnly: false, + multiSelect: false, + multiline: false, + tooltip: null, + widgets: [{ pageIndex: 0, x: 0, y: 0, width: 10, height: 10 }], + }; +} + +describe("mergeSignatureAppearances", () => { + it("does not duplicate a signature the backend already returned", () => { + // The backend now returns signature fields; the pdfium pass returns the + // same field with a rendered appearance. They must merge to ONE entry. + const backend = [field("FullName", "text"), field("Sign", "signature")]; + const sig = { + ...field("Sign", "signature"), + appearanceDataUrl: "data:img", + }; + + const merged = mergeSignatureAppearances(backend, [sig]); + + expect(merged).toHaveLength(2); + expect(merged.filter((f) => f.name === "Sign")).toHaveLength(1); + // …and the surviving entry is enriched with the rendered appearance. + expect(merged.find((f) => f.name === "Sign")?.appearanceDataUrl).toBe( + "data:img", + ); + }); + + it("appends a signature the backend did not return", () => { + const backend = [field("FullName", "text")]; + const sig = { ...field("Ghost", "signature"), appearanceDataUrl: "data:x" }; + + const merged = mergeSignatureAppearances(backend, [sig]); + + expect(merged).toHaveLength(2); + expect(merged.map((f) => f.name)).toEqual(["FullName", "Ghost"]); + }); + + it("returns the backend list unchanged when there are no signatures", () => { + const backend = [field("A", "text"), field("B", "checkbox")]; + expect(mergeSignatureAppearances(backend, [])).toBe(backend); + }); + + it("does not overwrite an existing appearance", () => { + const backend = [ + { ...field("Sign", "signature"), appearanceDataUrl: "keep" }, + ]; + const sig = { ...field("Sign", "signature"), appearanceDataUrl: "new" }; + const merged = mergeSignatureAppearances(backend, [sig]); + expect(merged.find((f) => f.name === "Sign")?.appearanceDataUrl).toBe( + "keep", + ); + }); +}); diff --git a/frontend/editor/src/core/tools/formFill/formFieldMerge.ts b/frontend/editor/src/core/tools/formFill/formFieldMerge.ts new file mode 100644 index 0000000000..f2e085264e --- /dev/null +++ b/frontend/editor/src/core/tools/formFill/formFieldMerge.ts @@ -0,0 +1,39 @@ +/** + * Merge helpers for assembling the form field list shown to the user. + * + * In pdfbox mode the backend already returns every field (including signature + * fields), but it cannot render a signature's visual appearance. The frontend + * separately rasterises signature appearances via PDFium. These must be merged + * BY NAME — enriching the existing backend entry with the rendered appearance — + * rather than concatenated, otherwise a signature appears twice in the list. + */ +import type { FormField } from "@app/tools/formFill/types"; + +/** + * Returns a new array where each pdfium-rendered signature field either enriches + * the matching backend field (by name) with its `appearanceDataUrl`, or is + * appended if the backend didn't return it. Never produces duplicates by name. + */ +export function mergeSignatureAppearances( + backendFields: FormField[], + signatureFields: FormField[], +): FormField[] { + if (signatureFields.length === 0) return backendFields; + + const merged = backendFields.map((f) => ({ ...f })); + const byName = new Map(merged.map((f) => [f.name, f])); + + for (const sig of signatureFields) { + const existing = byName.get(sig.name); + if (existing) { + if (sig.appearanceDataUrl && !existing.appearanceDataUrl) { + existing.appearanceDataUrl = sig.appearanceDataUrl; + } + } else { + merged.push({ ...sig }); + byName.set(sig.name, merged[merged.length - 1]); + } + } + + return merged; +} diff --git a/frontend/editor/src/core/tools/formFill/formFillEvents.ts b/frontend/editor/src/core/tools/formFill/formFillEvents.ts new file mode 100644 index 0000000000..5ddc40ac94 --- /dev/null +++ b/frontend/editor/src/core/tools/formFill/formFillEvents.ts @@ -0,0 +1,18 @@ +/** + * The custom event the form tools use to hand a freshly-produced PDF blob to + * the viewer (EmbedPdfViewer listens for it and reloads the file, preserving + * scroll/rotation). Centralised so the event name isn't duplicated as a string + * across the fill/create/modify flows. + */ +export const FORM_APPLY_EVENT = "formfill:apply"; + +export interface FormApplyDetail { + blob: Blob; +} + +/** Dispatch a produced PDF blob to the viewer for reload + refresh. */ +export function dispatchFormApply(blob: Blob): void { + window.dispatchEvent( + new CustomEvent(FORM_APPLY_EVENT, { detail: { blob } }), + ); +} diff --git a/frontend/editor/src/core/tools/formFill/formSnapUtils.ts b/frontend/editor/src/core/tools/formFill/formSnapUtils.ts new file mode 100644 index 0000000000..51aacb7388 --- /dev/null +++ b/frontend/editor/src/core/tools/formFill/formSnapUtils.ts @@ -0,0 +1,144 @@ +/** + * Lightweight alignment snapping for the form field editor. + * + * Everything here works in page pixel space (top-left origin), the same space + * the creation/edit overlays operate in. A dragged rectangle's edges and centre + * snap to the edges and centres of the other fields on the page when they come + * within a small threshold, and the matched lines are returned as guides for + * visual feedback. + */ +import type { PixelRect } from "@app/tools/formFill/formCoordinateUtils"; + +export const DEFAULT_SNAP_THRESHOLD = 6; + +export interface SnapGuide { + /** "v" = vertical line at x; "h" = horizontal line at y. */ + orientation: "v" | "h"; + /** Pixel offset of the line within the page. */ + position: number; +} + +export interface SnapTargets { + /** Candidate vertical lines (left/right/centre of other fields). */ + xs: number[]; + /** Candidate horizontal lines (top/bottom/middle of other fields). */ + ys: number[]; +} + +/** Build snap targets from the pixel rects of the other fields on a page. */ +export function collectSnapTargets(rects: PixelRect[]): SnapTargets { + const xs: number[] = []; + const ys: number[] = []; + for (const r of rects) { + xs.push(r.left, r.left + r.width, r.left + r.width / 2); + ys.push(r.top, r.top + r.height, r.top + r.height / 2); + } + return { xs, ys }; +} + +function nearest( + value: number, + targets: number[], + threshold: number, +): { snapped: number; delta: number } | null { + let best: { snapped: number; delta: number } | null = null; + for (const t of targets) { + const delta = t - value; + if ( + Math.abs(delta) <= threshold && + (!best || Math.abs(delta) < Math.abs(best.delta)) + ) { + best = { snapped: t, delta }; + } + } + return best; +} + +/** + * Snap a moving rectangle (size fixed). Returns the adjusted top-left plus the + * guide lines that were matched. + */ +export function snapMove( + rect: PixelRect, + targets: SnapTargets, + threshold = DEFAULT_SNAP_THRESHOLD, +): { left: number; top: number; guides: SnapGuide[] } { + const guides: SnapGuide[] = []; + let { left, top } = rect; + + // X axis: try left edge, right edge, then centre. + const xCandidates = [left, left + rect.width, left + rect.width / 2]; + let xSnap: { snapped: number; delta: number } | null = null; + for (const c of xCandidates) { + const hit = nearest(c, targets.xs, threshold); + if (hit && (!xSnap || Math.abs(hit.delta) < Math.abs(xSnap.delta))) + xSnap = hit; + } + if (xSnap) { + left += xSnap.delta; + guides.push({ orientation: "v", position: xSnap.snapped }); + } + + const yCandidates = [top, top + rect.height, top + rect.height / 2]; + let ySnap: { snapped: number; delta: number } | null = null; + for (const c of yCandidates) { + const hit = nearest(c, targets.ys, threshold); + if (hit && (!ySnap || Math.abs(hit.delta) < Math.abs(ySnap.delta))) + ySnap = hit; + } + if (ySnap) { + top += ySnap.delta; + guides.push({ orientation: "h", position: ySnap.snapped }); + } + + return { left, top, guides }; +} + +/** + * Snap a rectangle being resized. `edges` flags which sides are moving; only + * those edges snap, the opposite edges stay put. + */ +export function snapResize( + rect: PixelRect, + edges: { left?: boolean; right?: boolean; top?: boolean; bottom?: boolean }, + targets: SnapTargets, + threshold = DEFAULT_SNAP_THRESHOLD, +): { rect: PixelRect; guides: SnapGuide[] } { + const guides: SnapGuide[] = []; + let { left, top, width, height } = rect; + const right = left + width; + const bottom = top + height; + + if (edges.left) { + const hit = nearest(left, targets.xs, threshold); + if (hit) { + left = hit.snapped; + width = right - left; + guides.push({ orientation: "v", position: hit.snapped }); + } + } + if (edges.right) { + const hit = nearest(right, targets.xs, threshold); + if (hit) { + width = hit.snapped - left; + guides.push({ orientation: "v", position: hit.snapped }); + } + } + if (edges.top) { + const hit = nearest(top, targets.ys, threshold); + if (hit) { + top = hit.snapped; + height = bottom - top; + guides.push({ orientation: "h", position: hit.snapped }); + } + } + if (edges.bottom) { + const hit = nearest(bottom, targets.ys, threshold); + if (hit) { + height = hit.snapped - top; + guides.push({ orientation: "h", position: hit.snapped }); + } + } + + return { rect: { left, top, width, height }, guides }; +} diff --git a/frontend/editor/src/core/tools/formFill/types.ts b/frontend/editor/src/core/tools/formFill/types.ts index 3f263ff817..1627c277b2 100644 --- a/frontend/editor/src/core/tools/formFill/types.ts +++ b/frontend/editor/src/core/tools/formFill/types.ts @@ -13,6 +13,12 @@ export interface WidgetCoordinates { exportValue?: string; /** Font size in PDF points */ fontSize?: number; + /** + * CropBox height in PDF points for the page this widget sits on. Lets the + * editor reverse the backend's Y-flip when sending new/changed coordinates + * back for create/modify operations. + */ + cropBoxHeight?: number; } export interface FormField { @@ -67,6 +73,90 @@ export interface ButtonAction { submitFlags?: number; } +/** + * Field types that can be created/edited structurally through the editor. + */ +export type CreatableFieldType = + | "text" + | "checkbox" + | "combobox" + | "listbox" + | "radio" + | "button" + | "signature"; + +export const CREATABLE_FIELD_TYPES: CreatableFieldType[] = [ + "text", + "checkbox", + "combobox", + "listbox", + "radio", + "button", + "signature", +]; + +/** + * A new field queued for creation. Coordinates are CropBox-relative, + * lower-left-origin PDF points — the reverse of what the backend emits in + * WidgetCoordinates, ready to POST to /api/v1/form/add-fields. + */ +export interface NewFieldDefinition { + name: string; + label?: string; + type: CreatableFieldType; + pageIndex: number; + x: number; + y: number; + width: number; + height: number; + required?: boolean; + multiSelect?: boolean; + options?: string[]; + defaultValue?: string; + tooltip?: string; + fontSize?: number; + readOnly?: boolean; + multiline?: boolean; + maxLength?: number; // text only; >0 also makes it a comb field + /** Push-button activation action: "reset" | "print" | "uri:" | "submit:" */ + buttonAction?: string; +} + +/** + * A change to an existing field. Only non-undefined properties are applied. + * Coordinates (when present) are CropBox-relative, lower-left-origin PDF points. + */ +export interface ModifyFieldDefinition { + targetName: string; + name?: string; + label?: string; + type?: FormFieldType; + pageIndex?: number; + x?: number; + y?: number; + width?: number; + height?: number; + required?: boolean; + multiSelect?: boolean; + options?: string[]; + defaultValue?: string; + tooltip?: string; + fontSize?: number; + readOnly?: boolean; + multiline?: boolean; + maxLength?: number; // text only; >0 also makes it a comb field +} + +/** A batch of field edits committed in one request via /api/v1/form/edit-fields. */ +export interface FieldEditBatch { + add?: NewFieldDefinition[]; + modify?: ModifyFieldDefinition[]; + delete?: string[]; +} + +/** The form tool's working mode. */ +export type FormMode = "fill" | "create" | "modify"; + export interface FormFillState { /** Fields fetched from backend with coordinates */ fields: FormField[]; diff --git a/frontend/editor/src/core/tools/formFill/useFormCommit.ts b/frontend/editor/src/core/tools/formFill/useFormCommit.ts new file mode 100644 index 0000000000..4b01011f5a --- /dev/null +++ b/frontend/editor/src/core/tools/formFill/useFormCommit.ts @@ -0,0 +1,39 @@ +/** + * Shared commit boilerplate for the create and modify panels: run an async + * action that produces the edited PDF blob, hand it to the viewer, and track + * committing/error state. Keeps both panels from repeating the same + * try/catch/dispatch dance. + */ +import { useState, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { dispatchFormApply } from "@app/tools/formFill/formFillEvents"; + +export function useFormCommit(onApplied?: (blob: Blob) => void) { + const { t } = useTranslation(); + const [committing, setCommitting] = useState(false); + const [error, setError] = useState(null); + + const commit = useCallback( + async ( + run: () => Promise, + errorKey: string, + errorFallback: string, + ) => { + setCommitting(true); + setError(null); + try { + const blob = await run(); + dispatchFormApply(blob); + onApplied?.(blob); + } catch (err: any) { + setError(err?.message || t(errorKey, errorFallback)); + console.error("[FormFill] commit failed:", err); + } finally { + setCommitting(false); + } + }, + [onApplied, t], + ); + + return { committing, error, setError, commit }; +} diff --git a/frontend/editor/src/core/tools/formFill/usePageScale.ts b/frontend/editor/src/core/tools/formFill/usePageScale.ts new file mode 100644 index 0000000000..8dabbe7f53 --- /dev/null +++ b/frontend/editor/src/core/tools/formFill/usePageScale.ts @@ -0,0 +1,56 @@ +/** + * Shared page-scale helpers for the form overlays. + * + * Both the creation and edit overlays need the pixel<->PDF-point scale for a + * rendered page (and a pointer-to-local-pixel conversion). This centralises + * that so they stay in sync with FormFieldOverlay's basis and don't duplicate + * the documentState math. + */ +import { useMemo } from "react"; +import { useDocumentState } from "@embedpdf/core/react"; + +export interface PageScale { + scaleX: number; + scaleY: number; + /** CropBox height in PDF points; 0 until the page has rendered. */ + pageHeightPts: number; + /** CropBox width in PDF points; 0 until the page has rendered. */ + pageWidthPts: number; +} + +/** + * Pixel<->point scale for a page, derived from EmbedPDF's document state. + * `scaleX = pageWidthPx / pageWidthPts`. `pageWidthPts` is 0 until the page has + * rendered, so callers should guard on it before drawing. + */ +export function usePageScale( + documentId: string, + pageIndex: number, + pageWidth: number, + pageHeight: number, +): PageScale { + const documentState = useDocumentState(documentId); + return useMemo(() => { + const pdfPage = documentState?.document?.pages?.[pageIndex]; + if (!pdfPage?.size || !pageWidth || !pageHeight) { + const s = documentState?.scale ?? 1; + return { scaleX: s, scaleY: s, pageHeightPts: 0, pageWidthPts: 0 }; + } + return { + scaleX: pageWidth / pdfPage.size.width, + scaleY: pageHeight / pdfPage.size.height, + pageHeightPts: pdfPage.size.height, + pageWidthPts: pdfPage.size.width, + }; + }, [documentState, pageIndex, pageWidth, pageHeight]); +} + +/** Pointer position relative to an element's top-left, in CSS pixels. */ +export function getLocalPoint( + e: { clientX: number; clientY: number }, + el: HTMLElement | null, +): { x: number; y: number } { + const rect = el?.getBoundingClientRect(); + if (!rect) return { x: 0, y: 0 }; + return { x: e.clientX - rect.left, y: e.clientY - rect.top }; +}