From 39b55f7f19e4637f2e580e2f4fe7291df47c8ecc Mon Sep 17 00:00:00 2001 From: Reece Date: Wed, 17 Dec 2025 00:05:53 +0000 Subject: [PATCH] Improvements --- .../components/viewer/AnnotationAPIBridge.tsx | 495 ++++++++----- .../src/core/components/viewer/viewerTypes.ts | 16 +- frontend/src/core/tools/Annotate.tsx | 678 ++++++++++++------ 3 files changed, 782 insertions(+), 407 deletions(-) diff --git a/frontend/src/core/components/viewer/AnnotationAPIBridge.tsx b/frontend/src/core/components/viewer/AnnotationAPIBridge.tsx index a1c2f7881..f138dba71 100644 --- a/frontend/src/core/components/viewer/AnnotationAPIBridge.tsx +++ b/frontend/src/core/components/viewer/AnnotationAPIBridge.tsx @@ -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; +type AnnotationDefaults = + | { + type: + | PdfAnnotationSubtype.HIGHLIGHT + | PdfAnnotationSubtype.UNDERLINE + | PdfAnnotationSubtype.STRIKEOUT + | PdfAnnotationSubtype.SQUIGGLY; + color: string; + opacity: number; + customData?: Record; + } + | { + type: PdfAnnotationSubtype.INK; + color: string; + opacity?: number; + borderWidth?: number; + strokeWidth?: number; + lineWidth?: number; + customData?: Record; + } + | { + type: PdfAnnotationSubtype.FREETEXT; + fontColor?: string; + fontSize?: number; + fontFamily?: string; + textAlign?: number; + opacity?: number; + backgroundColor?: string; + borderWidth?: number; + contents?: string; + icon?: PdfAnnotationIcon; + customData?: Record; + } + | { + 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; + } + | { + 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; + } + | { + type: PdfAnnotationSubtype.STAMP; + imageSrc?: string; + imageSize?: { width: number; height: number }; + customData?: Record; + } + | 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 = { + 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 = { + 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(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) => { - 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; }); diff --git a/frontend/src/core/components/viewer/viewerTypes.ts b/frontend/src/core/components/viewer/viewerTypes.ts index 53097a066..87a3c7a1a 100644 --- a/frontend/src/core/components/viewer/viewerTypes.ts +++ b/frontend/src/core/components/viewer/viewerTypes.ts @@ -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) => 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; +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; } diff --git a/frontend/src/core/tools/Annotate.tsx b/frontend/src/core/tools/Annotate.tsx index 43fed68b3..1f7a118bb 100644 --- a/frontend/src/core/tools/Annotate.tsx +++ b/frontend/src/core/tools/Annotate.tsx @@ -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(null); const [selectedAnnId, setSelectedAnnId] = useState(null); + const selectedAnnIdRef = useRef(null); + const activeToolRef = useRef('highlight'); + const wasAnnotateActiveRef = useRef(false); const [selectedTextDraft, setSelectedTextDraft] = useState(''); const [selectedFontSize, setSelectedFontSize] = useState(14); const selectedUpdateTimer = useRef | null>(null); const [stampImageData, setStampImageData] = useState(); + 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(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 = { - 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) => { <> {t('annotation.settings', 'Settings')} - - - {['square', 'circle', 'polygon'].includes(activeTool) - ? t('annotation.strokeColor', 'Stroke Color') - : t('annotation.color', 'Color') - } - - { - 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); - }} - /> - - {['square', 'circle', 'polygon'].includes(activeTool) && ( - - {t('annotation.fillColor', 'Fill Color')} - { - setColorPickerTarget('shapeFill'); - setIsColorPickerOpen(true); - }} - /> - - )} - - - {activeTool === 'ink' && ( - - {t('annotation.strokeWidth', 'Width')} - - - )} - - {(activeTool === 'highlight' || activeTool === 'inkHighlighter') && ( - - {t('annotation.opacity', 'Opacity')} - - - )} - - {activeTool === 'inkHighlighter' && ( - - {t('annotation.strokeWidth', 'Width')} - - - )} - - {activeTool === 'text' && ( - <> - - {t('annotation.fontSize', 'Font size')} - - - - {t('annotation.textAlignment', 'Text Alignment')} - - setTextAlignment('left')} - size="md" - > - - - setTextAlignment('center')} - size="md" - > - - - setTextAlignment('right')} - size="md" - > - - - - - - )} - - {['square', 'circle', 'line', 'polygon'].includes(activeTool) && ( - <> - - {t('annotation.opacity', 'Opacity')} - { - setShapeOpacity(value); - setShapeStrokeOpacity(value); - setShapeFillOpacity(value); - }} /> - - - {activeTool === 'line' ? ( - <> - {t('annotation.strokeWidth', 'Width')} - - - ) : ( - - - {t('annotation.strokeWidth', 'Stroke')} - - - - + + + {['square', 'circle', 'polygon'].includes(activeTool) + ? t('annotation.strokeColor', 'Stroke Color') + : t('annotation.color', 'Color') + } + + { + 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); + }} + /> + + {['square', 'circle', 'polygon'].includes(activeTool) && ( + + {t('annotation.fillColor', 'Fill Color')} + { + setColorPickerTarget('shapeFill'); + setIsColorPickerOpen(true); + }} + /> + )} - + + + {activeTool === 'ink' && ( + + {t('annotation.strokeWidth', 'Width')} + + + )} + + {(activeTool === 'highlight' || activeTool === 'inkHighlighter') && ( + + {t('annotation.opacity', 'Opacity')} + + + )} + + {activeTool === 'inkHighlighter' && ( + + {t('annotation.strokeWidth', 'Width')} + + + )} + + {activeTool === 'text' && ( + <> + + {t('annotation.fontSize', 'Font size')} + + + + {t('annotation.textAlignment', 'Text Alignment')} + + setTextAlignment('left')} + size="md" + > + + + setTextAlignment('center')} + size="md" + > + + + setTextAlignment('right')} + size="md" + > + + + + + + )} + + {['square', 'circle', 'line', 'polygon'].includes(activeTool) && ( + <> + + {t('annotation.opacity', 'Opacity')} + { + setShapeOpacity(value); + setShapeStrokeOpacity(value); + setShapeFillOpacity(value); + }} /> + + + {activeTool === 'line' ? ( + <> + {t('annotation.strokeWidth', 'Width')} + + + ) : ( + + + {t('annotation.strokeWidth', 'Stroke')} + + + + + )} + + + )} )} @@ -643,15 +875,13 @@ const Annotate = (_props: BaseToolProps) => { { 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({