Compare commits

...
Author SHA1 Message Date
Anthony Stirling 95ae588283 Merge remote-tracking branch 'origin/main' into pr6655
# Conflicts:
#	frontend/editor/public/locales/en-US/translation.toml
#	frontend/editor/src/core/tools/formFill/FormFill.tsx
2026-07-14 12:30:45 +01:00
Frooodle c1c0cd4ca6 merge main 2026-07-06 19:01:43 +01:00
Frooodle 57c93fe0cd merge main 2026-07-02 09:52:30 +01:00
Frooodle ccdf677141 merge main 2026-07-01 08:53:33 +01:00
Frooodle 7cd3228eba merge main 2026-06-30 08:48:37 +01:00
Frooodle f4b316f5cd merge main 2026-06-29 11:35:28 +01:00
Frooodle 20ca9ed3f4 merge main 2026-06-27 08:55:18 +01:00
Frooodle c886650840 merge main 2026-06-25 14:04:39 +01:00
Frooodle 99ef3c61e5 merge main 2026-06-25 10:00:55 +01:00
Frooodle 3c693fc049 merge main 2026-06-24 11:55:58 +01:00
Frooodle c10a5b9aa5 sort translation toml 2026-06-23 22:51:58 +01:00
Frooodle f41bf2d83c fix merge integration 2026-06-23 22:14:35 +01:00
Frooodle c18b9a1da0 merge main 2026-06-23 21:54:51 +01:00
Anthony Stirling 6ae294f909 fix: refine form editor colours, dragging and mode-tab contrast 2026-06-14 16:51:34 +01:00
Anthony Stirling 8a282c0d30 fix: derive choice field label from field name, not first option 2026-06-14 16:51:25 +01:00
Anthony Stirling 16c39c187d refactor: dedupe form-editor overlays and panels into shared hooks/components 2026-06-13 18:03:13 +01:00
Anthony Stirling 62684ba706 explain signature field is a placeholder filled by signers, not signed in-app 2026-06-13 13:27:10 +01:00
Anthony Stirling 6ba69b284a rename form tool to "Form Editor" to reflect create/edit/delete scope 2026-06-13 12:51:34 +01:00
Anthony Stirling 80b141e5f5 feat: form field editor - create/modify/delete with radio, button, signature, comb types 2026-06-13 12:51:34 +01:00
Anthony Stirling 0cdd6776ee Merge denysvitali's form field editing work (PR #5777) 2026-06-13 12:51:33 +01:00
Denys VitaliandClaude Sonnet 4.6 8fca517b9c Fix form field creation errors in PDF
- Fix null pointer exception when page annotations list is null
- Explicitly set /P entry in widget COS dictionary for proper page
  reference persistence, fixing "Could not find page for widget" warnings
- Apply code formatting (spotless)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 19:35:28 +01:00
Denys VitaliandClaude Sonnet 4.6 a4c3beebdf Add comprehensive form field editing capabilities
- Add form field creation overlay with visual drag-to-create
- Add form field modification panel for editing existing fields
- Add property editor for field attributes (name, value, formatting)
- Implement snap-to-grid and alignment utilities
- Fix coordinate system mapping between PDF and display
- Update FormFillContext for field CRUD operations
- Add support for text, checkbox, radio, and combo box fields
- Improve field positioning accuracy across save/load cycles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 16:28:19 +01:00
38 changed files with 4710 additions and 120 deletions
@@ -94,5 +94,12 @@ public class FormFieldWithCoordinates {
@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;
}
}
@@ -11,6 +11,10 @@ import java.util.stream.Collectors;
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 @@ public enum FormFieldTypeSupport {
List<String> options)
throws IOException {
PDTextField textField = (PDTextField) field;
if (definition.fontSize() != null && definition.fontSize() > 0) {
textField.setDefaultAppearance("/Helv " + definition.fontSize() + " Tf 0 g");
}
if (Boolean.TRUE.equals(definition.multiline())) {
textField.setMultiline(true);
}
// 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 @@ public enum FormFieldTypeSupport {
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<String> 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<String, FormFieldTypeSupport> BY_TYPE =
@@ -460,6 +460,7 @@ public class FormUtils {
.height(finalH)
.exportValue(exportValue)
.fontSize(extractFontSize(field))
.cropBoxHeight(cropHeight)
.build();
}
@@ -1463,16 +1464,29 @@ public class FormUtils {
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 @@ public class FormUtils {
|| 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 class FormUtils {
if (!typeChanging) {
try {
modifyFieldPropertiesInPlace(originalField, modification, desiredName);
modifyFieldPropertiesInPlace(
document, originalField, modification, desiredName);
log.debug("Successfully modified field '{}' in-place", lookupName);
continue; // Skip the remove-and-recreate process
} catch (Exception e) {
@@ -1625,7 +1663,12 @@ public class FormUtils {
modification.multiSelect(),
modification.options(),
modification.defaultValue(),
modification.tooltip());
modification.tooltip(),
modification.fontSize(),
modification.readOnly(),
modification.multiline(),
modification.maxLength(),
null);
List<String> sanitizedOptions = sanitizeOptions(modification.options());
@@ -1665,7 +1708,10 @@ public class FormUtils {
}
private void modifyFieldPropertiesInPlace(
PDField field, ModifyFormFieldDefinition modification, String newName)
PDDocument document,
PDField field,
ModifyFormFieldDefinition modification,
String newName)
throws IOException {
if (newName != null && !newName.equals(field.getPartialName())) {
field.setPartialName(newName);
@@ -1715,6 +1761,124 @@ public class FormUtils {
}
}
}
// 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<PDAnnotationWidget> 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<PDAnnotationWidget> 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 @@ public class FormUtils {
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<NewFormFieldDefinition> definitions)
throws IOException {
if (document == null || definitions == null || definitions.isEmpty()) return;
PDAcroForm acroForm = getAcroFormSafely(document);
if (acroForm == null) {
// Create a new AcroForm for PDFs that don't have one yet
acroForm = new PDAcroForm(document);
document.getDocumentCatalog().setAcroForm(acroForm);
}
Set<String> existingNames = collectExistingFieldNames(acroForm);
int pageCount = document.getNumberOfPages();
for (NewFormFieldDefinition definition : definitions) {
if (definition == null) continue;
String resolvedType =
Optional.ofNullable(definition.type())
.map(FormUtils::normalizeFieldType)
.orElse(FIELD_TYPE_TEXT);
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<String> 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<String> options)
throws IOException {
List<String> 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<PDAnnotationWidget> widgets = new ArrayList<>();
List<String> exportValues = new ArrayList<>();
Set<String> 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<PDAnnotation> 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<String> 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<NewFormFieldDefinition> adds,
List<ModifyFormFieldDefinition> modifies,
List<String> 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<String> fieldNames) {
if (document == null || fieldNames == null || fieldNames.isEmpty()) return;
@@ -2247,9 +2625,33 @@ public class FormUtils {
}
}
field.setRequired(Boolean.TRUE.equals(definition.required()));
if (Boolean.TRUE.equals(definition.readOnly())) {
field.setReadOnly(true);
}
PDAnnotationWidget widget =
existingWidget != null ? existingWidget : new PDAnnotationWidget();
// 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<PDAnnotationWidget> 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 @@ public class FormUtils {
}
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 @@ public class FormUtils {
}
}
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<PDAnnotationWidget> widgets = new ArrayList<>(field.getWidgets());
if (!widgets.contains(widget)) {
widgets.add(widget);
field.setWidgets(widgets);
}
widget.setParent(field);
}
List<PDAnnotation> 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 class FormUtils {
Boolean multiSelect,
List<String> 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 class FormUtils {
String name,
String label,
String type,
Integer pageIndex,
Float x,
Float y,
Float width,
Float height,
Boolean required,
Boolean multiSelect,
List<String> options,
String defaultValue,
String tooltip) {}
String tooltip,
Float fontSize,
Boolean readOnly,
Boolean multiline,
Integer maxLength) {}
/** A mixed batch of field edits applied in one request via {@link #applyFieldEdits}. */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record FieldEditBatch(
List<NewFormFieldDefinition> add,
List<ModifyFormFieldDefinition> modify,
List<String> delete) {}
@JsonInclude(JsonInclude.Include.NON_NULL)
public record FormFieldInfo(
@@ -130,13 +130,15 @@ class FormFieldTypeSupportTest {
}
@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
@@ -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.
*
* <p>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<String> 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");
}
}
}
@@ -705,10 +705,19 @@ class FormUtilsGapTest {
"newName",
"New Label",
null, // keep type (text) -> in-place path
null,
null,
null,
null,
null,
Boolean.TRUE,
null,
null,
null,
null,
null,
null,
null,
null);
FormUtils.modifyFormFields(doc, List.of(mod));
@@ -731,7 +740,8 @@ class FormUtilsGapTest {
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"missing", null, null, null, null, null, null, null, null);
"missing", null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null);
FormUtils.modifyFormFields(doc, List.of(mod));
@@ -754,7 +764,8 @@ class FormUtilsGapTest {
mods.add(null);
mods.add(
new FormUtils.ModifyFormFieldDefinition(
" ", null, null, null, null, null, null, null, null));
" ", null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null));
FormUtils.modifyFormFields(doc, mods);
assertEquals(1, FormUtils.extractFormFields(doc).size());
@@ -476,8 +476,17 @@ class FormUtilsMoreTest {
"combobox",
null,
null,
null,
null,
null,
null,
null,
List.of("One", "Two"),
"One",
null,
null,
null,
null,
null);
FormUtils.modifyFormFields(doc, List.of(mod));
@@ -505,10 +514,19 @@ class FormUtilsMoreTest {
null,
"listbox", // same type -> in-place path
null,
null,
null,
null,
null,
null,
Boolean.TRUE,
List.of("X", "Y", "Z"),
null,
"Choose items");
"Choose items",
null,
null,
null,
null);
FormUtils.modifyFormFields(doc, List.of(mod));
@@ -529,7 +547,24 @@ class FormUtilsMoreTest {
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"keep", null, null, "bogusType", null, null, null, null, null);
"keep",
null,
null,
"bogusType",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null);
FormUtils.modifyFormFields(doc, List.of(mod));
// The field is preserved unchanged because the target type is unsupported.
@@ -554,7 +589,8 @@ class FormUtilsMoreTest {
// Rename beta -> alpha; should be uniquified to avoid the collision.
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"beta", "alpha", null, null, null, null, null, null, null);
"beta", "alpha", null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null);
FormUtils.modifyFormFields(doc, List.of(mod));
@@ -575,7 +611,8 @@ class FormUtilsMoreTest {
doc.addPage(new PDPage());
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"x", null, null, null, null, null, null, null, null);
"x", null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null);
FormUtils.modifyFormFields(doc, List.of(mod));
}
}
@@ -257,6 +257,87 @@ public class FormFillController {
}
}
@PostMapping(value = "/add-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Add new form fields",
description =
"Creates new form fields in the provided PDF and returns the updated file")
public ResponseEntity<Resource> addFields(
@Parameter(
description = "The input PDF file",
required = true,
content =
@Content(
mediaType = MediaType.APPLICATION_PDF_VALUE,
schema = @Schema(type = "string", format = "binary")))
@RequestParam("file")
MultipartFile file,
@Parameter(
description = "JSON array of new field definitions",
example =
"[{\"name\":\"NewField\",\"type\":\"text\",\"pageIndex\":0,"
+ "\"x\":50,\"y\":700,\"width\":200,\"height\":20}]")
@RequestPart(value = "fields", required = false)
byte[] fieldsPayload)
throws IOException {
String rawFields = decodePart(fieldsPayload);
List<FormUtils.NewFormFieldDefinition> definitions =
FormPayloadParser.parseNewFieldDefinitions(objectMapper, rawFields);
if (definitions.isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.dataRequired",
"{0} must contain at least one definition",
"fields payload");
}
return processSingleFile(
file, "updated", document -> FormUtils.addNewFields(document, definitions));
}
@PostMapping(value = "/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<Resource> 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",
@@ -28,6 +28,8 @@ final class FormPayloadParser {
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {};
private static final TypeReference<List<FormUtils.ModifyFormFieldDefinition>>
MODIFY_FIELD_LIST_TYPE = new TypeReference<>() {};
private static final TypeReference<List<FormUtils.NewFormFieldDefinition>> NEW_FIELD_LIST_TYPE =
new TypeReference<>() {};
private static final TypeReference<List<String>> STRING_LIST_TYPE = new TypeReference<>() {};
private FormPayloadParser() {}
@@ -94,6 +96,43 @@ final class FormPayloadParser {
return objectMapper.readValue(json, MODIFY_FIELD_LIST_TYPE);
}
static List<FormUtils.NewFormFieldDefinition> 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<FormUtils.NewFormFieldDefinition> adds = List.of();
List<FormUtils.ModifyFormFieldDefinition> modifies = List.of();
List<String> 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<String> parseNameList(ObjectMapper objectMapper, String json) {
if (json == null || json.isBlank()) {
return List.of();
@@ -330,6 +330,85 @@ class FormFillControllerTest {
}
}
// ── 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<Resource> 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<Resource> response = controller.editFields(file, json.getBytes());
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
}
}
// ── buildBaseName ──────────────────────────────────────────────────
@Nested
@@ -170,6 +170,94 @@ class FormPayloadParserTest {
}
}
// ── parseNewFieldDefinitions ───────────────────────────────────────
@Nested
@DisplayName("parseNewFieldDefinitions")
class ParseNewFieldDefinitions {
@Test
@DisplayName("returns empty list for null input")
void nullInput() {
List<FormUtils.NewFormFieldDefinition> result =
FormPayloadParser.parseNewFieldDefinitions(objectMapper, null);
assertThat(result).isEmpty();
}
@Test
@DisplayName("returns empty list for blank input")
void blankInput() {
List<FormUtils.NewFormFieldDefinition> 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<FormUtils.NewFormFieldDefinition> 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
@@ -4087,6 +4087,7 @@ allSaved = "All saved"
extractCsvError = "Failed to extract CSV"
extractXlsxError = "Failed to extract XLSX"
flattenAfterFilling = "Flatten after filling"
page = "Page"
requiredAbbreviation = "req"
requiredFieldsError = "Please fill in all required fields"
rescanFields = "Re-scan fields"
@@ -4095,6 +4096,55 @@ save = "Save"
saveShortcut = "Ctrl+S to save"
unsavedChanges = "Unsaved changes"
[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)"
multiline = "Multi-line"
multiSelect = "Allow multiple selection"
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"
@@ -4416,8 +4466,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"
@@ -10875,7 +10925,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"
+2 -2
View File
@@ -262,8 +262,8 @@
},
"formFill": {
"image": "/og_images/form-fill.png",
"title": "Fill Form - Stirling PDF",
"description": "Fill PDF form fields interactively with a visual editor"
"title": "Form Editor - Stirling PDF",
"description": "Fill, create, edit, and delete PDF form fields with a visual editor"
},
"multiTool": {
"image": "/og_images/multi-tool.png",
@@ -46,6 +46,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";
import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
import { alert } from "@app/components/toast";
@@ -868,8 +869,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
@@ -101,6 +101,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";
@@ -1110,6 +1112,28 @@ export function LocalEmbedPDF({
/>
)}
{/* Create-mode: drag to place new fields */}
{enableFormFill && (
<FormFieldCreationOverlay
documentId={documentId}
pageIndex={pageIndex}
pageWidth={width}
pageHeight={height}
fileId={fileId}
/>
)}
{/* Modify-mode: select / move / resize existing fields */}
{enableFormFill && (
<FormFieldEditOverlay
documentId={documentId}
pageIndex={pageIndex}
pageWidth={width}
pageHeight={height}
fileId={fileId}
/>
)}
{/* SignatureFieldOverlay — bitmaps of digital-signature appearances */}
{file && (
<SignatureFieldOverlay
@@ -116,7 +116,7 @@ export function useViewerWorkbenchBarButtons(
const layersLabel = t("workbenchBar.toggleLayers", "Toggle Layers");
const commentsLabel = t("workbenchBar.toggleComments", "Comments");
const annotationsLabel = t("workbenchBar.annotations", "Annotations");
const formFillLabel = t("workbenchBar.formFill", "Fill Form");
const formFillLabel = t("workbenchBar.formFill", "Form Editor");
const rulerLabel = t("workbenchBar.ruler", "Ruler / Measure");
const readAloudLabel = t("workbenchBar.readAloud", "Read Aloud");
const readAloudSpeedLabel = t("workbenchBar.readAloudSpeed", "Speed");
@@ -451,11 +451,11 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
height="1.5rem"
/>
),
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,
@@ -463,7 +463,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: <LocalIcon icon="lock-outline" width="1.5rem" height="1.5rem" />,
@@ -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);
});
});
@@ -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<string, string> = {};
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");
});
});
@@ -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<CreatableFieldType, string> = {
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<string | null>(null);
const { committing, error, commit } = useFormCommit(onApplied);
// Auto-expand the property editor of a freshly-drawn field so its settings
// (especially options for choice/radio) are visible immediately.
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 (
<div className={styles.header}>
<Stack gap="xs">
<Text size="xs" c="dimmed">
{t(
"formFill.create.hint",
"Pick a field type, then draw it on the page.",
)}
</Text>
{/* Type palette */}
<Group gap={6} wrap="wrap">
{CREATABLE_FIELD_TYPES.map((type) => {
const armed = creationType === type;
return (
<Button
key={type}
size="xs"
variant={armed ? "filled" : "default"}
leftSection={FIELD_TYPE_ICON[type]}
onClick={() => setCreationType(armed ? null : type)}
data-testid={`form-create-type-${type}`}
>
{TYPE_LABEL[type]}
</Button>
);
})}
</Group>
{creationType && (
<Alert color="blue" variant="light" p="xs" radius="sm">
<Text size="xs">
{t(
"formFill.create.placing",
"Draw a {{type}} field on the page. Press Esc to stop.",
{ type: TYPE_LABEL[creationType] },
)}
</Text>
</Alert>
)}
{error && (
<Alert
icon={<WarningAmberIcon sx={{ fontSize: 16 }} />}
color="red"
variant="light"
p="xs"
radius="sm"
>
<Text size="xs">{error}</Text>
</Alert>
)}
{/* Queued fields */}
{pendingFields.length === 0 ? (
<Text size="xs" c="dimmed" ta="center" py="md">
{t("formFill.create.empty", "No fields drawn yet.")}
</Text>
) : (
<Stack gap={6}>
{pendingFields.map((pf) => {
const expanded = expandedId === pf.id;
return (
<Paper key={pf.id} withBorder p={6} radius="sm">
<Group gap={6} wrap="nowrap" justify="space-between">
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
<span
style={{
color: `var(--mantine-color-${FIELD_TYPE_COLOR[pf.type]}-6)`,
display: "flex",
}}
>
{FIELD_TYPE_ICON[pf.type]}
</span>
<Text size="xs" truncate>
{pf.name}
</Text>
<Text size="xs" c="dimmed">
p{pf.pageIndex + 1}
</Text>
</Group>
<Group gap={2} wrap="nowrap">
<ActionIcon
size="sm"
variant="subtle"
aria-label={t(
"formFill.create.editField",
"Edit field",
)}
onClick={() => setExpandedId(expanded ? null : pf.id)}
data-testid={`form-pending-edit-${pf.id}`}
>
<EditIcon sx={{ fontSize: 16 }} />
</ActionIcon>
<ActionIcon
size="sm"
variant="subtle"
color="red"
aria-label={t(
"formFill.create.removeField",
"Remove field",
)}
onClick={() => removePendingField(pf.id)}
data-testid={`form-pending-remove-${pf.id}`}
>
<DeleteOutlineIcon sx={{ fontSize: 16 }} />
</ActionIcon>
</Group>
</Group>
<Collapse in={expanded}>
<div style={{ marginTop: 8 }}>
<FormFieldPropertyEditor
value={pf}
onChange={(patch) =>
updatePendingField(
pf.id,
patch as Partial<NewFieldDefinition>,
)
}
showName
/>
</div>
</Collapse>
</Paper>
);
})}
</Stack>
)}
<Button
size="xs"
onClick={handleCommit}
loading={committing}
disabled={!currentFile || pendingFields.length === 0}
data-testid="form-create-commit"
>
{t("formFill.create.commit", "Add {{count}} field(s) to PDF", {
count: pendingFields.length,
})}
</Button>
</Stack>
</div>
);
}
export default FormFieldCreatePanel;
@@ -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<CreatableFieldType, { w: number; h: number }> = {
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<HTMLDivElement>(null);
const [dragRect, setDragRect] = useState<PixelRect | null>(null);
const [guides, setGuides] = useState<SnapGuide[]>([]);
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
const { scaleX, scaleY, pageHeightPts, pageWidthPts } = usePageScale(
documentId,
pageIndex,
pageWidth,
pageHeight,
);
// Pixel rects of the OTHER fields on this page, used as snap targets.
const snapRects = useMemo<PixelRect[]>(() => {
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 (
<div
ref={rootRef}
data-testid={`form-create-overlay-${pageIndex}`}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={finishDrag}
style={{
position: "absolute",
inset: 0,
pointerEvents: active ? "auto" : "none",
cursor: active ? "crosshair" : "default",
userSelect: "none",
WebkitUserSelect: "none",
zIndex: 5,
}}
>
{/* Already-queued fields on this page */}
{pendingOnPage.map((pf) => {
const r = backendRectToPixels(pf, scaleX, scaleY, pageHeightPts);
return (
<div
key={pf.id}
style={{
position: "absolute",
left: r.left,
top: r.top,
width: r.width,
height: r.height,
border: `1.5px dashed ${FORM_COLORS.accent}`,
background: FORM_COLORS.accentFill,
borderRadius: 2,
pointerEvents: "none",
boxSizing: "border-box",
}}
>
<span
style={{
position: "absolute",
top: -16,
left: 0,
fontSize: 10,
lineHeight: "14px",
padding: "0 4px",
background: FORM_COLORS.accent,
color: "#fff",
borderRadius: 2,
whiteSpace: "nowrap",
}}
>
{pf.name}
</span>
</div>
);
})}
{/* Live drag preview */}
{dragRect && creationType && (
<div
style={{
position: "absolute",
left: dragRect.left,
top: dragRect.top,
width: dragRect.width,
height: dragRect.height,
border: `2px dashed ${FORM_COLORS.accent}`,
background: FORM_COLORS.accentFill,
pointerEvents: "none",
boxSizing: "border-box",
}}
/>
)}
{/* Alignment guides */}
<SnapGuides guides={guides} />
</div>
);
}
export default FormFieldCreationOverlay;
@@ -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<HandleId, { x: number; y: number }> = {
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<HTMLDivElement>(null);
const interactionRef = useRef<Interaction | null>(null);
const [liveRect, setLiveRect] = useState<PixelRect | null>(null);
const [guides, setGuides] = useState<SnapGuide[]>([]);
const { scaleX, scaleY, pageHeightPts, pageWidthPts } = 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<PixelRect[]>(() => {
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 (
<div
ref={rootRef}
data-testid={`form-edit-overlay-${pageIndex}`}
onPointerDown={(e) => {
// 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 (
<div
key={field.name}
data-testid={`form-edit-field-${field.name}`}
onPointerDown={(e) => {
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,
}}
>
<span
style={{
position: "absolute",
top: -16,
left: 0,
fontSize: 10,
lineHeight: "14px",
padding: "0 4px",
background: isDeleted
? FORM_COLORS.danger
: isSelected
? FORM_COLORS.accent
: FORM_COLORS.neutralChip,
color: "#fff",
borderRadius: 2,
whiteSpace: "nowrap",
opacity: isSelected || isDeleted ? 1 : 0.75,
}}
>
{field.label || field.name}
</span>
</div>
);
})}
{/* Resize handles for the selected single-widget field */}
{selectedRect &&
selectedSingleWidget &&
!deletedFieldNames.includes(selectedFieldName ?? "") &&
HANDLES.map((h) => {
const pos = handlePosition(h.id, selectedRect);
return (
<div
key={h.id}
data-testid={`form-edit-handle-${h.id}`}
onPointerDown={(e) =>
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 */}
<SnapGuides guides={guides} />
</div>
);
}
export default FormFieldEditOverlay;
@@ -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<HTMLDivElement>(null);
// Group fields by their first widget's page.
const { sortedPages, fieldsByPage } = useMemo(() => {
const byPage = new Map<number, FormField[]>();
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 (
<div className={styles.root}>
<div className={styles.header}>
<Text size="xs" c="dimmed">
{t(
"formFill.modify.hint",
"Select a field to edit its properties, drag it on the page, or delete it.",
)}
</Text>
{error && (
<Alert
icon={<WarningAmberIcon sx={{ fontSize: 16 }} />}
color="red"
variant="light"
p="xs"
radius="sm"
>
<Text size="xs">{error}</Text>
</Alert>
)}
<Button
size="xs"
onClick={handleCommit}
loading={committing}
disabled={!currentFile || !hasUncommittedChanges}
data-testid="form-modify-commit"
>
{t("formFill.modify.commit", "Save {{count}} change(s)", {
count: changeCount,
})}
</Button>
{state.fields.length === 0 && !state.loading && (
<Text size="xs" c="dimmed" ta="center" py="md">
{t("formFill.modify.empty", "This PDF has no form fields yet.")}
</Text>
)}
</div>
<ScrollArea className={styles.fieldList}>
<div className={styles.fieldListInner}>
{sortedPages.map((pageIdx, i) => (
<React.Fragment key={pageIdx}>
<div
className={styles.pageDivider}
style={i === 0 ? { marginTop: 0 } : undefined}
>
<Text className={styles.pageDividerLabel}>
{t("formFill.page", "Page")} {pageIdx + 1}
</Text>
</div>
{fieldsByPage.get(pageIdx)!.map((field) => {
const selected = selectedFieldName === field.name;
const deleted = deletedFieldNames.includes(field.name);
const coords = currentCoords(field, modifiedFields[field.name]);
return (
<Paper
key={field.name}
ref={selected ? selectedRowRef : undefined}
withBorder
p={6}
radius="sm"
style={{
cursor: "pointer",
borderColor: selected
? "var(--mantine-color-blue-5)"
: undefined,
opacity: deleted ? 0.55 : 1,
}}
onClick={() =>
setSelectedField(selected ? null : field.name)
}
data-testid={`form-modify-row-${field.name}`}
>
<Group gap={6} wrap="nowrap" justify="space-between">
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
<span
style={{
color: `var(--mantine-color-${FIELD_TYPE_COLOR[field.type]}-6)`,
display: "flex",
}}
>
{FIELD_TYPE_ICON[field.type]}
</span>
<Text
size="xs"
truncate
td={deleted ? "line-through" : undefined}
>
{field.label || field.name}
</Text>
</Group>
<Tooltip
label={
deleted
? t("formFill.modify.restore", "Restore")
: t("formFill.modify.delete", "Delete")
}
withArrow
>
<ActionIcon
size="sm"
variant="subtle"
color={deleted ? "blue" : "red"}
aria-label={
deleted
? t("formFill.modify.restore", "Restore")
: t("formFill.modify.delete", "Delete")
}
onClick={(e) => {
e.stopPropagation();
toggleFieldDeleted(field.name);
}}
data-testid={`form-modify-delete-${field.name}`}
>
{deleted ? (
<RestoreIcon sx={{ fontSize: 16 }} />
) : (
<DeleteOutlineIcon sx={{ fontSize: 16 }} />
)}
</ActionIcon>
</Tooltip>
</Group>
<Collapse in={selected && !deleted}>
<div
style={{ marginTop: 8 }}
onClick={(e) => e.stopPropagation()}
>
<FormFieldPropertyEditor
value={editorValue(field)}
onChange={(patch) =>
stageModification(
field.name,
patch as Partial<ModifyFieldDefinition>,
)
}
showName
allowTypeChange
/>
{coords && (
<Group gap={6} mt="xs" grow>
<NumberInput
size="xs"
label="X"
value={Math.round(coords.x)}
onChange={(v) =>
typeof v === "number" &&
stageModification(field.name, {
pageIndex: pageIdx,
x: v,
y: coords.y,
width: coords.width,
height: coords.height,
})
}
/>
<NumberInput
size="xs"
label="Y"
value={Math.round(coords.y)}
onChange={(v) =>
typeof v === "number" &&
stageModification(field.name, {
pageIndex: pageIdx,
x: coords.x,
y: v,
width: coords.width,
height: coords.height,
})
}
/>
<NumberInput
size="xs"
label="W"
value={Math.round(coords.width)}
min={1}
onChange={(v) =>
typeof v === "number" &&
stageModification(field.name, {
pageIndex: pageIdx,
x: coords.x,
y: coords.y,
width: v,
height: coords.height,
})
}
/>
<NumberInput
size="xs"
label="H"
value={Math.round(coords.height)}
min={1}
onChange={(v) =>
typeof v === "number" &&
stageModification(field.name, {
pageIndex: pageIdx,
x: coords.x,
y: coords.y,
width: coords.width,
height: v,
})
}
/>
</Group>
)}
</div>
</Collapse>
</Paper>
);
})}
</React.Fragment>
))}
</div>
</ScrollArea>
</div>
);
}
export default FormFieldModifyPanel;
@@ -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<EditableFieldProps>) => void;
/** Show the field-name input (create mode). */
showName?: boolean;
/** Allow changing the field type (modify mode). */
allowTypeChange?: boolean;
}
const TYPE_LABEL: Record<string, string> = {
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 (
<Stack gap="xs">
{isSignature && (
<Alert
color="blue"
variant="light"
p="xs"
radius="sm"
icon={<InfoOutlinedIcon sx={{ fontSize: 16 }} />}
>
<Text size="xs">
{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.",
)}
</Text>
</Alert>
)}
{showName && (
<TextInput
size="xs"
label={t("formFill.editor.name", "Field name")}
value={value.name ?? ""}
onChange={(e) => onChange({ name: e.currentTarget.value })}
/>
)}
<TextInput
size="xs"
label={
isButton
? t("formFill.editor.caption", "Button caption")
: t("formFill.editor.label", "Label")
}
value={value.label ?? ""}
onChange={(e) => onChange({ label: e.currentTarget.value })}
/>
{allowTypeChange && (
<Select
size="xs"
label={t("formFill.editor.type", "Type")}
value={canRetype ? value.type : null}
data={TYPE_CHANGE_OPTIONS.map((tp) => ({
value: tp,
label: TYPE_LABEL[tp],
}))}
disabled={!canRetype}
placeholder={canRetype ? undefined : TYPE_LABEL[value.type]}
onChange={(v) => v && onChange({ type: v })}
comboboxProps={{ withinPortal: true }}
/>
)}
{hasOptions && (
<Stack gap={4}>
<Text size="xs" fw={600}>
{t("formFill.editor.options", "Options")}
</Text>
{(value.options ?? []).length === 0 && (
<Text size="xs" c="dimmed">
{t("formFill.editor.optionsEmpty", "Add at least one option.")}
</Text>
)}
{(value.options ?? []).map((opt, i) => (
<Group key={i} gap={4} wrap="nowrap">
<TextInput
size="xs"
style={{ flex: 1 }}
value={opt}
placeholder={t(
"formFill.editor.optionPlaceholder",
"Option {{n}}",
{
n: i + 1,
},
)}
onChange={(e) => updateOption(i, e.currentTarget.value)}
/>
<ActionIcon
size="sm"
variant="subtle"
color="red"
aria-label={t("formFill.editor.removeOption", "Remove option")}
onClick={() => removeOption(i)}
>
<DeleteOutlineIcon sx={{ fontSize: 16 }} />
</ActionIcon>
</Group>
))}
<Button
size="compact-xs"
variant="light"
leftSection={<AddIcon sx={{ fontSize: 14 }} />}
onClick={addOption}
>
{t("formFill.editor.addOption", "Add option")}
</Button>
</Stack>
)}
{isFillable && (
<TextInput
size="xs"
label={t("formFill.editor.defaultValue", "Default value")}
value={value.defaultValue ?? ""}
onChange={(e) => onChange({ defaultValue: e.currentTarget.value })}
/>
)}
<TextInput
size="xs"
label={t("formFill.editor.tooltip", "Tooltip")}
value={value.tooltip ?? ""}
onChange={(e) => onChange({ tooltip: e.currentTarget.value })}
/>
{isButton && (
<>
<Select
size="xs"
label={t("formFill.editor.action", "Button action")}
value={action.kind}
data={[
{ value: "none", label: t("formFill.editor.actionNone", "None") },
{
value: "reset",
label: t("formFill.editor.actionReset", "Reset form"),
},
{
value: "print",
label: t("formFill.editor.actionPrint", "Print"),
},
{
value: "uri",
label: t("formFill.editor.actionUri", "Open URL"),
},
{
value: "submit",
label: t("formFill.editor.actionSubmit", "Submit to URL"),
},
]}
onChange={(v) =>
onChange({
buttonAction: buildButtonAction(v ?? "none", action.url),
})
}
comboboxProps={{ withinPortal: true }}
/>
{(action.kind === "uri" || action.kind === "submit") && (
<TextInput
size="xs"
label={t("formFill.editor.actionUrl", "URL")}
value={action.url}
onChange={(e) =>
onChange({
buttonAction: buildButtonAction(
action.kind,
e.currentTarget.value,
),
})
}
/>
)}
</>
)}
{isVariableText && (
<NumberInput
size="xs"
label={t("formFill.editor.fontSize", "Font size")}
value={value.fontSize ?? ""}
min={1}
max={144}
onChange={(v) =>
onChange({ fontSize: typeof v === "number" ? v : undefined })
}
/>
)}
{isText && (
<>
<Switch
size="xs"
label={t("formFill.editor.multiline", "Multi-line")}
checked={!!value.multiline}
onChange={(e) => onChange({ multiline: e.currentTarget.checked })}
/>
<NumberInput
size="xs"
label={t("formFill.editor.maxLength", "Max length (comb)")}
value={value.maxLength ?? ""}
min={0}
max={500}
onChange={(v) =>
onChange({ maxLength: typeof v === "number" ? v : undefined })
}
/>
</>
)}
{value.type === "listbox" && (
<Switch
size="xs"
label={t("formFill.editor.multiSelect", "Allow multiple selection")}
checked={!!value.multiSelect}
onChange={(e) => onChange({ multiSelect: e.currentTarget.checked })}
/>
)}
{isFillable && (
<Switch
size="xs"
label={t("formFill.editor.required", "Required")}
checked={!!value.required}
onChange={(e) => onChange({ required: e.currentTarget.checked })}
/>
)}
{isFillable && (
<Switch
size="xs"
label={t("formFill.editor.readOnly", "Read-only")}
checked={!!value.readOnly}
onChange={(e) => onChange({ readOnly: e.currentTarget.checked })}
/>
)}
</Stack>
);
}
export default FormFieldPropertyEditor;
@@ -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;
@@ -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,
@@ -24,6 +22,7 @@ import {
ScrollArea,
Progress,
Tooltip,
SegmentedControl,
} from "@mantine/core";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
@@ -50,7 +49,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";
@@ -58,65 +56,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: <EditNoteIcon className={styles.modeTabIcon} />,
ready: true,
},
{
id: "make",
label: "Create",
icon: <PostAddIcon className={styles.modeTabIcon} />,
ready: false,
},
{
id: "batch",
label: "Batch",
icon: <FileCopyIcon className={styles.modeTabIcon} />,
ready: false,
},
{
id: "modify",
label: "Modify",
icon: <BuildCircleIcon className={styles.modeTabIcon} />,
ready: false,
},
];
// ---------------------------------------------------------------------------
// Coming-soon placeholder for unimplemented tabs
// ---------------------------------------------------------------------------
// ComingSoonPlaceholder — re-enable when mode tabs are exposed
// function ComingSoonPlaceholder({ mode }: { mode: ModeTabDef }) {
// return (
// <div className={styles.comingSoon}>
// <DescriptionIcon className={styles.comingSoonIcon} />
// <div className={styles.comingSoonTitle}>{mode.label} Forms</div>
// <div className={styles.comingSoonDesc}>
// This feature is coming soon. Stay tuned!
// </div>
// </div>
// );
// }
// ---------------------------------------------------------------------------
// Main FormFill component
// ---------------------------------------------------------------------------
@@ -133,18 +88,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: <EditNoteIcon className={styles.modeTabIcon} />,
},
{
id: "create",
label: t("formFill.mode.create", "Create"),
icon: <PostAddIcon className={styles.modeTabIcon} />,
},
{
id: "modify",
label: t("formFill.mode.modify", "Modify"),
icon: <BuildCircleIcon className={styles.modeTabIcon} />,
},
],
[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<FormMode>('fill');
const mode: FormMode = "fill";
const [flatten, setFlatten] = useState(false);
const [saving, setSaving] = useState(false);
const [extracting, setExtracting] = useState(false);
@@ -262,14 +235,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) {
const status = isAxiosError(err) ? err.response?.status : undefined;
const message =
@@ -389,7 +359,7 @@ const FormFill = (_props: BaseToolProps) => {
return (
<div className={styles.root}>
{/* ---- Mode selection (commented out until additional modes are implemented) ----
{/* ---- Mode selection ---- */}
<div className={styles.modeTabs}>
<SegmentedControl
value={mode}
@@ -414,10 +384,16 @@ const FormFill = (_props: BaseToolProps) => {
}}
/>
</div>
---- */}
{/* ---- Coming-soon for non-ready tabs (hidden while mode tabs are disabled) ---- */}
{/* !currentModeDef.ready && <ComingSoonPlaceholder mode={currentModeDef} /> */}
{/* ---- Create mode ---- */}
{mode === "create" && (
<FormFieldCreatePanel currentFile={currentFile as File | Blob | null} />
)}
{/* ---- Modify mode ---- */}
{mode === "modify" && (
<FormFieldModifyPanel currentFile={currentFile as File | Blob | null} />
)}
{/* ---- Fill Form content ---- */}
{mode === "fill" && (
@@ -35,11 +35,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)
@@ -206,6 +217,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<NewFieldDefinition, "name"> & { name?: string },
) => string;
updatePendingField: (id: string, patch: Partial<NewFieldDefinition>) => void;
removePendingField: (id: string) => void;
clearPendingFields: () => void;
/** POST queued fields to the backend; resolves to the updated PDF blob. */
commitNewFields: (file: File | Blob) => Promise<Blob>;
// --- 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<string, ModifyFieldDefinition>;
/** Merge a partial change for a field into the staged set. */
stageModification: (
targetName: string,
patch: Partial<ModifyFieldDefinition>,
) => 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<Blob>;
/** True when create or modify mode has uncommitted work. */
hasUncommittedChanges: boolean;
}
const FormFillContext = createContext<FormFillContextValue | null>(null);
@@ -307,6 +368,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<FormMode>("fill");
const [creationType, setCreationType] = useState<CreatableFieldType | null>(
null,
);
const [pendingFields, setPendingFields] = useState<PendingField[]>([]);
const [selectedFieldName, setSelectedField] = useState<string | null>(null);
const [modifiedFields, setModifiedFields] = useState<
Record<string, ModifyFieldDefinition>
>({});
const [deletedFieldNames, setDeletedFieldNames] = useState<string[]>([]);
// 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.
@@ -322,6 +405,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);
@@ -333,8 +421,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
@@ -342,9 +432,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:",
@@ -481,7 +569,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<NewFieldDefinition, "name"> & { 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<string, string> = {
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<NewFieldDefinition>) => {
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<Blob> => {
// 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<ModifyFieldDefinition>) => {
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<Blob> => {
// 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<number, FormField[]>();
@@ -511,6 +737,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,
@@ -527,6 +774,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,
],
);
@@ -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) => (
<div
key={i}
style={
g.orientation === "v"
? {
position: "absolute",
left: g.position,
top: 0,
width: 1,
height: "100%",
background: GUIDE_COLOR,
pointerEvents: "none",
}
: {
position: "absolute",
top: g.position,
left: 0,
height: 1,
width: "100%",
background: GUIDE_COLOR,
pointerEvents: "none",
}
}
/>
))}
</>
);
}
export default SnapGuides;
@@ -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<Blob> {
const formData = new FormData();
formData.append("file", file);
formData.append(
"fields",
new Blob([JSON.stringify(fields)], { type: "application/json" }),
);
const response = await apiClient.post("/api/v1/form/add-fields", formData, {
responseType: "blob",
});
return response.data;
}
/**
* Modify existing form fields (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<Blob> {
const formData = new FormData();
formData.append("file", file);
formData.append(
"updates",
new Blob([JSON.stringify(updates)], { type: "application/json" }),
);
const response = await apiClient.post(
"/api/v1/form/modify-fields",
formData,
{ responseType: "blob" },
);
return response.data;
}
/**
* 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<Blob> {
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<Blob> {
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;
}
@@ -0,0 +1,120 @@
/**
* Coordinate helpers for the form field editor.
*
* Three coordinate spaces are in play:
*
* - Pixel space: rendered <div> 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),
};
}
@@ -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;
@@ -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",
);
});
});
@@ -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;
}
@@ -0,0 +1,18 @@
/**
* The custom event the form tools use to hand a freshly-produced PDF blob to
* the viewer (EmbedPdfViewer listens for it and reloads the file, preserving
* scroll/rotation). Centralised so the event name isn't duplicated as a string
* across the fill/create/modify flows.
*/
export const FORM_APPLY_EVENT = "formfill:apply";
export interface FormApplyDetail {
blob: Blob;
}
/** Dispatch a produced PDF blob to the viewer for reload + refresh. */
export function dispatchFormApply(blob: Blob): void {
window.dispatchEvent(
new CustomEvent<FormApplyDetail>(FORM_APPLY_EVENT, { detail: { blob } }),
);
}
@@ -0,0 +1,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 };
}
@@ -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:<url>" | "submit:<url>" */
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[];
@@ -0,0 +1,42 @@
/**
* Shared commit boilerplate for the create and modify panels: run an async
* action that produces the edited PDF blob, hand it to the viewer, and track
* committing/error state. Keeps both panels from repeating the same
* try/catch/dispatch dance.
*/
import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { dispatchFormApply } from "@app/tools/formFill/formFillEvents";
export function useFormCommit(onApplied?: (blob: Blob) => void) {
const { t } = useTranslation();
const [committing, setCommitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const commit = useCallback(
async (
run: () => Promise<Blob>,
errorKey: string,
errorFallback: string,
) => {
setCommitting(true);
setError(null);
try {
const blob = await run();
dispatchFormApply(blob);
onApplied?.(blob);
} catch (err) {
setError(
(err instanceof Error ? err.message : undefined) ||
t(errorKey, errorFallback),
);
console.error("[FormFill] commit failed:", err);
} finally {
setCommitting(false);
}
},
[onApplied, t],
);
return { committing, error, setError, commit };
}
@@ -0,0 +1,56 @@
/**
* Shared page-scale helpers for the form overlays.
*
* Both the creation and edit overlays need the pixel<->PDF-point scale for a
* rendered page (and a pointer-to-local-pixel conversion). This centralises
* that so they stay in sync with FormFieldOverlay's basis and don't duplicate
* the documentState math.
*/
import { useMemo } from "react";
import { useDocumentState } from "@embedpdf/core/react";
export interface PageScale {
scaleX: number;
scaleY: number;
/** CropBox height in PDF points; 0 until the page has rendered. */
pageHeightPts: number;
/** CropBox width in PDF points; 0 until the page has rendered. */
pageWidthPts: number;
}
/**
* Pixel<->point scale for a page, derived from EmbedPDF's document state.
* `scaleX = pageWidthPx / pageWidthPts`. `pageWidthPts` is 0 until the page has
* rendered, so callers should guard on it before drawing.
*/
export function usePageScale(
documentId: string,
pageIndex: number,
pageWidth: number,
pageHeight: number,
): PageScale {
const documentState = useDocumentState(documentId);
return useMemo(() => {
const pdfPage = documentState?.document?.pages?.[pageIndex];
if (!pdfPage?.size || !pageWidth || !pageHeight) {
const s = documentState?.scale ?? 1;
return { scaleX: s, scaleY: s, pageHeightPts: 0, pageWidthPts: 0 };
}
return {
scaleX: pageWidth / pdfPage.size.width,
scaleY: pageHeight / pdfPage.size.height,
pageHeightPts: pdfPage.size.height,
pageWidthPts: pdfPage.size.width,
};
}, [documentState, pageIndex, pageWidth, pageHeight]);
}
/** Pointer position relative to an element's top-left, in CSS pixels. */
export function getLocalPoint(
e: { clientX: number; clientY: number },
el: HTMLElement | null,
): { x: number; y: number } {
const rect = el?.getBoundingClientRect();
if (!rect) return { x: 0, y: 0 };
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}