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
James BruntonandGitHub 776749277c Redesign policies to use typed mappings properly (#7017)
# Description of Changes
The Policies page and all the frontend logic for running Policies is not
making use of the bidirectional type mappings that we now have to safely
convert from frontend to backend param models and vice versa. This
changes the way we track the types throughout so we use the mappings
properly.

Because of this, the Add Watermark settings in Policies now actually
pre-populate with the defaults instead of with nothing like they
previously did.

<img width="791" height="725" alt="image"
src="https://github.com/user-attachments/assets/cbdf4ae0-35af-4792-bf64-89216e48d304"
/>
2026-07-14 09:58:04 +00:00
James BruntonandGitHub 41b1b89fcb Fix Policies page showing the Editor as a source twice (#7022)
# Description of Changes
The Policies page currently hard-codes the Editor to be available as a
source, but we now also have a virtual Editor source on the backend,
which the Policies page also renders. This removes the now-unnecessary
hard-coded Editor source.

## Before

<img width="842" height="640" alt="image"
src="https://github.com/user-attachments/assets/d78b33a3-fed4-4bb0-a02f-489ca2ae0614"
/>

## After

<img width="785" height="586" alt="image"
src="https://github.com/user-attachments/assets/37aa664f-74f2-41c7-b89a-9b483bffc3a2"
/>
2026-07-14 09:43:07 +00:00
James BruntonandGitHub 4d4e994562 Fix crash in Processor when loading tool settings with tooltips (#7015)
# Description of Changes
Some of the tool settings make use of editor preferences indirectly, but
the Processor never gets that provider, so it crashes when trying to
load them.
2026-07-14 09:09:23 +00: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
53 changed files with 5371 additions and 350 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" />,
@@ -13,7 +13,6 @@ import {
import {
deserializeToolStep,
getExecutableTools,
serializeStepFromEndpoint,
serializeToolStep,
stepRequiresUpload,
type WorkingToolStep,
@@ -199,41 +198,6 @@ describe("serialize/deserialize round-trip", () => {
});
});
describe("serializeStepFromEndpoint", () => {
test("maps a wizard step's UI params to the backend contract, filling defaults", () => {
// The shape the policy setup wizard holds: an endpoint plus UI-shaped params
// (redact's `wordsToRedact`), with several fields left to their defaults.
const api = serializeStepFromEndpoint(
"/api/v1/security/auto-redact",
{ mode: "automatic", useRegex: true, wordsToRedact: ["ssn", "card"] },
dynamicRegistry,
);
expect(api.operation).toBe("/api/v1/security/auto-redact");
// wordsToRedact -> listOfText (the field the backend actually reads), and the
// frontend-only `mode` is dropped.
expect(api.parameters).toMatchObject({ listOfText: "ssn\ncard" });
expect(api.parameters).not.toHaveProperty("wordsToRedact");
expect(api.parameters).not.toHaveProperty("mode");
// Fields the wizard never set still get their defaults so the body is complete.
expect(api.parameters).toHaveProperty("wholeWordSearch");
expect(api.parameters).toHaveProperty("customPadding");
});
test("passes an unmapped endpoint's params through unchanged", () => {
expect(
serializeStepFromEndpoint(
"/api/v1/unknown/thing",
{ keep: true },
dynamicRegistry,
),
).toEqual({
operation: "/api/v1/unknown/thing",
parameters: { keep: true },
});
});
});
describe("stepRequiresUpload", () => {
const step = (params: Record<string, unknown>): WorkingToolStep => ({
toolId: "compress" as ToolId,
@@ -197,31 +197,6 @@ export function serializeToolStep(
return { operation, parameters };
}
/**
* Serialize a step held as an endpoint path plus frontend-shaped params - the form the policy setup
* wizard keeps, where params match the tool's UI shape (e.g. redact's `wordsToRedact`) rather than
* the backend contract - into the backend step contract, mapping params through the tool's
* `toApiParams` (merged over its defaults, so fields the wizard never set still get their defaults).
* The endpoint maps to a tool by path, so this works for dynamic-endpoint tools whose config
* endpoint is a function. Endpoints that map to no known tool pass through unchanged.
*/
export function serializeStepFromEndpoint(
operation: string,
params: ErasedToolParams,
registry: Partial<ToolRegistry>,
): ToolApiStep {
const match = findToolByEndpoint({ operation, parameters: params }, registry);
const config = match?.[1].operationConfig;
if (!config) return { operation, parameters: params };
const merged = { ...(config.defaultParameters ?? {}), ...params };
return {
operation: resolveEndpoint(config, merged) ?? operation,
parameters: config.toApiParams
? (config.toApiParams(merged) as Record<string, unknown>)
: {},
};
}
/**
* Find the registry tool for a stored step's endpoint: exact match for static endpoints, else
* membership in a dynamic tool's declared `endpoints` set (replaying its function can't recover a
@@ -0,0 +1,37 @@
import { describe, expect, test } from "vitest";
import { describeToolOperation } from "@app/hooks/tools/shared/toolOperationDescriptor";
interface Params {
a: number;
}
// A minimal config that type-checks against the flatten endpoint's model.
const CONFIG = {
endpoint: "/api/v1/misc/flatten" as const,
defaultParameters: { a: 1 } satisfies Params,
toApiParams: (p: Params) => ({ renderDpi: p.a }),
fromApiParams: (api: { renderDpi?: number }) => ({ a: api.renderDpi ?? 0 }),
};
describe("describeToolOperation", () => {
test("wraps the config's mappers and endpoint into a descriptor", () => {
const d = describeToolOperation("/api/v1/misc/flatten", CONFIG);
expect(d.endpoint).toBe("/api/v1/misc/flatten");
expect(d.toApi({ a: 200 })).toEqual({ renderDpi: 200 });
});
test("fromApi merges the mapped values over the defaults", () => {
const d = describeToolOperation("/api/v1/misc/flatten", CONFIG);
expect(d.fromApi({ renderDpi: 72 })).toEqual({ a: 72 });
});
test("throws when the config lacks a mapper", () => {
expect(() =>
describeToolOperation("/api/v1/misc/flatten", {
endpoint: "/api/v1/misc/flatten" as const,
defaultParameters: { a: 1 },
toApiParams: (p: Params) => ({ renderDpi: p.a }),
}),
).toThrow(/mappers/);
});
});
@@ -0,0 +1,59 @@
/**
* Typed wrapper over a tool's `toApiParams`/`fromApiParams` mappers, binding one endpoint to safe
* frontend<->backend parameter conversion.
*/
import type { ToolApiParams, ToolEndpoint } from "@app/types/toolApiTypes";
export interface ToolOperationDescriptor<E extends ToolEndpoint, TParams> {
readonly endpoint: E;
readonly defaultParameters: TParams;
toApi(params: TParams): ToolApiParams[E];
/** Backend model -> full frontend params (defaults merged under the mapped values). */
fromApi(api: ToolApiParams[E]): TParams;
}
/**
* Structural subset of a tool's config. `CE` is the config's declared endpoint type, inferred from
* the `endpoint` field: the literal for static tools, or the whole `ToolEndpoint` union for
* dynamic-endpoint tools (whose endpoint is a function typed against the union).
*/
export interface BidirectionalToolConfig<TParams, CE extends ToolEndpoint> {
endpoint: CE | null | ((params: TParams) => CE | null);
defaultParameters?: TParams;
toApiParams?(params: TParams): ToolApiParams[CE];
fromApiParams?(api: ToolApiParams[CE]): Partial<TParams>;
}
/**
* Pin a config to `endpoint` (passed explicitly, since dynamic-endpoint tools declare `endpoint` as
* a function). `E extends CE` rejects pairing a static tool's config with the wrong endpoint, while
* allowing a dynamic tool whose `CE` is the full union. Throws when mappers or defaults are missing.
*/
export function describeToolOperation<
E extends CE,
CE extends ToolEndpoint,
TParams,
>(
endpoint: E,
config: BidirectionalToolConfig<TParams, CE>,
): ToolOperationDescriptor<E, TParams> {
const { toApiParams, fromApiParams, defaultParameters } = config;
if (!toApiParams || !fromApiParams || defaultParameters === undefined) {
throw new Error(
`describeToolOperation: "${endpoint}" is missing mappers or defaults`,
);
}
return {
endpoint,
defaultParameters,
// A dynamic tool's mapper is typed against the union; narrow to this endpoint (sound - the
// runtime mapper produces this endpoint's model).
toApi: (params) => toApiParams(params) as ToolApiParams[E],
fromApi: (api) =>
({
...defaultParameters,
...fromApiParams(api as ToolApiParams[CE]),
}) as TParams,
};
}
@@ -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 };
}
+22 -50
View File
@@ -13,6 +13,8 @@ import type { TFunction } from "i18next";
import { apiClient } from "@portal/api/http";
import { fromWirePolicy, toWirePolicy } from "@app/policies/codec";
import { runsToActivity, runsToStats } from "@app/policies/runs";
import { policyStep, type PolicyToolStep } from "@app/policies/operations";
import type { ToolEndpoint } from "@app/types/toolApiTypes";
import type {
PolicyDecodedState,
PolicyRunView,
@@ -66,7 +68,7 @@ export interface PolicyConfigDef {
rules: string[];
scopeLabel: string;
fields: PolicyField[];
defaultOperations: WirePipelineStep[];
defaultOperations: PolicyToolStep[];
}
export interface PolicyState {
@@ -128,20 +130,11 @@ export interface CatalogueEntry {
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Tool → endpoint registry */
/* Endpoint display labels */
/* ──────────────────────────────────────────────────────────────────────── */
export const TOOL_ENDPOINTS: Record<string, string> = {
redact: "/api/v1/security/auto-redact",
sanitize: "/api/v1/security/sanitize-pdf",
watermark: "/api/v1/security/add-watermark",
ocr: "/api/v1/misc/ocr-pdf",
flatten: "/api/v1/misc/flatten",
compress: "/api/v1/misc/compress-pdf",
};
/** Values are i18n keys — render with t(). */
export const ENDPOINT_LABELS: Record<string, string> = {
/** i18n keys keyed by {@link ToolEndpoint}; labels stored steps in the detail view. */
export const ENDPOINT_LABELS: Partial<Record<ToolEndpoint, string>> = {
"/api/v1/security/auto-redact": "portal.policies.endpoints.autoRedact",
"/api/v1/security/sanitize-pdf": "portal.policies.endpoints.sanitizePdf",
"/api/v1/security/add-watermark": "portal.policies.endpoints.addWatermark",
@@ -154,7 +147,8 @@ export function humanizeEndpoint(
path: string,
t: (key: string) => string,
): string {
if (ENDPOINT_LABELS[path]) return t(ENDPOINT_LABELS[path]);
const label = ENDPOINT_LABELS[path as ToolEndpoint];
if (label) return t(label);
const last = path.split("/").filter(Boolean).pop() ?? path;
return last
.replace(/-/g, " ")
@@ -230,10 +224,7 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
"portal.policies.config.ingestion.rules.3",
],
scopeLabel: "portal.policies.config.scopeAll",
defaultOperations: [
{ operation: TOOL_ENDPOINTS.ocr, parameters: {} },
{ operation: TOOL_ENDPOINTS.flatten, parameters: {} },
],
defaultOperations: [policyStep("ocr"), policyStep("flatten")],
fields: [
{
label: "portal.policies.config.ingestion.fields.minConfidence",
@@ -260,32 +251,16 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
],
scopeLabel: "portal.policies.config.scopeAll",
defaultOperations: [
{
operation: TOOL_ENDPOINTS.redact,
parameters: {
mode: "automatic",
useRegex: true,
convertPDFToImage: true,
wordsToRedact: DEFAULT_PII_PATTERNS,
},
},
{
operation: TOOL_ENDPOINTS.sanitize,
parameters: {
removeJavaScript: true,
removeEmbeddedFiles: false,
removeMetadata: false,
removeLinks: false,
removeFonts: false,
},
},
{
operation: TOOL_ENDPOINTS.watermark,
// convertPDFToImage bakes the watermark in so it can't be stripped
parameters: {
convertPDFToImage: true,
},
},
// Flatten to image so redactions can't be lifted off.
policyStep("redact", {
useRegex: true,
convertPDFToImage: true,
wordsToRedact: DEFAULT_PII_PATTERNS,
}),
// JavaScript removal only; the tool enables removeEmbeddedFiles by default, so turn it off.
policyStep("sanitize", { removeEmbeddedFiles: false }),
// Bake in via image so it can't be stripped.
policyStep("watermark", { convertPDFToImage: true }),
],
fields: [],
},
@@ -297,10 +272,7 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
"portal.policies.config.compliance.rules.2",
],
scopeLabel: "portal.policies.config.scopeAll",
defaultOperations: [
{ operation: TOOL_ENDPOINTS.sanitize, parameters: {} },
{ operation: TOOL_ENDPOINTS.flatten, parameters: {} },
],
defaultOperations: [policyStep("sanitize"), policyStep("flatten")],
fields: [
{
label: "portal.policies.config.compliance.fields.frameworks",
@@ -343,7 +315,7 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
"portal.policies.config.routing.rules.2",
],
scopeLabel: "portal.policies.config.scopeAll",
defaultOperations: [{ operation: TOOL_ENDPOINTS.compress, parameters: {} }],
defaultOperations: [policyStep("compress")],
fields: [
{
label: "portal.policies.config.routing.fields.destination",
@@ -374,7 +346,7 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
"portal.policies.config.retention.rules.2",
],
scopeLabel: "portal.policies.config.scopeAll",
defaultOperations: [{ operation: TOOL_ENDPOINTS.compress, parameters: {} }],
defaultOperations: [policyStep("compress")],
fields: [
{
label: "portal.policies.config.retention.fields.keepFor",
@@ -0,0 +1,52 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { Tooltip } from "@app/components/shared/Tooltip";
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
import type { WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation";
import { PipelineStepSettings } from "@portal/components/pipelines/PipelineStepSettings";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, fallback?: string) => fallback ?? key,
}),
}));
// A stand-in tool-settings UI that uses the shared editor Tooltip. The Tooltip
// pulls in the Preferences + Sidebar contexts, which the portal does not mount
// app-wide — so this reproduces the "usePreferences must be used within a
// PreferencesProvider" crash unless PipelineStepSettings supplies them.
function TooltipSettings() {
return (
<Tooltip content="help">
<button type="button">field</button>
</Tooltip>
);
}
const step = {
support: "editable",
toolId: "compress",
params: {},
} as unknown as WorkingToolStep;
const registry = {
compress: { automationSettings: TooltipSettings },
} as unknown as Partial<ToolRegistry>;
describe("PipelineStepSettings", () => {
it("renders reused editor tool settings (which use the shared Tooltip) without app-wide Preferences/Sidebar providers", () => {
expect(() =>
render(
<MantineProvider>
<PipelineStepSettings
step={step}
registry={registry}
onChange={() => {}}
/>
</MantineProvider>,
),
).not.toThrow();
expect(screen.getByText("field")).toBeInTheDocument();
});
});
@@ -1,6 +1,8 @@
import { Suspense } from "react";
import { useTranslation } from "react-i18next";
import { Banner } from "@app/ui";
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
import { SidebarProvider } from "@app/contexts/SidebarContext";
import { type ToolRegistry } from "@app/data/toolsTaxonomy";
import { type ErasedToolParams } from "@app/hooks/tools/shared/toolOperationTypes";
import { type WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation";
@@ -46,14 +48,18 @@ export function PipelineStepSettings({
}
return (
<Suspense fallback={null}>
<Settings
parameters={step.params}
onParameterChange={(key, value) =>
onChange({ ...step.params, [key]: value })
}
disabled={false}
/>
</Suspense>
<PreferencesProvider>
<SidebarProvider>
<Suspense fallback={null}>
<Settings
parameters={step.params}
onParameterChange={(key, value) =>
onChange({ ...step.params, [key]: value })
}
disabled={false}
/>
</Suspense>
</SidebarProvider>
</PreferencesProvider>
);
}
@@ -0,0 +1,134 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
fireEvent,
render as baseRender,
screen,
waitFor,
} from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { PolicySetupWizard } from "@portal/components/policies/PolicySetupWizard";
import {
POLICY_CATEGORIES,
POLICY_CONFIG,
type CatalogueEntry,
type DecoratedPolicy,
type PolicySetupResult,
type PipelineStep,
} from "@portal/api/policies";
const render = (ui: Parameters<typeof baseRender>[0]) =>
baseRender(ui, { wrapper: MantineProvider });
// Deterministic i18n: return the fallback when given, else the key. initReactI18next is stubbed
// because the import graph pulls core/i18n.ts, which registers it as a plugin.
vi.mock("react-i18next", () => ({
useTranslation: () => ({
// Second arg is a string fallback in some call sites and an interpolation object in others;
// only treat a string as the fallback.
t: (key: string, fallback?: unknown) =>
typeof fallback === "string" ? fallback : key,
i18n: { changeLanguage: vi.fn() },
}),
initReactI18next: { type: "3rdParty", init: vi.fn() },
}));
const fetchSources = vi.fn();
vi.mock("@portal/api/sources", () => ({
fetchSources: () => fetchSources(),
}));
const CONTINUE = "portal.policies.wizard.actions.continue";
const SAVE_CHANGES = "portal.policies.wizard.actions.saveChanges";
const ENABLE = "portal.policies.wizard.actions.enablePolicy";
const security = POLICY_CATEGORIES.find((c) => c.id === "security")!;
const securityConfig = POLICY_CONFIG.security;
function editEntry(steps: PipelineStep[]): CatalogueEntry {
const policy: DecoratedPolicy = {
category: security,
config: securityConfig,
state: {
configured: true,
status: "active",
sources: ["editor"],
scopeTypes: [],
reviewerEmail: "",
fieldValues: {},
runOn: "upload",
outputMode: "new_version",
outputName: "",
outputNamePosition: "suffix",
maxRetries: 0,
retryDelayMinutes: 0,
backendId: "pol-1",
isDefault: true,
},
steps,
stats: { enforced: 0, dataProcessed: "-", activeFor: "-" },
activity: [],
};
return { category: security, config: securityConfig, policy };
}
/** Advance the wizard from the workflow tab to the settings tab and submit. */
async function submitWizard(saveLabel: string) {
fireEvent.click(await screen.findByRole("button", { name: CONTINUE }));
fireEvent.click(await screen.findByRole("button", { name: saveLabel }));
}
describe("PolicySetupWizard", () => {
beforeEach(() => {
fetchSources.mockResolvedValue({ sources: [] });
});
it("round-trips a saved step's backend params on edit", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
const entry = editEntry([
{
operation: "/api/v1/security/auto-redact",
parameters: { listOfText: "foo\nbar", useRegex: true },
},
]);
render(
<PolicySetupWizard entry={entry} onClose={vi.fn()} onSubmit={onSubmit} />,
);
await submitWizard(SAVE_CHANGES);
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
const result = onSubmit.mock.calls[0][1] as PolicySetupResult;
// Only the saved tool is enabled on edit, and its patterns survive the wire -> UI -> wire trip.
expect(result.steps).toEqual([
expect.objectContaining({
operation: "/api/v1/security/auto-redact",
parameters: expect.objectContaining({ listOfText: "foo\nbar" }),
}),
]);
});
it("seeds the preset chain for a new policy (redact + sanitize on, watermark off)", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
const entry: CatalogueEntry = {
category: security,
config: securityConfig,
policy: null,
};
render(
<PolicySetupWizard entry={entry} onClose={vi.fn()} onSubmit={onSubmit} />,
);
await submitWizard(ENABLE);
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
const result = onSubmit.mock.calls[0][1] as PolicySetupResult;
const endpoints = result.steps.map((s) => s.operation);
expect(endpoints).toEqual([
"/api/v1/security/auto-redact",
"/api/v1/security/sanitize-pdf",
]);
// Redact carries the preset PII patterns as the backend's listOfText.
const redact = result.steps[0].parameters as { listOfText?: string };
expect(redact.listOfText).toBeTruthy();
});
});
@@ -13,23 +13,24 @@ import {
} from "@app/ui";
import { SettingsRow } from "@app/ui/SettingsRow";
import {
TOOL_ENDPOINTS,
humanizeEndpoint,
type CatalogueEntry,
type PipelineStep,
type PolicySetupResult,
} from "@portal/api/policies";
import type { ToolRegistry, ToolRegistryEntry } from "@app/data/toolsTaxonomy";
import {
deserializeToolStep,
serializeStepFromEndpoint,
} from "@app/hooks/tools/shared/toolAutomation";
policyEndpoint,
policyStepFromWire,
policyStepToWire,
type PolicyParams,
type PolicyToolId,
type PolicyToolStep,
} from "@app/policies/operations";
import { fetchSources } from "@portal/api/sources";
import { useAsync } from "@portal/hooks/useAsync";
import { PolicyFieldRow } from "@portal/components/policies/PolicyFieldRow";
import { policyIcon } from "@portal/components/policies/policyIcons";
import { sourceTypeMeta } from "@portal/components/sources/sourceTypes";
import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
import { PolicyRedactConfig } from "@app/components/policies/PolicyRedactConfig";
import { PolicyWatermarkConfig } from "@app/components/policies/PolicyWatermarkConfig";
import "@portal/views/Policies.css";
@@ -47,12 +48,8 @@ interface PolicySetupWizardProps {
type Step = "workflow" | "settings";
/** A configurable tool in the workflow step: whether it runs + its params. */
interface ToolState {
operation: string;
enabled: boolean;
parameters: Record<string, unknown>;
}
/** A policy step plus whether it runs. */
type ToolState = PolicyToolStep & { enabled: boolean };
/** Resolve each field's effective value: saved override, else definition default. */
function resolveFieldValues(
@@ -69,9 +66,8 @@ function resolveFieldValues(
* round-trips); otherwise the category preset's default chain. Each preset step
* starts enabled — the user toggles tools off in the workflow.
*/
// Temporary: tracks which tools start disabled until the tool registry lands in
// the portal and can drive this via registry metadata or a defaultEnabled flag.
const DISABLED_BY_DEFAULT = new Set(["/api/v1/security/add-watermark"]);
// Temporary until the catalogue carries a defaultEnabled flag.
const DISABLED_BY_DEFAULT = new Set<PolicyToolId>(["watermark"]);
/**
* Policy-facing framing for each capability a policy can include. Labels and
@@ -81,43 +77,43 @@ const DISABLED_BY_DEFAULT = new Set(["/api/v1/security/add-watermark"]);
* the humanised endpoint name with no description.
*/
const CAPABILITY_META: Record<
string,
PolicyToolId,
{ labelKey: string; labelEn: string; descKey: string; descEn: string }
> = {
[TOOL_ENDPOINTS.redact]: {
redact: {
labelKey: "portal.policies.wizard.capability.redact.label",
labelEn: "Redact sensitive information",
descKey: "portal.policies.wizard.capability.redact.desc",
descEn:
"Finds and blacks out sensitive details — like Social Security and card numbers — so they can't be read.",
},
[TOOL_ENDPOINTS.sanitize]: {
sanitize: {
labelKey: "portal.policies.wizard.capability.sanitize.label",
labelEn: "Strip active content",
descKey: "portal.policies.wizard.capability.sanitize.desc",
descEn:
"Removes hidden JavaScript so nothing can run automatically when the document is opened.",
},
[TOOL_ENDPOINTS.watermark]: {
watermark: {
labelKey: "portal.policies.wizard.capability.watermark.label",
labelEn: "Apply a watermark",
descKey: "portal.policies.wizard.capability.watermark.desc",
descEn: "Stamps a visible mark (e.g. “Confidential”) across every page.",
},
[TOOL_ENDPOINTS.ocr]: {
ocr: {
labelKey: "portal.policies.wizard.capability.ocr.label",
labelEn: "Make text searchable",
descKey: "portal.policies.wizard.capability.ocr.desc",
descEn: "Runs OCR so scanned pages become selectable, searchable text.",
},
[TOOL_ENDPOINTS.flatten]: {
flatten: {
labelKey: "portal.policies.wizard.capability.flatten.label",
labelEn: "Flatten the document",
descKey: "portal.policies.wizard.capability.flatten.desc",
descEn:
"Merges form fields and annotations into the page so they can't be edited.",
},
[TOOL_ENDPOINTS.compress]: {
compress: {
labelKey: "portal.policies.wizard.capability.compress.label",
labelEn: "Reduce file size",
descKey: "portal.policies.wizard.capability.compress.desc",
@@ -125,29 +121,24 @@ const CAPABILITY_META: Record<
},
};
function seedTools(
entry: CatalogueEntry,
registry: Partial<ToolRegistry>,
): ToolState[] {
function seedTools(entry: CatalogueEntry): ToolState[] {
const savedSteps = entry.policy?.steps ?? [];
const savedByOp = new Map(savedSteps.map((s) => [s.operation, s]));
// Always use defaultOperations as the canonical list so tools added after a
// policy was first saved still appear when editing.
return entry.config.defaultOperations.map((s) => {
const saved = savedByOp.get(s.operation);
const savedByTool = new Map<PolicyToolId, PolicyToolStep>();
for (const wire of savedSteps) {
const step = policyStepFromWire(wire);
if (step) savedByTool.set(step.toolId, step);
}
// defaultOperations is the canonical list (so tools added later still show on edit); a saved
// step's params win over the preset.
return entry.config.defaultOperations.map((preset) => {
const saved = savedByTool.get(preset.toolId);
return {
operation: s.operation,
...(saved ?? preset),
enabled: saved
? true
: savedSteps.length > 0
? false
: !DISABLED_BY_DEFAULT.has(s.operation),
// Saved steps are in the backend contract shape; map them back to the UI
// shape the config controls edit (e.g. `listOfText` -> `wordsToRedact`).
// Presets are already authored in the UI shape, so use them as-is.
parameters: saved
? deserializeToolStep(saved, registry).params
: s.parameters,
: !DISABLED_BY_DEFAULT.has(preset.toolId),
};
});
}
@@ -185,26 +176,12 @@ function PolicySetupWizardBody({
onSubmit: (entry: CatalogueEntry, result: PolicySetupResult) => Promise<void>;
}) {
const { t } = useTranslation();
const { allTools: toolRegistry } = useToolRegistry();
// Portal tool operations are endpoint paths (/api/v1/…), not short registry IDs.
// Build a reverse map so we can look up icons and display names by endpoint.
const registryByEndpoint = useMemo(() => {
const map = new Map<string, ToolRegistryEntry>();
for (const entry of Object.values(toolRegistry)) {
const ep = (entry as ToolRegistryEntry).operationConfig?.endpoint;
if (typeof ep === "string") map.set(ep, entry as ToolRegistryEntry);
}
return map;
}, [toolRegistry]);
const { category, config, policy } = entry;
const isEdit = policy != null;
const [step, setStep] = useState<Step>("workflow");
const [tools, setTools] = useState<ToolState[]>(() =>
seedTools(entry, toolRegistry),
);
const [tools, setTools] = useState<ToolState[]>(() => seedTools(entry));
const [fieldValues, setFieldValues] = useState(() =>
resolveFieldValues(entry),
);
@@ -213,22 +190,11 @@ function PolicySetupWizardBody({
);
const sourcesAsync = useAsync(() => fetchSources(), []);
const availableSources = useMemo(() => {
const backendSources = (sourcesAsync.data?.sources ?? []).filter(
(s) => s.status !== "disabled",
);
const editorSource = {
id: "editor",
name: t("portal.sources.types.editor.label"),
type: "editor",
status: "active" as const,
referenceCount: 0,
referencingPolicies: [],
config: [],
docsTotal: null,
};
return [editorSource, ...backendSources];
}, [sourcesAsync.data, t]);
const availableSources = useMemo(
() =>
(sourcesAsync.data?.sources ?? []).filter((s) => s.status !== "disabled"),
[sourcesAsync.data],
);
// Document-type scoping has no UI; preserve any saved scope on edit and
// default new policies to all document types.
const [scopeTypes] = useState<string[]>(policy?.state.scopeTypes ?? []);
@@ -256,9 +222,20 @@ function PolicySetupWizardBody({
const enabledTools = useMemo(() => tools.filter((tl) => tl.enabled), [tools]);
function patchTool(operation: string, patch: Partial<ToolState>) {
function setToolEnabled(toolId: PolicyToolId, enabled: boolean) {
setTools((prev) =>
prev.map((tl) => (tl.operation === operation ? { ...tl, ...patch } : tl)),
prev.map((tl) => (tl.toolId === toolId ? { ...tl, enabled } : tl)),
);
}
function setToolParams<Id extends PolicyToolId>(
toolId: Id,
params: PolicyParams<Id>,
) {
setTools((prev) =>
prev.map((tl) =>
tl.toolId === toolId ? ({ ...tl, params } as ToolState) : tl,
),
);
}
@@ -277,11 +254,8 @@ function PolicySetupWizardBody({
}
setError(null);
setSubmitting(true);
// Map each tool's UI-shaped params (e.g. redact's `wordsToRedact`) into the
// backend step contract (e.g. `listOfText`) via its `toApiParams`; saving the
// UI shape verbatim would drop those fields and the step would run with none.
const steps: PipelineStep[] = enabledTools.map((tl) =>
serializeStepFromEndpoint(tl.operation, tl.parameters, toolRegistry),
policyStepToWire(tl),
);
try {
await onSubmit(entry, {
@@ -386,20 +360,16 @@ function PolicySetupWizardBody({
<Card padding="none">
<div className="portal-policies__capabilities">
{tools.map((tl) => {
const meta = CAPABILITY_META[tl.operation];
const meta = CAPABILITY_META[tl.toolId];
const label = meta
? t(meta.labelKey, meta.labelEn)
: (registryByEndpoint.get(tl.operation)?.name ??
humanizeEndpoint(tl.operation, t));
: humanizeEndpoint(policyEndpoint(tl.toolId), t);
const description = meta
? t(meta.descKey, meta.descEn)
: undefined;
const hasConfig =
tl.operation === TOOL_ENDPOINTS.redact ||
tl.operation === TOOL_ENDPOINTS.watermark;
return (
<div
key={tl.operation}
key={tl.toolId}
className="portal-policies__capability"
data-on={tl.enabled || undefined}
>
@@ -411,27 +381,27 @@ function PolicySetupWizardBody({
size="sm"
checked={tl.enabled}
onChange={(checked) =>
patchTool(tl.operation, { enabled: checked })
setToolEnabled(tl.toolId, checked)
}
label=""
/>
}
/>
{tl.enabled && hasConfig && (
{tl.enabled && (
<div className="portal-policies__capability-config">
{tl.operation === TOOL_ENDPOINTS.redact && (
{tl.toolId === "redact" && (
<PolicyRedactConfig
parameters={tl.parameters}
onChange={(parameters) =>
patchTool(tl.operation, { parameters })
parameters={tl.params}
onChange={(params) =>
setToolParams("redact", params)
}
/>
)}
{tl.operation === TOOL_ENDPOINTS.watermark && (
{tl.toolId === "watermark" && (
<PolicyWatermarkConfig
parameters={tl.parameters}
onChange={(parameters) =>
patchTool(tl.operation, { parameters })
parameters={tl.params}
onChange={(params) =>
setToolParams("watermark", params)
}
/>
)}
@@ -475,9 +445,8 @@ function PolicySetupWizardBody({
{t("portal.policies.wizard.sources.loading")}
</p>
) : (
// The editor is always an available source (unconditionally prepended
// to availableSources), so the list is never empty — no "no sources"
// state exists.
// The backend always returns the editor as a virtual source, so the
// loaded list is never empty - no "no sources" state exists.
<div className="portal-policies__sources">
{availableSources.map((src) => (
// A selectable multi-line tile (icon + name + type + check).
+23 -3
View File
@@ -4,13 +4,33 @@
* only builds seed data for the MSW handlers and tests.
*/
import type { PolicyRunView, WirePolicy } from "@app/policies/types";
import { POLICY_CONFIG } from "@portal/api/policies";
import type {
PolicyRunView,
WirePipelineStep,
WirePolicy,
} from "@app/policies/types";
/* ──────────────────────────────────────────────────────────────────────── */
/* Seed data — real backend wire format */
/* ──────────────────────────────────────────────────────────────────────── */
// Literal wire steps (not derived from the catalogue) so this fixtures module stays independent of
// @portal/api/policies and its heavy tool-operation import graph.
const SECURITY_STEPS: WirePipelineStep[] = [
{
operation: "/api/v1/security/auto-redact",
parameters: {
listOfText: "",
useRegex: true,
convertPDFToImage: true,
},
},
{
operation: "/api/v1/security/sanitize-pdf",
parameters: { removeJavaScript: true },
},
];
export function seedPolicies(): WirePolicy[] {
return [
{
@@ -19,7 +39,7 @@ export function seedPolicies(): WirePolicy[] {
owner: "security@acme.com",
enabled: true,
trigger: null,
steps: POLICY_CONFIG.security.defaultOperations,
steps: SECURITY_STEPS,
output: {
type: "inline",
options: {
@@ -1,6 +1,7 @@
import { MultiSelect } from "@app/ui/MultiSelect";
import { useTranslation } from "react-i18next";
import { PII_PRESETS } from "@app/data/policyDefinitions";
import type { RedactParameters } from "@app/hooks/tools/redact/useRedactParameters";
/** The set of preset regexes — used to separate preset words from custom ones. */
export const PRESET_PATTERNS = new Set(PII_PRESETS.map((p) => p.pattern));
@@ -8,8 +9,8 @@ const PATTERN_BY_VALUE = new Map(PII_PRESETS.map((p) => [p.value, p.pattern]));
const VALUE_BY_PATTERN = new Map(PII_PRESETS.map((p) => [p.pattern, p.value]));
interface PolicyPiiFieldProps {
parameters: Record<string, unknown>;
onChange: (parameters: Record<string, unknown>) => void;
parameters: RedactParameters;
onChange: (parameters: RedactParameters) => void;
disabled?: boolean;
}
@@ -26,9 +27,7 @@ export function PolicyPiiField({
disabled,
}: PolicyPiiFieldProps) {
const { t } = useTranslation();
const words = Array.isArray(parameters.wordsToRedact)
? (parameters.wordsToRedact as string[])
: [];
const words = parameters.wordsToRedact;
const selected = words
.map((w) => VALUE_BY_PATTERN.get(w))
.filter((v): v is string => Boolean(v));
@@ -1,9 +1,10 @@
import { useEffect } from "react";
import { PolicyPiiField } from "@app/components/policies/PolicyPiiField";
import type { RedactParameters } from "@app/hooks/tools/redact/useRedactParameters";
interface PolicyRedactConfigProps {
parameters: Record<string, unknown>;
onChange: (parameters: Record<string, unknown>) => void;
parameters: RedactParameters;
onChange: (parameters: RedactParameters) => void;
disabled?: boolean;
}
@@ -3,8 +3,8 @@ import AddWatermarkSingleStepSettings from "@app/components/tools/addWatermark/A
import type { AddWatermarkParameters } from "@app/hooks/tools/addWatermark/useAddWatermarkParameters";
interface PolicyWatermarkConfigProps {
parameters: Record<string, unknown>;
onChange: (parameters: Record<string, unknown>) => void;
parameters: AddWatermarkParameters;
onChange: (parameters: AddWatermarkParameters) => void;
disabled?: boolean;
}
@@ -20,7 +20,7 @@ export function PolicyWatermarkConfig({
disabled,
}: PolicyWatermarkConfigProps) {
useEffect(() => {
const patch: Record<string, unknown> = {};
const patch: Partial<AddWatermarkParameters> = {};
if (parameters.convertPDFToImage !== true) patch.convertPDFToImage = true;
// Policies only support text watermarks.
if (parameters.watermarkType !== "text") patch.watermarkType = "text";
@@ -29,7 +29,7 @@ export function PolicyWatermarkConfig({
return (
<AddWatermarkSingleStepSettings
parameters={parameters as unknown as AddWatermarkParameters}
parameters={parameters}
onParameterChange={(key, value) =>
onChange({ ...parameters, [key]: value })
}
@@ -0,0 +1,112 @@
import { describe, expect, test } from "vitest";
import {
POLICY_OPERATIONS,
policyEndpoint,
policyStep,
policyStepFromWire,
policyStepToWire,
policyToolIdForEndpoint,
type PolicyToolId,
} from "@app/policies/operations";
const ALL_TOOL_IDS = Object.keys(POLICY_OPERATIONS) as PolicyToolId[];
describe("POLICY_OPERATIONS", () => {
test("every category operation is a typed descriptor with a known endpoint", () => {
// The catalogue uses these six across all categories; each must be wired.
expect(ALL_TOOL_IDS.sort()).toEqual([
"compress",
"flatten",
"ocr",
"redact",
"sanitize",
"watermark",
]);
for (const id of ALL_TOOL_IDS) {
expect(POLICY_OPERATIONS[id].endpoint).toBe(policyEndpoint(id));
expect(typeof POLICY_OPERATIONS[id].toApi).toBe("function");
expect(typeof POLICY_OPERATIONS[id].fromApi).toBe("function");
}
});
test("policyEndpoint returns the pinned endpoint literal", () => {
expect(policyEndpoint("redact")).toBe("/api/v1/security/auto-redact");
expect(policyEndpoint("sanitize")).toBe("/api/v1/security/sanitize-pdf");
expect(policyEndpoint("watermark")).toBe("/api/v1/security/add-watermark");
expect(policyEndpoint("ocr")).toBe("/api/v1/misc/ocr-pdf");
expect(policyEndpoint("flatten")).toBe("/api/v1/misc/flatten");
expect(policyEndpoint("compress")).toBe("/api/v1/misc/compress-pdf");
});
test("policyToolIdForEndpoint maps endpoints back, and rejects non-policy ones", () => {
for (const id of ALL_TOOL_IDS) {
expect(policyToolIdForEndpoint(policyEndpoint(id))).toBe(id);
}
expect(policyToolIdForEndpoint("/api/v1/misc/repair")).toBeNull();
expect(policyToolIdForEndpoint("not-an-endpoint")).toBeNull();
});
});
describe("policyStep", () => {
test("merges partial params over the tool's defaults", () => {
const step = policyStep("redact", {
useRegex: true,
wordsToRedact: ["ssn", "card"],
});
expect(step.toolId).toBe("redact");
// Overrides applied...
expect(step.params.useRegex).toBe(true);
expect(step.params.wordsToRedact).toEqual(["ssn", "card"]);
// ...and untouched fields fall back to the tool's defaults.
expect(step.params.mode).toBe("automatic");
expect(step.params.redactColor).toBe("#000000");
});
});
describe("wire conversion", () => {
test("redact maps frontend params to the backend request model (wordsToRedact -> listOfText)", () => {
const wire = policyStepToWire(
policyStep("redact", {
useRegex: true,
convertPDFToImage: true,
wordsToRedact: ["ssn", "card"],
}),
);
expect(wire.operation).toBe("/api/v1/security/auto-redact");
// The backend field the endpoint actually reads, and no frontend-only `mode`/`wordsToRedact`.
expect(wire.parameters).toMatchObject({
listOfText: "ssn\ncard",
useRegex: true,
convertPDFToImage: true,
});
expect(wire.parameters).not.toHaveProperty("wordsToRedact");
expect(wire.parameters).not.toHaveProperty("mode");
});
test("every policy operation round-trips through wire and back", () => {
for (const id of ALL_TOOL_IDS) {
const step = policyStep(id);
const back = policyStepFromWire(policyStepToWire(step));
expect(back?.toolId).toBe(id);
}
});
test("redact round-trip preserves the configured patterns", () => {
const step = policyStep("redact", {
useRegex: true,
wordsToRedact: ["ssn", "card"],
});
const back = policyStepFromWire(policyStepToWire(step));
expect(back?.toolId).toBe("redact");
if (back?.toolId === "redact") {
expect(back.params.wordsToRedact).toEqual(["ssn", "card"]);
expect(back.params.useRegex).toBe(true);
}
});
test("a non-policy endpoint decodes to null", () => {
expect(
policyStepFromWire({ operation: "/api/v1/misc/repair", parameters: {} }),
).toBeNull();
});
});
@@ -0,0 +1,131 @@
/**
* The tool operations the Policies feature can run, each a typed {@link ToolOperationDescriptor}.
* Source of truth for the catalogue, wizard, and wire conversion. Add a tool here to use it in a
* policy - the catalogue can't reference an untyped operation.
*/
import { describeToolOperation } from "@app/hooks/tools/shared/toolOperationDescriptor";
import { redactOperationConfig } from "@app/hooks/tools/redact/useRedactOperation";
import { sanitizeOperationConfig } from "@app/hooks/tools/sanitize/useSanitizeOperation";
import { addWatermarkOperationConfig } from "@app/hooks/tools/addWatermark/useAddWatermarkOperation";
import { ocrOperationConfig } from "@app/hooks/tools/ocr/useOCROperation";
import { flattenOperationConfig } from "@app/hooks/tools/flatten/useFlattenOperation";
import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation";
import type { ToolOperationDescriptor } from "@app/hooks/tools/shared/toolOperationDescriptor";
import type { ToolApiParams, ToolEndpoint } from "@app/types/toolApiTypes";
import type { WirePipelineStep } from "@app/policies/types";
export const POLICY_OPERATIONS = {
redact: describeToolOperation(
"/api/v1/security/auto-redact",
redactOperationConfig,
),
sanitize: describeToolOperation(
"/api/v1/security/sanitize-pdf",
sanitizeOperationConfig,
),
watermark: describeToolOperation(
"/api/v1/security/add-watermark",
addWatermarkOperationConfig,
),
ocr: describeToolOperation("/api/v1/misc/ocr-pdf", ocrOperationConfig),
flatten: describeToolOperation(
"/api/v1/misc/flatten",
flattenOperationConfig,
),
compress: describeToolOperation(
"/api/v1/misc/compress-pdf",
compressOperationConfig,
),
} as const;
export type PolicyToolId = keyof typeof POLICY_OPERATIONS;
export type PolicyParams<Id extends PolicyToolId> =
(typeof POLICY_OPERATIONS)[Id] extends ToolOperationDescriptor<
ToolEndpoint,
infer P
>
? P
: never;
/** Discriminated on `toolId` so `params` matches the tool. */
export type PolicyToolStep = {
[Id in PolicyToolId]: { toolId: Id; params: PolicyParams<Id> };
}[PolicyToolId];
export type PolicyToolStepOf<Id extends PolicyToolId> = Extract<
PolicyToolStep,
{ toolId: Id }
>;
const POLICY_TOOL_IDS = Object.keys(POLICY_OPERATIONS) as PolicyToolId[];
const TOOL_ID_BY_ENDPOINT = new Map<string, PolicyToolId>(
POLICY_TOOL_IDS.map((id) => [POLICY_OPERATIONS[id].endpoint, id]),
);
export function policyEndpoint(toolId: PolicyToolId): ToolEndpoint {
return POLICY_OPERATIONS[toolId].endpoint;
}
/** Tool id for an endpoint path, or null if it isn't a policy tool. */
export function policyToolIdForEndpoint(endpoint: string): PolicyToolId | null {
return TOOL_ID_BY_ENDPOINT.get(endpoint) ?? null;
}
/** A step for `toolId`, partial params merged over the tool's defaults. */
export function policyStep<Id extends PolicyToolId>(
toolId: Id,
params: Partial<PolicyParams<Id>> = {},
): PolicyToolStepOf<Id> {
const defaults = POLICY_OPERATIONS[toolId].defaultParameters as object;
return {
toolId,
params: { ...defaults, ...(params as object) },
} as PolicyToolStepOf<Id>;
}
export function policyStepToWire(step: PolicyToolStep): WirePipelineStep {
return serializeStep(step);
}
// Generic over the id so `params` stays correlated with the descriptor; TS can't do that through
// the union, so `op` is widened here (a contained cast at the wire boundary).
function serializeStep<Id extends PolicyToolId>(step: {
toolId: Id;
params: PolicyParams<Id>;
}): WirePipelineStep {
const op = POLICY_OPERATIONS[step.toolId] as ToolOperationDescriptor<
ToolEndpoint,
PolicyParams<Id>
>;
return {
operation: op.endpoint,
parameters: op.toApi(step.params) as Record<string, unknown>,
};
}
/** Wire step -> typed policy step, or null if the endpoint isn't a policy tool. */
export function policyStepFromWire(
wire: WirePipelineStep,
): PolicyToolStep | null {
const toolId = policyToolIdForEndpoint(wire.operation);
if (!toolId) return null;
return deserializeStep(toolId, wire.parameters);
}
function deserializeStep<Id extends PolicyToolId>(
toolId: Id,
parameters: Record<string, unknown>,
): PolicyToolStepOf<Id> {
const op = POLICY_OPERATIONS[toolId] as ToolOperationDescriptor<
ToolEndpoint,
PolicyParams<Id>
>;
// Wire params are untyped JSON; this is the one point they enter the typed model.
const params = op.fromApi(
parameters as unknown as ToolApiParams[ToolEndpoint],
);
return { toolId, params } as unknown as PolicyToolStepOf<Id>;
}