mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
feat(form-fill): FormFill tool with context and UI components for PDF form filling (#5711)
This commit is contained in:
@@ -0,0 +1,98 @@
|
|||||||
|
package stirling.software.common.model;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/** Form field information with coordinates for interactive form viewer. */
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
@Schema(description = "Form field with coordinates and metadata")
|
||||||
|
public class FormFieldWithCoordinates {
|
||||||
|
|
||||||
|
@Schema(description = "Fully qualified field name", example = "form1.firstName")
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Schema(description = "Display label for the field", example = "First Name")
|
||||||
|
private String label;
|
||||||
|
|
||||||
|
@Schema(description = "Field type: text, checkbox, radio, combobox, listbox, button, signature")
|
||||||
|
private String type;
|
||||||
|
|
||||||
|
@Schema(description = "Current field value")
|
||||||
|
private String value;
|
||||||
|
|
||||||
|
@Schema(
|
||||||
|
description =
|
||||||
|
"Available options (export values) for choice fields"
|
||||||
|
+ " (dropdown, radio, listbox)")
|
||||||
|
private List<String> options;
|
||||||
|
|
||||||
|
@Schema(
|
||||||
|
description =
|
||||||
|
"Human-readable display labels for choice field options,"
|
||||||
|
+ " parallel to the 'options' list. Null when identical to options.")
|
||||||
|
private List<String> displayOptions;
|
||||||
|
|
||||||
|
@Schema(description = "Whether the field is required")
|
||||||
|
private boolean required;
|
||||||
|
|
||||||
|
@Schema(description = "Whether the field is read-only")
|
||||||
|
private boolean readOnly;
|
||||||
|
|
||||||
|
@Schema(description = "Whether this is a multi-select list box")
|
||||||
|
private boolean multiSelect;
|
||||||
|
|
||||||
|
@Schema(description = "Whether this is a multi-line text field")
|
||||||
|
private boolean multiline;
|
||||||
|
|
||||||
|
@Schema(description = "Tooltip/alternate name for the field")
|
||||||
|
private String tooltip;
|
||||||
|
|
||||||
|
@Schema(description = "Widget coordinates on each page (fields can have multiple widgets)")
|
||||||
|
private List<WidgetCoordinates> widgets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coordinates for a single widget annotation (visual representation of the field). A field can
|
||||||
|
* have multiple widgets if it appears on multiple pages.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
@Schema(description = "Widget coordinates in PDF space")
|
||||||
|
public static class WidgetCoordinates {
|
||||||
|
|
||||||
|
@Schema(description = "Page index (0-based)", example = "0")
|
||||||
|
private int pageIndex;
|
||||||
|
|
||||||
|
@Schema(description = "X coordinate in PDF points (lower-left origin)")
|
||||||
|
private float x;
|
||||||
|
|
||||||
|
@Schema(description = "Y coordinate in PDF points (lower-left origin)")
|
||||||
|
private float y;
|
||||||
|
|
||||||
|
@Schema(description = "Width in PDF points")
|
||||||
|
private float width;
|
||||||
|
|
||||||
|
@Schema(description = "Height in PDF points")
|
||||||
|
private float height;
|
||||||
|
|
||||||
|
@Schema(description = "Export value for this widget (radio/checkbox buttons only)")
|
||||||
|
private String exportValue;
|
||||||
|
|
||||||
|
@Schema(description = "Font size in PDF points")
|
||||||
|
private Float fontSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
@@ -27,6 +27,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
|||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
|
import stirling.software.common.model.FormFieldWithCoordinates;
|
||||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||||
import stirling.software.common.util.ExceptionUtils;
|
import stirling.software.common.util.ExceptionUtils;
|
||||||
import stirling.software.common.util.WebResponseUtils;
|
import stirling.software.common.util.WebResponseUtils;
|
||||||
@@ -104,12 +105,40 @@ public class FormFillController {
|
|||||||
|
|
||||||
requirePdf(file);
|
requirePdf(file);
|
||||||
try (PDDocument document = pdfDocumentFactory.load(file, true)) {
|
try (PDDocument document = pdfDocumentFactory.load(file, true)) {
|
||||||
|
FormUtils.repairMissingWidgetPageReferences(document);
|
||||||
FormUtils.FormFieldExtraction extraction =
|
FormUtils.FormFieldExtraction extraction =
|
||||||
FormUtils.extractFieldsWithTemplate(document);
|
FormUtils.extractFieldsWithTemplate(document);
|
||||||
return ResponseEntity.ok(extraction);
|
return ResponseEntity.ok(extraction);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/fields-with-coordinates", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
@Operation(
|
||||||
|
summary = "Inspect PDF form fields with widget coordinates",
|
||||||
|
description =
|
||||||
|
"Returns metadata describing each field in the provided PDF form, "
|
||||||
|
+ "including precise widget coordinates for interactive rendering")
|
||||||
|
public ResponseEntity<List<FormFieldWithCoordinates>> listFieldsWithCoordinates(
|
||||||
|
@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)
|
||||||
|
throws IOException {
|
||||||
|
|
||||||
|
requirePdf(file);
|
||||||
|
try (PDDocument document = pdfDocumentFactory.load(file, true)) {
|
||||||
|
FormUtils.repairMissingWidgetPageReferences(document);
|
||||||
|
List<FormFieldWithCoordinates> fields =
|
||||||
|
FormUtils.extractFormFieldsWithCoordinates(document);
|
||||||
|
return ResponseEntity.ok(fields);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping(value = "/modify-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
@PostMapping(value = "/modify-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
@Operation(
|
@Operation(
|
||||||
summary = "Modify existing form fields",
|
summary = "Modify existing form fields",
|
||||||
@@ -215,6 +244,7 @@ public class FormFillController {
|
|||||||
|
|
||||||
String baseName = buildBaseName(file, suffix);
|
String baseName = buildBaseName(file, suffix);
|
||||||
try (PDDocument document = pdfDocumentFactory.load(file)) {
|
try (PDDocument document = pdfDocumentFactory.load(file)) {
|
||||||
|
FormUtils.repairMissingWidgetPageReferences(document);
|
||||||
processor.accept(document);
|
processor.accept(document);
|
||||||
return saveDocument(document, baseName);
|
return saveDocument(document, baseName);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import java.io.IOException;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.IdentityHashMap;
|
import java.util.IdentityHashMap;
|
||||||
@@ -18,6 +19,7 @@ import java.util.Optional;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
import org.apache.pdfbox.cos.COSArray;
|
import org.apache.pdfbox.cos.COSArray;
|
||||||
|
import org.apache.pdfbox.cos.COSBase;
|
||||||
import org.apache.pdfbox.cos.COSDictionary;
|
import org.apache.pdfbox.cos.COSDictionary;
|
||||||
import org.apache.pdfbox.cos.COSName;
|
import org.apache.pdfbox.cos.COSName;
|
||||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||||
@@ -46,6 +48,7 @@ import lombok.experimental.UtilityClass;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import stirling.software.common.model.ApplicationProperties;
|
import stirling.software.common.model.ApplicationProperties;
|
||||||
|
import stirling.software.common.model.FormFieldWithCoordinates;
|
||||||
import stirling.software.common.util.ApplicationContextProvider;
|
import stirling.software.common.util.ApplicationContextProvider;
|
||||||
import stirling.software.common.util.ExceptionUtils;
|
import stirling.software.common.util.ExceptionUtils;
|
||||||
import stirling.software.common.util.RegexPatternUtils;
|
import stirling.software.common.util.RegexPatternUtils;
|
||||||
@@ -67,6 +70,13 @@ public class FormUtils {
|
|||||||
public final Set<String> CHOICE_FIELD_TYPES =
|
public final Set<String> CHOICE_FIELD_TYPES =
|
||||||
Set.of(FIELD_TYPE_COMBOBOX, FIELD_TYPE_LISTBOX, FIELD_TYPE_RADIO);
|
Set.of(FIELD_TYPE_COMBOBOX, FIELD_TYPE_LISTBOX, FIELD_TYPE_RADIO);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Threshold in PDF points for considering two widgets to be on the same line. Fields whose
|
||||||
|
* y-coordinates differ by less than this value are sorted left-to-right by x-coordinate instead
|
||||||
|
* of top-to-bottom.
|
||||||
|
*/
|
||||||
|
private static final float SAME_LINE_THRESHOLD_PT = 10.0f;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a normalized logical type string for the supplied PDFBox field instance. Centralized
|
* Returns a normalized logical type string for the supplied PDFBox field instance. Centralized
|
||||||
* so all callers share identical mapping logic.
|
* so all callers share identical mapping logic.
|
||||||
@@ -109,6 +119,8 @@ public class FormUtils {
|
|||||||
List<FormFieldInfo> fields = new ArrayList<>();
|
List<FormFieldInfo> fields = new ArrayList<>();
|
||||||
Map<String, Integer> typeCounters = new HashMap<>();
|
Map<String, Integer> typeCounters = new HashMap<>();
|
||||||
Map<Integer, Integer> pageOrderCounters = new HashMap<>();
|
Map<Integer, Integer> pageOrderCounters = new HashMap<>();
|
||||||
|
Map<COSDictionary, Integer> annotationPageMap = buildAnnotationPageMap(document);
|
||||||
|
|
||||||
for (PDField field : acroForm.getFieldTree()) {
|
for (PDField field : acroForm.getFieldTree()) {
|
||||||
if (!(field instanceof PDTerminalField terminalField)) {
|
if (!(field instanceof PDTerminalField terminalField)) {
|
||||||
continue;
|
continue;
|
||||||
@@ -125,7 +137,7 @@ public class FormUtils {
|
|||||||
|
|
||||||
String currentValue = safeValue(terminalField);
|
String currentValue = safeValue(terminalField);
|
||||||
boolean required = field.isRequired();
|
boolean required = field.isRequired();
|
||||||
int pageIndex = resolveFirstWidgetPageIndex(document, terminalField);
|
int pageIndex = resolveFirstWidgetPageIndex(document, terminalField, annotationPageMap);
|
||||||
List<String> options = resolveOptions(terminalField);
|
List<String> options = resolveOptions(terminalField);
|
||||||
String tooltip = resolveTooltip(terminalField);
|
String tooltip = resolveTooltip(terminalField);
|
||||||
int typeIndex = typeCounters.merge(type, 1, Integer::sum);
|
int typeIndex = typeCounters.merge(type, 1, Integer::sum);
|
||||||
@@ -164,6 +176,396 @@ public class FormUtils {
|
|||||||
return Collections.unmodifiableList(fields);
|
return Collections.unmodifiableList(fields);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract form fields with widget coordinates for the interactive form viewer.
|
||||||
|
*
|
||||||
|
* @param document PDF document
|
||||||
|
* @return List of form fields with coordinates and metadata
|
||||||
|
*/
|
||||||
|
public List<FormFieldWithCoordinates> extractFormFieldsWithCoordinates(PDDocument document) {
|
||||||
|
if (document == null) return List.of();
|
||||||
|
|
||||||
|
PDAcroForm acroForm = getAcroFormSafely(document);
|
||||||
|
if (acroForm == null) return List.of();
|
||||||
|
|
||||||
|
List<FormFieldWithCoordinates> fields = new ArrayList<>();
|
||||||
|
Map<String, Integer> typeCounters = new HashMap<>();
|
||||||
|
|
||||||
|
Map<COSDictionary, Integer> annotationPageMap = buildAnnotationPageMap(document);
|
||||||
|
|
||||||
|
for (PDField field : acroForm.getFieldTree()) {
|
||||||
|
if (!(field instanceof PDTerminalField terminalField)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
String type = detectFieldType(terminalField);
|
||||||
|
String name =
|
||||||
|
Optional.ofNullable(field.getFullyQualifiedName())
|
||||||
|
.orElseGet(field::getPartialName);
|
||||||
|
if (name == null || name.isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
String currentValue = safeValue(terminalField);
|
||||||
|
boolean required = field.isRequired();
|
||||||
|
boolean readOnly = field.isReadOnly();
|
||||||
|
List<String> options = resolveOptions(terminalField);
|
||||||
|
List<String> displayOptions = resolveDisplayOptions(terminalField);
|
||||||
|
String tooltip = resolveTooltip(terminalField);
|
||||||
|
int typeIndex = typeCounters.merge(type, 1, Integer::sum);
|
||||||
|
String displayLabel =
|
||||||
|
deriveDisplayLabel(field, name, tooltip, type, typeIndex, options);
|
||||||
|
boolean multiSelect = resolveMultiSelect(terminalField);
|
||||||
|
boolean multiline =
|
||||||
|
terminalField instanceof PDTextField
|
||||||
|
&& ((PDTextField) terminalField).isMultiline();
|
||||||
|
|
||||||
|
// Extract widget coordinates
|
||||||
|
List<FormFieldWithCoordinates.WidgetCoordinates> widgets =
|
||||||
|
extractWidgetCoordinates(document, terminalField, annotationPageMap);
|
||||||
|
|
||||||
|
// Only include displayOptions when they differ from export options
|
||||||
|
List<String> displayOptsToSend = null;
|
||||||
|
if (displayOptions != null
|
||||||
|
&& !displayOptions.isEmpty()
|
||||||
|
&& !displayOptions.equals(options)) {
|
||||||
|
displayOptsToSend = displayOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.add(
|
||||||
|
FormFieldWithCoordinates.builder()
|
||||||
|
.name(name)
|
||||||
|
.label(displayLabel)
|
||||||
|
.type(type)
|
||||||
|
.value(currentValue)
|
||||||
|
.options(options.isEmpty() ? null : options)
|
||||||
|
.displayOptions(displayOptsToSend)
|
||||||
|
.required(required)
|
||||||
|
.readOnly(readOnly)
|
||||||
|
.multiSelect(multiSelect)
|
||||||
|
.multiline(multiline)
|
||||||
|
.tooltip(tooltip)
|
||||||
|
.widgets(widgets.isEmpty() ? null : widgets)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by page and position
|
||||||
|
fields.sort(new FieldCoordinateComparator());
|
||||||
|
|
||||||
|
log.debug("Total fields processed: {}", fields.size());
|
||||||
|
log.debug(
|
||||||
|
"Fields WITH widgets: {}",
|
||||||
|
fields.stream()
|
||||||
|
.filter(f -> f.getWidgets() != null && !f.getWidgets().isEmpty())
|
||||||
|
.count());
|
||||||
|
log.debug(
|
||||||
|
"Fields WITHOUT widgets: {}",
|
||||||
|
fields.stream()
|
||||||
|
.filter(f -> f.getWidgets() == null || f.getWidgets().isEmpty())
|
||||||
|
.count());
|
||||||
|
|
||||||
|
fields.stream()
|
||||||
|
.filter(f -> f.getWidgets() == null || f.getWidgets().isEmpty())
|
||||||
|
.forEach(
|
||||||
|
f ->
|
||||||
|
log.debug(
|
||||||
|
"Field '{}' type={} has NO widget coordinates",
|
||||||
|
f.getName(),
|
||||||
|
f.getType()));
|
||||||
|
|
||||||
|
return Collections.unmodifiableList(fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract widget coordinates for a form field.
|
||||||
|
*
|
||||||
|
* @param document PDF document
|
||||||
|
* @param field Terminal field
|
||||||
|
* @return List of widget coordinates
|
||||||
|
*/
|
||||||
|
private List<FormFieldWithCoordinates.WidgetCoordinates> extractWidgetCoordinates(
|
||||||
|
PDDocument document,
|
||||||
|
PDTerminalField field,
|
||||||
|
Map<COSDictionary, Integer> annotationPageMap) {
|
||||||
|
List<FormFieldWithCoordinates.WidgetCoordinates> result = new ArrayList<>();
|
||||||
|
|
||||||
|
List<PDAnnotationWidget> widgets = field.getWidgets();
|
||||||
|
|
||||||
|
log.debug(
|
||||||
|
"Field '{}' type={} has {} widgets",
|
||||||
|
field.getFullyQualifiedName(),
|
||||||
|
field.getClass().getSimpleName(),
|
||||||
|
widgets != null ? widgets.size() : 0);
|
||||||
|
|
||||||
|
if (widgets == null || widgets.isEmpty()) {
|
||||||
|
// Some fields (especially text fields) might be their own widget annotation
|
||||||
|
log.trace(
|
||||||
|
"Field '{}' has no widgets, checking if field acts as its own annotation",
|
||||||
|
field.getFullyQualifiedName());
|
||||||
|
try {
|
||||||
|
COSDictionary fieldDict = field.getCOSObject();
|
||||||
|
COSBase rectBase = fieldDict.getDictionaryObject(COSName.RECT);
|
||||||
|
if (rectBase instanceof COSArray rectArray) {
|
||||||
|
int pageIndex =
|
||||||
|
findPageIndexForAnnotation(document, fieldDict, annotationPageMap);
|
||||||
|
if (pageIndex >= 0) {
|
||||||
|
PDRectangle rectangle = new PDRectangle(rectArray);
|
||||||
|
result.add(
|
||||||
|
createWidgetCoordinates(
|
||||||
|
document, rectangle, pageIndex, null, field));
|
||||||
|
} else {
|
||||||
|
log.warn(
|
||||||
|
"Found rectangle for field '{}' but could not resolve page index",
|
||||||
|
field.getFullyQualifiedName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug(
|
||||||
|
"Could not extract direct rectangle for field '{}': {}",
|
||||||
|
field.getFullyQualifiedName(),
|
||||||
|
e.getMessage());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For radio buttons, pre-resolve export values per widget
|
||||||
|
List<String> exportValues = null;
|
||||||
|
if (field instanceof PDRadioButton radio) {
|
||||||
|
exportValues = radio.getExportValues();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < widgets.size(); i++) {
|
||||||
|
PDAnnotationWidget widget = widgets.get(i);
|
||||||
|
try {
|
||||||
|
PDRectangle rectangle = widget.getRectangle();
|
||||||
|
if (rectangle == null) {
|
||||||
|
log.warn(
|
||||||
|
"Field '{}' widget {} has NULL rectangle",
|
||||||
|
field.getFullyQualifiedName(),
|
||||||
|
i);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int pageIndex = resolveWidgetPageIndex(document, widget, annotationPageMap);
|
||||||
|
if (pageIndex < 0) {
|
||||||
|
log.warn(
|
||||||
|
"Field '{}' widget {} could not resolve page index",
|
||||||
|
field.getFullyQualifiedName(),
|
||||||
|
i);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve export value for radio/checkbox widgets
|
||||||
|
String exportValue = null;
|
||||||
|
if (exportValues != null && i < exportValues.size()) {
|
||||||
|
exportValue = exportValues.get(i);
|
||||||
|
} else if (field instanceof PDButton) {
|
||||||
|
// Fall back to appearance state name from the widget's normal appearance
|
||||||
|
try {
|
||||||
|
var ap = widget.getAppearance();
|
||||||
|
if (ap != null && ap.getNormalAppearance() != null) {
|
||||||
|
var normalAp = ap.getNormalAppearance();
|
||||||
|
if (normalAp.isSubDictionary()) {
|
||||||
|
for (var cosName : normalAp.getSubDictionary().keySet()) {
|
||||||
|
String key = cosName.getName();
|
||||||
|
if (!"Off".equals(key)) {
|
||||||
|
exportValue = key;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.trace(
|
||||||
|
"Could not extract export value for widget in '{}': {}",
|
||||||
|
field.getFullyQualifiedName(),
|
||||||
|
e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.add(
|
||||||
|
createWidgetCoordinates(
|
||||||
|
document, rectangle, pageIndex, exportValue, field));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug(
|
||||||
|
"Failed to extract coordinates for widget in field '{}': {}",
|
||||||
|
field.getFullyQualifiedName(),
|
||||||
|
e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private FormFieldWithCoordinates.WidgetCoordinates createWidgetCoordinates(
|
||||||
|
PDDocument document,
|
||||||
|
PDRectangle rectangle,
|
||||||
|
int pageIndex,
|
||||||
|
String exportValue,
|
||||||
|
PDTerminalField field) {
|
||||||
|
if (pageIndex < 0 || pageIndex >= document.getNumberOfPages()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
PDPage page = document.getPage(pageIndex);
|
||||||
|
PDRectangle cropBox = page.getCropBox();
|
||||||
|
|
||||||
|
// Use CropBox dimensions for the y-flip.
|
||||||
|
// Note: getWidth() and getHeight() return dimensions BEFORE rotation.
|
||||||
|
float cropHeight = cropBox.getHeight();
|
||||||
|
|
||||||
|
// Get absolute widget coordinates (in MediaBox space, un-rotated)
|
||||||
|
float pdfX = rectangle.getLowerLeftX();
|
||||||
|
float pdfY = rectangle.getLowerLeftY();
|
||||||
|
float width = rectangle.getWidth();
|
||||||
|
float height = rectangle.getHeight();
|
||||||
|
|
||||||
|
// Adjust relative to CropBox origin
|
||||||
|
float relativeX = pdfX - cropBox.getLowerLeftX();
|
||||||
|
float relativeY = pdfY - cropBox.getLowerLeftY();
|
||||||
|
|
||||||
|
// Convert from PDF lower-left origin to CSS upper-left origin (y-flip).
|
||||||
|
// Widget /Rect coordinates are always in un-rotated PDF user space.
|
||||||
|
// The embedpdf viewer wraps all page content inside a <Rotate> CSS
|
||||||
|
// component that handles visual rotation — we must NOT apply any
|
||||||
|
// rotation transform here, or widgets would be double-rotated.
|
||||||
|
float finalX = relativeX;
|
||||||
|
float finalY = cropHeight - relativeY - height;
|
||||||
|
float finalW = width;
|
||||||
|
float finalH = height;
|
||||||
|
|
||||||
|
// Validate coordinates are within reasonable bounds
|
||||||
|
if (finalX < -1.0f
|
||||||
|
|| finalY < -1.0f
|
||||||
|
|| finalX > cropBox.getWidth() * 2 // Allow some horizontal overflow
|
||||||
|
|| finalY > cropHeight + 1.0f) {
|
||||||
|
log.warn(
|
||||||
|
"Widget coordinates out of bounds for field '{}': page={}, x={}, y={}, w={}, h={}",
|
||||||
|
field.getFullyQualifiedName(),
|
||||||
|
pageIndex,
|
||||||
|
finalX,
|
||||||
|
finalY,
|
||||||
|
finalW,
|
||||||
|
finalH);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return FormFieldWithCoordinates.WidgetCoordinates.builder()
|
||||||
|
.pageIndex(pageIndex)
|
||||||
|
.x(finalX)
|
||||||
|
.y(finalY)
|
||||||
|
.width(finalW)
|
||||||
|
.height(finalH)
|
||||||
|
.exportValue(exportValue)
|
||||||
|
.fontSize(extractFontSize(field))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repairs widgets with missing page references by scanning all pages and setting the /P entry
|
||||||
|
* for orphan widgets.
|
||||||
|
*
|
||||||
|
* <p>This should be called BEFORE extracting form field coordinates.
|
||||||
|
*
|
||||||
|
* @param document PDF document to repair
|
||||||
|
*/
|
||||||
|
public void repairMissingWidgetPageReferences(PDDocument document) {
|
||||||
|
try {
|
||||||
|
PDAcroForm acroForm = getAcroFormSafely(document);
|
||||||
|
if (acroForm == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("Checking for widgets with missing page references...");
|
||||||
|
int repairedCount = 0;
|
||||||
|
|
||||||
|
Map<COSDictionary, Integer> annotationPageMap = buildAnnotationPageMap(document);
|
||||||
|
|
||||||
|
for (PDField field : acroForm.getFieldTree()) {
|
||||||
|
if (!(field instanceof PDTerminalField terminalField)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<PDAnnotationWidget> widgets = terminalField.getWidgets();
|
||||||
|
|
||||||
|
if (widgets == null || widgets.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (PDAnnotationWidget widget : widgets) {
|
||||||
|
if (widget.getPage() == null) {
|
||||||
|
Integer pageIndex = annotationPageMap.get(widget.getCOSObject());
|
||||||
|
if (pageIndex != null && pageIndex >= 0) {
|
||||||
|
PDPage foundPage = document.getPage(pageIndex);
|
||||||
|
widget.setPage(foundPage);
|
||||||
|
repairedCount++;
|
||||||
|
log.debug(
|
||||||
|
"Repaired widget for field '{}' - set page reference via map",
|
||||||
|
field.getFullyQualifiedName());
|
||||||
|
} else {
|
||||||
|
log.warn(
|
||||||
|
"Could not find page for widget in field '{}'",
|
||||||
|
field.getFullyQualifiedName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (repairedCount > 0) {
|
||||||
|
log.debug(
|
||||||
|
"Successfully repaired {} widgets with missing page references",
|
||||||
|
repairedCount);
|
||||||
|
} else {
|
||||||
|
log.debug("No widgets needed repair");
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error repairing widget page references: {}", e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int findPageIndexForAnnotation(
|
||||||
|
PDDocument document,
|
||||||
|
COSDictionary annotDict,
|
||||||
|
Map<COSDictionary, Integer> annotationPageMap) {
|
||||||
|
try {
|
||||||
|
// Method 0: Check the pre-built lookup map (fastest)
|
||||||
|
if (annotationPageMap != null) {
|
||||||
|
Integer idx = annotationPageMap.get(annotDict);
|
||||||
|
if (idx != null) {
|
||||||
|
return idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 1: Check the /P entry if it points to a page
|
||||||
|
COSBase base = annotDict.getDictionaryObject(COSName.P);
|
||||||
|
COSDictionary pageDict = (base instanceof COSDictionary c) ? c : null;
|
||||||
|
if (pageDict != null) {
|
||||||
|
for (int i = 0; i < document.getNumberOfPages(); i++) {
|
||||||
|
if (document.getPage(i).getCOSObject() == pageDict) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 2: Fallback search through all pages' annotations
|
||||||
|
for (int i = 0; i < document.getNumberOfPages(); i++) {
|
||||||
|
PDPage page = document.getPage(i);
|
||||||
|
List<PDAnnotation> annotations = page.getAnnotations();
|
||||||
|
if (annotations != null) {
|
||||||
|
for (PDAnnotation annot : annotations) {
|
||||||
|
if (annot != null && annot.getCOSObject() == annotDict) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.trace("Error finding page for annotation: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a single record object (field-name -> value placeholder) that can be directly submitted
|
* Build a single record object (field-name -> value placeholder) that can be directly submitted
|
||||||
* to /api/v1/form/fill as the 'data' JSON. For checkboxes a boolean false is supplied unless
|
* to /api/v1/form/fill as the 'data' JSON. For checkboxes a boolean false is supplied unless
|
||||||
@@ -312,7 +714,24 @@ public class FormUtils {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
flattenViaRendering(document, acroForm);
|
if (acroForm == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use PDFBox's built-in field flattening which bakes form field values
|
||||||
|
// into the page content stream as static text/graphics, removing the
|
||||||
|
// interactive form structure but preserving all other document content
|
||||||
|
// (images, text, annotations, etc.) at full quality.
|
||||||
|
try {
|
||||||
|
ensureAppearances(acroForm);
|
||||||
|
acroForm.flatten();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn(
|
||||||
|
"PDFBox acroForm.flatten() failed, falling back to rendering: {}",
|
||||||
|
e.getMessage(),
|
||||||
|
e);
|
||||||
|
flattenViaRendering(document, acroForm);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void rebuildDocumentFromImages(PDDocument document, PDFRenderer renderer, int dpi)
|
private void rebuildDocumentFromImages(PDDocument document, PDFRenderer renderer, int dpi)
|
||||||
@@ -385,7 +804,7 @@ public class FormUtils {
|
|||||||
|
|
||||||
PDPage page = widget.getPage();
|
PDPage page = widget.getPage();
|
||||||
if (page == null) {
|
if (page == null) {
|
||||||
page = resolveWidgetPage(document, widget);
|
page = resolveWidgetPage(document, widget, null);
|
||||||
if (page != null) {
|
if (page != null) {
|
||||||
widget.setPage(page);
|
widget.setPage(page);
|
||||||
}
|
}
|
||||||
@@ -820,6 +1239,16 @@ public class FormUtils {
|
|||||||
|
|
||||||
private String safeValue(PDTerminalField field) {
|
private String safeValue(PDTerminalField field) {
|
||||||
try {
|
try {
|
||||||
|
// PDChoice.getValueAsString() returns a raw COS string representation
|
||||||
|
// that doesn't reliably reflect the selected value. Use getValue()
|
||||||
|
// which returns the proper List<String> of selected options.
|
||||||
|
if (field instanceof PDChoice choiceField) {
|
||||||
|
List<String> selected = choiceField.getValue();
|
||||||
|
if (selected == null || selected.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return String.join(",", selected);
|
||||||
|
}
|
||||||
return field.getValueAsString();
|
return field.getValueAsString();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.debug(
|
log.debug(
|
||||||
@@ -833,14 +1262,25 @@ public class FormUtils {
|
|||||||
List<String> resolveOptions(PDTerminalField field) {
|
List<String> resolveOptions(PDTerminalField field) {
|
||||||
try {
|
try {
|
||||||
if (field instanceof PDChoice choice) {
|
if (field instanceof PDChoice choice) {
|
||||||
List<String> display = choice.getOptionsDisplayValues();
|
LinkedHashSet<String> allowed = new LinkedHashSet<>();
|
||||||
if (display != null && !display.isEmpty()) {
|
|
||||||
return new ArrayList<>(display);
|
|
||||||
}
|
|
||||||
List<String> exportValues = choice.getOptionsExportValues();
|
List<String> exportValues = choice.getOptionsExportValues();
|
||||||
if (exportValues != null && !exportValues.isEmpty()) {
|
List<String> displayValues = choice.getOptionsDisplayValues();
|
||||||
return new ArrayList<>(exportValues);
|
|
||||||
|
if (exportValues != null) {
|
||||||
|
exportValues.stream()
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(s -> !s.isEmpty())
|
||||||
|
.forEach(allowed::add);
|
||||||
}
|
}
|
||||||
|
if (displayValues != null) {
|
||||||
|
displayValues.stream()
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(s -> !s.isEmpty())
|
||||||
|
.forEach(allowed::add);
|
||||||
|
}
|
||||||
|
return new ArrayList<>(allowed);
|
||||||
} else if (field instanceof PDRadioButton radio) {
|
} else if (field instanceof PDRadioButton radio) {
|
||||||
List<String> exports = radio.getExportValues();
|
List<String> exports = radio.getExportValues();
|
||||||
if (exports != null && !exports.isEmpty()) {
|
if (exports != null && !exports.isEmpty()) {
|
||||||
@@ -861,6 +1301,29 @@ public class FormUtils {
|
|||||||
return Collections.emptyList();
|
return Collections.emptyList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the display-value labels for a choice field's options. For radio / checkbox this
|
||||||
|
* returns an empty list (no separate display values). For PDChoice fields, if the PDF provides
|
||||||
|
* distinct display values, those are returned; otherwise an empty list (indicating that the
|
||||||
|
* export values from {@link #resolveOptions} should be shown directly).
|
||||||
|
*/
|
||||||
|
List<String> resolveDisplayOptions(PDTerminalField field) {
|
||||||
|
try {
|
||||||
|
if (field instanceof PDChoice choice) {
|
||||||
|
List<String> display = choice.getOptionsDisplayValues();
|
||||||
|
if (display != null && !display.isEmpty()) {
|
||||||
|
return new ArrayList<>(display);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug(
|
||||||
|
"Failed to resolve display options for field '{}': {}",
|
||||||
|
field.getFullyQualifiedName(),
|
||||||
|
e.getMessage());
|
||||||
|
}
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
private boolean resolveMultiSelect(PDTerminalField field) {
|
private boolean resolveMultiSelect(PDTerminalField field) {
|
||||||
if (field instanceof PDListBox listBox) {
|
if (field instanceof PDListBox listBox) {
|
||||||
try {
|
try {
|
||||||
@@ -875,6 +1338,44 @@ public class FormUtils {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Float extractFontSize(PDTerminalField field) {
|
||||||
|
try {
|
||||||
|
String da = null;
|
||||||
|
if (field instanceof PDVariableText vt) {
|
||||||
|
da = vt.getDefaultAppearance();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (da == null || da.isBlank()) {
|
||||||
|
// Check parent/acroform default appearance if field's is missing
|
||||||
|
PDAcroForm form = field.getAcroForm();
|
||||||
|
if (form != null) {
|
||||||
|
da = form.getDefaultAppearance();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (da != null && !da.isBlank()) {
|
||||||
|
// Standard DA looks like: /Helv 12 Tf 0 g
|
||||||
|
// We want the number before 'Tf'
|
||||||
|
String[] tokens = da.split("\\s+");
|
||||||
|
for (int i = 0; i < tokens.length; i++) {
|
||||||
|
if ("Tf".equals(tokens[i]) && i > 0) {
|
||||||
|
try {
|
||||||
|
float size = Float.parseFloat(tokens[i - 1]);
|
||||||
|
return size > 0 ? size : null;
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.trace(
|
||||||
|
"Could not extract font size for field '{}': {}",
|
||||||
|
field.getFullyQualifiedName(),
|
||||||
|
e.getMessage());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private boolean isSettableCheckBoxState(String state) {
|
private boolean isSettableCheckBoxState(String state) {
|
||||||
if (state == null) return false;
|
if (state == null) return false;
|
||||||
String trimmed = state.trim();
|
String trimmed = state.trim();
|
||||||
@@ -952,6 +1453,13 @@ public class FormUtils {
|
|||||||
|
|
||||||
if (simplified.isEmpty()) return true;
|
if (simplified.isEmpty()) return true;
|
||||||
|
|
||||||
|
// Detect UUID-like hex strings (e.g. "cdc47b7041524571 7b2d93017fe77bf7")
|
||||||
|
// Standard UUIDs are 32 hex characters; require at least that to avoid
|
||||||
|
// false positives on short hex-like field names.
|
||||||
|
String nospaces = simplified.replaceAll("\\s+", "");
|
||||||
|
if (nospaces.length() >= 32 && nospaces.matches("^[0-9a-fA-F]{8}[0-9a-fA-F]{24,}$"))
|
||||||
|
return true;
|
||||||
|
|
||||||
return patterns.getGenericFieldNamePattern().matcher(simplified).matches()
|
return patterns.getGenericFieldNamePattern().matcher(simplified).matches()
|
||||||
|| patterns.getSimpleFormFieldPattern().matcher(simplified).matches()
|
|| patterns.getSimpleFormFieldPattern().matcher(simplified).matches()
|
||||||
|| patterns.getOptionalTNumericPattern().matcher(simplified).matches();
|
|| patterns.getOptionalTNumericPattern().matcher(simplified).matches();
|
||||||
@@ -1007,7 +1515,7 @@ public class FormUtils {
|
|||||||
|
|
||||||
PDAnnotationWidget widget = widgets.get(0);
|
PDAnnotationWidget widget = widgets.get(0);
|
||||||
PDRectangle originalRectangle = cloneRectangle(widget.getRectangle());
|
PDRectangle originalRectangle = cloneRectangle(widget.getRectangle());
|
||||||
PDPage page = resolveWidgetPage(document, widget);
|
PDPage page = resolveWidgetPage(document, widget, null);
|
||||||
if (page == null || originalRectangle == null) {
|
if (page == null || originalRectangle == null) {
|
||||||
log.warn(
|
log.warn(
|
||||||
"Unable to resolve widget page or rectangle for '{}'; skipping",
|
"Unable to resolve widget page or rectangle for '{}'; skipping",
|
||||||
@@ -1064,7 +1572,7 @@ public class FormUtils {
|
|||||||
desiredName,
|
desiredName,
|
||||||
modification.label(),
|
modification.label(),
|
||||||
resolvedType,
|
resolvedType,
|
||||||
determineWidgetPageIndex(document, widget),
|
determineWidgetPageIndex(document, widget, null),
|
||||||
originalRectangle.getLowerLeftX(),
|
originalRectangle.getLowerLeftX(),
|
||||||
originalRectangle.getLowerLeftY(),
|
originalRectangle.getLowerLeftY(),
|
||||||
originalRectangle.getWidth(),
|
originalRectangle.getWidth(),
|
||||||
@@ -1205,59 +1713,43 @@ public class FormUtils {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private int resolveFirstWidgetPageIndex(PDDocument document, PDTerminalField field) {
|
private int resolveFirstWidgetPageIndex(
|
||||||
|
PDDocument document,
|
||||||
|
PDTerminalField field,
|
||||||
|
Map<COSDictionary, Integer> annotationPageMap) {
|
||||||
List<PDAnnotationWidget> widgets = field.getWidgets();
|
List<PDAnnotationWidget> widgets = field.getWidgets();
|
||||||
if (widgets == null || widgets.isEmpty()) {
|
if (widgets == null || widgets.isEmpty()) {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
Map<PDAnnotationWidget, Integer> widgetPageFallbacks = null;
|
|
||||||
for (PDAnnotationWidget widget : widgets) {
|
for (PDAnnotationWidget widget : widgets) {
|
||||||
int idx = resolveWidgetPageIndex(document, widget);
|
int idx = resolveWidgetPageIndex(document, widget, annotationPageMap);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
return idx;
|
return idx;
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
COSDictionary widgetDictionary = widget.getCOSObject();
|
|
||||||
if (widgetDictionary != null
|
|
||||||
&& widgetDictionary.getDictionaryObject(COSName.P) == null) {
|
|
||||||
if (widgetPageFallbacks == null) {
|
|
||||||
widgetPageFallbacks = buildWidgetPageFallbackMap(document);
|
|
||||||
}
|
|
||||||
Integer fallbackIndex = widgetPageFallbacks.get(widget);
|
|
||||||
if (fallbackIndex != null && fallbackIndex >= 0) {
|
|
||||||
return fallbackIndex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.debug(
|
|
||||||
"Failed to inspect widget page reference for field '{}': {}",
|
|
||||||
field.getFullyQualifiedName(),
|
|
||||||
e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
private int resolveWidgetPageIndex(PDDocument document, PDAnnotationWidget widget) {
|
private int resolveWidgetPageIndex(
|
||||||
|
PDDocument document,
|
||||||
|
PDAnnotationWidget widget,
|
||||||
|
Map<COSDictionary, Integer> annotationPageMap) {
|
||||||
if (document == null || widget == null) {
|
if (document == null || widget == null) {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
COSDictionary widgetDictionary = widget.getCOSObject();
|
// Method 0: Check the pre-built lookup map (fastest)
|
||||||
if (widgetDictionary != null
|
if (annotationPageMap != null) {
|
||||||
&& widgetDictionary.getDictionaryObject(COSName.P) == null) {
|
Integer idx = annotationPageMap.get(widget.getCOSObject());
|
||||||
Map<PDAnnotationWidget, Integer> fallback = buildWidgetPageFallbackMap(document);
|
if (idx != null) {
|
||||||
Integer index = fallback.get(widget);
|
return idx;
|
||||||
if (index != null) {
|
|
||||||
return index;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
|
||||||
log.debug("Widget page lookup via fallback map failed: {}", e.getMessage());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
PDPage page = widget.getPage();
|
PDPage page = widget.getPage();
|
||||||
if (page != null) {
|
if (page != null) {
|
||||||
|
// indexOf is O(N), still slower than map but better than scanning annotations
|
||||||
int idx = document.getPages().indexOf(page);
|
int idx = document.getPages().indexOf(page);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
return idx;
|
return idx;
|
||||||
@@ -1267,14 +1759,36 @@ public class FormUtils {
|
|||||||
log.debug("Widget page lookup failed: {}", e.getMessage());
|
log.debug("Widget page lookup failed: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Method 1: Check the /P entry if it points to a page
|
||||||
|
try {
|
||||||
|
COSDictionary widgetDictionary = widget.getCOSObject();
|
||||||
|
if (widgetDictionary != null) {
|
||||||
|
COSBase base = widgetDictionary.getDictionaryObject(COSName.P);
|
||||||
|
COSDictionary pageDict = (base instanceof COSDictionary c) ? c : null;
|
||||||
|
if (pageDict != null) {
|
||||||
|
for (int i = 0; i < document.getNumberOfPages(); i++) {
|
||||||
|
if (document.getPage(i).getCOSObject() == pageDict) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("Widget page lookup via /P entry failed: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 2: Fallback search through all pages' annotations
|
||||||
int pageCount = document.getNumberOfPages();
|
int pageCount = document.getNumberOfPages();
|
||||||
|
COSDictionary widgetDict = widget.getCOSObject();
|
||||||
for (int i = 0; i < pageCount; i++) {
|
for (int i = 0; i < pageCount; i++) {
|
||||||
try {
|
try {
|
||||||
PDPage candidate = document.getPage(i);
|
PDPage candidate = document.getPage(i);
|
||||||
List<PDAnnotation> annotations = candidate.getAnnotations();
|
List<PDAnnotation> annotations = candidate.getAnnotations();
|
||||||
for (PDAnnotation annotation : annotations) {
|
if (annotations != null) {
|
||||||
if (annotation == widget) {
|
for (PDAnnotation annot : annotations) {
|
||||||
return i;
|
if (annot != null && annot.getCOSObject() == widgetDict) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
@@ -1317,7 +1831,7 @@ public class FormUtils {
|
|||||||
List<PDAnnotationWidget> widgets = field.getWidgets();
|
List<PDAnnotationWidget> widgets = field.getWidgets();
|
||||||
if (widgets != null) {
|
if (widgets != null) {
|
||||||
for (PDAnnotationWidget widget : widgets) {
|
for (PDAnnotationWidget widget : widgets) {
|
||||||
PDPage page = resolveWidgetPage(document, widget);
|
PDPage page = resolveWidgetPage(document, widget, null);
|
||||||
if (page != null) {
|
if (page != null) {
|
||||||
page.getAnnotations().remove(widget);
|
page.getAnnotations().remove(widget);
|
||||||
}
|
}
|
||||||
@@ -1437,7 +1951,10 @@ public class FormUtils {
|
|||||||
rectangle.getHeight());
|
rectangle.getHeight());
|
||||||
}
|
}
|
||||||
|
|
||||||
private PDPage resolveWidgetPage(PDDocument document, PDAnnotationWidget widget) {
|
private PDPage resolveWidgetPage(
|
||||||
|
PDDocument document,
|
||||||
|
PDAnnotationWidget widget,
|
||||||
|
Map<COSDictionary, Integer> annotationPageMap) {
|
||||||
if (widget == null) {
|
if (widget == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1445,7 +1962,7 @@ public class FormUtils {
|
|||||||
if (page != null) {
|
if (page != null) {
|
||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
int pageIndex = determineWidgetPageIndex(document, widget);
|
int pageIndex = determineWidgetPageIndex(document, widget, annotationPageMap);
|
||||||
if (pageIndex >= 0) {
|
if (pageIndex >= 0) {
|
||||||
try {
|
try {
|
||||||
return document.getPage(pageIndex);
|
return document.getPage(pageIndex);
|
||||||
@@ -1456,11 +1973,21 @@ public class FormUtils {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private int determineWidgetPageIndex(PDDocument document, PDAnnotationWidget widget) {
|
private int determineWidgetPageIndex(
|
||||||
|
PDDocument document,
|
||||||
|
PDAnnotationWidget widget,
|
||||||
|
Map<COSDictionary, Integer> annotationPageMap) {
|
||||||
if (document == null || widget == null) {
|
if (document == null || widget == null) {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (annotationPageMap != null) {
|
||||||
|
Integer idx = annotationPageMap.get(widget.getCOSObject());
|
||||||
|
if (idx != null) {
|
||||||
|
return idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
PDPage directPage = widget.getPage();
|
PDPage directPage = widget.getPage();
|
||||||
if (directPage != null) {
|
if (directPage != null) {
|
||||||
int index = 0;
|
int index = 0;
|
||||||
@@ -1488,6 +2015,33 @@ public class FormUtils {
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a map of annotation COS dictionaries to their respective page index. Scan once
|
||||||
|
* per-document to avoid O(N^2) lookups during field extraction.
|
||||||
|
*/
|
||||||
|
public Map<COSDictionary, Integer> buildAnnotationPageMap(PDDocument document) {
|
||||||
|
if (document == null) {
|
||||||
|
return Collections.emptyMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<COSDictionary, Integer> map = new HashMap<>();
|
||||||
|
int pageCount = document.getNumberOfPages();
|
||||||
|
for (int i = 0; i < pageCount; i++) {
|
||||||
|
try {
|
||||||
|
PDPage page = document.getPage(i);
|
||||||
|
List<PDAnnotation> annotations = page.getAnnotations();
|
||||||
|
for (PDAnnotation annot : annotations) {
|
||||||
|
if (annot != null) {
|
||||||
|
map.putIfAbsent(annot.getCOSObject(), i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("Failed to index annotations for page {}: {}", i, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
private Map<PDAnnotationWidget, Integer> buildWidgetPageFallbackMap(PDDocument document) {
|
private Map<PDAnnotationWidget, Integer> buildWidgetPageFallbackMap(PDDocument document) {
|
||||||
if (document == null) {
|
if (document == null) {
|
||||||
return Collections.emptyMap();
|
return Collections.emptyMap();
|
||||||
@@ -1760,4 +2314,46 @@ public class FormUtils {
|
|||||||
boolean multiSelect,
|
boolean multiSelect,
|
||||||
String tooltip,
|
String tooltip,
|
||||||
int pageOrder) {}
|
int pageOrder) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comparator for sorting form fields by page, then vertically (top-to-bottom), then
|
||||||
|
* horizontally (left-to-right) for fields on approximately the same line.
|
||||||
|
*/
|
||||||
|
static final class FieldCoordinateComparator implements Comparator<FormFieldWithCoordinates> {
|
||||||
|
|
||||||
|
private static int firstWidgetPageIndex(FormFieldWithCoordinates f) {
|
||||||
|
return (f.getWidgets() != null && !f.getWidgets().isEmpty())
|
||||||
|
? f.getWidgets().get(0).getPageIndex()
|
||||||
|
: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float firstWidgetY(FormFieldWithCoordinates f) {
|
||||||
|
return (f.getWidgets() != null && !f.getWidgets().isEmpty())
|
||||||
|
? f.getWidgets().get(0).getY()
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float firstWidgetX(FormFieldWithCoordinates f) {
|
||||||
|
return (f.getWidgets() != null && !f.getWidgets().isEmpty())
|
||||||
|
? f.getWidgets().get(0).getX()
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int compare(FormFieldWithCoordinates a, FormFieldWithCoordinates b) {
|
||||||
|
int pageA = firstWidgetPageIndex(a);
|
||||||
|
int pageB = firstWidgetPageIndex(b);
|
||||||
|
int pageCompare = Integer.compare(pageA, pageB);
|
||||||
|
if (pageCompare != 0) return pageCompare;
|
||||||
|
|
||||||
|
float yA = firstWidgetY(a);
|
||||||
|
float yB = firstWidgetY(b);
|
||||||
|
|
||||||
|
// Fields on approximately the same line should be sorted left-to-right
|
||||||
|
if (Math.abs(yA - yB) < SAME_LINE_THRESHOLD_PT) {
|
||||||
|
return Float.compare(firstWidgetX(a), firstWidgetX(b));
|
||||||
|
}
|
||||||
|
return Float.compare(yA, yB);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@ plugins {
|
|||||||
id "org.springframework.boot" version "3.5.9"
|
id "org.springframework.boot" version "3.5.9"
|
||||||
id "org.springdoc.openapi-gradle-plugin" version "1.9.0"
|
id "org.springdoc.openapi-gradle-plugin" version "1.9.0"
|
||||||
id "io.swagger.swaggerhub" version "1.3.2"
|
id "io.swagger.swaggerhub" version "1.3.2"
|
||||||
id "com.diffplug.spotless" version "8.2.1"
|
id "com.diffplug.spotless" version "8.1.0"
|
||||||
id "com.github.jk1.dependency-license-report" version "3.0.1"
|
id "com.github.jk1.dependency-license-report" version "3.0.1"
|
||||||
//id "nebula.lint" version "19.0.3"
|
//id "nebula.lint" version "19.0.3"
|
||||||
id "org.sonarqube" version "7.2.2.6593"
|
id "org.sonarqube" version "7.2.2.6593"
|
||||||
@@ -67,7 +67,7 @@ springBoot {
|
|||||||
|
|
||||||
allprojects {
|
allprojects {
|
||||||
group = 'stirling.software'
|
group = 'stirling.software'
|
||||||
version = '2.4.6'
|
version = '2.4.5'
|
||||||
|
|
||||||
configurations.configureEach {
|
configurations.configureEach {
|
||||||
exclude group: 'commons-logging', module: 'commons-logging'
|
exclude group: 'commons-logging', module: 'commons-logging'
|
||||||
|
|||||||
@@ -3300,6 +3300,10 @@ desc = "Build multi-step workflows by chaining together PDF actions. Ideal for r
|
|||||||
tags = "workflow,sequence,automation,automate,batch,batch processing,pipeline,chain,multi-step,recurring,scheduled,automatic,process multiple,bulk operations"
|
tags = "workflow,sequence,automation,automate,batch,batch processing,pipeline,chain,multi-step,recurring,scheduled,automatic,process multiple,bulk operations"
|
||||||
title = "Automate"
|
title = "Automate"
|
||||||
|
|
||||||
|
[home.formFill]
|
||||||
|
desc = "Fill PDF form fields interactively with a visual editor"
|
||||||
|
title = "Fill Form"
|
||||||
|
|
||||||
[home.autoRename]
|
[home.autoRename]
|
||||||
desc = "Auto renames a PDF file based on its detected header"
|
desc = "Auto renames a PDF file based on its detected header"
|
||||||
tags = "auto-detect,header-based,organize,relabel,auto rename,automatic rename,smart rename,rename by content,filename,file naming,detect title"
|
tags = "auto-detect,header-based,organize,relabel,auto rename,automatic rename,smart rename,rename by content,filename,file naming,detect title"
|
||||||
@@ -5282,6 +5286,7 @@ annotations = "Annotations"
|
|||||||
applyRedactionsFirst = "Apply redactions first"
|
applyRedactionsFirst = "Apply redactions first"
|
||||||
closePdf = "Close PDF"
|
closePdf = "Close PDF"
|
||||||
closeSelected = "Close Selected Files"
|
closeSelected = "Close Selected Files"
|
||||||
|
formFill = "Fill Form"
|
||||||
deleteSelected = "Delete Selected Pages"
|
deleteSelected = "Delete Selected Pages"
|
||||||
deselectAll = "Deselect All"
|
deselectAll = "Deselect All"
|
||||||
downloadAll = "Download All"
|
downloadAll = "Download All"
|
||||||
@@ -6447,6 +6452,15 @@ loading = "Loading attachments..."
|
|||||||
empty = "No attachments in this document"
|
empty = "No attachments in this document"
|
||||||
noMatch = "No attachments match your search"
|
noMatch = "No attachments match your search"
|
||||||
|
|
||||||
|
[viewer.formBar]
|
||||||
|
title = "Form Fields"
|
||||||
|
unsavedBadge = "Unsaved"
|
||||||
|
unsavedDesc = "You have unsaved changes"
|
||||||
|
hasFieldsDesc = "This PDF contains fillable fields"
|
||||||
|
dismiss = "Dismiss"
|
||||||
|
apply = "Apply Changes"
|
||||||
|
download = "Download PDF"
|
||||||
|
|
||||||
[viewPdf]
|
[viewPdf]
|
||||||
header = "View PDF"
|
header = "View PDF"
|
||||||
tags = "view,read,annotate,text,image,highlight,edit"
|
tags = "view,read,annotate,text,image,highlight,edit"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { useAppInitialization } from "@app/hooks/useAppInitialization";
|
|||||||
import { useLogoAssets } from '@app/hooks/useLogoAssets';
|
import { useLogoAssets } from '@app/hooks/useLogoAssets';
|
||||||
import AppConfigLoader from '@app/components/shared/AppConfigLoader';
|
import AppConfigLoader from '@app/components/shared/AppConfigLoader';
|
||||||
import { RedactionProvider } from "@app/contexts/RedactionContext";
|
import { RedactionProvider } from "@app/contexts/RedactionContext";
|
||||||
|
import { FormFillProvider } from "@app/tools/formFill/FormFillContext";
|
||||||
|
|
||||||
// Component to initialize scarf tracking (must be inside AppConfigProvider)
|
// Component to initialize scarf tracking (must be inside AppConfigProvider)
|
||||||
function ScarfTrackingInitializer() {
|
function ScarfTrackingInitializer() {
|
||||||
@@ -98,6 +99,7 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
|||||||
<PageEditorProvider>
|
<PageEditorProvider>
|
||||||
<SignatureProvider>
|
<SignatureProvider>
|
||||||
<RedactionProvider>
|
<RedactionProvider>
|
||||||
|
<FormFillProvider>
|
||||||
<AnnotationProvider>
|
<AnnotationProvider>
|
||||||
<RightRailProvider>
|
<RightRailProvider>
|
||||||
<TourOrchestrationProvider>
|
<TourOrchestrationProvider>
|
||||||
@@ -107,6 +109,7 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
|||||||
</TourOrchestrationProvider>
|
</TourOrchestrationProvider>
|
||||||
</RightRailProvider>
|
</RightRailProvider>
|
||||||
</AnnotationProvider>
|
</AnnotationProvider>
|
||||||
|
</FormFillProvider>
|
||||||
</RedactionProvider>
|
</RedactionProvider>
|
||||||
</SignatureProvider>
|
</SignatureProvider>
|
||||||
</PageEditorProvider>
|
</PageEditorProvider>
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import { isStirlingFile } from '@app/types/fileContext';
|
|||||||
import { useViewerRightRailButtons } from '@app/components/viewer/useViewerRightRailButtons';
|
import { useViewerRightRailButtons } from '@app/components/viewer/useViewerRightRailButtons';
|
||||||
import { StampPlacementOverlay } from '@app/components/viewer/StampPlacementOverlay';
|
import { StampPlacementOverlay } from '@app/components/viewer/StampPlacementOverlay';
|
||||||
import { useWheelZoom } from '@app/hooks/useWheelZoom';
|
import { useWheelZoom } from '@app/hooks/useWheelZoom';
|
||||||
|
import { useFormFill } from '@app/tools/formFill/FormFillContext';
|
||||||
|
import { FormSaveBar } from '@app/tools/formFill/FormSaveBar';
|
||||||
|
|
||||||
export interface EmbedPdfViewerProps {
|
export interface EmbedPdfViewerProps {
|
||||||
sidebarsVisible: boolean;
|
sidebarsVisible: boolean;
|
||||||
@@ -86,9 +88,15 @@ const EmbedPdfViewerContent = ({
|
|||||||
const pendingScrollRestoreRef = useRef<number | null>(null);
|
const pendingScrollRestoreRef = useRef<number | null>(null);
|
||||||
const scrollRestoreAttemptsRef = useRef<number>(0);
|
const scrollRestoreAttemptsRef = useRef<number>(0);
|
||||||
|
|
||||||
|
// Rotation preservation system
|
||||||
|
// Similar to scroll preservation - track rotation across file reloads
|
||||||
|
const pendingRotationRestoreRef = useRef<number | null>(null);
|
||||||
|
const rotationRestoreAttemptsRef = useRef<number>(0);
|
||||||
// Track the file ID we should be viewing after a save (to handle list reordering)
|
// Track the file ID we should be viewing after a save (to handle list reordering)
|
||||||
const pendingFileIdRef = useRef<string | null>(null);
|
const pendingFileIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const formApplyInProgressRef = useRef(false);
|
||||||
|
|
||||||
// Get redaction context
|
// Get redaction context
|
||||||
const { redactionsApplied, setRedactionsApplied } = useRedaction();
|
const { redactionsApplied, setRedactionsApplied } = useRedaction();
|
||||||
|
|
||||||
@@ -107,6 +115,9 @@ const EmbedPdfViewerContent = ({
|
|||||||
|
|
||||||
const { selectedTool } = useNavigationState();
|
const { selectedTool } = useNavigationState();
|
||||||
|
|
||||||
|
// Form fill context
|
||||||
|
const { fetchFields: fetchFormFields, setProviderMode } = useFormFill();
|
||||||
|
|
||||||
const isInAnnotationTool = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage' || selectedTool === 'annotate';
|
const isInAnnotationTool = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage' || selectedTool === 'annotate';
|
||||||
const isSignatureMode = isInAnnotationTool;
|
const isSignatureMode = isInAnnotationTool;
|
||||||
const isManualRedactMode = selectedTool === 'redact';
|
const isManualRedactMode = selectedTool === 'redact';
|
||||||
@@ -117,6 +128,19 @@ const EmbedPdfViewerContent = ({
|
|||||||
// Enable redaction only when redaction tool is selected
|
// Enable redaction only when redaction tool is selected
|
||||||
const shouldEnableRedaction = selectedTool === 'redact';
|
const shouldEnableRedaction = selectedTool === 'redact';
|
||||||
|
|
||||||
|
// FormFill tool mode — uses PDFBox backend for full-fidelity form handling
|
||||||
|
const isFormFillToolActive = (selectedTool as string) === 'formFill';
|
||||||
|
|
||||||
|
// Form overlays are shown in BOTH modes:
|
||||||
|
// - Normal viewer: form overlays visible (pdf-lib, frontend-only)
|
||||||
|
// - formFill tool: form overlays visible (PDFBox, backend)
|
||||||
|
const shouldEnableFormFill = true;
|
||||||
|
|
||||||
|
// Switch the provider when the tool mode changes
|
||||||
|
useEffect(() => {
|
||||||
|
setProviderMode(isFormFillToolActive ? 'pdfbox' : 'pdflib');
|
||||||
|
}, [isFormFillToolActive, setProviderMode]);
|
||||||
|
|
||||||
// Track previous annotation/redaction state to detect tool switches
|
// Track previous annotation/redaction state to detect tool switches
|
||||||
const prevEnableAnnotationsRef = useRef(shouldEnableAnnotations);
|
const prevEnableAnnotationsRef = useRef(shouldEnableAnnotations);
|
||||||
const prevEnableRedactionRef = useRef(shouldEnableRedaction);
|
const prevEnableRedactionRef = useRef(shouldEnableRedaction);
|
||||||
@@ -367,6 +391,9 @@ const EmbedPdfViewerContent = ({
|
|||||||
// Use the continuously tracked scroll position - more reliable than reading at this moment
|
// Use the continuously tracked scroll position - more reliable than reading at this moment
|
||||||
const pageToRestore = lastKnownScrollPageRef.current;
|
const pageToRestore = lastKnownScrollPageRef.current;
|
||||||
|
|
||||||
|
// Save the current rotation to restore after reload
|
||||||
|
const currentRotation = rotationState.rotation ?? 0;
|
||||||
|
|
||||||
// Step 0: Commit any pending redactions before export
|
// Step 0: Commit any pending redactions before export
|
||||||
const hadPendingRedactions = (redactionTrackerRef.current?.getPendingCount() ?? 0) > 0;
|
const hadPendingRedactions = (redactionTrackerRef.current?.getPendingCount() ?? 0) > 0;
|
||||||
|
|
||||||
@@ -408,6 +435,9 @@ const EmbedPdfViewerContent = ({
|
|||||||
pendingScrollRestoreRef.current = pageToRestore;
|
pendingScrollRestoreRef.current = pageToRestore;
|
||||||
scrollRestoreAttemptsRef.current = 0;
|
scrollRestoreAttemptsRef.current = 0;
|
||||||
|
|
||||||
|
// Store the rotation to restore after file replacement
|
||||||
|
pendingRotationRestoreRef.current = currentRotation;
|
||||||
|
rotationRestoreAttemptsRef.current = 0;
|
||||||
// Store the new file ID so we can track it after the list reorders
|
// Store the new file ID so we can track it after the list reorders
|
||||||
const newFileId = stubs[0]?.id;
|
const newFileId = stubs[0]?.id;
|
||||||
if (newFileId) {
|
if (newFileId) {
|
||||||
@@ -424,7 +454,72 @@ const EmbedPdfViewerContent = ({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Apply changes failed:', error);
|
console.error('Apply changes failed:', error);
|
||||||
}
|
}
|
||||||
}, [currentFile, activeFiles, activeFileIndex, exportActions, actions, selectors, setHasUnsavedChanges, setRedactionsApplied]);
|
}, [currentFile, activeFiles, activeFileIndex, exportActions, actions, selectors, setHasUnsavedChanges, setRedactionsApplied, rotationState.rotation]);
|
||||||
|
|
||||||
|
// Apply form fill changes - reload the filled PDF into the viewer
|
||||||
|
const handleFormApply = useCallback(async (filledBlob: Blob) => {
|
||||||
|
if (formApplyInProgressRef.current) return;
|
||||||
|
if (!currentFile || activeFileIds.length === 0) return;
|
||||||
|
|
||||||
|
formApplyInProgressRef.current = true;
|
||||||
|
try {
|
||||||
|
console.log('[Viewer] Applying form fill changes - reloading filled PDF');
|
||||||
|
|
||||||
|
// Use the continuously tracked scroll position
|
||||||
|
const pageToRestore = lastKnownScrollPageRef.current;
|
||||||
|
|
||||||
|
// Save the current rotation to restore after reload
|
||||||
|
const currentRotation = rotationState.rotation ?? 0;
|
||||||
|
|
||||||
|
// Convert Blob to File
|
||||||
|
const filename = currentFile.name || 'document.pdf';
|
||||||
|
const file = new File([filledBlob], filename, { type: 'application/pdf' });
|
||||||
|
|
||||||
|
// Get current file info for creating the updated version
|
||||||
|
const currentFileId = activeFiles[activeFileIndex]?.fileId;
|
||||||
|
if (!currentFileId) throw new Error('Current file ID not found');
|
||||||
|
|
||||||
|
const parentStub = selectors.getStirlingFileStub(currentFileId);
|
||||||
|
if (!parentStub) throw new Error('Parent stub not found');
|
||||||
|
|
||||||
|
// Create StirlingFiles and stubs for version history
|
||||||
|
const { stirlingFiles, stubs } = await createStirlingFilesAndStubs([file], parentStub, 'multiTool');
|
||||||
|
|
||||||
|
// Store the page to restore after file replacement
|
||||||
|
pendingScrollRestoreRef.current = pageToRestore;
|
||||||
|
scrollRestoreAttemptsRef.current = 0;
|
||||||
|
|
||||||
|
// Store the rotation to restore after file replacement
|
||||||
|
pendingRotationRestoreRef.current = currentRotation;
|
||||||
|
rotationRestoreAttemptsRef.current = 0;
|
||||||
|
|
||||||
|
// Store the new file ID for tracking
|
||||||
|
const newFileId = stubs[0]?.id;
|
||||||
|
if (newFileId) {
|
||||||
|
pendingFileIdRef.current = newFileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the current file in context
|
||||||
|
await actions.consumeFiles([currentFileId], stirlingFiles, stubs);
|
||||||
|
|
||||||
|
console.log('[Viewer] Form fill changes applied successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Viewer] Apply form changes failed:', error);
|
||||||
|
} finally {
|
||||||
|
formApplyInProgressRef.current = false;
|
||||||
|
}
|
||||||
|
}, [currentFile, activeFiles, activeFileIndex, actions, selectors, activeFileIds.length, rotationState.rotation]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
const blob = (e as CustomEvent).detail?.blob;
|
||||||
|
if (blob) {
|
||||||
|
handleFormApply(blob);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('formfill:apply', handler);
|
||||||
|
return () => window.removeEventListener('formfill:apply', handler);
|
||||||
|
}, [handleFormApply]);
|
||||||
|
|
||||||
// Discard pending redactions but save already-applied ones
|
// Discard pending redactions but save already-applied ones
|
||||||
// This is called when user clicks "Discard & Leave" - we want to:
|
// This is called when user clicks "Discard & Leave" - we want to:
|
||||||
@@ -439,6 +534,10 @@ const EmbedPdfViewerContent = ({
|
|||||||
try {
|
try {
|
||||||
console.log('[Viewer] Discarding pending marks but saving applied redactions');
|
console.log('[Viewer] Discarding pending marks but saving applied redactions');
|
||||||
|
|
||||||
|
// Save current view state to restore after file replacement
|
||||||
|
const pageToRestore = lastKnownScrollPageRef.current;
|
||||||
|
const currentRotation = rotationState.rotation ?? 0;
|
||||||
|
|
||||||
// Export PDF WITHOUT committing pending marks - this saves only applied redactions
|
// Export PDF WITHOUT committing pending marks - this saves only applied redactions
|
||||||
const arrayBuffer = await exportActions.saveAsCopy();
|
const arrayBuffer = await exportActions.saveAsCopy();
|
||||||
if (!arrayBuffer) {
|
if (!arrayBuffer) {
|
||||||
@@ -459,6 +558,12 @@ const EmbedPdfViewerContent = ({
|
|||||||
|
|
||||||
const { stirlingFiles, stubs } = await createStirlingFilesAndStubs([file], parentStub, 'multiTool');
|
const { stirlingFiles, stubs } = await createStirlingFilesAndStubs([file], parentStub, 'multiTool');
|
||||||
|
|
||||||
|
// Store view state to restore after file replacement
|
||||||
|
pendingScrollRestoreRef.current = pageToRestore;
|
||||||
|
scrollRestoreAttemptsRef.current = 0;
|
||||||
|
pendingRotationRestoreRef.current = currentRotation;
|
||||||
|
rotationRestoreAttemptsRef.current = 0;
|
||||||
|
|
||||||
// Consume only the current file (replace in context)
|
// Consume only the current file (replace in context)
|
||||||
await actions.consumeFiles([currentFileId], stirlingFiles, stubs);
|
await actions.consumeFiles([currentFileId], stirlingFiles, stubs);
|
||||||
|
|
||||||
@@ -470,7 +575,7 @@ const EmbedPdfViewerContent = ({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save applied redactions:', error);
|
console.error('Failed to save applied redactions:', error);
|
||||||
}
|
}
|
||||||
}, [redactionsApplied, currentFile, activeFiles, activeFileIndex, activeFileIds.length, exportActions, actions, selectors, setRedactionsApplied]);
|
}, [redactionsApplied, currentFile, activeFiles, activeFileIndex, activeFileIds.length, exportActions, actions, selectors, setRedactionsApplied, rotationState.rotation]);
|
||||||
|
|
||||||
// Restore scroll position after file replacement or tool switch
|
// Restore scroll position after file replacement or tool switch
|
||||||
// Uses polling with retries to ensure the scroll succeeds
|
// Uses polling with retries to ensure the scroll succeeds
|
||||||
@@ -524,6 +629,57 @@ const EmbedPdfViewerContent = ({
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [scrollState.totalPages, scrollActions, getScrollState]);
|
}, [scrollState.totalPages, scrollActions, getScrollState]);
|
||||||
|
|
||||||
|
// Restore rotation after file replacement or tool switch
|
||||||
|
// Uses polling with retries to ensure the rotation succeeds
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingRotationRestoreRef.current === null) return;
|
||||||
|
|
||||||
|
const rotationToRestore = pendingRotationRestoreRef.current;
|
||||||
|
const maxAttempts = 10;
|
||||||
|
const attemptInterval = 100; // ms between attempts
|
||||||
|
|
||||||
|
const attemptRotation = () => {
|
||||||
|
const currentState = getScrollState();
|
||||||
|
|
||||||
|
// Only attempt if PDF is loaded (totalPages > 0)
|
||||||
|
if (currentState.totalPages > 0) {
|
||||||
|
_rotationActions.setRotation(rotationToRestore);
|
||||||
|
|
||||||
|
// Check if rotation succeeded after a brief delay
|
||||||
|
setTimeout(() => {
|
||||||
|
const currentRotation = _rotationActions.getRotation();
|
||||||
|
if (currentRotation === rotationToRestore || rotationRestoreAttemptsRef.current >= maxAttempts) {
|
||||||
|
// Success or max attempts reached - clear pending
|
||||||
|
pendingRotationRestoreRef.current = null;
|
||||||
|
rotationRestoreAttemptsRef.current = 0;
|
||||||
|
} else {
|
||||||
|
// Rotation might not have worked, retry
|
||||||
|
rotationRestoreAttemptsRef.current++;
|
||||||
|
if (rotationRestoreAttemptsRef.current < maxAttempts) {
|
||||||
|
setTimeout(attemptRotation, attemptInterval);
|
||||||
|
} else {
|
||||||
|
// Give up after max attempts
|
||||||
|
pendingRotationRestoreRef.current = null;
|
||||||
|
rotationRestoreAttemptsRef.current = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
} else if (rotationRestoreAttemptsRef.current < maxAttempts) {
|
||||||
|
// PDF not ready yet, retry
|
||||||
|
rotationRestoreAttemptsRef.current++;
|
||||||
|
setTimeout(attemptRotation, attemptInterval);
|
||||||
|
} else {
|
||||||
|
// Give up after max attempts
|
||||||
|
pendingRotationRestoreRef.current = null;
|
||||||
|
rotationRestoreAttemptsRef.current = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start attempting after initial delay
|
||||||
|
const timer = setTimeout(attemptRotation, 150);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [scrollState.totalPages, _rotationActions, getScrollState]);
|
||||||
|
|
||||||
// Register applyChanges with ViewerContext so tools can access it directly
|
// Register applyChanges with ViewerContext so tools can access it directly
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setApplyChanges(applyChanges);
|
setApplyChanges(applyChanges);
|
||||||
@@ -535,6 +691,49 @@ const EmbedPdfViewerContent = ({
|
|||||||
// Register viewer right-rail buttons
|
// Register viewer right-rail buttons
|
||||||
useViewerRightRailButtons();
|
useViewerRightRailButtons();
|
||||||
|
|
||||||
|
// Auto-fetch form fields when a PDF is loaded in the viewer.
|
||||||
|
// In normal viewer mode, this uses pdf-lib (frontend-only).
|
||||||
|
// In formFill tool mode, this uses PDFBox (backend).
|
||||||
|
const formFillFileIdRef = useRef<string | null>(null);
|
||||||
|
const formFillProviderRef = useRef(isFormFillToolActive);
|
||||||
|
|
||||||
|
// Generate a unique identifier for the current file to detect file changes
|
||||||
|
const currentFileId = React.useMemo(() => {
|
||||||
|
if (!currentFile) return null;
|
||||||
|
|
||||||
|
if (isStirlingFile(currentFile)) {
|
||||||
|
return `stirling-${currentFile.fileId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// File is also a Blob, but has more specific properties
|
||||||
|
if (currentFile instanceof File) {
|
||||||
|
return `file-${currentFile.name}-${currentFile.size}-${currentFile.lastModified}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for any other object (shouldn't happen in practice)
|
||||||
|
return `unknown-${(currentFile as any).size || 0}`;
|
||||||
|
}, [currentFile]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fileChanged = currentFileId !== formFillFileIdRef.current;
|
||||||
|
const providerChanged = formFillProviderRef.current !== isFormFillToolActive;
|
||||||
|
formFillProviderRef.current = isFormFillToolActive;
|
||||||
|
|
||||||
|
if (fileChanged) {
|
||||||
|
console.log('[FormFill] File changed. Old:', formFillFileIdRef.current, 'New:', currentFileId);
|
||||||
|
formFillFileIdRef.current = currentFileId;
|
||||||
|
// NOTE: Don't call resetFormFill() here — fetchFormFields() handles
|
||||||
|
// clearing old state internally. Calling reset() before fetch() would
|
||||||
|
// double-increment fetchVersionRef, causing version mismatches when
|
||||||
|
// the effect re-fires before the async fetch completes.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentFile && (fileChanged || providerChanged)) {
|
||||||
|
console.log('[FormFill] Fetching form fields for:', currentFileId);
|
||||||
|
fetchFormFields(currentFile, currentFileId ?? undefined);
|
||||||
|
}
|
||||||
|
}, [isFormFillToolActive, currentFile, currentFileId, fetchFormFields]);
|
||||||
|
|
||||||
const sidebarWidthRem = 15;
|
const sidebarWidthRem = 15;
|
||||||
const totalRightMargin =
|
const totalRightMargin =
|
||||||
(isThumbnailSidebarVisible ? sidebarWidthRem : 0) +
|
(isThumbnailSidebarVisible ? sidebarWidthRem : 0) +
|
||||||
@@ -586,7 +785,7 @@ const EmbedPdfViewerContent = ({
|
|||||||
transition: 'margin-right 0.3s ease'
|
transition: 'margin-right 0.3s ease'
|
||||||
}}>
|
}}>
|
||||||
<LocalEmbedPDF
|
<LocalEmbedPDF
|
||||||
key={currentFile && isStirlingFile(currentFile) ? currentFile.fileId : (effectiveFile.file instanceof File ? effectiveFile.file.name : effectiveFile.url)}
|
key={currentFileId || 'no-file'}
|
||||||
file={effectiveFile.file}
|
file={effectiveFile.file}
|
||||||
url={effectiveFile.url}
|
url={effectiveFile.url}
|
||||||
fileName={
|
fileName={
|
||||||
@@ -597,16 +796,24 @@ const EmbedPdfViewerContent = ({
|
|||||||
enableAnnotations={shouldEnableAnnotations}
|
enableAnnotations={shouldEnableAnnotations}
|
||||||
showBakedAnnotations={isAnnotationsVisible}
|
showBakedAnnotations={isAnnotationsVisible}
|
||||||
enableRedaction={shouldEnableRedaction}
|
enableRedaction={shouldEnableRedaction}
|
||||||
|
enableFormFill={shouldEnableFormFill}
|
||||||
isManualRedactionMode={isManualRedactMode}
|
isManualRedactionMode={isManualRedactMode}
|
||||||
signatureApiRef={signatureApiRef as React.RefObject<any>}
|
signatureApiRef={signatureApiRef as React.RefObject<any>}
|
||||||
annotationApiRef={annotationApiRef as React.RefObject<any>}
|
annotationApiRef={annotationApiRef as React.RefObject<any>}
|
||||||
historyApiRef={historyApiRef as React.RefObject<any>}
|
historyApiRef={historyApiRef as React.RefObject<any>}
|
||||||
redactionTrackerRef={redactionTrackerRef as React.RefObject<RedactionPendingTrackerAPI>}
|
redactionTrackerRef={redactionTrackerRef as React.RefObject<RedactionPendingTrackerAPI>}
|
||||||
|
fileId={currentFileId}
|
||||||
onSignatureAdded={() => {
|
onSignatureAdded={() => {
|
||||||
// Handle signature added - for debugging, enable console logs as needed
|
// Handle signature added - for debugging, enable console logs as needed
|
||||||
// Future: Handle signature completion
|
// Future: Handle signature completion
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{/* Floating save bar for form-filled PDFs (like Chrome/Firefox PDF viewers) */}
|
||||||
|
<FormSaveBar
|
||||||
|
file={currentFile ?? null}
|
||||||
|
isFormFillToolActive={isFormFillToolActive}
|
||||||
|
onApply={handleFormApply}
|
||||||
|
/>
|
||||||
<StampPlacementOverlay
|
<StampPlacementOverlay
|
||||||
containerRef={pdfContainerRef}
|
containerRef={pdfContainerRef}
|
||||||
isActive={isPlacementOverlayActive}
|
isActive={isPlacementOverlayActive}
|
||||||
@@ -677,7 +884,9 @@ const EmbedPdfViewerContent = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const EmbedPdfViewer = (props: EmbedPdfViewerProps) => {
|
const EmbedPdfViewer = (props: EmbedPdfViewerProps) => {
|
||||||
return <EmbedPdfViewerContent {...props} />;
|
return (
|
||||||
|
<EmbedPdfViewerContent {...props} />
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default EmbedPdfViewer;
|
export default EmbedPdfViewer;
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import { DocumentPermissionsAPIBridge } from '@app/components/viewer/DocumentPer
|
|||||||
import { DocumentReadyWrapper } from '@app/components/viewer/DocumentReadyWrapper';
|
import { DocumentReadyWrapper } from '@app/components/viewer/DocumentReadyWrapper';
|
||||||
import { ActiveDocumentProvider } from '@app/components/viewer/ActiveDocumentContext';
|
import { ActiveDocumentProvider } from '@app/components/viewer/ActiveDocumentContext';
|
||||||
import { absoluteWithBasePath } from '@app/constants/app';
|
import { absoluteWithBasePath } from '@app/constants/app';
|
||||||
|
import { FormFieldOverlay } from '@app/tools/formFill/FormFieldOverlay';
|
||||||
|
|
||||||
const DOCUMENT_NAME = 'stirling-pdf-viewer';
|
const DOCUMENT_NAME = 'stirling-pdf-viewer';
|
||||||
|
|
||||||
@@ -65,6 +66,7 @@ interface LocalEmbedPDFProps {
|
|||||||
fileName?: string;
|
fileName?: string;
|
||||||
enableAnnotations?: boolean;
|
enableAnnotations?: boolean;
|
||||||
enableRedaction?: boolean;
|
enableRedaction?: boolean;
|
||||||
|
enableFormFill?: boolean;
|
||||||
isManualRedactionMode?: boolean;
|
isManualRedactionMode?: boolean;
|
||||||
showBakedAnnotations?: boolean;
|
showBakedAnnotations?: boolean;
|
||||||
onSignatureAdded?: (annotation: any) => void;
|
onSignatureAdded?: (annotation: any) => void;
|
||||||
@@ -72,9 +74,11 @@ interface LocalEmbedPDFProps {
|
|||||||
annotationApiRef?: React.RefObject<AnnotationAPI>;
|
annotationApiRef?: React.RefObject<AnnotationAPI>;
|
||||||
historyApiRef?: React.RefObject<HistoryAPI>;
|
historyApiRef?: React.RefObject<HistoryAPI>;
|
||||||
redactionTrackerRef?: React.RefObject<RedactionPendingTrackerAPI>;
|
redactionTrackerRef?: React.RefObject<RedactionPendingTrackerAPI>;
|
||||||
|
/** File identity passed through to FormFieldOverlay for stale-field guards */
|
||||||
|
fileId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false, enableRedaction = false, isManualRedactionMode = false, showBakedAnnotations = true, onSignatureAdded, signatureApiRef, annotationApiRef, historyApiRef, redactionTrackerRef }: LocalEmbedPDFProps) {
|
export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false, enableRedaction = false, enableFormFill = false, isManualRedactionMode = false, showBakedAnnotations = true, onSignatureAdded, signatureApiRef, annotationApiRef, historyApiRef, redactionTrackerRef, fileId }: LocalEmbedPDFProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||||
const [, setAnnotations] = useState<Array<{id: string, pageIndex: number, rect: any}>>([]);
|
const [, setAnnotations] = useState<Array<{id: string, pageIndex: number, rect: any}>>([]);
|
||||||
@@ -121,7 +125,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
|||||||
}),
|
}),
|
||||||
createPluginRegistration(ScrollPluginPackage),
|
createPluginRegistration(ScrollPluginPackage),
|
||||||
createPluginRegistration(RenderPluginPackage, {
|
createPluginRegistration(RenderPluginPackage, {
|
||||||
withForms: true,
|
withForms: !enableFormFill, // Disable native form rendering when our interactive overlay is active
|
||||||
withAnnotations: showBakedAnnotations && !enableAnnotations, // Show baked annotations only when: visibility is ON and annotation layer is OFF
|
withAnnotations: showBakedAnnotations && !enableAnnotations, // Show baked annotations only when: visibility is ON and annotation layer is OFF
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -200,7 +204,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
|||||||
// Register print plugin for printing PDFs
|
// Register print plugin for printing PDFs
|
||||||
createPluginRegistration(PrintPluginPackage),
|
createPluginRegistration(PrintPluginPackage),
|
||||||
];
|
];
|
||||||
}, [pdfUrl, enableAnnotations, showBakedAnnotations, fileName, file, url]);
|
}, [pdfUrl, enableAnnotations, enableFormFill, showBakedAnnotations, fileName, file, url]);
|
||||||
|
|
||||||
// Initialize the engine with the React hook - use local WASM for offline support
|
// Initialize the engine with the React hook - use local WASM for offline support
|
||||||
const { engine, isLoading, error } = usePdfiumEngine({
|
const { engine, isLoading, error } = usePdfiumEngine({
|
||||||
@@ -731,6 +735,17 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
|||||||
</div>
|
</div>
|
||||||
<TextSelectionHandler documentId={documentId} pageIndex={pageIndex} />
|
<TextSelectionHandler documentId={documentId} pageIndex={pageIndex} />
|
||||||
|
|
||||||
|
{/* FormFieldOverlay for interactive form filling */}
|
||||||
|
{enableFormFill && (
|
||||||
|
<FormFieldOverlay
|
||||||
|
documentId={documentId}
|
||||||
|
pageIndex={pageIndex}
|
||||||
|
pageWidth={width}
|
||||||
|
pageHeight={height}
|
||||||
|
fileId={fileId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* AnnotationLayer for annotation editing and annotation-based redactions */}
|
{/* AnnotationLayer for annotation editing and annotation-based redactions */}
|
||||||
{(enableAnnotations || enableRedaction) && (
|
{(enableAnnotations || enableRedaction) && (
|
||||||
<AnnotationLayer
|
<AnnotationLayer
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Box, ScrollArea } from '@mantine/core';
|
import { Box, ScrollArea } from '@mantine/core';
|
||||||
import { useViewer } from '@app/contexts/ViewerContext';
|
import { useViewer } from '@app/contexts/ViewerContext';
|
||||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
||||||
@@ -27,10 +27,16 @@ export function ThumbnailSidebar({ visible, onToggle: _onToggle, activeFileIndex
|
|||||||
setThumbnails({});
|
setThumbnails({});
|
||||||
}, [activeFileIndex]);
|
}, [activeFileIndex]);
|
||||||
|
|
||||||
|
// Keep a ref to thumbnails for cleanup on unmount
|
||||||
|
const thumbnailsRef = useRef(thumbnails);
|
||||||
|
useEffect(() => {
|
||||||
|
thumbnailsRef.current = thumbnails;
|
||||||
|
}, [thumbnails]);
|
||||||
|
|
||||||
// Clear thumbnails when sidebar closes and revoke blob URLs to prevent memory leaks
|
// Clear thumbnails when sidebar closes and revoke blob URLs to prevent memory leaks
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
Object.values(thumbnails).forEach((thumbUrl) => {
|
Object.values(thumbnailsRef.current).forEach((thumbUrl) => {
|
||||||
// Only revoke if it's a blob URL (not 'error')
|
// Only revoke if it's a blob URL (not 'error')
|
||||||
if (typeof thumbUrl === 'string' && thumbUrl.startsWith('blob:')) {
|
if (typeof thumbUrl === 'string' && thumbUrl.startsWith('blob:')) {
|
||||||
URL.revokeObjectURL(thumbUrl);
|
URL.revokeObjectURL(thumbUrl);
|
||||||
@@ -38,48 +44,89 @@ export function ThumbnailSidebar({ visible, onToggle: _onToggle, activeFileIndex
|
|||||||
});
|
});
|
||||||
setThumbnails({});
|
setThumbnails({});
|
||||||
}
|
}
|
||||||
}, [visible]); // Remove thumbnails from dependency to prevent infinite loop
|
}, [visible]);
|
||||||
|
|
||||||
|
// Cleanup all blob URLs on unmount to prevent memory leaks
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
Object.values(thumbnailsRef.current).forEach((thumbUrl) => {
|
||||||
|
if (typeof thumbUrl === 'string' && thumbUrl.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(thumbUrl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Generate thumbnails when sidebar becomes visible
|
// Generate thumbnails when sidebar becomes visible
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible || scrollState.totalPages === 0) return;
|
if (!visible || scrollState.totalPages === 0) return;
|
||||||
if (!thumbnailAPI) return;
|
if (!thumbnailAPI) return;
|
||||||
|
|
||||||
|
let isCancelled = false;
|
||||||
|
|
||||||
const generateThumbnails = async () => {
|
const generateThumbnails = async () => {
|
||||||
for (let pageIndex = 0; pageIndex < scrollState.totalPages; pageIndex++) {
|
const allPages = Array.from({ length: scrollState.totalPages }, (_, i) => i);
|
||||||
if (thumbnails[pageIndex]) continue; // Skip if already generated
|
const currentPage = scrollState.currentPage - 1;
|
||||||
|
|
||||||
|
// Group pages by priority:
|
||||||
|
// 1. Current page
|
||||||
|
// 2. Visible neighbors (current +/- 3)
|
||||||
|
// 3. Everything else
|
||||||
|
const prioritized = [
|
||||||
|
...allPages.filter(i => i === currentPage),
|
||||||
|
...allPages.filter(i => i !== currentPage && Math.abs(i - currentPage) <= 3),
|
||||||
|
...allPages.filter(i => Math.abs(i - currentPage) > 3)
|
||||||
|
];
|
||||||
|
|
||||||
|
const CONCURRENCY_LIMIT = 3;
|
||||||
|
const queue = [...prioritized];
|
||||||
|
|
||||||
|
const processNext = async () => {
|
||||||
|
if (queue.length === 0 || isCancelled) return;
|
||||||
|
const pageIndex = queue.shift()!;
|
||||||
|
|
||||||
|
if (thumbnailsRef.current[pageIndex]) {
|
||||||
|
await processNext();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const thumbTask = thumbnailAPI.renderThumb(pageIndex, 1.0);
|
const thumbTask = thumbnailAPI.renderThumb(pageIndex, 1.0);
|
||||||
|
const thumbBlob = await thumbTask.toPromise();
|
||||||
|
if (isCancelled) {
|
||||||
|
// If cancelled during generation, revoke the new URL
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const thumbUrl = URL.createObjectURL(thumbBlob);
|
||||||
|
|
||||||
// Convert Task to Promise and handle properly
|
setThumbnails(prev => ({
|
||||||
thumbTask.toPromise().then((thumbBlob: Blob) => {
|
...prev,
|
||||||
const thumbUrl = URL.createObjectURL(thumbBlob);
|
[pageIndex]: thumbUrl
|
||||||
|
}));
|
||||||
setThumbnails(prev => ({
|
} catch (error) {
|
||||||
...prev,
|
console.error('Failed to generate thumbnail for page', pageIndex + 1, error);
|
||||||
[pageIndex]: thumbUrl
|
if (!isCancelled) {
|
||||||
}));
|
|
||||||
}).catch((error: any) => {
|
|
||||||
console.error('Failed to generate thumbnail for page', pageIndex + 1, error);
|
|
||||||
setThumbnails(prev => ({
|
setThumbnails(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[pageIndex]: 'error'
|
[pageIndex]: 'error'
|
||||||
}));
|
}));
|
||||||
});
|
}
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to generate thumbnail for page', pageIndex + 1, error);
|
|
||||||
setThumbnails(prev => ({
|
|
||||||
...prev,
|
|
||||||
[pageIndex]: 'error'
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
await processNext();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start initial concurrent batch
|
||||||
|
const workers = Array.from({ length: CONCURRENCY_LIMIT }, () => processNext());
|
||||||
|
await Promise.all(workers);
|
||||||
};
|
};
|
||||||
|
|
||||||
generateThumbnails();
|
generateThumbnails();
|
||||||
}, [visible, scrollState.totalPages, thumbnailAPI]);
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
};
|
||||||
|
}, [visible, scrollState.totalPages, thumbnailAPI, scrollState.currentPage]);
|
||||||
|
|
||||||
const handlePageClick = (pageIndex: number) => {
|
const handlePageClick = (pageIndex: number) => {
|
||||||
const pageNumber = pageIndex + 1; // Convert to 1-based
|
const pageNumber = pageIndex + 1; // Convert to 1-based
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
|||||||
import { useNavigationState, useNavigationGuard } from '@app/contexts/NavigationContext';
|
import { useNavigationState, useNavigationGuard } from '@app/contexts/NavigationContext';
|
||||||
import { BASE_PATH, withBasePath } from '@app/constants/app';
|
import { BASE_PATH, withBasePath } from '@app/constants/app';
|
||||||
import { useRedaction, useRedactionMode } from '@app/contexts/RedactionContext';
|
import { useRedaction, useRedactionMode } from '@app/contexts/RedactionContext';
|
||||||
|
import TextFieldsIcon from '@mui/icons-material/TextFields';
|
||||||
|
|
||||||
export function useViewerRightRailButtons() {
|
export function useViewerRightRailButtons() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
@@ -21,7 +22,7 @@ export function useViewerRightRailButtons() {
|
|||||||
const [isPanning, setIsPanning] = useState<boolean>(() => viewer.getPanState()?.isPanning ?? false);
|
const [isPanning, setIsPanning] = useState<boolean>(() => viewer.getPanState()?.isPanning ?? false);
|
||||||
const { sidebarRefs } = useSidebarContext();
|
const { sidebarRefs } = useSidebarContext();
|
||||||
const { position: tooltipPosition } = useRightRailTooltipSide(sidebarRefs, 12);
|
const { position: tooltipPosition } = useRightRailTooltipSide(sidebarRefs, 12);
|
||||||
const { handleToolSelect } = useToolWorkflow();
|
const { handleToolSelect, handleBackToTools } = useToolWorkflow();
|
||||||
const { selectedTool } = useNavigationState();
|
const { selectedTool } = useNavigationState();
|
||||||
const { requestNavigation } = useNavigationGuard();
|
const { requestNavigation } = useNavigationGuard();
|
||||||
const { redactionsApplied, activeType: redactionActiveType } = useRedaction();
|
const { redactionsApplied, activeType: redactionActiveType } = useRedaction();
|
||||||
@@ -77,6 +78,9 @@ export function useViewerRightRailButtons() {
|
|||||||
const attachmentLabel = t('rightRail.toggleAttachments', 'Toggle Attachments');
|
const attachmentLabel = t('rightRail.toggleAttachments', 'Toggle Attachments');
|
||||||
const printLabel = t('rightRail.print', 'Print PDF');
|
const printLabel = t('rightRail.print', 'Print PDF');
|
||||||
const annotationsLabel = t('rightRail.annotations', 'Annotations');
|
const annotationsLabel = t('rightRail.annotations', 'Annotations');
|
||||||
|
const formFillLabel = t('rightRail.formFill', 'Fill Form');
|
||||||
|
|
||||||
|
const isFormFillActive = (selectedTool as string) === 'formFill';
|
||||||
|
|
||||||
const viewerButtons = useMemo<RightRailButtonWithAction[]>(() => {
|
const viewerButtons = useMemo<RightRailButtonWithAction[]>(() => {
|
||||||
const buttons: RightRailButtonWithAction[] = [
|
const buttons: RightRailButtonWithAction[] = [
|
||||||
@@ -253,6 +257,35 @@ export function useViewerRightRailButtons() {
|
|||||||
<ViewerAnnotationControls currentView="viewer" disabled={disabled} />
|
<ViewerAnnotationControls currentView="viewer" disabled={disabled} />
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'viewer-form-fill',
|
||||||
|
tooltip: formFillLabel,
|
||||||
|
ariaLabel: formFillLabel,
|
||||||
|
section: 'top' as const,
|
||||||
|
order: 62,
|
||||||
|
render: ({ disabled }) => (
|
||||||
|
<Tooltip content={formFillLabel} position={tooltipPosition} offset={12} arrow portalTarget={document.body}>
|
||||||
|
<ActionIcon
|
||||||
|
variant={isFormFillActive ? 'filled' : 'subtle'}
|
||||||
|
radius="md"
|
||||||
|
className="right-rail-icon"
|
||||||
|
onClick={() => {
|
||||||
|
if (disabled) return;
|
||||||
|
if (isFormFillActive) {
|
||||||
|
handleBackToTools();
|
||||||
|
} else {
|
||||||
|
handleToolSelect('formFill' as any);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-pressed={isFormFillActive}
|
||||||
|
color={isFormFillActive ? 'blue' : undefined}
|
||||||
|
>
|
||||||
|
<TextFieldsIcon sx={{ fontSize: '1.5rem' }} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Optional: Save button for annotations (always registered when this hook is used
|
// Optional: Save button for annotations (always registered when this hook is used
|
||||||
@@ -282,6 +315,8 @@ export function useViewerRightRailButtons() {
|
|||||||
handleToolSelect,
|
handleToolSelect,
|
||||||
pendingCount,
|
pendingCount,
|
||||||
redactionActiveType,
|
redactionActiveType,
|
||||||
|
formFillLabel,
|
||||||
|
isFormFillActive,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useRightRailButtons(viewerButtons);
|
useRightRailButtons(viewerButtons);
|
||||||
|
|||||||
@@ -221,10 +221,10 @@ export const mantineTheme = createTheme({
|
|||||||
},
|
},
|
||||||
option: {
|
option: {
|
||||||
color: 'var(--text-primary)',
|
color: 'var(--text-primary)',
|
||||||
'&[dataHovered]': {
|
'&[data-hovered]': {
|
||||||
backgroundColor: 'var(--hover-bg)',
|
backgroundColor: 'var(--hover-bg)',
|
||||||
},
|
},
|
||||||
'&[dataSelected]': {
|
'&[data-selected]': {
|
||||||
backgroundColor: 'var(--color-primary-100)',
|
backgroundColor: 'var(--color-primary-100)',
|
||||||
color: 'var(--color-primary-900)',
|
color: 'var(--color-primary-900)',
|
||||||
},
|
},
|
||||||
@@ -356,7 +356,7 @@ export const mantineTheme = createTheme({
|
|||||||
},
|
},
|
||||||
control: {
|
control: {
|
||||||
color: 'var(--text-secondary)',
|
color: 'var(--text-secondary)',
|
||||||
'[dataActive]': {
|
'&[data-active]': {
|
||||||
backgroundColor: 'var(--bg-surface)',
|
backgroundColor: 'var(--bg-surface)',
|
||||||
color: 'var(--text-primary)',
|
color: 'var(--text-primary)',
|
||||||
boxShadow: 'var(--shadow-sm)',
|
boxShadow: 'var(--shadow-sm)',
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* FormFieldOverlay stub for the core build.
|
||||||
|
* This file is overridden in src/proprietary/tools/formFill/FormFieldOverlay.tsx
|
||||||
|
* when building the proprietary variant.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface FormFieldOverlayProps {
|
||||||
|
documentId: string;
|
||||||
|
pageIndex: number;
|
||||||
|
pageWidth: number;
|
||||||
|
pageHeight: number;
|
||||||
|
fileId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormFieldOverlay(_props: FormFieldOverlayProps) {
|
||||||
|
// Core build stub — renders nothing
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FormFieldOverlay;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* FormFieldSidebar stub for the core build.
|
||||||
|
* This file is overridden in src/proprietary/tools/formFill/FormFieldSidebar.tsx
|
||||||
|
* when building the proprietary variant.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface FormFieldSidebarProps {
|
||||||
|
visible: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormFieldSidebar(_props: FormFieldSidebarProps) {
|
||||||
|
// Core build stub — renders nothing
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FormFieldSidebar;
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* FormFillProvider stub for the core build.
|
||||||
|
* This file is overridden in src/proprietary/tools/formFill/FormFillContext.tsx
|
||||||
|
* when building the proprietary variant.
|
||||||
|
*/
|
||||||
|
import React, { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
interface FormFillContextValue {
|
||||||
|
state: {
|
||||||
|
fields: any[];
|
||||||
|
values: Record<string, string>;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
activeFieldName: string | null;
|
||||||
|
isDirty: boolean;
|
||||||
|
validationErrors: Record<string, string>;
|
||||||
|
};
|
||||||
|
fetchFields: (file: File | Blob, fileId?: string) => Promise<void>;
|
||||||
|
setValue: (fieldName: string, value: string) => void;
|
||||||
|
setActiveField: (fieldName: string | null) => void;
|
||||||
|
submitForm: (file: File | Blob, flatten?: boolean) => Promise<Blob>;
|
||||||
|
getField: (fieldName: string) => any | undefined;
|
||||||
|
getFieldsForPage: (pageIndex: number) => any[];
|
||||||
|
getValue: (fieldName: string) => string;
|
||||||
|
validateForm: () => boolean;
|
||||||
|
reset: () => void;
|
||||||
|
fieldsByPage: Map<number, any[]>;
|
||||||
|
activeProviderName: string;
|
||||||
|
setProviderMode: (mode: 'pdflib' | 'pdfbox') => void;
|
||||||
|
forFileId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const noopAsync = async () => {};
|
||||||
|
const noop = () => {};
|
||||||
|
|
||||||
|
const FormFillContext = createContext<FormFillContextValue | null>(null);
|
||||||
|
|
||||||
|
export const useFormFill = (): FormFillContextValue => {
|
||||||
|
const ctx = useContext(FormFillContext);
|
||||||
|
if (!ctx) {
|
||||||
|
// Return a default no-op value for core builds
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
fields: [],
|
||||||
|
values: {},
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
activeFieldName: null,
|
||||||
|
isDirty: false,
|
||||||
|
validationErrors: {},
|
||||||
|
},
|
||||||
|
fetchFields: noopAsync,
|
||||||
|
setValue: noop,
|
||||||
|
setActiveField: noop,
|
||||||
|
submitForm: async () => new Blob(),
|
||||||
|
getField: () => undefined,
|
||||||
|
getFieldsForPage: () => [],
|
||||||
|
getValue: () => '',
|
||||||
|
validateForm: () => true,
|
||||||
|
reset: noop,
|
||||||
|
fieldsByPage: new Map(),
|
||||||
|
activeProviderName: 'none',
|
||||||
|
setProviderMode: noop,
|
||||||
|
forFileId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** No-op stub for core builds */
|
||||||
|
export function useFieldValue(_fieldName: string): string {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No-op stub for core builds */
|
||||||
|
export function useAllFormValues(): Record<string, string> {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormFillProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
// In core build, just render children without provider
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FormFillContext;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* FormSaveBar stub for the core build.
|
||||||
|
* This file is overridden in src/proprietary/tools/formFill/FormSaveBar.tsx
|
||||||
|
* when building the proprietary variant.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface FormSaveBarProps {
|
||||||
|
file: File | Blob | null;
|
||||||
|
isFormFillToolActive: boolean;
|
||||||
|
onApply?: (filledBlob: Blob) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormSaveBar(_props: FormSaveBarProps) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FormSaveBar;
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { type ProprietaryToolRegistry } from "@app/data/toolsTaxonomy";
|
import { type ProprietaryToolRegistry } from "@app/data/toolsTaxonomy";
|
||||||
|
import { ToolCategoryId, SubcategoryId } from "@app/data/toolsTaxonomy";
|
||||||
|
import FormFill from "@app/tools/formFill/FormFill";
|
||||||
|
import React from "react";
|
||||||
|
import TextFieldsIcon from '@mui/icons-material/TextFields';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook that provides the proprietary tool registry.
|
* Hook that provides the proprietary tool registry.
|
||||||
@@ -8,5 +13,21 @@ import { type ProprietaryToolRegistry } from "@app/data/toolsTaxonomy";
|
|||||||
* and will be included in the main tool registry.
|
* and will be included in the main tool registry.
|
||||||
*/
|
*/
|
||||||
export function useProprietaryToolRegistry(): ProprietaryToolRegistry {
|
export function useProprietaryToolRegistry(): ProprietaryToolRegistry {
|
||||||
return useMemo<ProprietaryToolRegistry>(() => ({}), []);
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return useMemo<ProprietaryToolRegistry>(() => ({
|
||||||
|
formFill: {
|
||||||
|
icon: React.createElement(TextFieldsIcon, { sx: { fontSize: '1.5rem' } }),
|
||||||
|
name: t('home.formFill.title', 'Fill Form'),
|
||||||
|
component: FormFill,
|
||||||
|
description: t('home.formFill.desc', 'Fill PDF form fields interactively with a visual editor'),
|
||||||
|
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||||
|
subcategoryId: SubcategoryId.GENERAL,
|
||||||
|
workbench: 'viewer' as const,
|
||||||
|
endpoints: ['form-fill'],
|
||||||
|
automationSettings: null,
|
||||||
|
supportsAutomate: false,
|
||||||
|
synonyms: ['form', 'fill', 'fillable', 'input', 'field', 'acroform'],
|
||||||
|
},
|
||||||
|
}), [t]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
/**
|
||||||
|
* FieldInput: Shared, self-subscribing form field input widget.
|
||||||
|
*
|
||||||
|
* Used by both FormFill (left panel) and FormFieldSidebar (right panel).
|
||||||
|
* Each instance subscribes to its own field value via useFieldValue(),
|
||||||
|
* so only the active widget re-renders when its value changes.
|
||||||
|
*/
|
||||||
|
import React, { useCallback, memo } from 'react';
|
||||||
|
import {
|
||||||
|
TextInput,
|
||||||
|
Textarea,
|
||||||
|
Checkbox,
|
||||||
|
Radio,
|
||||||
|
Select,
|
||||||
|
MultiSelect,
|
||||||
|
Stack,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useFieldValue } from '@proprietary/tools/formFill/FormFillContext';
|
||||||
|
import type { FormField } from '@proprietary/tools/formFill/types';
|
||||||
|
|
||||||
|
function FieldInputInner({
|
||||||
|
field,
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
}: {
|
||||||
|
field: FormField;
|
||||||
|
value: string;
|
||||||
|
onValueChange: (fieldName: string, value: string) => void;
|
||||||
|
}) {
|
||||||
|
const onChange = useCallback(
|
||||||
|
(v: string) => onValueChange(field.name, v),
|
||||||
|
[onValueChange, field.name]
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (field.type) {
|
||||||
|
case 'text':
|
||||||
|
if (field.multiline) {
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
size="xs"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.currentTarget.value)}
|
||||||
|
placeholder={field.tooltip || `Enter ${field.label}`}
|
||||||
|
disabled={field.readOnly}
|
||||||
|
autosize
|
||||||
|
minRows={2}
|
||||||
|
maxRows={5}
|
||||||
|
styles={{ input: { fontSize: '0.8125rem' } }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
size="xs"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.currentTarget.value)}
|
||||||
|
placeholder={field.tooltip || `Enter ${field.label}`}
|
||||||
|
disabled={field.readOnly}
|
||||||
|
styles={{ input: { fontSize: '0.8125rem' } }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'checkbox': {
|
||||||
|
const isChecked = !!value && value !== 'Off';
|
||||||
|
const onValue = (field.widgets && field.widgets[0]?.exportValue) || 'Yes';
|
||||||
|
return (
|
||||||
|
<Checkbox
|
||||||
|
size="xs"
|
||||||
|
checked={isChecked}
|
||||||
|
onChange={(e) => onChange(e.currentTarget.checked ? onValue : 'Off')}
|
||||||
|
label={field.label}
|
||||||
|
disabled={field.readOnly}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'combobox': {
|
||||||
|
const comboData = (field.options || []).map((opt, idx) => ({
|
||||||
|
value: opt,
|
||||||
|
label: (field.displayOptions && field.displayOptions[idx]) || opt,
|
||||||
|
}));
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
size="xs"
|
||||||
|
data={comboData}
|
||||||
|
value={value || null}
|
||||||
|
onChange={(v) => onChange(v || '')}
|
||||||
|
placeholder={`Select ${field.label}`}
|
||||||
|
clearable
|
||||||
|
searchable
|
||||||
|
disabled={field.readOnly}
|
||||||
|
aria-label={field.label || field.name}
|
||||||
|
aria-required={field.required}
|
||||||
|
styles={{ input: { fontSize: '0.8125rem' } }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'listbox': {
|
||||||
|
const listData = (field.options || []).map((opt, idx) => ({
|
||||||
|
value: opt,
|
||||||
|
label: (field.displayOptions && field.displayOptions[idx]) || opt,
|
||||||
|
}));
|
||||||
|
if (field.multiSelect) {
|
||||||
|
const selectedValues = value ? value.split(',').filter(Boolean) : [];
|
||||||
|
return (
|
||||||
|
<MultiSelect
|
||||||
|
size="xs"
|
||||||
|
data={listData}
|
||||||
|
value={selectedValues}
|
||||||
|
onChange={(vals) => onChange(vals.join(','))}
|
||||||
|
placeholder={`Select ${field.label}`}
|
||||||
|
searchable
|
||||||
|
disabled={field.readOnly}
|
||||||
|
aria-label={field.label || field.name}
|
||||||
|
aria-required={field.required}
|
||||||
|
styles={{ input: { fontSize: '0.8125rem' } }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
size="xs"
|
||||||
|
data={listData}
|
||||||
|
value={value || null}
|
||||||
|
onChange={(v) => onChange(v || '')}
|
||||||
|
placeholder={`Select ${field.label}`}
|
||||||
|
clearable
|
||||||
|
searchable
|
||||||
|
disabled={field.readOnly}
|
||||||
|
aria-label={field.label || field.name}
|
||||||
|
aria-required={field.required}
|
||||||
|
styles={{ input: { fontSize: '0.8125rem' } }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'radio': {
|
||||||
|
const radioOptions: { value: string; label: string }[] = [];
|
||||||
|
if (field.widgets && field.widgets.length > 0) {
|
||||||
|
for (const w of field.widgets) {
|
||||||
|
if (w.exportValue && !radioOptions.some((o) => o.value === w.exportValue)) {
|
||||||
|
radioOptions.push({ value: w.exportValue, label: w.exportValue });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (radioOptions.length === 0 && field.options) {
|
||||||
|
radioOptions.push(...field.options.map((o) => ({ value: o, label: o })));
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Radio.Group
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
aria-label={field.label || field.name}
|
||||||
|
aria-required={field.required}
|
||||||
|
>
|
||||||
|
<Stack gap={4} mt={4}>
|
||||||
|
{radioOptions.map((opt) => (
|
||||||
|
<Radio
|
||||||
|
key={opt.value}
|
||||||
|
size="xs"
|
||||||
|
value={opt.value}
|
||||||
|
label={opt.label}
|
||||||
|
disabled={field.readOnly}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Radio.Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
size="xs"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.currentTarget.value)}
|
||||||
|
disabled={field.readOnly}
|
||||||
|
aria-label={field.label || field.name}
|
||||||
|
aria-required={field.required}
|
||||||
|
styles={{ input: { fontSize: '0.8125rem' } }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FieldInputBase = memo(FieldInputInner);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-subscribing FieldInput — reads its own value via useFieldValue.
|
||||||
|
* Only re-renders when this specific field's value changes.
|
||||||
|
*/
|
||||||
|
export function FieldInput({
|
||||||
|
field,
|
||||||
|
onValueChange,
|
||||||
|
}: {
|
||||||
|
field: FormField;
|
||||||
|
onValueChange: (fieldName: string, value: string) => void;
|
||||||
|
}) {
|
||||||
|
const value = useFieldValue(field.name);
|
||||||
|
return <FieldInputBase field={field} value={value} onValueChange={onValueChange} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FieldInput;
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
/**
|
||||||
|
* FormFieldOverlay — Renders interactive HTML form widgets on top of a PDF page.
|
||||||
|
*
|
||||||
|
* This layer is placed inside the renderPage callback of the EmbedPDF Scroller,
|
||||||
|
* similar to how AnnotationLayer, RedactionLayer, and LinkLayer work.
|
||||||
|
*
|
||||||
|
* It reads the form field coordinates (in un-rotated CSS space, top-left origin)
|
||||||
|
* and scales them using the document scale from EmbedPDF.
|
||||||
|
*
|
||||||
|
* Each widget renders an appropriate HTML input (text, checkbox, dropdown, etc.)
|
||||||
|
* that synchronises bidirectionally with FormFillContext values.
|
||||||
|
*
|
||||||
|
* Coordinate handling:
|
||||||
|
* Both providers (PdfLibFormProvider and PdfBoxFormProvider) output widget
|
||||||
|
* coordinates in un-rotated PDF space (y-flipped to CSS upper-left origin).
|
||||||
|
* The <Rotate> component (which wraps this overlay along with page tiles)
|
||||||
|
* handles visual rotation via CSS transforms — same as TilingLayer,
|
||||||
|
* AnnotationLayer, and LinkLayer.
|
||||||
|
*/
|
||||||
|
import React, { useCallback, useMemo, memo } from 'react';
|
||||||
|
import { useDocumentState } from '@embedpdf/core/react';
|
||||||
|
import { useFormFill, useFieldValue } from '@proprietary/tools/formFill/FormFillContext';
|
||||||
|
import type { FormField, WidgetCoordinates } from '@proprietary/tools/formFill/types';
|
||||||
|
|
||||||
|
interface WidgetInputProps {
|
||||||
|
field: FormField;
|
||||||
|
widget: WidgetCoordinates;
|
||||||
|
isActive: boolean;
|
||||||
|
error?: string;
|
||||||
|
scaleX: number;
|
||||||
|
scaleY: number;
|
||||||
|
onFocus: (fieldName: string) => void;
|
||||||
|
onChange: (fieldName: string, value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WidgetInput subscribes to its own field value via useSyncExternalStore,
|
||||||
|
* so it only re-renders when its specific value changes — not when ANY
|
||||||
|
* form value in the entire document changes.
|
||||||
|
*/
|
||||||
|
function WidgetInputInner({
|
||||||
|
field,
|
||||||
|
widget,
|
||||||
|
isActive,
|
||||||
|
error,
|
||||||
|
scaleX,
|
||||||
|
scaleY,
|
||||||
|
onFocus,
|
||||||
|
onChange,
|
||||||
|
}: WidgetInputProps) {
|
||||||
|
// Per-field value subscription — only this widget re-renders when its value changes
|
||||||
|
const value = useFieldValue(field.name);
|
||||||
|
|
||||||
|
// Coordinates are in visual CSS space (top-left origin).
|
||||||
|
// Multiply by per-axis scale to get rendered pixel coordinates.
|
||||||
|
const left = widget.x * scaleX;
|
||||||
|
const top = widget.y * scaleY;
|
||||||
|
const width = widget.width * scaleX;
|
||||||
|
const height = widget.height * scaleY;
|
||||||
|
|
||||||
|
const borderColor = error ? '#f44336' : (isActive ? '#2196F3' : 'rgba(33, 150, 243, 0.4)');
|
||||||
|
const bgColor = error
|
||||||
|
? 'rgba(244, 67, 54, 0.08)'
|
||||||
|
: (isActive ? 'rgba(33, 150, 243, 0.08)' : 'rgba(255, 255, 255, 0.85)');
|
||||||
|
|
||||||
|
const commonStyle: React.CSSProperties = {
|
||||||
|
position: 'absolute',
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
zIndex: 10,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
border: `2px solid ${borderColor}`,
|
||||||
|
borderRadius: 2,
|
||||||
|
background: bgColor,
|
||||||
|
transition: 'border-color 0.15s, background 0.15s, box-shadow 0.15s',
|
||||||
|
boxShadow: isActive
|
||||||
|
? `0 0 0 2px ${error ? 'rgba(244, 67, 54, 0.25)' : 'rgba(33, 150, 243, 0.25)'}`
|
||||||
|
: 'none',
|
||||||
|
cursor: field.readOnly ? 'default' : 'text',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: field.multiline ? 'stretch' : 'center',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Scale font size with the widget height (using Y scale as a proxy for uniform font scaling).
|
||||||
|
// PDF form fields use fontSize=0 to mean "auto-size" (scale to fit the box).
|
||||||
|
// For single-line fields (e.g. Title), scale closer to the box height.
|
||||||
|
// For multiline fields (e.g. Description), use a smaller capped size.
|
||||||
|
const fontSize = widget.fontSize
|
||||||
|
? widget.fontSize * scaleY
|
||||||
|
: field.multiline
|
||||||
|
? Math.max(8, Math.min(height * 0.65, 14))
|
||||||
|
: Math.max(8, height * 0.7);
|
||||||
|
|
||||||
|
const inputBaseStyle: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
border: 'none',
|
||||||
|
outline: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
padding: 0,
|
||||||
|
paddingLeft: `${Math.max(2, 4 * scaleX)}px`,
|
||||||
|
paddingRight: `${Math.max(2, 4 * scaleX)}px`,
|
||||||
|
fontSize: `${fontSize}px`,
|
||||||
|
fontFamily: 'Helvetica, Arial, sans-serif',
|
||||||
|
color: '#000',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
lineHeight: 'normal',
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFocus = () => onFocus(field.name);
|
||||||
|
|
||||||
|
switch (field.type) {
|
||||||
|
case 'text':
|
||||||
|
return (
|
||||||
|
<div style={commonStyle} title={error || field.tooltip || field.label}>
|
||||||
|
{field.multiline ? (
|
||||||
|
<textarea
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(field.name, e.target.value)}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
disabled={field.readOnly}
|
||||||
|
placeholder={field.label}
|
||||||
|
style={{
|
||||||
|
...inputBaseStyle,
|
||||||
|
resize: 'none',
|
||||||
|
overflow: 'auto',
|
||||||
|
paddingTop: `${Math.max(1, 2 * scaleY)}px`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id={`${field.name}_${widget.pageIndex}_${widget.x}_${widget.y}`}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(field.name, e.target.value)}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
disabled={field.readOnly}
|
||||||
|
placeholder={field.label}
|
||||||
|
style={inputBaseStyle}
|
||||||
|
aria-label={field.label || field.name}
|
||||||
|
aria-required={field.required}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
aria-describedby={error ? `${field.name}-error` : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'checkbox': {
|
||||||
|
// Checkbox is checked when value is anything other than 'Off' or empty
|
||||||
|
const isChecked = !!value && value !== 'Off';
|
||||||
|
// When toggling on, use the widget's exportValue (e.g. 'Red', 'Blue') or fall back to 'Yes'
|
||||||
|
const onValue = widget.exportValue || 'Yes';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...commonStyle,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
cursor: field.readOnly ? 'default' : 'pointer',
|
||||||
|
}}
|
||||||
|
title={error || field.tooltip || field.label}
|
||||||
|
onClick={() => {
|
||||||
|
if (field.readOnly) return;
|
||||||
|
handleFocus();
|
||||||
|
onChange(field.name, isChecked ? 'Off' : onValue);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: `${Math.max(12, height * 0.7)}px`,
|
||||||
|
lineHeight: 1,
|
||||||
|
color: isChecked ? '#2196F3' : 'transparent',
|
||||||
|
fontWeight: 700,
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'combobox':
|
||||||
|
case 'listbox': {
|
||||||
|
const inputId = `${field.name}_${widget.pageIndex}_${widget.x}_${widget.y}`;
|
||||||
|
|
||||||
|
// For multi-select, value should be an array
|
||||||
|
// We store as comma-separated string, so parse it
|
||||||
|
const selectValue = field.multiSelect
|
||||||
|
? (value ? value.split(',').map(v => v.trim()) : [])
|
||||||
|
: value;
|
||||||
|
|
||||||
|
const handleSelectChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
if (field.multiSelect) {
|
||||||
|
// For multi-select, join selected options with comma
|
||||||
|
const selected = Array.from(e.target.selectedOptions, opt => opt.value);
|
||||||
|
onChange(field.name, selected.join(','));
|
||||||
|
} else {
|
||||||
|
onChange(field.name, e.target.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={commonStyle} title={error || field.tooltip || field.label}>
|
||||||
|
<select
|
||||||
|
id={inputId}
|
||||||
|
value={selectValue}
|
||||||
|
onChange={handleSelectChange}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
disabled={field.readOnly}
|
||||||
|
multiple={field.multiSelect}
|
||||||
|
style={{
|
||||||
|
...inputBaseStyle,
|
||||||
|
padding: 0,
|
||||||
|
paddingLeft: 2,
|
||||||
|
appearance: 'auto',
|
||||||
|
WebkitAppearance: 'auto' as React.CSSProperties['WebkitAppearance'],
|
||||||
|
}}
|
||||||
|
aria-label={field.label || field.name}
|
||||||
|
aria-required={field.required}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
>
|
||||||
|
{!field.multiSelect && <option value="">— select —</option>}
|
||||||
|
{(field.options || []).map((opt, idx) => (
|
||||||
|
<option key={opt} value={opt}>
|
||||||
|
{(field.displayOptions && field.displayOptions[idx]) || opt}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'radio': {
|
||||||
|
// Each radio widget has an exportValue set by the backend
|
||||||
|
const optionValue = widget.exportValue || '';
|
||||||
|
if (!optionValue) return null; // no export value, skip
|
||||||
|
const isSelected = value === optionValue;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...commonStyle,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
cursor: field.readOnly ? 'default' : 'pointer',
|
||||||
|
}}
|
||||||
|
title={error || field.tooltip || `${field.label}: ${optionValue}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (field.readOnly || value === optionValue) return; // Don't deselect radio buttons
|
||||||
|
handleFocus();
|
||||||
|
onChange(field.name, optionValue);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: Math.max(8, height * 0.5),
|
||||||
|
height: Math.max(8, height * 0.5),
|
||||||
|
borderRadius: '50%',
|
||||||
|
border: '2px solid #666',
|
||||||
|
background: isSelected ? '#2196F3' : 'transparent',
|
||||||
|
display: 'block',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'signature':
|
||||||
|
case 'button':
|
||||||
|
// Just render a highlighted area — not editable
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...commonStyle,
|
||||||
|
background: 'rgba(200,200,200,0.3)',
|
||||||
|
border: '1px dashed #999',
|
||||||
|
cursor: 'default',
|
||||||
|
}}
|
||||||
|
title={field.tooltip || `${field.type}: ${field.label}`}
|
||||||
|
onClick={handleFocus}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div style={commonStyle} title={field.tooltip || field.label}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(field.name, e.target.value)}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
disabled={field.readOnly}
|
||||||
|
style={inputBaseStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const WidgetInput = memo(WidgetInputInner);
|
||||||
|
|
||||||
|
interface FormFieldOverlayProps {
|
||||||
|
documentId: string;
|
||||||
|
pageIndex: number;
|
||||||
|
pageWidth: number; // rendered CSS pixel width (from renderPage callback)
|
||||||
|
pageHeight: number; // rendered CSS pixel height
|
||||||
|
/** File identity — if provided, overlay only renders when context fields match this file */
|
||||||
|
fileId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormFieldOverlay({
|
||||||
|
documentId,
|
||||||
|
pageIndex,
|
||||||
|
pageWidth,
|
||||||
|
pageHeight,
|
||||||
|
fileId,
|
||||||
|
}: FormFieldOverlayProps) {
|
||||||
|
const { setValue, setActiveField, fieldsByPage, state, forFileId } = useFormFill();
|
||||||
|
const { activeFieldName, validationErrors } = state;
|
||||||
|
|
||||||
|
// Get scale from EmbedPDF document state — same pattern as LinkLayer
|
||||||
|
// NOTE: All hooks must be called unconditionally (before any early returns)
|
||||||
|
const documentState = useDocumentState(documentId);
|
||||||
|
|
||||||
|
const { scaleX, scaleY } = useMemo(() => {
|
||||||
|
const pdfPage = documentState?.document?.pages?.[pageIndex];
|
||||||
|
if (!pdfPage || !pdfPage.size || !pageWidth || !pageHeight) {
|
||||||
|
const s = documentState?.scale ?? 1;
|
||||||
|
return { scaleX: s, scaleY: s };
|
||||||
|
}
|
||||||
|
|
||||||
|
// pdfPage.size contains un-rotated (MediaBox) dimensions;
|
||||||
|
// pageWidth/pageHeight from Scroller also use these un-rotated dims * scale
|
||||||
|
return {
|
||||||
|
scaleX: pageWidth / pdfPage.size.width,
|
||||||
|
scaleY: pageHeight / pdfPage.size.height,
|
||||||
|
};
|
||||||
|
}, [documentState, pageIndex, pageWidth, pageHeight]);
|
||||||
|
|
||||||
|
const pageFields = useMemo(
|
||||||
|
() => fieldsByPage.get(pageIndex) || [],
|
||||||
|
[fieldsByPage, pageIndex]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFocus = useCallback(
|
||||||
|
(fieldName: string) => setActiveField(fieldName),
|
||||||
|
[setActiveField]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(fieldName: string, value: string) => setValue(fieldName, value),
|
||||||
|
[setValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Guard: don't render fields from a previous document.
|
||||||
|
// If fileId is provided and doesn't match what the context fetched for, render nothing.
|
||||||
|
if (fileId != null && forFileId != null && fileId !== forFileId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Also guard: if fields exist but no forFileId is set (reset happened), don't render stale fields
|
||||||
|
if (fileId != null && forFileId == null && state.fields.length > 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageFields.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
pointerEvents: 'none', // allow click-through except on widgets
|
||||||
|
zIndex: 5, // above TilingLayer, below LinkLayer
|
||||||
|
}}
|
||||||
|
data-form-overlay-page={pageIndex}
|
||||||
|
>
|
||||||
|
{pageFields.map((field: FormField) =>
|
||||||
|
(field.widgets || [])
|
||||||
|
.filter((w: WidgetCoordinates) => w.pageIndex === pageIndex)
|
||||||
|
.map((widget: WidgetCoordinates, widgetIdx: number) => {
|
||||||
|
// Coordinates are in un-rotated PDF space (y-flipped to CSS TL origin).
|
||||||
|
// The <Rotate> CSS wrapper handles visual rotation for us,
|
||||||
|
// just like it does for TilingLayer, LinkLayer, etc.
|
||||||
|
return (
|
||||||
|
<WidgetInput
|
||||||
|
key={`${field.name}-${widgetIdx}`}
|
||||||
|
field={field}
|
||||||
|
widget={widget}
|
||||||
|
isActive={activeFieldName === field.name}
|
||||||
|
error={validationErrors[field.name]}
|
||||||
|
scaleX={scaleX}
|
||||||
|
scaleY={scaleY}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FormFieldOverlay;
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
/**
|
||||||
|
* FormFieldSidebar — A right-side panel for viewing and filling form fields
|
||||||
|
* when the dedicated formFill tool is NOT selected (normal viewer mode).
|
||||||
|
*
|
||||||
|
* Redesigned with:
|
||||||
|
* - Consistent CSS module styling matching the main FormFill panel
|
||||||
|
* - Shared FieldInput component (no duplication)
|
||||||
|
* - Better visual hierarchy and spacing
|
||||||
|
*/
|
||||||
|
import React, { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Text,
|
||||||
|
ScrollArea,
|
||||||
|
Badge,
|
||||||
|
Tooltip,
|
||||||
|
ActionIcon,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useFormFill } from '@proprietary/tools/formFill/FormFillContext';
|
||||||
|
import { FieldInput } from '@proprietary/tools/formFill/FieldInput';
|
||||||
|
import { FIELD_TYPE_ICON, FIELD_TYPE_COLOR } from '@proprietary/tools/formFill/fieldMeta';
|
||||||
|
import type { FormField } from '@proprietary/tools/formFill/types';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
import TextFieldsIcon from '@mui/icons-material/TextFields';
|
||||||
|
import styles from '@proprietary/tools/formFill/FormFill.module.css';
|
||||||
|
|
||||||
|
interface FormFieldSidebarProps {
|
||||||
|
visible: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormFieldSidebar({
|
||||||
|
visible,
|
||||||
|
onToggle,
|
||||||
|
}: FormFieldSidebarProps) {
|
||||||
|
useTranslation();
|
||||||
|
const { state, setValue, setActiveField } = useFormFill();
|
||||||
|
const { fields, activeFieldName, loading } = state;
|
||||||
|
const activeFieldRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeFieldName && activeFieldRef.current) {
|
||||||
|
activeFieldRef.current.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'center',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [activeFieldName]);
|
||||||
|
|
||||||
|
const handleFieldClick = useCallback(
|
||||||
|
(fieldName: string) => {
|
||||||
|
setActiveField(fieldName);
|
||||||
|
},
|
||||||
|
[setActiveField]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleValueChange = useCallback(
|
||||||
|
(fieldName: string, value: string) => {
|
||||||
|
setValue(fieldName, value);
|
||||||
|
},
|
||||||
|
[setValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
const fieldsByPage = new Map<number, FormField[]>();
|
||||||
|
for (const field of fields) {
|
||||||
|
const pageIndex =
|
||||||
|
field.widgets && field.widgets.length > 0 ? field.widgets[0].pageIndex : 0;
|
||||||
|
if (!fieldsByPage.has(pageIndex)) {
|
||||||
|
fieldsByPage.set(pageIndex, []);
|
||||||
|
}
|
||||||
|
fieldsByPage.get(pageIndex)!.push(field);
|
||||||
|
}
|
||||||
|
const sortedPages = Array.from(fieldsByPage.keys()).sort((a, b) => a - b);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
width: '18.5rem',
|
||||||
|
height: '100%',
|
||||||
|
zIndex: 999,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
background: 'var(--bg-toolbar, var(--mantine-color-body))',
|
||||||
|
borderLeft: '1px solid var(--border-subtle, var(--mantine-color-default-border))',
|
||||||
|
boxShadow: '-4px 0 16px rgba(0,0,0,0.08)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '0.625rem 0.75rem',
|
||||||
|
borderBottom: '1px solid var(--border-subtle, var(--mantine-color-default-border))',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<TextFieldsIcon sx={{ fontSize: 18, opacity: 0.7 }} />
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
Form Fields
|
||||||
|
</Text>
|
||||||
|
<Badge size="xs" variant="light" color="blue" radius="sm">
|
||||||
|
{fields.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<ActionIcon variant="subtle" size="sm" onClick={onToggle} aria-label="Close sidebar">
|
||||||
|
<CloseIcon sx={{ fontSize: 16 }} />
|
||||||
|
</ActionIcon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<ScrollArea style={{ flex: 1 }}>
|
||||||
|
{loading && (
|
||||||
|
<div className={styles.emptyState}>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Loading form fields...
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && fields.length === 0 && (
|
||||||
|
<div className={styles.emptyState}>
|
||||||
|
<span className={styles.emptyStateText}>
|
||||||
|
No form fields found in this PDF
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && fields.length > 0 && (
|
||||||
|
<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}>
|
||||||
|
Page {pageIdx + 1}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fieldsByPage.get(pageIdx)!.map((field) => {
|
||||||
|
const isActive = activeFieldName === field.name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={field.name}
|
||||||
|
ref={isActive ? activeFieldRef : undefined}
|
||||||
|
className={`${styles.fieldCard} ${
|
||||||
|
isActive ? styles.fieldCardActive : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => handleFieldClick(field.name)}
|
||||||
|
>
|
||||||
|
<div className={styles.fieldHeader}>
|
||||||
|
<Tooltip label={field.type} withArrow position="left">
|
||||||
|
<span
|
||||||
|
className={styles.fieldTypeIcon}
|
||||||
|
style={{
|
||||||
|
color: `var(--mantine-color-${FIELD_TYPE_COLOR[field.type]}-6)`,
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{FIELD_TYPE_ICON[field.type]}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<span className={styles.fieldName}>
|
||||||
|
{field.label || field.name}
|
||||||
|
</span>
|
||||||
|
{field.required && (
|
||||||
|
<span className={styles.fieldRequired}>req</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{field.type !== 'button' && field.type !== 'signature' && (
|
||||||
|
<div
|
||||||
|
className={styles.fieldInputWrap}
|
||||||
|
>
|
||||||
|
<FieldInput
|
||||||
|
field={field}
|
||||||
|
onValueChange={handleValueChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{field.tooltip && (
|
||||||
|
<div className={styles.fieldHint}>
|
||||||
|
{field.tooltip}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FormFieldSidebar;
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
.root {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modeTabs {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-bottom: 1px solid var(--border-default, var(--mantine-color-default-border));
|
||||||
|
background: transparent;
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmentedRoot {
|
||||||
|
background: rgba(0, 0, 0, 0.05) !important;
|
||||||
|
border-radius: var(--radius-sm) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-mantine-color-scheme="dark"]) .segmentedRoot {
|
||||||
|
background: rgba(255, 255, 255, 0.05) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmentedIndicator {
|
||||||
|
background-color: var(--mantine-color-blue-filled) !important;
|
||||||
|
box-shadow: var(--shadow-sm) !important;
|
||||||
|
border-radius: var(--radius-sm) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmentedLabel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0;
|
||||||
|
padding: 0.125rem 0;
|
||||||
|
min-height: 2.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmentedInnerLabel {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modeTabIcon {
|
||||||
|
font-size: 1rem !important;
|
||||||
|
margin-bottom: 0.125rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: transparent;
|
||||||
|
border-bottom: 1px solid var(--border-default, var(--mantine-color-default-border));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressRow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressLabel {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionBar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionBar > *:first-child {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldList {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldListInner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageDivider {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageDivider::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-default, var(--mantine-color-default-border));
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageDividerLabel {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldCard {
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-default, var(--mantine-color-default-border));
|
||||||
|
background: var(--bg-surface, var(--mantine-color-body));
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldCard:hover {
|
||||||
|
border-color: var(--mantine-color-blue-5);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldCardActive {
|
||||||
|
border-color: var(--mantine-color-blue-5);
|
||||||
|
background-color: var(--mantine-color-blue-light);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--mantine-color-blue-light-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldCardError {
|
||||||
|
border-color: var(--mantine-color-red-5);
|
||||||
|
background-color: var(--mantine-color-red-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldTypeIcon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldName {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldRequired {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.5625rem;
|
||||||
|
padding: 0.0625rem 0.375rem;
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
background: var(--mantine-color-red-light);
|
||||||
|
color: var(--mantine-color-red-light-color);
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldInputWrap {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldHint {
|
||||||
|
margin-top: 0.375rem;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.4;
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldError {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--mantine-color-red-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyState {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 5rem 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyStateIcon {
|
||||||
|
font-size: 2.5rem !important;
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyStateText {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
max-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsavedDot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--mantine-color-yellow-6);
|
||||||
|
margin-right: 0.375rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-top: 1px solid var(--border-default, var(--mantine-color-default-border));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comingSoon {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comingSoonIcon {
|
||||||
|
font-size: 3rem !important;
|
||||||
|
opacity: 0.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comingSoonTitle {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comingSoonDesc {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-muted);
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,560 @@
|
|||||||
|
/**
|
||||||
|
* FormFill: The tool component that renders in the left ToolPanel
|
||||||
|
* when the "Fill Form" 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
|
||||||
|
*/
|
||||||
|
import React, { useEffect, useCallback, useState, useRef, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Text,
|
||||||
|
Alert,
|
||||||
|
Switch,
|
||||||
|
Loader,
|
||||||
|
ScrollArea,
|
||||||
|
Progress,
|
||||||
|
Tooltip,
|
||||||
|
ActionIcon,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useFormFill, useAllFormValues } from '@proprietary/tools/formFill/FormFillContext';
|
||||||
|
import { useNavigation } from '@app/contexts/NavigationContext';
|
||||||
|
import { useViewer } from '@app/contexts/ViewerContext';
|
||||||
|
import { useFileState } from '@app/contexts/FileContext';
|
||||||
|
import { Skeleton } from '@mantine/core';
|
||||||
|
import { isStirlingFile } from '@app/types/fileContext';
|
||||||
|
import type { BaseToolProps } from '@app/types/tool';
|
||||||
|
import type { FormField } from '@proprietary/tools/formFill/types';
|
||||||
|
import { FieldInput } from '@proprietary/tools/formFill/FieldInput';
|
||||||
|
import { FIELD_TYPE_ICON, FIELD_TYPE_COLOR } from '@proprietary/tools/formFill/fieldMeta';
|
||||||
|
import SaveIcon from '@mui/icons-material/Save';
|
||||||
|
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';
|
||||||
|
import styles from '@proprietary/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
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const FormFill = (_props: BaseToolProps) => {
|
||||||
|
const { selectedTool } = useNavigation();
|
||||||
|
const { selectors, state: fileState } = useFileState();
|
||||||
|
|
||||||
|
const {
|
||||||
|
state: formState,
|
||||||
|
fetchFields,
|
||||||
|
submitForm,
|
||||||
|
setValue,
|
||||||
|
setActiveField,
|
||||||
|
validateForm,
|
||||||
|
} = useFormFill();
|
||||||
|
|
||||||
|
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);
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [lastSavedFlatten, setLastSavedFlatten] = useState<boolean | null>(null);
|
||||||
|
const flattenChanged = lastSavedFlatten !== null && flatten !== lastSavedFlatten;
|
||||||
|
|
||||||
|
const savingRef = useRef(false);
|
||||||
|
|
||||||
|
const handleExtractJson = useCallback(() => {
|
||||||
|
setExtracting(true);
|
||||||
|
try {
|
||||||
|
const data = JSON.stringify(allValues, null, 2);
|
||||||
|
const blob = new Blob([data], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `form-data-${new Date().getTime()}.json`;
|
||||||
|
a.click();
|
||||||
|
// Delay revocation so the browser has time to start the download
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 250);
|
||||||
|
} finally {
|
||||||
|
setExtracting(false);
|
||||||
|
}
|
||||||
|
}, [allValues]);
|
||||||
|
const activeFieldRef = useRef<HTMLDivElement>(null);
|
||||||
|
const isDirtyRef = useRef(formState.isDirty);
|
||||||
|
isDirtyRef.current = formState.isDirty;
|
||||||
|
|
||||||
|
const activeFiles = selectors.getFiles();
|
||||||
|
const selectedFileIds = fileState.ui.selectedFileIds;
|
||||||
|
const currentFile = useMemo(() => {
|
||||||
|
if (activeFiles.length === 0) return null;
|
||||||
|
if (selectedFileIds.length > 0) {
|
||||||
|
const sel = activeFiles.find(
|
||||||
|
(f) => isStirlingFile(f) && selectedFileIds.includes(f.fileId)
|
||||||
|
);
|
||||||
|
if (sel) return sel;
|
||||||
|
}
|
||||||
|
return activeFiles[0];
|
||||||
|
}, [activeFiles, selectedFileIds]);
|
||||||
|
|
||||||
|
const isActive = selectedTool === 'formFill';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (formState.activeFieldName && activeFieldRef.current) {
|
||||||
|
activeFieldRef.current.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'center',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [formState.activeFieldName]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
// Ref-based guard prevents concurrent saves that cause file duplication
|
||||||
|
if (savingRef.current) return;
|
||||||
|
if (!currentFile || !isStirlingFile(currentFile)) return;
|
||||||
|
|
||||||
|
if (!validateForm()) {
|
||||||
|
setSaveError('Please fill in all required fields');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
savingRef.current = true;
|
||||||
|
setSaving(true);
|
||||||
|
setSaveError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const filledBlob = await submitForm(currentFile, flatten);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
} catch (err: any) {
|
||||||
|
const message = err?.response?.status === 413
|
||||||
|
? 'File too large. Try reducing the PDF size first.'
|
||||||
|
: err?.response?.status === 400
|
||||||
|
? 'Invalid form data. Please check all fields.'
|
||||||
|
: err?.message || 'Failed to save filled form';
|
||||||
|
setSaveError(message);
|
||||||
|
console.error('[FormFill] Save failed:', err);
|
||||||
|
} finally {
|
||||||
|
savingRef.current = false;
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [currentFile, submitForm, flatten, validateForm]);
|
||||||
|
|
||||||
|
// Keyboard shortcut: Ctrl+S to save
|
||||||
|
const flattenChangedRef = useRef(flattenChanged);
|
||||||
|
flattenChangedRef.current = flattenChanged;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActive) return;
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isDirtyRef.current || flattenChangedRef.current) handleSave();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [isActive, handleSave]);
|
||||||
|
|
||||||
|
// Data loss prevention: warn on beforeunload if dirty
|
||||||
|
useEffect(() => {
|
||||||
|
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||||
|
if (formState.isDirty) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.returnValue = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
}, [formState.isDirty]);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
if (currentFile && isStirlingFile(currentFile)) {
|
||||||
|
fetchFields(currentFile, currentFile.fileId);
|
||||||
|
} else if (currentFile) {
|
||||||
|
fetchFields(currentFile);
|
||||||
|
}
|
||||||
|
}, [currentFile, fetchFields]);
|
||||||
|
|
||||||
|
const handleValueChange = useCallback(
|
||||||
|
(fieldName: string, value: string) => {
|
||||||
|
setValue(fieldName, value);
|
||||||
|
},
|
||||||
|
[setValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFieldClick = useCallback(
|
||||||
|
(fieldName: string, pageIndex?: number) => {
|
||||||
|
setActiveField(fieldName);
|
||||||
|
if (pageIndex !== undefined) {
|
||||||
|
scrollActions.scrollToPage(pageIndex + 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setActiveField, scrollActions]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Memoize fields grouped by page
|
||||||
|
const { sortedPages, fieldsByPage } = useMemo(() => {
|
||||||
|
const byPage = new Map<number, FormField[]>();
|
||||||
|
for (const field of formState.fields) {
|
||||||
|
const pageIndex =
|
||||||
|
field.widgets && field.widgets.length > 0 ? field.widgets[0].pageIndex : 0;
|
||||||
|
if (!byPage.has(pageIndex)) {
|
||||||
|
byPage.set(pageIndex, []);
|
||||||
|
}
|
||||||
|
byPage.get(pageIndex)!.push(field);
|
||||||
|
}
|
||||||
|
const pages = Array.from(byPage.keys()).sort((a, b) => a - b);
|
||||||
|
return { sortedPages: pages, fieldsByPage: byPage };
|
||||||
|
}, [formState.fields]);
|
||||||
|
|
||||||
|
// Progress tracking
|
||||||
|
|
||||||
|
const fillableFields = useMemo(() => {
|
||||||
|
return formState.fields.filter((f) => f.type !== 'button' && f.type !== 'signature');
|
||||||
|
}, [formState.fields]);
|
||||||
|
|
||||||
|
const fillableCount = fillableFields.length;
|
||||||
|
|
||||||
|
const filledCount = useMemo(() => {
|
||||||
|
return fillableFields.filter((f) => {
|
||||||
|
const v = allValues[f.name];
|
||||||
|
return v && v !== 'Off' && v.trim() !== '';
|
||||||
|
}).length;
|
||||||
|
}, [fillableFields, allValues]);
|
||||||
|
|
||||||
|
const requiredFields = useMemo(() => {
|
||||||
|
return fillableFields.filter((f) => f.required);
|
||||||
|
}, [fillableFields]);
|
||||||
|
|
||||||
|
const requiredCount = requiredFields.length;
|
||||||
|
|
||||||
|
const filledRequiredCount = useMemo(() => {
|
||||||
|
return requiredFields.filter((f) => {
|
||||||
|
const v = allValues[f.name];
|
||||||
|
return v && v !== 'Off' && v.trim() !== '';
|
||||||
|
}).length;
|
||||||
|
}, [requiredFields, allValues]);
|
||||||
|
|
||||||
|
if (!isActive) return null;
|
||||||
|
|
||||||
|
// const currentModeDef = MODE_TABS.find((t) => t.id === mode)!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.root}>
|
||||||
|
{/* ---- Mode selection (commented out until additional modes are implemented) ----
|
||||||
|
<div className={styles.modeTabs}>
|
||||||
|
<SegmentedControl
|
||||||
|
value={mode}
|
||||||
|
onChange={(val) => setMode(val as FormMode)}
|
||||||
|
data={MODE_TABS.map((tab) => ({
|
||||||
|
value: tab.id,
|
||||||
|
label: (
|
||||||
|
<div className={styles.segmentedLabel}>
|
||||||
|
{tab.icon}
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))}
|
||||||
|
fullWidth
|
||||||
|
radius="xs"
|
||||||
|
size="xs"
|
||||||
|
classNames={{
|
||||||
|
root: styles.segmentedRoot,
|
||||||
|
indicator: styles.segmentedIndicator,
|
||||||
|
control: styles.segmentedControl,
|
||||||
|
label: styles.segmentedInnerLabel,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
---- */}
|
||||||
|
|
||||||
|
{/* ---- Coming-soon for non-ready tabs (hidden while mode tabs are disabled) ---- */}
|
||||||
|
{/* !currentModeDef.ready && <ComingSoonPlaceholder mode={currentModeDef} /> */}
|
||||||
|
|
||||||
|
{/* ---- Fill Form content ---- */}
|
||||||
|
{mode === 'fill' && (
|
||||||
|
<>
|
||||||
|
{/* Header / controls */}
|
||||||
|
<div className={styles.header}>
|
||||||
|
{/* Loading state */}
|
||||||
|
{formState.loading && (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<Loader size={14} />
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Analysing form fields...
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Skeleton height={48} radius="sm" />
|
||||||
|
<Skeleton height={48} radius="sm" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{formState.error && (
|
||||||
|
<Alert
|
||||||
|
icon={<WarningAmberIcon sx={{ fontSize: 16 }} />}
|
||||||
|
color="red"
|
||||||
|
variant="light"
|
||||||
|
p="xs"
|
||||||
|
radius="sm"
|
||||||
|
>
|
||||||
|
<Text size="xs">{formState.error}</Text>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ready state with fields */}
|
||||||
|
{!formState.loading && formState.fields.length > 0 && (
|
||||||
|
<>
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div>
|
||||||
|
<div className={styles.progressRow}>
|
||||||
|
<span className={styles.progressLabel}>
|
||||||
|
{filledCount} / {fillableCount} filled
|
||||||
|
{requiredCount > 0 && (
|
||||||
|
<span style={{ marginLeft: '0.5rem', opacity: 0.7 }}>
|
||||||
|
({filledRequiredCount}/{requiredCount} req.)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className={styles.progressLabel}>
|
||||||
|
{fillableCount > 0
|
||||||
|
? Math.round((filledCount / fillableCount) * 100)
|
||||||
|
: 0}
|
||||||
|
%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={fillableCount > 0 ? (filledCount / fillableCount) * 100 : 0}
|
||||||
|
size={6}
|
||||||
|
radius="xl"
|
||||||
|
color={filledRequiredCount === requiredCount ? 'teal' : 'blue'}
|
||||||
|
mt={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Flatten toggle */}
|
||||||
|
<Switch
|
||||||
|
label="Flatten after filling"
|
||||||
|
checked={flatten}
|
||||||
|
onChange={(e) => setFlatten(e.currentTarget.checked)}
|
||||||
|
size="xs"
|
||||||
|
styles={{
|
||||||
|
label: { fontSize: '0.75rem', cursor: 'pointer' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className={styles.actionBar}>
|
||||||
|
<Button
|
||||||
|
leftSection={<SaveIcon sx={{ fontSize: 14 }} />}
|
||||||
|
size="xs"
|
||||||
|
onClick={handleSave}
|
||||||
|
loading={saving}
|
||||||
|
disabled={!formState.isDirty && !flattenChanged}
|
||||||
|
flex={1}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="blue"
|
||||||
|
leftSection={<FileDownloadIcon sx={{ fontSize: 14 }} />}
|
||||||
|
loading={extracting}
|
||||||
|
onClick={handleExtractJson}
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
Extract JSON
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Tooltip label="Re-scan fields" withArrow position="bottom">
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
size="md"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
aria-label="Re-scan form fields"
|
||||||
|
>
|
||||||
|
<RefreshIcon sx={{ fontSize: 16 }} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{saveError && (
|
||||||
|
<Alert color="red" variant="light" p="xs" radius="sm">
|
||||||
|
<Text size="xs">{saveError}</Text>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{!formState.loading && formState.fields.length === 0 && !formState.error && (
|
||||||
|
<div className={styles.emptyState}>
|
||||||
|
<DescriptionIcon className={styles.emptyStateIcon} />
|
||||||
|
<span className={styles.emptyStateText}>
|
||||||
|
No fillable form fields found in this PDF.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- Scrollable field list ---- */}
|
||||||
|
{!formState.loading && formState.fields.length > 0 && (
|
||||||
|
<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}>
|
||||||
|
Page {pageIdx + 1}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fieldsByPage.get(pageIdx)!.map((field) => {
|
||||||
|
const isFieldActive = formState.activeFieldName === field.name;
|
||||||
|
const hasError = !!validationErrors[field.name];
|
||||||
|
const pageIndex =
|
||||||
|
field.widgets && field.widgets.length > 0
|
||||||
|
? field.widgets[0].pageIndex
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={field.name}
|
||||||
|
ref={isFieldActive ? activeFieldRef : undefined}
|
||||||
|
className={`${styles.fieldCard} ${
|
||||||
|
isFieldActive ? styles.fieldCardActive : ''
|
||||||
|
} ${hasError ? styles.fieldCardError : ''}`}
|
||||||
|
onClick={() => handleFieldClick(field.name, pageIndex)}
|
||||||
|
>
|
||||||
|
<div className={styles.fieldHeader}>
|
||||||
|
<span
|
||||||
|
className={styles.fieldTypeIcon}
|
||||||
|
style={{
|
||||||
|
color: `var(--mantine-color-${FIELD_TYPE_COLOR[field.type]}-6)`,
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{FIELD_TYPE_ICON[field.type]}
|
||||||
|
</span>
|
||||||
|
<span className={styles.fieldName}>
|
||||||
|
{field.label || field.name}
|
||||||
|
</span>
|
||||||
|
{field.required && (
|
||||||
|
<span className={styles.fieldRequired}>req</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{field.type !== 'button' && field.type !== 'signature' && (
|
||||||
|
<div
|
||||||
|
className={styles.fieldInputWrap}
|
||||||
|
>
|
||||||
|
<FieldInput
|
||||||
|
field={field}
|
||||||
|
onValueChange={handleValueChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasError && (
|
||||||
|
<div className={styles.fieldError}>
|
||||||
|
{validationErrors[field.name]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{field.tooltip && (
|
||||||
|
<div className={styles.fieldHint}>
|
||||||
|
{field.tooltip}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- Status bar ---- */}
|
||||||
|
{!formState.loading && formState.fields.length > 0 && (
|
||||||
|
<div className={styles.statusBar}>
|
||||||
|
<span>
|
||||||
|
{(formState.isDirty || flattenChanged) && <span className={styles.unsavedDot} />}
|
||||||
|
{formState.isDirty || flattenChanged ? 'Unsaved changes' : 'All saved'}
|
||||||
|
</span>
|
||||||
|
<span>Ctrl+S to save</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FormFill;
|
||||||
@@ -0,0 +1,507 @@
|
|||||||
|
/**
|
||||||
|
* FormFillContext — React context for form fill state management.
|
||||||
|
*
|
||||||
|
* Provider-agnostic: delegates data fetching/saving to an IFormDataProvider.
|
||||||
|
* - PdfLibFormProvider: frontend-only, uses pdf-lib (for normal viewer mode)
|
||||||
|
* - PdfBoxFormProvider: backend API via PDFBox (for dedicated formFill tool)
|
||||||
|
*
|
||||||
|
* The active provider can be switched at runtime via setProvider(). This allows
|
||||||
|
* EmbedPdfViewer to auto-select:
|
||||||
|
* - Normal viewer → PdfLibFormProvider (no backend calls for large PDFs)
|
||||||
|
* - formFill tool → PdfBoxFormProvider (full-fidelity PDFBox handling)
|
||||||
|
*
|
||||||
|
* Performance Architecture:
|
||||||
|
* Form values are stored in a FormValuesStore (external to React state) to
|
||||||
|
* avoid full context re-renders on every keystroke. Individual widgets
|
||||||
|
* subscribe to their specific field via useFieldValue() + useSyncExternalStore,
|
||||||
|
* so only the active widget re-renders when its value changes.
|
||||||
|
*
|
||||||
|
* The UI components (FormFieldOverlay, FormFill, FormFieldSidebar) consume
|
||||||
|
* this context regardless of which provider is active.
|
||||||
|
*/
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useReducer,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useSyncExternalStore,
|
||||||
|
} from 'react';
|
||||||
|
import { useDebouncedCallback } from '@mantine/hooks';
|
||||||
|
import type { FormField, FormFillState, WidgetCoordinates } from '@proprietary/tools/formFill/types';
|
||||||
|
import type { IFormDataProvider } from '@proprietary/tools/formFill/providers/types';
|
||||||
|
import { PdfLibFormProvider } from '@proprietary/tools/formFill/providers/PdfLibFormProvider';
|
||||||
|
import { PdfBoxFormProvider } from '@proprietary/tools/formFill/providers/PdfBoxFormProvider';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// FormValuesStore — external store for field values (outside React state)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type Listener = () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* External store that holds form values outside of React state.
|
||||||
|
*
|
||||||
|
* This avoids triggering full context re-renders on every keystroke.
|
||||||
|
* Components subscribe per-field via useSyncExternalStore, so only
|
||||||
|
* the widget being edited re-renders.
|
||||||
|
*/
|
||||||
|
class FormValuesStore {
|
||||||
|
private _fieldListeners = new Map<string, Set<Listener>>();
|
||||||
|
private _globalListeners = new Set<Listener>();
|
||||||
|
|
||||||
|
private _values: Record<string, string> = {};
|
||||||
|
|
||||||
|
get values(): Record<string, string> {
|
||||||
|
return this._values;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _version = 0;
|
||||||
|
|
||||||
|
get version(): number {
|
||||||
|
return this._version;
|
||||||
|
}
|
||||||
|
|
||||||
|
getValue(fieldName: string): string {
|
||||||
|
return this._values[fieldName] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue(fieldName: string, value: string): void {
|
||||||
|
if (this._values[fieldName] === value) return;
|
||||||
|
this._values[fieldName] = value;
|
||||||
|
this._version++;
|
||||||
|
this._fieldListeners.get(fieldName)?.forEach((l) => l());
|
||||||
|
this._globalListeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace all values (e.g., on fetch or reset) */
|
||||||
|
reset(values: Record<string, string> = {}): void {
|
||||||
|
this._values = values;
|
||||||
|
this._version++;
|
||||||
|
for (const listeners of this._fieldListeners.values()) {
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
this._globalListeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to a single field's value changes */
|
||||||
|
subscribeField(fieldName: string, listener: Listener): () => void {
|
||||||
|
if (!this._fieldListeners.has(fieldName)) {
|
||||||
|
this._fieldListeners.set(fieldName, new Set());
|
||||||
|
}
|
||||||
|
this._fieldListeners.get(fieldName)!.add(listener);
|
||||||
|
return () => {
|
||||||
|
this._fieldListeners.get(fieldName)?.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to any value change */
|
||||||
|
subscribeGlobal(listener: Listener): () => void {
|
||||||
|
this._globalListeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this._globalListeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reducer — handles everything EXCEPT values (which live in FormValuesStore)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| { type: 'FETCH_START' }
|
||||||
|
| { type: 'FETCH_SUCCESS'; fields: FormField[] }
|
||||||
|
| { type: 'FETCH_ERROR'; error: string }
|
||||||
|
| { type: 'MARK_DIRTY' }
|
||||||
|
| { type: 'SET_ACTIVE_FIELD'; fieldName: string | null }
|
||||||
|
| { type: 'SET_VALIDATION_ERRORS'; errors: Record<string, string> }
|
||||||
|
| { type: 'CLEAR_VALIDATION_ERROR'; fieldName: string }
|
||||||
|
| { type: 'MARK_CLEAN' }
|
||||||
|
| { type: 'RESET' };
|
||||||
|
|
||||||
|
const initialState: FormFillState = {
|
||||||
|
fields: [],
|
||||||
|
values: {}, // kept for backward compat but canonical values live in FormValuesStore
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
activeFieldName: null,
|
||||||
|
isDirty: false,
|
||||||
|
validationErrors: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
function reducer(state: FormFillState, action: Action): FormFillState {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'FETCH_START':
|
||||||
|
return { ...state, loading: true, error: null };
|
||||||
|
case 'FETCH_SUCCESS': {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
fields: action.fields,
|
||||||
|
values: {}, // values managed by FormValuesStore
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
isDirty: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'FETCH_ERROR':
|
||||||
|
return { ...state, loading: false, error: action.error };
|
||||||
|
case 'MARK_DIRTY':
|
||||||
|
if (state.isDirty) return state; // avoid unnecessary re-render
|
||||||
|
return { ...state, isDirty: true };
|
||||||
|
case 'SET_ACTIVE_FIELD':
|
||||||
|
return { ...state, activeFieldName: action.fieldName };
|
||||||
|
case 'SET_VALIDATION_ERRORS':
|
||||||
|
return { ...state, validationErrors: action.errors };
|
||||||
|
case 'CLEAR_VALIDATION_ERROR': {
|
||||||
|
if (!state.validationErrors[action.fieldName]) return state;
|
||||||
|
const { [action.fieldName]: _, ...rest } = state.validationErrors;
|
||||||
|
return { ...state, validationErrors: rest };
|
||||||
|
}
|
||||||
|
case 'MARK_CLEAN':
|
||||||
|
return { ...state, isDirty: false };
|
||||||
|
case 'RESET':
|
||||||
|
return initialState;
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormFillContextValue {
|
||||||
|
state: FormFillState;
|
||||||
|
/** Fetch form fields for the given file using the active provider */
|
||||||
|
fetchFields: (file: File | Blob, fileId?: string) => Promise<void>;
|
||||||
|
/** Update a single field value */
|
||||||
|
setValue: (fieldName: string, value: string) => void;
|
||||||
|
/** Set the currently focused field */
|
||||||
|
setActiveField: (fieldName: string | null) => void;
|
||||||
|
/** Submit filled form and return the filled PDF blob */
|
||||||
|
submitForm: (
|
||||||
|
file: File | Blob,
|
||||||
|
flatten?: boolean
|
||||||
|
) => Promise<Blob>;
|
||||||
|
/** Get field by name */
|
||||||
|
getField: (fieldName: string) => FormField | undefined;
|
||||||
|
/** Get fields for a specific page index */
|
||||||
|
getFieldsForPage: (pageIndex: number) => FormField[];
|
||||||
|
/** Get the current value for a field (reads from external store) */
|
||||||
|
getValue: (fieldName: string) => string;
|
||||||
|
/** Validate the current form state and return true if valid */
|
||||||
|
validateForm: () => boolean;
|
||||||
|
/** Clear all form state (fields, values, errors) */
|
||||||
|
reset: () => void;
|
||||||
|
/** Pre-computed map of page index to fields for performance */
|
||||||
|
fieldsByPage: Map<number, FormField[]>;
|
||||||
|
/** Name of the currently active provider ('pdf-lib' | 'pdfbox') */
|
||||||
|
activeProviderName: string;
|
||||||
|
/**
|
||||||
|
* Switch the active data provider.
|
||||||
|
* Use 'pdflib' for frontend-only pdf-lib, 'pdfbox' for backend PDFBox.
|
||||||
|
* Resets form state when switching providers.
|
||||||
|
*/
|
||||||
|
setProviderMode: (mode: 'pdflib' | 'pdfbox') => void;
|
||||||
|
/** The file ID that the current form fields belong to (null if no fields loaded) */
|
||||||
|
forFileId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormFillContext = createContext<FormFillContextValue | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Separate context for the values store.
|
||||||
|
* This allows useFieldValue() to subscribe without depending on the main context.
|
||||||
|
*/
|
||||||
|
const FormValuesStoreContext = createContext<FormValuesStore | null>(null);
|
||||||
|
|
||||||
|
export const useFormFill = (): FormFillContextValue => {
|
||||||
|
const ctx = useContext(FormFillContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useFormFill must be used within a FormFillProvider');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to a single field's value. Only re-renders when that specific
|
||||||
|
* field's value changes — not when any other form value changes.
|
||||||
|
*
|
||||||
|
* Uses useSyncExternalStore for tear-free reads.
|
||||||
|
*/
|
||||||
|
export function useFieldValue(fieldName: string): string {
|
||||||
|
const store = useContext(FormValuesStoreContext);
|
||||||
|
if (!store) {
|
||||||
|
throw new Error('useFieldValue must be used within a FormFillProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscribe = useCallback(
|
||||||
|
(cb: () => void) => store.subscribeField(fieldName, cb),
|
||||||
|
[store, fieldName]
|
||||||
|
);
|
||||||
|
const getSnapshot = useCallback(
|
||||||
|
() => store.getValue(fieldName),
|
||||||
|
[store, fieldName]
|
||||||
|
);
|
||||||
|
|
||||||
|
return useSyncExternalStore(subscribe, getSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to all values (e.g., for progress counters or form submission).
|
||||||
|
* Re-renders on every value change — use sparingly.
|
||||||
|
*/
|
||||||
|
export function useAllFormValues(): Record<string, string> {
|
||||||
|
const store = useContext(FormValuesStoreContext);
|
||||||
|
if (!store) {
|
||||||
|
throw new Error('useAllFormValues must be used within a FormFillProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscribe = useCallback(
|
||||||
|
(cb: () => void) => store.subscribeGlobal(cb),
|
||||||
|
[store]
|
||||||
|
);
|
||||||
|
const getSnapshot = useCallback(
|
||||||
|
() => store.values,
|
||||||
|
[store]
|
||||||
|
);
|
||||||
|
|
||||||
|
return useSyncExternalStore(subscribe, getSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Singleton provider instances */
|
||||||
|
const pdfLibProvider = new PdfLibFormProvider();
|
||||||
|
const pdfBoxProvider = new PdfBoxFormProvider();
|
||||||
|
|
||||||
|
export function FormFillProvider({
|
||||||
|
children,
|
||||||
|
provider: providerProp,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
/** Override the initial provider. If not given, defaults to pdf-lib. */
|
||||||
|
provider?: IFormDataProvider;
|
||||||
|
}) {
|
||||||
|
const initialMode = providerProp?.name === 'pdfbox' ? 'pdfbox' : 'pdflib';
|
||||||
|
const [providerMode, setProviderModeState] = useState<'pdflib' | 'pdfbox'>(initialMode);
|
||||||
|
const providerModeRef = useRef(initialMode as 'pdflib' | 'pdfbox');
|
||||||
|
providerModeRef.current = providerMode;
|
||||||
|
const provider = providerProp ?? (providerMode === 'pdfbox' ? pdfBoxProvider : pdfLibProvider);
|
||||||
|
const providerRef = useRef(provider);
|
||||||
|
providerRef.current = provider;
|
||||||
|
|
||||||
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
|
const fieldsRef = useRef<FormField[]>([]);
|
||||||
|
fieldsRef.current = state.fields;
|
||||||
|
|
||||||
|
// Version counter to cancel stale async fetch responses.
|
||||||
|
// Incremented on every fetchFields() and reset() call.
|
||||||
|
const fetchVersionRef = useRef(0);
|
||||||
|
|
||||||
|
// Track which file the current fields belong to
|
||||||
|
const forFileIdRef = useRef<string | null>(null);
|
||||||
|
const [forFileId, setForFileId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// External values store — values live HERE, not in the reducer.
|
||||||
|
// This prevents full context re-renders on every keystroke.
|
||||||
|
const [valuesStore] = useState(() => new FormValuesStore());
|
||||||
|
|
||||||
|
const fetchFields = useCallback(async (file: File | Blob, fileId?: string) => {
|
||||||
|
// Increment version so any in-flight fetch for a previous file is discarded.
|
||||||
|
// NOTE: setProviderMode() also increments fetchVersionRef to invalidate
|
||||||
|
// in-flight fetches when switching providers. This is intentional — the
|
||||||
|
// fetch started here captures the NEW version, so stale results are
|
||||||
|
// correctly discarded.
|
||||||
|
const version = ++fetchVersionRef.current;
|
||||||
|
|
||||||
|
// Immediately clear previous state so FormFieldOverlay's stale-file guards
|
||||||
|
// prevent rendering fields from a previous document during the fetch.
|
||||||
|
forFileIdRef.current = null;
|
||||||
|
setForFileId(null);
|
||||||
|
valuesStore.reset({});
|
||||||
|
dispatch({ type: 'RESET' });
|
||||||
|
dispatch({ type: 'FETCH_START' });
|
||||||
|
try {
|
||||||
|
const fields = await providerRef.current.fetchFields(file);
|
||||||
|
// If another fetch or reset happened while we were waiting, discard this result
|
||||||
|
if (fetchVersionRef.current !== version) {
|
||||||
|
console.log('[FormFill] Discarding stale fetch result (version mismatch)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Initialise values in the external store
|
||||||
|
const values: Record<string, string> = {};
|
||||||
|
for (const field of fields) {
|
||||||
|
values[field.name] = field.value ?? '';
|
||||||
|
}
|
||||||
|
valuesStore.reset(values);
|
||||||
|
forFileIdRef.current = fileId ?? null;
|
||||||
|
setForFileId(fileId ?? null);
|
||||||
|
dispatch({ type: 'FETCH_SUCCESS', fields });
|
||||||
|
} catch (err: any) {
|
||||||
|
if (fetchVersionRef.current !== version) return; // stale
|
||||||
|
const msg =
|
||||||
|
err?.response?.data?.message ||
|
||||||
|
err?.message ||
|
||||||
|
'Failed to fetch form fields';
|
||||||
|
dispatch({ type: 'FETCH_ERROR', error: msg });
|
||||||
|
}
|
||||||
|
}, [valuesStore]);
|
||||||
|
|
||||||
|
const validateFieldDebounced = useDebouncedCallback((fieldName: string) => {
|
||||||
|
const field = fieldsRef.current.find((f) => f.name === fieldName);
|
||||||
|
if (!field || !field.required) return;
|
||||||
|
|
||||||
|
const val = valuesStore.getValue(fieldName);
|
||||||
|
if (!val || val.trim() === '' || val === 'Off') {
|
||||||
|
dispatch({
|
||||||
|
type: 'SET_VALIDATION_ERRORS',
|
||||||
|
errors: { ...state.validationErrors, [fieldName]: `${field.label} is required` },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
dispatch({ type: 'CLEAR_VALIDATION_ERROR', fieldName });
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
const validateForm = useCallback((): boolean => {
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
for (const field of fieldsRef.current) {
|
||||||
|
const val = valuesStore.getValue(field.name);
|
||||||
|
if (field.required && (!val || val.trim() === '' || val === 'Off')) {
|
||||||
|
errors[field.name] = `${field.label} is required`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dispatch({ type: 'SET_VALIDATION_ERRORS', errors });
|
||||||
|
return Object.keys(errors).length === 0;
|
||||||
|
}, [valuesStore]);
|
||||||
|
|
||||||
|
const setValue = useCallback(
|
||||||
|
(fieldName: string, value: string) => {
|
||||||
|
// Update external store (triggers per-field subscribers only)
|
||||||
|
valuesStore.setValue(fieldName, value);
|
||||||
|
// Mark form as dirty in React state (only triggers re-render once)
|
||||||
|
dispatch({ type: 'MARK_DIRTY' });
|
||||||
|
validateFieldDebounced(fieldName);
|
||||||
|
},
|
||||||
|
[valuesStore, validateFieldDebounced]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setActiveField = useCallback(
|
||||||
|
(fieldName: string | null) => {
|
||||||
|
dispatch({ type: 'SET_ACTIVE_FIELD', fieldName });
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitForm = useCallback(
|
||||||
|
async (file: File | Blob, flatten = false) => {
|
||||||
|
const blob = await providerRef.current.fillForm(file, valuesStore.values, flatten);
|
||||||
|
dispatch({ type: 'MARK_CLEAN' });
|
||||||
|
return blob;
|
||||||
|
},
|
||||||
|
[valuesStore]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setProviderMode = useCallback(
|
||||||
|
(mode: 'pdflib' | 'pdfbox') => {
|
||||||
|
// Use the ref to check the current mode synchronously — avoids
|
||||||
|
// relying on stale closure state and allows the early return.
|
||||||
|
if (providerModeRef.current === mode) return;
|
||||||
|
|
||||||
|
// provider (pdfbox vs pdflib).
|
||||||
|
const newProvider = mode === 'pdfbox' ? pdfBoxProvider : pdfLibProvider;
|
||||||
|
providerRef.current = newProvider;
|
||||||
|
providerModeRef.current = mode;
|
||||||
|
|
||||||
|
fetchVersionRef.current++;
|
||||||
|
forFileIdRef.current = null;
|
||||||
|
setForFileId(null);
|
||||||
|
valuesStore.reset({});
|
||||||
|
dispatch({ type: 'RESET' });
|
||||||
|
|
||||||
|
setProviderModeState(mode);
|
||||||
|
},
|
||||||
|
[valuesStore]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getField = useCallback(
|
||||||
|
(fieldName: string) =>
|
||||||
|
fieldsRef.current.find((f) => f.name === fieldName),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getFieldsForPage = useCallback(
|
||||||
|
(pageIndex: number) =>
|
||||||
|
fieldsRef.current.filter((f) =>
|
||||||
|
f.widgets?.some((w: WidgetCoordinates) => w.pageIndex === pageIndex)
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getValue = useCallback(
|
||||||
|
(fieldName: string) => valuesStore.getValue(fieldName),
|
||||||
|
[valuesStore]
|
||||||
|
);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
// Increment version to invalidate any in-flight fetch
|
||||||
|
fetchVersionRef.current++;
|
||||||
|
forFileIdRef.current = null;
|
||||||
|
setForFileId(null);
|
||||||
|
valuesStore.reset({});
|
||||||
|
dispatch({ type: 'RESET' });
|
||||||
|
}, [valuesStore]);
|
||||||
|
|
||||||
|
const fieldsByPage = useMemo(() => {
|
||||||
|
const map = new Map<number, FormField[]>();
|
||||||
|
for (const field of state.fields) {
|
||||||
|
const pageIdx = field.widgets?.[0]?.pageIndex ?? 0;
|
||||||
|
if (!map.has(pageIdx)) map.set(pageIdx, []);
|
||||||
|
map.get(pageIdx)!.push(field);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [state.fields]);
|
||||||
|
|
||||||
|
// Context value — does NOT depend on values, so keystrokes don't
|
||||||
|
// trigger re-renders of all context consumers.
|
||||||
|
const value = useMemo<FormFillContextValue>(
|
||||||
|
() => ({
|
||||||
|
state,
|
||||||
|
fetchFields,
|
||||||
|
setValue,
|
||||||
|
setActiveField,
|
||||||
|
submitForm,
|
||||||
|
getField,
|
||||||
|
getFieldsForPage,
|
||||||
|
getValue,
|
||||||
|
validateForm,
|
||||||
|
reset,
|
||||||
|
fieldsByPage,
|
||||||
|
activeProviderName: providerRef.current.name,
|
||||||
|
setProviderMode,
|
||||||
|
forFileId,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
state,
|
||||||
|
fetchFields,
|
||||||
|
setValue,
|
||||||
|
setActiveField,
|
||||||
|
submitForm,
|
||||||
|
getField,
|
||||||
|
getFieldsForPage,
|
||||||
|
getValue,
|
||||||
|
validateForm,
|
||||||
|
reset,
|
||||||
|
fieldsByPage,
|
||||||
|
providerMode,
|
||||||
|
setProviderMode,
|
||||||
|
forFileId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormValuesStoreContext.Provider value={valuesStore}>
|
||||||
|
<FormFillContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</FormFillContext.Provider>
|
||||||
|
</FormValuesStoreContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FormFillContext;
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
/**
|
||||||
|
* FormSaveBar — A notification banner for form-filled PDFs.
|
||||||
|
*
|
||||||
|
* Appears at the top-right of the PDF viewer when the current PDF has
|
||||||
|
* fillable form fields. Provides options to apply changes or download
|
||||||
|
* the filled PDF.
|
||||||
|
*
|
||||||
|
* This component is used in normal viewer mode (pdf-lib provider) where
|
||||||
|
* the dedicated FormFill tool panel is NOT active. It provides a clean
|
||||||
|
* save UX that users expect from browser PDF viewers.
|
||||||
|
*/
|
||||||
|
import React, { useCallback, useState } from 'react';
|
||||||
|
import { Stack, Group, Text, Button, Transition, CloseButton, Paper, Badge } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import DownloadIcon from '@mui/icons-material/Download';
|
||||||
|
import SaveIcon from '@mui/icons-material/Save';
|
||||||
|
import EditNoteIcon from '@mui/icons-material/EditNote';
|
||||||
|
import { useFormFill } from '@proprietary/tools/formFill/FormFillContext';
|
||||||
|
|
||||||
|
interface FormSaveBarProps {
|
||||||
|
/** The current file being viewed */
|
||||||
|
file: File | Blob | null;
|
||||||
|
/** Whether the formFill tool is active (bar is hidden when tool panel is showing) */
|
||||||
|
isFormFillToolActive: boolean;
|
||||||
|
/** Callback when form changes are applied (should reload PDF with filled values) */
|
||||||
|
onApply?: (filledBlob: Blob) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormSaveBar({ file, isFormFillToolActive, onApply }: FormSaveBarProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { state, submitForm } = useFormFill();
|
||||||
|
const { fields, isDirty, loading } = state;
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [applying, setApplying] = useState(false);
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
|
||||||
|
// Reset dismissed state when file changes
|
||||||
|
const [prevFile, setPrevFile] = useState<File | Blob | null>(null);
|
||||||
|
if (file !== prevFile) {
|
||||||
|
setPrevFile(file);
|
||||||
|
setDismissed(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApply = useCallback(async () => {
|
||||||
|
if (!file || applying || saving) return;
|
||||||
|
setApplying(true);
|
||||||
|
try {
|
||||||
|
// Generate the filled PDF
|
||||||
|
const filledBlob = await submitForm(file, false);
|
||||||
|
|
||||||
|
// Call the onApply callback to reload the PDF in the viewer
|
||||||
|
if (onApply) {
|
||||||
|
await onApply(filledBlob);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[FormSaveBar] Apply failed:', err);
|
||||||
|
} finally {
|
||||||
|
setApplying(false);
|
||||||
|
}
|
||||||
|
}, [file, applying, saving, submitForm, onApply]);
|
||||||
|
|
||||||
|
const handleDownload = useCallback(async () => {
|
||||||
|
if (!file || saving || applying) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const blob = await submitForm(file, false);
|
||||||
|
// Trigger browser download
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = file instanceof File ? file.name : 'filled-form.pdf';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[FormSaveBar] Download failed:', err);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [file, saving, applying, submitForm]);
|
||||||
|
|
||||||
|
// Don't show when:
|
||||||
|
// - formFill tool is active (it has its own save panel)
|
||||||
|
// - no form fields found
|
||||||
|
// - still loading
|
||||||
|
// - user dismissed the bar
|
||||||
|
const hasFields = fields.length > 0;
|
||||||
|
const visible = !isFormFillToolActive && hasFields && !loading && !dismissed;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Transition mounted={visible} transition="slide-down" duration={300}>
|
||||||
|
{(styles) => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...styles,
|
||||||
|
position: 'absolute',
|
||||||
|
top: '1rem',
|
||||||
|
right: '1rem',
|
||||||
|
zIndex: 100,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
shadow="lg"
|
||||||
|
radius="md"
|
||||||
|
withBorder
|
||||||
|
style={{
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
minWidth: '320px',
|
||||||
|
maxWidth: '420px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack gap="xs" p="md">
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<EditNoteIcon
|
||||||
|
sx={{
|
||||||
|
fontSize: 24,
|
||||||
|
color: isDirty ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-gray-6)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{t('viewer.formBar.title', 'Form Fields')}
|
||||||
|
</Text>
|
||||||
|
{isDirty && (
|
||||||
|
<Badge size="xs" color="blue" variant="light">
|
||||||
|
{t('viewer.formBar.unsavedBadge', 'Unsaved')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" c="dimmed" mt={2}>
|
||||||
|
{isDirty
|
||||||
|
? t('viewer.formBar.unsavedDesc', 'You have unsaved changes')
|
||||||
|
: t('viewer.formBar.hasFieldsDesc', 'This PDF contains fillable fields')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<CloseButton
|
||||||
|
size="sm"
|
||||||
|
variant="subtle"
|
||||||
|
onClick={() => setDismissed(true)}
|
||||||
|
aria-label={t('viewer.formBar.dismiss', 'Dismiss')}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{isDirty && (
|
||||||
|
<Group gap="xs" mt="xs">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
color="blue"
|
||||||
|
leftSection={<SaveIcon sx={{ fontSize: 18 }} />}
|
||||||
|
loading={applying}
|
||||||
|
disabled={saving}
|
||||||
|
onClick={handleApply}
|
||||||
|
flex={1}
|
||||||
|
>
|
||||||
|
{t('viewer.formBar.apply', 'Apply Changes')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="filled"
|
||||||
|
color="blue"
|
||||||
|
leftSection={<DownloadIcon sx={{ fontSize: 18 }} />}
|
||||||
|
loading={saving}
|
||||||
|
disabled={applying}
|
||||||
|
onClick={handleDownload}
|
||||||
|
flex={1}
|
||||||
|
>
|
||||||
|
{t('viewer.formBar.download', 'Download PDF')}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FormSaveBar;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Shared field type metadata: icons and color mappings.
|
||||||
|
* Used by FormFill, FormFieldSidebar, and any future form tools.
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import type { FormFieldType } from '@proprietary/tools/formFill/types';
|
||||||
|
import TextFieldsIcon from '@mui/icons-material/TextFields';
|
||||||
|
import CheckBoxIcon from '@mui/icons-material/CheckBox';
|
||||||
|
import ArrowDropDownCircleIcon from '@mui/icons-material/ArrowDropDownCircle';
|
||||||
|
import RadioButtonCheckedIcon from '@mui/icons-material/RadioButtonChecked';
|
||||||
|
import ListIcon from '@mui/icons-material/List';
|
||||||
|
import DrawIcon from '@mui/icons-material/Draw';
|
||||||
|
|
||||||
|
export const FIELD_TYPE_ICON: Record<FormFieldType, React.ReactNode> = {
|
||||||
|
text: <TextFieldsIcon sx={{ fontSize: 'inherit' }} />,
|
||||||
|
checkbox: <CheckBoxIcon sx={{ fontSize: 'inherit' }} />,
|
||||||
|
combobox: <ArrowDropDownCircleIcon sx={{ fontSize: 'inherit' }} />,
|
||||||
|
listbox: <ListIcon sx={{ fontSize: 'inherit' }} />,
|
||||||
|
radio: <RadioButtonCheckedIcon sx={{ fontSize: 'inherit' }} />,
|
||||||
|
button: <DrawIcon sx={{ fontSize: 'inherit' }} />,
|
||||||
|
signature: <DrawIcon sx={{ fontSize: 'inherit' }} />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FIELD_TYPE_COLOR: Record<FormFieldType, string> = {
|
||||||
|
text: 'blue',
|
||||||
|
checkbox: 'green',
|
||||||
|
combobox: 'violet',
|
||||||
|
listbox: 'cyan',
|
||||||
|
radio: 'orange',
|
||||||
|
button: 'gray',
|
||||||
|
signature: 'pink',
|
||||||
|
};
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* API service for form-related backend calls.
|
||||||
|
*/
|
||||||
|
import apiClient from '@app/services/apiClient';
|
||||||
|
import type { FormField } from '@proprietary/tools/formFill/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch form fields with coordinates from the backend.
|
||||||
|
* Calls POST /api/v1/form/fields-with-coordinates
|
||||||
|
*/
|
||||||
|
export async function fetchFormFieldsWithCoordinates(
|
||||||
|
file: File | Blob
|
||||||
|
): Promise<FormField[]> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const response = await apiClient.post<FormField[]>(
|
||||||
|
'/api/v1/form/fields-with-coordinates',
|
||||||
|
formData,
|
||||||
|
{
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill form fields and get back a filled PDF blob.
|
||||||
|
* Calls POST /api/v1/form/fill
|
||||||
|
*/
|
||||||
|
export async function fillFormFields(
|
||||||
|
file: File | Blob,
|
||||||
|
values: Record<string, string>,
|
||||||
|
flatten: boolean = false
|
||||||
|
): Promise<Blob> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append(
|
||||||
|
'data',
|
||||||
|
new Blob([JSON.stringify(values)], { type: 'application/json' })
|
||||||
|
);
|
||||||
|
formData.append('flatten', String(flatten));
|
||||||
|
|
||||||
|
const response = await apiClient.post('/api/v1/form/fill', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export { FormFillProvider, useFormFill, useFieldValue, useAllFormValues } from '@proprietary/tools/formFill/FormFillContext';
|
||||||
|
export { FormFieldSidebar } from '@proprietary/tools/formFill/FormFieldSidebar';
|
||||||
|
export { FormFieldOverlay } from '@proprietary/tools/formFill/FormFieldOverlay';
|
||||||
|
export { FormSaveBar } from '@proprietary/tools/formFill/FormSaveBar';
|
||||||
|
export { default as FormFill } from '@proprietary/tools/formFill/FormFill';
|
||||||
|
export { FieldInput } from '@proprietary/tools/formFill/FieldInput';
|
||||||
|
export { FIELD_TYPE_ICON, FIELD_TYPE_COLOR } from '@proprietary/tools/formFill/fieldMeta';
|
||||||
|
export type { FormField, FormFieldType, FormFillState, WidgetCoordinates } from '@proprietary/tools/formFill/types';
|
||||||
|
export type { IFormDataProvider } from '@proprietary/tools/formFill/providers/types';
|
||||||
|
export { PdfLibFormProvider } from '@proprietary/tools/formFill/providers/PdfLibFormProvider';
|
||||||
|
export { PdfBoxFormProvider } from '@proprietary/tools/formFill/providers/PdfBoxFormProvider';
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* PdfBoxFormProvider — Backend API form data provider using PDFBox.
|
||||||
|
*
|
||||||
|
* Delegates form field extraction and filling to the server-side Java
|
||||||
|
* implementation via REST endpoints. This provides full-fidelity form
|
||||||
|
* handling including complex field types, appearance generation, and
|
||||||
|
* proper CJK font support.
|
||||||
|
*
|
||||||
|
* Used in the dedicated formFill tool mode.
|
||||||
|
*/
|
||||||
|
import type { FormField } from '@proprietary/tools/formFill/types';
|
||||||
|
import type { IFormDataProvider } from '@proprietary/tools/formFill/providers/types';
|
||||||
|
import {
|
||||||
|
fetchFormFieldsWithCoordinates,
|
||||||
|
fillFormFields,
|
||||||
|
} from '@proprietary/tools/formFill/formApi';
|
||||||
|
|
||||||
|
export class PdfBoxFormProvider implements IFormDataProvider {
|
||||||
|
readonly name = 'pdfbox';
|
||||||
|
|
||||||
|
async fetchFields(file: File | Blob): Promise<FormField[]> {
|
||||||
|
return fetchFormFieldsWithCoordinates(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fillForm(
|
||||||
|
file: File | Blob,
|
||||||
|
values: Record<string, string>,
|
||||||
|
flatten: boolean,
|
||||||
|
): Promise<Blob> {
|
||||||
|
return fillFormFields(file, values, flatten);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,588 @@
|
|||||||
|
/**
|
||||||
|
* PdfLibFormProvider — Frontend-only form data provider using pdf-lib.
|
||||||
|
*
|
||||||
|
* Extracts form fields directly from the PDF byte stream and fills them
|
||||||
|
* without any backend calls. This avoids sending large PDFs (potentially
|
||||||
|
* hundreds of MB) to the server for a feature that can be done entirely
|
||||||
|
* on the client.
|
||||||
|
*
|
||||||
|
* Used in normal viewer mode when the user views a PDF with form fields.
|
||||||
|
*
|
||||||
|
* Coordinate system:
|
||||||
|
* pdf-lib provides widget rectangles in PDF user space (lower-left origin).
|
||||||
|
* We transform them to CSS space (top-left origin) matching what the backend
|
||||||
|
* FormUtils.createWidgetCoordinates() does, so the same overlay code works
|
||||||
|
* for both providers.
|
||||||
|
*/
|
||||||
|
import { PDFDocument, PDFForm, PDFField, PDFTextField, PDFCheckBox,
|
||||||
|
PDFDropdown, PDFRadioGroup, PDFOptionList, PDFButton, PDFSignature,
|
||||||
|
PDFName, PDFDict, PDFArray, PDFNumber, PDFRef, PDFPage } from 'pdf-lib';
|
||||||
|
import type { FormField, FormFieldType, WidgetCoordinates } from '@proprietary/tools/formFill/types';
|
||||||
|
import type { IFormDataProvider } from '@proprietary/tools/formFill/providers/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a File/Blob as ArrayBuffer.
|
||||||
|
*/
|
||||||
|
async function readAsArrayBuffer(file: File | Blob): Promise<ArrayBuffer> {
|
||||||
|
return file.arrayBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the page index for a widget annotation by finding which page contains it.
|
||||||
|
*/
|
||||||
|
function getWidgetPageIndex(
|
||||||
|
widget: PDFDict,
|
||||||
|
pages: PDFPage[],
|
||||||
|
): number {
|
||||||
|
// Check /P entry first (direct page reference)
|
||||||
|
const pRef = widget.get(PDFName.of('P'));
|
||||||
|
if (pRef instanceof PDFRef) {
|
||||||
|
for (let i = 0; i < pages.length; i++) {
|
||||||
|
if (pages[i].ref === pRef) return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to scanning each page's /Annots array
|
||||||
|
const widgetRef = findWidgetRef(widget, pages);
|
||||||
|
if (widgetRef !== undefined) return widgetRef;
|
||||||
|
|
||||||
|
return 0; // default to first page
|
||||||
|
}
|
||||||
|
|
||||||
|
function findWidgetRef(widget: PDFDict, pages: PDFPage[]): number | undefined {
|
||||||
|
for (let i = 0; i < pages.length; i++) {
|
||||||
|
const annots = pages[i].node.lookup(PDFName.of('Annots'));
|
||||||
|
if (annots instanceof PDFArray) {
|
||||||
|
for (let j = 0; j < annots.size(); j++) {
|
||||||
|
const annotRef = annots.get(j);
|
||||||
|
const annotDict = annots.lookup(j);
|
||||||
|
if (annotDict === widget || annotRef === (widget as any).ref) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the page rotation in degrees (0, 90, 180, 270).
|
||||||
|
*/
|
||||||
|
function getPageRotation(page: PDFPage): number {
|
||||||
|
const rot = page.getRotation();
|
||||||
|
return rot?.angle ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract widget rectangles from a PDFField, transforming from PDF space
|
||||||
|
* (lower-left origin) to CSS space (top-left origin).
|
||||||
|
*
|
||||||
|
* Widget /Rect coordinates are always in un-rotated PDF user space
|
||||||
|
* (defined by the MediaBox/CropBox). We only need a y-flip to convert
|
||||||
|
* from PDF's lower-left origin to CSS's upper-left origin.
|
||||||
|
*
|
||||||
|
* The embedpdf viewer wraps all page content (including this overlay)
|
||||||
|
* inside a <Rotate> CSS component that handles visual rotation.
|
||||||
|
* Therefore we must NOT apply any rotation here — doing so would
|
||||||
|
* double-rotate the widgets.
|
||||||
|
*/
|
||||||
|
function extractWidgets(
|
||||||
|
field: PDFField,
|
||||||
|
pages: PDFPage[],
|
||||||
|
_doc: PDFDocument,
|
||||||
|
): WidgetCoordinates[] {
|
||||||
|
const widgets: WidgetCoordinates[] = [];
|
||||||
|
// Access the underlying PDFDict from the acro field
|
||||||
|
const acroFieldDict = (field.acroField as any).dict as PDFDict;
|
||||||
|
|
||||||
|
// Get all widget annotations for this field
|
||||||
|
const widgetDicts = getFieldWidgets(acroFieldDict);
|
||||||
|
|
||||||
|
for (const wDict of widgetDicts) {
|
||||||
|
const rect = wDict.lookup(PDFName.of('Rect'));
|
||||||
|
if (!(rect instanceof PDFArray) || rect.size() < 4) continue;
|
||||||
|
|
||||||
|
const x1 = numberVal(rect.lookup(0));
|
||||||
|
const y1 = numberVal(rect.lookup(1));
|
||||||
|
const x2 = numberVal(rect.lookup(2));
|
||||||
|
const y2 = numberVal(rect.lookup(3));
|
||||||
|
|
||||||
|
const pageIndex = getWidgetPageIndex(wDict, pages);
|
||||||
|
const page = pages[pageIndex];
|
||||||
|
if (!page) continue;
|
||||||
|
|
||||||
|
// Get CropBox dimensions (un-rotated) for coordinate transformation
|
||||||
|
const cropBox = getCropBox(page);
|
||||||
|
const cropHeight = cropBox.height;
|
||||||
|
const cropX = cropBox.x;
|
||||||
|
const cropY = cropBox.y;
|
||||||
|
|
||||||
|
// Widget rect in PDF space (lower-left origin, un-rotated)
|
||||||
|
const pdfX = Math.min(x1, x2);
|
||||||
|
const pdfY = Math.min(y1, y2);
|
||||||
|
const pdfW = Math.abs(x2 - x1);
|
||||||
|
const pdfH = Math.abs(y2 - y1);
|
||||||
|
|
||||||
|
// Adjust relative to CropBox origin
|
||||||
|
const relativeX = pdfX - cropX;
|
||||||
|
const relativeY = pdfY - cropY;
|
||||||
|
|
||||||
|
// Convert from PDF lower-left origin to CSS upper-left origin (y-flip).
|
||||||
|
// No rotation transform here — the <Rotate> CSS component in the viewer
|
||||||
|
// handles page rotation for all overlays including form fields.
|
||||||
|
const finalX = relativeX;
|
||||||
|
const finalY = cropHeight - relativeY - pdfH;
|
||||||
|
const finalW = pdfW;
|
||||||
|
const finalH = pdfH;
|
||||||
|
|
||||||
|
// Extract export value for checkboxes/radios
|
||||||
|
let exportValue: string | undefined;
|
||||||
|
const ap = wDict.lookup(PDFName.of('AP'));
|
||||||
|
if (ap instanceof PDFDict) {
|
||||||
|
const normal = ap.lookup(PDFName.of('N'));
|
||||||
|
if (normal instanceof PDFDict) {
|
||||||
|
// The keys of /N (other than /Off) are the export values
|
||||||
|
const keys = normal.entries()
|
||||||
|
.map(([k]) => k.decodeText())
|
||||||
|
.filter(k => k !== 'Off');
|
||||||
|
if (keys.length > 0) exportValue = keys[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Also check /AS for current appearance state
|
||||||
|
if (!exportValue) {
|
||||||
|
const asEntry = wDict.lookup(PDFName.of('AS'));
|
||||||
|
if (asEntry instanceof PDFName) {
|
||||||
|
const asVal = asEntry.decodeText();
|
||||||
|
if (asVal !== 'Off') exportValue = asVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract font size from default appearance string
|
||||||
|
let fontSize: number | undefined;
|
||||||
|
const da = wDict.lookup(PDFName.of('DA'));
|
||||||
|
if (da) {
|
||||||
|
const daStr = da.toString();
|
||||||
|
const tfMatch = daStr.match(/(\d+(?:\.\d+)?)\s+Tf/);
|
||||||
|
if (tfMatch) {
|
||||||
|
fontSize = parseFloat(tfMatch[1]);
|
||||||
|
if (fontSize === 0) fontSize = undefined; // 0 means auto-size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
widgets.push({
|
||||||
|
pageIndex,
|
||||||
|
x: finalX,
|
||||||
|
y: finalY,
|
||||||
|
width: finalW,
|
||||||
|
height: finalH,
|
||||||
|
exportValue,
|
||||||
|
fontSize,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return widgets;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberVal(obj: any): number {
|
||||||
|
if (obj instanceof PDFNumber) return obj.asNumber();
|
||||||
|
if (typeof obj === 'number') return obj;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the CropBox (or MediaBox fallback) dimensions in un-rotated PDF space.
|
||||||
|
* These are the raw dictionary values without any rotation adjustment.
|
||||||
|
*/
|
||||||
|
function getCropBox(page: PDFPage): { x: number; y: number; width: number; height: number } {
|
||||||
|
// Check direct CropBox entry
|
||||||
|
const cropBox = page.node.lookup(PDFName.of('CropBox'));
|
||||||
|
if (cropBox instanceof PDFArray && cropBox.size() >= 4) {
|
||||||
|
return {
|
||||||
|
x: numberVal(cropBox.lookup(0)),
|
||||||
|
y: numberVal(cropBox.lookup(1)),
|
||||||
|
width: numberVal(cropBox.lookup(2)) - numberVal(cropBox.lookup(0)),
|
||||||
|
height: numberVal(cropBox.lookup(3)) - numberVal(cropBox.lookup(1)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Check direct MediaBox entry
|
||||||
|
const mediaBox = page.node.lookup(PDFName.of('MediaBox'));
|
||||||
|
if (mediaBox instanceof PDFArray && mediaBox.size() >= 4) {
|
||||||
|
return {
|
||||||
|
x: numberVal(mediaBox.lookup(0)),
|
||||||
|
y: numberVal(mediaBox.lookup(1)),
|
||||||
|
width: numberVal(mediaBox.lookup(2)) - numberVal(mediaBox.lookup(0)),
|
||||||
|
height: numberVal(mediaBox.lookup(3)) - numberVal(mediaBox.lookup(1)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Traverse parent page-tree nodes for inherited MediaBox
|
||||||
|
let node: any = page.node;
|
||||||
|
while (node) {
|
||||||
|
const parentNode = node.lookup(PDFName.of('Parent'));
|
||||||
|
if (parentNode instanceof PDFDict) {
|
||||||
|
const inheritedBox = parentNode.lookup(PDFName.of('MediaBox'));
|
||||||
|
if (inheritedBox instanceof PDFArray && inheritedBox.size() >= 4) {
|
||||||
|
return {
|
||||||
|
x: numberVal(inheritedBox.lookup(0)),
|
||||||
|
y: numberVal(inheritedBox.lookup(1)),
|
||||||
|
width: numberVal(inheritedBox.lookup(2)) - numberVal(inheritedBox.lookup(0)),
|
||||||
|
height: numberVal(inheritedBox.lookup(3)) - numberVal(inheritedBox.lookup(1)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
node = parentNode;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Last resort: use page.getSize() but un-rotate the dimensions
|
||||||
|
const { width, height } = page.getSize();
|
||||||
|
const rotation = getPageRotation(page);
|
||||||
|
if (rotation === 90 || rotation === 270) {
|
||||||
|
return { x: 0, y: 0, width: height, height: width };
|
||||||
|
}
|
||||||
|
return { x: 0, y: 0, width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the widget annotation dictionaries for a field.
|
||||||
|
* A field can either BE a widget (merged) or have child /Kids that are widgets.
|
||||||
|
*/
|
||||||
|
function getFieldWidgets(acroField: PDFDict): PDFDict[] {
|
||||||
|
const kids = acroField.lookup(PDFName.of('Kids'));
|
||||||
|
if (kids instanceof PDFArray) {
|
||||||
|
const result: PDFDict[] = [];
|
||||||
|
for (let i = 0; i < kids.size(); i++) {
|
||||||
|
const kid = kids.lookup(i);
|
||||||
|
if (kid instanceof PDFDict) {
|
||||||
|
// Check if this kid is a widget (has /Rect) vs another field node
|
||||||
|
const subtype = kid.lookup(PDFName.of('Subtype'));
|
||||||
|
if (subtype instanceof PDFName && subtype.decodeText() === 'Widget') {
|
||||||
|
result.push(kid);
|
||||||
|
} else if (kid.lookup(PDFName.of('Rect'))) {
|
||||||
|
// Merged field/widget — has Rect but maybe no explicit Subtype
|
||||||
|
result.push(kid);
|
||||||
|
} else {
|
||||||
|
// Intermediate field node — recurse
|
||||||
|
result.push(...getFieldWidgets(kid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No Kids — the field dict itself is the widget (merged field/widget)
|
||||||
|
if (acroField.lookup(PDFName.of('Rect'))) {
|
||||||
|
return [acroField];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the FormFieldType from a pdf-lib PDFField.
|
||||||
|
*/
|
||||||
|
function getFieldType(field: PDFField): FormFieldType {
|
||||||
|
if (field instanceof PDFTextField) return 'text';
|
||||||
|
if (field instanceof PDFCheckBox) return 'checkbox';
|
||||||
|
if (field instanceof PDFDropdown) return 'combobox';
|
||||||
|
if (field instanceof PDFRadioGroup) return 'radio';
|
||||||
|
if (field instanceof PDFOptionList) return 'listbox';
|
||||||
|
if (field instanceof PDFButton) return 'button';
|
||||||
|
if (field instanceof PDFSignature) return 'signature';
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current value of a field as a string.
|
||||||
|
*/
|
||||||
|
function getFieldValue(field: PDFField): string {
|
||||||
|
try {
|
||||||
|
if (field instanceof PDFTextField) {
|
||||||
|
return field.getText() ?? '';
|
||||||
|
}
|
||||||
|
if (field instanceof PDFCheckBox) {
|
||||||
|
return field.isChecked() ? 'Yes' : 'Off';
|
||||||
|
}
|
||||||
|
if (field instanceof PDFDropdown) {
|
||||||
|
const selected = field.getSelected();
|
||||||
|
return selected.length > 0 ? selected[0] : '';
|
||||||
|
}
|
||||||
|
if (field instanceof PDFRadioGroup) {
|
||||||
|
return getRadioValue(field);
|
||||||
|
}
|
||||||
|
if (field instanceof PDFOptionList) {
|
||||||
|
const selected = field.getSelected();
|
||||||
|
return selected.join(',');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Some fields may throw on getValue if malformed
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRadioValue(field: PDFRadioGroup): string {
|
||||||
|
const selected = field.getSelected() ?? '';
|
||||||
|
if (!selected || selected === 'Off') return selected;
|
||||||
|
|
||||||
|
const options = field.getOptions();
|
||||||
|
|
||||||
|
if (options.includes(selected)) return selected;
|
||||||
|
|
||||||
|
const mappedOption = mapAppearanceStateToOption(field, selected, options);
|
||||||
|
if (mappedOption) return mappedOption;
|
||||||
|
|
||||||
|
const index = parseInt(selected, 10);
|
||||||
|
if (!isNaN(index) && index >= 0 && index < options.length) {
|
||||||
|
return options[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapAppearanceStateToOption(
|
||||||
|
field: PDFRadioGroup,
|
||||||
|
stateName: string,
|
||||||
|
options: string[],
|
||||||
|
): string | undefined {
|
||||||
|
try {
|
||||||
|
const acroFieldDict = (field.acroField as any).dict as PDFDict;
|
||||||
|
const widgets = getFieldWidgets(acroFieldDict);
|
||||||
|
|
||||||
|
for (let i = 0; i < widgets.length; i++) {
|
||||||
|
const ap = widgets[i].lookup(PDFName.of('AP'));
|
||||||
|
if (!(ap instanceof PDFDict)) continue;
|
||||||
|
|
||||||
|
const normal = ap.lookup(PDFName.of('N'));
|
||||||
|
if (!(normal instanceof PDFDict)) continue;
|
||||||
|
|
||||||
|
const keys = normal.entries().map(([k]) => k.decodeText());
|
||||||
|
if (keys.includes(stateName) && i < options.length) {
|
||||||
|
return options[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fallback handled by caller
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRadioValueForSelect(
|
||||||
|
field: PDFRadioGroup,
|
||||||
|
value: string,
|
||||||
|
): string | null {
|
||||||
|
const options = field.getOptions();
|
||||||
|
|
||||||
|
if (options.includes(value)) return value;
|
||||||
|
|
||||||
|
const mappedOption = mapAppearanceStateToOption(field, value, options);
|
||||||
|
if (mappedOption) return mappedOption;
|
||||||
|
|
||||||
|
const index = parseInt(value, 10);
|
||||||
|
if (!isNaN(index) && index >= 0 && index < options.length) {
|
||||||
|
return options[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
const lower = value.toLowerCase();
|
||||||
|
const match = options.find(o => o.toLowerCase() === lower);
|
||||||
|
if (match) return match;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get field options (for dropdowns, listboxes, radios).
|
||||||
|
*/
|
||||||
|
function getFieldOptions(field: PDFField): string[] | null {
|
||||||
|
try {
|
||||||
|
if (field instanceof PDFDropdown) {
|
||||||
|
return field.getOptions();
|
||||||
|
}
|
||||||
|
if (field instanceof PDFOptionList) {
|
||||||
|
return field.getOptions();
|
||||||
|
}
|
||||||
|
if (field instanceof PDFRadioGroup) {
|
||||||
|
return field.getOptions();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a field is read-only.
|
||||||
|
*/
|
||||||
|
function isFieldReadOnly(field: PDFField): boolean {
|
||||||
|
try {
|
||||||
|
return field.isReadOnly();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a field is required.
|
||||||
|
*/
|
||||||
|
function isFieldRequired(field: PDFField): boolean {
|
||||||
|
try {
|
||||||
|
return field.isRequired();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get field tooltip (TU entry).
|
||||||
|
*/
|
||||||
|
function getFieldTooltip(acroField: PDFDict): string | null {
|
||||||
|
const tu = acroField.lookup(PDFName.of('TU'));
|
||||||
|
if (tu) {
|
||||||
|
try {
|
||||||
|
return tu.toString().replace(/^\(|\)$/g, '');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a text field is multiline (flag bit 13 set in /Ff).
|
||||||
|
*/
|
||||||
|
function isMultiline(field: PDFField): boolean {
|
||||||
|
if (!(field instanceof PDFTextField)) return false;
|
||||||
|
try {
|
||||||
|
return field.isMultiline();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the label for a field — use the partial name or the full qualified name.
|
||||||
|
*/
|
||||||
|
function getFieldLabel(field: PDFField): string {
|
||||||
|
const name = field.getName();
|
||||||
|
// Use the last segment of the qualified name as the label
|
||||||
|
const parts = name.split('.');
|
||||||
|
return parts[parts.length - 1] || name;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PdfLibFormProvider implements IFormDataProvider {
|
||||||
|
readonly name = 'pdf-lib';
|
||||||
|
|
||||||
|
async fetchFields(file: File | Blob): Promise<FormField[]> {
|
||||||
|
const arrayBuffer = await readAsArrayBuffer(file);
|
||||||
|
const doc = await PDFDocument.load(arrayBuffer, {
|
||||||
|
ignoreEncryption: true,
|
||||||
|
updateMetadata: false,
|
||||||
|
throwOnInvalidObject: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
let form: PDFForm;
|
||||||
|
try {
|
||||||
|
form = doc.getForm();
|
||||||
|
} catch {
|
||||||
|
// No AcroForm — return empty
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = form.getFields();
|
||||||
|
if (fields.length === 0) return [];
|
||||||
|
|
||||||
|
const pages = doc.getPages();
|
||||||
|
const result: FormField[] = [];
|
||||||
|
|
||||||
|
for (const field of fields) {
|
||||||
|
const type = getFieldType(field);
|
||||||
|
const widgets = extractWidgets(field, pages, doc);
|
||||||
|
|
||||||
|
// Skip fields with no visible widgets
|
||||||
|
if (widgets.length === 0) continue;
|
||||||
|
|
||||||
|
const formField: FormField = {
|
||||||
|
name: field.getName(),
|
||||||
|
label: getFieldLabel(field),
|
||||||
|
type,
|
||||||
|
value: getFieldValue(field),
|
||||||
|
options: getFieldOptions(field),
|
||||||
|
displayOptions: null, // pdf-lib doesn't expose display vs export values separately
|
||||||
|
required: isFieldRequired(field),
|
||||||
|
readOnly: isFieldReadOnly(field),
|
||||||
|
multiSelect: field instanceof PDFOptionList,
|
||||||
|
multiline: isMultiline(field),
|
||||||
|
tooltip: getFieldTooltip((field.acroField as any).dict as PDFDict),
|
||||||
|
widgets,
|
||||||
|
};
|
||||||
|
|
||||||
|
result.push(formField);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fillForm(
|
||||||
|
file: File | Blob,
|
||||||
|
values: Record<string, string>,
|
||||||
|
flatten: boolean,
|
||||||
|
): Promise<Blob> {
|
||||||
|
const arrayBuffer = await readAsArrayBuffer(file);
|
||||||
|
const doc = await PDFDocument.load(arrayBuffer, {
|
||||||
|
ignoreEncryption: true,
|
||||||
|
throwOnInvalidObject: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = doc.getForm();
|
||||||
|
const fields = form.getFields();
|
||||||
|
|
||||||
|
for (const field of fields) {
|
||||||
|
const fieldName = field.getName();
|
||||||
|
if (!(fieldName in values)) continue;
|
||||||
|
|
||||||
|
const value = values[fieldName];
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (field instanceof PDFTextField) {
|
||||||
|
field.setText(value || undefined);
|
||||||
|
} else if (field instanceof PDFCheckBox) {
|
||||||
|
if (value && value !== 'Off') {
|
||||||
|
field.check();
|
||||||
|
} else {
|
||||||
|
field.uncheck();
|
||||||
|
}
|
||||||
|
} else if (field instanceof PDFDropdown) {
|
||||||
|
if (value) {
|
||||||
|
field.select(value);
|
||||||
|
} else {
|
||||||
|
field.clear();
|
||||||
|
}
|
||||||
|
} else if (field instanceof PDFRadioGroup) {
|
||||||
|
if (value && value !== 'Off') {
|
||||||
|
const resolved = resolveRadioValueForSelect(field, value);
|
||||||
|
if (resolved) {
|
||||||
|
field.select(resolved);
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
`[PdfLibFormProvider] Radio value "${value}" could not be mapped to options [${field.getOptions().join(', ')}] for field "${fieldName}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (field instanceof PDFOptionList) {
|
||||||
|
if (value) {
|
||||||
|
const vals = value.split(',').filter(Boolean);
|
||||||
|
field.select(vals[0]); // PDFOptionList.select takes single value
|
||||||
|
} else {
|
||||||
|
field.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[PdfLibFormProvider] Failed to set value for field "${fieldName}":`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flatten) {
|
||||||
|
form.flatten();
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdfBytes = await doc.save();
|
||||||
|
return new Blob([pdfBytes.slice().buffer as ArrayBuffer], { type: 'application/pdf' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export type { IFormDataProvider } from '@proprietary/tools/formFill/providers/types';
|
||||||
|
export { PdfLibFormProvider } from '@proprietary/tools/formFill/providers/PdfLibFormProvider';
|
||||||
|
export { PdfBoxFormProvider } from '@proprietary/tools/formFill/providers/PdfBoxFormProvider';
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* IFormDataProvider — Common interface for form data providers.
|
||||||
|
*
|
||||||
|
* This abstraction allows the form fill UI to work with different backends:
|
||||||
|
* - PdfLibFormProvider: Frontend-only, uses pdf-lib to extract/fill form fields
|
||||||
|
* (used in normal viewer mode to avoid sending large PDFs to the backend)
|
||||||
|
* - PdfBoxFormProvider: Backend API, uses PDFBox via REST endpoints
|
||||||
|
* (used in the dedicated formFill tool for full-fidelity form handling)
|
||||||
|
*
|
||||||
|
* The UI components (FormFieldOverlay, FormFill, FormFieldSidebar) consume
|
||||||
|
* data through FormFillContext, which delegates to whichever provider is active.
|
||||||
|
*/
|
||||||
|
import type { FormField } from '@proprietary/tools/formFill/types';
|
||||||
|
|
||||||
|
export interface IFormDataProvider {
|
||||||
|
/** Unique identifier for the provider (for debugging/logging) */
|
||||||
|
readonly name: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract form fields with their coordinates from a PDF file.
|
||||||
|
* Returns the same FormField[] shape regardless of provider.
|
||||||
|
*/
|
||||||
|
fetchFields(file: File | Blob): Promise<FormField[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply filled values to a PDF and return the resulting PDF blob.
|
||||||
|
* @param file - The original PDF
|
||||||
|
* @param values - Map of field name → value
|
||||||
|
* @param flatten - Whether to flatten the form (make fields non-editable)
|
||||||
|
* @returns The filled PDF as a Blob
|
||||||
|
*/
|
||||||
|
fillForm(
|
||||||
|
file: File | Blob,
|
||||||
|
values: Record<string, string>,
|
||||||
|
flatten: boolean,
|
||||||
|
): Promise<Blob>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Types for the Form Fill PDF Viewer feature.
|
||||||
|
* These mirror the backend FormFieldWithCoordinates model.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface WidgetCoordinates {
|
||||||
|
pageIndex: number;
|
||||||
|
x: number; // PDF points, un-rotated, CSS upper-left origin
|
||||||
|
y: number; // PDF points, un-rotated, CSS upper-left origin
|
||||||
|
width: number; // PDF points
|
||||||
|
height: number; // PDF points
|
||||||
|
/** Export value for this specific widget (radio/checkbox only) */
|
||||||
|
exportValue?: string;
|
||||||
|
/** Font size in PDF points */
|
||||||
|
fontSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormField {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
type: FormFieldType;
|
||||||
|
value: string;
|
||||||
|
/** Export values used for data binding (sent to backend) */
|
||||||
|
options: string[] | null;
|
||||||
|
/** Human-readable display labels parallel to options. Null when same as options. */
|
||||||
|
displayOptions: string[] | null;
|
||||||
|
required: boolean;
|
||||||
|
readOnly: boolean;
|
||||||
|
multiSelect: boolean;
|
||||||
|
multiline: boolean;
|
||||||
|
tooltip: string | null;
|
||||||
|
widgets: WidgetCoordinates[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FormFieldType =
|
||||||
|
| 'text'
|
||||||
|
| 'checkbox'
|
||||||
|
| 'combobox'
|
||||||
|
| 'listbox'
|
||||||
|
| 'radio'
|
||||||
|
| 'button'
|
||||||
|
| 'signature';
|
||||||
|
|
||||||
|
export interface FormFillState {
|
||||||
|
/** Fields fetched from backend with coordinates */
|
||||||
|
fields: FormField[];
|
||||||
|
/** Current user-entered values keyed by field name */
|
||||||
|
values: Record<string, string>;
|
||||||
|
/** Whether a backend fetch is in progress */
|
||||||
|
loading: boolean;
|
||||||
|
/** Error message from fetch */
|
||||||
|
error: string | null;
|
||||||
|
/** Currently focused/selected field name */
|
||||||
|
activeFieldName: string | null;
|
||||||
|
/** Whether the form has been modified */
|
||||||
|
isDirty: boolean;
|
||||||
|
/** Current validation errors keyed by field name */
|
||||||
|
validationErrors: Record<string, string>;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export const PROPRIETARY_REGULAR_TOOL_IDS = [
|
export const PROPRIETARY_REGULAR_TOOL_IDS = [
|
||||||
|
'formFill',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const PROPRIETARY_SUPER_TOOL_IDS = [
|
export const PROPRIETARY_SUPER_TOOL_IDS = [
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
"paths": {
|
"paths": {
|
||||||
"@app/*": [
|
"@app/*": [
|
||||||
"src/core/*"
|
"src/core/*"
|
||||||
]
|
],
|
||||||
|
"@core/*": ["src/core/*"],
|
||||||
|
"@proprietary/*": ["src/core/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"src/proprietary/*",
|
"src/proprietary/*",
|
||||||
"src/core/*"
|
"src/core/*"
|
||||||
],
|
],
|
||||||
"@core/*": ["src/core/*"]
|
"@core/*": ["src/core/*"],
|
||||||
|
"@proprietary/*": ["src/proprietary/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"src/proprietary/*",
|
"src/proprietary/*",
|
||||||
"src/core/*"
|
"src/core/*"
|
||||||
],
|
],
|
||||||
|
"@proprietary/*": ["src/proprietary/*"],
|
||||||
"@core/*": ["src/core/*"]
|
"@core/*": ["src/core/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user