mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 11:00:47 +02:00
* 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 ✅
423 lines
16 KiB
TypeScript
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;
|
|
});
|