mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 19:10:47 +02:00
Restructure frontend code to allow for extensions (#4721)
# Description of Changes Move frontend code into `core` folder and add infrastructure for `proprietary` folder to include premium, non-OSS features
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useSearch } from '@embedpdf/plugin-search/react';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
import { SEARCH_CONSTANTS } from '@app/components/viewer/constants/search';
|
||||
|
||||
interface SearchLayerProps {
|
||||
pageIndex: number;
|
||||
scale: number;
|
||||
highlightColor?: string;
|
||||
activeHighlightColor?: string;
|
||||
opacity?: number;
|
||||
padding?: number;
|
||||
borderRadius?: number;
|
||||
}
|
||||
|
||||
interface SearchResultState {
|
||||
results: Array<{
|
||||
pageIndex: number;
|
||||
rects: Array<{
|
||||
origin: { x: number; y: number };
|
||||
size: { width: number; height: number };
|
||||
}>;
|
||||
}>;
|
||||
activeResultIndex?: number;
|
||||
}
|
||||
|
||||
export function CustomSearchLayer({
|
||||
pageIndex,
|
||||
scale,
|
||||
highlightColor = SEARCH_CONSTANTS.HIGHLIGHT_COLORS.BACKGROUND,
|
||||
activeHighlightColor = SEARCH_CONSTANTS.HIGHLIGHT_COLORS.ACTIVE_BACKGROUND,
|
||||
opacity = SEARCH_CONSTANTS.HIGHLIGHT_COLORS.OPACITY,
|
||||
padding = SEARCH_CONSTANTS.UI.HIGHLIGHT_PADDING,
|
||||
borderRadius = 4
|
||||
}: SearchLayerProps) {
|
||||
const { provides: searchProvides } = useSearch();
|
||||
const { scrollActions } = useViewer();
|
||||
const [searchResultState, setSearchResultState] = useState<SearchResultState | null>(null);
|
||||
|
||||
// Subscribe to search result state changes
|
||||
useEffect(() => {
|
||||
if (!searchProvides) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = searchProvides.onSearchResultStateChange?.((state: SearchResultState) => {
|
||||
// Auto-scroll to active search result
|
||||
if (state?.results && state.activeResultIndex !== undefined && state.activeResultIndex >= 0) {
|
||||
const activeResult = state.results[state.activeResultIndex];
|
||||
if (activeResult) {
|
||||
const pageNumber = activeResult.pageIndex + 1; // Convert to 1-based page number
|
||||
scrollActions.scrollToPage(pageNumber);
|
||||
}
|
||||
}
|
||||
|
||||
setSearchResultState(state);
|
||||
});
|
||||
|
||||
|
||||
return unsubscribe;
|
||||
}, [searchProvides, pageIndex]);
|
||||
|
||||
// Filter results for current page while preserving original indices
|
||||
const pageResults = useMemo(() => {
|
||||
if (!searchResultState?.results) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filtered = searchResultState.results
|
||||
.map((result, originalIndex) => ({ result, originalIndex }))
|
||||
.filter(({ result }) => result.pageIndex === pageIndex);
|
||||
|
||||
return filtered;
|
||||
}, [searchResultState, pageIndex]);
|
||||
|
||||
if (!pageResults.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 10
|
||||
}}>
|
||||
{pageResults.map(({ result, originalIndex }, idx) => (
|
||||
<div key={`result-${idx}`}>
|
||||
{result.rects.map((rect, rectIdx) => (
|
||||
<div
|
||||
key={`rect-${idx}-${rectIdx}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `${rect.origin.y * scale - padding}px`,
|
||||
left: `${rect.origin.x * scale - padding}px`,
|
||||
width: `${rect.size.width * scale + (padding * 2)}px`,
|
||||
height: `${rect.size.height * scale + (padding * 2)}px`,
|
||||
backgroundColor: originalIndex === searchResultState?.activeResultIndex
|
||||
? activeHighlightColor
|
||||
: highlightColor,
|
||||
opacity: opacity,
|
||||
borderRadius: `${borderRadius}px`,
|
||||
transform: 'scale(1.02)',
|
||||
transformOrigin: 'center',
|
||||
transition: 'opacity 0.2s ease-in-out, background-color 0.2s ease-in-out',
|
||||
pointerEvents: 'none',
|
||||
boxShadow: originalIndex === searchResultState?.activeResultIndex
|
||||
? `0 0 0 1px ${SEARCH_CONSTANTS.HIGHLIGHT_COLORS.ACTIVE_BACKGROUND}80`
|
||||
: 'none'
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Box, Center, Text, ActionIcon } from '@mantine/core';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
|
||||
import { useFileState, useFileActions } from "@app/contexts/FileContext";
|
||||
import { useFileWithUrl } from "@app/hooks/useFileWithUrl";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { LocalEmbedPDF } from '@app/components/viewer/LocalEmbedPDF';
|
||||
import { PdfViewerToolbar } from '@app/components/viewer/PdfViewerToolbar';
|
||||
import { ThumbnailSidebar } from '@app/components/viewer/ThumbnailSidebar';
|
||||
import { useNavigationGuard, useNavigationState } from '@app/contexts/NavigationContext';
|
||||
import { useSignature } from '@app/contexts/SignatureContext';
|
||||
import { createStirlingFilesAndStubs } from '@app/services/fileStubHelpers';
|
||||
import NavigationWarningModal from '@app/components/shared/NavigationWarningModal';
|
||||
import { isStirlingFile } from '@app/types/fileContext';
|
||||
import { useViewerRightRailButtons } from '@app/components/viewer/useViewerRightRailButtons';
|
||||
|
||||
export interface EmbedPdfViewerProps {
|
||||
sidebarsVisible: boolean;
|
||||
setSidebarsVisible: (v: boolean) => void;
|
||||
onClose?: () => void;
|
||||
previewFile?: File | null;
|
||||
activeFileIndex?: number;
|
||||
setActiveFileIndex?: (index: number) => void;
|
||||
}
|
||||
|
||||
const EmbedPdfViewerContent = ({
|
||||
sidebarsVisible: _sidebarsVisible,
|
||||
setSidebarsVisible: _setSidebarsVisible,
|
||||
onClose,
|
||||
previewFile,
|
||||
activeFileIndex: externalActiveFileIndex,
|
||||
setActiveFileIndex: externalSetActiveFileIndex,
|
||||
}: EmbedPdfViewerProps) => {
|
||||
const viewerRef = React.useRef<HTMLDivElement>(null);
|
||||
const [isViewerHovered, setIsViewerHovered] = React.useState(false);
|
||||
|
||||
const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, spreadActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getZoomState, getSpreadState, getRotationState, isAnnotationMode, isAnnotationsVisible, exportActions } = useViewer();
|
||||
|
||||
// Register viewer right-rail buttons
|
||||
useViewerRightRailButtons();
|
||||
|
||||
const scrollState = getScrollState();
|
||||
const zoomState = getZoomState();
|
||||
const spreadState = getSpreadState();
|
||||
const rotationState = getRotationState();
|
||||
|
||||
// Track initial rotation to detect changes
|
||||
const initialRotationRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (initialRotationRef.current === null && rotationState.rotation !== undefined) {
|
||||
initialRotationRef.current = rotationState.rotation;
|
||||
}
|
||||
}, [rotationState.rotation]);
|
||||
|
||||
// Get signature context
|
||||
const { signatureApiRef, historyApiRef } = useSignature();
|
||||
|
||||
// Get current file from FileContext
|
||||
const { selectors, state } = useFileState();
|
||||
const { actions } = useFileActions();
|
||||
const activeFiles = selectors.getFiles();
|
||||
const activeFileIds = activeFiles.map(f => f.fileId);
|
||||
const selectedFileIds = state.ui.selectedFileIds;
|
||||
|
||||
// Navigation guard for unsaved changes
|
||||
const { setHasUnsavedChanges, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker } = useNavigationGuard();
|
||||
|
||||
// Check if we're in signature mode OR viewer annotation mode
|
||||
const { selectedTool } = useNavigationState();
|
||||
const isSignatureMode = selectedTool === 'sign';
|
||||
|
||||
// Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations
|
||||
const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || isAnnotationsVisible;
|
||||
|
||||
// Track which file tab is active
|
||||
const [internalActiveFileIndex, setInternalActiveFileIndex] = useState(0);
|
||||
const activeFileIndex = externalActiveFileIndex ?? internalActiveFileIndex;
|
||||
const setActiveFileIndex = externalSetActiveFileIndex ?? setInternalActiveFileIndex;
|
||||
const hasInitializedFromSelection = useRef(false);
|
||||
|
||||
// When viewer opens with a selected file, switch to that file
|
||||
useEffect(() => {
|
||||
if (!hasInitializedFromSelection.current && selectedFileIds.length > 0 && activeFiles.length > 0) {
|
||||
const selectedFileId = selectedFileIds[0];
|
||||
const index = activeFiles.findIndex(f => f.fileId === selectedFileId);
|
||||
if (index !== -1 && index !== activeFileIndex) {
|
||||
setActiveFileIndex(index);
|
||||
}
|
||||
hasInitializedFromSelection.current = true;
|
||||
}
|
||||
}, [selectedFileIds, activeFiles, activeFileIndex]);
|
||||
|
||||
// Reset active tab if it's out of bounds
|
||||
useEffect(() => {
|
||||
if (activeFileIndex >= activeFiles.length && activeFiles.length > 0) {
|
||||
setActiveFileIndex(0);
|
||||
}
|
||||
}, [activeFiles.length, activeFileIndex]);
|
||||
|
||||
// Determine which file to display
|
||||
const currentFile = React.useMemo(() => {
|
||||
if (previewFile) {
|
||||
return previewFile;
|
||||
} else if (activeFiles.length > 0) {
|
||||
return activeFiles[activeFileIndex] || activeFiles[0];
|
||||
}
|
||||
return null;
|
||||
}, [previewFile, activeFiles, activeFileIndex]);
|
||||
|
||||
// Get file with URL for rendering
|
||||
const fileWithUrl = useFileWithUrl(currentFile);
|
||||
|
||||
// Determine the effective file to display
|
||||
const effectiveFile = React.useMemo(() => {
|
||||
if (previewFile) {
|
||||
// In preview mode, show the preview file
|
||||
if (previewFile.size === 0) {
|
||||
return null;
|
||||
}
|
||||
return { file: previewFile, url: null };
|
||||
} else {
|
||||
return fileWithUrl;
|
||||
}
|
||||
}, [previewFile, fileWithUrl]);
|
||||
|
||||
// Handle scroll wheel zoom with accumulator for smooth trackpad pinch
|
||||
useEffect(() => {
|
||||
let accumulator = 0;
|
||||
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
// Check if Ctrl (Windows/Linux) or Cmd (Mac) is pressed
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
accumulator += event.deltaY;
|
||||
const threshold = 10;
|
||||
|
||||
if (accumulator <= -threshold) {
|
||||
// Accumulated scroll up - zoom in
|
||||
zoomActions.zoomIn();
|
||||
accumulator = 0;
|
||||
} else if (accumulator >= threshold) {
|
||||
// Accumulated scroll down - zoom out
|
||||
zoomActions.zoomOut();
|
||||
accumulator = 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const viewerElement = viewerRef.current;
|
||||
if (viewerElement) {
|
||||
viewerElement.addEventListener('wheel', handleWheel, { passive: false });
|
||||
return () => {
|
||||
viewerElement.removeEventListener('wheel', handleWheel);
|
||||
};
|
||||
}
|
||||
}, [zoomActions]);
|
||||
|
||||
// Handle keyboard zoom shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!isViewerHovered) return;
|
||||
|
||||
// Check if Ctrl (Windows/Linux) or Cmd (Mac) is pressed
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
if (event.key === '=' || event.key === '+') {
|
||||
// Ctrl+= or Ctrl++ for zoom in
|
||||
event.preventDefault();
|
||||
zoomActions.zoomIn();
|
||||
} else if (event.key === '-' || event.key === '_') {
|
||||
// Ctrl+- for zoom out
|
||||
event.preventDefault();
|
||||
zoomActions.zoomOut();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isViewerHovered]);
|
||||
|
||||
// Register checker for unsaved changes (annotations only for now)
|
||||
useEffect(() => {
|
||||
if (previewFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const checkForChanges = () => {
|
||||
// Check for annotation changes via history
|
||||
const hasAnnotationChanges = historyApiRef.current?.canUndo() || false;
|
||||
|
||||
console.log('[Viewer] Checking for unsaved changes:', {
|
||||
hasAnnotationChanges
|
||||
});
|
||||
return hasAnnotationChanges;
|
||||
};
|
||||
|
||||
console.log('[Viewer] Registering unsaved changes checker');
|
||||
registerUnsavedChangesChecker(checkForChanges);
|
||||
|
||||
return () => {
|
||||
console.log('[Viewer] Unregistering unsaved changes checker');
|
||||
unregisterUnsavedChangesChecker();
|
||||
};
|
||||
}, [historyApiRef, previewFile, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker]);
|
||||
|
||||
// Apply changes - save annotations to new file version
|
||||
const applyChanges = useCallback(async () => {
|
||||
if (!currentFile || activeFileIds.length === 0) return;
|
||||
|
||||
try {
|
||||
console.log('[Viewer] Applying changes - exporting PDF with annotations');
|
||||
|
||||
// Step 1: Export PDF with annotations using EmbedPDF
|
||||
const arrayBuffer = await exportActions.saveAsCopy();
|
||||
if (!arrayBuffer) {
|
||||
throw new Error('Failed to export PDF');
|
||||
}
|
||||
|
||||
console.log('[Viewer] Exported PDF size:', arrayBuffer.byteLength);
|
||||
|
||||
// Step 2: Convert ArrayBuffer to File
|
||||
const blob = new Blob([arrayBuffer], { type: 'application/pdf' });
|
||||
const filename = currentFile.name || 'document.pdf';
|
||||
const file = new File([blob], filename, { type: 'application/pdf' });
|
||||
|
||||
// Step 3: Create StirlingFiles and stubs for version history
|
||||
const parentStub = selectors.getStirlingFileStub(activeFileIds[0]);
|
||||
if (!parentStub) throw new Error('Parent stub not found');
|
||||
|
||||
const { stirlingFiles, stubs } = await createStirlingFilesAndStubs([file], parentStub, 'multiTool');
|
||||
|
||||
// Step 4: Consume files (replace in context)
|
||||
await actions.consumeFiles(activeFileIds, stirlingFiles, stubs);
|
||||
|
||||
setHasUnsavedChanges(false);
|
||||
} catch (error) {
|
||||
console.error('Apply changes failed:', error);
|
||||
}
|
||||
}, [currentFile, activeFileIds, exportActions, actions, selectors, setHasUnsavedChanges]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={viewerRef}
|
||||
onMouseEnter={() => setIsViewerHovered(true)}
|
||||
onMouseLeave={() => setIsViewerHovered(false)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
contain: 'layout style paint'
|
||||
}}>
|
||||
{/* Close Button - Only show in preview mode */}
|
||||
{onClose && previewFile && (
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="gray"
|
||||
size="lg"
|
||||
style={{ position: 'absolute', top: '1rem', right: '1rem', zIndex: 1000, borderRadius: '50%' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<CloseIcon />
|
||||
</ActionIcon>
|
||||
)}
|
||||
|
||||
{!effectiveFile ? (
|
||||
<Center style={{ flex: 1 }}>
|
||||
<Text c="red">Error: No file provided to viewer</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<>
|
||||
{/* EmbedPDF Viewer */}
|
||||
<Box style={{
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
marginRight: isThumbnailSidebarVisible ? '15rem' : '0',
|
||||
transition: 'margin-right 0.3s ease'
|
||||
}}>
|
||||
<LocalEmbedPDF
|
||||
key={currentFile && isStirlingFile(currentFile) ? currentFile.fileId : (effectiveFile.file instanceof File ? effectiveFile.file.name : effectiveFile.url)}
|
||||
file={effectiveFile.file}
|
||||
url={effectiveFile.url}
|
||||
enableAnnotations={shouldEnableAnnotations}
|
||||
signatureApiRef={signatureApiRef as React.RefObject<any>}
|
||||
historyApiRef={historyApiRef as React.RefObject<any>}
|
||||
onSignatureAdded={() => {
|
||||
// Handle signature added - for debugging, enable console logs as needed
|
||||
// Future: Handle signature completion
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Bottom Toolbar Overlay */}
|
||||
{effectiveFile && (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 50,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
pointerEvents: "none",
|
||||
background: "transparent",
|
||||
}}
|
||||
>
|
||||
<div style={{ pointerEvents: "auto" }}>
|
||||
<PdfViewerToolbar
|
||||
currentPage={scrollState.currentPage}
|
||||
totalPages={scrollState.totalPages}
|
||||
onPageChange={(page) => {
|
||||
// Page navigation handled by scrollActions
|
||||
console.log('Navigate to page:', page);
|
||||
}}
|
||||
dualPage={spreadState.isDualPage}
|
||||
onDualPageToggle={() => {
|
||||
spreadActions.toggleSpreadMode();
|
||||
}}
|
||||
currentZoom={zoomState.zoomPercent}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Thumbnail Sidebar */}
|
||||
<ThumbnailSidebar
|
||||
visible={isThumbnailSidebarVisible}
|
||||
onToggle={toggleThumbnailSidebar}
|
||||
activeFileIndex={activeFileIndex}
|
||||
/>
|
||||
|
||||
{/* Navigation Warning Modal */}
|
||||
{!previewFile && (
|
||||
<NavigationWarningModal
|
||||
onApplyAndContinue={async () => {
|
||||
await applyChanges();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const EmbedPdfViewer = (props: EmbedPdfViewerProps) => {
|
||||
return <EmbedPdfViewerContent {...props} />;
|
||||
};
|
||||
|
||||
export default EmbedPdfViewer;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useExportCapability } from '@embedpdf/plugin-export/react';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
|
||||
/**
|
||||
* Component that runs inside EmbedPDF context and provides export functionality
|
||||
*/
|
||||
export function ExportAPIBridge() {
|
||||
const { provides: exportApi } = useExportCapability();
|
||||
const { registerBridge } = useViewer();
|
||||
|
||||
useEffect(() => {
|
||||
if (exportApi) {
|
||||
// Register this bridge with ViewerContext
|
||||
registerBridge('export', {
|
||||
state: {
|
||||
canExport: true,
|
||||
},
|
||||
api: exportApi
|
||||
});
|
||||
}
|
||||
}, [exportApi, registerBridge]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useImperativeHandle, forwardRef, useEffect } from 'react';
|
||||
import { useHistoryCapability } from '@embedpdf/plugin-history/react';
|
||||
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
|
||||
import { useSignature } from '@app/contexts/SignatureContext';
|
||||
import { uuidV4 } from '@embedpdf/models';
|
||||
import type { HistoryAPI } from '@app/components/viewer/viewerTypes';
|
||||
|
||||
export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge(_, ref) {
|
||||
const { provides: historyApi } = useHistoryCapability();
|
||||
const { provides: annotationApi } = useAnnotationCapability();
|
||||
const { getImageData, storeImageData } = useSignature();
|
||||
|
||||
// Monitor annotation events to detect when annotations are restored
|
||||
useEffect(() => {
|
||||
if (!annotationApi) return;
|
||||
|
||||
const handleAnnotationEvent = (event: any) => {
|
||||
const annotation = event.annotation;
|
||||
|
||||
// Store image data for all STAMP annotations immediately when created or modified
|
||||
if (annotation && annotation.type === 13 && annotation.id && annotation.imageSrc) {
|
||||
const storedImageData = getImageData(annotation.id);
|
||||
if (!storedImageData || storedImageData !== annotation.imageSrc) {
|
||||
storeImageData(annotation.id, annotation.imageSrc);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle annotation restoration after undo operations
|
||||
if (event.type === 'create' && event.committed) {
|
||||
// Check if this is a STAMP annotation (signature) that might need image data restoration
|
||||
if (annotation && annotation.type === 13 && annotation.id) {
|
||||
getImageData(annotation.id);
|
||||
|
||||
// Delay the check to allow the annotation to be fully created
|
||||
setTimeout(() => {
|
||||
const currentStoredData = getImageData(annotation.id);
|
||||
// Check if the annotation lacks image data but we have it stored
|
||||
if (currentStoredData && (!annotation.imageSrc || annotation.imageSrc !== currentStoredData)) {
|
||||
|
||||
// Generate new ID to avoid React key conflicts
|
||||
const newId = uuidV4();
|
||||
|
||||
// Recreation with stored image data
|
||||
const restoredData = {
|
||||
type: annotation.type,
|
||||
rect: annotation.rect,
|
||||
author: annotation.author || 'Digital Signature',
|
||||
subject: annotation.subject || 'Digital Signature',
|
||||
pageIndex: event.pageIndex,
|
||||
id: newId,
|
||||
created: annotation.created || new Date(),
|
||||
imageSrc: currentStoredData
|
||||
};
|
||||
|
||||
// Update stored data to use new ID
|
||||
storeImageData(newId, currentStoredData);
|
||||
|
||||
// Replace the annotation with one that has proper image data
|
||||
try {
|
||||
annotationApi.deleteAnnotation(event.pageIndex, annotation.id);
|
||||
// Small delay to ensure deletion completes
|
||||
setTimeout(() => {
|
||||
annotationApi.createAnnotation(event.pageIndex, restoredData);
|
||||
}, 50);
|
||||
} catch (error) {
|
||||
console.error('HistoryAPI: Failed to restore annotation:', error);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add the event listener
|
||||
annotationApi.onAnnotationEvent(handleAnnotationEvent);
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
// Note: EmbedPDF doesn't provide a way to remove event listeners
|
||||
// This is a limitation of the current API
|
||||
};
|
||||
}, [annotationApi, getImageData, storeImageData]);
|
||||
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
undo: () => {
|
||||
if (historyApi) {
|
||||
historyApi.undo();
|
||||
}
|
||||
},
|
||||
|
||||
redo: () => {
|
||||
if (historyApi) {
|
||||
historyApi.redo();
|
||||
}
|
||||
},
|
||||
|
||||
canUndo: () => {
|
||||
return historyApi ? historyApi.canUndo() : false;
|
||||
},
|
||||
|
||||
canRedo: () => {
|
||||
return historyApi ? historyApi.canRedo() : false;
|
||||
},
|
||||
}), [historyApi]);
|
||||
|
||||
return null; // This is a bridge component with no UI
|
||||
});
|
||||
|
||||
HistoryAPIBridge.displayName = 'HistoryAPIBridge';
|
||||
@@ -0,0 +1,343 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { createPluginRegistration } from '@embedpdf/core';
|
||||
import { EmbedPDF } from '@embedpdf/core/react';
|
||||
import { usePdfiumEngine } from '@embedpdf/engines/react';
|
||||
|
||||
// Import the essential plugins
|
||||
import { Viewport, ViewportPluginPackage } from '@embedpdf/plugin-viewport/react';
|
||||
import { Scroller, ScrollPluginPackage, ScrollStrategy } from '@embedpdf/plugin-scroll/react';
|
||||
import { LoaderPluginPackage } from '@embedpdf/plugin-loader/react';
|
||||
import { RenderPluginPackage } from '@embedpdf/plugin-render/react';
|
||||
import { ZoomPluginPackage } from '@embedpdf/plugin-zoom/react';
|
||||
import { InteractionManagerPluginPackage, PagePointerProvider, GlobalPointerProvider } from '@embedpdf/plugin-interaction-manager/react';
|
||||
import { SelectionLayer, SelectionPluginPackage } from '@embedpdf/plugin-selection/react';
|
||||
import { TilingLayer, TilingPluginPackage } from '@embedpdf/plugin-tiling/react';
|
||||
import { PanPluginPackage } from '@embedpdf/plugin-pan/react';
|
||||
import { SpreadPluginPackage, SpreadMode } from '@embedpdf/plugin-spread/react';
|
||||
import { SearchPluginPackage } from '@embedpdf/plugin-search/react';
|
||||
import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react';
|
||||
import { RotatePluginPackage, Rotate } from '@embedpdf/plugin-rotate/react';
|
||||
import { ExportPluginPackage } from '@embedpdf/plugin-export/react';
|
||||
|
||||
// Import annotation plugins
|
||||
import { HistoryPluginPackage } from '@embedpdf/plugin-history/react';
|
||||
import { AnnotationLayer, AnnotationPluginPackage } from '@embedpdf/plugin-annotation/react';
|
||||
import { PdfAnnotationSubtype } from '@embedpdf/models';
|
||||
import { CustomSearchLayer } from '@app/components/viewer/CustomSearchLayer';
|
||||
import { ZoomAPIBridge } from '@app/components/viewer/ZoomAPIBridge';
|
||||
import ToolLoadingFallback from '@app/components/tools/ToolLoadingFallback';
|
||||
import { Center, Stack, Text } from '@mantine/core';
|
||||
import { ScrollAPIBridge } from '@app/components/viewer/ScrollAPIBridge';
|
||||
import { SelectionAPIBridge } from '@app/components/viewer/SelectionAPIBridge';
|
||||
import { PanAPIBridge } from '@app/components/viewer/PanAPIBridge';
|
||||
import { SpreadAPIBridge } from '@app/components/viewer/SpreadAPIBridge';
|
||||
import { SearchAPIBridge } from '@app/components/viewer/SearchAPIBridge';
|
||||
import { ThumbnailAPIBridge } from '@app/components/viewer/ThumbnailAPIBridge';
|
||||
import { RotateAPIBridge } from '@app/components/viewer/RotateAPIBridge';
|
||||
import { SignatureAPIBridge } from '@app/components/viewer/SignatureAPIBridge';
|
||||
import { HistoryAPIBridge } from '@app/components/viewer/HistoryAPIBridge';
|
||||
import type { SignatureAPI, HistoryAPI } from '@app/components/viewer/viewerTypes';
|
||||
import { ExportAPIBridge } from '@app/components/viewer/ExportAPIBridge';
|
||||
|
||||
interface LocalEmbedPDFProps {
|
||||
file?: File | Blob;
|
||||
url?: string | null;
|
||||
enableAnnotations?: boolean;
|
||||
onSignatureAdded?: (annotation: any) => void;
|
||||
signatureApiRef?: React.RefObject<SignatureAPI>;
|
||||
historyApiRef?: React.RefObject<HistoryAPI>;
|
||||
}
|
||||
|
||||
export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatureAdded, signatureApiRef, historyApiRef }: LocalEmbedPDFProps) {
|
||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||
const [, setAnnotations] = useState<Array<{id: string, pageIndex: number, rect: any}>>([]);
|
||||
|
||||
// Convert File to URL if needed
|
||||
useEffect(() => {
|
||||
if (file) {
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
setPdfUrl(objectUrl);
|
||||
return () => URL.revokeObjectURL(objectUrl);
|
||||
} else if (url) {
|
||||
setPdfUrl(url);
|
||||
}
|
||||
}, [file, url]);
|
||||
|
||||
// Create plugins configuration
|
||||
const plugins = useMemo(() => {
|
||||
if (!pdfUrl) return [];
|
||||
|
||||
// Calculate 3.5rem in pixels dynamically based on root font size
|
||||
const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
const viewportGap = rootFontSize * 3.5;
|
||||
|
||||
return [
|
||||
createPluginRegistration(LoaderPluginPackage, {
|
||||
loadingOptions: {
|
||||
type: 'url',
|
||||
pdfFile: {
|
||||
id: 'stirling-pdf-viewer',
|
||||
url: pdfUrl,
|
||||
},
|
||||
},
|
||||
}),
|
||||
createPluginRegistration(ViewportPluginPackage, {
|
||||
viewportGap,
|
||||
}),
|
||||
createPluginRegistration(ScrollPluginPackage, {
|
||||
strategy: ScrollStrategy.Vertical,
|
||||
initialPage: 0,
|
||||
}),
|
||||
createPluginRegistration(RenderPluginPackage),
|
||||
|
||||
// Register interaction manager (required for zoom and selection features)
|
||||
createPluginRegistration(InteractionManagerPluginPackage),
|
||||
|
||||
// Register selection plugin (depends on InteractionManager)
|
||||
createPluginRegistration(SelectionPluginPackage),
|
||||
|
||||
// Register history plugin for undo/redo (recommended for annotations)
|
||||
...(enableAnnotations ? [createPluginRegistration(HistoryPluginPackage)] : []),
|
||||
|
||||
// Register annotation plugin (depends on InteractionManager, Selection, History)
|
||||
...(enableAnnotations ? [createPluginRegistration(AnnotationPluginPackage, {
|
||||
annotationAuthor: 'Digital Signature',
|
||||
autoCommit: true,
|
||||
deactivateToolAfterCreate: false,
|
||||
selectAfterCreate: true,
|
||||
})] : []),
|
||||
|
||||
// Register pan plugin (depends on Viewport, InteractionManager)
|
||||
createPluginRegistration(PanPluginPackage, {
|
||||
defaultMode: 'mobile', // Try mobile mode which might be more permissive
|
||||
}),
|
||||
|
||||
// Register zoom plugin with configuration
|
||||
createPluginRegistration(ZoomPluginPackage, {
|
||||
defaultZoomLevel: 1.4, // Start at 140% zoom for better readability
|
||||
minZoom: 0.2,
|
||||
maxZoom: 3.0,
|
||||
}),
|
||||
|
||||
// Register tiling plugin (depends on Render, Scroll, Viewport)
|
||||
createPluginRegistration(TilingPluginPackage, {
|
||||
tileSize: 768,
|
||||
overlapPx: 5,
|
||||
extraRings: 1,
|
||||
}),
|
||||
|
||||
// Register spread plugin for dual page layout
|
||||
createPluginRegistration(SpreadPluginPackage, {
|
||||
defaultSpreadMode: SpreadMode.None, // Start with single page view
|
||||
}),
|
||||
|
||||
// Register search plugin for text search
|
||||
createPluginRegistration(SearchPluginPackage),
|
||||
|
||||
// Register thumbnail plugin for page thumbnails
|
||||
createPluginRegistration(ThumbnailPluginPackage),
|
||||
|
||||
// Register rotate plugin
|
||||
createPluginRegistration(RotatePluginPackage),
|
||||
|
||||
// Register export plugin for downloading PDFs
|
||||
createPluginRegistration(ExportPluginPackage, {
|
||||
defaultFileName: 'document.pdf',
|
||||
}),
|
||||
];
|
||||
}, [pdfUrl]);
|
||||
|
||||
// Initialize the engine with the React hook
|
||||
const { engine, isLoading, error } = usePdfiumEngine();
|
||||
|
||||
|
||||
// Early return if no file or URL provided
|
||||
if (!file && !url) {
|
||||
return (
|
||||
<Center h="100%" w="100%">
|
||||
<Stack align="center" gap="md">
|
||||
<div style={{ fontSize: '24px' }}>📄</div>
|
||||
<Text c="dimmed" size="sm">
|
||||
No PDF provided
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !engine || !pdfUrl) {
|
||||
return <ToolLoadingFallback toolName="PDF Engine" />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Center h="100%" w="100%">
|
||||
<Stack align="center" gap="md">
|
||||
<div style={{ fontSize: '24px' }}>❌</div>
|
||||
<Text c="red" size="sm" style={{ textAlign: 'center' }}>
|
||||
Error loading PDF engine: {error.message}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
// Wrap your UI with the <EmbedPDF> provider
|
||||
return (
|
||||
<div
|
||||
className='ph-no-capture'
|
||||
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
}}>
|
||||
<EmbedPDF
|
||||
engine={engine}
|
||||
plugins={plugins}
|
||||
onInitialized={enableAnnotations ? async (registry) => {
|
||||
const annotationPlugin = registry.getPlugin('annotation');
|
||||
if (!annotationPlugin || !annotationPlugin.provides) return;
|
||||
|
||||
const annotationApi = annotationPlugin.provides();
|
||||
if (!annotationApi) return;
|
||||
|
||||
// Add custom signature stamp tool for image signatures
|
||||
annotationApi.addTool({
|
||||
id: 'signatureStamp',
|
||||
name: 'Digital Signature',
|
||||
interaction: { exclusive: false, cursor: 'copy' },
|
||||
matchScore: () => 0,
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.STAMP,
|
||||
// Image will be set dynamically when signature is created
|
||||
},
|
||||
});
|
||||
|
||||
// Add custom ink signature tool for drawn signatures
|
||||
annotationApi.addTool({
|
||||
id: 'signatureInk',
|
||||
name: 'Signature Draw',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
matchScore: () => 0,
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.INK,
|
||||
color: '#000000',
|
||||
opacity: 1.0,
|
||||
borderWidth: 2,
|
||||
},
|
||||
});
|
||||
|
||||
// Listen for annotation events to track annotations and notify parent
|
||||
annotationApi.onAnnotationEvent((event: any) => {
|
||||
if (event.type === 'create' && event.committed) {
|
||||
// Add to annotations list
|
||||
setAnnotations(prev => [...prev, {
|
||||
id: event.annotation.id,
|
||||
pageIndex: event.pageIndex,
|
||||
rect: event.annotation.rect
|
||||
}]);
|
||||
|
||||
|
||||
// Notify parent if callback provided
|
||||
if (onSignatureAdded) {
|
||||
onSignatureAdded(event.annotation);
|
||||
}
|
||||
} else if (event.type === 'delete' && event.committed) {
|
||||
// Remove from annotations list
|
||||
setAnnotations(prev => prev.filter(ann => ann.id !== event.annotation.id));
|
||||
} else if (event.type === 'loaded') {
|
||||
// Handle initial load of annotations
|
||||
const loadedAnnotations = event.annotations || [];
|
||||
setAnnotations(loadedAnnotations.map((ann: any) => ({
|
||||
id: ann.id,
|
||||
pageIndex: ann.pageIndex || 0,
|
||||
rect: ann.rect
|
||||
})));
|
||||
}
|
||||
});
|
||||
} : undefined}
|
||||
>
|
||||
<ZoomAPIBridge />
|
||||
<ScrollAPIBridge />
|
||||
<SelectionAPIBridge />
|
||||
<PanAPIBridge />
|
||||
<SpreadAPIBridge />
|
||||
<SearchAPIBridge />
|
||||
<ThumbnailAPIBridge />
|
||||
<RotateAPIBridge />
|
||||
{enableAnnotations && <SignatureAPIBridge ref={signatureApiRef} />}
|
||||
{enableAnnotations && <HistoryAPIBridge ref={historyApiRef} />}
|
||||
<ExportAPIBridge />
|
||||
<GlobalPointerProvider>
|
||||
<Viewport
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-background)',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
maxHeight: '100%',
|
||||
maxWidth: '100%',
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
contain: 'strict',
|
||||
}}
|
||||
>
|
||||
<Scroller
|
||||
renderPage={({ document, width, height, pageIndex, scale, rotation }) => {
|
||||
return (
|
||||
<Rotate key={document?.id} pageSize={{ width, height }}>
|
||||
<PagePointerProvider pageIndex={pageIndex} pageWidth={width} pageHeight={height} scale={scale} rotation={rotation}>
|
||||
<div
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
position: 'relative',
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
MozUserSelect: 'none',
|
||||
msUserSelect: 'none',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)'
|
||||
}}
|
||||
draggable={false}
|
||||
onDragStart={(e) => e.preventDefault()}
|
||||
onDrop={(e) => e.preventDefault()}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
>
|
||||
{/* High-resolution tile layer */}
|
||||
<TilingLayer pageIndex={pageIndex} scale={scale} />
|
||||
|
||||
{/* Search highlight layer */}
|
||||
<CustomSearchLayer pageIndex={pageIndex} scale={scale} />
|
||||
|
||||
{/* Selection layer for text interaction */}
|
||||
<SelectionLayer pageIndex={pageIndex} scale={scale} />
|
||||
{/* Annotation layer for signatures (only when enabled) */}
|
||||
{enableAnnotations && (
|
||||
<AnnotationLayer
|
||||
pageIndex={pageIndex}
|
||||
scale={scale}
|
||||
pageWidth={width}
|
||||
pageHeight={height}
|
||||
rotation={rotation}
|
||||
selectionOutlineColor="#007ACC"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PagePointerProvider>
|
||||
</Rotate>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Viewport>
|
||||
</GlobalPointerProvider>
|
||||
</EmbedPDF>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { createPluginRegistration } from '@embedpdf/core';
|
||||
import { EmbedPDF } from '@embedpdf/core/react';
|
||||
import { usePdfiumEngine } from '@embedpdf/engines/react';
|
||||
|
||||
// Import the essential plugins
|
||||
import { Viewport, ViewportPluginPackage } from '@embedpdf/plugin-viewport/react';
|
||||
import { Scroller, ScrollPluginPackage, ScrollStrategy } from '@embedpdf/plugin-scroll/react';
|
||||
import { LoaderPluginPackage } from '@embedpdf/plugin-loader/react';
|
||||
import { RenderPluginPackage } from '@embedpdf/plugin-render/react';
|
||||
import { ZoomPluginPackage } from '@embedpdf/plugin-zoom/react';
|
||||
import { InteractionManagerPluginPackage, PagePointerProvider, GlobalPointerProvider } from '@embedpdf/plugin-interaction-manager/react';
|
||||
import { SelectionLayer, SelectionPluginPackage } from '@embedpdf/plugin-selection/react';
|
||||
import { TilingLayer, TilingPluginPackage } from '@embedpdf/plugin-tiling/react';
|
||||
import { PanPluginPackage } from '@embedpdf/plugin-pan/react';
|
||||
import { SpreadPluginPackage, SpreadMode } from '@embedpdf/plugin-spread/react';
|
||||
import { SearchPluginPackage } from '@embedpdf/plugin-search/react';
|
||||
import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react';
|
||||
import { RotatePluginPackage, Rotate } from '@embedpdf/plugin-rotate/react';
|
||||
import { Rotation } from '@embedpdf/models';
|
||||
|
||||
// Import annotation plugins
|
||||
import { HistoryPluginPackage } from '@embedpdf/plugin-history/react';
|
||||
import { AnnotationLayer, AnnotationPluginPackage } from '@embedpdf/plugin-annotation/react';
|
||||
import { PdfAnnotationSubtype } from '@embedpdf/models';
|
||||
|
||||
import { CustomSearchLayer } from '@app/components/viewer/CustomSearchLayer';
|
||||
import { ZoomAPIBridge } from '@app/components/viewer/ZoomAPIBridge';
|
||||
import ToolLoadingFallback from '@app/components/tools/ToolLoadingFallback';
|
||||
import { Center, Stack, Text } from '@mantine/core';
|
||||
import { ScrollAPIBridge } from '@app/components/viewer/ScrollAPIBridge';
|
||||
import { SelectionAPIBridge } from '@app/components/viewer/SelectionAPIBridge';
|
||||
import { PanAPIBridge } from '@app/components/viewer/PanAPIBridge';
|
||||
import { SpreadAPIBridge } from '@app/components/viewer/SpreadAPIBridge';
|
||||
import { SearchAPIBridge } from '@app/components/viewer/SearchAPIBridge';
|
||||
import { ThumbnailAPIBridge } from '@app/components/viewer/ThumbnailAPIBridge';
|
||||
import { RotateAPIBridge } from '@app/components/viewer/RotateAPIBridge';
|
||||
|
||||
interface LocalEmbedPDFWithAnnotationsProps {
|
||||
file?: File | Blob;
|
||||
url?: string | null;
|
||||
onAnnotationChange?: (annotations: any[]) => void;
|
||||
}
|
||||
|
||||
export function LocalEmbedPDFWithAnnotations({
|
||||
file,
|
||||
url,
|
||||
onAnnotationChange
|
||||
}: LocalEmbedPDFWithAnnotationsProps) {
|
||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||
|
||||
// Convert File to URL if needed
|
||||
useEffect(() => {
|
||||
if (file) {
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
setPdfUrl(objectUrl);
|
||||
return () => URL.revokeObjectURL(objectUrl);
|
||||
} else if (url) {
|
||||
setPdfUrl(url);
|
||||
}
|
||||
}, [file, url]);
|
||||
|
||||
// Create plugins configuration with annotation support
|
||||
const plugins = useMemo(() => {
|
||||
if (!pdfUrl) return [];
|
||||
|
||||
// Calculate 3.5rem in pixels dynamically based on root font size
|
||||
const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
const viewportGap = rootFontSize * 3.5;
|
||||
|
||||
return [
|
||||
createPluginRegistration(LoaderPluginPackage, {
|
||||
loadingOptions: {
|
||||
type: 'url',
|
||||
pdfFile: {
|
||||
id: 'stirling-pdf-signing-viewer',
|
||||
url: pdfUrl,
|
||||
},
|
||||
},
|
||||
}),
|
||||
createPluginRegistration(ViewportPluginPackage, {
|
||||
viewportGap,
|
||||
}),
|
||||
createPluginRegistration(ScrollPluginPackage, {
|
||||
strategy: ScrollStrategy.Vertical,
|
||||
initialPage: 0,
|
||||
}),
|
||||
createPluginRegistration(RenderPluginPackage),
|
||||
|
||||
// Register interaction manager (required for annotations)
|
||||
createPluginRegistration(InteractionManagerPluginPackage),
|
||||
|
||||
// Register selection plugin (depends on InteractionManager)
|
||||
createPluginRegistration(SelectionPluginPackage),
|
||||
|
||||
// Register history plugin for undo/redo (recommended for annotations)
|
||||
createPluginRegistration(HistoryPluginPackage),
|
||||
|
||||
// Register annotation plugin (depends on InteractionManager, Selection, History)
|
||||
createPluginRegistration(AnnotationPluginPackage, {
|
||||
annotationAuthor: 'Digital Signature',
|
||||
autoCommit: true,
|
||||
deactivateToolAfterCreate: false,
|
||||
selectAfterCreate: true,
|
||||
}),
|
||||
|
||||
// Register pan plugin
|
||||
createPluginRegistration(PanPluginPackage, {
|
||||
defaultMode: 'mobile',
|
||||
}),
|
||||
|
||||
// Register zoom plugin
|
||||
createPluginRegistration(ZoomPluginPackage, {
|
||||
defaultZoomLevel: 1.4,
|
||||
minZoom: 0.2,
|
||||
maxZoom: 3.0,
|
||||
}),
|
||||
|
||||
// Register tiling plugin
|
||||
createPluginRegistration(TilingPluginPackage, {
|
||||
tileSize: 768,
|
||||
overlapPx: 5,
|
||||
extraRings: 1,
|
||||
}),
|
||||
|
||||
// Register spread plugin
|
||||
createPluginRegistration(SpreadPluginPackage, {
|
||||
defaultSpreadMode: SpreadMode.None,
|
||||
}),
|
||||
|
||||
// Register search plugin
|
||||
createPluginRegistration(SearchPluginPackage),
|
||||
|
||||
// Register thumbnail plugin
|
||||
createPluginRegistration(ThumbnailPluginPackage),
|
||||
|
||||
// Register rotate plugin
|
||||
createPluginRegistration(RotatePluginPackage, {
|
||||
defaultRotation: Rotation.Degree0,
|
||||
}),
|
||||
];
|
||||
}, [pdfUrl]);
|
||||
|
||||
// Initialize the engine
|
||||
const { engine, isLoading, error } = usePdfiumEngine();
|
||||
|
||||
// Early return if no file or URL provided
|
||||
if (!file && !url) {
|
||||
return (
|
||||
<Center h="100%" w="100%">
|
||||
<Stack align="center" gap="md">
|
||||
<div style={{ fontSize: '24px' }}>📄</div>
|
||||
<Text c="dimmed" size="sm">
|
||||
No PDF provided
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !engine || !pdfUrl) {
|
||||
return <ToolLoadingFallback toolName="PDF Engine" />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Center h="100%" w="100%">
|
||||
<Stack align="center" gap="md">
|
||||
<div style={{ fontSize: '24px' }}>❌</div>
|
||||
<Text c="red" size="sm" style={{ textAlign: 'center' }}>
|
||||
Error loading PDF engine: {error.message}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
minWidth: 0
|
||||
}}>
|
||||
<EmbedPDF
|
||||
engine={engine}
|
||||
plugins={plugins}
|
||||
onInitialized={async (registry) => {
|
||||
const annotationPlugin = registry.getPlugin('annotation');
|
||||
if (!annotationPlugin || !annotationPlugin.provides) return;
|
||||
|
||||
const annotationApi = annotationPlugin.provides();
|
||||
if (!annotationApi) return;
|
||||
|
||||
// Add custom signature stamp tool
|
||||
annotationApi.addTool({
|
||||
id: 'signatureStamp',
|
||||
name: 'Digital Signature',
|
||||
interaction: { exclusive: false, cursor: 'copy' },
|
||||
matchScore: () => 0,
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.STAMP,
|
||||
// Will be set dynamically when user creates signature
|
||||
},
|
||||
});
|
||||
|
||||
// Add custom ink signature tool
|
||||
annotationApi.addTool({
|
||||
id: 'signatureInk',
|
||||
name: 'Signature Draw',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
matchScore: () => 0,
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.INK,
|
||||
color: '#000000',
|
||||
opacity: 1.0,
|
||||
borderWidth: 2,
|
||||
},
|
||||
});
|
||||
|
||||
// Listen for annotation events to notify parent
|
||||
if (onAnnotationChange) {
|
||||
annotationApi.onAnnotationEvent((event: any) => {
|
||||
if (event.committed) {
|
||||
// Get all annotations and notify parent
|
||||
// This is a simplified approach - in reality you'd need to get all annotations
|
||||
onAnnotationChange([event.annotation]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ZoomAPIBridge />
|
||||
<ScrollAPIBridge />
|
||||
<SelectionAPIBridge />
|
||||
<PanAPIBridge />
|
||||
<SpreadAPIBridge />
|
||||
<SearchAPIBridge />
|
||||
<ThumbnailAPIBridge />
|
||||
<RotateAPIBridge />
|
||||
<GlobalPointerProvider>
|
||||
<Viewport
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-surface)',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
maxHeight: '100%',
|
||||
maxWidth: '100%',
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
contain: 'strict',
|
||||
}}
|
||||
>
|
||||
<Scroller
|
||||
renderPage={({ width, height, pageIndex, scale, rotation }: {
|
||||
width: number;
|
||||
height: number;
|
||||
pageIndex: number;
|
||||
scale: number;
|
||||
rotation?: number;
|
||||
}) => (
|
||||
<Rotate pageSize={{ width, height }}>
|
||||
<PagePointerProvider {...{
|
||||
pageWidth: width,
|
||||
pageHeight: height,
|
||||
pageIndex,
|
||||
scale,
|
||||
rotation: rotation || 0
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
position: 'relative',
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
MozUserSelect: 'none',
|
||||
msUserSelect: 'none'
|
||||
}}
|
||||
draggable={false}
|
||||
onDragStart={(e) => e.preventDefault()}
|
||||
onDrop={(e) => e.preventDefault()}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
>
|
||||
{/* High-resolution tile layer */}
|
||||
<TilingLayer pageIndex={pageIndex} scale={scale} />
|
||||
|
||||
{/* Search highlight layer */}
|
||||
<CustomSearchLayer pageIndex={pageIndex} scale={scale} />
|
||||
|
||||
{/* Selection layer for text interaction */}
|
||||
<SelectionLayer pageIndex={pageIndex} scale={scale} />
|
||||
|
||||
{/* Annotation layer for signatures */}
|
||||
<AnnotationLayer
|
||||
pageIndex={pageIndex}
|
||||
scale={scale}
|
||||
pageWidth={width}
|
||||
pageHeight={height}
|
||||
rotation={rotation || 0}
|
||||
selectionOutlineColor="#007ACC"
|
||||
/>
|
||||
</div>
|
||||
</PagePointerProvider>
|
||||
</Rotate>
|
||||
)}
|
||||
/>
|
||||
</Viewport>
|
||||
</GlobalPointerProvider>
|
||||
</EmbedPDF>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { usePan } from '@embedpdf/plugin-pan/react';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
|
||||
/**
|
||||
* Component that runs inside EmbedPDF context and updates pan state in ViewerContext
|
||||
*/
|
||||
export function PanAPIBridge() {
|
||||
const { provides: pan, isPanning } = usePan();
|
||||
const { registerBridge } = useViewer();
|
||||
|
||||
// Store state locally
|
||||
const [_localState, setLocalState] = useState({
|
||||
isPanning: false
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (pan) {
|
||||
// Update local state
|
||||
const newState = {
|
||||
isPanning
|
||||
};
|
||||
setLocalState(newState);
|
||||
|
||||
// Register this bridge with ViewerContext
|
||||
registerBridge('pan', {
|
||||
state: newState,
|
||||
api: {
|
||||
enable: () => {
|
||||
pan.enablePan();
|
||||
},
|
||||
disable: () => {
|
||||
pan.disablePan();
|
||||
},
|
||||
toggle: () => {
|
||||
pan.togglePan();
|
||||
},
|
||||
makePanDefault: () => pan.makePanDefault(),
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [pan, isPanning]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button, Paper, Group, NumberInput } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
import FirstPageIcon from '@mui/icons-material/FirstPage';
|
||||
import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos';
|
||||
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
|
||||
import LastPageIcon from '@mui/icons-material/LastPage';
|
||||
import DescriptionIcon from '@mui/icons-material/Description';
|
||||
import ViewWeekIcon from '@mui/icons-material/ViewWeek';
|
||||
|
||||
interface PdfViewerToolbarProps {
|
||||
// Page navigation props (placeholders for now)
|
||||
currentPage?: number;
|
||||
totalPages?: number;
|
||||
onPageChange?: (page: number) => void;
|
||||
|
||||
// Dual page toggle (placeholder for now)
|
||||
dualPage?: boolean;
|
||||
onDualPageToggle?: () => void;
|
||||
|
||||
// Zoom controls (connected via ViewerContext)
|
||||
currentZoom?: number;
|
||||
}
|
||||
|
||||
export function PdfViewerToolbar({
|
||||
currentPage = 1,
|
||||
totalPages: _totalPages = 1,
|
||||
onPageChange,
|
||||
dualPage = false,
|
||||
onDualPageToggle,
|
||||
currentZoom: _currentZoom = 100,
|
||||
}: PdfViewerToolbarProps) {
|
||||
const { t } = useTranslation();
|
||||
const { getScrollState, getZoomState, scrollActions, zoomActions, registerImmediateZoomUpdate, registerImmediateScrollUpdate } = useViewer();
|
||||
|
||||
const scrollState = getScrollState();
|
||||
const zoomState = getZoomState();
|
||||
const [pageInput, setPageInput] = useState(scrollState.currentPage || currentPage);
|
||||
const [displayZoomPercent, setDisplayZoomPercent] = useState(zoomState.zoomPercent || 140);
|
||||
|
||||
// Register for immediate scroll updates and sync with actual scroll state
|
||||
useEffect(() => {
|
||||
registerImmediateScrollUpdate((currentPage, _totalPages) => {
|
||||
setPageInput(currentPage);
|
||||
});
|
||||
setPageInput(scrollState.currentPage);
|
||||
}, [registerImmediateScrollUpdate]);
|
||||
|
||||
// Register for immediate zoom updates and sync with actual zoom state
|
||||
useEffect(() => {
|
||||
registerImmediateZoomUpdate(setDisplayZoomPercent);
|
||||
setDisplayZoomPercent(zoomState.zoomPercent || 140);
|
||||
}, [zoomState.zoomPercent, registerImmediateZoomUpdate]);
|
||||
|
||||
const handleZoomOut = () => {
|
||||
zoomActions.zoomOut();
|
||||
};
|
||||
|
||||
const handleZoomIn = () => {
|
||||
zoomActions.zoomIn();
|
||||
};
|
||||
|
||||
const handlePageNavigation = (page: number) => {
|
||||
scrollActions.scrollToPage(page);
|
||||
if (onPageChange) {
|
||||
onPageChange(page);
|
||||
}
|
||||
setPageInput(page);
|
||||
};
|
||||
|
||||
const handleFirstPage = () => {
|
||||
scrollActions.scrollToFirstPage();
|
||||
};
|
||||
|
||||
const handlePreviousPage = () => {
|
||||
scrollActions.scrollToPreviousPage();
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
scrollActions.scrollToNextPage();
|
||||
};
|
||||
|
||||
const handleLastPage = () => {
|
||||
scrollActions.scrollToLastPage();
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
radius="xl xl 0 0"
|
||||
shadow="sm"
|
||||
p={12}
|
||||
pb={12}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
boxShadow: "0 -2px 8px rgba(0,0,0,0.04)",
|
||||
pointerEvents: "auto",
|
||||
minWidth: '26.5rem',
|
||||
}}
|
||||
>
|
||||
{/* First Page Button */}
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
size="md"
|
||||
px={8}
|
||||
radius="xl"
|
||||
onClick={handleFirstPage}
|
||||
disabled={scrollState.currentPage === 1}
|
||||
style={{ minWidth: '2.5rem' }}
|
||||
title={t("viewer.firstPage", "First Page")}
|
||||
>
|
||||
<FirstPageIcon fontSize="small" />
|
||||
</Button>
|
||||
|
||||
{/* Previous Page Button */}
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
size="md"
|
||||
px={8}
|
||||
radius="xl"
|
||||
onClick={handlePreviousPage}
|
||||
disabled={scrollState.currentPage === 1}
|
||||
style={{ minWidth: '2.5rem' }}
|
||||
title={t("viewer.previousPage", "Previous Page")}
|
||||
>
|
||||
<ArrowBackIosIcon fontSize="small" />
|
||||
</Button>
|
||||
|
||||
{/* Page Input */}
|
||||
<NumberInput
|
||||
value={pageInput}
|
||||
onChange={(value) => {
|
||||
const page = Number(value);
|
||||
setPageInput(page);
|
||||
if (!isNaN(page) && page >= 1 && page <= scrollState.totalPages) {
|
||||
handlePageNavigation(page);
|
||||
}
|
||||
}}
|
||||
min={1}
|
||||
max={scrollState.totalPages}
|
||||
hideControls
|
||||
styles={{
|
||||
input: { width: 48, textAlign: "center", fontWeight: 500, fontSize: 16 },
|
||||
}}
|
||||
/>
|
||||
|
||||
<span style={{ fontWeight: 500, fontSize: 16 }}>
|
||||
/ {scrollState.totalPages}
|
||||
</span>
|
||||
|
||||
{/* Next Page Button */}
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
size="md"
|
||||
px={8}
|
||||
radius="xl"
|
||||
onClick={handleNextPage}
|
||||
disabled={scrollState.currentPage === scrollState.totalPages}
|
||||
style={{ minWidth: '2.5rem' }}
|
||||
title={t("viewer.nextPage", "Next Page")}
|
||||
>
|
||||
<ArrowForwardIosIcon fontSize="small" />
|
||||
</Button>
|
||||
|
||||
{/* Last Page Button */}
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
size="md"
|
||||
px={8}
|
||||
radius="xl"
|
||||
onClick={handleLastPage}
|
||||
disabled={scrollState.currentPage === scrollState.totalPages}
|
||||
style={{ minWidth: '2.5rem' }}
|
||||
title={t("viewer.lastPage", "Last Page")}
|
||||
>
|
||||
<LastPageIcon fontSize="small" />
|
||||
</Button>
|
||||
|
||||
{/* Dual Page Toggle */}
|
||||
<Button
|
||||
variant={dualPage ? "filled" : "light"}
|
||||
color="blue"
|
||||
size="md"
|
||||
radius="xl"
|
||||
onClick={onDualPageToggle}
|
||||
style={{ minWidth: '2.5rem' }}
|
||||
title={dualPage ? t("viewer.singlePageView", "Single Page View") : t("viewer.dualPageView", "Dual Page View")}
|
||||
>
|
||||
{dualPage ? <DescriptionIcon fontSize="small" /> : <ViewWeekIcon fontSize="small" />}
|
||||
</Button>
|
||||
|
||||
{/* Zoom Controls */}
|
||||
<Group gap={4} align="center" style={{ marginLeft: 16 }}>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
size="md"
|
||||
radius="xl"
|
||||
onClick={handleZoomOut}
|
||||
style={{ minWidth: '2rem', padding: 0 }}
|
||||
title={t("viewer.zoomOut", "Zoom out")}
|
||||
>
|
||||
−
|
||||
</Button>
|
||||
<span style={{ minWidth: '2.5rem', textAlign: "center" }}>
|
||||
{displayZoomPercent}%
|
||||
</span>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
size="md"
|
||||
radius="xl"
|
||||
onClick={handleZoomIn}
|
||||
style={{ minWidth: '2rem', padding: 0 }}
|
||||
title={t("viewer.zoomIn", "Zoom in")}
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRotate } from '@embedpdf/plugin-rotate/react';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
|
||||
/**
|
||||
* Component that runs inside EmbedPDF context and updates rotation state in ViewerContext
|
||||
*/
|
||||
export function RotateAPIBridge() {
|
||||
const { provides: rotate, rotation } = useRotate();
|
||||
const { registerBridge } = useViewer();
|
||||
|
||||
// Store state locally
|
||||
const [_localState, setLocalState] = useState({
|
||||
rotation: 0
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (rotate) {
|
||||
// Update local state
|
||||
const newState = {
|
||||
rotation
|
||||
};
|
||||
setLocalState(newState);
|
||||
|
||||
// Register this bridge with ViewerContext
|
||||
registerBridge('rotation', {
|
||||
state: newState,
|
||||
api: {
|
||||
rotateForward: () => rotate.rotateForward(),
|
||||
rotateBackward: () => rotate.rotateBackward(),
|
||||
setRotation: (rotationValue: number) => rotate.setRotation(rotationValue),
|
||||
getRotation: () => rotation,
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [rotate, rotation]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useScroll } from '@embedpdf/plugin-scroll/react';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
|
||||
/**
|
||||
* ScrollAPIBridge manages scroll state and exposes scroll actions.
|
||||
* Registers with ViewerContext to provide scroll functionality to UI components.
|
||||
*/
|
||||
export function ScrollAPIBridge() {
|
||||
const { provides: scroll, state: scrollState } = useScroll();
|
||||
const { registerBridge, triggerImmediateScrollUpdate } = useViewer();
|
||||
|
||||
useEffect(() => {
|
||||
if (scroll && scrollState) {
|
||||
const newState = {
|
||||
currentPage: scrollState.currentPage,
|
||||
totalPages: scrollState.totalPages,
|
||||
};
|
||||
|
||||
// Trigger immediate update for responsive UI
|
||||
triggerImmediateScrollUpdate(newState.currentPage, newState.totalPages);
|
||||
|
||||
registerBridge('scroll', {
|
||||
state: newState,
|
||||
api: scroll
|
||||
});
|
||||
}
|
||||
}, [scroll, scrollState]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearch } from '@embedpdf/plugin-search/react';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
|
||||
interface SearchResult {
|
||||
pageIndex: number;
|
||||
rects: Array<{
|
||||
origin: { x: number; y: number };
|
||||
size: { width: number; height: number };
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* SearchAPIBridge manages search state and provides search functionality.
|
||||
* Listens for search result changes from EmbedPDF and maintains local state.
|
||||
*/
|
||||
export function SearchAPIBridge() {
|
||||
const { provides: search } = useSearch();
|
||||
const { registerBridge } = useViewer();
|
||||
|
||||
const [localState, setLocalState] = useState({
|
||||
results: null as SearchResult[] | null,
|
||||
activeIndex: 0
|
||||
});
|
||||
|
||||
// Subscribe to search result changes from EmbedPDF
|
||||
useEffect(() => {
|
||||
if (!search) return;
|
||||
|
||||
const unsubscribe = search.onSearchResultStateChange?.((state: any) => {
|
||||
const newState = {
|
||||
results: state?.results || null,
|
||||
activeIndex: (state?.activeResultIndex || 0) + 1 // Convert to 1-based index
|
||||
};
|
||||
|
||||
setLocalState(prevState => {
|
||||
// Only update if state actually changed
|
||||
if (prevState.results !== newState.results || prevState.activeIndex !== newState.activeIndex) {
|
||||
return newState;
|
||||
}
|
||||
return prevState;
|
||||
});
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [search]);
|
||||
|
||||
// Register bridge whenever search API or state changes
|
||||
useEffect(() => {
|
||||
if (search) {
|
||||
registerBridge('search', {
|
||||
state: localState,
|
||||
api: {
|
||||
search: async (query: string) => {
|
||||
search.startSearch();
|
||||
return search.searchAllPages(query);
|
||||
},
|
||||
clear: () => {
|
||||
search.stopSearch();
|
||||
setLocalState({ results: null, activeIndex: 0 });
|
||||
},
|
||||
next: () => search.nextResult(),
|
||||
previous: () => search.previousResult(),
|
||||
goToResult: (index: number) => search.goToResult(index),
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [search, localState]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box, TextInput, ActionIcon, Text, Group } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LocalIcon } from '@app/components/shared/LocalIcon';
|
||||
import { ViewerContext } from '@app/contexts/ViewerContext';
|
||||
|
||||
interface SearchInterfaceProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
|
||||
const { t } = useTranslation();
|
||||
const viewerContext = React.useContext(ViewerContext);
|
||||
|
||||
const searchState = viewerContext?.getSearchState();
|
||||
const searchResults = searchState?.results;
|
||||
const searchActiveIndex = searchState?.activeIndex;
|
||||
const searchActions = viewerContext?.searchActions;
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [jumpToValue, setJumpToValue] = useState('');
|
||||
const [resultInfo, setResultInfo] = useState<{
|
||||
currentIndex: number;
|
||||
totalResults: number;
|
||||
query: string;
|
||||
} | null>(null);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
// Monitor search state changes
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
|
||||
const checkSearchState = () => {
|
||||
// Use ViewerContext state instead of window APIs
|
||||
if (searchResults && searchResults.length > 0) {
|
||||
const activeIndex = searchActiveIndex || 1;
|
||||
|
||||
setResultInfo({
|
||||
currentIndex: activeIndex,
|
||||
totalResults: searchResults.length,
|
||||
query: searchQuery // Use local search query
|
||||
});
|
||||
} else if (searchQuery && searchResults?.length === 0) {
|
||||
// Show "no results" state
|
||||
setResultInfo({
|
||||
currentIndex: 0,
|
||||
totalResults: 0,
|
||||
query: searchQuery
|
||||
});
|
||||
} else {
|
||||
setResultInfo(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Check immediately and then poll for updates
|
||||
checkSearchState();
|
||||
const interval = setInterval(checkSearchState, 200);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [visible, searchResults, searchActiveIndex, searchQuery]);
|
||||
|
||||
const handleSearch = async (query: string) => {
|
||||
if (!query.trim()) {
|
||||
// If query is empty, clear the search
|
||||
handleClearSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
if (query.trim() && searchActions) {
|
||||
setIsSearching(true);
|
||||
try {
|
||||
await searchActions.search(query.trim());
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleSearch(searchQuery);
|
||||
} else if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
searchActions?.next();
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
searchActions?.previous();
|
||||
};
|
||||
|
||||
const handleClearSearch = () => {
|
||||
searchActions?.clear();
|
||||
setSearchQuery('');
|
||||
setResultInfo(null);
|
||||
};
|
||||
|
||||
// No longer need to sync with external API on mount - removed
|
||||
|
||||
const handleJumpToResult = (index: number) => {
|
||||
// Use context actions instead of window API - functionality simplified for now
|
||||
if (resultInfo && index >= 1 && index <= resultInfo.totalResults) {
|
||||
// Note: goToResult functionality would need to be implemented in SearchAPIBridge
|
||||
console.log('Jump to result:', index);
|
||||
}
|
||||
};
|
||||
|
||||
const handleJumpToSubmit = () => {
|
||||
const index = parseInt(jumpToValue);
|
||||
if (index && resultInfo && index >= 1 && index <= resultInfo.totalResults) {
|
||||
handleJumpToResult(index);
|
||||
}
|
||||
};
|
||||
|
||||
const handleJumpToKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleJumpToSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const _handleClose = () => {
|
||||
handleClearSearch();
|
||||
onClose();
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
padding: '0px'
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Group mb="md">
|
||||
<Text size="sm" fw={600}>
|
||||
{t('search.title', 'Search PDF')}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{/* Search input */}
|
||||
<Group mb="md">
|
||||
<TextInput
|
||||
placeholder={t('search.placeholder', 'Enter search term...')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
const newValue = e.currentTarget.value;
|
||||
setSearchQuery(newValue);
|
||||
// If user clears the input, clear the search highlights
|
||||
if (!newValue.trim()) {
|
||||
handleClearSearch();
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{ flex: 1 }}
|
||||
rightSection={
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
onClick={() => handleSearch(searchQuery)}
|
||||
disabled={!searchQuery.trim() || isSearching}
|
||||
loading={isSearching}
|
||||
>
|
||||
<LocalIcon icon="search" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Results info and navigation */}
|
||||
{resultInfo && (
|
||||
<Group justify="space-between" align="center">
|
||||
{resultInfo.totalResults === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('search.noResults', 'No results found')}
|
||||
</Text>
|
||||
) : (
|
||||
<Group gap="xs" align="center">
|
||||
<TextInput
|
||||
size="xs"
|
||||
value={jumpToValue}
|
||||
onChange={(e) => setJumpToValue(e.currentTarget.value)}
|
||||
onKeyDown={handleJumpToKeyDown}
|
||||
onBlur={handleJumpToSubmit}
|
||||
placeholder={resultInfo.currentIndex.toString()}
|
||||
style={{ width: '3rem' }}
|
||||
type="number"
|
||||
min="1"
|
||||
max={resultInfo.totalResults}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
of {resultInfo.totalResults}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{resultInfo.totalResults > 0 && (
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={handlePrevious}
|
||||
disabled={resultInfo.currentIndex <= 1}
|
||||
aria-label="Previous result"
|
||||
>
|
||||
<LocalIcon icon="keyboard-arrow-up" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={handleNext}
|
||||
disabled={resultInfo.currentIndex >= resultInfo.totalResults}
|
||||
aria-label="Next result"
|
||||
>
|
||||
<LocalIcon icon="keyboard-arrow-down" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={handleClearSearch}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<LocalIcon icon="close" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{isSearching && (
|
||||
<Text size="xs" c="dimmed" ta="center" mt="sm">
|
||||
{t('search.searching', 'Searching...')}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSelectionCapability, SelectionRangeX } from '@embedpdf/plugin-selection/react';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
|
||||
/**
|
||||
* Component that runs inside EmbedPDF context and updates selection state in ViewerContext
|
||||
*/
|
||||
export function SelectionAPIBridge() {
|
||||
const { provides: selection } = useSelectionCapability();
|
||||
const { registerBridge } = useViewer();
|
||||
const [hasSelection, setHasSelection] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (selection) {
|
||||
const newState = {
|
||||
hasSelection
|
||||
};
|
||||
|
||||
// Register this bridge with ViewerContext
|
||||
registerBridge('selection', {
|
||||
state: newState,
|
||||
api: {
|
||||
copyToClipboard: () => selection.copyToClipboard(),
|
||||
getSelectedText: () => selection.getSelectedText(),
|
||||
getFormattedSelection: () => selection.getFormattedSelection(),
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for selection changes to track when text is selected
|
||||
const unsubscribe = selection.onSelectionChange((sel: SelectionRangeX | null) => {
|
||||
const hasText = !!sel;
|
||||
setHasSelection(hasText);
|
||||
const updatedState = { hasSelection: hasText };
|
||||
// Re-register with updated state
|
||||
registerBridge('selection', {
|
||||
state: updatedState,
|
||||
api: {
|
||||
copyToClipboard: () => selection.copyToClipboard(),
|
||||
getSelectedText: () => selection.getSelectedText(),
|
||||
getFormattedSelection: () => selection.getFormattedSelection(),
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Intercept Ctrl+C only when we have PDF text selected
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'c' && hasSelection) {
|
||||
// Call EmbedPDF's copyToClipboard API
|
||||
selection.copyToClipboard();
|
||||
// Don't prevent default - let EmbedPDF handle the clipboard
|
||||
}
|
||||
};
|
||||
|
||||
// Add keyboard listener
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}
|
||||
}, [selection, hasSelection]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { useImperativeHandle, forwardRef, useEffect } from 'react';
|
||||
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
|
||||
import { PdfAnnotationSubtype, uuidV4 } from '@embedpdf/models';
|
||||
import { useSignature } from '@app/contexts/SignatureContext';
|
||||
import type { SignatureAPI } from '@app/components/viewer/viewerTypes';
|
||||
|
||||
export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPIBridge(_, ref) {
|
||||
const { provides: annotationApi } = useAnnotationCapability();
|
||||
const { signatureConfig, storeImageData, isPlacementMode } = useSignature();
|
||||
|
||||
|
||||
// Enable keyboard deletion of selected annotations
|
||||
useEffect(() => {
|
||||
// Always enable delete key when we have annotation API and are in sign mode
|
||||
if (!annotationApi || (isPlacementMode === undefined)) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||
const selectedAnnotation = annotationApi.getSelectedAnnotation?.();
|
||||
|
||||
if (selectedAnnotation) {
|
||||
const annotation = selectedAnnotation as any;
|
||||
const pageIndex = annotation.object?.pageIndex || 0;
|
||||
const id = annotation.object?.id;
|
||||
|
||||
// For STAMP annotations, ensure image data is preserved before deletion
|
||||
if (annotation.object?.type === 13 && id) {
|
||||
// Get current annotation data to ensure we have latest image data stored
|
||||
const pageAnnotationsTask = annotationApi.getPageAnnotations?.({ pageIndex });
|
||||
if (pageAnnotationsTask) {
|
||||
pageAnnotationsTask.toPromise().then((pageAnnotations: any) => {
|
||||
const currentAnn = pageAnnotations?.find((ann: any) => ann.id === id);
|
||||
if (currentAnn && currentAnn.imageSrc) {
|
||||
// Ensure the image data is stored in our persistent store
|
||||
storeImageData(id, currentAnn.imageSrc);
|
||||
}
|
||||
}).catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
// Use EmbedPDF's native deletion which should integrate with history
|
||||
if ((annotationApi as any).deleteSelected) {
|
||||
(annotationApi as any).deleteSelected();
|
||||
} else {
|
||||
// Fallback to direct deletion - less ideal for history
|
||||
if (id) {
|
||||
annotationApi.deleteAnnotation(pageIndex, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [annotationApi, storeImageData, isPlacementMode]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
addImageSignature: (signatureData: string, x: number, y: number, width: number, height: number, pageIndex: number) => {
|
||||
if (!annotationApi) return;
|
||||
|
||||
// Create image stamp annotation with proper image data
|
||||
|
||||
const annotationId = uuidV4();
|
||||
|
||||
// Store image data in our persistent store
|
||||
storeImageData(annotationId, signatureData);
|
||||
|
||||
annotationApi.createAnnotation(pageIndex, {
|
||||
type: PdfAnnotationSubtype.STAMP,
|
||||
rect: {
|
||||
origin: { x, y },
|
||||
size: { width, height }
|
||||
},
|
||||
author: 'Digital Signature',
|
||||
subject: 'Digital Signature',
|
||||
pageIndex: pageIndex,
|
||||
id: annotationId,
|
||||
created: new Date(),
|
||||
// Store image data in multiple places to ensure history captures it
|
||||
imageSrc: signatureData,
|
||||
contents: signatureData, // Some annotation systems use contents
|
||||
data: signatureData, // Try data field
|
||||
imageData: signatureData, // Try imageData field
|
||||
appearance: signatureData // Try appearance field
|
||||
});
|
||||
},
|
||||
|
||||
activateDrawMode: () => {
|
||||
if (!annotationApi) return;
|
||||
|
||||
// Activate the built-in ink tool for drawing
|
||||
annotationApi.setActiveTool('ink');
|
||||
|
||||
// Set default ink tool properties (black color, 2px width)
|
||||
const activeTool = annotationApi.getActiveTool();
|
||||
if (activeTool && activeTool.id === 'ink') {
|
||||
annotationApi.setToolDefaults('ink', {
|
||||
color: '#000000',
|
||||
thickness: 2,
|
||||
lineWidth: 2,
|
||||
strokeWidth: 2,
|
||||
width: 2
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
activateSignaturePlacementMode: () => {
|
||||
if (!annotationApi || !signatureConfig) return;
|
||||
|
||||
try {
|
||||
if (signatureConfig.signatureType === 'text' && signatureConfig.signerName) {
|
||||
// Skip native text tools - always use stamp for consistent sizing
|
||||
const activatedTool = null;
|
||||
|
||||
if (!activatedTool) {
|
||||
// Create text image as stamp with actual pixel size matching desired display size
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
const baseFontSize = signatureConfig.fontSize || 16;
|
||||
const fontFamily = signatureConfig.fontFamily || 'Helvetica';
|
||||
const textColor = signatureConfig.textColor || '#000000';
|
||||
|
||||
// Canvas pixel size = display size (EmbedPDF uses pixel dimensions directly)
|
||||
canvas.width = Math.max(200, signatureConfig.signerName.length * baseFontSize * 0.6);
|
||||
canvas.height = baseFontSize + 20;
|
||||
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.font = `${baseFontSize}px ${fontFamily}`;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(signatureConfig.signerName, 10, canvas.height / 2);
|
||||
const dataURL = canvas.toDataURL();
|
||||
|
||||
// Deactivate and reactivate to force refresh
|
||||
annotationApi.setActiveTool(null);
|
||||
annotationApi.setActiveTool('stamp');
|
||||
const stampTool = annotationApi.getActiveTool();
|
||||
if (stampTool && stampTool.id === 'stamp') {
|
||||
annotationApi.setToolDefaults('stamp', {
|
||||
imageSrc: dataURL,
|
||||
subject: `Text Signature - ${signatureConfig.signerName}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (signatureConfig.signatureData) {
|
||||
// Use stamp tool for image/canvas signatures
|
||||
annotationApi.setActiveTool('stamp');
|
||||
const activeTool = annotationApi.getActiveTool();
|
||||
|
||||
if (activeTool && activeTool.id === 'stamp') {
|
||||
annotationApi.setToolDefaults('stamp', {
|
||||
imageSrc: signatureConfig.signatureData,
|
||||
subject: `Digital Signature - ${signatureConfig.reason || 'Document signing'}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error activating signature tool:', error);
|
||||
}
|
||||
},
|
||||
|
||||
updateDrawSettings: (color: string, size: number) => {
|
||||
if (!annotationApi) return;
|
||||
|
||||
// Always update ink tool defaults - use multiple property names for compatibility
|
||||
annotationApi.setToolDefaults('ink', {
|
||||
color: color,
|
||||
thickness: size,
|
||||
lineWidth: size,
|
||||
strokeWidth: size,
|
||||
width: size
|
||||
});
|
||||
|
||||
// Force reactivate ink tool to ensure new settings take effect
|
||||
annotationApi.setActiveTool(null); // Deactivate first
|
||||
setTimeout(() => {
|
||||
annotationApi.setActiveTool('ink'); // Reactivate with new settings
|
||||
}, 50);
|
||||
},
|
||||
|
||||
activateDeleteMode: () => {
|
||||
if (!annotationApi) return;
|
||||
// Activate selection tool to allow selecting and deleting annotations
|
||||
// Users can click annotations to select them, then press Delete key or right-click to delete
|
||||
annotationApi.setActiveTool('select');
|
||||
},
|
||||
|
||||
deleteAnnotation: (annotationId: string, pageIndex: number) => {
|
||||
if (!annotationApi) return;
|
||||
|
||||
// Before deleting, try to preserve image data for potential undo
|
||||
const pageAnnotationsTask = annotationApi.getPageAnnotations?.({ pageIndex });
|
||||
if (pageAnnotationsTask) {
|
||||
pageAnnotationsTask.toPromise().then((pageAnnotations: any) => {
|
||||
const annotation = pageAnnotations?.find((ann: any) => ann.id === annotationId);
|
||||
if (annotation && annotation.type === 13 && annotation.imageSrc) {
|
||||
// Store image data before deletion
|
||||
storeImageData(annotationId, annotation.imageSrc);
|
||||
}
|
||||
}).catch(console.error);
|
||||
}
|
||||
|
||||
// Delete specific annotation by ID
|
||||
annotationApi.deleteAnnotation(pageIndex, annotationId);
|
||||
},
|
||||
|
||||
deactivateTools: () => {
|
||||
if (!annotationApi) return;
|
||||
annotationApi.setActiveTool(null);
|
||||
},
|
||||
|
||||
getPageAnnotations: async (pageIndex: number): Promise<any[]> => {
|
||||
if (!annotationApi || !annotationApi.getPageAnnotations) {
|
||||
console.warn('getPageAnnotations not available');
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const pageAnnotationsTask = annotationApi.getPageAnnotations({ pageIndex });
|
||||
if (pageAnnotationsTask && pageAnnotationsTask.toPromise) {
|
||||
const annotations = await pageAnnotationsTask.toPromise();
|
||||
return annotations || [];
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error(`Error getting annotations for page ${pageIndex}:`, error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
}), [annotationApi, signatureConfig]);
|
||||
|
||||
|
||||
return null; // This is a bridge component with no UI
|
||||
});
|
||||
|
||||
SignatureAPIBridge.displayName = 'SignatureAPIBridge';
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useSpread, SpreadMode } from '@embedpdf/plugin-spread/react';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
|
||||
/**
|
||||
* Component that runs inside EmbedPDF context and updates spread state in ViewerContext
|
||||
*/
|
||||
export function SpreadAPIBridge() {
|
||||
const { provides: spread, spreadMode } = useSpread();
|
||||
const { registerBridge } = useViewer();
|
||||
|
||||
useEffect(() => {
|
||||
if (spread) {
|
||||
const newState = {
|
||||
spreadMode,
|
||||
isDualPage: spreadMode !== SpreadMode.None
|
||||
};
|
||||
|
||||
// Register this bridge with ViewerContext
|
||||
registerBridge('spread', {
|
||||
state: newState,
|
||||
api: {
|
||||
setSpreadMode: (mode: SpreadMode) => {
|
||||
spread.setSpreadMode(mode);
|
||||
},
|
||||
getSpreadMode: () => spread.getSpreadMode(),
|
||||
toggleSpreadMode: () => {
|
||||
// Toggle between None and Odd (most common dual-page mode)
|
||||
const newMode = spreadMode === SpreadMode.None ? SpreadMode.Odd : SpreadMode.None;
|
||||
spread.setSpreadMode(newMode);
|
||||
},
|
||||
SpreadMode: SpreadMode, // Export enum for reference
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [spread, spreadMode]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useThumbnailCapability } from '@embedpdf/plugin-thumbnail/react';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
|
||||
/**
|
||||
* ThumbnailAPIBridge provides thumbnail generation functionality.
|
||||
* Exposes thumbnail API to UI components without managing state.
|
||||
*/
|
||||
export function ThumbnailAPIBridge() {
|
||||
const { provides: thumbnail } = useThumbnailCapability();
|
||||
const { registerBridge } = useViewer();
|
||||
|
||||
useEffect(() => {
|
||||
if (thumbnail) {
|
||||
registerBridge('thumbnail', {
|
||||
state: null, // No state - just provides API
|
||||
api: thumbnail
|
||||
});
|
||||
}
|
||||
}, [thumbnail]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Box, ScrollArea } from '@mantine/core';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
|
||||
interface ThumbnailSidebarProps {
|
||||
visible: boolean;
|
||||
onToggle: () => void;
|
||||
activeFileIndex?: number;
|
||||
}
|
||||
|
||||
export function ThumbnailSidebar({ visible, onToggle: _onToggle, activeFileIndex }: ThumbnailSidebarProps) {
|
||||
const { getScrollState, scrollActions, getThumbnailAPI } = useViewer();
|
||||
const [thumbnails, setThumbnails] = useState<{ [key: number]: string }>({});
|
||||
|
||||
const scrollState = getScrollState();
|
||||
const thumbnailAPI = getThumbnailAPI();
|
||||
|
||||
// Clear thumbnails when active file changes
|
||||
useEffect(() => {
|
||||
// Revoke old blob URLs to prevent memory leaks
|
||||
Object.values(thumbnails).forEach((thumbUrl) => {
|
||||
if (typeof thumbUrl === 'string' && thumbUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(thumbUrl);
|
||||
}
|
||||
});
|
||||
setThumbnails({});
|
||||
}, [activeFileIndex]);
|
||||
|
||||
// Clear thumbnails when sidebar closes and revoke blob URLs to prevent memory leaks
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
Object.values(thumbnails).forEach((thumbUrl) => {
|
||||
// Only revoke if it's a blob URL (not 'error')
|
||||
if (typeof thumbUrl === 'string' && thumbUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(thumbUrl);
|
||||
}
|
||||
});
|
||||
setThumbnails({});
|
||||
}
|
||||
}, [visible]); // Remove thumbnails from dependency to prevent infinite loop
|
||||
|
||||
// Generate thumbnails when sidebar becomes visible
|
||||
useEffect(() => {
|
||||
if (!visible || scrollState.totalPages === 0) return;
|
||||
if (!thumbnailAPI) return;
|
||||
|
||||
const generateThumbnails = async () => {
|
||||
for (let pageIndex = 0; pageIndex < scrollState.totalPages; pageIndex++) {
|
||||
if (thumbnails[pageIndex]) continue; // Skip if already generated
|
||||
|
||||
try {
|
||||
const thumbTask = thumbnailAPI.renderThumb(pageIndex, 1.0);
|
||||
|
||||
// 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]: 'error'
|
||||
}));
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to generate thumbnail for page', pageIndex + 1, error);
|
||||
setThumbnails(prev => ({
|
||||
...prev,
|
||||
[pageIndex]: 'error'
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
generateThumbnails();
|
||||
}, [visible, scrollState.totalPages, thumbnailAPI]);
|
||||
|
||||
const handlePageClick = (pageIndex: number) => {
|
||||
const pageNumber = pageIndex + 1; // Convert to 1-based
|
||||
scrollActions.scrollToPage(pageNumber);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Thumbnail Sidebar */}
|
||||
{visible && (
|
||||
<Box
|
||||
style={{
|
||||
position: 'fixed',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '15rem',
|
||||
backgroundColor: 'var(--bg-surface)',
|
||||
borderLeft: '1px solid var(--border-subtle)',
|
||||
zIndex: 998,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxShadow: '-2px 0 8px rgba(0, 0, 0, 0.1)'
|
||||
}}
|
||||
>
|
||||
{/* Thumbnails Container */}
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
<Box p="sm">
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px'
|
||||
}}>
|
||||
{Array.from({ length: scrollState.totalPages }, (_, pageIndex) => (
|
||||
<Box
|
||||
key={pageIndex}
|
||||
onClick={() => handlePageClick(pageIndex)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderRadius: '8px',
|
||||
padding: '8px',
|
||||
backgroundColor: scrollState.currentPage === pageIndex + 1
|
||||
? 'var(--color-primary-100)'
|
||||
: 'transparent',
|
||||
border: scrollState.currentPage === pageIndex + 1
|
||||
? '2px solid var(--color-primary-500)'
|
||||
: '2px solid transparent',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (scrollState.currentPage !== pageIndex + 1) {
|
||||
e.currentTarget.style.backgroundColor = 'var(--hover-bg)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (scrollState.currentPage !== pageIndex + 1) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Thumbnail Image */}
|
||||
{thumbnails[pageIndex] && thumbnails[pageIndex] !== 'error' ? (
|
||||
<img
|
||||
className='ph-no-capture'
|
||||
src={thumbnails[pageIndex]}
|
||||
alt={`Page ${pageIndex + 1} thumbnail`}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
height: 'auto',
|
||||
borderRadius: '4px',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
border: '1px solid var(--border-subtle)'
|
||||
}}
|
||||
/>
|
||||
) : thumbnails[pageIndex] === 'error' ? (
|
||||
<div style={{
|
||||
width: '11.5rem',
|
||||
height: '15rem',
|
||||
backgroundColor: 'var(--color-red-50)',
|
||||
border: '1px solid var(--color-red-200)',
|
||||
borderRadius: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--color-red-500)',
|
||||
fontSize: '12px'
|
||||
}}>
|
||||
Failed
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
width: '11.5rem',
|
||||
height: '15rem',
|
||||
backgroundColor: 'var(--bg-muted)',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
borderRadius: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--text-muted)',
|
||||
fontSize: '12px'
|
||||
}}>
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Page Number */}
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
color: scrollState.currentPage === pageIndex + 1
|
||||
? 'var(--color-primary-500)'
|
||||
: 'var(--text-muted)'
|
||||
}}>
|
||||
Page {pageIndex + 1}
|
||||
</div>
|
||||
</Box>
|
||||
))}
|
||||
</div>
|
||||
</Box>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import EmbedPdfViewer from '@app/components/viewer/EmbedPdfViewer';
|
||||
|
||||
export interface ViewerProps {
|
||||
sidebarsVisible: boolean;
|
||||
setSidebarsVisible: (v: boolean) => void;
|
||||
onClose?: () => void;
|
||||
previewFile?: File | null;
|
||||
activeFileIndex?: number;
|
||||
setActiveFileIndex?: (index: number) => void;
|
||||
}
|
||||
|
||||
const Viewer = (props: ViewerProps) => {
|
||||
// Default to EmbedPDF viewer
|
||||
return <EmbedPdfViewer {...props} />;
|
||||
};
|
||||
|
||||
export default Viewer;
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useZoom } from '@embedpdf/plugin-zoom/react';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
|
||||
/**
|
||||
* Component that runs inside EmbedPDF context and manages zoom state locally
|
||||
*/
|
||||
export function ZoomAPIBridge() {
|
||||
const { provides: zoom, state: zoomState } = useZoom();
|
||||
const { registerBridge, triggerImmediateZoomUpdate } = useViewer();
|
||||
const hasSetInitialZoom = useRef(false);
|
||||
|
||||
// Set initial zoom once when plugin is ready
|
||||
useEffect(() => {
|
||||
if (!zoom || hasSetInitialZoom.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const attemptInitialZoom = () => {
|
||||
try {
|
||||
zoom.requestZoom(1.4);
|
||||
hasSetInitialZoom.current = true;
|
||||
} catch (error) {
|
||||
console.log('Zoom initialization delayed, viewport not ready:', error);
|
||||
retryTimer = setTimeout(() => {
|
||||
try {
|
||||
zoom.requestZoom(1.4);
|
||||
hasSetInitialZoom.current = true;
|
||||
} catch (retryError) {
|
||||
console.log('Zoom initialization failed:', retryError);
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setTimeout(attemptInitialZoom, 50);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
if (retryTimer) {
|
||||
clearTimeout(retryTimer);
|
||||
}
|
||||
};
|
||||
}, [zoom, zoomState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (zoom && zoomState) {
|
||||
// Update local state
|
||||
const currentZoomLevel = zoomState.currentZoomLevel ?? 1.4;
|
||||
const newState = {
|
||||
currentZoom: currentZoomLevel,
|
||||
zoomPercent: Math.round(currentZoomLevel * 100),
|
||||
};
|
||||
|
||||
// Trigger immediate update for responsive UI
|
||||
triggerImmediateZoomUpdate(newState.zoomPercent);
|
||||
|
||||
// Register this bridge with ViewerContext
|
||||
registerBridge('zoom', {
|
||||
state: newState,
|
||||
api: zoom
|
||||
});
|
||||
}
|
||||
}, [zoom, zoomState]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export const SEARCH_CONSTANTS = {
|
||||
HIGHLIGHT_COLORS: {
|
||||
BACKGROUND: '#ffff00',
|
||||
ACTIVE_BACKGROUND: '#ff6b35',
|
||||
OPACITY: 0.4,
|
||||
},
|
||||
TIMING: {
|
||||
DEBOUNCE_DELAY: 300,
|
||||
},
|
||||
UI: {
|
||||
HIGHLIGHT_PADDING: 2,
|
||||
MIN_SEARCH_LENGTH: 1,
|
||||
MAX_RESULTS_DISPLAY: 100,
|
||||
}
|
||||
} as const;
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ActionIcon, Popover } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
import { useRightRailButtons, RightRailButtonWithAction } from '@app/hooks/useRightRailButtons';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { Tooltip } from '@app/components/shared/Tooltip';
|
||||
import { SearchInterface } from '@app/components/viewer/SearchInterface';
|
||||
import ViewerAnnotationControls from '@app/components/shared/rightRail/ViewerAnnotationControls';
|
||||
|
||||
export function useViewerRightRailButtons() {
|
||||
const { t } = useTranslation();
|
||||
const viewer = useViewer();
|
||||
const [isPanning, setIsPanning] = useState<boolean>(() => viewer.getPanState()?.isPanning ?? false);
|
||||
|
||||
// Lift i18n labels out of memo for clarity
|
||||
const searchLabel = t('rightRail.search', 'Search PDF');
|
||||
const panLabel = t('rightRail.panMode', 'Pan Mode');
|
||||
const rotateLeftLabel = t('rightRail.rotateLeft', 'Rotate Left');
|
||||
const rotateRightLabel = t('rightRail.rotateRight', 'Rotate Right');
|
||||
const sidebarLabel = t('rightRail.toggleSidebar', 'Toggle Sidebar');
|
||||
|
||||
const viewerButtons = useMemo<RightRailButtonWithAction[]>(() => {
|
||||
return [
|
||||
{
|
||||
id: 'viewer-search',
|
||||
tooltip: searchLabel,
|
||||
ariaLabel: searchLabel,
|
||||
section: 'top' as const,
|
||||
order: 10,
|
||||
render: ({ disabled }) => (
|
||||
<Tooltip content={searchLabel} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<Popover position="left" withArrow shadow="md" offset={8}>
|
||||
<Popover.Target>
|
||||
<div style={{ display: 'inline-flex' }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
disabled={disabled}
|
||||
aria-label={searchLabel}
|
||||
>
|
||||
<LocalIcon icon="search" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<div style={{ minWidth: '20rem' }}>
|
||||
<SearchInterface visible={true} onClose={() => {}} />
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'viewer-pan-mode',
|
||||
tooltip: panLabel,
|
||||
ariaLabel: panLabel,
|
||||
section: 'top' as const,
|
||||
order: 20,
|
||||
render: ({ disabled }) => (
|
||||
<Tooltip content={panLabel} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<ActionIcon
|
||||
variant={isPanning ? 'filled' : 'subtle'}
|
||||
color={isPanning ? 'blue' : undefined}
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
viewer.panActions.togglePan();
|
||||
setIsPanning(prev => !prev);
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<LocalIcon icon="pan-tool-rounded" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'viewer-rotate-left',
|
||||
icon: <LocalIcon icon="rotate-left" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: rotateLeftLabel,
|
||||
ariaLabel: rotateLeftLabel,
|
||||
section: 'top' as const,
|
||||
order: 30,
|
||||
onClick: () => {
|
||||
viewer.rotationActions.rotateBackward();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'viewer-rotate-right',
|
||||
icon: <LocalIcon icon="rotate-right" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: rotateRightLabel,
|
||||
ariaLabel: rotateRightLabel,
|
||||
section: 'top' as const,
|
||||
order: 40,
|
||||
onClick: () => {
|
||||
viewer.rotationActions.rotateForward();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'viewer-toggle-sidebar',
|
||||
icon: <LocalIcon icon="view-list" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: sidebarLabel,
|
||||
ariaLabel: sidebarLabel,
|
||||
section: 'top' as const,
|
||||
order: 50,
|
||||
onClick: () => {
|
||||
viewer.toggleThumbnailSidebar();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'viewer-annotation-controls',
|
||||
section: 'top' as const,
|
||||
order: 60,
|
||||
render: ({ disabled }) => (
|
||||
<ViewerAnnotationControls currentView="viewer" disabled={disabled} />
|
||||
)
|
||||
}
|
||||
];
|
||||
}, [t, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel]);
|
||||
|
||||
useRightRailButtons(viewerButtons);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export interface SignatureAPI {
|
||||
addImageSignature: (
|
||||
signatureData: string,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
pageIndex: number
|
||||
) => void;
|
||||
activateDrawMode: () => void;
|
||||
activateSignaturePlacementMode: () => void;
|
||||
activateDeleteMode: () => void;
|
||||
deleteAnnotation: (annotationId: string, pageIndex: number) => void;
|
||||
updateDrawSettings: (color: string, size: number) => void;
|
||||
deactivateTools: () => void;
|
||||
getPageAnnotations: (pageIndex: number) => Promise<any[]>;
|
||||
}
|
||||
|
||||
export interface HistoryAPI {
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
canUndo: () => boolean;
|
||||
canRedo: () => boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user