Add comprehensive form field editing capabilities
- Add form field creation overlay with visual drag-to-create - Add form field modification panel for editing existing fields - Add property editor for field attributes (name, value, formatting) - Implement snap-to-grid and alignment utilities - Fix coordinate system mapping between PDF and display - Update FormFillContext for field CRUD operations - Add support for text, checkbox, radio, and combo box fields - Improve field positioning accuracy across save/load cycles Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
30c258ce0b
commit
a4c3beebdf
@@ -94,5 +94,8 @@ public class FormFieldWithCoordinates {
|
||||
|
||||
@Schema(description = "Font size in PDF points")
|
||||
private Float fontSize;
|
||||
|
||||
@Schema(description = "CropBox height in PDF points (used for Y-flip)")
|
||||
private Float cropBoxHeight;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,13 @@ public enum FormFieldTypeSupport {
|
||||
List<String> options)
|
||||
throws IOException {
|
||||
PDTextField textField = (PDTextField) field;
|
||||
if (definition.fontSize() != null && definition.fontSize() > 0) {
|
||||
textField.setDefaultAppearance(
|
||||
"/Helv " + definition.fontSize() + " Tf 0 g");
|
||||
}
|
||||
if (Boolean.TRUE.equals(definition.multiline())) {
|
||||
textField.setMultiline(true);
|
||||
}
|
||||
String defaultValue = Optional.ofNullable(definition.defaultValue()).orElse("");
|
||||
if (!defaultValue.isBlank()) {
|
||||
FormUtils.setTextValue(textField, defaultValue);
|
||||
|
||||
@@ -455,6 +455,7 @@ public class FormUtils {
|
||||
.height(finalH)
|
||||
.exportValue(exportValue)
|
||||
.fontSize(extractFontSize(field))
|
||||
.cropBoxHeight(cropHeight)
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -1551,7 +1552,8 @@ public class FormUtils {
|
||||
|
||||
if (!typeChanging) {
|
||||
try {
|
||||
modifyFieldPropertiesInPlace(originalField, modification, desiredName);
|
||||
modifyFieldPropertiesInPlace(
|
||||
document, originalField, modification, desiredName);
|
||||
log.debug("Successfully modified field '{}' in-place", lookupName);
|
||||
continue; // Skip the remove-and-recreate process
|
||||
} catch (Exception e) {
|
||||
@@ -1578,7 +1580,10 @@ public class FormUtils {
|
||||
modification.multiSelect(),
|
||||
modification.options(),
|
||||
modification.defaultValue(),
|
||||
modification.tooltip());
|
||||
modification.tooltip(),
|
||||
modification.fontSize(),
|
||||
modification.readOnly(),
|
||||
modification.multiline());
|
||||
|
||||
List<String> sanitizedOptions = sanitizeOptions(modification.options());
|
||||
|
||||
@@ -1618,7 +1623,10 @@ public class FormUtils {
|
||||
}
|
||||
|
||||
private void modifyFieldPropertiesInPlace(
|
||||
PDField field, ModifyFormFieldDefinition modification, String newName)
|
||||
PDDocument document,
|
||||
PDField field,
|
||||
ModifyFormFieldDefinition modification,
|
||||
String newName)
|
||||
throws IOException {
|
||||
if (newName != null && !newName.equals(field.getPartialName())) {
|
||||
field.setPartialName(newName);
|
||||
@@ -1668,6 +1676,90 @@ public class FormUtils {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update readOnly flag
|
||||
if (modification.readOnly() != null) {
|
||||
field.setReadOnly(modification.readOnly());
|
||||
}
|
||||
|
||||
// Update multiline flag (text fields only)
|
||||
if (modification.multiline() != null && field instanceof PDTextField tf) {
|
||||
tf.setMultiline(modification.multiline());
|
||||
}
|
||||
|
||||
// Update font size (variable text fields only)
|
||||
boolean fontSizeChanged = false;
|
||||
if (modification.fontSize() != null
|
||||
&& modification.fontSize() > 0
|
||||
&& field instanceof PDVariableText vt) {
|
||||
String da = vt.getDefaultAppearance();
|
||||
if (da != null && !da.isBlank()) {
|
||||
// Replace the size number before "Tf" in the DA string
|
||||
String[] tokens = da.split("\\s+");
|
||||
for (int i = 0; i < tokens.length; i++) {
|
||||
if ("Tf".equals(tokens[i]) && i > 0) {
|
||||
tokens[i - 1] = String.valueOf(modification.fontSize());
|
||||
break;
|
||||
}
|
||||
}
|
||||
vt.setDefaultAppearance(String.join(" ", tokens));
|
||||
} else {
|
||||
vt.setDefaultAppearance("/Helv " + modification.fontSize() + " Tf 0 g");
|
||||
}
|
||||
fontSizeChanged = true;
|
||||
|
||||
// Also clear appearance for font size changes so new font renders correctly
|
||||
List<PDAnnotationWidget> widgets = field.getWidgets();
|
||||
if (widgets != null && !widgets.isEmpty()) {
|
||||
widgets.get(0).getCOSObject().removeItem(COSName.AP);
|
||||
}
|
||||
}
|
||||
|
||||
// Update widget coordinates if any coordinate field is non-null.
|
||||
// The frontend sends CropBox-relative coordinates (matching what createWidgetCoordinates
|
||||
// extracted). We must add back the CropBox offset to get absolute PDF coordinates.
|
||||
if (modification.x() != null
|
||||
|| modification.y() != null
|
||||
|| modification.width() != null
|
||||
|| modification.height() != null) {
|
||||
List<PDAnnotationWidget> widgets = field.getWidgets();
|
||||
if (widgets != null && !widgets.isEmpty()) {
|
||||
PDAnnotationWidget widget = widgets.get(0);
|
||||
PDRectangle rect = widget.getRectangle();
|
||||
if (rect != null) {
|
||||
// Resolve CropBox to reverse the extraction's coordinate transform
|
||||
PDPage page = resolveWidgetPage(document, widget, null);
|
||||
float offX = 0;
|
||||
float offY = 0;
|
||||
if (page != null) {
|
||||
PDRectangle cropBox = page.getCropBox();
|
||||
offX = cropBox.getLowerLeftX();
|
||||
offY = cropBox.getLowerLeftY();
|
||||
}
|
||||
float newX =
|
||||
modification.x() != null
|
||||
? modification.x() + offX
|
||||
: rect.getLowerLeftX();
|
||||
float newY =
|
||||
modification.y() != null
|
||||
? modification.y() + offY
|
||||
: rect.getLowerLeftY();
|
||||
float newW =
|
||||
modification.width() != null ? modification.width() : rect.getWidth();
|
||||
float newH =
|
||||
modification.height() != null
|
||||
? modification.height()
|
||||
: rect.getHeight();
|
||||
widget.setRectangle(new PDRectangle(newX, newY, newW, newH));
|
||||
|
||||
// Remove stale appearance stream so ensureAppearances() generates
|
||||
// a fresh one matching the new rectangle. Without this, PDF viewers
|
||||
// stretch the old appearance (designed for the old BBox) to fit
|
||||
// the new widget rectangle, causing fields to look distorted.
|
||||
widget.getCOSObject().removeItem(COSName.AP);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String fallbackLabelForType(String type, int typeIndex) {
|
||||
@@ -1795,6 +1887,81 @@ public class FormUtils {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add new form fields to a document based on the supplied definitions. If the document does not
|
||||
* already contain an AcroForm, one is created automatically.
|
||||
*/
|
||||
public void addNewFields(PDDocument document, List<NewFormFieldDefinition> definitions)
|
||||
throws IOException {
|
||||
if (document == null || definitions == null || definitions.isEmpty()) return;
|
||||
|
||||
PDAcroForm acroForm = getAcroFormSafely(document);
|
||||
if (acroForm == null) {
|
||||
// Create a new AcroForm for PDFs that don't have one yet
|
||||
acroForm = new PDAcroForm(document);
|
||||
document.getDocumentCatalog().setAcroForm(acroForm);
|
||||
}
|
||||
|
||||
Set<String> existingNames = collectExistingFieldNames(acroForm);
|
||||
int pageCount = document.getNumberOfPages();
|
||||
|
||||
for (NewFormFieldDefinition definition : definitions) {
|
||||
if (definition == null) continue;
|
||||
|
||||
String resolvedType =
|
||||
Optional.ofNullable(definition.type())
|
||||
.map(FormUtils::normalizeFieldType)
|
||||
.orElse(FIELD_TYPE_TEXT);
|
||||
|
||||
FormFieldTypeSupport handler = FormFieldTypeSupport.forTypeName(resolvedType);
|
||||
if (handler == null || handler.doesNotsupportsDefinitionCreation()) {
|
||||
log.warn(
|
||||
"Unsupported or non-creatable field type '{}'; defaulting to text",
|
||||
resolvedType);
|
||||
handler = FormFieldTypeSupport.TEXT;
|
||||
}
|
||||
|
||||
int pageIdx = definition.pageIndex() != null ? definition.pageIndex() : 0;
|
||||
if (pageIdx < 0 || pageIdx >= pageCount) {
|
||||
log.warn(
|
||||
"Page index {} out of range (0-{}); clamping to last page",
|
||||
pageIdx,
|
||||
pageCount - 1);
|
||||
pageIdx = Math.max(0, pageCount - 1);
|
||||
}
|
||||
PDPage page = document.getPage(pageIdx);
|
||||
|
||||
float x = definition.x() != null ? definition.x() : 0f;
|
||||
float y = definition.y() != null ? definition.y() : 0f;
|
||||
float w = definition.width() != null ? definition.width() : 150f;
|
||||
float h = definition.height() != null ? definition.height() : 20f;
|
||||
PDRectangle rectangle = new PDRectangle(x, y, w, h);
|
||||
|
||||
String baseName =
|
||||
Optional.ofNullable(definition.name())
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.orElse("field");
|
||||
String uniqueName = generateUniqueFieldName(baseName, existingNames);
|
||||
existingNames.add(uniqueName);
|
||||
|
||||
List<String> options = sanitizeOptions(definition.options());
|
||||
|
||||
try {
|
||||
createNewField(handler, acroForm, page, rectangle, uniqueName, definition, options);
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Failed to create field '{}' of type '{}': {}",
|
||||
uniqueName,
|
||||
resolvedType,
|
||||
e.getMessage(),
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
ensureAppearances(acroForm);
|
||||
}
|
||||
|
||||
public void deleteFormFields(PDDocument document, List<String> fieldNames) {
|
||||
if (document == null || fieldNames == null || fieldNames.isEmpty()) return;
|
||||
|
||||
@@ -2200,6 +2367,9 @@ public class FormUtils {
|
||||
}
|
||||
}
|
||||
field.setRequired(Boolean.TRUE.equals(definition.required()));
|
||||
if (Boolean.TRUE.equals(definition.readOnly())) {
|
||||
field.setReadOnly(true);
|
||||
}
|
||||
|
||||
PDAnnotationWidget widget =
|
||||
existingWidget != null ? existingWidget : new PDAnnotationWidget();
|
||||
@@ -2285,7 +2455,10 @@ public class FormUtils {
|
||||
Boolean multiSelect,
|
||||
List<String> options,
|
||||
String defaultValue,
|
||||
String tooltip) {}
|
||||
String tooltip,
|
||||
Float fontSize,
|
||||
Boolean readOnly,
|
||||
Boolean multiline) {}
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record ModifyFormFieldDefinition(
|
||||
@@ -2293,11 +2466,19 @@ public class FormUtils {
|
||||
String name,
|
||||
String label,
|
||||
String type,
|
||||
Integer pageIndex,
|
||||
Float x,
|
||||
Float y,
|
||||
Float width,
|
||||
Float height,
|
||||
Boolean required,
|
||||
Boolean multiSelect,
|
||||
List<String> options,
|
||||
String defaultValue,
|
||||
String tooltip) {}
|
||||
String tooltip,
|
||||
Float fontSize,
|
||||
Boolean readOnly,
|
||||
Boolean multiline) {}
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record FormFieldInfo(
|
||||
|
||||
+38
@@ -139,6 +139,44 @@ public class FormFillController {
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping(value = "/add-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Add new form fields",
|
||||
description =
|
||||
"Creates new form fields in the provided PDF and returns the updated file")
|
||||
public ResponseEntity<byte[]> addFields(
|
||||
@Parameter(
|
||||
description = "The input PDF file",
|
||||
required = true,
|
||||
content =
|
||||
@Content(
|
||||
mediaType = MediaType.APPLICATION_PDF_VALUE,
|
||||
schema = @Schema(type = "string", format = "binary")))
|
||||
@RequestParam("file")
|
||||
MultipartFile file,
|
||||
@Parameter(
|
||||
description = "JSON array of new field definitions",
|
||||
example =
|
||||
"[{\"name\":\"NewField\",\"type\":\"text\",\"pageIndex\":0,"
|
||||
+ "\"x\":50,\"y\":700,\"width\":200,\"height\":20}]")
|
||||
@RequestPart(value = "fields", required = false)
|
||||
byte[] fieldsPayload)
|
||||
throws IOException {
|
||||
|
||||
String rawFields = decodePart(fieldsPayload);
|
||||
List<FormUtils.NewFormFieldDefinition> definitions =
|
||||
FormPayloadParser.parseNewFieldDefinitions(objectMapper, rawFields);
|
||||
if (definitions.isEmpty()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.dataRequired",
|
||||
"{0} must contain at least one definition",
|
||||
"fields payload");
|
||||
}
|
||||
|
||||
return processSingleFile(
|
||||
file, "updated", document -> FormUtils.addNewFields(document, definitions));
|
||||
}
|
||||
|
||||
@PostMapping(value = "/modify-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Modify existing form fields",
|
||||
|
||||
+10
@@ -28,6 +28,8 @@ final class FormPayloadParser {
|
||||
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {};
|
||||
private static final TypeReference<List<FormUtils.ModifyFormFieldDefinition>>
|
||||
MODIFY_FIELD_LIST_TYPE = new TypeReference<>() {};
|
||||
private static final TypeReference<List<FormUtils.NewFormFieldDefinition>>
|
||||
NEW_FIELD_LIST_TYPE = new TypeReference<>() {};
|
||||
private static final TypeReference<List<String>> STRING_LIST_TYPE = new TypeReference<>() {};
|
||||
|
||||
private FormPayloadParser() {}
|
||||
@@ -95,6 +97,14 @@ final class FormPayloadParser {
|
||||
return objectMapper.readValue(json, MODIFY_FIELD_LIST_TYPE);
|
||||
}
|
||||
|
||||
static List<FormUtils.NewFormFieldDefinition> parseNewFieldDefinitions(
|
||||
ObjectMapper objectMapper, String json) throws IOException {
|
||||
if (json == null || json.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
return objectMapper.readValue(json, NEW_FIELD_LIST_TYPE);
|
||||
}
|
||||
|
||||
static List<String> parseNameList(ObjectMapper objectMapper, String json) throws IOException {
|
||||
if (json == null || json.isBlank()) {
|
||||
return List.of();
|
||||
|
||||
@@ -3338,6 +3338,36 @@ title = "Automate"
|
||||
desc = "Fill PDF form fields interactively with a visual editor"
|
||||
title = "Fill Form"
|
||||
|
||||
[formFill.createMode]
|
||||
title = "Create Fields"
|
||||
instruction = "Select a field type, then drag on the PDF to place it."
|
||||
addButton = "Add {count} Field(s) to PDF"
|
||||
emptyState = "No pending fields. Select a type above and drag on the PDF to create one."
|
||||
|
||||
[formFill.modifyMode]
|
||||
title = "Modify Fields"
|
||||
instruction = "Click a field below or on the PDF to select it. Drag to move, use handles to resize."
|
||||
saveButton = "Save {count} Change(s)"
|
||||
emptyState = "No form fields found. Use Create mode to add fields first."
|
||||
modifiedBadge = "modified"
|
||||
|
||||
[formFill.propertyEditor]
|
||||
name = "Name"
|
||||
label = "Label"
|
||||
type = "Type"
|
||||
tooltip = "Tooltip"
|
||||
defaultValue = "Default Value"
|
||||
required = "Required"
|
||||
readOnly = "Read-only"
|
||||
multiline = "Multiline"
|
||||
fontSize = "Font Size (pt)"
|
||||
options = "Options"
|
||||
addOption = "Add Option"
|
||||
multiSelect = "Multi-select"
|
||||
position = "Position"
|
||||
positionAndSize = "Position & Size (PDF points)"
|
||||
fieldProperties = "Field Properties"
|
||||
|
||||
[home.autoRename]
|
||||
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"
|
||||
|
||||
@@ -58,6 +58,8 @@ import { DocumentReadyWrapper } from '@app/components/viewer/DocumentReadyWrappe
|
||||
import { ActiveDocumentProvider } from '@app/components/viewer/ActiveDocumentContext';
|
||||
import { absoluteWithBasePath } from '@app/constants/app';
|
||||
import { FormFieldOverlay } from '@app/tools/formFill/FormFieldOverlay';
|
||||
import { FormFieldCreationOverlay } from '@app/tools/formFill/FormFieldCreationOverlay';
|
||||
import { FormFieldEditOverlay } from '@app/tools/formFill/FormFieldEditOverlay';
|
||||
|
||||
const DOCUMENT_NAME = 'stirling-pdf-viewer';
|
||||
|
||||
@@ -747,6 +749,26 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* FormFieldCreationOverlay for creating new fields */}
|
||||
{enableFormFill && (
|
||||
<FormFieldCreationOverlay
|
||||
documentId={documentId}
|
||||
pageIndex={pageIndex}
|
||||
pageWidth={width}
|
||||
pageHeight={height}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* FormFieldEditOverlay for moving/resizing existing fields */}
|
||||
{enableFormFill && (
|
||||
<FormFieldEditOverlay
|
||||
documentId={documentId}
|
||||
pageIndex={pageIndex}
|
||||
pageWidth={width}
|
||||
pageHeight={height}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* AnnotationLayer for annotation editing and annotation-based redactions */}
|
||||
{(enableAnnotations || enableRedaction) && (
|
||||
<AnnotationLayer
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* FormFieldCreatePanel — Left panel for "Create" mode.
|
||||
*
|
||||
* Contains:
|
||||
* - Field type palette (click to start placing)
|
||||
* - Pending fields list with property editing
|
||||
* - "Add Fields to PDF" commit button
|
||||
*/
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Text,
|
||||
ScrollArea,
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
Collapse,
|
||||
Loader,
|
||||
} from '@mantine/core';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
||||
import { useFormFill } from '@app/tools/formFill/FormFillContext';
|
||||
import { useFileState } from '@app/contexts/FileContext';
|
||||
import { isStirlingFile } from '@app/types/fileContext';
|
||||
import { FIELD_TYPE_ICON, FIELD_TYPE_COLOR } from '@app/tools/formFill/fieldMeta';
|
||||
import { FormFieldPropertyEditor } from '@app/tools/formFill/FormFieldPropertyEditor';
|
||||
import type { FormFieldType } from '@app/tools/formFill/types';
|
||||
import styles from '@app/tools/formFill/FormFill.module.css';
|
||||
|
||||
const CREATABLE_TYPES: { type: FormFieldType; label: string }[] = [
|
||||
{ type: 'text', label: 'Text' },
|
||||
{ type: 'checkbox', label: 'Checkbox' },
|
||||
{ type: 'combobox', label: 'Dropdown' },
|
||||
{ type: 'listbox', label: 'List' },
|
||||
];
|
||||
|
||||
export function FormFieldCreatePanel() {
|
||||
const {
|
||||
creationState,
|
||||
setPlacingFieldType,
|
||||
removePendingField,
|
||||
updatePendingField,
|
||||
commitNewFields,
|
||||
fetchFields,
|
||||
} = useFormFill();
|
||||
|
||||
const { selectors, state: fileState } = useFileState();
|
||||
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 [expandedIdx, setExpandedIdx] = useState<number | null>(null);
|
||||
const [committing, setCommitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleCommit = useCallback(async () => {
|
||||
if (!currentFile || !isStirlingFile(currentFile)) return;
|
||||
if (creationState.pendingFields.length === 0) return;
|
||||
|
||||
setCommitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const blob = await commitNewFields(currentFile);
|
||||
// Apply the result to the viewer via custom event (same pattern as FormFill save)
|
||||
const event = new CustomEvent('formfill:apply', { detail: { blob } });
|
||||
window.dispatchEvent(event);
|
||||
// Re-fetch fields to pick up the new ones
|
||||
fetchFields(currentFile, currentFile.fileId);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Failed to add fields');
|
||||
} finally {
|
||||
setCommitting(false);
|
||||
}
|
||||
}, [currentFile, creationState.pendingFields, commitNewFields, fetchFields]);
|
||||
|
||||
const { pendingFields, placingFieldType } = creationState;
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.header}>
|
||||
<Text size="xs" fw={600}>Select a field type, then drag on the PDF to place it.</Text>
|
||||
|
||||
{/* Field type palette */}
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{CREATABLE_TYPES.map(({ type, label }) => {
|
||||
const isActive = placingFieldType === type;
|
||||
return (
|
||||
<Tooltip key={type} label={label} withArrow position="bottom">
|
||||
<UnstyledButton
|
||||
onClick={() => setPlacingFieldType(isActive ? null : type)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
padding: '6px 10px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
border: `1.5px solid ${isActive ? `var(--mantine-color-${FIELD_TYPE_COLOR[type]}-5)` : 'var(--border-default, var(--mantine-color-default-border))'}`,
|
||||
background: isActive ? `var(--mantine-color-${FIELD_TYPE_COLOR[type]}-light)` : 'transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
minWidth: 52,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '1.125rem', color: `var(--mantine-color-${FIELD_TYPE_COLOR[type]}-6)`, lineHeight: 1, display: 'flex' }}>
|
||||
{FIELD_TYPE_ICON[type]}
|
||||
</span>
|
||||
<Text size="xs" fw={600} style={{ fontSize: '0.625rem', textTransform: 'uppercase' }}>
|
||||
{label}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{pendingFields.length > 0 && (
|
||||
<Button
|
||||
leftSection={committing ? <Loader size={14} color="white" /> : <SaveIcon sx={{ fontSize: 14 }} />}
|
||||
size="xs"
|
||||
onClick={handleCommit}
|
||||
loading={committing}
|
||||
fullWidth
|
||||
>
|
||||
Add {pendingFields.length} Field{pendingFields.length !== 1 ? 's' : ''} to PDF
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert icon={<WarningAmberIcon sx={{ fontSize: 16 }} />} color="red" variant="light" p="xs" radius="sm">
|
||||
<Text size="xs">{error}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pending fields list */}
|
||||
<ScrollArea className={styles.fieldList}>
|
||||
<div className={styles.fieldListInner}>
|
||||
{pendingFields.length === 0 && (
|
||||
<div className={styles.emptyState} style={{ padding: '2rem 1rem' }}>
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
No pending fields. Select a type above and drag on the PDF to create one.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingFields.map((field, idx) => (
|
||||
<div key={idx} className={styles.fieldCard}>
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}
|
||||
onClick={() => setExpandedIdx(expandedIdx === idx ? null : idx)}
|
||||
>
|
||||
<span style={{ fontSize: '0.875rem', color: `var(--mantine-color-${FIELD_TYPE_COLOR[field.type]}-6)`, display: 'flex' }}>
|
||||
{FIELD_TYPE_ICON[field.type]}
|
||||
</span>
|
||||
<Text size="xs" fw={600} style={{ flex: 1 }}>
|
||||
{field.name || `New ${field.type}`}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">p.{field.pageIndex + 1}</Text>
|
||||
<ActionIcon variant="subtle" color="red" size="sm" onClick={(e) => { e.stopPropagation(); removePendingField(idx); }}>
|
||||
<DeleteIcon sx={{ fontSize: 14 }} />
|
||||
</ActionIcon>
|
||||
{expandedIdx === idx
|
||||
? <ExpandLessIcon sx={{ fontSize: 16, opacity: 0.5 }} />
|
||||
: <ExpandMoreIcon sx={{ fontSize: 16, opacity: 0.5 }} />
|
||||
}
|
||||
</div>
|
||||
|
||||
<Collapse in={expandedIdx === idx}>
|
||||
<div style={{ paddingTop: 8 }}>
|
||||
<FormFieldPropertyEditor
|
||||
field={field}
|
||||
onChange={(updated) => updatePendingField(idx, updated)}
|
||||
showCoordinates
|
||||
/>
|
||||
</div>
|
||||
</Collapse>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* FormFieldCreationOverlay — Rendered on PDF pages in "Create" mode.
|
||||
*
|
||||
* - Shows crosshair cursor when a field type is selected for placement
|
||||
* - Handles drag-to-define field rectangle (pointerdown → pointermove → pointerup)
|
||||
* - Renders a preview rectangle during drag
|
||||
* - Renders pending (uncommitted) fields as dashed outlines
|
||||
* - Converts CSS coordinates to PDF coordinates on completion
|
||||
* - Snaps to nearby field edges with visual guide lines
|
||||
*/
|
||||
import React, { useCallback, useRef, useMemo, useEffect, useState } from 'react';
|
||||
import { useDocumentState } from '@embedpdf/core/react';
|
||||
import { useFormFill } from '@app/tools/formFill/FormFillContext';
|
||||
import { cssToPdfRect, pixelsToPdfPoints } from '@app/tools/formFill/formCoordinateUtils';
|
||||
import { FIELD_TYPE_ICON, FIELD_TYPE_COLOR } from '@app/tools/formFill/fieldMeta';
|
||||
import {
|
||||
collectSnapTargets,
|
||||
collectPendingFieldSnapTargets,
|
||||
snapRect,
|
||||
type SnapGuide,
|
||||
} from '@app/tools/formFill/formSnapUtils';
|
||||
import type { NewFieldDefinition } from '@app/tools/formFill/types';
|
||||
|
||||
const MIN_SIZE_PTS = 10;
|
||||
|
||||
interface FormFieldCreationOverlayProps {
|
||||
documentId: string;
|
||||
pageIndex: number;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
}
|
||||
|
||||
export function FormFieldCreationOverlay({
|
||||
documentId,
|
||||
pageIndex,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
}: FormFieldCreationOverlayProps) {
|
||||
const {
|
||||
mode,
|
||||
creationState,
|
||||
setCreationDragRect,
|
||||
addPendingField,
|
||||
setPlacingFieldType,
|
||||
modifiedFields,
|
||||
state: { fields: allFields },
|
||||
} = useFormFill();
|
||||
|
||||
const documentState = useDocumentState(documentId);
|
||||
|
||||
const { scaleX, scaleY, pageWidthPts, pageHeightPts } = useMemo(() => {
|
||||
const pdfPage = documentState?.document?.pages?.[pageIndex];
|
||||
if (!pdfPage || !pdfPage.size || !pageWidth || !pageHeight) {
|
||||
const s = documentState?.scale ?? 1;
|
||||
return { scaleX: s, scaleY: s, pageWidthPts: pageWidth / s, pageHeightPts: pageHeight / s };
|
||||
}
|
||||
// Prefer CropBox height from backend (if available) for exact Y-flip consistency.
|
||||
const firstWidget = allFields
|
||||
.find(f => f.widgets?.some(w => w.pageIndex === pageIndex))
|
||||
?.widgets?.find(w => w.pageIndex === pageIndex);
|
||||
const cbHeight = firstWidget?.cropBoxHeight;
|
||||
return {
|
||||
scaleX: pageWidth / pdfPage.size.width,
|
||||
scaleY: pageHeight / pdfPage.size.height,
|
||||
pageWidthPts: pdfPage.size.width,
|
||||
pageHeightPts: cbHeight ?? pdfPage.size.height,
|
||||
};
|
||||
}, [documentState, pageIndex, pageWidth, pageHeight, allFields]);
|
||||
|
||||
const dragging = useRef<{ startX: number; startY: number } | null>(null);
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
const [snapGuides, setSnapGuides] = useState<SnapGuide[]>([]);
|
||||
|
||||
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
||||
if (!creationState.placingFieldType) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const rect = overlayRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const pixelX = e.clientX - rect.left;
|
||||
const pixelY = e.clientY - rect.top;
|
||||
|
||||
dragging.current = { startX: pixelX, startY: pixelY };
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
|
||||
setCreationDragRect({
|
||||
x: pixelX,
|
||||
y: pixelY,
|
||||
width: 0,
|
||||
height: 0,
|
||||
pageIndex,
|
||||
});
|
||||
}, [creationState.placingFieldType, pageIndex, setCreationDragRect]);
|
||||
|
||||
const handlePointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragging.current || !creationState.placingFieldType) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const rect = overlayRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const pixelX = Math.max(0, Math.min(pageWidth, e.clientX - rect.left));
|
||||
const pixelY = Math.max(0, Math.min(pageHeight, e.clientY - rect.top));
|
||||
|
||||
let x = Math.min(dragging.current.startX, pixelX);
|
||||
let y = Math.min(dragging.current.startY, pixelY);
|
||||
const width = Math.abs(pixelX - dragging.current.startX);
|
||||
const height = Math.abs(pixelY - dragging.current.startY);
|
||||
|
||||
// Apply snapping to the drag rectangle
|
||||
const existingTargets = collectSnapTargets(
|
||||
allFields, null, pageIndex,
|
||||
scaleX, scaleY, pageHeightPts, modifiedFields,
|
||||
);
|
||||
const pendingTargets = collectPendingFieldSnapTargets(
|
||||
creationState.pendingFields, pageIndex,
|
||||
scaleX, scaleY, pageHeightPts,
|
||||
);
|
||||
const targets = [...existingTargets, ...pendingTargets];
|
||||
const snapped = snapRect(x, y, width, height, targets);
|
||||
x = snapped.left;
|
||||
y = snapped.top;
|
||||
setSnapGuides(snapped.guides);
|
||||
|
||||
setCreationDragRect({ x, y, width, height, pageIndex });
|
||||
}, [creationState.placingFieldType, creationState.pendingFields, pageWidth, pageHeight, pageIndex, allFields, scaleX, scaleY, pageHeightPts, modifiedFields, setCreationDragRect]);
|
||||
|
||||
const handlePointerUp = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragging.current || !creationState.placingFieldType) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
setSnapGuides([]);
|
||||
|
||||
const rect = overlayRef.current?.getBoundingClientRect();
|
||||
if (!rect) {
|
||||
dragging.current = null;
|
||||
setCreationDragRect(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const pixelX = Math.max(0, Math.min(pageWidth, e.clientX - rect.left));
|
||||
const pixelY = Math.max(0, Math.min(pageHeight, e.clientY - rect.top));
|
||||
|
||||
// Compute CSS rect in PDF points
|
||||
const startPts = pixelsToPdfPoints(
|
||||
dragging.current.startX, dragging.current.startY,
|
||||
pageWidth, pageHeight, pageWidthPts, pageHeightPts
|
||||
);
|
||||
const endPts = pixelsToPdfPoints(
|
||||
pixelX, pixelY,
|
||||
pageWidth, pageHeight, pageWidthPts, pageHeightPts
|
||||
);
|
||||
|
||||
const cssX = Math.min(startPts.x, endPts.x);
|
||||
const cssY = Math.min(startPts.y, endPts.y);
|
||||
const cssW = Math.abs(endPts.x - startPts.x);
|
||||
const cssH = Math.abs(endPts.y - startPts.y);
|
||||
|
||||
dragging.current = null;
|
||||
setCreationDragRect(null);
|
||||
|
||||
// Enforce minimum size
|
||||
if (cssW < MIN_SIZE_PTS || cssH < MIN_SIZE_PTS) return;
|
||||
|
||||
// Convert from CSS TL origin to PDF BL origin
|
||||
const pdfRect = cssToPdfRect(
|
||||
{ x: cssX, y: cssY, width: cssW, height: cssH },
|
||||
pageHeightPts
|
||||
);
|
||||
|
||||
const fieldType = creationState.placingFieldType;
|
||||
const count = creationState.pendingFields.length;
|
||||
const newField: NewFieldDefinition = {
|
||||
name: `${fieldType}_${count + 1}`,
|
||||
type: fieldType,
|
||||
pageIndex,
|
||||
x: pdfRect.x,
|
||||
y: pdfRect.y,
|
||||
width: pdfRect.width,
|
||||
height: pdfRect.height,
|
||||
};
|
||||
|
||||
addPendingField(newField);
|
||||
}, [
|
||||
creationState.placingFieldType,
|
||||
creationState.pendingFields.length,
|
||||
pageWidth, pageHeight, pageWidthPts, pageHeightPts,
|
||||
pageIndex, setCreationDragRect, addPendingField,
|
||||
]);
|
||||
|
||||
// Escape to cancel placement
|
||||
useEffect(() => {
|
||||
if (mode !== 'make') return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (creationState.placingFieldType) {
|
||||
setPlacingFieldType(null);
|
||||
setCreationDragRect(null);
|
||||
dragging.current = null;
|
||||
setSnapGuides([]);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [mode, creationState.placingFieldType, setPlacingFieldType, setCreationDragRect]);
|
||||
|
||||
// Don't render if not in create mode
|
||||
if (mode !== 'make') return null;
|
||||
|
||||
const isPlacing = !!creationState.placingFieldType;
|
||||
const dragRect = creationState.dragRect;
|
||||
const showDragRect = dragRect && dragRect.pageIndex === pageIndex;
|
||||
|
||||
// Render pending fields for this page
|
||||
const pendingForPage = creationState.pendingFields.filter(f => f.pageIndex === pageIndex);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
zIndex: 15,
|
||||
cursor: isPlacing ? 'crosshair' : 'default',
|
||||
pointerEvents: isPlacing ? 'auto' : 'none',
|
||||
}}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
>
|
||||
{/* Snap guide lines */}
|
||||
{snapGuides.map((guide, i) =>
|
||||
guide.axis === 'x' ? (
|
||||
<div
|
||||
key={`guide-${i}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: guide.position,
|
||||
top: 0,
|
||||
width: 0,
|
||||
height: '100%',
|
||||
borderLeft: '1px dashed rgba(255, 0, 100, 0.6)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 20,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
key={`guide-${i}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: guide.position,
|
||||
width: '100%',
|
||||
height: 0,
|
||||
borderTop: '1px dashed rgba(255, 0, 100, 0.6)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 20,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Drag preview rectangle */}
|
||||
{showDragRect && dragRect.width > 0 && dragRect.height > 0 && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: dragRect.x,
|
||||
top: dragRect.y,
|
||||
width: dragRect.width,
|
||||
height: dragRect.height,
|
||||
border: '2px dashed var(--mantine-color-blue-5)',
|
||||
background: 'rgba(33, 150, 243, 0.1)',
|
||||
borderRadius: 2,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Pending fields as dashed outlines */}
|
||||
{pendingForPage.map((field, idx) => {
|
||||
// Convert PDF coords back to CSS for display
|
||||
const cssY = pageHeightPts - field.y - field.height;
|
||||
const left = field.x * scaleX;
|
||||
const top = cssY * scaleY;
|
||||
const width = field.width * scaleX;
|
||||
const height = field.height * scaleY;
|
||||
const color = `var(--mantine-color-${FIELD_TYPE_COLOR[field.type]}-5)`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`pending-${idx}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
border: `2px dashed ${color}`,
|
||||
background: `var(--mantine-color-${FIELD_TYPE_COLOR[field.type]}-light)`,
|
||||
borderRadius: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
pointerEvents: 'none',
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: Math.min(height * 0.6, 18), color, lineHeight: 1, display: 'flex' }}>
|
||||
{FIELD_TYPE_ICON[field.type]}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
/**
|
||||
* FormFieldEditOverlay — Rendered on PDF pages in "Modify" mode.
|
||||
*
|
||||
* - Click to select an existing field
|
||||
* - Drag field body to reposition
|
||||
* - Drag corner/edge handles to resize
|
||||
* - Updates modifiedFields in context on completion
|
||||
* - Snaps to nearby field edges with visual guide lines
|
||||
*/
|
||||
import React, { useCallback, useRef, useMemo, useEffect, useState } from 'react';
|
||||
import { useDocumentState } from '@embedpdf/core/react';
|
||||
import { useFormFill } from '@app/tools/formFill/FormFillContext';
|
||||
import { cssToPdfRect, pdfToCssRect } from '@app/tools/formFill/formCoordinateUtils';
|
||||
import { FIELD_TYPE_COLOR } from '@app/tools/formFill/fieldMeta';
|
||||
import { collectSnapTargets, snapRect, snapRectResize, type SnapGuide, type ResizeEdges } from '@app/tools/formFill/formSnapUtils';
|
||||
import type { FormField, WidgetCoordinates } from '@app/tools/formFill/types';
|
||||
|
||||
const MIN_SIZE_PX = 8;
|
||||
const HANDLE_SIZE = 8;
|
||||
|
||||
interface FormFieldEditOverlayProps {
|
||||
documentId: string;
|
||||
pageIndex: number;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
}
|
||||
|
||||
type HandlePosition = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w';
|
||||
|
||||
interface DragState {
|
||||
type: 'move' | 'resize';
|
||||
handle?: HandlePosition;
|
||||
startMouseX: number;
|
||||
startMouseY: number;
|
||||
startLeft: number;
|
||||
startTop: number;
|
||||
startWidth: number;
|
||||
startHeight: number;
|
||||
}
|
||||
|
||||
const HANDLE_POSITIONS: { pos: HandlePosition; cursor: string; top: string; left: string }[] = [
|
||||
{ pos: 'nw', cursor: 'nwse-resize', top: '0%', left: '0%' },
|
||||
{ pos: 'n', cursor: 'ns-resize', top: '0%', left: '50%' },
|
||||
{ pos: 'ne', cursor: 'nesw-resize', top: '0%', left: '100%' },
|
||||
{ pos: 'e', cursor: 'ew-resize', top: '50%', left: '100%' },
|
||||
{ pos: 'se', cursor: 'nwse-resize', top: '100%', left: '100%' },
|
||||
{ pos: 's', cursor: 'ns-resize', top: '100%', left: '50%' },
|
||||
{ pos: 'sw', cursor: 'nesw-resize', top: '100%', left: '0%' },
|
||||
{ pos: 'w', cursor: 'ew-resize', top: '50%', left: '0%' },
|
||||
];
|
||||
|
||||
export function FormFieldEditOverlay({
|
||||
documentId,
|
||||
pageIndex,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
}: FormFieldEditOverlayProps) {
|
||||
const {
|
||||
mode,
|
||||
fieldsByPage,
|
||||
editState,
|
||||
setEditState,
|
||||
modifiedFields,
|
||||
updateFieldCoordinates,
|
||||
state: { fields: allFields },
|
||||
} = useFormFill();
|
||||
|
||||
const documentState = useDocumentState(documentId);
|
||||
|
||||
const pageFields = useMemo(
|
||||
() => fieldsByPage.get(pageIndex) || [],
|
||||
[fieldsByPage, pageIndex]
|
||||
);
|
||||
|
||||
const { scaleX, scaleY, pageWidthPts, pageHeightPts } = useMemo(() => {
|
||||
const pdfPage = documentState?.document?.pages?.[pageIndex];
|
||||
if (!pdfPage || !pdfPage.size || !pageWidth || !pageHeight) {
|
||||
const s = documentState?.scale ?? 1;
|
||||
return { scaleX: s, scaleY: s, pageWidthPts: pageWidth / s, pageHeightPts: pageHeight / s };
|
||||
}
|
||||
// Prefer CropBox height from backend (if available) for exact Y-flip consistency.
|
||||
const firstWidget = pageFields.find(f => f.widgets?.some(w => w.pageIndex === pageIndex))
|
||||
?.widgets?.find(w => w.pageIndex === pageIndex);
|
||||
const cbHeight = firstWidget?.cropBoxHeight;
|
||||
return {
|
||||
scaleX: pageWidth / pdfPage.size.width,
|
||||
scaleY: pageHeight / pdfPage.size.height,
|
||||
pageWidthPts: pdfPage.size.width,
|
||||
pageHeightPts: cbHeight ?? pdfPage.size.height,
|
||||
};
|
||||
}, [documentState, pageIndex, pageWidth, pageHeight, pageFields]);
|
||||
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
const dragFieldRef = useRef<string | null>(null);
|
||||
const pendingRectRef = useRef<{ x: number; y: number; width: number; height: number } | null>(null);
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
const [snapGuides, setSnapGuides] = useState<SnapGuide[]>([]);
|
||||
|
||||
// Get the CSS rect for a field (considering pending modifications)
|
||||
const getFieldCssRect = useCallback((field: FormField, widget: WidgetCoordinates) => {
|
||||
const modified = modifiedFields.get(field.name);
|
||||
if (modified && modified.x != null && modified.y != null && modified.width != null && modified.height != null) {
|
||||
// modified coords are in PDF BL origin — convert to CSS using THIS widget's cropBoxHeight
|
||||
const cropBoxHeight = widget.cropBoxHeight ?? pageHeightPts;
|
||||
const css = pdfToCssRect(
|
||||
{ x: modified.x, y: modified.y, width: modified.width, height: modified.height },
|
||||
cropBoxHeight
|
||||
);
|
||||
return {
|
||||
left: css.x * scaleX,
|
||||
top: css.y * scaleY,
|
||||
width: css.width * scaleX,
|
||||
height: css.height * scaleY,
|
||||
};
|
||||
}
|
||||
// Widget coords are already in CSS TL origin (y-flipped by backend)
|
||||
return {
|
||||
left: widget.x * scaleX,
|
||||
top: widget.y * scaleY,
|
||||
width: widget.width * scaleX,
|
||||
height: widget.height * scaleY,
|
||||
};
|
||||
}, [modifiedFields, pageHeightPts, scaleX, scaleY]);
|
||||
|
||||
const handleFieldClick = useCallback((e: React.PointerEvent, fieldName: string) => {
|
||||
e.stopPropagation();
|
||||
setEditState({
|
||||
selectedFieldName: fieldName,
|
||||
interaction: 'idle',
|
||||
pendingRect: null,
|
||||
});
|
||||
}, [setEditState]);
|
||||
|
||||
const handlePointerDown = useCallback((
|
||||
e: React.PointerEvent,
|
||||
fieldName: string,
|
||||
type: 'move' | 'resize',
|
||||
handle?: HandlePosition
|
||||
) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Find current CSS rect for this field
|
||||
const field = pageFields.find(f => f.name === fieldName);
|
||||
if (!field) return;
|
||||
const widget = field.widgets?.find(w => w.pageIndex === pageIndex);
|
||||
if (!widget) return;
|
||||
|
||||
const rect = getFieldCssRect(field, widget);
|
||||
|
||||
dragRef.current = {
|
||||
type,
|
||||
handle,
|
||||
startMouseX: e.clientX,
|
||||
startMouseY: e.clientY,
|
||||
startLeft: rect.left,
|
||||
startTop: rect.top,
|
||||
startWidth: rect.width,
|
||||
startHeight: rect.height,
|
||||
};
|
||||
dragFieldRef.current = fieldName;
|
||||
|
||||
setEditState({
|
||||
selectedFieldName: fieldName,
|
||||
interaction: type === 'move' ? 'moving' : 'resizing',
|
||||
pendingRect: { x: rect.left, y: rect.top, width: rect.width, height: rect.height },
|
||||
});
|
||||
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}, [pageFields, pageIndex, getFieldCssRect, setEditState]);
|
||||
|
||||
const handlePointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragRef.current || !dragFieldRef.current) return;
|
||||
e.preventDefault();
|
||||
|
||||
const drag = dragRef.current;
|
||||
const dx = e.clientX - drag.startMouseX;
|
||||
const dy = e.clientY - drag.startMouseY;
|
||||
|
||||
let newLeft = drag.startLeft;
|
||||
let newTop = drag.startTop;
|
||||
let newWidth = drag.startWidth;
|
||||
let newHeight = drag.startHeight;
|
||||
|
||||
if (drag.type === 'move') {
|
||||
newLeft = Math.max(0, Math.min(pageWidth - drag.startWidth, drag.startLeft + dx));
|
||||
newTop = Math.max(0, Math.min(pageHeight - drag.startHeight, drag.startTop + dy));
|
||||
} else if (drag.type === 'resize' && drag.handle) {
|
||||
const h = drag.handle;
|
||||
// Adjust dimensions based on handle position
|
||||
if (h.includes('e')) {
|
||||
newWidth = Math.max(MIN_SIZE_PX, drag.startWidth + dx);
|
||||
}
|
||||
if (h.includes('w')) {
|
||||
const dw = Math.min(dx, drag.startWidth - MIN_SIZE_PX);
|
||||
newLeft = drag.startLeft + dw;
|
||||
newWidth = drag.startWidth - dw;
|
||||
}
|
||||
if (h.includes('s')) {
|
||||
newHeight = Math.max(MIN_SIZE_PX, drag.startHeight + dy);
|
||||
}
|
||||
if (h.includes('n')) {
|
||||
const dh = Math.min(dy, drag.startHeight - MIN_SIZE_PX);
|
||||
newTop = drag.startTop + dh;
|
||||
newHeight = drag.startHeight - dh;
|
||||
}
|
||||
// Clamp to page bounds
|
||||
newLeft = Math.max(0, newLeft);
|
||||
newTop = Math.max(0, newTop);
|
||||
if (newLeft + newWidth > pageWidth) newWidth = pageWidth - newLeft;
|
||||
if (newTop + newHeight > pageHeight) newHeight = pageHeight - newTop;
|
||||
}
|
||||
|
||||
// Apply snapping
|
||||
const targets = collectSnapTargets(
|
||||
allFields, dragFieldRef.current, pageIndex,
|
||||
scaleX, scaleY, pageHeightPts, modifiedFields,
|
||||
);
|
||||
|
||||
if (drag.type === 'move') {
|
||||
const snapped = snapRect(newLeft, newTop, newWidth, newHeight, targets);
|
||||
newLeft = snapped.left;
|
||||
newTop = snapped.top;
|
||||
setSnapGuides(snapped.guides);
|
||||
} else if (drag.type === 'resize' && drag.handle) {
|
||||
const h = drag.handle;
|
||||
const resizeEdges: ResizeEdges = {
|
||||
left: h.includes('w'),
|
||||
right: h.includes('e'),
|
||||
top: h.includes('n'),
|
||||
bottom: h.includes('s'),
|
||||
};
|
||||
const snapped = snapRectResize(newLeft, newTop, newWidth, newHeight, resizeEdges, targets);
|
||||
newLeft = snapped.left;
|
||||
newTop = snapped.top;
|
||||
newWidth = snapped.width;
|
||||
newHeight = snapped.height;
|
||||
setSnapGuides(snapped.guides);
|
||||
}
|
||||
|
||||
const rect = { x: newLeft, y: newTop, width: newWidth, height: newHeight };
|
||||
pendingRectRef.current = rect;
|
||||
setEditState({
|
||||
interaction: drag.type === 'move' ? 'moving' : 'resizing',
|
||||
pendingRect: rect,
|
||||
});
|
||||
}, [pageWidth, pageHeight, allFields, pageIndex, scaleX, scaleY, pageHeightPts, modifiedFields, setEditState]);
|
||||
|
||||
const handlePointerUp = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragRef.current || !dragFieldRef.current) return;
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
|
||||
const pendingRect = pendingRectRef.current;
|
||||
const fieldName = dragFieldRef.current;
|
||||
|
||||
dragRef.current = null;
|
||||
dragFieldRef.current = null;
|
||||
pendingRectRef.current = null;
|
||||
setSnapGuides([]);
|
||||
|
||||
if (!pendingRect) {
|
||||
setEditState({ interaction: 'idle', pendingRect: null });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the specific widget's cropBoxHeight for correct Y-flip.
|
||||
// Must match the height used during extraction to avoid coordinate mismatch.
|
||||
const field = allFields.find(f => f.name === fieldName);
|
||||
const widget = field?.widgets?.find(w => w.pageIndex === pageIndex);
|
||||
const cropBoxHeight = widget?.cropBoxHeight ?? pageHeightPts;
|
||||
|
||||
// Convert pixel rect to PDF-point CSS rect, then to PDF BL origin
|
||||
const cssPts = {
|
||||
x: pendingRect.x / scaleX,
|
||||
y: pendingRect.y / scaleY,
|
||||
width: pendingRect.width / scaleX,
|
||||
height: pendingRect.height / scaleY,
|
||||
};
|
||||
const pdfRect = cssToPdfRect(cssPts, cropBoxHeight);
|
||||
|
||||
updateFieldCoordinates(fieldName, {
|
||||
x: pdfRect.x,
|
||||
y: pdfRect.y,
|
||||
width: pdfRect.width,
|
||||
height: pdfRect.height,
|
||||
});
|
||||
|
||||
setEditState({ interaction: 'idle', pendingRect: null });
|
||||
}, [scaleX, scaleY, pageHeightPts, updateFieldCoordinates, setEditState, allFields, pageIndex]);
|
||||
|
||||
// Click on empty area deselects
|
||||
const handleOverlayClick = useCallback((e: React.PointerEvent) => {
|
||||
if (e.target === overlayRef.current) {
|
||||
setEditState({ selectedFieldName: null, interaction: 'idle', pendingRect: null });
|
||||
}
|
||||
}, [setEditState]);
|
||||
|
||||
// Escape to deselect, Delete to remove selection
|
||||
useEffect(() => {
|
||||
if (mode !== 'modify') return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && editState.selectedFieldName) {
|
||||
setEditState({ selectedFieldName: null, interaction: 'idle', pendingRect: null });
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [mode, editState.selectedFieldName, setEditState]);
|
||||
|
||||
if (mode !== 'modify') return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
zIndex: 15,
|
||||
pointerEvents: 'auto',
|
||||
cursor: editState.interaction !== 'idle' ? 'grabbing' : 'default',
|
||||
}}
|
||||
onPointerDown={handleOverlayClick}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
>
|
||||
{/* Snap guide lines */}
|
||||
{snapGuides.map((guide, i) =>
|
||||
guide.axis === 'x' ? (
|
||||
<div
|
||||
key={`guide-${i}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: guide.position,
|
||||
top: 0,
|
||||
width: 0,
|
||||
height: '100%',
|
||||
borderLeft: '1px dashed rgba(255, 0, 100, 0.6)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 20,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
key={`guide-${i}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: guide.position,
|
||||
width: '100%',
|
||||
height: 0,
|
||||
borderTop: '1px dashed rgba(255, 0, 100, 0.6)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 20,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{pageFields.map((field) => {
|
||||
const widgets = (field.widgets || []).filter(w => w.pageIndex === pageIndex);
|
||||
if (widgets.length === 0) return null;
|
||||
const widget = widgets[0];
|
||||
const isSelected = editState.selectedFieldName === field.name;
|
||||
const isModified = modifiedFields.has(field.name);
|
||||
|
||||
// Use pending rect during drag for the selected field
|
||||
const usesPending = isSelected && editState.pendingRect && editState.interaction !== 'idle';
|
||||
const rect = usesPending
|
||||
? { left: editState.pendingRect!.x, top: editState.pendingRect!.y, width: editState.pendingRect!.width, height: editState.pendingRect!.height }
|
||||
: getFieldCssRect(field, widget);
|
||||
|
||||
const color = `var(--mantine-color-${FIELD_TYPE_COLOR[field.type]}-5)`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={field.name}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
border: isSelected
|
||||
? `2px solid ${color}`
|
||||
: `1px solid ${isModified ? color : 'rgba(33, 150, 243, 0.4)'}`,
|
||||
borderRadius: 2,
|
||||
background: isSelected ? 'rgba(33, 150, 243, 0.08)' : 'transparent',
|
||||
cursor: isSelected ? 'grab' : 'pointer',
|
||||
boxSizing: 'border-box',
|
||||
transition: editState.interaction !== 'idle' ? 'none' : 'border-color 0.15s',
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (isSelected) {
|
||||
handlePointerDown(e, field.name, 'move');
|
||||
} else {
|
||||
handleFieldClick(e, field.name);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Resize handles — only for selected field */}
|
||||
{isSelected && editState.interaction === 'idle' && HANDLE_POSITIONS.map(({ pos, cursor, top, left }) => (
|
||||
<div
|
||||
key={pos}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top,
|
||||
left,
|
||||
width: HANDLE_SIZE,
|
||||
height: HANDLE_SIZE,
|
||||
background: 'white',
|
||||
border: `1.5px solid ${color}`,
|
||||
borderRadius: 1,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
cursor,
|
||||
zIndex: 1,
|
||||
}}
|
||||
onPointerDown={(e) => handlePointerDown(e, field.name, 'resize', pos)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* FormFieldModifyPanel — Left panel for "Modify" mode.
|
||||
*
|
||||
* Shows existing fields, allows selecting one for coordinate editing
|
||||
* via the overlay, and batches all modifications into one backend call.
|
||||
*/
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Text,
|
||||
ScrollArea,
|
||||
Alert,
|
||||
Loader,
|
||||
} from '@mantine/core';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
||||
import { useFormFill } from '@app/tools/formFill/FormFillContext';
|
||||
import { useFileState } from '@app/contexts/FileContext';
|
||||
import { isStirlingFile } from '@app/types/fileContext';
|
||||
import { FIELD_TYPE_ICON, FIELD_TYPE_COLOR } from '@app/tools/formFill/fieldMeta';
|
||||
import { FormFieldPropertyEditor } from '@app/tools/formFill/FormFieldPropertyEditor';
|
||||
import type { NewFieldDefinition, FormField } from '@app/tools/formFill/types';
|
||||
import styles from '@app/tools/formFill/FormFill.module.css';
|
||||
|
||||
export function FormFieldModifyPanel() {
|
||||
const {
|
||||
state: formState,
|
||||
editState,
|
||||
setEditState,
|
||||
modifiedFields,
|
||||
updateFieldCoordinates,
|
||||
updateFieldProperties,
|
||||
commitFieldModifications,
|
||||
fetchFields,
|
||||
} = useFormFill();
|
||||
|
||||
const { selectors, state: fileState } = useFileState();
|
||||
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 [committing, setCommitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleCommit = useCallback(async () => {
|
||||
if (!currentFile || !isStirlingFile(currentFile)) return;
|
||||
if (modifiedFields.size === 0) return;
|
||||
|
||||
setCommitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const blob = await commitFieldModifications(currentFile);
|
||||
const event = new CustomEvent('formfill:apply', { detail: { blob } });
|
||||
window.dispatchEvent(event);
|
||||
fetchFields(currentFile, currentFile.fileId);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Failed to save modifications');
|
||||
} finally {
|
||||
setCommitting(false);
|
||||
}
|
||||
}, [currentFile, modifiedFields, commitFieldModifications, fetchFields]);
|
||||
|
||||
const handleSelectField = useCallback((fieldName: string) => {
|
||||
setEditState({
|
||||
selectedFieldName: editState.selectedFieldName === fieldName ? null : fieldName,
|
||||
interaction: 'idle',
|
||||
pendingRect: null,
|
||||
});
|
||||
}, [editState.selectedFieldName, setEditState]);
|
||||
|
||||
const { fields } = formState;
|
||||
|
||||
// Build a NewFieldDefinition-like object for the property editor from the selected field
|
||||
const selectedField = useMemo<FormField | undefined>(
|
||||
() => editState.selectedFieldName ? fields.find(f => f.name === editState.selectedFieldName) : undefined,
|
||||
[fields, editState.selectedFieldName]
|
||||
);
|
||||
|
||||
const selectedFieldEditorData = useMemo<NewFieldDefinition | null>(() => {
|
||||
if (!selectedField) return null;
|
||||
const widget = selectedField.widgets?.[0];
|
||||
const pending = modifiedFields.get(selectedField.name) || {};
|
||||
// Coords: modifiedFields stores PDF BL origin. Widget coords are CSS TL (y-flipped).
|
||||
// Convert widget CSS TL → PDF BL for display consistency when no pending modification.
|
||||
const cropH = widget?.cropBoxHeight ?? 0;
|
||||
const widgetPdfY = cropH > 0 && widget
|
||||
? cropH - widget.y - widget.height
|
||||
: widget?.y ?? 0;
|
||||
const x = pending.x ?? widget?.x ?? 0;
|
||||
const y = pending.y ?? widgetPdfY;
|
||||
const w = pending.width ?? widget?.width ?? 100;
|
||||
const h = pending.height ?? widget?.height ?? 20;
|
||||
return {
|
||||
name: pending.name ?? selectedField.name,
|
||||
label: pending.label ?? selectedField.label ?? undefined,
|
||||
type: (pending.type ?? selectedField.type) as NewFieldDefinition['type'],
|
||||
pageIndex: widget?.pageIndex ?? 0,
|
||||
x,
|
||||
y,
|
||||
width: w,
|
||||
height: h,
|
||||
required: pending.required ?? selectedField.required,
|
||||
tooltip: pending.tooltip ?? selectedField.tooltip ?? undefined,
|
||||
defaultValue: pending.defaultValue ?? selectedField.value ?? undefined,
|
||||
fontSize: pending.fontSize ?? widget?.fontSize ?? undefined,
|
||||
readOnly: pending.readOnly ?? selectedField.readOnly,
|
||||
multiline: pending.multiline ?? selectedField.multiline,
|
||||
multiSelect: pending.multiSelect ?? selectedField.multiSelect,
|
||||
options: pending.options ?? selectedField.options ?? undefined,
|
||||
};
|
||||
}, [selectedField, modifiedFields]);
|
||||
|
||||
const handlePropertyChange = useCallback((updated: NewFieldDefinition) => {
|
||||
if (!selectedField) return;
|
||||
const props: Record<string, unknown> = {};
|
||||
if (updated.name !== selectedField.name) props.name = updated.name;
|
||||
if (updated.label !== (selectedField.label ?? undefined)) props.label = updated.label ?? '';
|
||||
if (updated.tooltip !== (selectedField.tooltip ?? undefined)) props.tooltip = updated.tooltip ?? '';
|
||||
if (updated.defaultValue !== (selectedField.value ?? undefined)) props.defaultValue = updated.defaultValue ?? '';
|
||||
if (updated.required !== selectedField.required) props.required = updated.required;
|
||||
if (updated.readOnly !== selectedField.readOnly) props.readOnly = updated.readOnly;
|
||||
if (updated.multiline !== selectedField.multiline) props.multiline = updated.multiline;
|
||||
if (updated.multiSelect !== selectedField.multiSelect) props.multiSelect = updated.multiSelect;
|
||||
if (updated.fontSize !== (selectedField.widgets?.[0]?.fontSize ?? undefined)) props.fontSize = updated.fontSize;
|
||||
if (updated.options !== (selectedField.options ?? undefined)) props.options = updated.options;
|
||||
if (updated.type !== selectedField.type) props.type = updated.type;
|
||||
if (Object.keys(props).length > 0) {
|
||||
updateFieldProperties(selectedField.name, props);
|
||||
}
|
||||
}, [selectedField, updateFieldProperties]);
|
||||
|
||||
const handleCoordsChange = useCallback((coords: { x: number; y: number; width: number; height: number }) => {
|
||||
if (!selectedField) return;
|
||||
updateFieldCoordinates(selectedField.name, coords);
|
||||
}, [selectedField, updateFieldCoordinates]);
|
||||
|
||||
// Group fields by page
|
||||
const fieldsByPage = useMemo(() => {
|
||||
const byPage = new Map<number, typeof fields>();
|
||||
for (const field of fields) {
|
||||
const pageIndex = field.widgets?.[0]?.pageIndex ?? 0;
|
||||
if (!byPage.has(pageIndex)) byPage.set(pageIndex, []);
|
||||
byPage.get(pageIndex)!.push(field);
|
||||
}
|
||||
return byPage;
|
||||
}, [fields]);
|
||||
|
||||
const sortedPages = useMemo(
|
||||
() => Array.from(fieldsByPage.keys()).sort((a, b) => a - b),
|
||||
[fieldsByPage]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.header}>
|
||||
<Text size="xs" fw={600}>
|
||||
Click a field below or on the PDF to select it. Drag to move, use handles to resize.
|
||||
</Text>
|
||||
|
||||
{modifiedFields.size > 0 && (
|
||||
<Button
|
||||
leftSection={committing ? <Loader size={14} color="white" /> : <SaveIcon sx={{ fontSize: 14 }} />}
|
||||
size="xs"
|
||||
onClick={handleCommit}
|
||||
loading={committing}
|
||||
fullWidth
|
||||
>
|
||||
Save {modifiedFields.size} Change{modifiedFields.size !== 1 ? 's' : ''}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert icon={<WarningAmberIcon sx={{ fontSize: 16 }} />} color="red" variant="light" p="xs" radius="sm">
|
||||
<Text size="xs">{error}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollArea className={styles.fieldList}>
|
||||
<div className={styles.fieldListInner}>
|
||||
{formState.loading && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '1rem' }}>
|
||||
<Loader size={14} />
|
||||
<Text size="xs" c="dimmed">Loading fields...</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!formState.loading && fields.length === 0 && (
|
||||
<div className={styles.emptyState} style={{ padding: '2rem 1rem' }}>
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
No form fields found. Use Create mode to add fields first.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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 isSelected = editState.selectedFieldName === field.name;
|
||||
const isModified = modifiedFields.has(field.name);
|
||||
const coords = modifiedFields.get(field.name);
|
||||
const widget = field.widgets?.[0];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={field.name}
|
||||
className={`${styles.fieldCard} ${isSelected ? styles.fieldCardActive : ''}`}
|
||||
onClick={() => handleSelectField(field.name)}
|
||||
>
|
||||
<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>
|
||||
{isModified && (
|
||||
<span style={{
|
||||
fontSize: '0.5625rem',
|
||||
padding: '0.0625rem 0.375rem',
|
||||
borderRadius: 'var(--radius-xs)',
|
||||
background: 'var(--mantine-color-yellow-light)',
|
||||
color: 'var(--mantine-color-yellow-light-color)',
|
||||
fontWeight: 800,
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
modified
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isSelected && widget && (
|
||||
<Text size="xs" c="dimmed" style={{ fontSize: '0.625rem' }}>
|
||||
{coords && coords.x != null && coords.y != null && coords.width != null && coords.height != null
|
||||
? `(${Math.round(coords.x)}, ${Math.round(coords.y)}) ${Math.round(coords.width)}×${Math.round(coords.height)} pt`
|
||||
: `(${Math.round(widget.x)}, ${Math.round(widget.y)}) ${Math.round(widget.width)}×${Math.round(widget.height)} pt`
|
||||
}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Inline property editor for the selected field */}
|
||||
{isSelected && selectedFieldEditorData && (
|
||||
<div
|
||||
style={{ borderTop: '1px solid var(--mantine-color-default-border)', marginTop: '0.375rem', paddingTop: '0.375rem' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<FormFieldPropertyEditor
|
||||
field={selectedFieldEditorData}
|
||||
onChange={handlePropertyChange}
|
||||
allowTypeChange
|
||||
showCoordinates
|
||||
onCoordsChange={handleCoordsChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Status bar */}
|
||||
{modifiedFields.size > 0 && (
|
||||
<div className={styles.statusBar}>
|
||||
<span>
|
||||
<span className={styles.unsavedDot} />
|
||||
{modifiedFields.size} unsaved change{modifiedFields.size !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -382,7 +382,7 @@ export function FormFieldOverlay({
|
||||
pageHeight,
|
||||
fileId,
|
||||
}: FormFieldOverlayProps) {
|
||||
const { setValue, setActiveField, fieldsByPage, state, forFileId } = useFormFill();
|
||||
const { setValue, setActiveField, fieldsByPage, state, forFileId, mode } = useFormFill();
|
||||
const { activeFieldName, validationErrors } = state;
|
||||
|
||||
// Get scale from EmbedPDF document state — same pattern as LinkLayer
|
||||
@@ -431,6 +431,9 @@ export function FormFieldOverlay({
|
||||
|
||||
if (pageFields.length === 0) return null;
|
||||
|
||||
// In modify or create mode, the edit/creation overlays handle interactions instead
|
||||
const isEditMode = state.fields.length > 0 && (mode === 'modify' || mode === 'make');
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -444,7 +447,8 @@ export function FormFieldOverlay({
|
||||
}}
|
||||
data-form-overlay-page={pageIndex}
|
||||
>
|
||||
{pageFields.map((field: FormField) =>
|
||||
{/* In edit modes, don't render interactive widgets — the edit/creation overlays handle that */}
|
||||
{!isEditMode && pageFields.map((field: FormField) =>
|
||||
(field.widgets || [])
|
||||
.filter((w: WidgetCoordinates) => w.pageIndex === pageIndex)
|
||||
.map((widget: WidgetCoordinates, widgetIdx: number) => {
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* FormFieldPropertyEditor — Shared property editor for form field definitions.
|
||||
* Used by both FormFieldCreatePanel (create mode) and FormFieldModifyPanel (modify mode).
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
TextInput,
|
||||
NumberInput,
|
||||
Select,
|
||||
Switch,
|
||||
Text,
|
||||
ActionIcon,
|
||||
Button,
|
||||
Stack,
|
||||
SimpleGrid,
|
||||
} from '@mantine/core';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import type { FormFieldType, NewFieldDefinition } from '@app/tools/formFill/types';
|
||||
|
||||
interface FormFieldPropertyEditorProps {
|
||||
field: NewFieldDefinition;
|
||||
onChange: (updated: NewFieldDefinition) => void;
|
||||
/** Whether to allow changing the field type (true in modify mode) */
|
||||
allowTypeChange?: boolean;
|
||||
/** Whether coordinates are read-only display */
|
||||
showCoordinates?: boolean;
|
||||
/** When provided, coordinate section renders editable NumberInputs */
|
||||
onCoordsChange?: (coords: { x: number; y: number; width: number; height: number }) => void;
|
||||
}
|
||||
|
||||
const FIELD_TYPE_OPTIONS: { value: FormFieldType; label: string }[] = [
|
||||
{ value: 'text', label: 'Text Field' },
|
||||
{ value: 'checkbox', label: 'Checkbox' },
|
||||
{ value: 'combobox', label: 'Dropdown' },
|
||||
{ value: 'listbox', label: 'List Box' },
|
||||
];
|
||||
|
||||
export function FormFieldPropertyEditor({
|
||||
field,
|
||||
onChange,
|
||||
allowTypeChange = false,
|
||||
showCoordinates = false,
|
||||
onCoordsChange,
|
||||
}: FormFieldPropertyEditorProps) {
|
||||
const hasOptions = field.type === 'combobox' || field.type === 'listbox';
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<TextInput
|
||||
label="Name"
|
||||
size="xs"
|
||||
value={field.name}
|
||||
onChange={(e) => onChange({ ...field, name: e.currentTarget.value })}
|
||||
placeholder="Field name"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Label"
|
||||
size="xs"
|
||||
value={field.label || ''}
|
||||
onChange={(e) => onChange({ ...field, label: e.currentTarget.value || undefined })}
|
||||
placeholder="Display label"
|
||||
/>
|
||||
|
||||
{allowTypeChange && (
|
||||
<Select
|
||||
label="Type"
|
||||
size="xs"
|
||||
data={FIELD_TYPE_OPTIONS}
|
||||
value={field.type}
|
||||
onChange={(val) => {
|
||||
if (val) onChange({ ...field, type: val as FormFieldType });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
label="Tooltip"
|
||||
size="xs"
|
||||
value={field.tooltip || ''}
|
||||
onChange={(e) => onChange({ ...field, tooltip: e.currentTarget.value || undefined })}
|
||||
placeholder="Help text"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Default Value"
|
||||
size="xs"
|
||||
value={field.defaultValue || ''}
|
||||
onChange={(e) => onChange({ ...field, defaultValue: e.currentTarget.value || undefined })}
|
||||
placeholder="Default value"
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Font Size (pt)"
|
||||
size="xs"
|
||||
value={field.fontSize ?? ''}
|
||||
onChange={(val) => onChange({ ...field, fontSize: typeof val === 'number' ? val : undefined })}
|
||||
placeholder="12 (default)"
|
||||
min={4}
|
||||
max={72}
|
||||
step={1}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label="Required"
|
||||
size="xs"
|
||||
checked={field.required || false}
|
||||
onChange={(e) => onChange({ ...field, required: e.currentTarget.checked })}
|
||||
styles={{ label: { fontSize: '0.75rem', cursor: 'pointer' } }}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label="Read-only"
|
||||
size="xs"
|
||||
checked={field.readOnly || false}
|
||||
onChange={(e) => onChange({ ...field, readOnly: e.currentTarget.checked })}
|
||||
styles={{ label: { fontSize: '0.75rem', cursor: 'pointer' } }}
|
||||
/>
|
||||
|
||||
{field.type === 'text' && (
|
||||
<Switch
|
||||
label="Multiline"
|
||||
size="xs"
|
||||
checked={field.multiline || false}
|
||||
onChange={(e) => onChange({ ...field, multiline: e.currentTarget.checked })}
|
||||
styles={{ label: { fontSize: '0.75rem', cursor: 'pointer' } }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasOptions && (
|
||||
<div>
|
||||
<Text size="xs" fw={500} mb={4}>Options</Text>
|
||||
{(field.options || []).map((opt, idx) => (
|
||||
<div key={idx} style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
|
||||
<TextInput
|
||||
size="xs"
|
||||
value={opt}
|
||||
onChange={(e) => {
|
||||
const newOpts = [...(field.options || [])];
|
||||
newOpts[idx] = e.currentTarget.value;
|
||||
onChange({ ...field, options: newOpts });
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
placeholder={`Option ${idx + 1}`}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newOpts = (field.options || []).filter((_, i) => i !== idx);
|
||||
onChange({ ...field, options: newOpts });
|
||||
}}
|
||||
>
|
||||
<DeleteIcon sx={{ fontSize: 14 }} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
leftSection={<AddIcon sx={{ fontSize: 14 }} />}
|
||||
onClick={() => {
|
||||
onChange({ ...field, options: [...(field.options || []), ''] });
|
||||
}}
|
||||
>
|
||||
Add Option
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{field.type === 'listbox' && (
|
||||
<Switch
|
||||
label="Multi-select"
|
||||
size="xs"
|
||||
checked={field.multiSelect || false}
|
||||
onChange={(e) => onChange({ ...field, multiSelect: e.currentTarget.checked })}
|
||||
styles={{ label: { fontSize: '0.75rem', cursor: 'pointer' } }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showCoordinates && !onCoordsChange && (
|
||||
<div>
|
||||
<Text size="xs" fw={500} c="dimmed" mb={2}>Position</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Page {field.pageIndex + 1} · ({Math.round(field.x)}, {Math.round(field.y)}) · {Math.round(field.width)} × {Math.round(field.height)} pt
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCoordinates && onCoordsChange && (
|
||||
<div>
|
||||
<Text size="xs" fw={500} c="dimmed" mb={4}>Position & Size (PDF points)</Text>
|
||||
<SimpleGrid cols={2} spacing={4}>
|
||||
<NumberInput
|
||||
label="X"
|
||||
size="xs"
|
||||
value={field.x}
|
||||
onChange={(val) => {
|
||||
if (typeof val === 'number') {
|
||||
onCoordsChange({ x: val, y: field.y, width: field.width, height: field.height });
|
||||
}
|
||||
}}
|
||||
step={1}
|
||||
decimalScale={1}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Y"
|
||||
size="xs"
|
||||
value={field.y}
|
||||
onChange={(val) => {
|
||||
if (typeof val === 'number') {
|
||||
onCoordsChange({ x: field.x, y: val, width: field.width, height: field.height });
|
||||
}
|
||||
}}
|
||||
step={1}
|
||||
decimalScale={1}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Width"
|
||||
size="xs"
|
||||
value={field.width}
|
||||
onChange={(val) => {
|
||||
if (typeof val === 'number' && val > 0) {
|
||||
onCoordsChange({ x: field.x, y: field.y, width: val, height: field.height });
|
||||
}
|
||||
}}
|
||||
min={1}
|
||||
step={1}
|
||||
decimalScale={1}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Height"
|
||||
size="xs"
|
||||
value={field.height}
|
||||
onChange={(val) => {
|
||||
if (typeof val === 'number' && val > 0) {
|
||||
onCoordsChange({ x: field.x, y: field.y, width: field.width, height: val });
|
||||
}
|
||||
}}
|
||||
min={1}
|
||||
step={1}
|
||||
decimalScale={1}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Progress,
|
||||
Tooltip,
|
||||
ActionIcon,
|
||||
SegmentedControl,
|
||||
} from '@mantine/core';
|
||||
import { useFormFill, useAllFormValues } from '@app/tools/formFill/FormFillContext';
|
||||
import { useNavigation } from '@app/contexts/NavigationContext';
|
||||
@@ -29,8 +30,11 @@ import { Skeleton } from '@mantine/core';
|
||||
import { isStirlingFile } from '@app/types/fileContext';
|
||||
import type { BaseToolProps } from '@app/types/tool';
|
||||
import type { FormField } from '@app/tools/formFill/types';
|
||||
import type { FormMode } from '@app/tools/formFill/types';
|
||||
import { FieldInput } from '@app/tools/formFill/FieldInput';
|
||||
import { FIELD_TYPE_ICON, FIELD_TYPE_COLOR } from '@app/tools/formFill/fieldMeta';
|
||||
import { FormFieldCreatePanel } from '@app/tools/formFill/FormFieldCreatePanel';
|
||||
import { FormFieldModifyPanel } from '@app/tools/formFill/FormFieldModifyPanel';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
||||
@@ -46,8 +50,6 @@ import styles from '@app/tools/formFill/FormFill.module.css';
|
||||
// Mode tabs — extensible for future form tools
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type FormMode = 'fill' | 'make' | 'batch' | 'modify';
|
||||
|
||||
interface ModeTabDef {
|
||||
id: FormMode;
|
||||
label: string;
|
||||
@@ -55,11 +57,11 @@ interface ModeTabDef {
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
const _MODE_TABS: ModeTabDef[] = [
|
||||
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: 'make', label: 'Create', icon: <PostAddIcon className={styles.modeTabIcon} />, ready: true },
|
||||
{ id: 'batch', label: 'Batch', icon: <FileCopyIcon className={styles.modeTabIcon} />, ready: false },
|
||||
{ id: 'modify', label: 'Modify', icon: <BuildCircleIcon className={styles.modeTabIcon} />, ready: false },
|
||||
{ id: 'modify', label: 'Modify', icon: <BuildCircleIcon className={styles.modeTabIcon} />, ready: true },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -94,6 +96,8 @@ const FormFill = (_props: BaseToolProps) => {
|
||||
setValue,
|
||||
setActiveField,
|
||||
validateForm,
|
||||
mode,
|
||||
setMode,
|
||||
} = useFormFill();
|
||||
|
||||
const allValues = useAllFormValues();
|
||||
@@ -101,11 +105,6 @@ const FormFill = (_props: BaseToolProps) => {
|
||||
|
||||
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);
|
||||
@@ -297,16 +296,14 @@ const FormFill = (_props: BaseToolProps) => {
|
||||
|
||||
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) ----
|
||||
{/* ---- Mode selection ---- */}
|
||||
<div className={styles.modeTabs}>
|
||||
<SegmentedControl
|
||||
value={mode}
|
||||
onChange={(val) => setMode(val as FormMode)}
|
||||
data={MODE_TABS.map((tab) => ({
|
||||
data={MODE_TABS.filter((tab) => tab.ready).map((tab) => ({
|
||||
value: tab.id,
|
||||
label: (
|
||||
<div className={styles.segmentedLabel}>
|
||||
@@ -326,10 +323,12 @@ const FormFill = (_props: BaseToolProps) => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
---- */}
|
||||
|
||||
{/* ---- Coming-soon for non-ready tabs (hidden while mode tabs are disabled) ---- */}
|
||||
{/* !currentModeDef.ready && <ComingSoonPlaceholder mode={currentModeDef} /> */}
|
||||
{/* ---- Create mode ---- */}
|
||||
{mode === 'make' && <FormFieldCreatePanel />}
|
||||
|
||||
{/* ---- Modify mode ---- */}
|
||||
{mode === 'modify' && <FormFieldModifyPanel />}
|
||||
|
||||
{/* ---- Fill Form content ---- */}
|
||||
{mode === 'fill' && (
|
||||
|
||||
@@ -30,7 +30,16 @@ import React, {
|
||||
useSyncExternalStore,
|
||||
} from 'react';
|
||||
import { useDebouncedCallback } from '@mantine/hooks';
|
||||
import type { FormField, FormFillState, WidgetCoordinates } from '@app/tools/formFill/types';
|
||||
import type {
|
||||
FormField,
|
||||
FormFillState,
|
||||
FormMode,
|
||||
WidgetCoordinates,
|
||||
NewFieldDefinition,
|
||||
ModifyFieldDefinition,
|
||||
FieldCreationState,
|
||||
FieldEditState,
|
||||
} from '@app/tools/formFill/types';
|
||||
import type { IFormDataProvider } from '@app/tools/formFill/providers/types';
|
||||
import { PdfLibFormProvider } from '@app/tools/formFill/providers/PdfLibFormProvider';
|
||||
import { PdfBoxFormProvider } from '@app/tools/formFill/providers/PdfBoxFormProvider';
|
||||
@@ -203,6 +212,44 @@ export interface FormFillContextValue {
|
||||
setProviderMode: (mode: 'pdflib' | 'pdfbox') => void;
|
||||
/** The file ID that the current form fields belong to (null if no fields loaded) */
|
||||
forFileId: string | null;
|
||||
|
||||
// --- Mode management (Create / Modify) ---
|
||||
/** Current active mode */
|
||||
mode: FormMode;
|
||||
/** Switch the active mode */
|
||||
setMode: (mode: FormMode) => void;
|
||||
|
||||
// --- Create mode state ---
|
||||
/** Creation state (pending fields, placing type, drag rect) */
|
||||
creationState: FieldCreationState;
|
||||
/** Set the field type being placed (null to cancel placement) */
|
||||
setPlacingFieldType: (type: import('@app/tools/formFill/types').FormFieldType | null) => void;
|
||||
/** Add a pending field to the creation queue */
|
||||
addPendingField: (field: NewFieldDefinition) => void;
|
||||
/** Remove a pending field by index */
|
||||
removePendingField: (index: number) => void;
|
||||
/** Update a pending field by index */
|
||||
updatePendingField: (index: number, field: NewFieldDefinition) => void;
|
||||
/** Set the drag rectangle during creation */
|
||||
setCreationDragRect: (rect: FieldCreationState['dragRect']) => void;
|
||||
/** Commit all pending fields to the backend and refresh */
|
||||
commitNewFields: (file: File | Blob) => Promise<Blob>;
|
||||
|
||||
// --- Modify mode state ---
|
||||
/** Edit state (selected field, interaction, pending rect) */
|
||||
editState: FieldEditState;
|
||||
/** Update the edit state */
|
||||
setEditState: (state: Partial<FieldEditState>) => void;
|
||||
/** Pending field modifications keyed by field name (coordinates + properties) */
|
||||
modifiedFields: Map<string, Partial<ModifyFieldDefinition>>;
|
||||
/** Record a coordinate change for a field */
|
||||
updateFieldCoordinates: (fieldName: string, coords: { x: number; y: number; width: number; height: number }) => void;
|
||||
/** Record a property change for a field (fontSize, readOnly, multiline, etc.) */
|
||||
updateFieldProperties: (fieldName: string, props: Partial<ModifyFieldDefinition>) => void;
|
||||
/** Commit all field modifications to the backend and refresh */
|
||||
commitFieldModifications: (file: File | Blob) => Promise<Blob>;
|
||||
/** Whether there are uncommitted changes in create or modify mode */
|
||||
hasUncommittedChanges: boolean;
|
||||
}
|
||||
|
||||
const FormFillContext = createContext<FormFillContextValue | null>(null);
|
||||
@@ -303,6 +350,118 @@ export function FormFillProvider({
|
||||
// This prevents full context re-renders on every keystroke.
|
||||
const [valuesStore] = useState(() => new FormValuesStore());
|
||||
|
||||
// --- Mode / Create / Modify state ---
|
||||
const [formMode, setFormMode] = useState<FormMode>('fill');
|
||||
const [creationState, setCreationState] = useState<FieldCreationState>({
|
||||
pendingFields: [],
|
||||
placingFieldType: null,
|
||||
dragRect: null,
|
||||
});
|
||||
const [editState, setEditStateRaw] = useState<FieldEditState>({
|
||||
selectedFieldName: null,
|
||||
interaction: 'idle',
|
||||
pendingRect: null,
|
||||
});
|
||||
const [modifiedFields, setModifiedFields] = useState<Map<string, Partial<ModifyFieldDefinition>>>(
|
||||
() => new Map()
|
||||
);
|
||||
|
||||
const setMode = useCallback((mode: FormMode) => {
|
||||
setFormMode(mode);
|
||||
// Reset mode-specific state when switching
|
||||
setCreationState({ pendingFields: [], placingFieldType: null, dragRect: null });
|
||||
setEditStateRaw({ selectedFieldName: null, interaction: 'idle', pendingRect: null });
|
||||
setModifiedFields(new Map());
|
||||
}, []);
|
||||
|
||||
const setPlacingFieldType = useCallback((type: import('@app/tools/formFill/types').FormFieldType | null) => {
|
||||
setCreationState(prev => ({ ...prev, placingFieldType: type, dragRect: null }));
|
||||
}, []);
|
||||
|
||||
const addPendingField = useCallback((field: NewFieldDefinition) => {
|
||||
setCreationState(prev => ({
|
||||
...prev,
|
||||
pendingFields: [...prev.pendingFields, field],
|
||||
placingFieldType: null,
|
||||
dragRect: null,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const removePendingField = useCallback((index: number) => {
|
||||
setCreationState(prev => ({
|
||||
...prev,
|
||||
pendingFields: prev.pendingFields.filter((_, i) => i !== index),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const updatePendingField = useCallback((index: number, field: NewFieldDefinition) => {
|
||||
setCreationState(prev => ({
|
||||
...prev,
|
||||
pendingFields: prev.pendingFields.map((f, i) => i === index ? field : f),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setCreationDragRect = useCallback((rect: FieldCreationState['dragRect']) => {
|
||||
setCreationState(prev => ({ ...prev, dragRect: rect }));
|
||||
}, []);
|
||||
|
||||
const commitNewFields = useCallback(async (file: File | Blob): Promise<Blob> => {
|
||||
const provider = providerRef.current;
|
||||
if (!provider.addFields) {
|
||||
throw new Error('Current provider does not support adding fields');
|
||||
}
|
||||
const blob = await provider.addFields(file, creationState.pendingFields);
|
||||
// Clear pending fields after successful commit
|
||||
setCreationState(prev => ({ ...prev, pendingFields: [] }));
|
||||
return blob;
|
||||
}, [creationState.pendingFields]);
|
||||
|
||||
const setEditState = useCallback((partial: Partial<FieldEditState>) => {
|
||||
setEditStateRaw(prev => ({ ...prev, ...partial }));
|
||||
}, []);
|
||||
|
||||
const updateFieldCoordinates = useCallback((fieldName: string, coords: { x: number; y: number; width: number; height: number }) => {
|
||||
setModifiedFields(prev => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(fieldName) || {};
|
||||
next.set(fieldName, { ...existing, x: coords.x, y: coords.y, width: coords.width, height: coords.height });
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateFieldProperties = useCallback((fieldName: string, props: Partial<ModifyFieldDefinition>) => {
|
||||
setModifiedFields(prev => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(fieldName) || {};
|
||||
next.set(fieldName, { ...existing, ...props });
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const commitFieldModifications = useCallback(async (file: File | Blob): Promise<Blob> => {
|
||||
const provider = providerRef.current;
|
||||
if (!provider.modifyFields) {
|
||||
throw new Error('Current provider does not support modifying fields');
|
||||
}
|
||||
const updates: ModifyFieldDefinition[] = [];
|
||||
for (const [fieldName, partial] of modifiedFields) {
|
||||
updates.push({
|
||||
targetName: fieldName,
|
||||
...partial,
|
||||
});
|
||||
}
|
||||
if (updates.length === 0) {
|
||||
throw new Error('No field modifications to commit');
|
||||
}
|
||||
const blob = await provider.modifyFields(file, updates);
|
||||
// Clear modifications after successful commit
|
||||
setModifiedFields(new Map());
|
||||
setEditStateRaw({ selectedFieldName: null, interaction: 'idle', pendingRect: null });
|
||||
return blob;
|
||||
}, [modifiedFields]);
|
||||
|
||||
const hasUncommittedChanges = creationState.pendingFields.length > 0 || modifiedFields.size > 0;
|
||||
|
||||
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
|
||||
@@ -476,6 +635,25 @@ export function FormFillProvider({
|
||||
activeProviderName: providerRef.current.name,
|
||||
setProviderMode,
|
||||
forFileId,
|
||||
// Mode management
|
||||
mode: formMode,
|
||||
setMode,
|
||||
// Create mode
|
||||
creationState,
|
||||
setPlacingFieldType,
|
||||
addPendingField,
|
||||
removePendingField,
|
||||
updatePendingField,
|
||||
setCreationDragRect,
|
||||
commitNewFields,
|
||||
// Modify mode
|
||||
editState,
|
||||
setEditState,
|
||||
modifiedFields,
|
||||
updateFieldCoordinates,
|
||||
updateFieldProperties,
|
||||
commitFieldModifications,
|
||||
hasUncommittedChanges,
|
||||
}),
|
||||
[
|
||||
state,
|
||||
@@ -492,6 +670,22 @@ export function FormFillProvider({
|
||||
providerMode,
|
||||
setProviderMode,
|
||||
forFileId,
|
||||
formMode,
|
||||
setMode,
|
||||
creationState,
|
||||
setPlacingFieldType,
|
||||
addPendingField,
|
||||
removePendingField,
|
||||
updatePendingField,
|
||||
setCreationDragRect,
|
||||
commitNewFields,
|
||||
editState,
|
||||
setEditState,
|
||||
modifiedFields,
|
||||
updateFieldCoordinates,
|
||||
updateFieldProperties,
|
||||
commitFieldModifications,
|
||||
hasUncommittedChanges,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* API service for form-related backend calls.
|
||||
*/
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import type { FormField } from '@app/tools/formFill/types';
|
||||
import type { FormField, NewFieldDefinition, ModifyFieldDefinition } from '@app/tools/formFill/types';
|
||||
|
||||
/**
|
||||
* Fetch form fields with coordinates from the backend.
|
||||
@@ -44,3 +44,45 @@ export async function fillFormFields(
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add new form fields to a PDF.
|
||||
* Calls POST /api/v1/form/add-fields
|
||||
*/
|
||||
export async function addFormFields(
|
||||
file: File | Blob,
|
||||
fields: NewFieldDefinition[]
|
||||
): Promise<Blob> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append(
|
||||
'fields',
|
||||
new Blob([JSON.stringify(fields)], { type: 'application/json' })
|
||||
);
|
||||
|
||||
const response = await apiClient.post('/api/v1/form/add-fields', formData, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modify existing form fields in a PDF (properties and/or coordinates).
|
||||
* Calls POST /api/v1/form/modify-fields
|
||||
*/
|
||||
export async function modifyFormFields(
|
||||
file: File | Blob,
|
||||
updates: ModifyFieldDefinition[]
|
||||
): Promise<Blob> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append(
|
||||
'updates',
|
||||
new Blob([JSON.stringify(updates)], { type: 'application/json' })
|
||||
);
|
||||
|
||||
const response = await apiClient.post('/api/v1/form/modify-fields', formData, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Coordinate conversion utilities for form field creation/editing.
|
||||
*
|
||||
* The FormFieldOverlay renders widgets in CSS space (top-left origin, y goes down).
|
||||
* The backend NewFormFieldDefinition expects PDF space (bottom-left origin, y goes up).
|
||||
*
|
||||
* Both coordinate systems use PDF points as units. The CSS overlay multiplies by
|
||||
* scaleX/scaleY to get pixel positions; these utilities work in the pre-scaled
|
||||
* PDF-point space.
|
||||
*/
|
||||
|
||||
export interface CssRect {
|
||||
x: number; // left, in PDF points (CSS TL origin)
|
||||
y: number; // top, in PDF points (CSS TL origin)
|
||||
width: number; // PDF points
|
||||
height: number; // PDF points
|
||||
}
|
||||
|
||||
export interface PdfRect {
|
||||
x: number; // lower-left X in PDF points (PDF BL origin)
|
||||
y: number; // lower-left Y in PDF points (PDF BL origin)
|
||||
width: number; // PDF points
|
||||
height: number; // PDF points
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert CSS overlay coordinates (top-left origin) to PDF coordinates (bottom-left origin).
|
||||
*
|
||||
* @param css - Rectangle in CSS space (y = distance from top)
|
||||
* @param pageHeightPts - The un-rotated page height in PDF points
|
||||
*/
|
||||
export function cssToPdfRect(css: CssRect, pageHeightPts: number): PdfRect {
|
||||
// In CSS: y is distance from top edge
|
||||
// In PDF: y is distance from bottom edge
|
||||
// PDF lower-left Y = pageHeight - (css.y + css.height)
|
||||
return {
|
||||
x: css.x,
|
||||
y: pageHeightPts - (css.y + css.height),
|
||||
width: css.width,
|
||||
height: css.height,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert PDF coordinates (bottom-left origin) to CSS overlay coordinates (top-left origin).
|
||||
*
|
||||
* @param pdf - Rectangle in PDF space (y = distance from bottom)
|
||||
* @param pageHeightPts - The un-rotated page height in PDF points
|
||||
*/
|
||||
export function pdfToCssRect(pdf: PdfRect, pageHeightPts: number): CssRect {
|
||||
return {
|
||||
x: pdf.x,
|
||||
y: pageHeightPts - pdf.y - pdf.height,
|
||||
width: pdf.width,
|
||||
height: pdf.height,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert pixel coordinates from a pointer event to PDF-point coordinates,
|
||||
* given the container dimensions and the page's PDF-point dimensions.
|
||||
*/
|
||||
export function pixelsToPdfPoints(
|
||||
pixelX: number,
|
||||
pixelY: number,
|
||||
containerWidth: number,
|
||||
containerHeight: number,
|
||||
pageWidthPts: number,
|
||||
pageHeightPts: number,
|
||||
): { x: number; y: number } {
|
||||
return {
|
||||
x: (pixelX / containerWidth) * pageWidthPts,
|
||||
y: (pixelY / containerHeight) * pageHeightPts,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Snap-to-edge utilities for form field alignment.
|
||||
*
|
||||
* During drag (move/resize/create), candidate edges are compared to edges of
|
||||
* all OTHER fields on the same page. If a candidate is within SNAP_THRESHOLD_PX
|
||||
* pixels of a target edge, it snaps to that edge.
|
||||
*/
|
||||
import type { FormField, ModifyFieldDefinition } from '@app/tools/formFill/types';
|
||||
import { pdfToCssRect } from '@app/tools/formFill/formCoordinateUtils';
|
||||
|
||||
const SNAP_THRESHOLD_PX = 6;
|
||||
|
||||
export type SnapAxis = 'x' | 'y';
|
||||
|
||||
export interface SnapResult {
|
||||
value: number;
|
||||
didSnap: boolean;
|
||||
}
|
||||
|
||||
export interface SnapGuide {
|
||||
axis: SnapAxis;
|
||||
/** Position in pixels along the snapped axis */
|
||||
position: number;
|
||||
}
|
||||
|
||||
interface FieldEdges {
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect pixel-space edges for all fields on the given page, excluding a
|
||||
* specific field (the one being dragged).
|
||||
*/
|
||||
export function collectSnapTargets(
|
||||
allFields: FormField[],
|
||||
excludeFieldName: string | null,
|
||||
pageIndex: number,
|
||||
scaleX: number,
|
||||
scaleY: number,
|
||||
pageHeightPts: number,
|
||||
modifiedFields: Map<string, Partial<ModifyFieldDefinition>>,
|
||||
): FieldEdges[] {
|
||||
const edges: FieldEdges[] = [];
|
||||
|
||||
for (const field of allFields) {
|
||||
if (field.name === excludeFieldName) continue;
|
||||
const widgets = (field.widgets || []).filter(w => w.pageIndex === pageIndex);
|
||||
if (widgets.length === 0) continue;
|
||||
const widget = widgets[0];
|
||||
|
||||
const modified = modifiedFields.get(field.name);
|
||||
let left: number, top: number, width: number, height: number;
|
||||
|
||||
if (modified && modified.x != null && modified.y != null && modified.width != null && modified.height != null) {
|
||||
const css = pdfToCssRect(
|
||||
{ x: modified.x, y: modified.y, width: modified.width, height: modified.height },
|
||||
pageHeightPts
|
||||
);
|
||||
left = css.x * scaleX;
|
||||
top = css.y * scaleY;
|
||||
width = css.width * scaleX;
|
||||
height = css.height * scaleY;
|
||||
} else {
|
||||
left = widget.x * scaleX;
|
||||
top = widget.y * scaleY;
|
||||
width = widget.width * scaleX;
|
||||
height = widget.height * scaleY;
|
||||
}
|
||||
|
||||
edges.push({
|
||||
left,
|
||||
right: left + width,
|
||||
top,
|
||||
bottom: top + height,
|
||||
centerX: left + width / 2,
|
||||
centerY: top + height / 2,
|
||||
});
|
||||
}
|
||||
|
||||
return edges;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given candidate edges for pending fields (not yet part of allFields),
|
||||
* add their pixel-space edges to the targets array.
|
||||
*/
|
||||
export function collectPendingFieldSnapTargets(
|
||||
pendingFields: { pageIndex: number; x: number; y: number; width: number; height: number }[],
|
||||
pageIndex: number,
|
||||
scaleX: number,
|
||||
scaleY: number,
|
||||
pageHeightPts: number,
|
||||
): FieldEdges[] {
|
||||
const edges: FieldEdges[] = [];
|
||||
|
||||
for (const field of pendingFields) {
|
||||
if (field.pageIndex !== pageIndex) continue;
|
||||
// Pending fields are in PDF BL origin
|
||||
const cssY = pageHeightPts - field.y - field.height;
|
||||
const left = field.x * scaleX;
|
||||
const top = cssY * scaleY;
|
||||
const width = field.width * scaleX;
|
||||
const height = field.height * scaleY;
|
||||
|
||||
edges.push({
|
||||
left,
|
||||
right: left + width,
|
||||
top,
|
||||
bottom: top + height,
|
||||
centerX: left + width / 2,
|
||||
centerY: top + height / 2,
|
||||
});
|
||||
}
|
||||
|
||||
return edges;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to snap a single value to the nearest target within threshold.
|
||||
*/
|
||||
export function applySnap(value: number, targets: number[], threshold = SNAP_THRESHOLD_PX): SnapResult {
|
||||
let best = value;
|
||||
let bestDist = threshold + 1;
|
||||
|
||||
for (const t of targets) {
|
||||
const dist = Math.abs(value - t);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
best = t;
|
||||
}
|
||||
}
|
||||
|
||||
return bestDist <= threshold
|
||||
? { value: best, didSnap: true }
|
||||
: { value, didSnap: false };
|
||||
}
|
||||
|
||||
function collectEdgeTargets(targets: FieldEdges[]): { x: number[]; y: number[] } {
|
||||
const x: number[] = [];
|
||||
const y: number[] = [];
|
||||
for (const t of targets) {
|
||||
x.push(t.left, t.right, t.centerX);
|
||||
y.push(t.top, t.bottom, t.centerY);
|
||||
}
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap a rectangle during **move** — shifts position, keeps dimensions fixed.
|
||||
* Snaps whichever edge/center is closest to a target.
|
||||
*/
|
||||
export function snapRect(
|
||||
left: number,
|
||||
top: number,
|
||||
width: number,
|
||||
height: number,
|
||||
targets: FieldEdges[],
|
||||
threshold = SNAP_THRESHOLD_PX,
|
||||
): {
|
||||
left: number;
|
||||
top: number;
|
||||
guides: SnapGuide[];
|
||||
} {
|
||||
const { x: xTargets, y: yTargets } = collectEdgeTargets(targets);
|
||||
const guides: SnapGuide[] = [];
|
||||
|
||||
// --- X axis: pick best of left / right / centerX ---
|
||||
const sL = applySnap(left, xTargets, threshold);
|
||||
const sR = applySnap(left + width, xTargets, threshold);
|
||||
const sCx = applySnap(left + width / 2, xTargets, threshold);
|
||||
|
||||
const xCandidates = [
|
||||
{ offset: sL.value - left, didSnap: sL.didSnap, dist: Math.abs(sL.value - left), pos: sL.value },
|
||||
{ offset: sR.value - (left + width), didSnap: sR.didSnap, dist: Math.abs(sR.value - (left + width)), pos: sR.value },
|
||||
{ offset: sCx.value - (left + width / 2), didSnap: sCx.didSnap, dist: Math.abs(sCx.value - (left + width / 2)), pos: sCx.value },
|
||||
].filter(c => c.didSnap);
|
||||
|
||||
let newLeft = left;
|
||||
if (xCandidates.length > 0) {
|
||||
xCandidates.sort((a, b) => a.dist - b.dist);
|
||||
newLeft = left + xCandidates[0].offset;
|
||||
guides.push({ axis: 'x', position: xCandidates[0].pos });
|
||||
}
|
||||
|
||||
// --- Y axis: pick best of top / bottom / centerY ---
|
||||
const sT = applySnap(top, yTargets, threshold);
|
||||
const sB = applySnap(top + height, yTargets, threshold);
|
||||
const sCy = applySnap(top + height / 2, yTargets, threshold);
|
||||
|
||||
const yCandidates = [
|
||||
{ offset: sT.value - top, didSnap: sT.didSnap, dist: Math.abs(sT.value - top), pos: sT.value },
|
||||
{ offset: sB.value - (top + height), didSnap: sB.didSnap, dist: Math.abs(sB.value - (top + height)), pos: sB.value },
|
||||
{ offset: sCy.value - (top + height / 2), didSnap: sCy.didSnap, dist: Math.abs(sCy.value - (top + height / 2)), pos: sCy.value },
|
||||
].filter(c => c.didSnap);
|
||||
|
||||
let newTop = top;
|
||||
if (yCandidates.length > 0) {
|
||||
yCandidates.sort((a, b) => a.dist - b.dist);
|
||||
newTop = top + yCandidates[0].offset;
|
||||
guides.push({ axis: 'y', position: yCandidates[0].pos });
|
||||
}
|
||||
|
||||
return { left: newLeft, top: newTop, guides };
|
||||
}
|
||||
|
||||
/** Which edges are free to move for a given resize handle. */
|
||||
export type ResizeEdges = {
|
||||
left?: boolean;
|
||||
right?: boolean;
|
||||
top?: boolean;
|
||||
bottom?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Snap a rectangle during **resize** — only the edges being dragged move;
|
||||
* the opposite edges stay fixed (dimensions change instead of position).
|
||||
*/
|
||||
export function snapRectResize(
|
||||
left: number,
|
||||
top: number,
|
||||
width: number,
|
||||
height: number,
|
||||
edges: ResizeEdges,
|
||||
targets: FieldEdges[],
|
||||
threshold = SNAP_THRESHOLD_PX,
|
||||
): {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
guides: SnapGuide[];
|
||||
} {
|
||||
const { x: xTargets, y: yTargets } = collectEdgeTargets(targets);
|
||||
const guides: SnapGuide[] = [];
|
||||
|
||||
let newLeft = left;
|
||||
let newWidth = width;
|
||||
let newTop = top;
|
||||
let newHeight = height;
|
||||
|
||||
// --- X axis ---
|
||||
if (edges.left) {
|
||||
const s = applySnap(left, xTargets, threshold);
|
||||
if (s.didSnap) {
|
||||
const delta = s.value - left;
|
||||
newLeft = s.value;
|
||||
newWidth = width - delta;
|
||||
guides.push({ axis: 'x', position: s.value });
|
||||
}
|
||||
}
|
||||
if (edges.right) {
|
||||
const right = left + width;
|
||||
const s = applySnap(right, xTargets, threshold);
|
||||
if (s.didSnap) {
|
||||
newWidth = s.value - newLeft;
|
||||
guides.push({ axis: 'x', position: s.value });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Y axis ---
|
||||
if (edges.top) {
|
||||
const s = applySnap(top, yTargets, threshold);
|
||||
if (s.didSnap) {
|
||||
const delta = s.value - top;
|
||||
newTop = s.value;
|
||||
newHeight = height - delta;
|
||||
guides.push({ axis: 'y', position: s.value });
|
||||
}
|
||||
}
|
||||
if (edges.bottom) {
|
||||
const bottom = top + height;
|
||||
const s = applySnap(bottom, yTargets, threshold);
|
||||
if (s.didSnap) {
|
||||
newHeight = s.value - newTop;
|
||||
guides.push({ axis: 'y', position: s.value });
|
||||
}
|
||||
}
|
||||
|
||||
return { left: newLeft, top: newTop, width: newWidth, height: newHeight, guides };
|
||||
}
|
||||
@@ -8,11 +8,13 @@
|
||||
*
|
||||
* Used in the dedicated formFill tool mode.
|
||||
*/
|
||||
import type { FormField } from '@app/tools/formFill/types';
|
||||
import type { FormField, NewFieldDefinition, ModifyFieldDefinition } from '@app/tools/formFill/types';
|
||||
import type { IFormDataProvider } from '@app/tools/formFill/providers/types';
|
||||
import {
|
||||
fetchFormFieldsWithCoordinates,
|
||||
fillFormFields,
|
||||
addFormFields,
|
||||
modifyFormFields,
|
||||
} from '@app/tools/formFill/formApi';
|
||||
|
||||
export class PdfBoxFormProvider implements IFormDataProvider {
|
||||
@@ -29,4 +31,18 @@ export class PdfBoxFormProvider implements IFormDataProvider {
|
||||
): Promise<Blob> {
|
||||
return fillFormFields(file, values, flatten);
|
||||
}
|
||||
|
||||
async addFields(
|
||||
file: File | Blob,
|
||||
fields: NewFieldDefinition[],
|
||||
): Promise<Blob> {
|
||||
return addFormFields(file, fields);
|
||||
}
|
||||
|
||||
async modifyFields(
|
||||
file: File | Blob,
|
||||
updates: ModifyFieldDefinition[],
|
||||
): Promise<Blob> {
|
||||
return modifyFormFields(file, updates);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* The UI components (FormFieldOverlay, FormFill, FormFieldSidebar) consume
|
||||
* data through FormFillContext, which delegates to whichever provider is active.
|
||||
*/
|
||||
import type { FormField } from '@app/tools/formFill/types';
|
||||
import type { FormField, NewFieldDefinition, ModifyFieldDefinition } from '@app/tools/formFill/types';
|
||||
|
||||
export interface IFormDataProvider {
|
||||
/** Unique identifier for the provider (for debugging/logging) */
|
||||
@@ -34,4 +34,22 @@ export interface IFormDataProvider {
|
||||
values: Record<string, string>,
|
||||
flatten: boolean,
|
||||
): Promise<Blob>;
|
||||
|
||||
/**
|
||||
* Add new form fields to a PDF.
|
||||
* Optional — only supported by backend providers.
|
||||
*/
|
||||
addFields?(
|
||||
file: File | Blob,
|
||||
fields: NewFieldDefinition[],
|
||||
): Promise<Blob>;
|
||||
|
||||
/**
|
||||
* Modify existing form fields (properties and/or coordinates).
|
||||
* Optional — only supported by backend providers.
|
||||
*/
|
||||
modifyFields?(
|
||||
file: File | Blob,
|
||||
updates: ModifyFieldDefinition[],
|
||||
): Promise<Blob>;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface WidgetCoordinates {
|
||||
exportValue?: string;
|
||||
/** Font size in PDF points */
|
||||
fontSize?: number;
|
||||
/** CropBox height in PDF points (used for Y-flip) */
|
||||
cropBoxHeight?: number;
|
||||
}
|
||||
|
||||
export interface FormField {
|
||||
@@ -41,6 +43,69 @@ export type FormFieldType =
|
||||
| 'button'
|
||||
| 'signature';
|
||||
|
||||
/** Mirrors backend NewFormFieldDefinition — used when creating fields */
|
||||
export interface NewFieldDefinition {
|
||||
name: string;
|
||||
label?: string;
|
||||
type: FormFieldType;
|
||||
pageIndex: number;
|
||||
x: number; // PDF lower-left origin
|
||||
y: number; // PDF lower-left origin
|
||||
width: number;
|
||||
height: number;
|
||||
required?: boolean;
|
||||
multiSelect?: boolean;
|
||||
options?: string[];
|
||||
defaultValue?: string;
|
||||
tooltip?: string;
|
||||
fontSize?: number;
|
||||
readOnly?: boolean;
|
||||
multiline?: boolean;
|
||||
}
|
||||
|
||||
/** Mirrors backend ModifyFormFieldDefinition — used when editing existing fields */
|
||||
export interface ModifyFieldDefinition {
|
||||
targetName: string;
|
||||
name?: string;
|
||||
label?: string;
|
||||
type?: string;
|
||||
pageIndex?: number;
|
||||
x?: number; // PDF lower-left origin
|
||||
y?: number; // PDF lower-left origin
|
||||
width?: number;
|
||||
height?: number;
|
||||
required?: boolean;
|
||||
multiSelect?: boolean;
|
||||
options?: string[];
|
||||
defaultValue?: string;
|
||||
tooltip?: string;
|
||||
fontSize?: number;
|
||||
readOnly?: boolean;
|
||||
multiline?: boolean;
|
||||
}
|
||||
|
||||
export type FormMode = 'fill' | 'make' | 'batch' | 'modify';
|
||||
|
||||
/** Tracks the state of field creation (Create mode) */
|
||||
export interface FieldCreationState {
|
||||
/** Queue of fields pending backend commit */
|
||||
pendingFields: NewFieldDefinition[];
|
||||
/** The field type currently selected for placement (null = no placement active) */
|
||||
placingFieldType: FormFieldType | null;
|
||||
/** Rectangle being drawn (CSS coords, only non-null during drag) */
|
||||
dragRect: { x: number; y: number; width: number; height: number; pageIndex: number } | null;
|
||||
}
|
||||
|
||||
/** Tracks the state of field editing (Modify mode) */
|
||||
export interface FieldEditState {
|
||||
/** Name of the currently selected field */
|
||||
selectedFieldName: string | null;
|
||||
/** Interaction in progress */
|
||||
interaction: 'idle' | 'moving' | 'resizing';
|
||||
/** The pending rectangle during drag (CSS coords) — null when idle */
|
||||
pendingRect: { x: number; y: number; width: number; height: number } | null;
|
||||
}
|
||||
|
||||
export interface FormFillState {
|
||||
/** Fields fetched from backend with coordinates */
|
||||
fields: FormField[];
|
||||
|
||||
Reference in New Issue
Block a user