feat(form-fill): FormFill tool with context and UI components for PDF form filling (#5711)

This commit is contained in:
Balázs Szücs
2026-02-13 15:10:48 +00:00
committed by GitHub
parent 5a1ed50e2b
commit 27bd34c29b
35 changed files with 4481 additions and 92 deletions
@@ -23,6 +23,7 @@ import { useAppInitialization } from "@app/hooks/useAppInitialization";
import { useLogoAssets } from '@app/hooks/useLogoAssets';
import AppConfigLoader from '@app/components/shared/AppConfigLoader';
import { RedactionProvider } from "@app/contexts/RedactionContext";
import { FormFillProvider } from "@app/tools/formFill/FormFillContext";
// Component to initialize scarf tracking (must be inside AppConfigProvider)
function ScarfTrackingInitializer() {
@@ -98,6 +99,7 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
<PageEditorProvider>
<SignatureProvider>
<RedactionProvider>
<FormFillProvider>
<AnnotationProvider>
<RightRailProvider>
<TourOrchestrationProvider>
@@ -107,6 +109,7 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
</TourOrchestrationProvider>
</RightRailProvider>
</AnnotationProvider>
</FormFillProvider>
</RedactionProvider>
</SignatureProvider>
</PageEditorProvider>
@@ -20,6 +20,8 @@ import { isStirlingFile } from '@app/types/fileContext';
import { useViewerRightRailButtons } from '@app/components/viewer/useViewerRightRailButtons';
import { StampPlacementOverlay } from '@app/components/viewer/StampPlacementOverlay';
import { useWheelZoom } from '@app/hooks/useWheelZoom';
import { useFormFill } from '@app/tools/formFill/FormFillContext';
import { FormSaveBar } from '@app/tools/formFill/FormSaveBar';
export interface EmbedPdfViewerProps {
sidebarsVisible: boolean;
@@ -86,9 +88,15 @@ const EmbedPdfViewerContent = ({
const pendingScrollRestoreRef = useRef<number | null>(null);
const scrollRestoreAttemptsRef = useRef<number>(0);
// Rotation preservation system
// Similar to scroll preservation - track rotation across file reloads
const pendingRotationRestoreRef = useRef<number | null>(null);
const rotationRestoreAttemptsRef = useRef<number>(0);
// Track the file ID we should be viewing after a save (to handle list reordering)
const pendingFileIdRef = useRef<string | null>(null);
const formApplyInProgressRef = useRef(false);
// Get redaction context
const { redactionsApplied, setRedactionsApplied } = useRedaction();
@@ -107,6 +115,9 @@ const EmbedPdfViewerContent = ({
const { selectedTool } = useNavigationState();
// Form fill context
const { fetchFields: fetchFormFields, setProviderMode } = useFormFill();
const isInAnnotationTool = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage' || selectedTool === 'annotate';
const isSignatureMode = isInAnnotationTool;
const isManualRedactMode = selectedTool === 'redact';
@@ -117,6 +128,19 @@ const EmbedPdfViewerContent = ({
// Enable redaction only when redaction tool is selected
const shouldEnableRedaction = selectedTool === 'redact';
// FormFill tool mode — uses PDFBox backend for full-fidelity form handling
const isFormFillToolActive = (selectedTool as string) === 'formFill';
// Form overlays are shown in BOTH modes:
// - Normal viewer: form overlays visible (pdf-lib, frontend-only)
// - formFill tool: form overlays visible (PDFBox, backend)
const shouldEnableFormFill = true;
// Switch the provider when the tool mode changes
useEffect(() => {
setProviderMode(isFormFillToolActive ? 'pdfbox' : 'pdflib');
}, [isFormFillToolActive, setProviderMode]);
// Track previous annotation/redaction state to detect tool switches
const prevEnableAnnotationsRef = useRef(shouldEnableAnnotations);
const prevEnableRedactionRef = useRef(shouldEnableRedaction);
@@ -367,6 +391,9 @@ const EmbedPdfViewerContent = ({
// Use the continuously tracked scroll position - more reliable than reading at this moment
const pageToRestore = lastKnownScrollPageRef.current;
// Save the current rotation to restore after reload
const currentRotation = rotationState.rotation ?? 0;
// Step 0: Commit any pending redactions before export
const hadPendingRedactions = (redactionTrackerRef.current?.getPendingCount() ?? 0) > 0;
@@ -408,6 +435,9 @@ const EmbedPdfViewerContent = ({
pendingScrollRestoreRef.current = pageToRestore;
scrollRestoreAttemptsRef.current = 0;
// Store the rotation to restore after file replacement
pendingRotationRestoreRef.current = currentRotation;
rotationRestoreAttemptsRef.current = 0;
// Store the new file ID so we can track it after the list reorders
const newFileId = stubs[0]?.id;
if (newFileId) {
@@ -424,7 +454,72 @@ const EmbedPdfViewerContent = ({
} catch (error) {
console.error('Apply changes failed:', error);
}
}, [currentFile, activeFiles, activeFileIndex, exportActions, actions, selectors, setHasUnsavedChanges, setRedactionsApplied]);
}, [currentFile, activeFiles, activeFileIndex, exportActions, actions, selectors, setHasUnsavedChanges, setRedactionsApplied, rotationState.rotation]);
// Apply form fill changes - reload the filled PDF into the viewer
const handleFormApply = useCallback(async (filledBlob: Blob) => {
if (formApplyInProgressRef.current) return;
if (!currentFile || activeFileIds.length === 0) return;
formApplyInProgressRef.current = true;
try {
console.log('[Viewer] Applying form fill changes - reloading filled PDF');
// Use the continuously tracked scroll position
const pageToRestore = lastKnownScrollPageRef.current;
// Save the current rotation to restore after reload
const currentRotation = rotationState.rotation ?? 0;
// Convert Blob to File
const filename = currentFile.name || 'document.pdf';
const file = new File([filledBlob], filename, { type: 'application/pdf' });
// Get current file info for creating the updated version
const currentFileId = activeFiles[activeFileIndex]?.fileId;
if (!currentFileId) throw new Error('Current file ID not found');
const parentStub = selectors.getStirlingFileStub(currentFileId);
if (!parentStub) throw new Error('Parent stub not found');
// Create StirlingFiles and stubs for version history
const { stirlingFiles, stubs } = await createStirlingFilesAndStubs([file], parentStub, 'multiTool');
// Store the page to restore after file replacement
pendingScrollRestoreRef.current = pageToRestore;
scrollRestoreAttemptsRef.current = 0;
// Store the rotation to restore after file replacement
pendingRotationRestoreRef.current = currentRotation;
rotationRestoreAttemptsRef.current = 0;
// Store the new file ID for tracking
const newFileId = stubs[0]?.id;
if (newFileId) {
pendingFileIdRef.current = newFileId;
}
// Replace the current file in context
await actions.consumeFiles([currentFileId], stirlingFiles, stubs);
console.log('[Viewer] Form fill changes applied successfully');
} catch (error) {
console.error('[Viewer] Apply form changes failed:', error);
} finally {
formApplyInProgressRef.current = false;
}
}, [currentFile, activeFiles, activeFileIndex, actions, selectors, activeFileIds.length, rotationState.rotation]);
useEffect(() => {
const handler = (e: Event) => {
const blob = (e as CustomEvent).detail?.blob;
if (blob) {
handleFormApply(blob);
}
};
window.addEventListener('formfill:apply', handler);
return () => window.removeEventListener('formfill:apply', handler);
}, [handleFormApply]);
// Discard pending redactions but save already-applied ones
// This is called when user clicks "Discard & Leave" - we want to:
@@ -439,6 +534,10 @@ const EmbedPdfViewerContent = ({
try {
console.log('[Viewer] Discarding pending marks but saving applied redactions');
// Save current view state to restore after file replacement
const pageToRestore = lastKnownScrollPageRef.current;
const currentRotation = rotationState.rotation ?? 0;
// Export PDF WITHOUT committing pending marks - this saves only applied redactions
const arrayBuffer = await exportActions.saveAsCopy();
if (!arrayBuffer) {
@@ -459,6 +558,12 @@ const EmbedPdfViewerContent = ({
const { stirlingFiles, stubs } = await createStirlingFilesAndStubs([file], parentStub, 'multiTool');
// Store view state to restore after file replacement
pendingScrollRestoreRef.current = pageToRestore;
scrollRestoreAttemptsRef.current = 0;
pendingRotationRestoreRef.current = currentRotation;
rotationRestoreAttemptsRef.current = 0;
// Consume only the current file (replace in context)
await actions.consumeFiles([currentFileId], stirlingFiles, stubs);
@@ -470,7 +575,7 @@ const EmbedPdfViewerContent = ({
} catch (error) {
console.error('Failed to save applied redactions:', error);
}
}, [redactionsApplied, currentFile, activeFiles, activeFileIndex, activeFileIds.length, exportActions, actions, selectors, setRedactionsApplied]);
}, [redactionsApplied, currentFile, activeFiles, activeFileIndex, activeFileIds.length, exportActions, actions, selectors, setRedactionsApplied, rotationState.rotation]);
// Restore scroll position after file replacement or tool switch
// Uses polling with retries to ensure the scroll succeeds
@@ -524,6 +629,57 @@ const EmbedPdfViewerContent = ({
return () => clearTimeout(timer);
}, [scrollState.totalPages, scrollActions, getScrollState]);
// Restore rotation after file replacement or tool switch
// Uses polling with retries to ensure the rotation succeeds
useEffect(() => {
if (pendingRotationRestoreRef.current === null) return;
const rotationToRestore = pendingRotationRestoreRef.current;
const maxAttempts = 10;
const attemptInterval = 100; // ms between attempts
const attemptRotation = () => {
const currentState = getScrollState();
// Only attempt if PDF is loaded (totalPages > 0)
if (currentState.totalPages > 0) {
_rotationActions.setRotation(rotationToRestore);
// Check if rotation succeeded after a brief delay
setTimeout(() => {
const currentRotation = _rotationActions.getRotation();
if (currentRotation === rotationToRestore || rotationRestoreAttemptsRef.current >= maxAttempts) {
// Success or max attempts reached - clear pending
pendingRotationRestoreRef.current = null;
rotationRestoreAttemptsRef.current = 0;
} else {
// Rotation might not have worked, retry
rotationRestoreAttemptsRef.current++;
if (rotationRestoreAttemptsRef.current < maxAttempts) {
setTimeout(attemptRotation, attemptInterval);
} else {
// Give up after max attempts
pendingRotationRestoreRef.current = null;
rotationRestoreAttemptsRef.current = 0;
}
}
}, 50);
} else if (rotationRestoreAttemptsRef.current < maxAttempts) {
// PDF not ready yet, retry
rotationRestoreAttemptsRef.current++;
setTimeout(attemptRotation, attemptInterval);
} else {
// Give up after max attempts
pendingRotationRestoreRef.current = null;
rotationRestoreAttemptsRef.current = 0;
}
};
// Start attempting after initial delay
const timer = setTimeout(attemptRotation, 150);
return () => clearTimeout(timer);
}, [scrollState.totalPages, _rotationActions, getScrollState]);
// Register applyChanges with ViewerContext so tools can access it directly
useEffect(() => {
setApplyChanges(applyChanges);
@@ -535,6 +691,49 @@ const EmbedPdfViewerContent = ({
// Register viewer right-rail buttons
useViewerRightRailButtons();
// Auto-fetch form fields when a PDF is loaded in the viewer.
// In normal viewer mode, this uses pdf-lib (frontend-only).
// In formFill tool mode, this uses PDFBox (backend).
const formFillFileIdRef = useRef<string | null>(null);
const formFillProviderRef = useRef(isFormFillToolActive);
// Generate a unique identifier for the current file to detect file changes
const currentFileId = React.useMemo(() => {
if (!currentFile) return null;
if (isStirlingFile(currentFile)) {
return `stirling-${currentFile.fileId}`;
}
// File is also a Blob, but has more specific properties
if (currentFile instanceof File) {
return `file-${currentFile.name}-${currentFile.size}-${currentFile.lastModified}`;
}
// Fallback for any other object (shouldn't happen in practice)
return `unknown-${(currentFile as any).size || 0}`;
}, [currentFile]);
useEffect(() => {
const fileChanged = currentFileId !== formFillFileIdRef.current;
const providerChanged = formFillProviderRef.current !== isFormFillToolActive;
formFillProviderRef.current = isFormFillToolActive;
if (fileChanged) {
console.log('[FormFill] File changed. Old:', formFillFileIdRef.current, 'New:', currentFileId);
formFillFileIdRef.current = currentFileId;
// NOTE: Don't call resetFormFill() here — fetchFormFields() handles
// clearing old state internally. Calling reset() before fetch() would
// double-increment fetchVersionRef, causing version mismatches when
// the effect re-fires before the async fetch completes.
}
if (currentFile && (fileChanged || providerChanged)) {
console.log('[FormFill] Fetching form fields for:', currentFileId);
fetchFormFields(currentFile, currentFileId ?? undefined);
}
}, [isFormFillToolActive, currentFile, currentFileId, fetchFormFields]);
const sidebarWidthRem = 15;
const totalRightMargin =
(isThumbnailSidebarVisible ? sidebarWidthRem : 0) +
@@ -586,7 +785,7 @@ const EmbedPdfViewerContent = ({
transition: 'margin-right 0.3s ease'
}}>
<LocalEmbedPDF
key={currentFile && isStirlingFile(currentFile) ? currentFile.fileId : (effectiveFile.file instanceof File ? effectiveFile.file.name : effectiveFile.url)}
key={currentFileId || 'no-file'}
file={effectiveFile.file}
url={effectiveFile.url}
fileName={
@@ -597,16 +796,24 @@ const EmbedPdfViewerContent = ({
enableAnnotations={shouldEnableAnnotations}
showBakedAnnotations={isAnnotationsVisible}
enableRedaction={shouldEnableRedaction}
enableFormFill={shouldEnableFormFill}
isManualRedactionMode={isManualRedactMode}
signatureApiRef={signatureApiRef as React.RefObject<any>}
annotationApiRef={annotationApiRef as React.RefObject<any>}
historyApiRef={historyApiRef as React.RefObject<any>}
redactionTrackerRef={redactionTrackerRef as React.RefObject<RedactionPendingTrackerAPI>}
fileId={currentFileId}
onSignatureAdded={() => {
// Handle signature added - for debugging, enable console logs as needed
// Future: Handle signature completion
}}
/>
{/* Floating save bar for form-filled PDFs (like Chrome/Firefox PDF viewers) */}
<FormSaveBar
file={currentFile ?? null}
isFormFillToolActive={isFormFillToolActive}
onApply={handleFormApply}
/>
<StampPlacementOverlay
containerRef={pdfContainerRef}
isActive={isPlacementOverlayActive}
@@ -677,7 +884,9 @@ const EmbedPdfViewerContent = ({
};
const EmbedPdfViewer = (props: EmbedPdfViewerProps) => {
return <EmbedPdfViewerContent {...props} />;
return (
<EmbedPdfViewerContent {...props} />
);
};
export default EmbedPdfViewer;
@@ -56,6 +56,7 @@ import { DocumentPermissionsAPIBridge } from '@app/components/viewer/DocumentPer
import { DocumentReadyWrapper } from '@app/components/viewer/DocumentReadyWrapper';
import { ActiveDocumentProvider } from '@app/components/viewer/ActiveDocumentContext';
import { absoluteWithBasePath } from '@app/constants/app';
import { FormFieldOverlay } from '@app/tools/formFill/FormFieldOverlay';
const DOCUMENT_NAME = 'stirling-pdf-viewer';
@@ -65,6 +66,7 @@ interface LocalEmbedPDFProps {
fileName?: string;
enableAnnotations?: boolean;
enableRedaction?: boolean;
enableFormFill?: boolean;
isManualRedactionMode?: boolean;
showBakedAnnotations?: boolean;
onSignatureAdded?: (annotation: any) => void;
@@ -72,9 +74,11 @@ interface LocalEmbedPDFProps {
annotationApiRef?: React.RefObject<AnnotationAPI>;
historyApiRef?: React.RefObject<HistoryAPI>;
redactionTrackerRef?: React.RefObject<RedactionPendingTrackerAPI>;
/** File identity passed through to FormFieldOverlay for stale-field guards */
fileId?: string | null;
}
export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false, enableRedaction = false, isManualRedactionMode = false, showBakedAnnotations = true, onSignatureAdded, signatureApiRef, annotationApiRef, historyApiRef, redactionTrackerRef }: LocalEmbedPDFProps) {
export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false, enableRedaction = false, enableFormFill = false, isManualRedactionMode = false, showBakedAnnotations = true, onSignatureAdded, signatureApiRef, annotationApiRef, historyApiRef, redactionTrackerRef, fileId }: LocalEmbedPDFProps) {
const { t } = useTranslation();
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [, setAnnotations] = useState<Array<{id: string, pageIndex: number, rect: any}>>([]);
@@ -121,7 +125,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
}),
createPluginRegistration(ScrollPluginPackage),
createPluginRegistration(RenderPluginPackage, {
withForms: true,
withForms: !enableFormFill, // Disable native form rendering when our interactive overlay is active
withAnnotations: showBakedAnnotations && !enableAnnotations, // Show baked annotations only when: visibility is ON and annotation layer is OFF
}),
@@ -200,7 +204,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
// Register print plugin for printing PDFs
createPluginRegistration(PrintPluginPackage),
];
}, [pdfUrl, enableAnnotations, showBakedAnnotations, fileName, file, url]);
}, [pdfUrl, enableAnnotations, enableFormFill, showBakedAnnotations, fileName, file, url]);
// Initialize the engine with the React hook - use local WASM for offline support
const { engine, isLoading, error } = usePdfiumEngine({
@@ -731,6 +735,17 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
</div>
<TextSelectionHandler documentId={documentId} pageIndex={pageIndex} />
{/* FormFieldOverlay for interactive form filling */}
{enableFormFill && (
<FormFieldOverlay
documentId={documentId}
pageIndex={pageIndex}
pageWidth={width}
pageHeight={height}
fileId={fileId}
/>
)}
{/* AnnotationLayer for annotation editing and annotation-based redactions */}
{(enableAnnotations || enableRedaction) && (
<AnnotationLayer
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { Box, ScrollArea } from '@mantine/core';
import { useViewer } from '@app/contexts/ViewerContext';
import { PrivateContent } from '@app/components/shared/PrivateContent';
@@ -27,10 +27,16 @@ export function ThumbnailSidebar({ visible, onToggle: _onToggle, activeFileIndex
setThumbnails({});
}, [activeFileIndex]);
// Keep a ref to thumbnails for cleanup on unmount
const thumbnailsRef = useRef(thumbnails);
useEffect(() => {
thumbnailsRef.current = thumbnails;
}, [thumbnails]);
// Clear thumbnails when sidebar closes and revoke blob URLs to prevent memory leaks
useEffect(() => {
if (!visible) {
Object.values(thumbnails).forEach((thumbUrl) => {
Object.values(thumbnailsRef.current).forEach((thumbUrl) => {
// Only revoke if it's a blob URL (not 'error')
if (typeof thumbUrl === 'string' && thumbUrl.startsWith('blob:')) {
URL.revokeObjectURL(thumbUrl);
@@ -38,48 +44,89 @@ export function ThumbnailSidebar({ visible, onToggle: _onToggle, activeFileIndex
});
setThumbnails({});
}
}, [visible]); // Remove thumbnails from dependency to prevent infinite loop
}, [visible]);
// Cleanup all blob URLs on unmount to prevent memory leaks
useEffect(() => {
return () => {
Object.values(thumbnailsRef.current).forEach((thumbUrl) => {
if (typeof thumbUrl === 'string' && thumbUrl.startsWith('blob:')) {
URL.revokeObjectURL(thumbUrl);
}
});
};
}, []);
// Generate thumbnails when sidebar becomes visible
useEffect(() => {
if (!visible || scrollState.totalPages === 0) return;
if (!thumbnailAPI) return;
let isCancelled = false;
const generateThumbnails = async () => {
for (let pageIndex = 0; pageIndex < scrollState.totalPages; pageIndex++) {
if (thumbnails[pageIndex]) continue; // Skip if already generated
const allPages = Array.from({ length: scrollState.totalPages }, (_, i) => i);
const currentPage = scrollState.currentPage - 1;
// Group pages by priority:
// 1. Current page
// 2. Visible neighbors (current +/- 3)
// 3. Everything else
const prioritized = [
...allPages.filter(i => i === currentPage),
...allPages.filter(i => i !== currentPage && Math.abs(i - currentPage) <= 3),
...allPages.filter(i => Math.abs(i - currentPage) > 3)
];
const CONCURRENCY_LIMIT = 3;
const queue = [...prioritized];
const processNext = async () => {
if (queue.length === 0 || isCancelled) return;
const pageIndex = queue.shift()!;
if (thumbnailsRef.current[pageIndex]) {
await processNext();
return;
}
try {
const thumbTask = thumbnailAPI.renderThumb(pageIndex, 1.0);
const thumbBlob = await thumbTask.toPromise();
if (isCancelled) {
// If cancelled during generation, revoke the new URL
return;
}
const thumbUrl = URL.createObjectURL(thumbBlob);
// Convert Task to Promise and handle properly
thumbTask.toPromise().then((thumbBlob: Blob) => {
const thumbUrl = URL.createObjectURL(thumbBlob);
setThumbnails(prev => ({
...prev,
[pageIndex]: thumbUrl
}));
}).catch((error: any) => {
console.error('Failed to generate thumbnail for page', pageIndex + 1, error);
setThumbnails(prev => ({
...prev,
[pageIndex]: thumbUrl
}));
} catch (error) {
console.error('Failed to generate thumbnail for page', pageIndex + 1, error);
if (!isCancelled) {
setThumbnails(prev => ({
...prev,
[pageIndex]: 'error'
}));
});
} catch (error) {
console.error('Failed to generate thumbnail for page', pageIndex + 1, error);
setThumbnails(prev => ({
...prev,
[pageIndex]: 'error'
}));
}
}
}
await processNext();
};
// Start initial concurrent batch
const workers = Array.from({ length: CONCURRENCY_LIMIT }, () => processNext());
await Promise.all(workers);
};
generateThumbnails();
}, [visible, scrollState.totalPages, thumbnailAPI]);
return () => {
isCancelled = true;
};
}, [visible, scrollState.totalPages, thumbnailAPI, scrollState.currentPage]);
const handlePageClick = (pageIndex: number) => {
const pageNumber = pageIndex + 1; // Convert to 1-based
@@ -13,6 +13,7 @@ import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useNavigationState, useNavigationGuard } from '@app/contexts/NavigationContext';
import { BASE_PATH, withBasePath } from '@app/constants/app';
import { useRedaction, useRedactionMode } from '@app/contexts/RedactionContext';
import TextFieldsIcon from '@mui/icons-material/TextFields';
export function useViewerRightRailButtons() {
const { t, i18n } = useTranslation();
@@ -21,7 +22,7 @@ export function useViewerRightRailButtons() {
const [isPanning, setIsPanning] = useState<boolean>(() => viewer.getPanState()?.isPanning ?? false);
const { sidebarRefs } = useSidebarContext();
const { position: tooltipPosition } = useRightRailTooltipSide(sidebarRefs, 12);
const { handleToolSelect } = useToolWorkflow();
const { handleToolSelect, handleBackToTools } = useToolWorkflow();
const { selectedTool } = useNavigationState();
const { requestNavigation } = useNavigationGuard();
const { redactionsApplied, activeType: redactionActiveType } = useRedaction();
@@ -77,6 +78,9 @@ export function useViewerRightRailButtons() {
const attachmentLabel = t('rightRail.toggleAttachments', 'Toggle Attachments');
const printLabel = t('rightRail.print', 'Print PDF');
const annotationsLabel = t('rightRail.annotations', 'Annotations');
const formFillLabel = t('rightRail.formFill', 'Fill Form');
const isFormFillActive = (selectedTool as string) === 'formFill';
const viewerButtons = useMemo<RightRailButtonWithAction[]>(() => {
const buttons: RightRailButtonWithAction[] = [
@@ -253,6 +257,35 @@ export function useViewerRightRailButtons() {
<ViewerAnnotationControls currentView="viewer" disabled={disabled} />
)
},
{
id: 'viewer-form-fill',
tooltip: formFillLabel,
ariaLabel: formFillLabel,
section: 'top' as const,
order: 62,
render: ({ disabled }) => (
<Tooltip content={formFillLabel} position={tooltipPosition} offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant={isFormFillActive ? 'filled' : 'subtle'}
radius="md"
className="right-rail-icon"
onClick={() => {
if (disabled) return;
if (isFormFillActive) {
handleBackToTools();
} else {
handleToolSelect('formFill' as any);
}
}}
disabled={disabled}
aria-pressed={isFormFillActive}
color={isFormFillActive ? 'blue' : undefined}
>
<TextFieldsIcon sx={{ fontSize: '1.5rem' }} />
</ActionIcon>
</Tooltip>
)
},
];
// Optional: Save button for annotations (always registered when this hook is used
@@ -282,6 +315,8 @@ export function useViewerRightRailButtons() {
handleToolSelect,
pendingCount,
redactionActiveType,
formFillLabel,
isFormFillActive,
]);
useRightRailButtons(viewerButtons);