Improvements

This commit is contained in:
Reece 2025-12-17 00:05:53 +00:00
parent 72ddd997a2
commit 39b55f7f19
3 changed files with 782 additions and 407 deletions

View File

@ -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;
});

View File

@ -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>;
}

View File

@ -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({