Move Forms location (#5769)

# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
This commit is contained in:
Anthony Stirling
2026-02-20 22:35:35 +00:00
committed by GitHub
parent 46d511b8f6
commit 83169ed0f4
30 changed files with 1362 additions and 1511 deletions
@@ -39,6 +39,7 @@ import AutoRename from "@app/tools/AutoRename";
import SingleLargePage from "@app/tools/SingleLargePage";
import PageLayout from "@app/tools/PageLayout";
import UnlockPdfForms from "@app/tools/UnlockPdfForms";
import FormFill from "@app/tools/formFill/FormFill";
import RemoveCertificateSign from "@app/tools/RemoveCertificateSign";
import RemoveImage from "@app/tools/RemoveImage";
import CertSign from "@app/tools/CertSign";
@@ -346,6 +347,19 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
synonyms: getSynonyms(t, "unlockPDFForms"),
automationSettings: null
},
formFill: {
icon: <LocalIcon icon="text-fields-rounded" width="1.5rem" height="1.5rem" />,
name: t('home.formFill.title', 'Fill Form'),
component: FormFill,
description: t('home.formFill.desc', 'Fill PDF form fields interactively with a visual editor'),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
workbench: 'viewer' as const,
endpoints: ['form-fill'],
automationSettings: null,
supportsAutomate: false,
synonyms: ['form', 'fill', 'fillable', 'input', 'field', 'acroform'],
},
changePermissions: {
icon: <LocalIcon icon="lock-outline" width="1.5rem" height="1.5rem" />,
name: t("home.changePermissions.title", "Change Permissions"),
@@ -0,0 +1,204 @@
/**
* FieldInput: Shared, self-subscribing form field input widget.
*
* Used by both FormFill (left panel) and FormFieldSidebar (right panel).
* Each instance subscribes to its own field value via useFieldValue(),
* so only the active widget re-renders when its value changes.
*/
import React, { useCallback, memo } from 'react';
import {
TextInput,
Textarea,
Checkbox,
Radio,
Select,
MultiSelect,
Stack,
} from '@mantine/core';
import { useFieldValue } from '@app/tools/formFill/FormFillContext';
import type { FormField } from '@app/tools/formFill/types';
function FieldInputInner({
field,
value,
onValueChange,
}: {
field: FormField;
value: string;
onValueChange: (fieldName: string, value: string) => void;
}) {
const onChange = useCallback(
(v: string) => onValueChange(field.name, v),
[onValueChange, field.name]
);
switch (field.type) {
case 'text':
if (field.multiline) {
return (
<Textarea
size="xs"
value={value}
onChange={(e) => onChange(e.currentTarget.value)}
placeholder={field.tooltip || `Enter ${field.label}`}
disabled={field.readOnly}
autosize
minRows={2}
maxRows={5}
styles={{ input: { fontSize: '0.8125rem' } }}
/>
);
}
return (
<TextInput
size="xs"
value={value}
onChange={(e) => onChange(e.currentTarget.value)}
placeholder={field.tooltip || `Enter ${field.label}`}
disabled={field.readOnly}
styles={{ input: { fontSize: '0.8125rem' } }}
/>
);
case 'checkbox': {
const isChecked = !!value && value !== 'Off';
const onValue = (field.widgets && field.widgets[0]?.exportValue) || 'Yes';
return (
<Checkbox
size="xs"
checked={isChecked}
onChange={(e) => onChange(e.currentTarget.checked ? onValue : 'Off')}
label={field.label}
disabled={field.readOnly}
/>
);
}
case 'combobox': {
const comboData = (field.options || []).map((opt, idx) => ({
value: opt,
label: (field.displayOptions && field.displayOptions[idx]) || opt,
}));
return (
<Select
size="xs"
data={comboData}
value={value || null}
onChange={(v) => onChange(v || '')}
placeholder={`Select ${field.label}`}
clearable
searchable
disabled={field.readOnly}
aria-label={field.label || field.name}
aria-required={field.required}
styles={{ input: { fontSize: '0.8125rem' } }}
/>
);
}
case 'listbox': {
const listData = (field.options || []).map((opt, idx) => ({
value: opt,
label: (field.displayOptions && field.displayOptions[idx]) || opt,
}));
if (field.multiSelect) {
const selectedValues = value ? value.split(',').filter(Boolean) : [];
return (
<MultiSelect
size="xs"
data={listData}
value={selectedValues}
onChange={(vals) => onChange(vals.join(','))}
placeholder={`Select ${field.label}`}
searchable
disabled={field.readOnly}
aria-label={field.label || field.name}
aria-required={field.required}
styles={{ input: { fontSize: '0.8125rem' } }}
/>
);
}
return (
<Select
size="xs"
data={listData}
value={value || null}
onChange={(v) => onChange(v || '')}
placeholder={`Select ${field.label}`}
clearable
searchable
disabled={field.readOnly}
aria-label={field.label || field.name}
aria-required={field.required}
styles={{ input: { fontSize: '0.8125rem' } }}
/>
);
}
case 'radio': {
const radioOptions: { value: string; label: string }[] = [];
if (field.widgets && field.widgets.length > 0) {
for (const w of field.widgets) {
if (w.exportValue && !radioOptions.some((o) => o.value === w.exportValue)) {
radioOptions.push({ value: w.exportValue, label: w.exportValue });
}
}
}
if (radioOptions.length === 0 && field.options) {
radioOptions.push(...field.options.map((o) => ({ value: o, label: o })));
}
return (
<Radio.Group
value={value}
onChange={onChange}
aria-label={field.label || field.name}
aria-required={field.required}
>
<Stack gap={4} mt={4}>
{radioOptions.map((opt) => (
<Radio
key={opt.value}
size="xs"
value={opt.value}
label={opt.label}
disabled={field.readOnly}
/>
))}
</Stack>
</Radio.Group>
);
}
default:
return (
<TextInput
size="xs"
value={value}
onChange={(e) => onChange(e.currentTarget.value)}
disabled={field.readOnly}
aria-label={field.label || field.name}
aria-required={field.required}
styles={{ input: { fontSize: '0.8125rem' } }}
/>
);
}
}
const FieldInputBase = memo(FieldInputInner);
/**
* Self-subscribing FieldInput — reads its own value via useFieldValue.
* Only re-renders when this specific field's value changes.
*/
export function FieldInput({
field,
onValueChange,
}: {
field: FormField;
onValueChange: (fieldName: string, value: string) => void;
}) {
const value = useFieldValue(field.name);
return <FieldInputBase field={field} value={value} onValueChange={onValueChange} />;
}
export default FieldInput;
@@ -1,20 +1,473 @@
/**
* FormFieldOverlay stub for the core build.
* This file is overridden in src/proprietary/tools/formFill/FormFieldOverlay.tsx
* when building the proprietary variant.
* FormFieldOverlay — Renders interactive HTML form widgets on top of a PDF page.
*
* This layer is placed inside the renderPage callback of the EmbedPDF Scroller,
* similar to how AnnotationLayer, RedactionLayer, and LinkLayer work.
*
* It reads the form field coordinates (in un-rotated CSS space, top-left origin)
* and scales them using the document scale from EmbedPDF.
*
* Each widget renders an appropriate HTML input (text, checkbox, dropdown, etc.)
* that synchronises bidirectionally with FormFillContext values.
*
* Coordinate handling:
* Both providers (PdfLibFormProvider and PdfBoxFormProvider) output widget
* coordinates in un-rotated PDF space (y-flipped to CSS upper-left origin).
* The <Rotate> component (which wraps this overlay along with page tiles)
* handles visual rotation via CSS transforms — same as TilingLayer,
* AnnotationLayer, and LinkLayer.
*/
import React, { useCallback, useMemo, memo } from 'react';
import { useDocumentState } from '@embedpdf/core/react';
import { useFormFill, useFieldValue } from '@app/tools/formFill/FormFillContext';
import type { FormField, WidgetCoordinates } from '@app/tools/formFill/types';
interface WidgetInputProps {
field: FormField;
widget: WidgetCoordinates;
isActive: boolean;
error?: string;
scaleX: number;
scaleY: number;
onFocus: (fieldName: string) => void;
onChange: (fieldName: string, value: string) => void;
}
/**
* WidgetInput subscribes to its own field value via useSyncExternalStore,
* so it only re-renders when its specific value changes — not when ANY
* form value in the entire document changes.
*/
function WidgetInputInner({
field,
widget,
isActive,
error,
scaleX,
scaleY,
onFocus,
onChange,
}: WidgetInputProps) {
// Per-field value subscription — only this widget re-renders when its value changes
const value = useFieldValue(field.name);
// Coordinates are in visual CSS space (top-left origin).
// Multiply by per-axis scale to get rendered pixel coordinates.
const left = widget.x * scaleX;
const top = widget.y * scaleY;
const width = widget.width * scaleX;
const height = widget.height * scaleY;
const borderColor = error ? '#f44336' : (isActive ? '#2196F3' : 'rgba(33, 150, 243, 0.4)');
const bgColor = error
? '#FFEBEE' // Red 50 (Opaque)
: (isActive ? '#E3F2FD' : '#FFFFFF'); // Blue 50 (Opaque) : White (Opaque)
const commonStyle: React.CSSProperties = {
position: 'absolute',
left,
top,
width,
height,
zIndex: 10,
boxSizing: 'border-box',
border: `1px solid ${borderColor}`,
borderRadius: 1,
background: isActive ? bgColor : 'transparent',
transition: 'border-color 0.15s, background 0.15s, box-shadow 0.15s',
boxShadow:
isActive && field.type !== 'radio' && field.type !== 'checkbox'
? `0 0 0 2px ${error ? 'rgba(244, 67, 54, 0.25)' : 'rgba(33, 150, 243, 0.25)'}`
: 'none',
cursor: field.readOnly ? 'default' : 'text',
pointerEvents: 'auto',
display: 'flex',
alignItems: field.multiline ? 'stretch' : 'center',
};
const stopPropagation = (e: React.SyntheticEvent) => {
e.stopPropagation();
// Also stop immediate propagation to native listeners to block non-React subscribers
if (e.nativeEvent) {
e.nativeEvent.stopImmediatePropagation?.();
}
};
const commonProps = {
style: commonStyle,
onPointerDown: stopPropagation,
onPointerUp: stopPropagation,
onMouseDown: stopPropagation,
onMouseUp: stopPropagation,
onClick: stopPropagation,
onDoubleClick: stopPropagation,
onKeyDown: stopPropagation,
onKeyUp: stopPropagation,
onKeyPress: stopPropagation,
onDragStart: stopPropagation,
onSelect: stopPropagation,
onContextMenu: stopPropagation,
};
const captureStopProps = {
onPointerDownCapture: stopPropagation,
onPointerUpCapture: stopPropagation,
onMouseDownCapture: stopPropagation,
onMouseUpCapture: stopPropagation,
onClickCapture: stopPropagation,
onKeyDownCapture: stopPropagation,
onKeyUpCapture: stopPropagation,
onKeyPressCapture: stopPropagation,
};
const fontSize = widget.fontSize
? widget.fontSize * scaleY
: field.multiline
? Math.max(6, Math.min(height * 0.60, 14))
: Math.max(6, height * 0.65);
const inputBaseStyle: React.CSSProperties = {
width: '100%',
height: '100%',
border: 'none',
outline: 'none',
background: 'transparent',
padding: 0,
paddingLeft: `${Math.max(2, 4 * scaleX)}px`,
paddingRight: `${Math.max(2, 4 * scaleX)}px`,
fontSize: `${fontSize}px`,
fontFamily: 'Helvetica, Arial, sans-serif',
color: '#000',
boxSizing: 'border-box',
lineHeight: 'normal',
};
const handleFocus = () => onFocus(field.name);
switch (field.type) {
case 'text':
return (
<div {...commonProps} title={error || field.tooltip || field.label}>
{field.multiline ? (
<textarea
value={value}
onChange={(e) => onChange(field.name, e.target.value)}
onFocus={handleFocus}
disabled={field.readOnly}
placeholder={field.label}
style={{
...inputBaseStyle,
resize: 'none',
overflow: 'auto',
paddingTop: `${Math.max(1, 2 * scaleY)}px`,
}}
{...captureStopProps}
/>
) : (
<input
type="text"
id={`${field.name}_${widget.pageIndex}_${widget.x}_${widget.y}`}
value={value}
onChange={(e) => onChange(field.name, e.target.value)}
onFocus={handleFocus}
disabled={field.readOnly}
placeholder={field.label}
style={inputBaseStyle}
aria-label={field.label || field.name}
aria-required={field.required}
aria-invalid={!!error}
aria-describedby={error ? `${field.name}-error` : undefined}
{...captureStopProps}
/>
)}
</div>
);
case 'checkbox': {
// Checkbox is checked when value is anything other than 'Off' or empty
const isChecked = !!value && value !== 'Off';
// When toggling on, use the widget's exportValue (e.g. 'Red', 'Blue') or fall back to 'Yes'
const onValue = widget.exportValue || 'Yes';
return (
<div
{...commonProps}
style={{
...commonStyle,
border: isActive ? commonStyle.border : '1px solid rgba(0,0,0,0.15)',
background: isActive ? bgColor : 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center', // Keep center for checkboxes as they are usually square hitboxes
cursor: field.readOnly ? 'default' : 'pointer',
}}
title={error || field.tooltip || field.label}
onClick={(e) => {
if (field.readOnly) return;
handleFocus();
onChange(field.name, isChecked ? 'Off' : onValue);
stopPropagation(e);
}}
>
<span
style={{
width: '85%',
height: '85%',
maxWidth: height * 0.9, // Prevent it from getting too wide in rectangular boxes
maxHeight: width * 0.9,
fontSize: `${Math.max(10, height * 0.75)}px`,
lineHeight: 1,
color: isChecked ? '#2196F3' : 'transparent',
background: '#FFF',
border: isChecked || isActive ? '1px solid #2196F3' : '1.5px solid #666',
borderRadius: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 700,
userSelect: 'none',
boxShadow: isActive ? '0 0 0 2px rgba(33, 150, 243, 0.2)' : 'none',
}}
>
</span>
</div>
);
}
case 'combobox':
case 'listbox': {
const inputId = `${field.name}_${widget.pageIndex}_${widget.x}_${widget.y}`;
// For multi-select, value should be an array
// We store as comma-separated string, so parse it
const selectValue = field.multiSelect
? (value ? value.split(',').map(v => v.trim()) : [])
: value;
const handleSelectChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
if (field.multiSelect) {
// For multi-select, join selected options with comma
const selected = Array.from(e.target.selectedOptions, opt => opt.value);
onChange(field.name, selected.join(','));
} else {
onChange(field.name, e.target.value);
}
};
return (
<div {...commonProps} title={error || field.tooltip || field.label}>
<select
id={inputId}
value={selectValue}
onChange={handleSelectChange}
onFocus={handleFocus}
disabled={field.readOnly}
multiple={field.multiSelect}
style={{
...inputBaseStyle,
padding: 0,
paddingLeft: 2,
appearance: 'auto',
WebkitAppearance: 'auto' as React.CSSProperties['WebkitAppearance'],
}}
aria-label={field.label || field.name}
aria-required={field.required}
aria-invalid={!!error}
{...captureStopProps}
>
{!field.multiSelect && <option value=""> select </option>}
{(field.options || []).map((opt, idx) => (
<option key={opt} value={opt}>
{(field.displayOptions && field.displayOptions[idx]) || opt}
</option>
))}
</select>
</div>
);
}
case 'radio': {
// Each radio widget has an exportValue set by the backend
const optionValue = widget.exportValue || '';
if (!optionValue) return null; // no export value, skip
const isSelected = value === optionValue;
return (
<div
{...commonProps}
style={{
...commonStyle,
border: isActive ? commonStyle.border : 'none',
background: 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start', // Align to start (left) instead of center for radio buttons
paddingLeft: Math.max(1, (height - Math.min(width, height) * 0.8) / 2), // Slight offset
cursor: field.readOnly ? 'default' : 'pointer',
}}
title={error || field.tooltip || `${field.label}: ${optionValue}`}
onClick={(e) => {
if (field.readOnly || value === optionValue) return; // Don't deselect radio buttons
handleFocus();
onChange(field.name, optionValue);
stopPropagation(e);
}}
>
<span
style={{
width: Math.min(width, height) * 0.8,
height: Math.min(width, height) * 0.8,
borderRadius: '50%',
border: `1.5px solid ${isSelected || isActive ? '#2196F3' : '#666'}`,
background: isSelected ? '#2196F3' : '#FFF',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: isSelected ? 'inset 0 0 0 2px white' : 'none',
transition: 'background 0.15s, border-color 0.15s',
}}
/>
</div>
);
}
case 'signature':
case 'button':
// Just render a highlighted area — not editable
return (
<div
{...commonProps}
style={{
...commonStyle,
background: 'rgba(200,200,200,0.3)',
border: '1px dashed #999',
cursor: 'default',
}}
title={field.tooltip || `${field.type}: ${field.label}`}
onClick={handleFocus}
/>
);
default:
return (
<div {...commonProps} title={field.tooltip || field.label}>
<input
type="text"
value={value}
onChange={(e) => onChange(field.name, e.target.value)}
onFocus={handleFocus}
disabled={field.readOnly}
style={inputBaseStyle}
{...captureStopProps}
/>
</div>
);
}
}
const WidgetInput = memo(WidgetInputInner);
interface FormFieldOverlayProps {
documentId: string;
pageIndex: number;
pageWidth: number;
pageHeight: number;
pageWidth: number; // rendered CSS pixel width (from renderPage callback)
pageHeight: number; // rendered CSS pixel height
/** File identity — if provided, overlay only renders when context fields match this file */
fileId?: string | null;
}
export function FormFieldOverlay(_props: FormFieldOverlayProps) {
// Core build stub — renders nothing
return null;
export function FormFieldOverlay({
documentId,
pageIndex,
pageWidth,
pageHeight,
fileId,
}: FormFieldOverlayProps) {
const { setValue, setActiveField, fieldsByPage, state, forFileId } = useFormFill();
const { activeFieldName, validationErrors } = state;
// Get scale from EmbedPDF document state — same pattern as LinkLayer
// NOTE: All hooks must be called unconditionally (before any early returns)
const documentState = useDocumentState(documentId);
const { scaleX, scaleY } = useMemo(() => {
const pdfPage = documentState?.document?.pages?.[pageIndex];
if (!pdfPage || !pdfPage.size || !pageWidth || !pageHeight) {
const s = documentState?.scale ?? 1;
return { scaleX: s, scaleY: s };
}
// pdfPage.size contains un-rotated (MediaBox) dimensions;
// pageWidth/pageHeight from Scroller also use these un-rotated dims * scale
return {
scaleX: pageWidth / pdfPage.size.width,
scaleY: pageHeight / pdfPage.size.height,
};
}, [documentState, pageIndex, pageWidth, pageHeight]);
const pageFields = useMemo(
() => fieldsByPage.get(pageIndex) || [],
[fieldsByPage, pageIndex]
);
const handleFocus = useCallback(
(fieldName: string) => setActiveField(fieldName),
[setActiveField]
);
const handleChange = useCallback(
(fieldName: string, value: string) => setValue(fieldName, value),
[setValue]
);
// Guard: don't render fields from a previous document.
// If fileId is provided and doesn't match what the context fetched for, render nothing.
if (fileId != null && forFileId != null && fileId !== forFileId) {
return null;
}
// Also guard: if fields exist but no forFileId is set (reset happened), don't render stale fields
if (fileId != null && forFileId == null && state.fields.length > 0) {
return null;
}
if (pageFields.length === 0) return null;
return (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none', // allow click-through except on widgets
zIndex: 5, // above TilingLayer, below LinkLayer
}}
data-form-overlay-page={pageIndex}
>
{pageFields.map((field: FormField) =>
(field.widgets || [])
.filter((w: WidgetCoordinates) => w.pageIndex === pageIndex)
.map((widget: WidgetCoordinates, widgetIdx: number) => {
// Coordinates are in un-rotated PDF space (y-flipped to CSS TL origin).
// The <Rotate> CSS wrapper handles visual rotation for us,
// just like it does for TilingLayer, LinkLayer, etc.
return (
<WidgetInput
key={`${field.name}-${widgetIdx}`}
field={field}
widget={widget}
isActive={activeFieldName === field.name}
error={validationErrors[field.name]}
scaleX={scaleX}
scaleY={scaleY}
onFocus={handleFocus}
onChange={handleChange}
/>
);
})
)}
</div>
);
}
export default FormFieldOverlay;
@@ -1,17 +1,210 @@
/**
* FormFieldSidebar stub for the core build.
* This file is overridden in src/proprietary/tools/formFill/FormFieldSidebar.tsx
* when building the proprietary variant.
* FormFieldSidebar — A right-side panel for viewing and filling form fields
* when the dedicated formFill tool is NOT selected (normal viewer mode).
*
* Redesigned with:
* - Consistent CSS module styling matching the main FormFill panel
* - Shared FieldInput component (no duplication)
* - Better visual hierarchy and spacing
*/
import React, { useCallback, useEffect, useRef } from 'react';
import {
Box,
Text,
ScrollArea,
Badge,
Tooltip,
ActionIcon,
} from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useFormFill } from '@app/tools/formFill/FormFillContext';
import { FieldInput } from '@app/tools/formFill/FieldInput';
import { FIELD_TYPE_ICON, FIELD_TYPE_COLOR } from '@app/tools/formFill/fieldMeta';
import type { FormField } from '@app/tools/formFill/types';
import CloseIcon from '@mui/icons-material/Close';
import TextFieldsIcon from '@mui/icons-material/TextFields';
import styles from '@app/tools/formFill/FormFill.module.css';
interface FormFieldSidebarProps {
visible: boolean;
onToggle: () => void;
}
export function FormFieldSidebar(_props: FormFieldSidebarProps) {
// Core build stub — renders nothing
return null;
export function FormFieldSidebar({
visible,
onToggle,
}: FormFieldSidebarProps) {
useTranslation();
const { state, setValue, setActiveField } = useFormFill();
const { fields, activeFieldName, loading } = state;
const activeFieldRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (activeFieldName && activeFieldRef.current) {
activeFieldRef.current.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}
}, [activeFieldName]);
const handleFieldClick = useCallback(
(fieldName: string) => {
setActiveField(fieldName);
},
[setActiveField]
);
const handleValueChange = useCallback(
(fieldName: string, value: string) => {
setValue(fieldName, value);
},
[setValue]
);
if (!visible) return null;
const fieldsByPage = new Map<number, FormField[]>();
for (const field of fields) {
const pageIndex =
field.widgets && field.widgets.length > 0 ? field.widgets[0].pageIndex : 0;
if (!fieldsByPage.has(pageIndex)) {
fieldsByPage.set(pageIndex, []);
}
fieldsByPage.get(pageIndex)!.push(field);
}
const sortedPages = Array.from(fieldsByPage.keys()).sort((a, b) => a - b);
return (
<Box
style={{
position: 'fixed',
top: 0,
right: 0,
width: '18.5rem',
height: '100%',
zIndex: 999,
display: 'flex',
flexDirection: 'column',
background: 'var(--bg-toolbar, var(--mantine-color-body))',
borderLeft: '1px solid var(--border-subtle, var(--mantine-color-default-border))',
boxShadow: '-4px 0 16px rgba(0,0,0,0.08)',
}}
>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0.625rem 0.75rem',
borderBottom: '1px solid var(--border-subtle, var(--mantine-color-default-border))',
flexShrink: 0,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<TextFieldsIcon sx={{ fontSize: 18, opacity: 0.7 }} />
<Text fw={600} size="sm">
Form Fields
</Text>
<Badge size="xs" variant="light" color="blue" radius="sm">
{fields.length}
</Badge>
</div>
<ActionIcon variant="subtle" size="sm" onClick={onToggle} aria-label="Close sidebar">
<CloseIcon sx={{ fontSize: 16 }} />
</ActionIcon>
</div>
{/* Content */}
<ScrollArea style={{ flex: 1 }}>
{loading && (
<div className={styles.emptyState}>
<Text size="sm" c="dimmed">
Loading form fields...
</Text>
</div>
)}
{!loading && fields.length === 0 && (
<div className={styles.emptyState}>
<span className={styles.emptyStateText}>
No form fields found in this PDF
</span>
</div>
)}
{!loading && fields.length > 0 && (
<div className={styles.fieldListInner}>
{sortedPages.map((pageIdx, i) => (
<React.Fragment key={pageIdx}>
<div
className={styles.pageDivider}
style={i === 0 ? { marginTop: 0 } : undefined}
>
<Text className={styles.pageDividerLabel}>
Page {pageIdx + 1}
</Text>
</div>
{fieldsByPage.get(pageIdx)!.map((field) => {
const isActive = activeFieldName === field.name;
return (
<div
key={field.name}
ref={isActive ? activeFieldRef : undefined}
className={`${styles.fieldCard} ${
isActive ? styles.fieldCardActive : ''
}`}
onClick={() => handleFieldClick(field.name)}
>
<div className={styles.fieldHeader}>
<Tooltip label={field.type} withArrow position="left">
<span
className={styles.fieldTypeIcon}
style={{
color: `var(--mantine-color-${FIELD_TYPE_COLOR[field.type]}-6)`,
fontSize: '0.875rem',
}}
>
{FIELD_TYPE_ICON[field.type]}
</span>
</Tooltip>
<span className={styles.fieldName}>
{field.label || field.name}
</span>
{field.required && (
<span className={styles.fieldRequired}>req</span>
)}
</div>
{field.type !== 'button' && field.type !== 'signature' && (
<div
className={styles.fieldInputWrap}
>
<FieldInput
field={field}
onValueChange={handleValueChange}
/>
</div>
)}
{field.tooltip && (
<div className={styles.fieldHint}>
{field.tooltip}
</div>
)}
</div>
);
})}
</React.Fragment>
))}
</div>
)}
</ScrollArea>
</Box>
);
}
export default FormFieldSidebar;
@@ -0,0 +1,286 @@
.root {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background: transparent;
}
.modeTabs {
flex-shrink: 0;
border-bottom: 1px solid var(--border-default, var(--mantine-color-default-border));
background: transparent;
padding: 0.25rem;
}
.segmentedRoot {
background: rgba(0, 0, 0, 0.05) !important;
border-radius: var(--radius-sm) !important;
}
:global([data-mantine-color-scheme="dark"]) .segmentedRoot {
background: rgba(255, 255, 255, 0.05) !important;
}
.segmentedIndicator {
background-color: var(--mantine-color-blue-filled) !important;
box-shadow: var(--shadow-sm) !important;
border-radius: var(--radius-sm) !important;
}
.segmentedLabel {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0;
padding: 0.125rem 0;
min-height: 2.25rem;
}
.segmentedInnerLabel {
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.02em;
color: var(--text-muted);
transition: color 0.15s ease;
line-height: 1;
}
.modeTabIcon {
font-size: 1rem !important;
margin-bottom: 0.125rem;
opacity: 0.8;
}
.header {
flex-shrink: 0;
padding: 0.75rem 1rem;
background: transparent;
border-bottom: 1px solid var(--border-default, var(--mantine-color-default-border));
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.progressRow {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.progressLabel {
font-size: 0.6875rem;
font-weight: 600;
color: var(--text-muted);
white-space: nowrap;
}
.actionBar {
display: flex;
gap: 0.5rem;
align-items: center;
}
.actionBar > *:first-child {
flex: 1;
}
.fieldList {
flex: 1;
overflow: hidden;
background: transparent;
}
.fieldListInner {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
}
.pageDivider {
margin-top: 0.75rem;
margin-bottom: 0.25rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.pageDivider::after {
content: '';
flex: 1;
height: 1px;
background: var(--border-default, var(--mantine-color-default-border));
opacity: 0.2;
}
.pageDividerLabel {
font-size: 0.625rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
opacity: 0.6;
}
.fieldCard {
padding: 0.625rem 0.75rem;
border-radius: var(--radius-md);
border: 1px solid var(--border-default, var(--mantine-color-default-border));
background: var(--bg-surface, var(--mantine-color-body));
cursor: pointer;
transition: all 0.15s ease;
}
.fieldCard:hover {
border-color: var(--mantine-color-blue-5);
background: var(--bg-surface);
}
.fieldCardActive {
border-color: var(--mantine-color-blue-5);
background-color: var(--mantine-color-blue-light);
box-shadow: inset 0 0 0 1px var(--mantine-color-blue-light-color);
}
.fieldCardError {
border-color: var(--mantine-color-red-5);
background-color: var(--mantine-color-red-light);
}
.fieldHeader {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.375rem;
}
.fieldTypeIcon {
flex-shrink: 0;
display: flex;
align-items: center;
line-height: 1;
opacity: 0.6;
}
.fieldName {
flex: 1;
font-size: 0.75rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-primary);
}
.fieldRequired {
flex-shrink: 0;
font-size: 0.5625rem;
padding: 0.0625rem 0.375rem;
border-radius: var(--radius-xs);
background: var(--mantine-color-red-light);
color: var(--mantine-color-red-light-color);
font-weight: 800;
text-transform: uppercase;
}
.fieldInputWrap {
margin-top: 0.25rem;
}
.fieldHint {
margin-top: 0.375rem;
font-size: 0.6875rem;
color: var(--text-muted);
line-height: 1.4;
font-style: italic;
opacity: 0.8;
}
.fieldError {
margin-top: 0.25rem;
font-size: 0.6875rem;
font-weight: 500;
color: var(--mantine-color-red-6);
}
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 5rem 1.5rem;
text-align: center;
color: var(--text-muted);
background: transparent;
}
.emptyStateIcon {
font-size: 2.5rem !important;
opacity: 0.2;
}
.emptyStateText {
font-size: 0.8125rem;
line-height: 1.6;
max-width: 180px;
}
.unsavedDot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--mantine-color-yellow-6);
margin-right: 0.375rem;
vertical-align: middle;
}
.statusBar {
flex-shrink: 0;
padding: 0.5rem 1rem;
border-top: 1px solid var(--border-default, var(--mantine-color-default-border));
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.6875rem;
font-weight: 600;
color: var(--text-muted);
background: transparent;
}
.comingSoon {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 4rem 2rem;
text-align: center;
background: transparent;
}
.comingSoonIcon {
font-size: 3rem !important;
opacity: 0.15;
}
.comingSoonTitle {
font-size: 1rem;
font-weight: 800;
color: var(--text-primary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.comingSoonDesc {
font-size: 0.75rem;
line-height: 1.6;
color: var(--text-muted);
max-width: 200px;
}
@@ -0,0 +1,560 @@
/**
* FormFill: The tool component that renders in the left ToolPanel
* when the "Fill Form" tool is selected.
*
* Redesigned with:
* - Mode tabs for future extensibility (Fill / Make / Batch / Modify)
* - Clean visual hierarchy with proper spacing
* - Shared FieldInput component (eliminates duplication)
* - CSS module for theme-consistent styling
* - Status bar at bottom for contextual info
*/
import React, { useEffect, useCallback, useState, useRef, useMemo } from 'react';
import {
Button,
Text,
Alert,
Switch,
Loader,
ScrollArea,
Progress,
Tooltip,
ActionIcon,
} from '@mantine/core';
import { useFormFill, useAllFormValues } from '@app/tools/formFill/FormFillContext';
import { useNavigation } from '@app/contexts/NavigationContext';
import { useViewer } from '@app/contexts/ViewerContext';
import { useFileState } from '@app/contexts/FileContext';
import { Skeleton } from '@mantine/core';
import { isStirlingFile } from '@app/types/fileContext';
import type { BaseToolProps } from '@app/types/tool';
import type { FormField } from '@app/tools/formFill/types';
import { FieldInput } from '@app/tools/formFill/FieldInput';
import { FIELD_TYPE_ICON, FIELD_TYPE_COLOR } from '@app/tools/formFill/fieldMeta';
import SaveIcon from '@mui/icons-material/Save';
import RefreshIcon from '@mui/icons-material/Refresh';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import EditNoteIcon from '@mui/icons-material/EditNote';
import PostAddIcon from '@mui/icons-material/PostAdd';
import FileCopyIcon from '@mui/icons-material/FileCopy';
import BuildCircleIcon from '@mui/icons-material/BuildCircle';
import DescriptionIcon from '@mui/icons-material/Description';
import FileDownloadIcon from '@mui/icons-material/FileDownload';
import styles from '@app/tools/formFill/FormFill.module.css';
// ---------------------------------------------------------------------------
// Mode tabs — extensible for future form tools
// ---------------------------------------------------------------------------
type FormMode = 'fill' | 'make' | 'batch' | 'modify';
interface ModeTabDef {
id: FormMode;
label: string;
icon: React.ReactNode;
ready: boolean;
}
const _MODE_TABS: ModeTabDef[] = [
{ id: 'fill', label: 'Fill', icon: <EditNoteIcon className={styles.modeTabIcon} />, ready: true },
{ id: 'make', label: 'Create', icon: <PostAddIcon className={styles.modeTabIcon} />, ready: false },
{ id: 'batch', label: 'Batch', icon: <FileCopyIcon className={styles.modeTabIcon} />, ready: false },
{ id: 'modify', label: 'Modify', icon: <BuildCircleIcon className={styles.modeTabIcon} />, ready: false },
];
// ---------------------------------------------------------------------------
// Coming-soon placeholder for unimplemented tabs
// ---------------------------------------------------------------------------
// ComingSoonPlaceholder — re-enable when mode tabs are exposed
// function ComingSoonPlaceholder({ mode }: { mode: ModeTabDef }) {
// return (
// <div className={styles.comingSoon}>
// <DescriptionIcon className={styles.comingSoonIcon} />
// <div className={styles.comingSoonTitle}>{mode.label} Forms</div>
// <div className={styles.comingSoonDesc}>
// This feature is coming soon. Stay tuned!
// </div>
// </div>
// );
// }
// ---------------------------------------------------------------------------
// Main FormFill component
// ---------------------------------------------------------------------------
const FormFill = (_props: BaseToolProps) => {
const { selectedTool } = useNavigation();
const { selectors, state: fileState } = useFileState();
const {
state: formState,
fetchFields,
submitForm,
setValue,
setActiveField,
validateForm,
} = useFormFill();
const allValues = useAllFormValues();
const { validationErrors } = formState;
const { scrollActions } = useViewer();
// Mode system is temporarily restricted to 'fill' only.
// Other modes (make, batch, modify) are defined above but not yet exposed in the UI.
// When ready, uncomment the SegmentedControl and mode state below.
// const [mode, setMode] = useState<FormMode>('fill');
const mode: FormMode = 'fill';
const [flatten, setFlatten] = useState(false);
const [saving, setSaving] = useState(false);
const [extracting, setExtracting] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [lastSavedFlatten, setLastSavedFlatten] = useState<boolean | null>(null);
const flattenChanged = lastSavedFlatten !== null && flatten !== lastSavedFlatten;
const savingRef = useRef(false);
const handleExtractJson = useCallback(() => {
setExtracting(true);
try {
const data = JSON.stringify(allValues, null, 2);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `form-data-${new Date().getTime()}.json`;
a.click();
// Delay revocation so the browser has time to start the download
setTimeout(() => URL.revokeObjectURL(url), 250);
} finally {
setExtracting(false);
}
}, [allValues]);
const activeFieldRef = useRef<HTMLDivElement>(null);
const isDirtyRef = useRef(formState.isDirty);
isDirtyRef.current = formState.isDirty;
const activeFiles = selectors.getFiles();
const selectedFileIds = fileState.ui.selectedFileIds;
const currentFile = useMemo(() => {
if (activeFiles.length === 0) return null;
if (selectedFileIds.length > 0) {
const sel = activeFiles.find(
(f) => isStirlingFile(f) && selectedFileIds.includes(f.fileId)
);
if (sel) return sel;
}
return activeFiles[0];
}, [activeFiles, selectedFileIds]);
const isActive = selectedTool === 'formFill';
useEffect(() => {
if (formState.activeFieldName && activeFieldRef.current) {
activeFieldRef.current.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}
}, [formState.activeFieldName]);
const handleSave = useCallback(async () => {
// Ref-based guard prevents concurrent saves that cause file duplication
if (savingRef.current) return;
if (!currentFile || !isStirlingFile(currentFile)) return;
if (!validateForm()) {
setSaveError('Please fill in all required fields');
return;
}
savingRef.current = true;
setSaving(true);
setSaveError(null);
try {
const filledBlob = await submitForm(currentFile, flatten);
// Track the flatten value at save so toggling it later re-enables Save
setLastSavedFlatten(flatten);
// Dispatch to the viewer's handleFormApply via custom event.
// This ensures the viewer tracks the new file ID, preserves
// scroll position and rotation — instead of our own consumeFiles
// call which would lose the viewer's file tracking context.
const event = new CustomEvent('formfill:apply', { detail: { blob: filledBlob } });
window.dispatchEvent(event);
} catch (err: any) {
const message = err?.response?.status === 413
? 'File too large. Try reducing the PDF size first.'
: err?.response?.status === 400
? 'Invalid form data. Please check all fields.'
: err?.message || 'Failed to save filled form';
setSaveError(message);
console.error('[FormFill] Save failed:', err);
} finally {
savingRef.current = false;
setSaving(false);
}
}, [currentFile, submitForm, flatten, validateForm]);
// Keyboard shortcut: Ctrl+S to save
const flattenChangedRef = useRef(flattenChanged);
flattenChangedRef.current = flattenChanged;
useEffect(() => {
if (!isActive) return;
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
if (isDirtyRef.current || flattenChangedRef.current) handleSave();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isActive, handleSave]);
// Data loss prevention: warn on beforeunload if dirty
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (formState.isDirty) {
e.preventDefault();
e.returnValue = '';
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [formState.isDirty]);
const handleRefresh = useCallback(() => {
if (currentFile && isStirlingFile(currentFile)) {
fetchFields(currentFile, currentFile.fileId);
} else if (currentFile) {
fetchFields(currentFile);
}
}, [currentFile, fetchFields]);
const handleValueChange = useCallback(
(fieldName: string, value: string) => {
setValue(fieldName, value);
},
[setValue]
);
const handleFieldClick = useCallback(
(fieldName: string, pageIndex?: number) => {
setActiveField(fieldName);
if (pageIndex !== undefined) {
scrollActions.scrollToPage(pageIndex + 1);
}
},
[setActiveField, scrollActions]
);
// Memoize fields grouped by page
const { sortedPages, fieldsByPage } = useMemo(() => {
const byPage = new Map<number, FormField[]>();
for (const field of formState.fields) {
const pageIndex =
field.widgets && field.widgets.length > 0 ? field.widgets[0].pageIndex : 0;
if (!byPage.has(pageIndex)) {
byPage.set(pageIndex, []);
}
byPage.get(pageIndex)!.push(field);
}
const pages = Array.from(byPage.keys()).sort((a, b) => a - b);
return { sortedPages: pages, fieldsByPage: byPage };
}, [formState.fields]);
// Progress tracking
const fillableFields = useMemo(() => {
return formState.fields.filter((f) => f.type !== 'button' && f.type !== 'signature');
}, [formState.fields]);
const fillableCount = fillableFields.length;
const filledCount = useMemo(() => {
return fillableFields.filter((f) => {
const v = allValues[f.name];
return v && v !== 'Off' && v.trim() !== '';
}).length;
}, [fillableFields, allValues]);
const requiredFields = useMemo(() => {
return fillableFields.filter((f) => f.required);
}, [fillableFields]);
const requiredCount = requiredFields.length;
const filledRequiredCount = useMemo(() => {
return requiredFields.filter((f) => {
const v = allValues[f.name];
return v && v !== 'Off' && v.trim() !== '';
}).length;
}, [requiredFields, allValues]);
if (!isActive) return null;
// const currentModeDef = MODE_TABS.find((t) => t.id === mode)!;
return (
<div className={styles.root}>
{/* ---- Mode selection (commented out until additional modes are implemented) ----
<div className={styles.modeTabs}>
<SegmentedControl
value={mode}
onChange={(val) => setMode(val as FormMode)}
data={MODE_TABS.map((tab) => ({
value: tab.id,
label: (
<div className={styles.segmentedLabel}>
{tab.icon}
<span>{tab.label}</span>
</div>
),
}))}
fullWidth
radius="xs"
size="xs"
classNames={{
root: styles.segmentedRoot,
indicator: styles.segmentedIndicator,
control: styles.segmentedControl,
label: styles.segmentedInnerLabel,
}}
/>
</div>
---- */}
{/* ---- Coming-soon for non-ready tabs (hidden while mode tabs are disabled) ---- */}
{/* !currentModeDef.ready && <ComingSoonPlaceholder mode={currentModeDef} /> */}
{/* ---- Fill Form content ---- */}
{mode === 'fill' && (
<>
{/* Header / controls */}
<div className={styles.header}>
{/* Loading state */}
{formState.loading && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Loader size={14} />
<Text size="xs" c="dimmed">
Analysing form fields...
</Text>
</div>
<Skeleton height={48} radius="sm" />
<Skeleton height={48} radius="sm" />
</>
)}
{/* Error state */}
{formState.error && (
<Alert
icon={<WarningAmberIcon sx={{ fontSize: 16 }} />}
color="red"
variant="light"
p="xs"
radius="sm"
>
<Text size="xs">{formState.error}</Text>
</Alert>
)}
{/* Ready state with fields */}
{!formState.loading && formState.fields.length > 0 && (
<>
{/* Progress bar */}
<div>
<div className={styles.progressRow}>
<span className={styles.progressLabel}>
{filledCount} / {fillableCount} filled
{requiredCount > 0 && (
<span style={{ marginLeft: '0.5rem', opacity: 0.7 }}>
({filledRequiredCount}/{requiredCount} req.)
</span>
)}
</span>
<span className={styles.progressLabel}>
{fillableCount > 0
? Math.round((filledCount / fillableCount) * 100)
: 0}
%
</span>
</div>
<Progress
value={fillableCount > 0 ? (filledCount / fillableCount) * 100 : 0}
size={6}
radius="xl"
color={filledRequiredCount === requiredCount ? 'teal' : 'blue'}
mt={4}
/>
</div>
{/* Flatten toggle */}
<Switch
label="Flatten after filling"
checked={flatten}
onChange={(e) => setFlatten(e.currentTarget.checked)}
size="xs"
styles={{
label: { fontSize: '0.75rem', cursor: 'pointer' },
}}
/>
{/* Action buttons */}
<div className={styles.actionBar}>
<Button
leftSection={<SaveIcon sx={{ fontSize: 14 }} />}
size="xs"
onClick={handleSave}
loading={saving}
disabled={!formState.isDirty && !flattenChanged}
flex={1}
>
Save
</Button>
<Button
variant="light"
color="blue"
leftSection={<FileDownloadIcon sx={{ fontSize: 14 }} />}
loading={extracting}
onClick={handleExtractJson}
size="xs"
>
Extract JSON
</Button>
<Tooltip label="Re-scan fields" withArrow position="bottom">
<ActionIcon
variant="light"
size="md"
onClick={handleRefresh}
aria-label="Re-scan form fields"
>
<RefreshIcon sx={{ fontSize: 16 }} />
</ActionIcon>
</Tooltip>
</div>
{/* Error message */}
{saveError && (
<Alert color="red" variant="light" p="xs" radius="sm">
<Text size="xs">{saveError}</Text>
</Alert>
)}
</>
)}
{/* Empty state */}
{!formState.loading && formState.fields.length === 0 && !formState.error && (
<div className={styles.emptyState}>
<DescriptionIcon className={styles.emptyStateIcon} />
<span className={styles.emptyStateText}>
No fillable form fields found in this PDF.
</span>
</div>
)}
</div>
{/* ---- Scrollable field list ---- */}
{!formState.loading && formState.fields.length > 0 && (
<ScrollArea className={styles.fieldList}>
<div className={styles.fieldListInner}>
{sortedPages.map((pageIdx, i) => (
<React.Fragment key={pageIdx}>
<div
className={styles.pageDivider}
style={i === 0 ? { marginTop: 0 } : undefined}
>
<Text className={styles.pageDividerLabel}>
Page {pageIdx + 1}
</Text>
</div>
{fieldsByPage.get(pageIdx)!.map((field) => {
const isFieldActive = formState.activeFieldName === field.name;
const hasError = !!validationErrors[field.name];
const pageIndex =
field.widgets && field.widgets.length > 0
? field.widgets[0].pageIndex
: undefined;
return (
<div
key={field.name}
ref={isFieldActive ? activeFieldRef : undefined}
className={`${styles.fieldCard} ${
isFieldActive ? styles.fieldCardActive : ''
} ${hasError ? styles.fieldCardError : ''}`}
onClick={() => handleFieldClick(field.name, pageIndex)}
>
<div className={styles.fieldHeader}>
<span
className={styles.fieldTypeIcon}
style={{
color: `var(--mantine-color-${FIELD_TYPE_COLOR[field.type]}-6)`,
fontSize: '0.875rem',
}}
>
{FIELD_TYPE_ICON[field.type]}
</span>
<span className={styles.fieldName}>
{field.label || field.name}
</span>
{field.required && (
<span className={styles.fieldRequired}>req</span>
)}
</div>
{field.type !== 'button' && field.type !== 'signature' && (
<div
className={styles.fieldInputWrap}
>
<FieldInput
field={field}
onValueChange={handleValueChange}
/>
</div>
)}
{hasError && (
<div className={styles.fieldError}>
{validationErrors[field.name]}
</div>
)}
{field.tooltip && (
<div className={styles.fieldHint}>
{field.tooltip}
</div>
)}
</div>
);
})}
</React.Fragment>
))}
</div>
</ScrollArea>
)}
{/* ---- Status bar ---- */}
{!formState.loading && formState.fields.length > 0 && (
<div className={styles.statusBar}>
<span>
{(formState.isDirty || flattenChanged) && <span className={styles.unsavedDot} />}
{formState.isDirty || flattenChanged ? 'Unsaved changes' : 'All saved'}
</span>
<span>Ctrl+S to save</span>
</div>
)}
</>
)}
</div>
);
};
export default FormFill;
@@ -1,85 +1,507 @@
/**
* FormFillProvider stub for the core build.
* This file is overridden in src/proprietary/tools/formFill/FormFillContext.tsx
* when building the proprietary variant.
* FormFillContext — React context for form fill state management.
*
* Provider-agnostic: delegates data fetching/saving to an IFormDataProvider.
* - PdfLibFormProvider: frontend-only, uses pdf-lib (for normal viewer mode)
* - PdfBoxFormProvider: backend API via PDFBox (for dedicated formFill tool)
*
* The active provider can be switched at runtime via setProvider(). This allows
* EmbedPdfViewer to auto-select:
* - Normal viewer → PdfLibFormProvider (no backend calls for large PDFs)
* - formFill tool → PdfBoxFormProvider (full-fidelity PDFBox handling)
*
* Performance Architecture:
* Form values are stored in a FormValuesStore (external to React state) to
* avoid full context re-renders on every keystroke. Individual widgets
* subscribe to their specific field via useFieldValue() + useSyncExternalStore,
* so only the active widget re-renders when its value changes.
*
* The UI components (FormFieldOverlay, FormFill, FormFieldSidebar) consume
* this context regardless of which provider is active.
*/
import React, { createContext, useContext } from 'react';
import React, {
createContext,
useCallback,
useContext,
useMemo,
useReducer,
useRef,
useState,
useSyncExternalStore,
} from 'react';
import { useDebouncedCallback } from '@mantine/hooks';
import type { FormField, FormFillState, WidgetCoordinates } 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';
interface FormFillContextValue {
state: {
fields: any[];
values: Record<string, string>;
loading: boolean;
error: string | null;
activeFieldName: string | null;
isDirty: boolean;
validationErrors: Record<string, string>;
};
// ---------------------------------------------------------------------------
// FormValuesStore — external store for field values (outside React state)
// ---------------------------------------------------------------------------
type Listener = () => void;
/**
* External store that holds form values outside of React state.
*
* This avoids triggering full context re-renders on every keystroke.
* Components subscribe per-field via useSyncExternalStore, so only
* the widget being edited re-renders.
*/
class FormValuesStore {
private _fieldListeners = new Map<string, Set<Listener>>();
private _globalListeners = new Set<Listener>();
private _values: Record<string, string> = {};
get values(): Record<string, string> {
return this._values;
}
private _version = 0;
get version(): number {
return this._version;
}
getValue(fieldName: string): string {
return this._values[fieldName] ?? '';
}
setValue(fieldName: string, value: string): void {
if (this._values[fieldName] === value) return;
this._values[fieldName] = value;
this._version++;
this._fieldListeners.get(fieldName)?.forEach((l) => l());
this._globalListeners.forEach((l) => l());
}
/** Replace all values (e.g., on fetch or reset) */
reset(values: Record<string, string> = {}): void {
this._values = values;
this._version++;
for (const listeners of this._fieldListeners.values()) {
listeners.forEach((l) => l());
}
this._globalListeners.forEach((l) => l());
}
/** Subscribe to a single field's value changes */
subscribeField(fieldName: string, listener: Listener): () => void {
if (!this._fieldListeners.has(fieldName)) {
this._fieldListeners.set(fieldName, new Set());
}
this._fieldListeners.get(fieldName)!.add(listener);
return () => {
this._fieldListeners.get(fieldName)?.delete(listener);
};
}
/** Subscribe to any value change */
subscribeGlobal(listener: Listener): () => void {
this._globalListeners.add(listener);
return () => {
this._globalListeners.delete(listener);
};
}
}
// ---------------------------------------------------------------------------
// Reducer — handles everything EXCEPT values (which live in FormValuesStore)
// ---------------------------------------------------------------------------
type Action =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; fields: FormField[] }
| { type: 'FETCH_ERROR'; error: string }
| { type: 'MARK_DIRTY' }
| { type: 'SET_ACTIVE_FIELD'; fieldName: string | null }
| { type: 'SET_VALIDATION_ERRORS'; errors: Record<string, string> }
| { type: 'CLEAR_VALIDATION_ERROR'; fieldName: string }
| { type: 'MARK_CLEAN' }
| { type: 'RESET' };
const initialState: FormFillState = {
fields: [],
values: {}, // kept for backward compat but canonical values live in FormValuesStore
loading: false,
error: null,
activeFieldName: null,
isDirty: false,
validationErrors: {},
};
function reducer(state: FormFillState, action: Action): FormFillState {
switch (action.type) {
case 'FETCH_START':
return { ...state, loading: true, error: null };
case 'FETCH_SUCCESS': {
return {
...state,
fields: action.fields,
values: {}, // values managed by FormValuesStore
loading: false,
error: null,
isDirty: false,
};
}
case 'FETCH_ERROR':
return { ...state, loading: false, error: action.error };
case 'MARK_DIRTY':
if (state.isDirty) return state; // avoid unnecessary re-render
return { ...state, isDirty: true };
case 'SET_ACTIVE_FIELD':
return { ...state, activeFieldName: action.fieldName };
case 'SET_VALIDATION_ERRORS':
return { ...state, validationErrors: action.errors };
case 'CLEAR_VALIDATION_ERROR': {
if (!state.validationErrors[action.fieldName]) return state;
const { [action.fieldName]: _, ...rest } = state.validationErrors;
return { ...state, validationErrors: rest };
}
case 'MARK_CLEAN':
return { ...state, isDirty: false };
case 'RESET':
return initialState;
default:
return state;
}
}
export interface FormFillContextValue {
state: FormFillState;
/** Fetch form fields for the given file using the active provider */
fetchFields: (file: File | Blob, fileId?: string) => Promise<void>;
/** Update a single field value */
setValue: (fieldName: string, value: string) => void;
/** Set the currently focused field */
setActiveField: (fieldName: string | null) => void;
submitForm: (file: File | Blob, flatten?: boolean) => Promise<Blob>;
getField: (fieldName: string) => any | undefined;
getFieldsForPage: (pageIndex: number) => any[];
/** Submit filled form and return the filled PDF blob */
submitForm: (
file: File | Blob,
flatten?: boolean
) => Promise<Blob>;
/** Get field by name */
getField: (fieldName: string) => FormField | undefined;
/** Get fields for a specific page index */
getFieldsForPage: (pageIndex: number) => FormField[];
/** Get the current value for a field (reads from external store) */
getValue: (fieldName: string) => string;
/** Validate the current form state and return true if valid */
validateForm: () => boolean;
/** Clear all form state (fields, values, errors) */
reset: () => void;
fieldsByPage: Map<number, any[]>;
/** Pre-computed map of page index to fields for performance */
fieldsByPage: Map<number, FormField[]>;
/** Name of the currently active provider ('pdf-lib' | 'pdfbox') */
activeProviderName: string;
/**
* Switch the active data provider.
* Use 'pdflib' for frontend-only pdf-lib, 'pdfbox' for backend PDFBox.
* Resets form state when switching providers.
*/
setProviderMode: (mode: 'pdflib' | 'pdfbox') => void;
/** The file ID that the current form fields belong to (null if no fields loaded) */
forFileId: string | null;
}
const noopAsync = async () => {};
const noop = () => {};
const FormFillContext = createContext<FormFillContextValue | null>(null);
/**
* Separate context for the values store.
* This allows useFieldValue() to subscribe without depending on the main context.
*/
const FormValuesStoreContext = createContext<FormValuesStore | null>(null);
export const useFormFill = (): FormFillContextValue => {
const ctx = useContext(FormFillContext);
if (!ctx) {
// Return a default no-op value for core builds
return {
state: {
fields: [],
values: {},
loading: false,
error: null,
activeFieldName: null,
isDirty: false,
validationErrors: {},
},
fetchFields: noopAsync,
setValue: noop,
setActiveField: noop,
submitForm: async () => new Blob(),
getField: () => undefined,
getFieldsForPage: () => [],
getValue: () => '',
validateForm: () => true,
reset: noop,
fieldsByPage: new Map(),
activeProviderName: 'none',
setProviderMode: noop,
forFileId: null,
};
throw new Error('useFormFill must be used within a FormFillProvider');
}
return ctx;
};
/** No-op stub for core builds */
export function useFieldValue(_fieldName: string): string {
return '';
/**
* Subscribe to a single field's value. Only re-renders when that specific
* field's value changes — not when any other form value changes.
*
* Uses useSyncExternalStore for tear-free reads.
*/
export function useFieldValue(fieldName: string): string {
const store = useContext(FormValuesStoreContext);
if (!store) {
throw new Error('useFieldValue must be used within a FormFillProvider');
}
const subscribe = useCallback(
(cb: () => void) => store.subscribeField(fieldName, cb),
[store, fieldName]
);
const getSnapshot = useCallback(
() => store.getValue(fieldName),
[store, fieldName]
);
return useSyncExternalStore(subscribe, getSnapshot);
}
/** No-op stub for core builds */
/**
* Subscribe to all values (e.g., for progress counters or form submission).
* Re-renders on every value change — use sparingly.
*/
export function useAllFormValues(): Record<string, string> {
return {};
const store = useContext(FormValuesStoreContext);
if (!store) {
throw new Error('useAllFormValues must be used within a FormFillProvider');
}
const subscribe = useCallback(
(cb: () => void) => store.subscribeGlobal(cb),
[store]
);
const getSnapshot = useCallback(
() => store.values,
[store]
);
return useSyncExternalStore(subscribe, getSnapshot);
}
export function FormFillProvider({ children }: { children: React.ReactNode }) {
// In core build, just render children without provider
return <>{children}</>;
/** Singleton provider instances */
const pdfLibProvider = new PdfLibFormProvider();
const pdfBoxProvider = new PdfBoxFormProvider();
export function FormFillProvider({
children,
provider: providerProp,
}: {
children: React.ReactNode;
/** Override the initial provider. If not given, defaults to pdf-lib. */
provider?: IFormDataProvider;
}) {
const initialMode = providerProp?.name === 'pdfbox' ? 'pdfbox' : 'pdflib';
const [providerMode, setProviderModeState] = useState<'pdflib' | 'pdfbox'>(initialMode);
const providerModeRef = useRef(initialMode as 'pdflib' | 'pdfbox');
providerModeRef.current = providerMode;
const provider = providerProp ?? (providerMode === 'pdfbox' ? pdfBoxProvider : pdfLibProvider);
const providerRef = useRef(provider);
providerRef.current = provider;
const [state, dispatch] = useReducer(reducer, initialState);
const fieldsRef = useRef<FormField[]>([]);
fieldsRef.current = state.fields;
// Version counter to cancel stale async fetch responses.
// Incremented on every fetchFields() and reset() call.
const fetchVersionRef = useRef(0);
// Track which file the current fields belong to
const forFileIdRef = useRef<string | null>(null);
const [forFileId, setForFileId] = useState<string | null>(null);
// External values store — values live HERE, not in the reducer.
// This prevents full context re-renders on every keystroke.
const [valuesStore] = useState(() => new FormValuesStore());
const fetchFields = useCallback(async (file: File | Blob, fileId?: string) => {
// Increment version so any in-flight fetch for a previous file is discarded.
// NOTE: setProviderMode() also increments fetchVersionRef to invalidate
// in-flight fetches when switching providers. This is intentional — the
// fetch started here captures the NEW version, so stale results are
// correctly discarded.
const version = ++fetchVersionRef.current;
// Immediately clear previous state so FormFieldOverlay's stale-file guards
// prevent rendering fields from a previous document during the fetch.
forFileIdRef.current = null;
setForFileId(null);
valuesStore.reset({});
dispatch({ type: 'RESET' });
dispatch({ type: 'FETCH_START' });
try {
const fields = await providerRef.current.fetchFields(file);
// If another fetch or reset happened while we were waiting, discard this result
if (fetchVersionRef.current !== version) {
console.log('[FormFill] Discarding stale fetch result (version mismatch)');
return;
}
// Initialise values in the external store
const values: Record<string, string> = {};
for (const field of fields) {
values[field.name] = field.value ?? '';
}
valuesStore.reset(values);
forFileIdRef.current = fileId ?? null;
setForFileId(fileId ?? null);
dispatch({ type: 'FETCH_SUCCESS', fields });
} catch (err: any) {
if (fetchVersionRef.current !== version) return; // stale
const msg =
err?.response?.data?.message ||
err?.message ||
'Failed to fetch form fields';
dispatch({ type: 'FETCH_ERROR', error: msg });
}
}, [valuesStore]);
const validateFieldDebounced = useDebouncedCallback((fieldName: string) => {
const field = fieldsRef.current.find((f) => f.name === fieldName);
if (!field || !field.required) return;
const val = valuesStore.getValue(fieldName);
if (!val || val.trim() === '' || val === 'Off') {
dispatch({
type: 'SET_VALIDATION_ERRORS',
errors: { ...state.validationErrors, [fieldName]: `${field.label} is required` },
});
} else {
dispatch({ type: 'CLEAR_VALIDATION_ERROR', fieldName });
}
}, 300);
const validateForm = useCallback((): boolean => {
const errors: Record<string, string> = {};
for (const field of fieldsRef.current) {
const val = valuesStore.getValue(field.name);
if (field.required && (!val || val.trim() === '' || val === 'Off')) {
errors[field.name] = `${field.label} is required`;
}
}
dispatch({ type: 'SET_VALIDATION_ERRORS', errors });
return Object.keys(errors).length === 0;
}, [valuesStore]);
const setValue = useCallback(
(fieldName: string, value: string) => {
// Update external store (triggers per-field subscribers only)
valuesStore.setValue(fieldName, value);
// Mark form as dirty in React state (only triggers re-render once)
dispatch({ type: 'MARK_DIRTY' });
validateFieldDebounced(fieldName);
},
[valuesStore, validateFieldDebounced]
);
const setActiveField = useCallback(
(fieldName: string | null) => {
dispatch({ type: 'SET_ACTIVE_FIELD', fieldName });
},
[]
);
const submitForm = useCallback(
async (file: File | Blob, flatten = false) => {
const blob = await providerRef.current.fillForm(file, valuesStore.values, flatten);
dispatch({ type: 'MARK_CLEAN' });
return blob;
},
[valuesStore]
);
const setProviderMode = useCallback(
(mode: 'pdflib' | 'pdfbox') => {
// Use the ref to check the current mode synchronously — avoids
// relying on stale closure state and allows the early return.
if (providerModeRef.current === mode) return;
// provider (pdfbox vs pdflib).
const newProvider = mode === 'pdfbox' ? pdfBoxProvider : pdfLibProvider;
providerRef.current = newProvider;
providerModeRef.current = mode;
fetchVersionRef.current++;
forFileIdRef.current = null;
setForFileId(null);
valuesStore.reset({});
dispatch({ type: 'RESET' });
setProviderModeState(mode);
},
[valuesStore]
);
const getField = useCallback(
(fieldName: string) =>
fieldsRef.current.find((f) => f.name === fieldName),
[]
);
const getFieldsForPage = useCallback(
(pageIndex: number) =>
fieldsRef.current.filter((f) =>
f.widgets?.some((w: WidgetCoordinates) => w.pageIndex === pageIndex)
),
[]
);
const getValue = useCallback(
(fieldName: string) => valuesStore.getValue(fieldName),
[valuesStore]
);
const reset = useCallback(() => {
// Increment version to invalidate any in-flight fetch
fetchVersionRef.current++;
forFileIdRef.current = null;
setForFileId(null);
valuesStore.reset({});
dispatch({ type: 'RESET' });
}, [valuesStore]);
const fieldsByPage = useMemo(() => {
const map = new Map<number, FormField[]>();
for (const field of state.fields) {
const pageIdx = field.widgets?.[0]?.pageIndex ?? 0;
if (!map.has(pageIdx)) map.set(pageIdx, []);
map.get(pageIdx)!.push(field);
}
return map;
}, [state.fields]);
// Context value — does NOT depend on values, so keystrokes don't
// trigger re-renders of all context consumers.
const value = useMemo<FormFillContextValue>(
() => ({
state,
fetchFields,
setValue,
setActiveField,
submitForm,
getField,
getFieldsForPage,
getValue,
validateForm,
reset,
fieldsByPage,
activeProviderName: providerRef.current.name,
setProviderMode,
forFileId,
}),
[
state,
fetchFields,
setValue,
setActiveField,
submitForm,
getField,
getFieldsForPage,
getValue,
validateForm,
reset,
fieldsByPage,
providerMode,
setProviderMode,
forFileId,
]
);
return (
<FormValuesStoreContext.Provider value={valuesStore}>
<FormFillContext.Provider value={value}>
{children}
</FormFillContext.Provider>
</FormValuesStoreContext.Provider>
);
}
export default FormFillContext;
@@ -1,17 +1,186 @@
/**
* FormSaveBar stub for the core build.
* This file is overridden in src/proprietary/tools/formFill/FormSaveBar.tsx
* when building the proprietary variant.
* FormSaveBar — A notification banner for form-filled PDFs.
*
* Appears at the top-right of the PDF viewer when the current PDF has
* fillable form fields. Provides options to apply changes or download
* the filled PDF.
*
* This component is used in normal viewer mode (pdf-lib provider) where
* the dedicated FormFill tool panel is NOT active. It provides a clean
* save UX that users expect from browser PDF viewers.
*/
import React, { useCallback, useState } from 'react';
import { Stack, Group, Text, Button, Transition, CloseButton, Paper, Badge } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import DownloadIcon from '@mui/icons-material/Download';
import SaveIcon from '@mui/icons-material/Save';
import EditNoteIcon from '@mui/icons-material/EditNote';
import { useFormFill } from '@app/tools/formFill/FormFillContext';
interface FormSaveBarProps {
/** The current file being viewed */
file: File | Blob | null;
/** Whether the formFill tool is active (bar is hidden when tool panel is showing) */
isFormFillToolActive: boolean;
/** Callback when form changes are applied (should reload PDF with filled values) */
onApply?: (filledBlob: Blob) => Promise<void>;
}
export function FormSaveBar(_props: FormSaveBarProps) {
return null;
export function FormSaveBar({ file, isFormFillToolActive, onApply }: FormSaveBarProps) {
const { t } = useTranslation();
const { state, submitForm } = useFormFill();
const { fields, isDirty, loading } = state;
const [saving, setSaving] = useState(false);
const [applying, setApplying] = useState(false);
const [dismissed, setDismissed] = useState(false);
// Reset dismissed state when file changes
const [prevFile, setPrevFile] = useState<File | Blob | null>(null);
if (file !== prevFile) {
setPrevFile(file);
setDismissed(false);
}
const handleApply = useCallback(async () => {
if (!file || applying || saving) return;
setApplying(true);
try {
// Generate the filled PDF
const filledBlob = await submitForm(file, false);
// Call the onApply callback to reload the PDF in the viewer
if (onApply) {
await onApply(filledBlob);
}
} catch (err) {
console.error('[FormSaveBar] Apply failed:', err);
} finally {
setApplying(false);
}
}, [file, applying, saving, submitForm, onApply]);
const handleDownload = useCallback(async () => {
if (!file || saving || applying) return;
setSaving(true);
try {
const blob = await submitForm(file, false);
// Trigger browser download
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = file instanceof File ? file.name : 'filled-form.pdf';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
console.error('[FormSaveBar] Download failed:', err);
} finally {
setSaving(false);
}
}, [file, saving, applying, submitForm]);
// Don't show when:
// - formFill tool is active (it has its own save panel)
// - no form fields found
// - still loading
// - user dismissed the bar
const hasFields = fields.length > 0;
const visible = !isFormFillToolActive && hasFields && !loading && !dismissed;
return (
<Transition mounted={visible} transition="slide-down" duration={300}>
{(styles) => (
<div
style={{
...styles,
position: 'absolute',
top: '1rem',
right: '1rem',
zIndex: 100,
pointerEvents: 'none',
}}
>
<Paper
shadow="lg"
radius="md"
withBorder
style={{
pointerEvents: 'auto',
minWidth: '320px',
maxWidth: '420px',
overflow: 'hidden',
}}
>
<Stack gap="xs" p="md">
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<EditNoteIcon
sx={{
fontSize: 24,
color: isDirty ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-gray-6)'
}}
/>
<div>
<Group gap="xs">
<Text size="sm" fw={600}>
{t('viewer.formBar.title', 'Form Fields')}
</Text>
{isDirty && (
<Badge size="xs" color="blue" variant="light">
{t('viewer.formBar.unsavedBadge', 'Unsaved')}
</Badge>
)}
</Group>
<Text size="xs" c="dimmed" mt={2}>
{isDirty
? t('viewer.formBar.unsavedDesc', 'You have unsaved changes')
: t('viewer.formBar.hasFieldsDesc', 'This PDF contains fillable fields')}
</Text>
</div>
</Group>
<CloseButton
size="sm"
variant="subtle"
onClick={() => setDismissed(true)}
aria-label={t('viewer.formBar.dismiss', 'Dismiss')}
/>
</Group>
{isDirty && (
<Group gap="xs" mt="xs">
<Button
size="sm"
variant="light"
color="blue"
leftSection={<SaveIcon sx={{ fontSize: 18 }} />}
loading={applying}
disabled={saving}
onClick={handleApply}
flex={1}
>
{t('viewer.formBar.apply', 'Apply Changes')}
</Button>
<Button
size="sm"
variant="filled"
color="blue"
leftSection={<DownloadIcon sx={{ fontSize: 18 }} />}
loading={saving}
disabled={applying}
onClick={handleDownload}
flex={1}
>
{t('viewer.formBar.download', 'Download PDF')}
</Button>
</Group>
)}
</Stack>
</Paper>
</div>
)}
</Transition>
);
}
export default FormSaveBar;
@@ -0,0 +1,32 @@
/**
* Shared field type metadata: icons and color mappings.
* Used by FormFill, FormFieldSidebar, and any future form tools.
*/
import React from 'react';
import type { FormFieldType } from '@app/tools/formFill/types';
import TextFieldsIcon from '@mui/icons-material/TextFields';
import CheckBoxIcon from '@mui/icons-material/CheckBox';
import ArrowDropDownCircleIcon from '@mui/icons-material/ArrowDropDownCircle';
import RadioButtonCheckedIcon from '@mui/icons-material/RadioButtonChecked';
import ListIcon from '@mui/icons-material/List';
import DrawIcon from '@mui/icons-material/Draw';
export const FIELD_TYPE_ICON: Record<FormFieldType, React.ReactNode> = {
text: <TextFieldsIcon sx={{ fontSize: 'inherit' }} />,
checkbox: <CheckBoxIcon sx={{ fontSize: 'inherit' }} />,
combobox: <ArrowDropDownCircleIcon sx={{ fontSize: 'inherit' }} />,
listbox: <ListIcon sx={{ fontSize: 'inherit' }} />,
radio: <RadioButtonCheckedIcon sx={{ fontSize: 'inherit' }} />,
button: <DrawIcon sx={{ fontSize: 'inherit' }} />,
signature: <DrawIcon sx={{ fontSize: 'inherit' }} />,
};
export const FIELD_TYPE_COLOR: Record<FormFieldType, string> = {
text: 'blue',
checkbox: 'green',
combobox: 'violet',
listbox: 'cyan',
radio: 'orange',
button: 'gray',
signature: 'pink',
};
@@ -0,0 +1,46 @@
/**
* API service for form-related backend calls.
*/
import apiClient from '@app/services/apiClient';
import type { FormField } from '@app/tools/formFill/types';
/**
* Fetch form fields with coordinates from the backend.
* Calls POST /api/v1/form/fields-with-coordinates
*/
export async function fetchFormFieldsWithCoordinates(
file: File | Blob
): Promise<FormField[]> {
const formData = new FormData();
formData.append('file', file);
const response = await apiClient.post<FormField[]>(
'/api/v1/form/fields-with-coordinates',
formData
);
return response.data;
}
/**
* Fill form fields and get back a filled PDF blob.
* Calls POST /api/v1/form/fill
*/
export async function fillFormFields(
file: File | Blob,
values: Record<string, string>,
flatten: boolean = false
): Promise<Blob> {
const formData = new FormData();
formData.append('file', file);
formData.append(
'data',
new Blob([JSON.stringify(values)], { type: 'application/json' })
);
formData.append('flatten', String(flatten));
const response = await apiClient.post('/api/v1/form/fill', formData, {
responseType: 'blob',
});
return response.data;
}
+11
View File
@@ -0,0 +1,11 @@
export { FormFillProvider, useFormFill, useFieldValue, useAllFormValues } from '@app/tools/formFill/FormFillContext';
export { FormFieldSidebar } from '@app/tools/formFill/FormFieldSidebar';
export { FormFieldOverlay } from '@app/tools/formFill/FormFieldOverlay';
export { FormSaveBar } from '@app/tools/formFill/FormSaveBar';
export { default as FormFill } from '@app/tools/formFill/FormFill';
export { FieldInput } from '@app/tools/formFill/FieldInput';
export { FIELD_TYPE_ICON, FIELD_TYPE_COLOR } from '@app/tools/formFill/fieldMeta';
export type { FormField, FormFieldType, FormFillState, WidgetCoordinates } from '@app/tools/formFill/types';
export type { IFormDataProvider } from '@app/tools/formFill/providers/types';
export { PdfLibFormProvider } from '@app/tools/formFill/providers/PdfLibFormProvider';
export { PdfBoxFormProvider } from '@app/tools/formFill/providers/PdfBoxFormProvider';
@@ -0,0 +1,32 @@
/**
* PdfBoxFormProvider — Backend API form data provider using PDFBox.
*
* Delegates form field extraction and filling to the server-side Java
* implementation via REST endpoints. This provides full-fidelity form
* handling including complex field types, appearance generation, and
* proper CJK font support.
*
* Used in the dedicated formFill tool mode.
*/
import type { FormField } from '@app/tools/formFill/types';
import type { IFormDataProvider } from '@app/tools/formFill/providers/types';
import {
fetchFormFieldsWithCoordinates,
fillFormFields,
} from '@app/tools/formFill/formApi';
export class PdfBoxFormProvider implements IFormDataProvider {
readonly name = 'pdfbox';
async fetchFields(file: File | Blob): Promise<FormField[]> {
return fetchFormFieldsWithCoordinates(file);
}
async fillForm(
file: File | Blob,
values: Record<string, string>,
flatten: boolean,
): Promise<Blob> {
return fillFormFields(file, values, flatten);
}
}
@@ -0,0 +1,701 @@
/**
* PdfLibFormProvider — Frontend-only form data provider using pdf-lib.
*
* Extracts form fields directly from the PDF byte stream and fills them
* without any backend calls. This avoids sending large PDFs (potentially
* hundreds of MB) to the server for a feature that can be done entirely
* on the client.
*
* Used in normal viewer mode when the user views a PDF with form fields.
*
* Coordinate system:
* pdf-lib provides widget rectangles in PDF user space (lower-left origin).
* We transform them to CSS space (top-left origin) matching what the backend
* FormUtils.createWidgetCoordinates() does, so the same overlay code works
* for both providers.
*/
import { PDFDocument, PDFForm, PDFField, PDFTextField, PDFCheckBox,
PDFDropdown, PDFRadioGroup, PDFOptionList, PDFButton, PDFSignature,
PDFName, PDFDict, PDFArray, PDFNumber, PDFRef, PDFPage,
PDFString, PDFHexString } from '@cantoo/pdf-lib';
import type { FormField, FormFieldType, WidgetCoordinates } from '@app/tools/formFill/types';
import type { IFormDataProvider } from '@app/tools/formFill/providers/types';
/**
* Read a File/Blob as ArrayBuffer.
*/
async function readAsArrayBuffer(file: File | Blob): Promise<ArrayBuffer> {
return file.arrayBuffer();
}
/**
* Get the page index for a widget annotation by finding which page contains it.
*/
function getWidgetPageIndex(
widget: PDFDict,
pages: PDFPage[],
): number {
// Check /P entry first (direct page reference)
const pRef = widget.get(PDFName.of('P'));
if (pRef instanceof PDFRef) {
for (let i = 0; i < pages.length; i++) {
if (pages[i].ref === pRef) return i;
}
}
// Fall back to scanning each page's /Annots array
const widgetRef = findWidgetRef(widget, pages);
if (widgetRef !== undefined) return widgetRef;
return 0; // default to first page
}
function findWidgetRef(widget: PDFDict, pages: PDFPage[]): number | undefined {
for (let i = 0; i < pages.length; i++) {
const annots = pages[i].node.lookup(PDFName.of('Annots'));
if (annots instanceof PDFArray) {
for (let j = 0; j < annots.size(); j++) {
const annotRef = annots.get(j);
const annotDict = annots.lookup(j);
if (annotDict === widget || annotRef === (widget as any).ref) {
return i;
}
}
}
}
return undefined;
}
/**
* Get the page rotation in degrees (0, 90, 180, 270).
*/
function getPageRotation(page: PDFPage): number {
const rot = page.getRotation();
return rot?.angle ?? 0;
}
/**
* Extract widget rectangles from a PDFField, transforming from PDF space
* (lower-left origin) to CSS space (top-left origin).
*
* Widget /Rect coordinates are always in un-rotated PDF user space
* (defined by the MediaBox/CropBox). We only need a y-flip to convert
* from PDF's lower-left origin to CSS's upper-left origin.
*
* The embedpdf viewer wraps all page content (including this overlay)
* inside a <Rotate> CSS component that handles visual rotation.
* Therefore we must NOT apply any rotation here — doing so would
* double-rotate the widgets.
*/
function extractWidgets(
field: PDFField,
pages: PDFPage[],
_doc: PDFDocument,
): WidgetCoordinates[] {
const widgets: WidgetCoordinates[] = [];
// Access the underlying PDFDict from the acro field
const acroFieldDict = (field.acroField as any).dict as PDFDict;
// Get all widget annotations for this field
const widgetDicts = getFieldWidgets(acroFieldDict);
for (const wDict of widgetDicts) {
const rect = wDict.lookup(PDFName.of('Rect'));
if (!(rect instanceof PDFArray) || rect.size() < 4) continue;
const x1 = numberVal(rect.lookup(0));
const y1 = numberVal(rect.lookup(1));
const x2 = numberVal(rect.lookup(2));
const y2 = numberVal(rect.lookup(3));
const pageIndex = getWidgetPageIndex(wDict, pages);
const page = pages[pageIndex];
if (!page) continue;
// Get CropBox dimensions (un-rotated) for coordinate transformation
const cropBox = getCropBox(page);
const cropHeight = cropBox.height;
const cropX = cropBox.x;
const cropY = cropBox.y;
// Widget rect in PDF space (lower-left origin, un-rotated)
const pdfX = Math.min(x1, x2);
const pdfY = Math.min(y1, y2);
const pdfW = Math.abs(x2 - x1);
const pdfH = Math.abs(y2 - y1);
// Adjust relative to CropBox origin
const relativeX = pdfX - cropX;
const relativeY = pdfY - cropY;
// Convert from PDF lower-left origin to CSS upper-left origin (y-flip).
// No rotation transform here — the <Rotate> CSS component in the viewer
// handles page rotation for all overlays including form fields.
const finalX = relativeX;
const finalY = cropHeight - relativeY - pdfH;
const finalW = pdfW;
const finalH = pdfH;
// Extract export value for checkboxes/radios
let exportValue: string | undefined;
const ap = wDict.lookup(PDFName.of('AP'));
if (ap instanceof PDFDict) {
const normal = ap.lookup(PDFName.of('N'));
if (normal instanceof PDFDict) {
// The keys of /N (other than /Off) are the export values.
// PDFDict.entries() reliably returns [PDFName, PDFObject][] in
// @cantoo/pdf-lib — no optional chaining needed.
try {
const entries = normal.entries();
const keys = entries
.map(([k]) => k.decodeText())
.filter((k) => k !== 'Off');
if (keys.length > 0) exportValue = keys[0];
} catch {
// Malformed AP dict — skip export value extraction
}
}
}
// Also check /AS for current appearance state
if (!exportValue) {
const asEntry = wDict.lookup(PDFName.of('AS'));
if (asEntry instanceof PDFName) {
const asVal = asEntry.decodeText();
if (asVal !== 'Off') exportValue = asVal;
}
}
// Extract font size from default appearance string
let fontSize: number | undefined;
const da = wDict.lookup(PDFName.of('DA'));
if (da) {
const daStr = da.toString();
const tfMatch = daStr.match(/(\d+(?:\.\d+)?)\s+Tf/);
if (tfMatch) {
fontSize = parseFloat(tfMatch[1]);
if (fontSize === 0) fontSize = undefined; // 0 means auto-size
}
}
widgets.push({
pageIndex,
x: finalX,
y: finalY,
width: finalW,
height: finalH,
exportValue,
fontSize,
});
}
return widgets;
}
function numberVal(obj: any): number {
if (obj instanceof PDFNumber) return obj.asNumber();
if (typeof obj === 'number') return obj;
return 0;
}
/**
* Get the CropBox (or MediaBox fallback) dimensions in un-rotated PDF space.
* These are the raw dictionary values without any rotation adjustment.
*/
function getCropBox(page: PDFPage): { x: number; y: number; width: number; height: number } {
// Check direct CropBox entry
const cropBox = page.node.lookup(PDFName.of('CropBox'));
if (cropBox instanceof PDFArray && cropBox.size() >= 4) {
return {
x: numberVal(cropBox.lookup(0)),
y: numberVal(cropBox.lookup(1)),
width: numberVal(cropBox.lookup(2)) - numberVal(cropBox.lookup(0)),
height: numberVal(cropBox.lookup(3)) - numberVal(cropBox.lookup(1)),
};
}
// Check direct MediaBox entry
const mediaBox = page.node.lookup(PDFName.of('MediaBox'));
if (mediaBox instanceof PDFArray && mediaBox.size() >= 4) {
return {
x: numberVal(mediaBox.lookup(0)),
y: numberVal(mediaBox.lookup(1)),
width: numberVal(mediaBox.lookup(2)) - numberVal(mediaBox.lookup(0)),
height: numberVal(mediaBox.lookup(3)) - numberVal(mediaBox.lookup(1)),
};
}
// Traverse parent page-tree nodes for inherited MediaBox
let node: any = page.node;
while (node) {
const parentNode = node.lookup(PDFName.of('Parent'));
if (parentNode instanceof PDFDict) {
const inheritedBox = parentNode.lookup(PDFName.of('MediaBox'));
if (inheritedBox instanceof PDFArray && inheritedBox.size() >= 4) {
return {
x: numberVal(inheritedBox.lookup(0)),
y: numberVal(inheritedBox.lookup(1)),
width: numberVal(inheritedBox.lookup(2)) - numberVal(inheritedBox.lookup(0)),
height: numberVal(inheritedBox.lookup(3)) - numberVal(inheritedBox.lookup(1)),
};
}
node = parentNode;
} else {
break;
}
}
// Last resort: use page.getSize() but un-rotate the dimensions
const { width, height } = page.getSize();
const rotation = getPageRotation(page);
if (rotation === 90 || rotation === 270) {
return { x: 0, y: 0, width: height, height: width };
}
return { x: 0, y: 0, width, height };
}
/**
* Get the widget annotation dictionaries for a field.
* A field can either BE a widget (merged) or have child /Kids that are widgets.
*/
function getFieldWidgets(acroField: PDFDict): PDFDict[] {
const kids = acroField.lookup(PDFName.of('Kids'));
if (kids instanceof PDFArray) {
const result: PDFDict[] = [];
for (let i = 0; i < kids.size(); i++) {
const kid = kids.lookup(i);
if (kid instanceof PDFDict) {
// Check if this kid is a widget (has /Rect) vs another field node
const subtype = kid.lookup(PDFName.of('Subtype'));
if (subtype instanceof PDFName && subtype.decodeText() === 'Widget') {
result.push(kid);
} else if (kid.lookup(PDFName.of('Rect'))) {
// Merged field/widget — has Rect but maybe no explicit Subtype
result.push(kid);
} else {
// Intermediate field node — recurse
result.push(...getFieldWidgets(kid));
}
}
}
return result;
}
// No Kids — the field dict itself is the widget (merged field/widget)
if (acroField.lookup(PDFName.of('Rect'))) {
return [acroField];
}
return [];
}
/**
* Determine the FormFieldType from a pdf-lib PDFField.
*/
function getFieldType(field: PDFField): FormFieldType {
if (field instanceof PDFTextField) return 'text';
if (field instanceof PDFCheckBox) return 'checkbox';
if (field instanceof PDFDropdown) return 'combobox';
if (field instanceof PDFRadioGroup) return 'radio';
if (field instanceof PDFOptionList) return 'listbox';
if (field instanceof PDFButton) return 'button';
if (field instanceof PDFSignature) return 'signature';
return 'text';
}
/**
* Get the current value of a field as a string.
*/
function getFieldValue(field: PDFField): string {
try {
if (field instanceof PDFTextField) {
return field.getText() ?? '';
}
if (field instanceof PDFCheckBox) {
return field.isChecked() ? 'Yes' : 'Off';
}
if (field instanceof PDFDropdown) {
const selected = field.getSelected();
return selected.length > 0 ? selected[0] : '';
}
if (field instanceof PDFRadioGroup) {
return getRadioValue(field);
}
if (field instanceof PDFOptionList) {
const selected = field.getSelected();
return selected.join(',');
}
} catch {
// Some fields may throw on getValue if malformed
}
return '';
}
function getRadioValue(field: PDFRadioGroup): string {
const selected = field.getSelected() ?? '';
if (!selected || selected === 'Off') return selected;
const options = field.getOptions();
if (options.includes(selected)) return selected;
const mappedOption = mapAppearanceStateToOption(field, selected, options);
if (mappedOption) return mappedOption;
const index = parseInt(selected, 10);
if (!isNaN(index) && index >= 0 && index < options.length) {
return options[index];
}
return selected;
}
function mapAppearanceStateToOption(
field: PDFRadioGroup,
stateName: string,
options: string[],
): string | undefined {
try {
const acroFieldDict = (field.acroField as any).dict as PDFDict;
const widgets = getFieldWidgets(acroFieldDict);
for (let i = 0; i < widgets.length; i++) {
const ap = widgets[i].lookup(PDFName.of('AP'));
if (!(ap instanceof PDFDict)) continue;
const normal = ap.lookup(PDFName.of('N'));
if (!(normal instanceof PDFDict)) continue;
let keys: string[] = [];
try {
keys = normal.entries().map(([k]) => k.decodeText());
} catch {
continue;
}
if (keys.includes(stateName) && i < options.length) {
return options[i];
}
}
} catch {
// Fallback handled by caller
}
return undefined;
}
function resolveRadioValueForSelect(
field: PDFRadioGroup,
value: string,
): string | null {
const options = field.getOptions();
if (options.includes(value)) return value;
const mappedOption = mapAppearanceStateToOption(field, value, options);
if (mappedOption) return mappedOption;
const index = parseInt(value, 10);
if (!isNaN(index) && index >= 0 && index < options.length) {
return options[index];
}
const lower = value.toLowerCase();
const match = options.find((o: string) => o.toLowerCase() === lower);
if (match) return match;
return null;
}
/**
* Get field options (for dropdowns, listboxes, radios).
*/
function getFieldOptions(field: PDFField): string[] | null {
try {
if (field instanceof PDFDropdown) {
return field.getOptions();
}
if (field instanceof PDFOptionList) {
return field.getOptions();
}
if (field instanceof PDFRadioGroup) {
return field.getOptions();
}
} catch {
// ignore
}
return null;
}
/**
* Extract display labels from the /Opt array if it contains [export, display]
* pairs. PDF spec §12.7.4.4: each element of /Opt may be either a text
* string (export value == display value) or a two-element array where the
* first element is the export value and the second is the display text.
*
* Returns null when every display value equals its export value (no distinct
* display labels exist), keeping the interface lean for the common case.
*/
function getFieldDisplayOptions(field: PDFField): string[] | null {
if (!(field instanceof PDFDropdown) && !(field instanceof PDFOptionList)) {
return null;
}
try {
const acroDict = (field.acroField as any).dict as PDFDict;
const optRaw = acroDict.lookup(PDFName.of('Opt'));
if (!(optRaw instanceof PDFArray)) return null;
const displays: string[] = [];
let hasDifference = false;
for (let i = 0; i < optRaw.size(); i++) {
try {
const entry = optRaw.lookup(i);
if (entry instanceof PDFArray && entry.size() >= 2) {
// [exportValue, displayValue] pair
const exp = decodeText(entry.lookup(0));
const disp = decodeText(entry.lookup(1));
displays.push(disp);
if (exp !== disp) hasDifference = true;
} else {
// Plain string — export and display are the same
const val = decodeText(entry);
displays.push(val);
}
} catch {
// Malformed /Opt entry — skip but continue processing remaining entries
continue;
}
}
if (displays.length === 0) return null;
return hasDifference ? displays : null;
} catch {
return null;
}
}
/** Decode a PDFString, PDFHexString, or PDFName to a JS string. */
function decodeText(obj: unknown): string {
if (obj instanceof PDFString || obj instanceof PDFHexString) {
return obj.decodeText();
}
if (obj instanceof PDFName) {
return obj.decodeText();
}
if (typeof obj === 'string') return obj;
return String(obj ?? '');
}
/**
* Check if a field is read-only.
*/
function isFieldReadOnly(field: PDFField): boolean {
try {
return field.isReadOnly();
} catch {
return false;
}
}
/**
* Check if a field is required.
*/
function isFieldRequired(field: PDFField): boolean {
try {
return field.isRequired();
} catch {
return false;
}
}
/**
* Get field tooltip (TU entry).
* Uses proper PDFString/PDFHexString decoding for correct Unicode support.
*/
function getFieldTooltip(acroField: PDFDict): string | null {
const tu = acroField.lookup(PDFName.of('TU'));
if (!tu) return null;
try {
// Prefer decodeText() for proper Unicode handling (UTF-16BE / PDFDocEncoding)
if (tu instanceof PDFString || tu instanceof PDFHexString) {
return tu.decodeText();
}
// Fallback: strip parentheses from raw toString() for other object types
return tu.toString().replace(/^\(|\)$/g, '');
} catch {
return null;
}
}
/**
* Check if a text field is multiline (flag bit 13 set in /Ff).
*/
function isMultiline(field: PDFField): boolean {
if (!(field instanceof PDFTextField)) return false;
try {
return field.isMultiline();
} catch {
return false;
}
}
/**
* Get the label for a field — use the partial name or the full qualified name.
*/
function getFieldLabel(field: PDFField): string {
const name = field.getName();
// Use the last segment of the qualified name as the label
const parts = name.split('.');
return parts[parts.length - 1] || name;
}
export class PdfLibFormProvider implements IFormDataProvider {
readonly name = 'pdf-lib';
async fetchFields(file: File | Blob): Promise<FormField[]> {
const arrayBuffer = await readAsArrayBuffer(file);
let doc: PDFDocument;
try {
doc = await PDFDocument.load(arrayBuffer, {
ignoreEncryption: true,
updateMetadata: false,
throwOnInvalidObject: false,
});
} catch (loadError) {
console.warn('[PdfLibFormProvider] Failed to load PDF document:', loadError);
return [];
}
let form: PDFForm;
try {
form = doc.getForm();
} catch (formError) {
// No AcroForm or broken catalog — return empty
console.warn('[PdfLibFormProvider] Failed to access AcroForm:', formError);
return [];
}
let fields: PDFField[];
try {
fields = form.getFields();
} catch (fieldsError) {
console.warn('[PdfLibFormProvider] Failed to enumerate form fields:', fieldsError);
return [];
}
if (fields.length === 0) return [];
let pages: PDFPage[];
try {
pages = doc.getPages();
} catch (pagesError) {
// Pages tree is invalid (same issue as usePdfLibLinks "invalid catalog").
// Without page references we can't place widgets, so return empty.
// The viewer will fall back to native form rendering via withForms.
console.warn(
'[PdfLibFormProvider] PDF pages tree is invalid — cannot place form widgets.',
'Native form rendering will be used as fallback.',
pagesError,
);
return [];
}
const result: FormField[] = [];
for (const field of fields) {
const fieldName = field.getName();
try {
const type = getFieldType(field);
const widgets = extractWidgets(field, pages, doc);
// Skip fields with no visible widgets
if (widgets.length === 0) continue;
const formField: FormField = {
name: field.getName(),
label: getFieldLabel(field),
type,
value: getFieldValue(field),
options: getFieldOptions(field),
displayOptions: getFieldDisplayOptions(field),
required: isFieldRequired(field),
readOnly: isFieldReadOnly(field),
multiSelect: field instanceof PDFOptionList,
multiline: isMultiline(field),
tooltip: getFieldTooltip((field.acroField as any).dict as PDFDict),
widgets,
};
result.push(formField);
} catch (fieldError) {
// Skip individual malformed fields but continue processing
console.warn(`[PdfLibFormProvider] Skipping field "${fieldName}":`, fieldError);
}
}
return result;
}
async fillForm(
file: File | Blob,
values: Record<string, string>,
flatten: boolean,
): Promise<Blob> {
const arrayBuffer = await readAsArrayBuffer(file);
const doc = await PDFDocument.load(arrayBuffer, {
ignoreEncryption: true,
throwOnInvalidObject: false,
});
const form = doc.getForm();
const fields = form.getFields();
for (const field of fields) {
const fieldName = field.getName();
if (!(fieldName in values)) continue;
const value = values[fieldName];
try {
if (field instanceof PDFTextField) {
field.setText(value || undefined);
} else if (field instanceof PDFCheckBox) {
if (value && value !== 'Off') {
field.check();
} else {
field.uncheck();
}
} else if (field instanceof PDFDropdown) {
if (value) {
field.select(value);
} else {
field.clear();
}
} else if (field instanceof PDFRadioGroup) {
if (value && value !== 'Off') {
const resolved = resolveRadioValueForSelect(field, value);
if (resolved) {
field.select(resolved);
} else {
console.warn(
`[PdfLibFormProvider] Radio value "${value}" could not be mapped to options [${field.getOptions().join(', ')}] for field "${fieldName}"`,
);
}
}
} else if (field instanceof PDFOptionList) {
if (value) {
const vals = value.split(',').filter(Boolean);
field.select(vals[0]); // PDFOptionList.select takes single value
} else {
field.clear();
}
}
} catch (err) {
console.warn(`[PdfLibFormProvider] Failed to set value for field "${fieldName}":`, err);
}
}
if (flatten) {
form.flatten();
}
const pdfBytes = await doc.save();
return new Blob([pdfBytes.slice().buffer as ArrayBuffer], { type: 'application/pdf' });
}
}
@@ -0,0 +1,3 @@
export type { IFormDataProvider } from '@app/tools/formFill/providers/types';
export { PdfLibFormProvider } from '@app/tools/formFill/providers/PdfLibFormProvider';
export { PdfBoxFormProvider } from '@app/tools/formFill/providers/PdfBoxFormProvider';
@@ -0,0 +1,37 @@
/**
* IFormDataProvider — Common interface for form data providers.
*
* This abstraction allows the form fill UI to work with different backends:
* - PdfLibFormProvider: Frontend-only, uses pdf-lib to extract/fill form fields
* (used in normal viewer mode to avoid sending large PDFs to the backend)
* - PdfBoxFormProvider: Backend API, uses PDFBox via REST endpoints
* (used in the dedicated formFill tool for full-fidelity form handling)
*
* The UI components (FormFieldOverlay, FormFill, FormFieldSidebar) consume
* data through FormFillContext, which delegates to whichever provider is active.
*/
import type { FormField } from '@app/tools/formFill/types';
export interface IFormDataProvider {
/** Unique identifier for the provider (for debugging/logging) */
readonly name: string;
/**
* Extract form fields with their coordinates from a PDF file.
* Returns the same FormField[] shape regardless of provider.
*/
fetchFields(file: File | Blob): Promise<FormField[]>;
/**
* Apply filled values to a PDF and return the resulting PDF blob.
* @param file - The original PDF
* @param values - Map of field name → value
* @param flatten - Whether to flatten the form (make fields non-editable)
* @returns The filled PDF as a Blob
*/
fillForm(
file: File | Blob,
values: Record<string, string>,
flatten: boolean,
): Promise<Blob>;
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Types for the Form Fill PDF Viewer feature.
* These mirror the backend FormFieldWithCoordinates model.
*/
export interface WidgetCoordinates {
pageIndex: number;
x: number; // PDF points, un-rotated, CSS upper-left origin
y: number; // PDF points, un-rotated, CSS upper-left origin
width: number; // PDF points
height: number; // PDF points
/** Export value for this specific widget (radio/checkbox only) */
exportValue?: string;
/** Font size in PDF points */
fontSize?: number;
}
export interface FormField {
name: string;
label: string;
type: FormFieldType;
value: string;
/** Export values used for data binding (sent to backend) */
options: string[] | null;
/** Human-readable display labels parallel to options. Null when same as options. */
displayOptions: string[] | null;
required: boolean;
readOnly: boolean;
multiSelect: boolean;
multiline: boolean;
tooltip: string | null;
widgets: WidgetCoordinates[] | null;
}
export type FormFieldType =
| 'text'
| 'checkbox'
| 'combobox'
| 'listbox'
| 'radio'
| 'button'
| 'signature';
export interface FormFillState {
/** Fields fetched from backend with coordinates */
fields: FormField[];
/** Current user-entered values keyed by field name */
values: Record<string, string>;
/** Whether a backend fetch is in progress */
loading: boolean;
/** Error message from fetch */
error: string | null;
/** Currently focused/selected field name */
activeFieldName: string | null;
/** Whether the form has been modified */
isDirty: boolean;
/** Current validation errors keyed by field name */
validationErrors: Record<string, string>;
}
+1
View File
@@ -56,6 +56,7 @@ export const CORE_REGULAR_TOOL_IDS = [
'showJS',
'bookletImposition',
'pdfTextEditor',
'formFill',
] as const;
export const CORE_SUPER_TOOL_IDS = [