Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95ae588283 | ||
|
|
776749277c | ||
|
|
41b1b89fcb | ||
|
|
4d4e994562 | ||
|
|
c1c0cd4ca6 | ||
|
|
57c93fe0cd | ||
|
|
ccdf677141 | ||
|
|
7cd3228eba | ||
|
|
f4b316f5cd | ||
|
|
20ca9ed3f4 | ||
|
|
c886650840 | ||
|
|
99ef3c61e5 | ||
|
|
3c693fc049 | ||
|
|
c10a5b9aa5 | ||
|
|
f41bf2d83c | ||
|
|
c18b9a1da0 | ||
|
|
6ae294f909 | ||
|
|
8a282c0d30 | ||
|
|
16c39c187d | ||
|
|
62684ba706 | ||
|
|
6ba69b284a | ||
|
|
80b141e5f5 | ||
|
|
0cdd6776ee | ||
|
|
8fca517b9c | ||
|
|
a4c3beebdf |
@@ -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(
|
||||
|
||||
+6
-4
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+81
@@ -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",
|
||||
|
||||
+39
@@ -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();
|
||||
|
||||
+79
@@ -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
|
||||
|
||||
+88
@@ -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"
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user