mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
feat(form-fill): FormFill tool with context and UI components for PDF form filling (#5711)
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user