mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-03-04 02:20:19 +01:00
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:
@@ -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"),
|
||||
|
||||
@@ -15,8 +15,8 @@ import {
|
||||
MultiSelect,
|
||||
Stack,
|
||||
} from '@mantine/core';
|
||||
import { useFieldValue } from '@proprietary/tools/formFill/FormFillContext';
|
||||
import type { FormField } from '@proprietary/tools/formFill/types';
|
||||
import { useFieldValue } from '@app/tools/formFill/FormFillContext';
|
||||
import type { FormField } from '@app/tools/formFill/types';
|
||||
|
||||
function FieldInputInner({
|
||||
field,
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
@@ -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.
|
||||
11
frontend/src/core/tools/formFill/index.ts
Normal file
11
frontend/src/core/tools/formFill/index.ts
Normal 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';
|
||||
@@ -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';
|
||||
@@ -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.
|
||||
3
frontend/src/core/tools/formFill/providers/index.ts
Normal file
3
frontend/src/core/tools/formFill/providers/index.ts
Normal file
@@ -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';
|
||||
@@ -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) */
|
||||
@@ -56,6 +56,7 @@ export const CORE_REGULAR_TOOL_IDS = [
|
||||
'showJS',
|
||||
'bookletImposition',
|
||||
'pdfTextEditor',
|
||||
'formFill',
|
||||
] as const;
|
||||
|
||||
export const CORE_SUPER_TOOL_IDS = [
|
||||
|
||||
@@ -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<ProprietaryToolRegistry>(() => ({
|
||||
formFill: {
|
||||
icon: React.createElement(TextFieldsIcon, { sx: { fontSize: '1.5rem' } }),
|
||||
name: t('home.formFill.title', 'Fill Form'),
|
||||
component: FormFill,
|
||||
description: t('home.formFill.desc', 'Fill PDF form fields interactively with a visual editor'),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
workbench: 'viewer' as const,
|
||||
endpoints: ['form-fill'],
|
||||
automationSettings: null,
|
||||
supportsAutomate: false,
|
||||
synonyms: ['form', 'fill', 'fillable', 'input', 'field', 'acroform'],
|
||||
},
|
||||
}), [t]);
|
||||
return useMemo<ProprietaryToolRegistry>(() => ({}), []);
|
||||
}
|
||||
|
||||
@@ -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 <Rotate> component (which wraps this overlay along with page tiles)
|
||||
* handles visual rotation via CSS transforms — same as TilingLayer,
|
||||
* AnnotationLayer, and LinkLayer.
|
||||
*/
|
||||
import React, { useCallback, useMemo, memo } from 'react';
|
||||
import { useDocumentState } from '@embedpdf/core/react';
|
||||
import { useFormFill, useFieldValue } from '@proprietary/tools/formFill/FormFillContext';
|
||||
import type { FormField, WidgetCoordinates } from '@proprietary/tools/formFill/types';
|
||||
|
||||
interface WidgetInputProps {
|
||||
field: FormField;
|
||||
widget: WidgetCoordinates;
|
||||
isActive: boolean;
|
||||
error?: string;
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
onFocus: (fieldName: string) => void;
|
||||
onChange: (fieldName: string, value: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* WidgetInput subscribes to its own field value via useSyncExternalStore,
|
||||
* so it only re-renders when its specific value changes — not when ANY
|
||||
* form value in the entire document changes.
|
||||
*/
|
||||
function WidgetInputInner({
|
||||
field,
|
||||
widget,
|
||||
isActive,
|
||||
error,
|
||||
scaleX,
|
||||
scaleY,
|
||||
onFocus,
|
||||
onChange,
|
||||
}: WidgetInputProps) {
|
||||
// Per-field value subscription — only this widget re-renders when its value changes
|
||||
const value = useFieldValue(field.name);
|
||||
|
||||
// Coordinates are in visual CSS space (top-left origin).
|
||||
// Multiply by per-axis scale to get rendered pixel coordinates.
|
||||
const left = widget.x * scaleX;
|
||||
const top = widget.y * scaleY;
|
||||
const width = widget.width * scaleX;
|
||||
const height = widget.height * scaleY;
|
||||
|
||||
const borderColor = error ? '#f44336' : (isActive ? '#2196F3' : 'rgba(33, 150, 243, 0.4)');
|
||||
const bgColor = error
|
||||
? '#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; // rendered CSS pixel width (from renderPage callback)
|
||||
pageHeight: number; // rendered CSS pixel height
|
||||
/** File identity — if provided, overlay only renders when context fields match this file */
|
||||
fileId?: string | null;
|
||||
}
|
||||
|
||||
export function FormFieldOverlay({
|
||||
documentId,
|
||||
pageIndex,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
fileId,
|
||||
}: FormFieldOverlayProps) {
|
||||
const { setValue, setActiveField, fieldsByPage, state, forFileId } = useFormFill();
|
||||
const { activeFieldName, validationErrors } = state;
|
||||
|
||||
// Get scale from EmbedPDF document state — same pattern as LinkLayer
|
||||
// NOTE: All hooks must be called unconditionally (before any early returns)
|
||||
const documentState = useDocumentState(documentId);
|
||||
|
||||
const { scaleX, scaleY } = useMemo(() => {
|
||||
const pdfPage = documentState?.document?.pages?.[pageIndex];
|
||||
if (!pdfPage || !pdfPage.size || !pageWidth || !pageHeight) {
|
||||
const s = documentState?.scale ?? 1;
|
||||
return { scaleX: s, scaleY: s };
|
||||
}
|
||||
|
||||
// pdfPage.size contains un-rotated (MediaBox) dimensions;
|
||||
// pageWidth/pageHeight from Scroller also use these un-rotated dims * scale
|
||||
return {
|
||||
scaleX: pageWidth / pdfPage.size.width,
|
||||
scaleY: pageHeight / pdfPage.size.height,
|
||||
};
|
||||
}, [documentState, pageIndex, pageWidth, pageHeight]);
|
||||
|
||||
const pageFields = useMemo(
|
||||
() => fieldsByPage.get(pageIndex) || [],
|
||||
[fieldsByPage, pageIndex]
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(fieldName: string) => setActiveField(fieldName),
|
||||
[setActiveField]
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(fieldName: string, value: string) => setValue(fieldName, value),
|
||||
[setValue]
|
||||
);
|
||||
|
||||
// Guard: don't render fields from a previous document.
|
||||
// If fileId is provided and doesn't match what the context fetched for, render nothing.
|
||||
if (fileId != null && forFileId != null && fileId !== forFileId) {
|
||||
return null;
|
||||
}
|
||||
// Also guard: if fields exist but no forFileId is set (reset happened), don't render stale fields
|
||||
if (fileId != null && forFileId == null && state.fields.length > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (pageFields.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none', // allow click-through except on widgets
|
||||
zIndex: 5, // above TilingLayer, below LinkLayer
|
||||
}}
|
||||
data-form-overlay-page={pageIndex}
|
||||
>
|
||||
{pageFields.map((field: FormField) =>
|
||||
(field.widgets || [])
|
||||
.filter((w: WidgetCoordinates) => w.pageIndex === pageIndex)
|
||||
.map((widget: WidgetCoordinates, widgetIdx: number) => {
|
||||
// Coordinates are in un-rotated PDF space (y-flipped to CSS TL origin).
|
||||
// The <Rotate> CSS wrapper handles visual rotation for us,
|
||||
// just like it does for TilingLayer, LinkLayer, etc.
|
||||
return (
|
||||
<WidgetInput
|
||||
key={`${field.name}-${widgetIdx}`}
|
||||
field={field}
|
||||
widget={widget}
|
||||
isActive={activeFieldName === field.name}
|
||||
error={validationErrors[field.name]}
|
||||
scaleX={scaleX}
|
||||
scaleY={scaleY}
|
||||
onFocus={handleFocus}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FormFieldOverlay;
|
||||
@@ -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<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;
|
||||
@@ -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<string, Set<Listener>>();
|
||||
private _globalListeners = new Set<Listener>();
|
||||
|
||||
private _values: Record<string, string> = {};
|
||||
|
||||
get values(): Record<string, string> {
|
||||
return this._values;
|
||||
}
|
||||
|
||||
private _version = 0;
|
||||
|
||||
get version(): number {
|
||||
return this._version;
|
||||
}
|
||||
|
||||
getValue(fieldName: string): string {
|
||||
return this._values[fieldName] ?? '';
|
||||
}
|
||||
|
||||
setValue(fieldName: string, value: string): void {
|
||||
if (this._values[fieldName] === value) return;
|
||||
this._values[fieldName] = value;
|
||||
this._version++;
|
||||
this._fieldListeners.get(fieldName)?.forEach((l) => l());
|
||||
this._globalListeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
/** Replace all values (e.g., on fetch or reset) */
|
||||
reset(values: Record<string, string> = {}): void {
|
||||
this._values = values;
|
||||
this._version++;
|
||||
for (const listeners of this._fieldListeners.values()) {
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
this._globalListeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
/** Subscribe to a single field's value changes */
|
||||
subscribeField(fieldName: string, listener: Listener): () => void {
|
||||
if (!this._fieldListeners.has(fieldName)) {
|
||||
this._fieldListeners.set(fieldName, new Set());
|
||||
}
|
||||
this._fieldListeners.get(fieldName)!.add(listener);
|
||||
return () => {
|
||||
this._fieldListeners.get(fieldName)?.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/** Subscribe to any value change */
|
||||
subscribeGlobal(listener: Listener): () => void {
|
||||
this._globalListeners.add(listener);
|
||||
return () => {
|
||||
this._globalListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reducer — handles everything EXCEPT values (which live in FormValuesStore)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Action =
|
||||
| { type: 'FETCH_START' }
|
||||
| { type: 'FETCH_SUCCESS'; fields: FormField[] }
|
||||
| { type: 'FETCH_ERROR'; error: string }
|
||||
| { type: 'MARK_DIRTY' }
|
||||
| { type: 'SET_ACTIVE_FIELD'; fieldName: string | null }
|
||||
| { type: 'SET_VALIDATION_ERRORS'; errors: Record<string, string> }
|
||||
| { type: 'CLEAR_VALIDATION_ERROR'; fieldName: string }
|
||||
| { type: 'MARK_CLEAN' }
|
||||
| { type: 'RESET' };
|
||||
|
||||
const initialState: FormFillState = {
|
||||
fields: [],
|
||||
values: {}, // kept for backward compat but canonical values live in FormValuesStore
|
||||
loading: false,
|
||||
error: null,
|
||||
activeFieldName: null,
|
||||
isDirty: false,
|
||||
validationErrors: {},
|
||||
};
|
||||
|
||||
function reducer(state: FormFillState, action: Action): FormFillState {
|
||||
switch (action.type) {
|
||||
case 'FETCH_START':
|
||||
return { ...state, loading: true, error: null };
|
||||
case 'FETCH_SUCCESS': {
|
||||
return {
|
||||
...state,
|
||||
fields: action.fields,
|
||||
values: {}, // values managed by FormValuesStore
|
||||
loading: false,
|
||||
error: null,
|
||||
isDirty: false,
|
||||
};
|
||||
}
|
||||
case 'FETCH_ERROR':
|
||||
return { ...state, loading: false, error: action.error };
|
||||
case 'MARK_DIRTY':
|
||||
if (state.isDirty) return state; // avoid unnecessary re-render
|
||||
return { ...state, isDirty: true };
|
||||
case 'SET_ACTIVE_FIELD':
|
||||
return { ...state, activeFieldName: action.fieldName };
|
||||
case 'SET_VALIDATION_ERRORS':
|
||||
return { ...state, validationErrors: action.errors };
|
||||
case 'CLEAR_VALIDATION_ERROR': {
|
||||
if (!state.validationErrors[action.fieldName]) return state;
|
||||
const { [action.fieldName]: _, ...rest } = state.validationErrors;
|
||||
return { ...state, validationErrors: rest };
|
||||
}
|
||||
case 'MARK_CLEAN':
|
||||
return { ...state, isDirty: false };
|
||||
case 'RESET':
|
||||
return initialState;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export interface FormFillContextValue {
|
||||
state: FormFillState;
|
||||
/** Fetch form fields for the given file using the active provider */
|
||||
fetchFields: (file: File | Blob, fileId?: string) => Promise<void>;
|
||||
/** Update a single field value */
|
||||
setValue: (fieldName: string, value: string) => void;
|
||||
/** Set the currently focused field */
|
||||
setActiveField: (fieldName: string | null) => void;
|
||||
/** Submit filled form and return the filled PDF blob */
|
||||
submitForm: (
|
||||
file: File | Blob,
|
||||
flatten?: boolean
|
||||
) => Promise<Blob>;
|
||||
/** Get field by name */
|
||||
getField: (fieldName: string) => FormField | undefined;
|
||||
/** Get fields for a specific page index */
|
||||
getFieldsForPage: (pageIndex: number) => FormField[];
|
||||
/** Get the current value for a field (reads from external store) */
|
||||
getValue: (fieldName: string) => string;
|
||||
/** Validate the current form state and return true if valid */
|
||||
validateForm: () => boolean;
|
||||
/** Clear all form state (fields, values, errors) */
|
||||
reset: () => void;
|
||||
/** Pre-computed map of page index to fields for performance */
|
||||
fieldsByPage: Map<number, FormField[]>;
|
||||
/** Name of the currently active provider ('pdf-lib' | 'pdfbox') */
|
||||
activeProviderName: string;
|
||||
/**
|
||||
* Switch the active data provider.
|
||||
* Use 'pdflib' for frontend-only pdf-lib, 'pdfbox' for backend PDFBox.
|
||||
* Resets form state when switching providers.
|
||||
*/
|
||||
setProviderMode: (mode: 'pdflib' | 'pdfbox') => void;
|
||||
/** The file ID that the current form fields belong to (null if no fields loaded) */
|
||||
forFileId: string | null;
|
||||
}
|
||||
|
||||
const FormFillContext = createContext<FormFillContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Separate context for the values store.
|
||||
* This allows useFieldValue() to subscribe without depending on the main context.
|
||||
*/
|
||||
const FormValuesStoreContext = createContext<FormValuesStore | null>(null);
|
||||
|
||||
export const useFormFill = (): FormFillContextValue => {
|
||||
const ctx = useContext(FormFillContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useFormFill must be used within a FormFillProvider');
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribe to a single field's value. Only re-renders when that specific
|
||||
* field's value changes — not when any other form value changes.
|
||||
*
|
||||
* Uses useSyncExternalStore for tear-free reads.
|
||||
*/
|
||||
export function useFieldValue(fieldName: string): string {
|
||||
const store = useContext(FormValuesStoreContext);
|
||||
if (!store) {
|
||||
throw new Error('useFieldValue must be used within a FormFillProvider');
|
||||
}
|
||||
|
||||
const subscribe = useCallback(
|
||||
(cb: () => void) => store.subscribeField(fieldName, cb),
|
||||
[store, fieldName]
|
||||
);
|
||||
const getSnapshot = useCallback(
|
||||
() => store.getValue(fieldName),
|
||||
[store, fieldName]
|
||||
);
|
||||
|
||||
return useSyncExternalStore(subscribe, getSnapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to all values (e.g., for progress counters or form submission).
|
||||
* Re-renders on every value change — use sparingly.
|
||||
*/
|
||||
export function useAllFormValues(): Record<string, string> {
|
||||
const store = useContext(FormValuesStoreContext);
|
||||
if (!store) {
|
||||
throw new Error('useAllFormValues must be used within a FormFillProvider');
|
||||
}
|
||||
|
||||
const subscribe = useCallback(
|
||||
(cb: () => void) => store.subscribeGlobal(cb),
|
||||
[store]
|
||||
);
|
||||
const getSnapshot = useCallback(
|
||||
() => store.values,
|
||||
[store]
|
||||
);
|
||||
|
||||
return useSyncExternalStore(subscribe, getSnapshot);
|
||||
}
|
||||
|
||||
/** Singleton provider instances */
|
||||
const pdfLibProvider = new PdfLibFormProvider();
|
||||
const pdfBoxProvider = new PdfBoxFormProvider();
|
||||
|
||||
export function FormFillProvider({
|
||||
children,
|
||||
provider: providerProp,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
/** Override the initial provider. If not given, defaults to pdf-lib. */
|
||||
provider?: IFormDataProvider;
|
||||
}) {
|
||||
const initialMode = providerProp?.name === 'pdfbox' ? 'pdfbox' : 'pdflib';
|
||||
const [providerMode, setProviderModeState] = useState<'pdflib' | 'pdfbox'>(initialMode);
|
||||
const providerModeRef = useRef(initialMode as 'pdflib' | 'pdfbox');
|
||||
providerModeRef.current = providerMode;
|
||||
const provider = providerProp ?? (providerMode === 'pdfbox' ? pdfBoxProvider : pdfLibProvider);
|
||||
const providerRef = useRef(provider);
|
||||
providerRef.current = provider;
|
||||
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const fieldsRef = useRef<FormField[]>([]);
|
||||
fieldsRef.current = state.fields;
|
||||
|
||||
// Version counter to cancel stale async fetch responses.
|
||||
// Incremented on every fetchFields() and reset() call.
|
||||
const fetchVersionRef = useRef(0);
|
||||
|
||||
// Track which file the current fields belong to
|
||||
const forFileIdRef = useRef<string | null>(null);
|
||||
const [forFileId, setForFileId] = useState<string | null>(null);
|
||||
|
||||
// External values store — values live HERE, not in the reducer.
|
||||
// This prevents full context re-renders on every keystroke.
|
||||
const [valuesStore] = useState(() => new FormValuesStore());
|
||||
|
||||
const fetchFields = useCallback(async (file: File | Blob, fileId?: string) => {
|
||||
// Increment version so any in-flight fetch for a previous file is discarded.
|
||||
// NOTE: setProviderMode() also increments fetchVersionRef to invalidate
|
||||
// in-flight fetches when switching providers. This is intentional — the
|
||||
// fetch started here captures the NEW version, so stale results are
|
||||
// correctly discarded.
|
||||
const version = ++fetchVersionRef.current;
|
||||
|
||||
// Immediately clear previous state so FormFieldOverlay's stale-file guards
|
||||
// prevent rendering fields from a previous document during the fetch.
|
||||
forFileIdRef.current = null;
|
||||
setForFileId(null);
|
||||
valuesStore.reset({});
|
||||
dispatch({ type: 'RESET' });
|
||||
dispatch({ type: 'FETCH_START' });
|
||||
try {
|
||||
const fields = await providerRef.current.fetchFields(file);
|
||||
// If another fetch or reset happened while we were waiting, discard this result
|
||||
if (fetchVersionRef.current !== version) {
|
||||
console.log('[FormFill] Discarding stale fetch result (version mismatch)');
|
||||
return;
|
||||
}
|
||||
// Initialise values in the external store
|
||||
const values: Record<string, string> = {};
|
||||
for (const field of fields) {
|
||||
values[field.name] = field.value ?? '';
|
||||
}
|
||||
valuesStore.reset(values);
|
||||
forFileIdRef.current = fileId ?? null;
|
||||
setForFileId(fileId ?? null);
|
||||
dispatch({ type: 'FETCH_SUCCESS', fields });
|
||||
} catch (err: any) {
|
||||
if (fetchVersionRef.current !== version) return; // stale
|
||||
const msg =
|
||||
err?.response?.data?.message ||
|
||||
err?.message ||
|
||||
'Failed to fetch form fields';
|
||||
dispatch({ type: 'FETCH_ERROR', error: msg });
|
||||
}
|
||||
}, [valuesStore]);
|
||||
|
||||
const validateFieldDebounced = useDebouncedCallback((fieldName: string) => {
|
||||
const field = fieldsRef.current.find((f) => f.name === fieldName);
|
||||
if (!field || !field.required) return;
|
||||
|
||||
const val = valuesStore.getValue(fieldName);
|
||||
if (!val || val.trim() === '' || val === 'Off') {
|
||||
dispatch({
|
||||
type: 'SET_VALIDATION_ERRORS',
|
||||
errors: { ...state.validationErrors, [fieldName]: `${field.label} is required` },
|
||||
});
|
||||
} else {
|
||||
dispatch({ type: 'CLEAR_VALIDATION_ERROR', fieldName });
|
||||
}
|
||||
}, 300);
|
||||
|
||||
const validateForm = useCallback((): boolean => {
|
||||
const errors: Record<string, string> = {};
|
||||
for (const field of fieldsRef.current) {
|
||||
const val = valuesStore.getValue(field.name);
|
||||
if (field.required && (!val || val.trim() === '' || val === 'Off')) {
|
||||
errors[field.name] = `${field.label} is required`;
|
||||
}
|
||||
}
|
||||
dispatch({ type: 'SET_VALIDATION_ERRORS', errors });
|
||||
return Object.keys(errors).length === 0;
|
||||
}, [valuesStore]);
|
||||
|
||||
const setValue = useCallback(
|
||||
(fieldName: string, value: string) => {
|
||||
// Update external store (triggers per-field subscribers only)
|
||||
valuesStore.setValue(fieldName, value);
|
||||
// Mark form as dirty in React state (only triggers re-render once)
|
||||
dispatch({ type: 'MARK_DIRTY' });
|
||||
validateFieldDebounced(fieldName);
|
||||
},
|
||||
[valuesStore, validateFieldDebounced]
|
||||
);
|
||||
|
||||
const setActiveField = useCallback(
|
||||
(fieldName: string | null) => {
|
||||
dispatch({ type: 'SET_ACTIVE_FIELD', fieldName });
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const submitForm = useCallback(
|
||||
async (file: File | Blob, flatten = false) => {
|
||||
const blob = await providerRef.current.fillForm(file, valuesStore.values, flatten);
|
||||
dispatch({ type: 'MARK_CLEAN' });
|
||||
return blob;
|
||||
},
|
||||
[valuesStore]
|
||||
);
|
||||
|
||||
const setProviderMode = useCallback(
|
||||
(mode: 'pdflib' | 'pdfbox') => {
|
||||
// Use the ref to check the current mode synchronously — avoids
|
||||
// relying on stale closure state and allows the early return.
|
||||
if (providerModeRef.current === mode) return;
|
||||
|
||||
// provider (pdfbox vs pdflib).
|
||||
const newProvider = mode === 'pdfbox' ? pdfBoxProvider : pdfLibProvider;
|
||||
providerRef.current = newProvider;
|
||||
providerModeRef.current = mode;
|
||||
|
||||
fetchVersionRef.current++;
|
||||
forFileIdRef.current = null;
|
||||
setForFileId(null);
|
||||
valuesStore.reset({});
|
||||
dispatch({ type: 'RESET' });
|
||||
|
||||
setProviderModeState(mode);
|
||||
},
|
||||
[valuesStore]
|
||||
);
|
||||
|
||||
const getField = useCallback(
|
||||
(fieldName: string) =>
|
||||
fieldsRef.current.find((f) => f.name === fieldName),
|
||||
[]
|
||||
);
|
||||
|
||||
const getFieldsForPage = useCallback(
|
||||
(pageIndex: number) =>
|
||||
fieldsRef.current.filter((f) =>
|
||||
f.widgets?.some((w: WidgetCoordinates) => w.pageIndex === pageIndex)
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const getValue = useCallback(
|
||||
(fieldName: string) => valuesStore.getValue(fieldName),
|
||||
[valuesStore]
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
// Increment version to invalidate any in-flight fetch
|
||||
fetchVersionRef.current++;
|
||||
forFileIdRef.current = null;
|
||||
setForFileId(null);
|
||||
valuesStore.reset({});
|
||||
dispatch({ type: 'RESET' });
|
||||
}, [valuesStore]);
|
||||
|
||||
const fieldsByPage = useMemo(() => {
|
||||
const map = new Map<number, FormField[]>();
|
||||
for (const field of state.fields) {
|
||||
const pageIdx = field.widgets?.[0]?.pageIndex ?? 0;
|
||||
if (!map.has(pageIdx)) map.set(pageIdx, []);
|
||||
map.get(pageIdx)!.push(field);
|
||||
}
|
||||
return map;
|
||||
}, [state.fields]);
|
||||
|
||||
// Context value — does NOT depend on values, so keystrokes don't
|
||||
// trigger re-renders of all context consumers.
|
||||
const value = useMemo<FormFillContextValue>(
|
||||
() => ({
|
||||
state,
|
||||
fetchFields,
|
||||
setValue,
|
||||
setActiveField,
|
||||
submitForm,
|
||||
getField,
|
||||
getFieldsForPage,
|
||||
getValue,
|
||||
validateForm,
|
||||
reset,
|
||||
fieldsByPage,
|
||||
activeProviderName: providerRef.current.name,
|
||||
setProviderMode,
|
||||
forFileId,
|
||||
}),
|
||||
[
|
||||
state,
|
||||
fetchFields,
|
||||
setValue,
|
||||
setActiveField,
|
||||
submitForm,
|
||||
getField,
|
||||
getFieldsForPage,
|
||||
getValue,
|
||||
validateForm,
|
||||
reset,
|
||||
fieldsByPage,
|
||||
providerMode,
|
||||
setProviderMode,
|
||||
forFileId,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<FormValuesStoreContext.Provider value={valuesStore}>
|
||||
<FormFillContext.Provider value={value}>
|
||||
{children}
|
||||
</FormFillContext.Provider>
|
||||
</FormValuesStoreContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default FormFillContext;
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
export function FormSaveBar({ file, isFormFillToolActive, onApply }: FormSaveBarProps) {
|
||||
const { t } = useTranslation();
|
||||
const { state, submitForm } = useFormFill();
|
||||
const { fields, isDirty, loading } = state;
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
// Reset dismissed state when file changes
|
||||
const [prevFile, setPrevFile] = useState<File | Blob | null>(null);
|
||||
if (file !== prevFile) {
|
||||
setPrevFile(file);
|
||||
setDismissed(false);
|
||||
}
|
||||
|
||||
const handleApply = useCallback(async () => {
|
||||
if (!file || applying || saving) return;
|
||||
setApplying(true);
|
||||
try {
|
||||
// Generate the filled PDF
|
||||
const filledBlob = await submitForm(file, false);
|
||||
|
||||
// Call the onApply callback to reload the PDF in the viewer
|
||||
if (onApply) {
|
||||
await onApply(filledBlob);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[FormSaveBar] Apply failed:', err);
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
}, [file, applying, saving, submitForm, onApply]);
|
||||
|
||||
const handleDownload = useCallback(async () => {
|
||||
if (!file || saving || applying) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const blob = await submitForm(file, false);
|
||||
// Trigger browser download
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = file instanceof File ? file.name : 'filled-form.pdf';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error('[FormSaveBar] Download failed:', err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [file, saving, applying, submitForm]);
|
||||
|
||||
// Don't show when:
|
||||
// - formFill tool is active (it has its own save panel)
|
||||
// - no form fields found
|
||||
// - still loading
|
||||
// - user dismissed the bar
|
||||
const hasFields = fields.length > 0;
|
||||
const visible = !isFormFillToolActive && hasFields && !loading && !dismissed;
|
||||
|
||||
return (
|
||||
<Transition mounted={visible} transition="slide-down" duration={300}>
|
||||
{(styles) => (
|
||||
<div
|
||||
style={{
|
||||
...styles,
|
||||
position: 'absolute',
|
||||
top: '1rem',
|
||||
right: '1rem',
|
||||
zIndex: 100,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
shadow="lg"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{
|
||||
pointerEvents: 'auto',
|
||||
minWidth: '320px',
|
||||
maxWidth: '420px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Stack gap="xs" p="md">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<EditNoteIcon
|
||||
sx={{
|
||||
fontSize: 24,
|
||||
color: isDirty ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-gray-6)'
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<Group gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
{t('viewer.formBar.title', 'Form Fields')}
|
||||
</Text>
|
||||
{isDirty && (
|
||||
<Badge size="xs" color="blue" variant="light">
|
||||
{t('viewer.formBar.unsavedBadge', 'Unsaved')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
{isDirty
|
||||
? t('viewer.formBar.unsavedDesc', 'You have unsaved changes')
|
||||
: t('viewer.formBar.hasFieldsDesc', 'This PDF contains fillable fields')}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<CloseButton
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
onClick={() => setDismissed(true)}
|
||||
aria-label={t('viewer.formBar.dismiss', 'Dismiss')}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{isDirty && (
|
||||
<Group gap="xs" mt="xs">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="blue"
|
||||
leftSection={<SaveIcon sx={{ fontSize: 18 }} />}
|
||||
loading={applying}
|
||||
disabled={saving}
|
||||
onClick={handleApply}
|
||||
flex={1}
|
||||
>
|
||||
{t('viewer.formBar.apply', 'Apply Changes')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="filled"
|
||||
color="blue"
|
||||
leftSection={<DownloadIcon sx={{ fontSize: 18 }} />}
|
||||
loading={saving}
|
||||
disabled={applying}
|
||||
onClick={handleDownload}
|
||||
flex={1}
|
||||
>
|
||||
{t('viewer.formBar.download', 'Download PDF')}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</div>
|
||||
)}
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
|
||||
export default FormSaveBar;
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
export const PROPRIETARY_REGULAR_TOOL_IDS = [
|
||||
'formFill',
|
||||
] as const;
|
||||
|
||||
export const PROPRIETARY_SUPER_TOOL_IDS = [
|
||||
|
||||
Reference in New Issue
Block a user