Files
Stirling-PDF/frontend/src/core/components/viewer/AnnotationAPIBridge.tsx
T
EthanHealy01andGitHub c9d693f1eb Improve annotations (#5919)
* Text box/notes movement improvements 
* Fix the issue where hiding, then showing annotations looses progress 
* Fix the issue where hidig/showing annotations jumps you back up to the
top of your open document 
* Support ctrl+c and  ctrl+v and backspace to delete 
* Better handling when moving to different tool from annotate 
* Added a color picker eyedropper button  
* Auto-switch to Select after note/text placement, so users can quickly
place and type 
2026-03-13 14:03:27 +00:00

423 lines
16 KiB
TypeScript

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,
AnnotationRect,
AnnotationSelection,
} from '@app/components/viewer/viewerTypes';
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
type NoteIcon = NonNullable<AnnotationToolOptions['icon']>;
type AnnotationDefaults =
| {
type:
| PdfAnnotationSubtype.HIGHLIGHT
| PdfAnnotationSubtype.UNDERLINE
| PdfAnnotationSubtype.STRIKEOUT
| PdfAnnotationSubtype.SQUIGGLY;
strokeColor: string;
color?: string;
opacity: number;
customData?: Record<string, unknown>;
}
| {
type: PdfAnnotationSubtype.INK;
strokeColor: string;
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;
color?: string;
interiorColor?: 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?: () => AnnotationSelection | null;
deselectAnnotation?: () => void;
updateAnnotation?: (pageIndex: number, annotationId: string, patch: AnnotationPatch) => void;
deleteAnnotation?: (pageIndex: number, annotationId: string) => void;
deleteAnnotations?: (annotations: Array<{ pageIndex: number; id: string }>) => void;
createAnnotation?: (pageIndex: number, annotation: Record<string, unknown>) => void;
getSelectedAnnotations?: () => AnnotationSelection[];
onAnnotationEvent?: (listener: (event: AnnotationEvent) => void) => void | (() => void);
purgeAnnotation?: (pageIndex: number, annotationId: string) => void;
/** v2.7.0: move annotation without regenerating its appearance stream */
moveAnnotation?: (pageIndex: number, annotationId: string, newRect: AnnotationRect) => 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',
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 => {
const colorValue = options?.color ?? (opacityOverride ? DEFAULTS.inkHighlighter : DEFAULTS.ink);
return {
type: PdfAnnotationSubtype.INK,
strokeColor: colorValue,
color: colorValue,
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) => {
const colorValue = options?.color ?? DEFAULTS.highlight;
return {
type: PdfAnnotationSubtype.HIGHLIGHT,
strokeColor: colorValue,
color: colorValue,
opacity: options?.opacity ?? 0.6,
...withCustomData(options),
};
},
underline: (options) => {
const colorValue = options?.color ?? DEFAULTS.underline;
return {
type: PdfAnnotationSubtype.UNDERLINE,
strokeColor: colorValue,
color: colorValue,
opacity: options?.opacity ?? 1,
...withCustomData(options),
};
},
strikeout: (options) => {
const colorValue = options?.color ?? DEFAULTS.strikeout;
return {
type: PdfAnnotationSubtype.STRIKEOUT,
strokeColor: colorValue,
color: colorValue,
opacity: options?.opacity ?? 1,
...withCustomData(options),
};
},
squiggly: (options) => {
const colorValue = options?.color ?? DEFAULTS.squiggly;
return {
type: PdfAnnotationSubtype.SQUIGGLY,
strokeColor: colorValue,
color: colorValue,
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 ? { color: options.fillColor, interiorColor: options.fillColor } : {}),
...withCustomData(options),
}),
note: (options) => {
const bgColor = options?.fillColor ?? DEFAULTS.note;
const fontColor = options?.color ?? DEFAULTS.text;
return {
type: PdfAnnotationSubtype.FREETEXT,
fontColor,
fontFamily: options?.fontFamily ?? 'Helvetica',
textAlign: options?.textAlign ?? 0,
fontSize: options?.fontSize ?? 12,
opacity: options?.opacity ?? 1,
color: bgColor,
interiorColor: bgColor,
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 documentReady = useDocumentReady();
const buildAnnotationDefaults = useCallback(
(toolId: AnnotationToolId, options?: AnnotationToolOptions) =>
TOOL_DEFAULT_BUILDERS[toolId]?.(options) ?? null,
[]
);
const configureAnnotationTool = useCallback(
(toolId: AnnotationToolId, options?: AnnotationToolOptions) => {
const api = annotationApi as unknown 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);
},
isReady: () => !!annotationApi && documentReady,
setAnnotationStyle: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => {
const defaults = buildAnnotationDefaults(toolId, options);
const api = annotationApi as unknown as AnnotationApiSurface | undefined;
if (defaults && api?.setToolDefaults) {
api.setToolDefaults(toolId, defaults);
}
},
getSelectedAnnotation: () => {
const api = annotationApi as unknown 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.
// Only log unexpected errors - "No active document" is a common expected state during init
const errorMessage = error instanceof Error ? error.message : String(error);
if (!errorMessage.includes('No active document')) {
console.error('[AnnotationAPIBridge] getSelectedAnnotation failed:', error);
}
return null;
}
},
deselectAnnotation: () => {
const api = annotationApi as unknown as AnnotationApiSurface | undefined;
api?.deselectAnnotation?.();
},
updateAnnotation: (pageIndex: number, annotationId: string, patch: AnnotationPatch) => {
const api = annotationApi as unknown as AnnotationApiSurface | undefined;
api?.updateAnnotation?.(pageIndex, annotationId, patch);
},
deactivateTools: () => {
const api = annotationApi as unknown as AnnotationApiSurface | undefined;
api?.setActiveTool?.(null);
},
onAnnotationEvent: (listener: (event: AnnotationEvent) => void) => {
const api = annotationApi as unknown as AnnotationApiSurface | undefined;
if (api?.onAnnotationEvent) {
return api.onAnnotationEvent(listener);
}
return undefined;
},
getActiveTool: () => {
const api = annotationApi as unknown as AnnotationApiSurface | undefined;
return api?.getActiveTool?.() ?? null;
},
deleteAnnotation: (pageIndex: number, annotationId: string) => {
const api = annotationApi as unknown as AnnotationApiSurface | undefined;
api?.deleteAnnotation?.(pageIndex, annotationId);
},
deleteAnnotations: (annotations: Array<{ pageIndex: number; id: string }>) => {
const api = annotationApi as unknown as AnnotationApiSurface | undefined;
api?.deleteAnnotations?.(annotations);
},
createAnnotation: (pageIndex: number, annotation: Record<string, unknown>) => {
const api = annotationApi as unknown as AnnotationApiSurface | undefined;
api?.createAnnotation?.(pageIndex, annotation);
},
getSelectedAnnotations: () => {
const api = annotationApi as unknown as AnnotationApiSurface | undefined;
return api?.getSelectedAnnotations?.() ?? [];
},
purgeAnnotation: (pageIndex: number, annotationId: string) => {
const api = annotationApi as unknown as AnnotationApiSurface | undefined;
api?.purgeAnnotation?.(pageIndex, annotationId);
},
moveAnnotation: (pageIndex: number, annotationId: string, newRect: AnnotationRect) => {
const api = annotationApi as unknown as AnnotationApiSurface | undefined;
api?.moveAnnotation?.(pageIndex, annotationId, newRect);
},
}),
[annotationApi, configureAnnotationTool, buildAnnotationDefaults]
);
return null;
});