mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-12-18 20:04:17 +01:00
Improvements
This commit is contained in:
parent
72ddd997a2
commit
39b55f7f19
@ -1,213 +1,340 @@
|
||||
import { useImperativeHandle, forwardRef, useCallback } from 'react';
|
||||
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
|
||||
import { PdfAnnotationSubtype, PdfAnnotationIcon } from '@embedpdf/models';
|
||||
import type { AnnotationToolId, AnnotationToolOptions, AnnotationAPI } from '@app/components/viewer/viewerTypes';
|
||||
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 getIconEnum = (icon?: string): PdfAnnotationIcon => {
|
||||
switch (icon) {
|
||||
case 'Comment': return PdfAnnotationIcon.Comment;
|
||||
case 'Key': return PdfAnnotationIcon.Key;
|
||||
case 'Note': return PdfAnnotationIcon.Note;
|
||||
case 'Help': return PdfAnnotationIcon.Help;
|
||||
case 'NewParagraph': return PdfAnnotationIcon.NewParagraph;
|
||||
case 'Paragraph': return PdfAnnotationIcon.Paragraph;
|
||||
case 'Insert': return PdfAnnotationIcon.Insert;
|
||||
default: return PdfAnnotationIcon.Comment;
|
||||
}
|
||||
};
|
||||
|
||||
const buildAnnotationDefaults = useCallback(
|
||||
(toolId: AnnotationToolId, options?: AnnotationToolOptions) => {
|
||||
switch (toolId) {
|
||||
case 'highlight':
|
||||
return {
|
||||
type: PdfAnnotationSubtype.HIGHLIGHT,
|
||||
color: options?.color ?? '#ffd54f',
|
||||
opacity: options?.opacity ?? 0.6,
|
||||
};
|
||||
case 'underline':
|
||||
return {
|
||||
type: PdfAnnotationSubtype.UNDERLINE,
|
||||
color: options?.color ?? '#ffb300',
|
||||
opacity: options?.opacity ?? 1,
|
||||
};
|
||||
case 'strikeout':
|
||||
return {
|
||||
type: PdfAnnotationSubtype.STRIKEOUT,
|
||||
color: options?.color ?? '#e53935',
|
||||
opacity: options?.opacity ?? 1,
|
||||
};
|
||||
case 'squiggly':
|
||||
return {
|
||||
type: PdfAnnotationSubtype.SQUIGGLY,
|
||||
color: options?.color ?? '#00acc1',
|
||||
opacity: options?.opacity ?? 1,
|
||||
};
|
||||
case 'ink':
|
||||
return {
|
||||
type: PdfAnnotationSubtype.INK,
|
||||
color: options?.color ?? '#1f2933',
|
||||
borderWidth: options?.thickness ?? 2,
|
||||
strokeWidth: options?.thickness ?? 2,
|
||||
lineWidth: options?.thickness ?? 2,
|
||||
};
|
||||
case 'inkHighlighter':
|
||||
return {
|
||||
type: PdfAnnotationSubtype.INK,
|
||||
color: options?.color ?? '#ffd54f',
|
||||
opacity: options?.opacity ?? 0.6,
|
||||
borderWidth: options?.thickness ?? 6,
|
||||
strokeWidth: options?.thickness ?? 6,
|
||||
lineWidth: options?.thickness ?? 6,
|
||||
};
|
||||
case 'text':
|
||||
return {
|
||||
type: PdfAnnotationSubtype.FREETEXT,
|
||||
fontColor: options?.color ?? '#111111',
|
||||
fontSize: options?.fontSize ?? 14,
|
||||
fontFamily: options?.fontFamily ?? 'Helvetica',
|
||||
textAlign: options?.textAlign ?? 0, // 0 = Left, 1 = Center, 2 = Right
|
||||
opacity: options?.opacity ?? 1,
|
||||
backgroundColor: options?.fillColor ?? '#fffef7',
|
||||
borderWidth: options?.thickness ?? 1,
|
||||
};
|
||||
case 'note':
|
||||
return {
|
||||
type: PdfAnnotationSubtype.TEXT,
|
||||
color: options?.color ?? '#ffa000',
|
||||
backgroundColor: '#ffff00',
|
||||
opacity: options?.opacity ?? 1,
|
||||
icon: getIconEnum(options?.icon),
|
||||
contents: options?.contents ?? '',
|
||||
};
|
||||
case 'square':
|
||||
return {
|
||||
type: PdfAnnotationSubtype.SQUARE,
|
||||
color: options?.color ?? '#0000ff',
|
||||
strokeColor: options?.strokeColor ?? '#cf5b5b',
|
||||
opacity: options?.opacity ?? 0.5,
|
||||
fillOpacity: options?.fillOpacity ?? 0.5,
|
||||
strokeOpacity: options?.strokeOpacity ?? 0.5,
|
||||
borderWidth: options?.borderWidth ?? 1,
|
||||
strokeWidth: options?.borderWidth ?? 1,
|
||||
lineWidth: options?.borderWidth ?? 1,
|
||||
};
|
||||
case 'circle':
|
||||
return {
|
||||
type: PdfAnnotationSubtype.CIRCLE,
|
||||
color: options?.color ?? '#0000ff',
|
||||
strokeColor: options?.strokeColor ?? '#cf5b5b',
|
||||
opacity: options?.opacity ?? 0.5,
|
||||
fillOpacity: options?.fillOpacity ?? 0.5,
|
||||
strokeOpacity: options?.strokeOpacity ?? 0.5,
|
||||
borderWidth: options?.borderWidth ?? 1,
|
||||
strokeWidth: options?.borderWidth ?? 1,
|
||||
lineWidth: options?.borderWidth ?? 1,
|
||||
};
|
||||
case 'line':
|
||||
return {
|
||||
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,
|
||||
};
|
||||
case 'lineArrow':
|
||||
return {
|
||||
type: PdfAnnotationSubtype.LINE,
|
||||
color: options?.color ?? '#1565c0',
|
||||
strokeColor: options?.color ?? '#1565c0',
|
||||
opacity: options?.opacity ?? 1,
|
||||
borderWidth: options?.borderWidth ?? 2,
|
||||
startStyle: 'None',
|
||||
endStyle: 'ClosedArrow',
|
||||
lineEndingStyles: { start: 'None', end: 'ClosedArrow' },
|
||||
};
|
||||
case 'polyline':
|
||||
return {
|
||||
type: PdfAnnotationSubtype.POLYLINE,
|
||||
color: options?.color ?? '#1565c0',
|
||||
opacity: options?.opacity ?? 1,
|
||||
borderWidth: options?.borderWidth ?? 2,
|
||||
};
|
||||
case 'polygon':
|
||||
return {
|
||||
type: PdfAnnotationSubtype.POLYGON,
|
||||
color: options?.color ?? '#0000ff',
|
||||
strokeColor: options?.strokeColor ?? '#cf5b5b',
|
||||
opacity: options?.opacity ?? 0.5,
|
||||
fillOpacity: options?.fillOpacity ?? 0.5,
|
||||
strokeOpacity: options?.strokeOpacity ?? 0.5,
|
||||
borderWidth: options?.borderWidth ?? 1,
|
||||
strokeWidth: options?.borderWidth ?? 1,
|
||||
lineWidth: options?.borderWidth ?? 1,
|
||||
};
|
||||
case 'stamp':
|
||||
return {
|
||||
type: PdfAnnotationSubtype.STAMP,
|
||||
};
|
||||
case 'select':
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
(toolId: AnnotationToolId, options?: AnnotationToolOptions) =>
|
||||
TOOL_DEFAULT_BUILDERS[toolId]?.(options) ?? null,
|
||||
[]
|
||||
);
|
||||
|
||||
const configureAnnotationTool = useCallback(
|
||||
(toolId: AnnotationToolId, options?: AnnotationToolOptions) => {
|
||||
if (!annotationApi) return;
|
||||
const api = annotationApi as AnnotationApiSurface | undefined;
|
||||
if (!api?.setActiveTool) return;
|
||||
|
||||
const defaults = buildAnnotationDefaults(toolId, options);
|
||||
|
||||
// Reset tool first, then activate (like SignatureAPIBridge does)
|
||||
annotationApi.setActiveTool(null);
|
||||
annotationApi.setActiveTool(toolId === 'select' ? null : toolId);
|
||||
api.setActiveTool(null);
|
||||
api.setActiveTool(toolId === 'select' ? null : toolId);
|
||||
|
||||
// Verify tool was activated before setting defaults (like SignatureAPIBridge does)
|
||||
const activeTool = annotationApi.getActiveTool();
|
||||
const activeTool = api.getActiveTool?.();
|
||||
if (activeTool && activeTool.id === toolId && defaults) {
|
||||
annotationApi.setToolDefaults(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 any;
|
||||
if (defaults && api?.setToolDefaults) {
|
||||
api.setToolDefaults(toolId, defaults);
|
||||
}
|
||||
},
|
||||
getSelectedAnnotation: () => {
|
||||
const api = annotationApi as any;
|
||||
return api?.getSelectedAnnotation?.() ?? null;
|
||||
},
|
||||
deselectAnnotation: () => {
|
||||
const api = annotationApi as any;
|
||||
api?.deselectAnnotation?.();
|
||||
},
|
||||
updateAnnotation: (pageIndex: number, annotationId: string, patch: Partial<any>) => {
|
||||
const api = annotationApi as any;
|
||||
api?.updateAnnotation?.(pageIndex, annotationId, patch);
|
||||
},
|
||||
deactivateTools: () => {
|
||||
if (!annotationApi) return;
|
||||
const api = annotationApi as any;
|
||||
api?.setActiveTool?.(null);
|
||||
},
|
||||
}), [annotationApi, configureAnnotationTool, 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;
|
||||
return api?.getSelectedAnnotation?.() ?? 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;
|
||||
});
|
||||
|
||||
@ -19,10 +19,12 @@ export interface SignatureAPI {
|
||||
export interface AnnotationAPI {
|
||||
activateAnnotationTool: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => void;
|
||||
setAnnotationStyle: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => void;
|
||||
getSelectedAnnotation: () => any | null;
|
||||
getSelectedAnnotation: () => AnnotationSelection | null;
|
||||
deselectAnnotation: () => void;
|
||||
updateAnnotation: (pageIndex: number, annotationId: string, patch: Partial<any>) => 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 {
|
||||
@ -53,6 +55,14 @@ export type AnnotationToolId =
|
||||
| '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;
|
||||
@ -64,6 +74,8 @@ export interface AnnotationToolOptions {
|
||||
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>;
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState, useContext, useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Text, Group, ActionIcon, Stack, Divider, Slider, Box, Tooltip as MantineTooltip, Button, TextInput, Textarea, NumberInput, Tooltip, Paper } from '@mantine/core';
|
||||
import { alert as showToast, updateToast } from '@app/components/toast';
|
||||
|
||||
import { createToolFlow } from '@app/components/tools/shared/createToolFlow';
|
||||
import { useNavigation } from '@app/contexts/NavigationContext';
|
||||
@ -14,9 +15,33 @@ import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import type { AnnotationToolId } from '@app/components/viewer/viewerTypes';
|
||||
import { SuggestedToolsSection } from '@app/components/tools/shared/SuggestedToolsSection';
|
||||
|
||||
const KNOWN_ANNOTATION_TOOLS: AnnotationToolId[] = [
|
||||
'select',
|
||||
'highlight',
|
||||
'underline',
|
||||
'strikeout',
|
||||
'squiggly',
|
||||
'ink',
|
||||
'inkHighlighter',
|
||||
'text',
|
||||
'note',
|
||||
'square',
|
||||
'circle',
|
||||
'line',
|
||||
'lineArrow',
|
||||
'polyline',
|
||||
'polygon',
|
||||
'stamp',
|
||||
'signatureStamp',
|
||||
'signatureInk',
|
||||
];
|
||||
|
||||
const isKnownAnnotationTool = (toolId: string | undefined | null): toolId is AnnotationToolId =>
|
||||
!!toolId && (KNOWN_ANNOTATION_TOOLS as string[]).includes(toolId);
|
||||
|
||||
const Annotate = (_props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { setToolAndWorkbench } = useNavigation();
|
||||
const { setToolAndWorkbench, selectedTool, workbench } = useNavigation();
|
||||
const { selectedFiles } = useFileSelection();
|
||||
const { selectors } = useFileContext();
|
||||
const {
|
||||
@ -29,6 +54,7 @@ const Annotate = (_props: BaseToolProps) => {
|
||||
setPlacementMode,
|
||||
placementPreviewSize,
|
||||
activateSignaturePlacementMode,
|
||||
setPlacementPreviewSize,
|
||||
} = useSignature();
|
||||
const viewerContext = useContext(ViewerContext);
|
||||
const { getZoomState, registerImmediateZoomUpdate } = useViewer();
|
||||
@ -58,23 +84,43 @@ const Annotate = (_props: BaseToolProps) => {
|
||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||
const [selectedAnn, setSelectedAnn] = useState<any | null>(null);
|
||||
const [selectedAnnId, setSelectedAnnId] = useState<string | null>(null);
|
||||
const selectedAnnIdRef = useRef<string | null>(null);
|
||||
const activeToolRef = useRef<AnnotationToolId>('highlight');
|
||||
const wasAnnotateActiveRef = useRef<boolean>(false);
|
||||
const [selectedTextDraft, setSelectedTextDraft] = useState<string>('');
|
||||
const [selectedFontSize, setSelectedFontSize] = useState<number>(14);
|
||||
const selectedUpdateTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [stampImageData, setStampImageData] = useState<string | undefined>();
|
||||
const [stampImageSize, setStampImageSize] = useState<{ width: number; height: number } | null>(null);
|
||||
const [isAnnotationPaused, setIsAnnotationPaused] = useState(false);
|
||||
const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false });
|
||||
const [isSavingCopy, setIsSavingCopy] = useState(false);
|
||||
const manualToolSwitch = useRef<boolean>(false);
|
||||
|
||||
// Zoom tracking for stamp size conversion
|
||||
const [currentZoom, setCurrentZoom] = useState(() => getZoomState()?.currentZoom ?? 1);
|
||||
const [currentZoom, setCurrentZoom] = useState(() => {
|
||||
const zoomState = getZoomState();
|
||||
if (!zoomState) return 1;
|
||||
if (typeof zoomState.zoomPercent === 'number') {
|
||||
return Math.max(zoomState.zoomPercent / 100, 0.01);
|
||||
}
|
||||
return Math.max(zoomState.currentZoom ?? 1, 0.01);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
return registerImmediateZoomUpdate((newZoom) => {
|
||||
setCurrentZoom(newZoom);
|
||||
return registerImmediateZoomUpdate((newZoomPercent) => {
|
||||
setCurrentZoom(Math.max(newZoomPercent / 100, 0.01));
|
||||
});
|
||||
}, [registerImmediateZoomUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
activeToolRef.current = activeTool;
|
||||
}, [activeTool]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedAnnIdRef.current = selectedAnnId;
|
||||
}, [selectedAnnId]);
|
||||
|
||||
// CSS to PDF size conversion accounting for zoom
|
||||
const cssToPdfSize = useCallback(
|
||||
(size: { width: number; height: number }) => {
|
||||
@ -88,9 +134,27 @@ const Annotate = (_props: BaseToolProps) => {
|
||||
[currentZoom]
|
||||
);
|
||||
|
||||
const computeStampDisplaySize = useCallback((natural: { width: number; height: number } | null) => {
|
||||
if (!natural) {
|
||||
return { width: 180, height: 120 };
|
||||
}
|
||||
const maxSide = 260;
|
||||
const minSide = 24;
|
||||
const { width, height } = natural;
|
||||
const largest = Math.max(width || maxSide, height || maxSide, 1);
|
||||
const scale = Math.min(1, maxSide / largest);
|
||||
return {
|
||||
width: Math.max(minSide, Math.round(width * scale)),
|
||||
height: Math.max(minSide, Math.round(height * scale)),
|
||||
};
|
||||
}, []);
|
||||
|
||||
const buildToolOptions = useCallback((toolId: AnnotationToolId, includeMetadata: boolean = true) => {
|
||||
const metadata = includeMetadata ? {
|
||||
customData: {
|
||||
toolId,
|
||||
annotationToolId: toolId,
|
||||
source: 'annotate',
|
||||
author: 'User', // Could be replaced with actual user name from auth context
|
||||
createdAt: new Date().toISOString(),
|
||||
modifiedAt: new Date().toISOString(),
|
||||
@ -113,6 +177,13 @@ const Annotate = (_props: BaseToolProps) => {
|
||||
case 'text':
|
||||
const textAlignNumber = textAlignment === 'left' ? 0 : textAlignment === 'center' ? 1 : 2;
|
||||
return { color: textColor, fontSize: textSize, textAlign: textAlignNumber, ...metadata };
|
||||
case 'note':
|
||||
return {
|
||||
color: textColor, // text color
|
||||
fillColor: highlightColor, // background color, shares highlight picker defaults
|
||||
opacity: 1,
|
||||
...metadata,
|
||||
};
|
||||
case 'square':
|
||||
case 'circle':
|
||||
case 'polygon':
|
||||
@ -135,30 +206,54 @@ const Annotate = (_props: BaseToolProps) => {
|
||||
borderWidth: shapeThickness,
|
||||
...metadata,
|
||||
};
|
||||
case 'stamp': {
|
||||
const pdfSize = stampImageSize ? cssToPdfSize(stampImageSize) : undefined;
|
||||
return {
|
||||
imageSrc: stampImageData,
|
||||
...(pdfSize ? { imageSize: pdfSize } : {}),
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}, [highlightColor, highlightOpacity, inkColor, inkWidth, freehandHighlighterWidth, underlineColor, underlineOpacity, strikeoutColor, strikeoutOpacity, squigglyColor, squigglyOpacity, textColor, textSize, textAlignment, shapeStrokeColor, shapeFillColor, shapeOpacity, shapeStrokeOpacity, shapeFillOpacity, shapeThickness]);
|
||||
}, [highlightColor, highlightOpacity, inkColor, inkWidth, freehandHighlighterWidth, underlineColor, underlineOpacity, strikeoutColor, strikeoutOpacity, squigglyColor, squigglyOpacity, textColor, textSize, textAlignment, shapeStrokeColor, shapeFillColor, shapeOpacity, shapeStrokeOpacity, shapeFillOpacity, shapeThickness, stampImageData, stampImageSize, cssToPdfSize]);
|
||||
|
||||
useEffect(() => {
|
||||
setToolAndWorkbench('annotate', 'viewer');
|
||||
}, [setToolAndWorkbench]);
|
||||
|
||||
useEffect(() => {
|
||||
const isAnnotateActive = workbench === 'viewer' && selectedTool === 'annotate';
|
||||
if (wasAnnotateActiveRef.current && !isAnnotateActive) {
|
||||
annotationApiRef?.current?.deactivateTools?.();
|
||||
signatureApiRef?.current?.deactivateTools?.();
|
||||
setPlacementMode(false);
|
||||
setIsAnnotationPaused(true);
|
||||
}
|
||||
wasAnnotateActiveRef.current = isAnnotateActive;
|
||||
}, [workbench, selectedTool, annotationApiRef, signatureApiRef, setPlacementMode]);
|
||||
|
||||
// Monitor history state for undo/redo availability
|
||||
useEffect(() => {
|
||||
const historyApi = historyApiRef?.current;
|
||||
if (!historyApi) return;
|
||||
|
||||
const checkHistory = () => {
|
||||
const updateAvailability = () => {
|
||||
setHistoryAvailability({
|
||||
canUndo: historyApi.canUndo?.() ?? false,
|
||||
canRedo: historyApi.canRedo?.() ?? false,
|
||||
});
|
||||
};
|
||||
|
||||
checkHistory();
|
||||
const interval = setInterval(checkHistory, 200);
|
||||
return () => clearInterval(interval);
|
||||
updateAvailability();
|
||||
|
||||
if (historyApi.subscribe) {
|
||||
const unsubscribe = historyApi.subscribe(updateAvailability);
|
||||
if (typeof unsubscribe === 'function') {
|
||||
return () => unsubscribe();
|
||||
}
|
||||
}
|
||||
}, [historyApiRef]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -169,6 +264,64 @@ const Annotate = (_props: BaseToolProps) => {
|
||||
annotationApiRef?.current?.activateAnnotationTool?.(activeTool, buildToolOptions(activeTool));
|
||||
}, [viewerContext?.isAnnotationMode, signatureApiRef, activeTool, buildToolOptions]);
|
||||
|
||||
const handleSaveCopy = useCallback(async () => {
|
||||
if (!viewerContext?.exportActions?.saveAsCopy) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingCopy(true);
|
||||
const toastId = showToast({
|
||||
alertType: 'neutral',
|
||||
title: t('annotation.savingCopy', 'Preparing download...'),
|
||||
progressBarPercentage: 15,
|
||||
isPersistentPopup: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const buffer = await viewerContext.exportActions.saveAsCopy();
|
||||
if (!buffer) {
|
||||
updateToast(toastId, {
|
||||
alertType: 'error',
|
||||
title: t('annotation.saveFailed', 'Unable to save copy'),
|
||||
durationMs: 4000,
|
||||
isPersistentPopup: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = new Blob([buffer], { type: 'application/pdf' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const baseName = selectedFiles[0]?.name;
|
||||
const filename = baseName
|
||||
? `${baseName.replace(/\.pdf$/i, '')}-annotated.pdf`
|
||||
: 'annotated.pdf';
|
||||
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 4000);
|
||||
|
||||
updateToast(toastId, {
|
||||
alertType: 'success',
|
||||
title: t('annotation.saveReady', 'Download ready'),
|
||||
progressBarPercentage: 100,
|
||||
durationMs: 2500,
|
||||
isPersistentPopup: false,
|
||||
});
|
||||
} catch (error: any) {
|
||||
updateToast(toastId, {
|
||||
alertType: 'error',
|
||||
title: t('annotation.saveFailed', 'Unable to save copy'),
|
||||
body: error?.message,
|
||||
durationMs: 4500,
|
||||
isPersistentPopup: false,
|
||||
});
|
||||
} finally {
|
||||
setIsSavingCopy(false);
|
||||
}
|
||||
}, [viewerContext?.exportActions, selectedFiles, t]);
|
||||
|
||||
const activateAnnotationTool = (toolId: AnnotationToolId) => {
|
||||
// If leaving stamp tool, clean up placement mode
|
||||
if (activeTool === 'stamp' && toolId !== 'stamp') {
|
||||
@ -194,8 +347,12 @@ const Annotate = (_props: BaseToolProps) => {
|
||||
|
||||
// For stamp, apply the image if we have one
|
||||
if (toolId === 'stamp' && stampImageData) {
|
||||
annotationApiRef?.current?.setAnnotationStyle?.('stamp', { imageSrc: stampImageData });
|
||||
annotationApiRef?.current?.activateAnnotationTool?.('stamp', { imageSrc: stampImageData });
|
||||
const stampOptions = {
|
||||
...options,
|
||||
imageSrc: stampImageData,
|
||||
};
|
||||
annotationApiRef?.current?.setAnnotationStyle?.('stamp', stampOptions);
|
||||
annotationApiRef?.current?.activateAnnotationTool?.('stamp', stampOptions);
|
||||
} else {
|
||||
annotationApiRef?.current?.activateAnnotationTool?.(toolId, options);
|
||||
}
|
||||
@ -209,7 +366,11 @@ const Annotate = (_props: BaseToolProps) => {
|
||||
useEffect(() => {
|
||||
// push style updates to EmbedPDF when sliders/colors change
|
||||
if (activeTool === 'stamp' && stampImageData) {
|
||||
annotationApiRef?.current?.setAnnotationStyle?.('stamp', { imageSrc: stampImageData });
|
||||
const options = buildToolOptions('stamp');
|
||||
annotationApiRef?.current?.setAnnotationStyle?.('stamp', {
|
||||
...options,
|
||||
imageSrc: stampImageData,
|
||||
});
|
||||
} else {
|
||||
annotationApiRef?.current?.setAnnotationStyle?.(activeTool, buildToolOptions(activeTool));
|
||||
}
|
||||
@ -220,13 +381,14 @@ const Annotate = (_props: BaseToolProps) => {
|
||||
// When preview size changes, update stamp annotation sizing
|
||||
// The SignatureAPIBridge will use placementPreviewSize from SignatureContext
|
||||
// and apply the converted size to the stamp tool automatically
|
||||
if (activeTool === 'stamp' && placementPreviewSize && stampImageData) {
|
||||
// Just update the image source; size is handled by SignatureAPIBridge
|
||||
if (activeTool === 'stamp' && stampImageData) {
|
||||
const size = placementPreviewSize ?? stampImageSize;
|
||||
annotationApiRef?.current?.setAnnotationStyle?.('stamp', {
|
||||
imageSrc: stampImageData,
|
||||
...(size ? { imageSize: cssToPdfSize(size) } : {}),
|
||||
});
|
||||
}
|
||||
}, [placementPreviewSize, activeTool, stampImageData, signatureApiRef]);
|
||||
}, [placementPreviewSize, activeTool, stampImageData, signatureApiRef, stampImageSize, cssToPdfSize]);
|
||||
|
||||
// Allow exiting multi-point tools with Escape (e.g., polyline)
|
||||
useEffect(() => {
|
||||
@ -244,64 +406,114 @@ const Annotate = (_props: BaseToolProps) => {
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [activeTool, buildToolOptions, signatureApiRef]);
|
||||
|
||||
// Poll selected annotation to allow editing existing highlights/text
|
||||
const deriveToolFromAnnotation = useCallback((annotation: any): AnnotationToolId | undefined => {
|
||||
if (!annotation) return undefined;
|
||||
const customToolId = annotation.customData?.toolId || annotation.customData?.annotationToolId;
|
||||
if (isKnownAnnotationTool(customToolId)) {
|
||||
return customToolId;
|
||||
}
|
||||
|
||||
const type = annotation.type ?? annotation.object?.type;
|
||||
switch (type) {
|
||||
case 3: return 'text'; // FREETEXT
|
||||
case 4: return 'line'; // LINE
|
||||
case 5: return 'square'; // SQUARE
|
||||
case 6: return 'circle'; // CIRCLE
|
||||
case 7: return 'polygon'; // POLYGON
|
||||
case 8: return 'polyline'; // POLYLINE
|
||||
case 9: return 'highlight'; // HIGHLIGHT
|
||||
case 10: return 'underline'; // UNDERLINE
|
||||
case 11: return 'squiggly'; // SQUIGGLY
|
||||
case 12: return 'strikeout'; // STRIKEOUT
|
||||
case 13: return 'stamp'; // STAMP
|
||||
case 15: return 'ink'; // INK
|
||||
default: return undefined;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const applySelectionFromAnnotation = useCallback((ann: any | null) => {
|
||||
const annObject = ann?.object ?? ann ?? null;
|
||||
const annId = annObject?.id ?? null;
|
||||
selectedAnnIdRef.current = annId;
|
||||
setSelectedAnnId(annId);
|
||||
setSelectedAnn(ann || null);
|
||||
|
||||
if (annObject?.contents !== undefined) {
|
||||
setSelectedTextDraft(annObject.contents ?? '');
|
||||
}
|
||||
if (annObject?.fontSize !== undefined) {
|
||||
setSelectedFontSize(annObject.fontSize ?? 14);
|
||||
}
|
||||
// Sync width properties based on annotation type
|
||||
const type = annObject?.type;
|
||||
if (type === 15 && annObject?.strokeWidth !== undefined) {
|
||||
// Type 15 = INK, uses strokeWidth
|
||||
setInkWidth(annObject.strokeWidth ?? 2);
|
||||
} else if (type >= 4 && type <= 8 && annObject?.strokeWidth !== undefined) {
|
||||
// Types 4-8 = Shapes (line, square, circle, polygon, polyline), use strokeWidth
|
||||
setShapeThickness(annObject.strokeWidth ?? 1);
|
||||
}
|
||||
|
||||
const matchingTool = deriveToolFromAnnotation(annObject);
|
||||
if (matchingTool && matchingTool !== activeToolRef.current && !manualToolSwitch.current) {
|
||||
setActiveTool(matchingTool);
|
||||
}
|
||||
}, [deriveToolFromAnnotation]);
|
||||
|
||||
// Track selection changes via events (fall back to light polling if events unavailable)
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const ann = annotationApiRef?.current?.getSelectedAnnotation?.();
|
||||
const annId = ann?.object?.id ?? null;
|
||||
// Only update state when selection actually changes
|
||||
if (annId !== selectedAnnId) {
|
||||
setSelectedAnn(ann || null);
|
||||
setSelectedAnnId(annId);
|
||||
if (ann?.object?.contents !== undefined) {
|
||||
setSelectedTextDraft(ann.object.contents ?? '');
|
||||
}
|
||||
if (ann?.object?.fontSize !== undefined) {
|
||||
setSelectedFontSize(ann.object.fontSize ?? 14);
|
||||
}
|
||||
const api = annotationApiRef?.current as any;
|
||||
if (!api) return;
|
||||
|
||||
// Switch active tool to match annotation type (unless user manually switched tools)
|
||||
if (ann?.object?.type !== undefined && !manualToolSwitch.current) {
|
||||
let matchingTool: AnnotationToolId | undefined;
|
||||
|
||||
// Special handling for INK type (15) - distinguish between pen and freehand highlighter
|
||||
if (ann.object.type === 15) {
|
||||
// Freehand highlighter typically has:
|
||||
// - Higher opacity (> 0.8) OR
|
||||
// - Larger width (> 4)
|
||||
const opacity = ann.object.opacity ?? 1;
|
||||
const width = ann.object.borderWidth ?? ann.object.strokeWidth ?? ann.object.lineWidth ?? 2;
|
||||
|
||||
if (opacity < 0.8 || width >= 5) {
|
||||
matchingTool = 'inkHighlighter';
|
||||
} else {
|
||||
matchingTool = 'ink';
|
||||
if (typeof api.onAnnotationEvent === 'function') {
|
||||
const handler = (event: any) => {
|
||||
const ann = event?.annotation ?? event?.selectedAnnotation ?? null;
|
||||
switch (event?.type) {
|
||||
case 'select':
|
||||
case 'selected':
|
||||
applySelectionFromAnnotation(ann ?? api.getSelectedAnnotation?.());
|
||||
break;
|
||||
case 'deselect':
|
||||
case 'clearSelection':
|
||||
applySelectionFromAnnotation(null);
|
||||
break;
|
||||
case 'delete':
|
||||
case 'remove':
|
||||
if (ann?.id && ann.id === selectedAnnIdRef.current) {
|
||||
applySelectionFromAnnotation(null);
|
||||
}
|
||||
} else {
|
||||
const typeToToolMap: Record<number, AnnotationToolId> = {
|
||||
3: 'text', // FREETEXT
|
||||
4: 'line', // LINE
|
||||
5: 'square', // SQUARE
|
||||
6: 'circle', // CIRCLE
|
||||
7: 'polygon', // POLYGON
|
||||
8: 'polyline', // POLYLINE
|
||||
9: 'highlight', // HIGHLIGHT
|
||||
10: 'underline', // UNDERLINE
|
||||
11: 'squiggly', // SQUIGGLY
|
||||
12: 'strikeout', // STRIKEOUT
|
||||
13: 'stamp', // STAMP
|
||||
};
|
||||
matchingTool = typeToToolMap[ann.object.type];
|
||||
}
|
||||
|
||||
if (matchingTool && matchingTool !== activeTool) {
|
||||
setActiveTool(matchingTool);
|
||||
}
|
||||
break;
|
||||
case 'update':
|
||||
case 'change':
|
||||
if (selectedAnnIdRef.current) {
|
||||
const current = api.getSelectedAnnotation?.();
|
||||
if (current) {
|
||||
applySelectionFromAnnotation(current);
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribe = api.onAnnotationEvent(handler);
|
||||
return () => {
|
||||
if (typeof unsubscribe === 'function') {
|
||||
unsubscribe();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: slower polling to avoid heavy CPU churn
|
||||
const interval = setInterval(() => {
|
||||
const ann = api.getSelectedAnnotation?.();
|
||||
if ((ann?.object?.id ?? null) !== selectedAnnIdRef.current) {
|
||||
applySelectionFromAnnotation(ann ?? null);
|
||||
}
|
||||
}, 150);
|
||||
}, 350);
|
||||
return () => clearInterval(interval);
|
||||
}, [signatureApiRef, selectedAnnId, activeTool]);
|
||||
}, [annotationApiRef, applySelectionFromAnnotation]);
|
||||
|
||||
const textMarkupTools: { id: AnnotationToolId; label: string; icon: string }[] = [
|
||||
{ id: 'highlight', label: t('annotation.highlight', 'Highlight'), icon: 'highlight' },
|
||||
@ -382,7 +594,18 @@ const Annotate = (_props: BaseToolProps) => {
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
const naturalSize = await new Promise<{ width: number; height: number } | null>((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve({ width: img.naturalWidth || img.width, height: img.naturalHeight || img.height });
|
||||
img.onerror = () => resolve(null);
|
||||
img.src = dataUrl;
|
||||
});
|
||||
|
||||
const displaySize = computeStampDisplaySize(naturalSize);
|
||||
setStampImageData(dataUrl);
|
||||
setStampImageSize(displaySize);
|
||||
setPlacementPreviewSize(displaySize);
|
||||
|
||||
// Configure SignatureContext for placement preview
|
||||
setSignatureConfig({
|
||||
@ -396,16 +619,23 @@ const Annotate = (_props: BaseToolProps) => {
|
||||
setTimeout(() => {
|
||||
viewerContext?.setAnnotationMode(true);
|
||||
setPlacementMode(true); // This shows the preview overlay
|
||||
annotationApiRef?.current?.setAnnotationStyle?.('stamp', { imageSrc: dataUrl });
|
||||
annotationApiRef?.current?.activateAnnotationTool?.('stamp', { imageSrc: dataUrl });
|
||||
const stampOptions = {
|
||||
...buildToolOptions('stamp'),
|
||||
imageSrc: dataUrl,
|
||||
...(displaySize ? { imageSize: cssToPdfSize(displaySize) } : {}),
|
||||
};
|
||||
annotationApiRef?.current?.setAnnotationStyle?.('stamp', stampOptions);
|
||||
annotationApiRef?.current?.activateAnnotationTool?.('stamp', stampOptions);
|
||||
}, 150);
|
||||
} catch (err) {
|
||||
console.error('Failed to load stamp image', err);
|
||||
}
|
||||
} else {
|
||||
setStampImageData(undefined);
|
||||
setStampImageSize(null);
|
||||
setPlacementMode(false);
|
||||
setSignatureConfig(null);
|
||||
setPlacementPreviewSize(null);
|
||||
}
|
||||
}}
|
||||
disabled={false}
|
||||
@ -425,156 +655,158 @@ const Annotate = (_props: BaseToolProps) => {
|
||||
<>
|
||||
<Text size="sm" fw={600}>{t('annotation.settings', 'Settings')}</Text>
|
||||
<Group gap="md">
|
||||
<Stack gap={4} align="center">
|
||||
<Text size="xs" c="dimmed">
|
||||
{['square', 'circle', 'polygon'].includes(activeTool)
|
||||
? t('annotation.strokeColor', 'Stroke Color')
|
||||
: t('annotation.color', 'Color')
|
||||
}
|
||||
</Text>
|
||||
<ColorSwatchButton
|
||||
color={
|
||||
activeTool === 'ink'
|
||||
? inkColor
|
||||
: activeTool === 'highlight' || activeTool === 'inkHighlighter'
|
||||
? highlightColor
|
||||
: activeTool === 'underline'
|
||||
? underlineColor
|
||||
: activeTool === 'strikeout'
|
||||
? strikeoutColor
|
||||
: activeTool === 'squiggly'
|
||||
? squigglyColor
|
||||
: ['square', 'circle', 'line', 'polygon'].includes(activeTool)
|
||||
? shapeStrokeColor
|
||||
: textColor
|
||||
}
|
||||
size={30}
|
||||
onClick={() => {
|
||||
const target =
|
||||
activeTool === 'ink'
|
||||
? 'ink'
|
||||
: activeTool === 'highlight' || activeTool === 'inkHighlighter'
|
||||
? 'highlight'
|
||||
: activeTool === 'underline'
|
||||
? 'underline'
|
||||
: activeTool === 'strikeout'
|
||||
? 'strikeout'
|
||||
: activeTool === 'squiggly'
|
||||
? 'squiggly'
|
||||
: ['square', 'circle', 'line', 'polygon'].includes(activeTool)
|
||||
? 'shapeStroke'
|
||||
: 'text';
|
||||
setColorPickerTarget(target);
|
||||
setIsColorPickerOpen(true);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
{['square', 'circle', 'polygon'].includes(activeTool) && (
|
||||
<Stack gap={4} align="center">
|
||||
<Text size="xs" c="dimmed">{t('annotation.fillColor', 'Fill Color')}</Text>
|
||||
<ColorSwatchButton
|
||||
color={shapeFillColor}
|
||||
size={30}
|
||||
onClick={() => {
|
||||
setColorPickerTarget('shapeFill');
|
||||
setIsColorPickerOpen(true);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{activeTool === 'ink' && (
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" mb={4}>{t('annotation.strokeWidth', 'Width')}</Text>
|
||||
<Slider min={1} max={12} value={inkWidth} onChange={setInkWidth} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{(activeTool === 'highlight' || activeTool === 'inkHighlighter') && (
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" mb={4}>{t('annotation.opacity', 'Opacity')}</Text>
|
||||
<Slider min={10} max={100} value={highlightOpacity} onChange={setHighlightOpacity} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{activeTool === 'inkHighlighter' && (
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" mb={4}>{t('annotation.strokeWidth', 'Width')}</Text>
|
||||
<Slider min={1} max={20} value={freehandHighlighterWidth} onChange={setFreehandHighlighterWidth} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{activeTool === 'text' && (
|
||||
<>
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" mb={4}>{t('annotation.fontSize', 'Font size')}</Text>
|
||||
<Slider min={8} max={32} value={textSize} onChange={setTextSize} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" mb={4}>{t('annotation.textAlignment', 'Text Alignment')}</Text>
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
variant={textAlignment === 'left' ? 'filled' : 'default'}
|
||||
onClick={() => setTextAlignment('left')}
|
||||
size="md"
|
||||
>
|
||||
<LocalIcon icon="format-align-left" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant={textAlignment === 'center' ? 'filled' : 'default'}
|
||||
onClick={() => setTextAlignment('center')}
|
||||
size="md"
|
||||
>
|
||||
<LocalIcon icon="format-align-center" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant={textAlignment === 'right' ? 'filled' : 'default'}
|
||||
onClick={() => setTextAlignment('right')}
|
||||
size="md"
|
||||
>
|
||||
<LocalIcon icon="format-align-right" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{['square', 'circle', 'line', 'polygon'].includes(activeTool) && (
|
||||
<>
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" mb={4}>{t('annotation.opacity', 'Opacity')}</Text>
|
||||
<Slider min={10} max={100} value={shapeOpacity} onChange={(value) => {
|
||||
setShapeOpacity(value);
|
||||
setShapeStrokeOpacity(value);
|
||||
setShapeFillOpacity(value);
|
||||
}} />
|
||||
</Box>
|
||||
<Box>
|
||||
{activeTool === 'line' ? (
|
||||
<>
|
||||
<Text size="xs" c="dimmed" mb={4}>{t('annotation.strokeWidth', 'Width')}</Text>
|
||||
<Slider min={1} max={12} value={shapeThickness} onChange={setShapeThickness} />
|
||||
</>
|
||||
) : (
|
||||
<Group gap="xs" align="flex-end">
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Text size="xs" c="dimmed" mb={4}>{t('annotation.strokeWidth', 'Stroke')}</Text>
|
||||
<Slider min={0} max={12} value={shapeThickness} onChange={setShapeThickness} />
|
||||
</Box>
|
||||
<Button
|
||||
size="xs"
|
||||
variant={shapeThickness === 0 ? 'filled' : 'light'}
|
||||
onClick={() => setShapeThickness(shapeThickness === 0 ? 1 : 0)}
|
||||
>
|
||||
{shapeThickness === 0
|
||||
? t('annotation.borderOff', 'Border: Off')
|
||||
: t('annotation.borderOn', 'Border: On')
|
||||
}
|
||||
</Button>
|
||||
</Group>
|
||||
<Stack gap={4} align="center">
|
||||
<Text size="xs" c="dimmed">
|
||||
{['square', 'circle', 'polygon'].includes(activeTool)
|
||||
? t('annotation.strokeColor', 'Stroke Color')
|
||||
: t('annotation.color', 'Color')
|
||||
}
|
||||
</Text>
|
||||
<ColorSwatchButton
|
||||
color={
|
||||
activeTool === 'ink'
|
||||
? inkColor
|
||||
: activeTool === 'highlight' || activeTool === 'inkHighlighter'
|
||||
? highlightColor
|
||||
: activeTool === 'underline'
|
||||
? underlineColor
|
||||
: activeTool === 'strikeout'
|
||||
? strikeoutColor
|
||||
: activeTool === 'squiggly'
|
||||
? squigglyColor
|
||||
: ['square', 'circle', 'line', 'polygon'].includes(activeTool)
|
||||
? shapeStrokeColor
|
||||
: textColor
|
||||
}
|
||||
size={30}
|
||||
onClick={() => {
|
||||
const target =
|
||||
activeTool === 'ink'
|
||||
? 'ink'
|
||||
: activeTool === 'highlight' || activeTool === 'inkHighlighter'
|
||||
? 'highlight'
|
||||
: activeTool === 'underline'
|
||||
? 'underline'
|
||||
: activeTool === 'strikeout'
|
||||
? 'strikeout'
|
||||
: activeTool === 'squiggly'
|
||||
? 'squiggly'
|
||||
: ['square', 'circle', 'line', 'polygon'].includes(activeTool)
|
||||
? 'shapeStroke'
|
||||
: 'text';
|
||||
setColorPickerTarget(target);
|
||||
setIsColorPickerOpen(true);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
{['square', 'circle', 'polygon'].includes(activeTool) && (
|
||||
<Stack gap={4} align="center">
|
||||
<Text size="xs" c="dimmed">{t('annotation.fillColor', 'Fill Color')}</Text>
|
||||
<ColorSwatchButton
|
||||
color={shapeFillColor}
|
||||
size={30}
|
||||
onClick={() => {
|
||||
setColorPickerTarget('shapeFill');
|
||||
setIsColorPickerOpen(true);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
{activeTool === 'ink' && (
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" mb={4}>{t('annotation.strokeWidth', 'Width')}</Text>
|
||||
<Slider min={1} max={12} value={inkWidth} onChange={setInkWidth} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{(activeTool === 'highlight' || activeTool === 'inkHighlighter') && (
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" mb={4}>{t('annotation.opacity', 'Opacity')}</Text>
|
||||
<Slider min={10} max={100} value={highlightOpacity} onChange={setHighlightOpacity} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{activeTool === 'inkHighlighter' && (
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" mb={4}>{t('annotation.strokeWidth', 'Width')}</Text>
|
||||
<Slider min={1} max={20} value={freehandHighlighterWidth} onChange={setFreehandHighlighterWidth} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{activeTool === 'text' && (
|
||||
<>
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" mb={4}>{t('annotation.fontSize', 'Font size')}</Text>
|
||||
<Slider min={8} max={32} value={textSize} onChange={setTextSize} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" mb={4}>{t('annotation.textAlignment', 'Text Alignment')}</Text>
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
variant={textAlignment === 'left' ? 'filled' : 'default'}
|
||||
onClick={() => setTextAlignment('left')}
|
||||
size="md"
|
||||
>
|
||||
<LocalIcon icon="format-align-left" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant={textAlignment === 'center' ? 'filled' : 'default'}
|
||||
onClick={() => setTextAlignment('center')}
|
||||
size="md"
|
||||
>
|
||||
<LocalIcon icon="format-align-center" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant={textAlignment === 'right' ? 'filled' : 'default'}
|
||||
onClick={() => setTextAlignment('right')}
|
||||
size="md"
|
||||
>
|
||||
<LocalIcon icon="format-align-right" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{['square', 'circle', 'line', 'polygon'].includes(activeTool) && (
|
||||
<>
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" mb={4}>{t('annotation.opacity', 'Opacity')}</Text>
|
||||
<Slider min={10} max={100} value={shapeOpacity} onChange={(value) => {
|
||||
setShapeOpacity(value);
|
||||
setShapeStrokeOpacity(value);
|
||||
setShapeFillOpacity(value);
|
||||
}} />
|
||||
</Box>
|
||||
<Box>
|
||||
{activeTool === 'line' ? (
|
||||
<>
|
||||
<Text size="xs" c="dimmed" mb={4}>{t('annotation.strokeWidth', 'Width')}</Text>
|
||||
<Slider min={1} max={12} value={shapeThickness} onChange={setShapeThickness} />
|
||||
</>
|
||||
) : (
|
||||
<Group gap="xs" align="flex-end">
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Text size="xs" c="dimmed" mb={4}>{t('annotation.strokeWidth', 'Stroke')}</Text>
|
||||
<Slider min={0} max={12} value={shapeThickness} onChange={setShapeThickness} />
|
||||
</Box>
|
||||
<Button
|
||||
size="xs"
|
||||
variant={shapeThickness === 0 ? 'filled' : 'light'}
|
||||
onClick={() => setShapeThickness(shapeThickness === 0 ? 1 : 0)}
|
||||
>
|
||||
{shapeThickness === 0
|
||||
? t('annotation.borderOff', 'Border: Off')
|
||||
: t('annotation.borderOn', 'Border: On')
|
||||
}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
@ -643,15 +875,13 @@ const Annotate = (_props: BaseToolProps) => {
|
||||
<Slider
|
||||
min={1}
|
||||
max={12}
|
||||
value={selectedAnn.object?.borderWidth ?? inkWidth}
|
||||
value={selectedAnn.object?.strokeWidth ?? inkWidth}
|
||||
onChange={(value) => {
|
||||
annotationApiRef?.current?.updateAnnotation?.(
|
||||
selectedAnn.object?.pageIndex ?? 0,
|
||||
selectedAnn.object?.id,
|
||||
{
|
||||
borderWidth: value,
|
||||
strokeWidth: value,
|
||||
lineWidth: value,
|
||||
}
|
||||
);
|
||||
setInkWidth(value);
|
||||
@ -1258,6 +1488,12 @@ const Annotate = (_props: BaseToolProps) => {
|
||||
colorPickerTarget,
|
||||
activeColor,
|
||||
buildToolOptions,
|
||||
undo,
|
||||
redo,
|
||||
historyAvailability,
|
||||
handleSaveCopy,
|
||||
isSavingCopy,
|
||||
isAnnotationPaused,
|
||||
]);
|
||||
|
||||
return createToolFlow({
|
||||
|
||||
Loading…
Reference in New Issue
Block a user