mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Feature/annotations (#5260)
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
import { useImperativeHandle, forwardRef, useCallback } from 'react';
|
||||
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
|
||||
import { PdfAnnotationSubtype, PdfAnnotationIcon } from '@embedpdf/models';
|
||||
import type {
|
||||
AnnotationToolId,
|
||||
AnnotationToolOptions,
|
||||
AnnotationAPI,
|
||||
AnnotationEvent,
|
||||
AnnotationPatch,
|
||||
} from '@app/components/viewer/viewerTypes';
|
||||
|
||||
type NoteIcon = NonNullable<AnnotationToolOptions['icon']>;
|
||||
type AnnotationDefaults =
|
||||
| {
|
||||
type:
|
||||
| PdfAnnotationSubtype.HIGHLIGHT
|
||||
| PdfAnnotationSubtype.UNDERLINE
|
||||
| PdfAnnotationSubtype.STRIKEOUT
|
||||
| PdfAnnotationSubtype.SQUIGGLY;
|
||||
color: string;
|
||||
opacity: number;
|
||||
customData?: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
type: PdfAnnotationSubtype.INK;
|
||||
color: string;
|
||||
opacity?: number;
|
||||
borderWidth?: number;
|
||||
strokeWidth?: number;
|
||||
lineWidth?: number;
|
||||
customData?: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
type: PdfAnnotationSubtype.FREETEXT;
|
||||
fontColor?: string;
|
||||
fontSize?: number;
|
||||
fontFamily?: string;
|
||||
textAlign?: number;
|
||||
opacity?: number;
|
||||
backgroundColor?: string;
|
||||
borderWidth?: number;
|
||||
contents?: string;
|
||||
icon?: PdfAnnotationIcon;
|
||||
customData?: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
type: PdfAnnotationSubtype.SQUARE | PdfAnnotationSubtype.CIRCLE | PdfAnnotationSubtype.POLYGON;
|
||||
color: string;
|
||||
strokeColor: string;
|
||||
opacity: number;
|
||||
fillOpacity: number;
|
||||
strokeOpacity: number;
|
||||
borderWidth: number;
|
||||
strokeWidth: number;
|
||||
lineWidth: number;
|
||||
customData?: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
type: PdfAnnotationSubtype.LINE | PdfAnnotationSubtype.POLYLINE;
|
||||
color: string;
|
||||
strokeColor?: string;
|
||||
opacity: number;
|
||||
borderWidth?: number;
|
||||
strokeWidth?: number;
|
||||
lineWidth?: number;
|
||||
startStyle?: string;
|
||||
endStyle?: string;
|
||||
lineEndingStyles?: { start: string; end: string };
|
||||
customData?: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
type: PdfAnnotationSubtype.STAMP;
|
||||
imageSrc?: string;
|
||||
imageSize?: { width: number; height: number };
|
||||
customData?: Record<string, unknown>;
|
||||
}
|
||||
| null;
|
||||
|
||||
type AnnotationApiSurface = {
|
||||
setActiveTool: (toolId: AnnotationToolId | null) => void;
|
||||
getActiveTool?: () => { id: AnnotationToolId } | null;
|
||||
setToolDefaults?: (toolId: AnnotationToolId, defaults: AnnotationDefaults) => void;
|
||||
getSelectedAnnotation?: () => unknown | null;
|
||||
deselectAnnotation?: () => void;
|
||||
updateAnnotation?: (pageIndex: number, annotationId: string, patch: AnnotationPatch) => void;
|
||||
onAnnotationEvent?: (listener: (event: AnnotationEvent) => void) => void | (() => void);
|
||||
};
|
||||
|
||||
type ToolDefaultsBuilder = (options?: AnnotationToolOptions) => AnnotationDefaults;
|
||||
|
||||
const NOTE_ICON_MAP: Record<NoteIcon, PdfAnnotationIcon> = {
|
||||
Comment: PdfAnnotationIcon.Comment,
|
||||
Key: PdfAnnotationIcon.Key,
|
||||
Note: PdfAnnotationIcon.Note,
|
||||
Help: PdfAnnotationIcon.Help,
|
||||
NewParagraph: PdfAnnotationIcon.NewParagraph,
|
||||
Paragraph: PdfAnnotationIcon.Paragraph,
|
||||
Insert: PdfAnnotationIcon.Insert,
|
||||
};
|
||||
|
||||
const DEFAULTS = {
|
||||
highlight: '#ffd54f',
|
||||
underline: '#ffb300',
|
||||
strikeout: '#e53935',
|
||||
squiggly: '#00acc1',
|
||||
ink: '#1f2933',
|
||||
inkHighlighter: '#ffd54f',
|
||||
text: '#111111',
|
||||
note: '#ffd54f', // match highlight color
|
||||
shapeFill: '#0000ff',
|
||||
shapeStroke: '#cf5b5b',
|
||||
shapeOpacity: 0.5,
|
||||
};
|
||||
|
||||
const withCustomData = (options?: AnnotationToolOptions) =>
|
||||
options?.customData ? { customData: options.customData } : {};
|
||||
|
||||
const getIconEnum = (icon?: NoteIcon) => NOTE_ICON_MAP[icon ?? 'Comment'] ?? PdfAnnotationIcon.Comment;
|
||||
|
||||
const buildStampDefaults: ToolDefaultsBuilder = (options) => ({
|
||||
type: PdfAnnotationSubtype.STAMP,
|
||||
...(options?.imageSrc ? { imageSrc: options.imageSrc } : {}),
|
||||
...(options?.imageSize ? { imageSize: options.imageSize } : {}),
|
||||
...withCustomData(options),
|
||||
});
|
||||
|
||||
const buildInkDefaults = (options?: AnnotationToolOptions, opacityOverride?: number): AnnotationDefaults => ({
|
||||
type: PdfAnnotationSubtype.INK,
|
||||
color: options?.color ?? (opacityOverride ? DEFAULTS.inkHighlighter : DEFAULTS.ink),
|
||||
opacity: options?.opacity ?? opacityOverride ?? 1,
|
||||
borderWidth: options?.thickness ?? (opacityOverride ? 6 : 2),
|
||||
strokeWidth: options?.thickness ?? (opacityOverride ? 6 : 2),
|
||||
lineWidth: options?.thickness ?? (opacityOverride ? 6 : 2),
|
||||
...withCustomData(options),
|
||||
});
|
||||
|
||||
const TOOL_DEFAULT_BUILDERS: Record<AnnotationToolId, ToolDefaultsBuilder> = {
|
||||
select: () => null,
|
||||
highlight: (options) => ({
|
||||
type: PdfAnnotationSubtype.HIGHLIGHT,
|
||||
color: options?.color ?? DEFAULTS.highlight,
|
||||
opacity: options?.opacity ?? 0.6,
|
||||
...withCustomData(options),
|
||||
}),
|
||||
underline: (options) => ({
|
||||
type: PdfAnnotationSubtype.UNDERLINE,
|
||||
color: options?.color ?? DEFAULTS.underline,
|
||||
opacity: options?.opacity ?? 1,
|
||||
...withCustomData(options),
|
||||
}),
|
||||
strikeout: (options) => ({
|
||||
type: PdfAnnotationSubtype.STRIKEOUT,
|
||||
color: options?.color ?? DEFAULTS.strikeout,
|
||||
opacity: options?.opacity ?? 1,
|
||||
...withCustomData(options),
|
||||
}),
|
||||
squiggly: (options) => ({
|
||||
type: PdfAnnotationSubtype.SQUIGGLY,
|
||||
color: options?.color ?? DEFAULTS.squiggly,
|
||||
opacity: options?.opacity ?? 1,
|
||||
...withCustomData(options),
|
||||
}),
|
||||
ink: (options) => buildInkDefaults(options),
|
||||
inkHighlighter: (options) => buildInkDefaults(options, options?.opacity ?? 0.6),
|
||||
text: (options) => ({
|
||||
type: PdfAnnotationSubtype.FREETEXT,
|
||||
fontColor: options?.color ?? DEFAULTS.text,
|
||||
fontSize: options?.fontSize ?? 14,
|
||||
fontFamily: options?.fontFamily ?? 'Helvetica',
|
||||
textAlign: options?.textAlign ?? 0,
|
||||
opacity: options?.opacity ?? 1,
|
||||
borderWidth: options?.thickness ?? 1,
|
||||
...(options?.fillColor ? { backgroundColor: options.fillColor } : {}),
|
||||
...withCustomData(options),
|
||||
}),
|
||||
note: (options) => {
|
||||
const backgroundColor = options?.fillColor ?? DEFAULTS.note;
|
||||
const fontColor = options?.color ?? DEFAULTS.text;
|
||||
return {
|
||||
type: PdfAnnotationSubtype.FREETEXT,
|
||||
fontColor,
|
||||
color: fontColor,
|
||||
fontFamily: options?.fontFamily ?? 'Helvetica',
|
||||
textAlign: options?.textAlign ?? 0,
|
||||
fontSize: options?.fontSize ?? 12,
|
||||
opacity: options?.opacity ?? 1,
|
||||
backgroundColor,
|
||||
borderWidth: options?.thickness ?? 0,
|
||||
contents: options?.contents ?? 'Note',
|
||||
icon: getIconEnum(options?.icon),
|
||||
...withCustomData(options),
|
||||
};
|
||||
},
|
||||
square: (options) => ({
|
||||
type: PdfAnnotationSubtype.SQUARE,
|
||||
color: options?.color ?? DEFAULTS.shapeFill,
|
||||
strokeColor: options?.strokeColor ?? DEFAULTS.shapeStroke,
|
||||
opacity: options?.opacity ?? DEFAULTS.shapeOpacity,
|
||||
fillOpacity: options?.fillOpacity ?? DEFAULTS.shapeOpacity,
|
||||
strokeOpacity: options?.strokeOpacity ?? DEFAULTS.shapeOpacity,
|
||||
borderWidth: options?.borderWidth ?? 1,
|
||||
strokeWidth: options?.borderWidth ?? 1,
|
||||
lineWidth: options?.borderWidth ?? 1,
|
||||
...withCustomData(options),
|
||||
}),
|
||||
circle: (options) => ({
|
||||
type: PdfAnnotationSubtype.CIRCLE,
|
||||
color: options?.color ?? DEFAULTS.shapeFill,
|
||||
strokeColor: options?.strokeColor ?? DEFAULTS.shapeStroke,
|
||||
opacity: options?.opacity ?? DEFAULTS.shapeOpacity,
|
||||
fillOpacity: options?.fillOpacity ?? DEFAULTS.shapeOpacity,
|
||||
strokeOpacity: options?.strokeOpacity ?? DEFAULTS.shapeOpacity,
|
||||
borderWidth: options?.borderWidth ?? 1,
|
||||
strokeWidth: options?.borderWidth ?? 1,
|
||||
lineWidth: options?.borderWidth ?? 1,
|
||||
...withCustomData(options),
|
||||
}),
|
||||
line: (options) => ({
|
||||
type: PdfAnnotationSubtype.LINE,
|
||||
color: options?.color ?? '#1565c0',
|
||||
strokeColor: options?.color ?? '#1565c0',
|
||||
opacity: options?.opacity ?? 1,
|
||||
borderWidth: options?.borderWidth ?? 2,
|
||||
strokeWidth: options?.borderWidth ?? 2,
|
||||
lineWidth: options?.borderWidth ?? 2,
|
||||
...withCustomData(options),
|
||||
}),
|
||||
lineArrow: (options) => ({
|
||||
type: PdfAnnotationSubtype.LINE,
|
||||
color: options?.color ?? '#1565c0',
|
||||
strokeColor: options?.color ?? '#1565c0',
|
||||
opacity: options?.opacity ?? 1,
|
||||
borderWidth: options?.borderWidth ?? 2,
|
||||
strokeWidth: options?.borderWidth ?? 2,
|
||||
lineWidth: options?.borderWidth ?? 2,
|
||||
startStyle: 'None',
|
||||
endStyle: 'ClosedArrow',
|
||||
lineEndingStyles: { start: 'None', end: 'ClosedArrow' },
|
||||
...withCustomData(options),
|
||||
}),
|
||||
polyline: (options) => ({
|
||||
type: PdfAnnotationSubtype.POLYLINE,
|
||||
color: options?.color ?? '#1565c0',
|
||||
opacity: options?.opacity ?? 1,
|
||||
borderWidth: options?.borderWidth ?? 2,
|
||||
...withCustomData(options),
|
||||
}),
|
||||
polygon: (options) => ({
|
||||
type: PdfAnnotationSubtype.POLYGON,
|
||||
color: options?.color ?? DEFAULTS.shapeFill,
|
||||
strokeColor: options?.strokeColor ?? DEFAULTS.shapeStroke,
|
||||
opacity: options?.opacity ?? DEFAULTS.shapeOpacity,
|
||||
fillOpacity: options?.fillOpacity ?? DEFAULTS.shapeOpacity,
|
||||
strokeOpacity: options?.strokeOpacity ?? DEFAULTS.shapeOpacity,
|
||||
borderWidth: options?.borderWidth ?? 1,
|
||||
strokeWidth: options?.borderWidth ?? 1,
|
||||
lineWidth: options?.borderWidth ?? 1,
|
||||
...withCustomData(options),
|
||||
}),
|
||||
stamp: buildStampDefaults,
|
||||
signatureStamp: buildStampDefaults,
|
||||
signatureInk: (options) => buildInkDefaults(options),
|
||||
};
|
||||
|
||||
export const AnnotationAPIBridge = forwardRef<AnnotationAPI>(function AnnotationAPIBridge(_props, ref) {
|
||||
// Use the provided annotation API just like SignatureAPIBridge/HistoryAPIBridge
|
||||
const { provides: annotationApi } = useAnnotationCapability();
|
||||
|
||||
const buildAnnotationDefaults = useCallback(
|
||||
(toolId: AnnotationToolId, options?: AnnotationToolOptions) =>
|
||||
TOOL_DEFAULT_BUILDERS[toolId]?.(options) ?? null,
|
||||
[]
|
||||
);
|
||||
|
||||
const configureAnnotationTool = useCallback(
|
||||
(toolId: AnnotationToolId, options?: AnnotationToolOptions) => {
|
||||
const api = annotationApi as AnnotationApiSurface | undefined;
|
||||
if (!api?.setActiveTool) return;
|
||||
|
||||
const defaults = buildAnnotationDefaults(toolId, options);
|
||||
|
||||
// Reset tool first, then activate (like SignatureAPIBridge does)
|
||||
api.setActiveTool(null);
|
||||
api.setActiveTool(toolId === 'select' ? null : toolId);
|
||||
|
||||
// Verify tool was activated before setting defaults (like SignatureAPIBridge does)
|
||||
const activeTool = api.getActiveTool?.();
|
||||
if (activeTool && activeTool.id === toolId && defaults) {
|
||||
api.setToolDefaults?.(toolId, defaults);
|
||||
}
|
||||
},
|
||||
[annotationApi, buildAnnotationDefaults]
|
||||
);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
activateAnnotationTool: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => {
|
||||
configureAnnotationTool(toolId, options);
|
||||
},
|
||||
setAnnotationStyle: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => {
|
||||
const defaults = buildAnnotationDefaults(toolId, options);
|
||||
const api = annotationApi as AnnotationApiSurface | undefined;
|
||||
if (defaults && api?.setToolDefaults) {
|
||||
api.setToolDefaults(toolId, defaults);
|
||||
}
|
||||
},
|
||||
getSelectedAnnotation: () => {
|
||||
const api = annotationApi as AnnotationApiSurface | undefined;
|
||||
if (!api?.getSelectedAnnotation) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return api.getSelectedAnnotation();
|
||||
} catch (error) {
|
||||
// Some EmbedPDF builds expose getSelectedAnnotation with an internal
|
||||
// `this`/state dependency (e.g. reading `selectedUid` from undefined).
|
||||
// If that happens, fail gracefully and treat it as "no selection"
|
||||
// instead of crashing the entire annotations tool.
|
||||
console.error('[AnnotationAPIBridge] getSelectedAnnotation failed:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
deselectAnnotation: () => {
|
||||
const api = annotationApi as AnnotationApiSurface | undefined;
|
||||
api?.deselectAnnotation?.();
|
||||
},
|
||||
updateAnnotation: (pageIndex: number, annotationId: string, patch: AnnotationPatch) => {
|
||||
const api = annotationApi as AnnotationApiSurface | undefined;
|
||||
api?.updateAnnotation?.(pageIndex, annotationId, patch);
|
||||
},
|
||||
deactivateTools: () => {
|
||||
const api = annotationApi as AnnotationApiSurface | undefined;
|
||||
api?.setActiveTool?.(null);
|
||||
},
|
||||
onAnnotationEvent: (listener: (event: AnnotationEvent) => void) => {
|
||||
const api = annotationApi as AnnotationApiSurface | undefined;
|
||||
if (api?.onAnnotationEvent) {
|
||||
return api.onAnnotationEvent(listener);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
getActiveTool: () => {
|
||||
const api = annotationApi as AnnotationApiSurface | undefined;
|
||||
return api?.getActiveTool?.() ?? null;
|
||||
},
|
||||
}),
|
||||
[annotationApi, configureAnnotationTool, buildAnnotationDefaults]
|
||||
);
|
||||
|
||||
return null;
|
||||
});
|
||||
@@ -56,9 +56,6 @@ const EmbedPdfViewerContent = ({
|
||||
exportActions,
|
||||
} = useViewer();
|
||||
|
||||
// Register viewer right-rail buttons
|
||||
useViewerRightRailButtons();
|
||||
|
||||
const scrollState = getScrollState();
|
||||
const rotationState = getRotationState();
|
||||
|
||||
@@ -70,8 +67,13 @@ const EmbedPdfViewerContent = ({
|
||||
}
|
||||
}, [rotationState.rotation]);
|
||||
|
||||
// Get signature context
|
||||
const { signatureApiRef, historyApiRef, signatureConfig, isPlacementMode } = useSignature();
|
||||
// Get signature and annotation contexts
|
||||
const { signatureApiRef, annotationApiRef, historyApiRef, signatureConfig, isPlacementMode } = useSignature();
|
||||
|
||||
// Track whether there are unsaved annotation changes in this viewer session.
|
||||
// This is our source of truth for navigation guards; it is set when the
|
||||
// annotation history changes, and cleared after we successfully apply changes.
|
||||
const hasAnnotationChangesRef = useRef(false);
|
||||
|
||||
// Get current file from FileContext
|
||||
const { selectors, state } = useFileState();
|
||||
@@ -85,8 +87,8 @@ const EmbedPdfViewerContent = ({
|
||||
|
||||
// Check if we're in an annotation tool
|
||||
const { selectedTool } = useNavigationState();
|
||||
// Tools that require the annotation layer (Sign, Add Text, Add Image)
|
||||
const isInAnnotationTool = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage';
|
||||
// Tools that require the annotation layer (Sign, Add Text, Add Image, Annotate)
|
||||
const isInAnnotationTool = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage' || selectedTool === 'annotate';
|
||||
|
||||
// Sync isAnnotationMode in ViewerContext with current tool
|
||||
useEffect(() => {
|
||||
@@ -225,6 +227,31 @@ const EmbedPdfViewerContent = ({
|
||||
};
|
||||
}, [isViewerHovered, isSearchInterfaceVisible, zoomActions, searchInterfaceActions]);
|
||||
|
||||
// Watch the annotation history API to detect when the document becomes "dirty".
|
||||
// We treat any change that makes the history undoable as unsaved changes until
|
||||
// the user explicitly applies them via applyChanges.
|
||||
useEffect(() => {
|
||||
const historyApi = historyApiRef.current;
|
||||
if (!historyApi || !historyApi.subscribe) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateHasChanges = () => {
|
||||
const canUndo = historyApi.canUndo?.() ?? false;
|
||||
if (!hasAnnotationChangesRef.current && canUndo) {
|
||||
hasAnnotationChangesRef.current = true;
|
||||
setHasUnsavedChanges(true);
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribe = historyApi.subscribe(updateHasChanges);
|
||||
return () => {
|
||||
if (typeof unsubscribe === 'function') {
|
||||
unsubscribe();
|
||||
}
|
||||
};
|
||||
}, [historyApiRef.current, setHasUnsavedChanges]);
|
||||
|
||||
// Register checker for unsaved changes (annotations only for now)
|
||||
useEffect(() => {
|
||||
if (previewFile) {
|
||||
@@ -232,39 +259,28 @@ const EmbedPdfViewerContent = ({
|
||||
}
|
||||
|
||||
const checkForChanges = () => {
|
||||
// Check for annotation changes via history
|
||||
const hasAnnotationChanges = historyApiRef.current?.canUndo() || false;
|
||||
|
||||
console.log('[Viewer] Checking for unsaved changes:', {
|
||||
hasAnnotationChanges
|
||||
});
|
||||
const hasAnnotationChanges = hasAnnotationChangesRef.current;
|
||||
return hasAnnotationChanges;
|
||||
};
|
||||
|
||||
console.log('[Viewer] Registering unsaved changes checker');
|
||||
registerUnsavedChangesChecker(checkForChanges);
|
||||
|
||||
return () => {
|
||||
console.log('[Viewer] Unregistering unsaved changes checker');
|
||||
unregisterUnsavedChangesChecker();
|
||||
};
|
||||
}, [historyApiRef, previewFile, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker]);
|
||||
}, [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';
|
||||
@@ -279,12 +295,29 @@ const EmbedPdfViewerContent = ({
|
||||
// Step 4: Consume files (replace in context)
|
||||
await actions.consumeFiles(activeFileIds, stirlingFiles, stubs);
|
||||
|
||||
// Mark annotations as saved so navigation away from the viewer is allowed.
|
||||
hasAnnotationChangesRef.current = false;
|
||||
setHasUnsavedChanges(false);
|
||||
} catch (error) {
|
||||
console.error('Apply changes failed:', error);
|
||||
}
|
||||
}, [currentFile, activeFileIds, exportActions, actions, selectors, setHasUnsavedChanges]);
|
||||
|
||||
// Expose annotation apply via a global event so tools (like Annotate) can
|
||||
// trigger saves from the left sidebar without tight coupling.
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
void applyChanges();
|
||||
};
|
||||
window.addEventListener('stirling-annotations-apply', handler);
|
||||
return () => {
|
||||
window.removeEventListener('stirling-annotations-apply', handler);
|
||||
};
|
||||
}, [applyChanges]);
|
||||
|
||||
// Register viewer right-rail buttons
|
||||
useViewerRightRailButtons();
|
||||
|
||||
const sidebarWidthRem = 15;
|
||||
const totalRightMargin =
|
||||
(isThumbnailSidebarVisible ? sidebarWidthRem : 0) + (isBookmarkSidebarVisible ? sidebarWidthRem : 0);
|
||||
@@ -340,6 +373,7 @@ const EmbedPdfViewerContent = ({
|
||||
enableAnnotations={isAnnotationMode}
|
||||
showBakedAnnotations={isAnnotationsVisible}
|
||||
signatureApiRef={signatureApiRef as React.RefObject<any>}
|
||||
annotationApiRef={annotationApiRef as React.RefObject<any>}
|
||||
historyApiRef={historyApiRef as React.RefObject<any>}
|
||||
onSignatureAdded={() => {
|
||||
// Handle signature added - for debugging, enable console logs as needed
|
||||
|
||||
@@ -38,8 +38,9 @@ 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 { AnnotationAPIBridge } from '@app/components/viewer/AnnotationAPIBridge';
|
||||
import { HistoryAPIBridge } from '@app/components/viewer/HistoryAPIBridge';
|
||||
import type { SignatureAPI, HistoryAPI } from '@app/components/viewer/viewerTypes';
|
||||
import type { SignatureAPI, AnnotationAPI, HistoryAPI } from '@app/components/viewer/viewerTypes';
|
||||
import { ExportAPIBridge } from '@app/components/viewer/ExportAPIBridge';
|
||||
import { BookmarkAPIBridge } from '@app/components/viewer/BookmarkAPIBridge';
|
||||
import { PrintAPIBridge } from '@app/components/viewer/PrintAPIBridge';
|
||||
@@ -55,10 +56,11 @@ interface LocalEmbedPDFProps {
|
||||
showBakedAnnotations?: boolean;
|
||||
onSignatureAdded?: (annotation: any) => void;
|
||||
signatureApiRef?: React.RefObject<SignatureAPI>;
|
||||
annotationApiRef?: React.RefObject<AnnotationAPI>;
|
||||
historyApiRef?: React.RefObject<HistoryAPI>;
|
||||
}
|
||||
|
||||
export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedAnnotations = true, onSignatureAdded, signatureApiRef, historyApiRef }: LocalEmbedPDFProps) {
|
||||
export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedAnnotations = true, onSignatureAdded, signatureApiRef, annotationApiRef, historyApiRef }: LocalEmbedPDFProps) {
|
||||
const { t } = useTranslation();
|
||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||
const [, setAnnotations] = useState<Array<{id: string, pageIndex: number, rect: any}>>([]);
|
||||
@@ -123,10 +125,8 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
|
||||
selectAfterCreate: true,
|
||||
}),
|
||||
|
||||
// Register pan plugin (depends on Viewport, InteractionManager)
|
||||
createPluginRegistration(PanPluginPackage, {
|
||||
defaultMode: 'mobile', // Try mobile mode which might be more permissive
|
||||
}),
|
||||
// Register pan plugin (depends on Viewport, InteractionManager) - keep disabled to prevent drag panning
|
||||
createPluginRegistration(PanPluginPackage, {}),
|
||||
|
||||
// Register zoom plugin with configuration
|
||||
createPluginRegistration(ZoomPluginPackage, {
|
||||
@@ -252,7 +252,315 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
|
||||
if (!annotationApi) return;
|
||||
|
||||
if (enableAnnotations) {
|
||||
annotationApi.addTool({
|
||||
const ensureTool = (tool: any) => {
|
||||
const existing = annotationApi.getTool?.(tool.id);
|
||||
if (!existing) {
|
||||
annotationApi.addTool(tool);
|
||||
}
|
||||
};
|
||||
|
||||
ensureTool({
|
||||
id: 'highlight',
|
||||
name: 'Highlight',
|
||||
interaction: { exclusive: true, cursor: 'text', textSelection: true },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.HIGHLIGHT ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.HIGHLIGHT,
|
||||
color: '#ffd54f',
|
||||
opacity: 0.6,
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: false,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'underline',
|
||||
name: 'Underline',
|
||||
interaction: { exclusive: true, cursor: 'text', textSelection: true },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.UNDERLINE ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.UNDERLINE,
|
||||
color: '#ffb300',
|
||||
opacity: 1,
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: false,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'strikeout',
|
||||
name: 'Strikeout',
|
||||
interaction: { exclusive: true, cursor: 'text', textSelection: true },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.STRIKEOUT ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.STRIKEOUT,
|
||||
color: '#e53935',
|
||||
opacity: 1,
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: false,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'squiggly',
|
||||
name: 'Squiggly',
|
||||
interaction: { exclusive: true, cursor: 'text', textSelection: true },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.SQUIGGLY ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.SQUIGGLY,
|
||||
color: '#00acc1',
|
||||
opacity: 1,
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: false,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'ink',
|
||||
name: 'Pen',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.INK ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.INK,
|
||||
color: '#1f2933',
|
||||
opacity: 1,
|
||||
borderWidth: 2,
|
||||
lineWidth: 2,
|
||||
strokeWidth: 2,
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: false,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'inkHighlighter',
|
||||
name: 'Ink Highlighter',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.INK && annotation.color === '#ffd54f' ? 8 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.INK,
|
||||
color: '#ffd54f',
|
||||
opacity: 0.5,
|
||||
borderWidth: 6,
|
||||
lineWidth: 6,
|
||||
strokeWidth: 6,
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: false,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'square',
|
||||
name: 'Square',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.SQUARE ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.SQUARE,
|
||||
color: '#0000ff', // fill color (blue)
|
||||
strokeColor: '#cf5b5b', // border color (reddish pink)
|
||||
opacity: 0.5,
|
||||
borderWidth: 1,
|
||||
strokeWidth: 1,
|
||||
lineWidth: 1,
|
||||
},
|
||||
clickBehavior: {
|
||||
enabled: true,
|
||||
defaultSize: { width: 120, height: 90 },
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: true,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'circle',
|
||||
name: 'Circle',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.CIRCLE ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.CIRCLE,
|
||||
color: '#0000ff', // fill color (blue)
|
||||
strokeColor: '#cf5b5b', // border color (reddish pink)
|
||||
opacity: 0.5,
|
||||
borderWidth: 1,
|
||||
strokeWidth: 1,
|
||||
lineWidth: 1,
|
||||
},
|
||||
clickBehavior: {
|
||||
enabled: true,
|
||||
defaultSize: { width: 100, height: 100 },
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: true,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'line',
|
||||
name: 'Line',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.LINE ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.LINE,
|
||||
color: '#1565c0',
|
||||
opacity: 1,
|
||||
borderWidth: 2,
|
||||
strokeWidth: 2,
|
||||
lineWidth: 2,
|
||||
},
|
||||
clickBehavior: {
|
||||
enabled: true,
|
||||
defaultLength: 120,
|
||||
defaultAngle: 0,
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: true,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'lineArrow',
|
||||
name: 'Arrow',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.LINE && (annotation.endStyle === 'ClosedArrow' || annotation.lineEndingStyles?.end === 'ClosedArrow') ? 9 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.LINE,
|
||||
color: '#1565c0',
|
||||
opacity: 1,
|
||||
borderWidth: 2,
|
||||
startStyle: 'None',
|
||||
endStyle: 'ClosedArrow',
|
||||
lineEndingStyles: { start: 'None', end: 'ClosedArrow' },
|
||||
},
|
||||
clickBehavior: {
|
||||
enabled: true,
|
||||
defaultLength: 120,
|
||||
defaultAngle: 0,
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: true,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'polyline',
|
||||
name: 'Polyline',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.POLYLINE ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.POLYLINE,
|
||||
color: '#1565c0',
|
||||
opacity: 1,
|
||||
borderWidth: 2,
|
||||
},
|
||||
clickBehavior: {
|
||||
enabled: true,
|
||||
finishOnDoubleClick: true,
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: true,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'polygon',
|
||||
name: 'Polygon',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.POLYGON ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.POLYGON,
|
||||
color: '#0000ff', // fill color (blue)
|
||||
strokeColor: '#cf5b5b', // border color (reddish pink)
|
||||
opacity: 0.5,
|
||||
borderWidth: 1,
|
||||
},
|
||||
clickBehavior: {
|
||||
enabled: true,
|
||||
finishOnDoubleClick: true,
|
||||
defaultSize: { width: 140, height: 100 },
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: true,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'text',
|
||||
name: 'Text',
|
||||
interaction: { exclusive: true, cursor: 'text' },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.FREETEXT ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.FREETEXT,
|
||||
textColor: '#111111',
|
||||
fontSize: 14,
|
||||
fontFamily: 'Helvetica',
|
||||
opacity: 1,
|
||||
interiorColor: '#fffef7',
|
||||
contents: 'Text',
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: false,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'note',
|
||||
name: 'Note',
|
||||
interaction: { exclusive: true, cursor: 'pointer' },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.FREETEXT ? 8 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.FREETEXT,
|
||||
textColor: '#1b1b1b',
|
||||
color: '#ffa000',
|
||||
interiorColor: '#fff8e1',
|
||||
opacity: 1,
|
||||
contents: 'Note',
|
||||
fontSize: 12,
|
||||
},
|
||||
clickBehavior: {
|
||||
enabled: true,
|
||||
defaultSize: { width: 160, height: 100 },
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: false,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'stamp',
|
||||
name: 'Image Stamp',
|
||||
interaction: { exclusive: false, cursor: 'copy' },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.STAMP ? 5 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.STAMP,
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: true,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'signatureStamp',
|
||||
name: 'Digital Signature',
|
||||
interaction: { exclusive: false, cursor: 'copy' },
|
||||
@@ -262,7 +570,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
|
||||
},
|
||||
});
|
||||
|
||||
annotationApi.addTool({
|
||||
ensureTool({
|
||||
id: 'signatureInk',
|
||||
name: 'Signature Draw',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
@@ -310,6 +618,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
|
||||
<ThumbnailAPIBridge />
|
||||
<RotateAPIBridge />
|
||||
{enableAnnotations && <SignatureAPIBridge ref={signatureApiRef} />}
|
||||
{enableAnnotations && <AnnotationAPIBridge ref={annotationApiRef} />}
|
||||
{enableAnnotations && <HistoryAPIBridge ref={historyApiRef} />}
|
||||
<ExportAPIBridge />
|
||||
<BookmarkAPIBridge />
|
||||
|
||||
@@ -104,12 +104,20 @@ const createTextStampImage = (
|
||||
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.font = `${fontSize}px ${fontFamily}`;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textAlign = config.textAlign || 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const horizontalPadding = paddingX;
|
||||
const verticalCenter = naturalHeight / 2;
|
||||
ctx.fillText(text, horizontalPadding, verticalCenter);
|
||||
|
||||
let xPosition = horizontalPadding;
|
||||
if (config.textAlign === 'center') {
|
||||
xPosition = naturalWidth / 2;
|
||||
} else if (config.textAlign === 'right') {
|
||||
xPosition = naturalWidth - horizontalPadding;
|
||||
}
|
||||
|
||||
ctx.fillText(text, xPosition, verticalCenter);
|
||||
|
||||
return {
|
||||
dataUrl: canvas.toDataURL('image/png'),
|
||||
@@ -199,12 +207,21 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
|
||||
}
|
||||
}, [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults, cssToPdfSize]);
|
||||
|
||||
|
||||
// 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) => {
|
||||
// Skip delete/backspace while a text input/textarea is focused (e.g., editing textbox)
|
||||
const target = event.target as HTMLElement | null;
|
||||
const tag = target?.tagName?.toLowerCase();
|
||||
const editable = target?.getAttribute?.('contenteditable');
|
||||
if (tag === 'input' || tag === 'textarea' || editable === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||
const selectedAnnotation = annotationApi.getSelectedAnnotation?.();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMemo, useState, useEffect, useCallback } from 'react';
|
||||
import { ActionIcon, Popover } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
@@ -9,6 +9,9 @@ import { SearchInterface } from '@app/components/viewer/SearchInterface';
|
||||
import ViewerAnnotationControls from '@app/components/shared/rightRail/ViewerAnnotationControls';
|
||||
import { useSidebarContext } from '@app/contexts/SidebarContext';
|
||||
import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { useNavigationState } from '@app/contexts/NavigationContext';
|
||||
import { BASE_PATH, withBasePath } from '@app/constants/app';
|
||||
|
||||
export function useViewerRightRailButtons() {
|
||||
const { t, i18n } = useTranslation();
|
||||
@@ -16,6 +19,32 @@ export function useViewerRightRailButtons() {
|
||||
const [isPanning, setIsPanning] = useState<boolean>(() => viewer.getPanState()?.isPanning ?? false);
|
||||
const { sidebarRefs } = useSidebarContext();
|
||||
const { position: tooltipPosition } = useRightRailTooltipSide(sidebarRefs, 12);
|
||||
const { handleToolSelect } = useToolWorkflow();
|
||||
const { selectedTool } = useNavigationState();
|
||||
|
||||
const stripBasePath = useCallback((path: string) => {
|
||||
if (BASE_PATH && path.startsWith(BASE_PATH)) {
|
||||
return path.slice(BASE_PATH.length) || '/';
|
||||
}
|
||||
return path;
|
||||
}, []);
|
||||
|
||||
const isAnnotationsPath = useCallback(() => {
|
||||
const cleanPath = stripBasePath(window.location.pathname).toLowerCase();
|
||||
return cleanPath === '/annotations' || cleanPath.endsWith('/annotations');
|
||||
}, [stripBasePath]);
|
||||
|
||||
const [isAnnotationsActive, setIsAnnotationsActive] = useState<boolean>(() => isAnnotationsPath());
|
||||
|
||||
useEffect(() => {
|
||||
setIsAnnotationsActive(isAnnotationsPath());
|
||||
}, [selectedTool, isAnnotationsPath]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePopState = () => setIsAnnotationsActive(isAnnotationsPath());
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, [isAnnotationsPath]);
|
||||
|
||||
// Lift i18n labels out of memo for clarity
|
||||
const searchLabel = t('rightRail.search', 'Search PDF');
|
||||
@@ -25,9 +54,11 @@ export function useViewerRightRailButtons() {
|
||||
const sidebarLabel = t('rightRail.toggleSidebar', 'Toggle Sidebar');
|
||||
const bookmarkLabel = t('rightRail.toggleBookmarks', 'Toggle Bookmarks');
|
||||
const printLabel = t('rightRail.print', 'Print PDF');
|
||||
const annotationsLabel = t('rightRail.annotations', 'Annotations');
|
||||
const saveChangesLabel = t('rightRail.saveChanges', 'Save Changes');
|
||||
|
||||
const viewerButtons = useMemo<RightRailButtonWithAction[]>(() => {
|
||||
return [
|
||||
const buttons: RightRailButtonWithAction[] = [
|
||||
{
|
||||
id: 'viewer-search',
|
||||
tooltip: searchLabel,
|
||||
@@ -147,6 +178,36 @@ export function useViewerRightRailButtons() {
|
||||
viewer.printActions.print();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'viewer-annotations',
|
||||
tooltip: annotationsLabel,
|
||||
ariaLabel: annotationsLabel,
|
||||
section: 'top' as const,
|
||||
order: 58,
|
||||
render: ({ disabled }) => (
|
||||
<Tooltip content={annotationsLabel} position={tooltipPosition} offset={12} arrow portalTarget={document.body}>
|
||||
<ActionIcon
|
||||
variant={isAnnotationsActive ? 'default' : 'subtle'}
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
if (disabled || isAnnotationsActive) return;
|
||||
const targetPath = withBasePath('/annotations');
|
||||
if (window.location.pathname !== targetPath) {
|
||||
window.history.pushState(null, '', targetPath);
|
||||
}
|
||||
setIsAnnotationsActive(true);
|
||||
handleToolSelect('annotate');
|
||||
}}
|
||||
disabled={disabled || isAnnotationsActive}
|
||||
aria-pressed={isAnnotationsActive}
|
||||
style={isAnnotationsActive ? { backgroundColor: 'var(--right-rail-pan-active-bg)' } : undefined}
|
||||
>
|
||||
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'viewer-annotation-controls',
|
||||
section: 'top' as const,
|
||||
@@ -154,9 +215,30 @@ export function useViewerRightRailButtons() {
|
||||
render: ({ disabled }) => (
|
||||
<ViewerAnnotationControls currentView="viewer" disabled={disabled} />
|
||||
)
|
||||
}
|
||||
},
|
||||
];
|
||||
}, [t, i18n.language, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel, bookmarkLabel, printLabel, tooltipPosition]);
|
||||
|
||||
// Optional: Save button for annotations (always registered when this hook is used
|
||||
// with a save handler; uses a ref to avoid infinite re-registration loops).
|
||||
return buttons;
|
||||
}, [
|
||||
t,
|
||||
i18n.language,
|
||||
viewer,
|
||||
isPanning,
|
||||
searchLabel,
|
||||
panLabel,
|
||||
rotateLeftLabel,
|
||||
rotateRightLabel,
|
||||
sidebarLabel,
|
||||
bookmarkLabel,
|
||||
printLabel,
|
||||
tooltipPosition,
|
||||
annotationsLabel,
|
||||
saveChangesLabel,
|
||||
isAnnotationsActive,
|
||||
handleToolSelect,
|
||||
]);
|
||||
|
||||
useRightRailButtons(viewerButtons);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,17 @@ export interface SignatureAPI {
|
||||
getPageAnnotations: (pageIndex: number) => Promise<any[]>;
|
||||
}
|
||||
|
||||
export interface AnnotationAPI {
|
||||
activateAnnotationTool: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => void;
|
||||
setAnnotationStyle: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => void;
|
||||
getSelectedAnnotation: () => AnnotationSelection | null;
|
||||
deselectAnnotation: () => void;
|
||||
updateAnnotation: (pageIndex: number, annotationId: string, patch: AnnotationPatch) => void;
|
||||
deactivateTools: () => void;
|
||||
onAnnotationEvent?: (listener: (event: AnnotationEvent) => void) => void | (() => void);
|
||||
getActiveTool?: () => { id: AnnotationToolId } | null;
|
||||
}
|
||||
|
||||
export interface HistoryAPI {
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
@@ -23,3 +34,50 @@ export interface HistoryAPI {
|
||||
canRedo: () => boolean;
|
||||
subscribe?: (listener: () => void) => () => void;
|
||||
}
|
||||
|
||||
export type AnnotationToolId =
|
||||
| 'select'
|
||||
| 'highlight'
|
||||
| 'underline'
|
||||
| 'strikeout'
|
||||
| 'squiggly'
|
||||
| 'ink'
|
||||
| 'inkHighlighter'
|
||||
| 'text'
|
||||
| 'note'
|
||||
| 'square'
|
||||
| 'circle'
|
||||
| 'line'
|
||||
| 'lineArrow'
|
||||
| 'polyline'
|
||||
| 'polygon'
|
||||
| 'stamp'
|
||||
| 'signatureStamp'
|
||||
| 'signatureInk';
|
||||
|
||||
export interface AnnotationEvent {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type AnnotationPatch = Record<string, unknown>;
|
||||
export type AnnotationSelection = unknown;
|
||||
|
||||
export interface AnnotationToolOptions {
|
||||
color?: string;
|
||||
fillColor?: string;
|
||||
strokeColor?: string;
|
||||
opacity?: number;
|
||||
strokeOpacity?: number;
|
||||
fillOpacity?: number;
|
||||
thickness?: number;
|
||||
borderWidth?: number;
|
||||
fontSize?: number;
|
||||
fontFamily?: string;
|
||||
textAlign?: number; // 0 = Left, 1 = Center, 2 = Right
|
||||
imageSrc?: string;
|
||||
imageSize?: { width: number; height: number };
|
||||
icon?: 'Comment' | 'Key' | 'Note' | 'Help' | 'NewParagraph' | 'Paragraph' | 'Insert';
|
||||
contents?: string;
|
||||
customData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user