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 (
+
+ {field.multiline ? (
+
+ );
+
+ 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 (
+ {
+ if (field.readOnly) return;
+ handleFocus();
+ onChange(field.name, isChecked ? 'Off' : onValue);
+ stopPropagation(e);
+ }}
+ >
+
+ ✓
+
+
+ );
+ }
+
+ 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) => {
+ 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 (
+
+
+ {!field.multiSelect && — select — }
+ {(field.options || []).map((opt, idx) => (
+
+ {(field.displayOptions && field.displayOptions[idx]) || opt}
+
+ ))}
+
+
+ );
+ }
+
+ 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 (
+ {
+ if (field.readOnly || value === optionValue) return; // Don't deselect radio buttons
+ handleFocus();
+ onChange(field.name, optionValue);
+ stopPropagation(e);
+ }}
+ >
+
+
+ );
+ }
+
+ case 'signature':
+ case 'button':
+ // Just render a highlighted area — not editable
+ return (
+
+ );
+
+ default:
+ return (
+
+ onChange(field.name, e.target.value)}
+ onFocus={handleFocus}
+ disabled={field.readOnly}
+ style={inputBaseStyle}
+ {...captureStopProps}
+ />
+
+ );
+ }
+}
+
+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 (
+
+ {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 CSS wrapper handles visual rotation for us,
+ // just like it does for TilingLayer, LinkLayer, etc.
+ return (
+
+ );
+ })
+ )}
+
+ );
}
export default FormFieldOverlay;
diff --git a/frontend/src/core/tools/formFill/FormFieldSidebar.tsx b/frontend/src/core/tools/formFill/FormFieldSidebar.tsx
index 637fdc0fb..949b530ff 100644
--- a/frontend/src/core/tools/formFill/FormFieldSidebar.tsx
+++ b/frontend/src/core/tools/formFill/FormFieldSidebar.tsx
@@ -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(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();
+ 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 (
+
+ {/* Header */}
+
+
+
+
+ Form Fields
+
+
+ {fields.length}
+
+
+
+
+
+
+
+ {/* Content */}
+
+ {loading && (
+
+
+ Loading form fields...
+
+
+ )}
+
+ {!loading && fields.length === 0 && (
+
+
+ No form fields found in this PDF
+
+
+ )}
+
+ {!loading && fields.length > 0 && (
+
+ {sortedPages.map((pageIdx, i) => (
+
+
+
+ Page {pageIdx + 1}
+
+
+
+ {fieldsByPage.get(pageIdx)!.map((field) => {
+ const isActive = activeFieldName === field.name;
+
+ return (
+ handleFieldClick(field.name)}
+ >
+
+
+
+ {FIELD_TYPE_ICON[field.type]}
+
+
+
+ {field.label || field.name}
+
+ {field.required && (
+ req
+ )}
+
+
+ {field.type !== 'button' && field.type !== 'signature' && (
+
+
+
+ )}
+
+ {field.tooltip && (
+
+ {field.tooltip}
+
+ )}
+
+ );
+ })}
+
+ ))}
+
+ )}
+
+
+ );
}
export default FormFieldSidebar;
diff --git a/frontend/src/proprietary/tools/formFill/FormFill.module.css b/frontend/src/core/tools/formFill/FormFill.module.css
similarity index 100%
rename from frontend/src/proprietary/tools/formFill/FormFill.module.css
rename to frontend/src/core/tools/formFill/FormFill.module.css
diff --git a/frontend/src/proprietary/tools/formFill/FormFill.tsx b/frontend/src/core/tools/formFill/FormFill.tsx
similarity index 98%
rename from frontend/src/proprietary/tools/formFill/FormFill.tsx
rename to frontend/src/core/tools/formFill/FormFill.tsx
index 11f89e4b9..f09bcc484 100644
--- a/frontend/src/proprietary/tools/formFill/FormFill.tsx
+++ b/frontend/src/core/tools/formFill/FormFill.tsx
@@ -21,16 +21,16 @@ import {
Tooltip,
ActionIcon,
} from '@mantine/core';
-import { useFormFill, useAllFormValues } from '@proprietary/tools/formFill/FormFillContext';
+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 '@proprietary/tools/formFill/types';
-import { FieldInput } from '@proprietary/tools/formFill/FieldInput';
-import { FIELD_TYPE_ICON, FIELD_TYPE_COLOR } from '@proprietary/tools/formFill/fieldMeta';
+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';
@@ -40,7 +40,7 @@ import FileCopyIcon from '@mui/icons-material/FileCopy';
import BuildCircleIcon from '@mui/icons-material/BuildCircle';
import DescriptionIcon from '@mui/icons-material/Description';
import FileDownloadIcon from '@mui/icons-material/FileDownload';
-import styles from '@proprietary/tools/formFill/FormFill.module.css';
+import styles from '@app/tools/formFill/FormFill.module.css';
// ---------------------------------------------------------------------------
// Mode tabs — extensible for future form tools
diff --git a/frontend/src/core/tools/formFill/FormFillContext.tsx b/frontend/src/core/tools/formFill/FormFillContext.tsx
index 42ea4d8ca..611c212c9 100644
--- a/frontend/src/core/tools/formFill/FormFillContext.tsx
+++ b/frontend/src/core/tools/formFill/FormFillContext.tsx
@@ -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;
- loading: boolean;
- error: string | null;
- activeFieldName: string | null;
- isDirty: boolean;
- validationErrors: Record;
- };
+// ---------------------------------------------------------------------------
+// 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>();
+ private _globalListeners = new Set();
+
+ private _values: Record = {};
+
+ get values(): Record {
+ 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 = {}): 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 }
+ | { 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;
+ /** 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;
- 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;
+ /** 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;
+ /** Pre-computed map of page index to fields for performance */
+ fieldsByPage: Map;
+ /** 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(null);
+/**
+ * Separate context for the values store.
+ * This allows useFieldValue() to subscribe without depending on the main context.
+ */
+const FormValuesStoreContext = createContext(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 {
- 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([]);
+ 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(null);
+ const [forFileId, setForFileId] = useState(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 = {};
+ 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 = {};
+ 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();
+ 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(
+ () => ({
+ 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 (
+
+
+ {children}
+
+
+ );
}
export default FormFillContext;
diff --git a/frontend/src/core/tools/formFill/FormSaveBar.tsx b/frontend/src/core/tools/formFill/FormSaveBar.tsx
index 673105997..cc433b6de 100644
--- a/frontend/src/core/tools/formFill/FormSaveBar.tsx
+++ b/frontend/src/core/tools/formFill/FormSaveBar.tsx
@@ -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;
}
-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(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 (
+
+ {(styles) => (
+
+
+
+
+
+
+
+
+
+ {t('viewer.formBar.title', 'Form Fields')}
+
+ {isDirty && (
+
+ {t('viewer.formBar.unsavedBadge', 'Unsaved')}
+
+ )}
+
+
+ {isDirty
+ ? t('viewer.formBar.unsavedDesc', 'You have unsaved changes')
+ : t('viewer.formBar.hasFieldsDesc', 'This PDF contains fillable fields')}
+
+
+
+ setDismissed(true)}
+ aria-label={t('viewer.formBar.dismiss', 'Dismiss')}
+ />
+
+
+ {isDirty && (
+
+ }
+ loading={applying}
+ disabled={saving}
+ onClick={handleApply}
+ flex={1}
+ >
+ {t('viewer.formBar.apply', 'Apply Changes')}
+
+ }
+ loading={saving}
+ disabled={applying}
+ onClick={handleDownload}
+ flex={1}
+ >
+ {t('viewer.formBar.download', 'Download PDF')}
+
+
+ )}
+
+
+
+ )}
+
+ );
}
export default FormSaveBar;
diff --git a/frontend/src/proprietary/tools/formFill/fieldMeta.tsx b/frontend/src/core/tools/formFill/fieldMeta.tsx
similarity index 94%
rename from frontend/src/proprietary/tools/formFill/fieldMeta.tsx
rename to frontend/src/core/tools/formFill/fieldMeta.tsx
index 80a27bb74..e205b76e9 100644
--- a/frontend/src/proprietary/tools/formFill/fieldMeta.tsx
+++ b/frontend/src/core/tools/formFill/fieldMeta.tsx
@@ -3,7 +3,7 @@
* Used by FormFill, FormFieldSidebar, and any future form tools.
*/
import React from 'react';
-import type { FormFieldType } from '@proprietary/tools/formFill/types';
+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';
diff --git a/frontend/src/proprietary/tools/formFill/formApi.ts b/frontend/src/core/tools/formFill/formApi.ts
similarity index 94%
rename from frontend/src/proprietary/tools/formFill/formApi.ts
rename to frontend/src/core/tools/formFill/formApi.ts
index 8b2c8d409..4607773f8 100644
--- a/frontend/src/proprietary/tools/formFill/formApi.ts
+++ b/frontend/src/core/tools/formFill/formApi.ts
@@ -2,7 +2,7 @@
* API service for form-related backend calls.
*/
import apiClient from '@app/services/apiClient';
-import type { FormField } from '@proprietary/tools/formFill/types';
+import type { FormField } from '@app/tools/formFill/types';
/**
* Fetch form fields with coordinates from the backend.
diff --git a/frontend/src/core/tools/formFill/index.ts b/frontend/src/core/tools/formFill/index.ts
new file mode 100644
index 000000000..31b5b4c90
--- /dev/null
+++ b/frontend/src/core/tools/formFill/index.ts
@@ -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';
diff --git a/frontend/src/proprietary/tools/formFill/providers/PdfBoxFormProvider.ts b/frontend/src/core/tools/formFill/providers/PdfBoxFormProvider.ts
similarity index 80%
rename from frontend/src/proprietary/tools/formFill/providers/PdfBoxFormProvider.ts
rename to frontend/src/core/tools/formFill/providers/PdfBoxFormProvider.ts
index 6cd01d6cd..a2329a48b 100644
--- a/frontend/src/proprietary/tools/formFill/providers/PdfBoxFormProvider.ts
+++ b/frontend/src/core/tools/formFill/providers/PdfBoxFormProvider.ts
@@ -8,12 +8,12 @@
*
* Used in the dedicated formFill tool mode.
*/
-import type { FormField } from '@proprietary/tools/formFill/types';
-import type { IFormDataProvider } from '@proprietary/tools/formFill/providers/types';
+import type { FormField } from '@app/tools/formFill/types';
+import type { IFormDataProvider } from '@app/tools/formFill/providers/types';
import {
fetchFormFieldsWithCoordinates,
fillFormFields,
-} from '@proprietary/tools/formFill/formApi';
+} from '@app/tools/formFill/formApi';
export class PdfBoxFormProvider implements IFormDataProvider {
readonly name = 'pdfbox';
diff --git a/frontend/src/proprietary/tools/formFill/providers/PdfLibFormProvider.ts b/frontend/src/core/tools/formFill/providers/PdfLibFormProvider.ts
similarity index 99%
rename from frontend/src/proprietary/tools/formFill/providers/PdfLibFormProvider.ts
rename to frontend/src/core/tools/formFill/providers/PdfLibFormProvider.ts
index 298d40aa8..2da141cc4 100644
--- a/frontend/src/proprietary/tools/formFill/providers/PdfLibFormProvider.ts
+++ b/frontend/src/core/tools/formFill/providers/PdfLibFormProvider.ts
@@ -18,8 +18,8 @@ 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 '@proprietary/tools/formFill/types';
-import type { IFormDataProvider } from '@proprietary/tools/formFill/providers/types';
+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.
diff --git a/frontend/src/core/tools/formFill/providers/index.ts b/frontend/src/core/tools/formFill/providers/index.ts
new file mode 100644
index 000000000..ec0a3663e
--- /dev/null
+++ b/frontend/src/core/tools/formFill/providers/index.ts
@@ -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';
diff --git a/frontend/src/proprietary/tools/formFill/providers/types.ts b/frontend/src/core/tools/formFill/providers/types.ts
similarity index 95%
rename from frontend/src/proprietary/tools/formFill/providers/types.ts
rename to frontend/src/core/tools/formFill/providers/types.ts
index be02a7583..605111fec 100644
--- a/frontend/src/proprietary/tools/formFill/providers/types.ts
+++ b/frontend/src/core/tools/formFill/providers/types.ts
@@ -10,7 +10,7 @@
* The UI components (FormFieldOverlay, FormFill, FormFieldSidebar) consume
* data through FormFillContext, which delegates to whichever provider is active.
*/
-import type { FormField } from '@proprietary/tools/formFill/types';
+import type { FormField } from '@app/tools/formFill/types';
export interface IFormDataProvider {
/** Unique identifier for the provider (for debugging/logging) */
diff --git a/frontend/src/proprietary/tools/formFill/types.ts b/frontend/src/core/tools/formFill/types.ts
similarity index 100%
rename from frontend/src/proprietary/tools/formFill/types.ts
rename to frontend/src/core/tools/formFill/types.ts
diff --git a/frontend/src/core/types/toolId.ts b/frontend/src/core/types/toolId.ts
index be434027d..7033c568d 100644
--- a/frontend/src/core/types/toolId.ts
+++ b/frontend/src/core/types/toolId.ts
@@ -56,6 +56,7 @@ export const CORE_REGULAR_TOOL_IDS = [
'showJS',
'bookletImposition',
'pdfTextEditor',
+ 'formFill',
] as const;
export const CORE_SUPER_TOOL_IDS = [
diff --git a/frontend/src/proprietary/data/useProprietaryToolRegistry.tsx b/frontend/src/proprietary/data/useProprietaryToolRegistry.tsx
index 228ee9e8c..7181598b9 100644
--- a/frontend/src/proprietary/data/useProprietaryToolRegistry.tsx
+++ b/frontend/src/proprietary/data/useProprietaryToolRegistry.tsx
@@ -1,10 +1,5 @@
import { useMemo } from "react";
-import { useTranslation } from "react-i18next";
import { type ProprietaryToolRegistry } from "@app/data/toolsTaxonomy";
-import { ToolCategoryId, SubcategoryId } from "@app/data/toolsTaxonomy";
-import FormFill from "@app/tools/formFill/FormFill";
-import React from "react";
-import TextFieldsIcon from '@mui/icons-material/TextFields';
/**
* Hook that provides the proprietary tool registry.
@@ -13,21 +8,5 @@ import TextFieldsIcon from '@mui/icons-material/TextFields';
* and will be included in the main tool registry.
*/
export function useProprietaryToolRegistry(): ProprietaryToolRegistry {
- const { t } = useTranslation();
-
- return useMemo(() => ({
- formFill: {
- icon: React.createElement(TextFieldsIcon, { sx: { fontSize: '1.5rem' } }),
- name: t('home.formFill.title', 'Fill Form'),
- component: FormFill,
- description: t('home.formFill.desc', 'Fill PDF form fields interactively with a visual editor'),
- categoryId: ToolCategoryId.STANDARD_TOOLS,
- subcategoryId: SubcategoryId.GENERAL,
- workbench: 'viewer' as const,
- endpoints: ['form-fill'],
- automationSettings: null,
- supportsAutomate: false,
- synonyms: ['form', 'fill', 'fillable', 'input', 'field', 'acroform'],
- },
- }), [t]);
+ return useMemo(() => ({}), []);
}
diff --git a/frontend/src/proprietary/tools/formFill/FormFieldOverlay.tsx b/frontend/src/proprietary/tools/formFill/FormFieldOverlay.tsx
deleted file mode 100644
index 1f70b9f16..000000000
--- a/frontend/src/proprietary/tools/formFill/FormFieldOverlay.tsx
+++ /dev/null
@@ -1,473 +0,0 @@
-/**
- * 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 component (which wraps this overlay along with page tiles)
- * handles visual rotation via CSS transforms — same as TilingLayer,
- * AnnotationLayer, and LinkLayer.
- */
-import React, { useCallback, useMemo, memo } from 'react';
-import { useDocumentState } from '@embedpdf/core/react';
-import { useFormFill, useFieldValue } from '@proprietary/tools/formFill/FormFillContext';
-import type { FormField, WidgetCoordinates } from '@proprietary/tools/formFill/types';
-
-interface WidgetInputProps {
- field: FormField;
- widget: WidgetCoordinates;
- isActive: boolean;
- error?: string;
- scaleX: number;
- scaleY: number;
- onFocus: (fieldName: string) => void;
- onChange: (fieldName: string, value: string) => void;
-}
-
-/**
- * WidgetInput subscribes to its own field value via useSyncExternalStore,
- * so it only re-renders when its specific value changes — not when ANY
- * form value in the entire document changes.
- */
-function WidgetInputInner({
- field,
- widget,
- isActive,
- error,
- scaleX,
- scaleY,
- onFocus,
- onChange,
-}: WidgetInputProps) {
- // Per-field value subscription — only this widget re-renders when its value changes
- const value = useFieldValue(field.name);
-
- // Coordinates are in visual CSS space (top-left origin).
- // Multiply by per-axis scale to get rendered pixel coordinates.
- const left = widget.x * scaleX;
- const top = widget.y * scaleY;
- const width = widget.width * scaleX;
- const height = widget.height * scaleY;
-
- const borderColor = error ? '#f44336' : (isActive ? '#2196F3' : 'rgba(33, 150, 243, 0.4)');
- const bgColor = error
- ? '#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 (
-
- {field.multiline ? (
-
- );
-
- 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 (
- {
- if (field.readOnly) return;
- handleFocus();
- onChange(field.name, isChecked ? 'Off' : onValue);
- stopPropagation(e);
- }}
- >
-
- ✓
-
-
- );
- }
-
- 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) => {
- 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 (
-
-
- {!field.multiSelect && — select — }
- {(field.options || []).map((opt, idx) => (
-
- {(field.displayOptions && field.displayOptions[idx]) || opt}
-
- ))}
-
-
- );
- }
-
- 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 (
- {
- if (field.readOnly || value === optionValue) return; // Don't deselect radio buttons
- handleFocus();
- onChange(field.name, optionValue);
- stopPropagation(e);
- }}
- >
-
-
- );
- }
-
- case 'signature':
- case 'button':
- // Just render a highlighted area — not editable
- return (
-
- );
-
- default:
- return (
-
- onChange(field.name, e.target.value)}
- onFocus={handleFocus}
- disabled={field.readOnly}
- style={inputBaseStyle}
- {...captureStopProps}
- />
-
- );
- }
-}
-
-const WidgetInput = memo(WidgetInputInner);
-
-interface FormFieldOverlayProps {
- documentId: string;
- pageIndex: number;
- pageWidth: number; // rendered CSS pixel width (from renderPage callback)
- pageHeight: number; // rendered CSS pixel height
- /** File identity — if provided, overlay only renders when context fields match this file */
- fileId?: string | null;
-}
-
-export function FormFieldOverlay({
- documentId,
- pageIndex,
- pageWidth,
- pageHeight,
- fileId,
-}: FormFieldOverlayProps) {
- const { setValue, setActiveField, fieldsByPage, state, forFileId } = useFormFill();
- const { activeFieldName, validationErrors } = state;
-
- // Get scale from EmbedPDF document state — same pattern as LinkLayer
- // NOTE: All hooks must be called unconditionally (before any early returns)
- const documentState = useDocumentState(documentId);
-
- const { scaleX, scaleY } = useMemo(() => {
- const pdfPage = documentState?.document?.pages?.[pageIndex];
- if (!pdfPage || !pdfPage.size || !pageWidth || !pageHeight) {
- const s = documentState?.scale ?? 1;
- return { scaleX: s, scaleY: s };
- }
-
- // pdfPage.size contains un-rotated (MediaBox) dimensions;
- // pageWidth/pageHeight from Scroller also use these un-rotated dims * scale
- return {
- scaleX: pageWidth / pdfPage.size.width,
- scaleY: pageHeight / pdfPage.size.height,
- };
- }, [documentState, pageIndex, pageWidth, pageHeight]);
-
- const pageFields = useMemo(
- () => fieldsByPage.get(pageIndex) || [],
- [fieldsByPage, pageIndex]
- );
-
- const handleFocus = useCallback(
- (fieldName: string) => setActiveField(fieldName),
- [setActiveField]
- );
-
- const handleChange = useCallback(
- (fieldName: string, value: string) => setValue(fieldName, value),
- [setValue]
- );
-
- // Guard: don't render fields from a previous document.
- // If fileId is provided and doesn't match what the context fetched for, render nothing.
- if (fileId != null && forFileId != null && fileId !== forFileId) {
- return null;
- }
- // Also guard: if fields exist but no forFileId is set (reset happened), don't render stale fields
- if (fileId != null && forFileId == null && state.fields.length > 0) {
- return null;
- }
-
- if (pageFields.length === 0) return null;
-
- return (
-
- {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 CSS wrapper handles visual rotation for us,
- // just like it does for TilingLayer, LinkLayer, etc.
- return (
-
- );
- })
- )}
-
- );
-}
-
-export default FormFieldOverlay;
diff --git a/frontend/src/proprietary/tools/formFill/FormFieldSidebar.tsx b/frontend/src/proprietary/tools/formFill/FormFieldSidebar.tsx
deleted file mode 100644
index fbd28f122..000000000
--- a/frontend/src/proprietary/tools/formFill/FormFieldSidebar.tsx
+++ /dev/null
@@ -1,210 +0,0 @@
-/**
- * FormFieldSidebar — A right-side panel for viewing and filling form fields
- * when the dedicated formFill tool is NOT selected (normal viewer mode).
- *
- * Redesigned with:
- * - Consistent CSS module styling matching the main FormFill panel
- * - Shared FieldInput component (no duplication)
- * - Better visual hierarchy and spacing
- */
-import React, { useCallback, useEffect, useRef } from 'react';
-import {
- Box,
- Text,
- ScrollArea,
- Badge,
- Tooltip,
- ActionIcon,
-} from '@mantine/core';
-import { useTranslation } from 'react-i18next';
-import { useFormFill } from '@proprietary/tools/formFill/FormFillContext';
-import { FieldInput } from '@proprietary/tools/formFill/FieldInput';
-import { FIELD_TYPE_ICON, FIELD_TYPE_COLOR } from '@proprietary/tools/formFill/fieldMeta';
-import type { FormField } from '@proprietary/tools/formFill/types';
-import CloseIcon from '@mui/icons-material/Close';
-import TextFieldsIcon from '@mui/icons-material/TextFields';
-import styles from '@proprietary/tools/formFill/FormFill.module.css';
-
-interface FormFieldSidebarProps {
- visible: boolean;
- onToggle: () => void;
-}
-
-export function FormFieldSidebar({
- visible,
- onToggle,
-}: FormFieldSidebarProps) {
- useTranslation();
- const { state, setValue, setActiveField } = useFormFill();
- const { fields, activeFieldName, loading } = state;
- const activeFieldRef = useRef(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();
- 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 (
-
- {/* Header */}
-
-
-
-
- Form Fields
-
-
- {fields.length}
-
-
-
-
-
-
-
- {/* Content */}
-
- {loading && (
-
-
- Loading form fields...
-
-
- )}
-
- {!loading && fields.length === 0 && (
-
-
- No form fields found in this PDF
-
-
- )}
-
- {!loading && fields.length > 0 && (
-
- {sortedPages.map((pageIdx, i) => (
-
-
-
- Page {pageIdx + 1}
-
-
-
- {fieldsByPage.get(pageIdx)!.map((field) => {
- const isActive = activeFieldName === field.name;
-
- return (
- handleFieldClick(field.name)}
- >
-
-
-
- {FIELD_TYPE_ICON[field.type]}
-
-
-
- {field.label || field.name}
-
- {field.required && (
- req
- )}
-
-
- {field.type !== 'button' && field.type !== 'signature' && (
-
-
-
- )}
-
- {field.tooltip && (
-
- {field.tooltip}
-
- )}
-
- );
- })}
-
- ))}
-
- )}
-
-
- );
-}
-
-export default FormFieldSidebar;
diff --git a/frontend/src/proprietary/tools/formFill/FormFillContext.tsx b/frontend/src/proprietary/tools/formFill/FormFillContext.tsx
deleted file mode 100644
index 6d752eb11..000000000
--- a/frontend/src/proprietary/tools/formFill/FormFillContext.tsx
+++ /dev/null
@@ -1,507 +0,0 @@
-/**
- * FormFillContext — React context for form fill state management.
- *
- * Provider-agnostic: delegates data fetching/saving to an IFormDataProvider.
- * - PdfLibFormProvider: frontend-only, uses pdf-lib (for normal viewer mode)
- * - PdfBoxFormProvider: backend API via PDFBox (for dedicated formFill tool)
- *
- * The active provider can be switched at runtime via setProvider(). This allows
- * EmbedPdfViewer to auto-select:
- * - Normal viewer → PdfLibFormProvider (no backend calls for large PDFs)
- * - formFill tool → PdfBoxFormProvider (full-fidelity PDFBox handling)
- *
- * Performance Architecture:
- * Form values are stored in a FormValuesStore (external to React state) to
- * avoid full context re-renders on every keystroke. Individual widgets
- * subscribe to their specific field via useFieldValue() + useSyncExternalStore,
- * so only the active widget re-renders when its value changes.
- *
- * The UI components (FormFieldOverlay, FormFill, FormFieldSidebar) consume
- * this context regardless of which provider is active.
- */
-import React, {
- createContext,
- useCallback,
- useContext,
- useMemo,
- useReducer,
- useRef,
- useState,
- useSyncExternalStore,
-} from 'react';
-import { useDebouncedCallback } from '@mantine/hooks';
-import type { FormField, FormFillState, WidgetCoordinates } from '@proprietary/tools/formFill/types';
-import type { IFormDataProvider } from '@proprietary/tools/formFill/providers/types';
-import { PdfLibFormProvider } from '@proprietary/tools/formFill/providers/PdfLibFormProvider';
-import { PdfBoxFormProvider } from '@proprietary/tools/formFill/providers/PdfBoxFormProvider';
-
-// ---------------------------------------------------------------------------
-// FormValuesStore — external store for field values (outside React state)
-// ---------------------------------------------------------------------------
-
-type Listener = () => void;
-
-/**
- * External store that holds form values outside of React state.
- *
- * This avoids triggering full context re-renders on every keystroke.
- * Components subscribe per-field via useSyncExternalStore, so only
- * the widget being edited re-renders.
- */
-class FormValuesStore {
- private _fieldListeners = new Map>();
- private _globalListeners = new Set();
-
- private _values: Record = {};
-
- get values(): Record {
- 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 = {}): 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 }
- | { 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;
- /** Update a single field value */
- setValue: (fieldName: string, value: string) => void;
- /** Set the currently focused field */
- setActiveField: (fieldName: string | null) => void;
- /** Submit filled form and return the filled PDF blob */
- submitForm: (
- file: File | Blob,
- flatten?: boolean
- ) => Promise;
- /** Get field by name */
- getField: (fieldName: string) => FormField | undefined;
- /** Get fields for a specific page index */
- getFieldsForPage: (pageIndex: number) => FormField[];
- /** Get the current value for a field (reads from external store) */
- getValue: (fieldName: string) => string;
- /** Validate the current form state and return true if valid */
- validateForm: () => boolean;
- /** Clear all form state (fields, values, errors) */
- reset: () => void;
- /** Pre-computed map of page index to fields for performance */
- fieldsByPage: Map;
- /** Name of the currently active provider ('pdf-lib' | 'pdfbox') */
- activeProviderName: string;
- /**
- * Switch the active data provider.
- * Use 'pdflib' for frontend-only pdf-lib, 'pdfbox' for backend PDFBox.
- * Resets form state when switching providers.
- */
- setProviderMode: (mode: 'pdflib' | 'pdfbox') => void;
- /** The file ID that the current form fields belong to (null if no fields loaded) */
- forFileId: string | null;
-}
-
-const FormFillContext = createContext(null);
-
-/**
- * Separate context for the values store.
- * This allows useFieldValue() to subscribe without depending on the main context.
- */
-const FormValuesStoreContext = createContext(null);
-
-export const useFormFill = (): FormFillContextValue => {
- const ctx = useContext(FormFillContext);
- if (!ctx) {
- throw new Error('useFormFill must be used within a FormFillProvider');
- }
- return ctx;
-};
-
-/**
- * Subscribe to a single field's value. Only re-renders when that specific
- * field's value changes — not when any other form value changes.
- *
- * Uses useSyncExternalStore for tear-free reads.
- */
-export function useFieldValue(fieldName: string): string {
- const store = useContext(FormValuesStoreContext);
- if (!store) {
- throw new Error('useFieldValue must be used within a FormFillProvider');
- }
-
- const subscribe = useCallback(
- (cb: () => void) => store.subscribeField(fieldName, cb),
- [store, fieldName]
- );
- const getSnapshot = useCallback(
- () => store.getValue(fieldName),
- [store, fieldName]
- );
-
- return useSyncExternalStore(subscribe, getSnapshot);
-}
-
-/**
- * Subscribe to all values (e.g., for progress counters or form submission).
- * Re-renders on every value change — use sparingly.
- */
-export function useAllFormValues(): Record {
- const store = useContext(FormValuesStoreContext);
- if (!store) {
- throw new Error('useAllFormValues must be used within a FormFillProvider');
- }
-
- const subscribe = useCallback(
- (cb: () => void) => store.subscribeGlobal(cb),
- [store]
- );
- const getSnapshot = useCallback(
- () => store.values,
- [store]
- );
-
- return useSyncExternalStore(subscribe, getSnapshot);
-}
-
-/** Singleton provider instances */
-const pdfLibProvider = new PdfLibFormProvider();
-const pdfBoxProvider = new PdfBoxFormProvider();
-
-export function FormFillProvider({
- children,
- provider: providerProp,
-}: {
- children: React.ReactNode;
- /** Override the initial provider. If not given, defaults to pdf-lib. */
- provider?: IFormDataProvider;
-}) {
- const initialMode = providerProp?.name === 'pdfbox' ? 'pdfbox' : 'pdflib';
- const [providerMode, setProviderModeState] = useState<'pdflib' | 'pdfbox'>(initialMode);
- const providerModeRef = useRef(initialMode as 'pdflib' | 'pdfbox');
- providerModeRef.current = providerMode;
- const provider = providerProp ?? (providerMode === 'pdfbox' ? pdfBoxProvider : pdfLibProvider);
- const providerRef = useRef(provider);
- providerRef.current = provider;
-
- const [state, dispatch] = useReducer(reducer, initialState);
- const fieldsRef = useRef([]);
- 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(null);
- const [forFileId, setForFileId] = useState(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 = {};
- 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 = {};
- 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();
- 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(
- () => ({
- 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 (
-
-
- {children}
-
-
- );
-}
-
-export default FormFillContext;
diff --git a/frontend/src/proprietary/tools/formFill/FormSaveBar.tsx b/frontend/src/proprietary/tools/formFill/FormSaveBar.tsx
deleted file mode 100644
index de6dbbc06..000000000
--- a/frontend/src/proprietary/tools/formFill/FormSaveBar.tsx
+++ /dev/null
@@ -1,186 +0,0 @@
-/**
- * FormSaveBar — A notification banner for form-filled PDFs.
- *
- * Appears at the top-right of the PDF viewer when the current PDF has
- * fillable form fields. Provides options to apply changes or download
- * the filled PDF.
- *
- * This component is used in normal viewer mode (pdf-lib provider) where
- * the dedicated FormFill tool panel is NOT active. It provides a clean
- * save UX that users expect from browser PDF viewers.
- */
-import React, { useCallback, useState } from 'react';
-import { Stack, Group, Text, Button, Transition, CloseButton, Paper, Badge } from '@mantine/core';
-import { useTranslation } from 'react-i18next';
-import DownloadIcon from '@mui/icons-material/Download';
-import SaveIcon from '@mui/icons-material/Save';
-import EditNoteIcon from '@mui/icons-material/EditNote';
-import { useFormFill } from '@proprietary/tools/formFill/FormFillContext';
-
-interface FormSaveBarProps {
- /** The current file being viewed */
- file: File | Blob | null;
- /** Whether the formFill tool is active (bar is hidden when tool panel is showing) */
- isFormFillToolActive: boolean;
- /** Callback when form changes are applied (should reload PDF with filled values) */
- onApply?: (filledBlob: Blob) => Promise;
-}
-
-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(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 (
-
- {(styles) => (
-
-
-
-
-
-
-
-
-
- {t('viewer.formBar.title', 'Form Fields')}
-
- {isDirty && (
-
- {t('viewer.formBar.unsavedBadge', 'Unsaved')}
-
- )}
-
-
- {isDirty
- ? t('viewer.formBar.unsavedDesc', 'You have unsaved changes')
- : t('viewer.formBar.hasFieldsDesc', 'This PDF contains fillable fields')}
-
-
-
- setDismissed(true)}
- aria-label={t('viewer.formBar.dismiss', 'Dismiss')}
- />
-
-
- {isDirty && (
-
- }
- loading={applying}
- disabled={saving}
- onClick={handleApply}
- flex={1}
- >
- {t('viewer.formBar.apply', 'Apply Changes')}
-
- }
- loading={saving}
- disabled={applying}
- onClick={handleDownload}
- flex={1}
- >
- {t('viewer.formBar.download', 'Download PDF')}
-
-
- )}
-
-
-
- )}
-
- );
-}
-
-export default FormSaveBar;
diff --git a/frontend/src/proprietary/tools/formFill/index.ts b/frontend/src/proprietary/tools/formFill/index.ts
deleted file mode 100644
index 8f6321bbf..000000000
--- a/frontend/src/proprietary/tools/formFill/index.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-export { FormFillProvider, useFormFill, useFieldValue, useAllFormValues } from '@proprietary/tools/formFill/FormFillContext';
-export { FormFieldSidebar } from '@proprietary/tools/formFill/FormFieldSidebar';
-export { FormFieldOverlay } from '@proprietary/tools/formFill/FormFieldOverlay';
-export { FormSaveBar } from '@proprietary/tools/formFill/FormSaveBar';
-export { default as FormFill } from '@proprietary/tools/formFill/FormFill';
-export { FieldInput } from '@proprietary/tools/formFill/FieldInput';
-export { FIELD_TYPE_ICON, FIELD_TYPE_COLOR } from '@proprietary/tools/formFill/fieldMeta';
-export type { FormField, FormFieldType, FormFillState, WidgetCoordinates } from '@proprietary/tools/formFill/types';
-export type { IFormDataProvider } from '@proprietary/tools/formFill/providers/types';
-export { PdfLibFormProvider } from '@proprietary/tools/formFill/providers/PdfLibFormProvider';
-export { PdfBoxFormProvider } from '@proprietary/tools/formFill/providers/PdfBoxFormProvider';
diff --git a/frontend/src/proprietary/tools/formFill/providers/index.ts b/frontend/src/proprietary/tools/formFill/providers/index.ts
deleted file mode 100644
index 31ed3cade..000000000
--- a/frontend/src/proprietary/tools/formFill/providers/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export type { IFormDataProvider } from '@proprietary/tools/formFill/providers/types';
-export { PdfLibFormProvider } from '@proprietary/tools/formFill/providers/PdfLibFormProvider';
-export { PdfBoxFormProvider } from '@proprietary/tools/formFill/providers/PdfBoxFormProvider';
diff --git a/frontend/src/proprietary/types/proprietaryToolId.ts b/frontend/src/proprietary/types/proprietaryToolId.ts
index 5c9e04a21..084cffa29 100644
--- a/frontend/src/proprietary/types/proprietaryToolId.ts
+++ b/frontend/src/proprietary/types/proprietaryToolId.ts
@@ -5,7 +5,6 @@
*/
export const PROPRIETARY_REGULAR_TOOL_IDS = [
- 'formFill',
] as const;
export const PROPRIETARY_SUPER_TOOL_IDS = [