diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index a425ad3d5..d91fc3563 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -708,6 +708,11 @@ tags = "signature,autograph" title = "Sign" desc = "Adds signature to PDF by drawing, text or image" +[home.annotate] +tags = "annotate,highlight,draw" +title = "Annotate" +desc = "Highlight, draw, add notes and shapes in the viewer" + [home.flatten] tags = "simplify,remove,interactive" title = "Flatten" @@ -3904,6 +3909,21 @@ draw = "Draw" save = "Save" saveChanges = "Save Changes" +[annotation] +title = "Annotate" +highlight = "Highlight" +pen = "Pen" +text = "Text box" +note = "Note" +rectangle = "Rectangle" +ellipse = "Ellipse" +select = "Select" +exit = "Exit annotation mode" +strokeWidth = "Width" +opacity = "Opacity" +fontSize = "Font size" +chooseColor = "Choose colour" + [search] title = "Search PDF" placeholder = "Enter search term..." diff --git a/frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx b/frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx index cd95b90b9..391d595c0 100644 --- a/frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx +++ b/frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx @@ -1,16 +1,15 @@ -import React, { useState, useEffect } from 'react'; -import { ActionIcon, Popover } from '@mantine/core'; +import React, { useEffect } from 'react'; +import { ActionIcon } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import LocalIcon from '@app/components/shared/LocalIcon'; import { Tooltip } from '@app/components/shared/Tooltip'; import { ViewerContext } from '@app/contexts/ViewerContext'; import { useSignature } from '@app/contexts/SignatureContext'; -import { ColorSwatchButton, ColorPicker } from '@app/components/annotation/shared/ColorPicker'; import { useFileState, useFileContext } from '@app/contexts/FileContext'; import { generateThumbnailWithMetadata } from '@app/utils/thumbnailUtils'; import { createProcessedFile } from '@app/contexts/file/fileActions'; import { createStirlingFile, createNewStirlingFileStub } from '@app/types/fileContext'; -import { useNavigationState } from '@app/contexts/NavigationContext'; +import { useNavigation, useNavigationState } from '@app/contexts/NavigationContext'; import { useSidebarContext } from '@app/contexts/SidebarContext'; import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide'; @@ -23,16 +22,14 @@ export default function ViewerAnnotationControls({ currentView, disabled = false const { t } = useTranslation(); const { sidebarRefs } = useSidebarContext(); const { position: tooltipPosition, offset: tooltipOffset } = useRightRailTooltipSide(sidebarRefs); - const [selectedColor, setSelectedColor] = useState('#000000'); - const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); - const [isHoverColorPickerOpen, setIsHoverColorPickerOpen] = useState(false); - // Viewer context for PDF controls - safely handle when not available const viewerContext = React.useContext(ViewerContext); // Signature context for accessing drawing API const { signatureApiRef, isPlacementMode } = useSignature(); + const { setToolAndWorkbench } = useNavigation(); + // File state for save functionality const { state, selectors } = useFileState(); const { actions: fileActions } = useFileContext(); @@ -41,6 +38,14 @@ export default function ViewerAnnotationControls({ currentView, disabled = false // Check if we're in sign mode const { selectedTool } = useNavigationState(); const isSignMode = selectedTool === 'sign'; + const isAnnotateMode = selectedTool === 'annotate'; + + // When leaving viewer, turn off annotation overlay + useEffect(() => { + if (currentView !== 'viewer' && viewerContext?.isAnnotationMode) { + viewerContext.setAnnotationMode(false); + } + }, [currentView, viewerContext]); // Turn off annotation mode when switching away from viewer useEffect(() => { @@ -75,89 +80,19 @@ export default function ViewerAnnotationControls({ currentView, disabled = false - {/* Annotation Mode Toggle with Drawing Controls */} - {viewerContext?.isAnnotationMode ? ( - // When active: Show color picker on hover -
setIsHoverColorPickerOpen(true)} - onMouseLeave={() => setIsHoverColorPickerOpen(false)} - style={{ display: 'inline-flex' }} + {/* Launch Annotate tool in the left panel */} + + setToolAndWorkbench('annotate', 'viewer')} + disabled={disabled} + aria-label={typeof t === 'function' ? t('rightRail.draw', 'Draw') : 'Draw'} > - setIsHoverColorPickerOpen(false)} - position="left" - withArrow - shadow="md" - offset={8} - > - - { - viewerContext?.toggleAnnotationMode(); - setIsHoverColorPickerOpen(false); // Close hover color picker when toggling off - // Deactivate drawing tool when exiting annotation mode - if (signatureApiRef?.current) { - try { - signatureApiRef.current.deactivateTools(); - } catch (error) { - console.log('Signature API not ready:', error); - } - } - }} - disabled={disabled} - aria-label="Drawing mode active" - > - - - - -
-
-
Drawing Color
- { - setIsHoverColorPickerOpen(false); // Close hover picker - setIsColorPickerOpen(true); // Open main color picker modal - }} - /> -
-
-
-
-
- ) : ( - // When inactive: Show "Draw" tooltip - - { - viewerContext?.toggleAnnotationMode(); - // Activate ink drawing tool when entering annotation mode - if (signatureApiRef?.current && currentView === 'viewer') { - try { - signatureApiRef.current.activateDrawMode(); - signatureApiRef.current.updateDrawSettings(selectedColor, 2); - } catch (error) { - console.log('Signature API not ready:', error); - } - } - }} - disabled={disabled} - aria-label={typeof t === 'function' ? t('rightRail.draw', 'Draw') : 'Draw'} - > - - - - )} + + + {/* Save PDF with Annotations */} @@ -213,25 +148,6 @@ export default function ViewerAnnotationControls({ currentView, disabled = false - - {/* Color Picker Modal */} - setIsColorPickerOpen(false)} - selectedColor={selectedColor} - onColorChange={(color) => { - setSelectedColor(color); - // Update drawing tool color if annotation mode is active - if (viewerContext?.isAnnotationMode && signatureApiRef?.current && currentView === 'viewer') { - try { - signatureApiRef.current.updateDrawSettings(color, 2); - } catch (error) { - console.log('Unable to update drawing settings:', error); - } - } - }} - title="Choose Drawing Color" - /> ); } diff --git a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx index 9ca8bac0a..6aa613812 100644 --- a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx @@ -84,9 +84,10 @@ const EmbedPdfViewerContent = ({ const { selectedTool } = useNavigationState(); // Tools that use the stamp/signature placement system with hover preview const isSignatureMode = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage'; + const isAnnotateTool = selectedTool === 'annotate'; // Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations - const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || isAnnotationsVisible; + const shouldEnableAnnotations = isSignatureMode || isAnnotateTool || isAnnotationMode || isAnnotationsVisible; const isPlacementOverlayActive = Boolean( isSignatureMode && shouldEnableAnnotations && isPlacementMode && signatureConfig ); diff --git a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx index 3e5fa5f1c..9a93a225a 100644 --- a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx +++ b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx @@ -121,9 +121,9 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur selectAfterCreate: true, }), - // Register pan plugin (depends on Viewport, InteractionManager) + // Register pan plugin (depends on Viewport, InteractionManager) - keep disabled to prevent drag panning createPluginRegistration(PanPluginPackage, { - defaultMode: 'mobile', // Try mobile mode which might be more permissive + defaultMode: 'disabled', }), // Register zoom plugin with configuration @@ -248,7 +248,305 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur if (!annotationApi) return; if (enableAnnotations) { - annotationApi.addTool({ + const ensureTool = (tool: any) => { + const existing = annotationApi.getTool?.(tool.id); + if (!existing) { + annotationApi.addTool(tool); + } + }; + + ensureTool({ + id: 'highlight', + name: 'Highlight', + interaction: { exclusive: true, cursor: 'text', textSelection: true }, + matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.HIGHLIGHT ? 10 : 0), + defaults: { + type: PdfAnnotationSubtype.HIGHLIGHT, + color: '#ffd54f', + opacity: 0.6, + }, + behavior: { + deactivateToolAfterCreate: false, + selectAfterCreate: true, + }, + }); + + ensureTool({ + id: 'underline', + name: 'Underline', + interaction: { exclusive: true, cursor: 'text', textSelection: true }, + matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.UNDERLINE ? 10 : 0), + defaults: { + type: PdfAnnotationSubtype.UNDERLINE, + color: '#ffb300', + opacity: 1, + }, + behavior: { + deactivateToolAfterCreate: false, + selectAfterCreate: true, + }, + }); + + ensureTool({ + id: 'strikeout', + name: 'Strikeout', + interaction: { exclusive: true, cursor: 'text', textSelection: true }, + matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.STRIKEOUT ? 10 : 0), + defaults: { + type: PdfAnnotationSubtype.STRIKEOUT, + color: '#e53935', + opacity: 1, + }, + behavior: { + deactivateToolAfterCreate: false, + selectAfterCreate: true, + }, + }); + + ensureTool({ + id: 'squiggly', + name: 'Squiggly', + interaction: { exclusive: true, cursor: 'text', textSelection: true }, + matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.SQUIGGLY ? 10 : 0), + defaults: { + type: PdfAnnotationSubtype.SQUIGGLY, + color: '#00acc1', + opacity: 1, + }, + behavior: { + deactivateToolAfterCreate: false, + selectAfterCreate: true, + }, + }); + + ensureTool({ + id: 'ink', + name: 'Pen', + interaction: { exclusive: true, cursor: 'crosshair' }, + matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.INK ? 10 : 0), + defaults: { + type: PdfAnnotationSubtype.INK, + color: '#1f2933', + opacity: 1, + borderWidth: 2, + }, + behavior: { + deactivateToolAfterCreate: false, + selectAfterCreate: true, + }, + }); + + ensureTool({ + id: 'inkHighlighter', + name: 'Ink Highlighter', + interaction: { exclusive: true, cursor: 'crosshair' }, + matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.INK && annotation.color === '#ffd54f' ? 8 : 0), + defaults: { + type: PdfAnnotationSubtype.INK, + color: '#ffd54f', + opacity: 0.5, + borderWidth: 6, + }, + behavior: { + deactivateToolAfterCreate: false, + selectAfterCreate: true, + }, + }); + + ensureTool({ + id: 'square', + name: 'Square', + interaction: { exclusive: true, cursor: 'crosshair' }, + matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.SQUARE ? 10 : 0), + defaults: { + type: PdfAnnotationSubtype.SQUARE, + color: '#1565c0', + interiorColor: '#e3f2fd', + opacity: 0.35, + borderWidth: 2, + }, + clickBehavior: { + enabled: true, + defaultSize: { width: 120, height: 90 }, + }, + behavior: { + deactivateToolAfterCreate: true, + selectAfterCreate: true, + }, + }); + + ensureTool({ + id: 'circle', + name: 'Circle', + interaction: { exclusive: true, cursor: 'crosshair' }, + matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.CIRCLE ? 10 : 0), + defaults: { + type: PdfAnnotationSubtype.CIRCLE, + color: '#1565c0', + interiorColor: '#e3f2fd', + opacity: 0.35, + borderWidth: 2, + }, + clickBehavior: { + enabled: true, + defaultSize: { width: 100, height: 100 }, + }, + behavior: { + deactivateToolAfterCreate: true, + selectAfterCreate: true, + }, + }); + + ensureTool({ + id: 'line', + name: 'Line', + interaction: { exclusive: true, cursor: 'crosshair' }, + matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.LINE ? 10 : 0), + defaults: { + type: PdfAnnotationSubtype.LINE, + color: '#1565c0', + opacity: 1, + borderWidth: 2, + }, + clickBehavior: { + enabled: true, + defaultLength: 120, + defaultAngle: 0, + }, + behavior: { + deactivateToolAfterCreate: true, + selectAfterCreate: true, + }, + }); + + ensureTool({ + id: 'lineArrow', + name: 'Arrow', + interaction: { exclusive: true, cursor: 'crosshair' }, + matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.LINE && (annotation.endStyle === 'ClosedArrow' || annotation.lineEndingStyles?.end === 'ClosedArrow') ? 9 : 0), + defaults: { + type: PdfAnnotationSubtype.LINE, + color: '#1565c0', + opacity: 1, + borderWidth: 2, + startStyle: 'None', + endStyle: 'ClosedArrow', + lineEndingStyles: { start: 'None', end: 'ClosedArrow' }, + }, + clickBehavior: { + enabled: true, + defaultLength: 120, + defaultAngle: 0, + }, + behavior: { + deactivateToolAfterCreate: true, + selectAfterCreate: true, + }, + }); + + ensureTool({ + id: 'polyline', + name: 'Polyline', + interaction: { exclusive: true, cursor: 'crosshair' }, + matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.POLYLINE ? 10 : 0), + defaults: { + type: PdfAnnotationSubtype.POLYLINE, + color: '#1565c0', + opacity: 1, + borderWidth: 2, + }, + clickBehavior: { + enabled: true, + finishOnDoubleClick: true, + }, + behavior: { + deactivateToolAfterCreate: true, + selectAfterCreate: true, + }, + }); + + ensureTool({ + id: 'polygon', + name: 'Polygon', + interaction: { exclusive: true, cursor: 'crosshair' }, + matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.POLYGON ? 10 : 0), + defaults: { + type: PdfAnnotationSubtype.POLYGON, + color: '#1565c0', + interiorColor: '#e3f2fd', + opacity: 0.35, + borderWidth: 2, + }, + clickBehavior: { + enabled: true, + finishOnDoubleClick: true, + defaultSize: { width: 140, height: 100 }, + }, + behavior: { + deactivateToolAfterCreate: true, + selectAfterCreate: true, + }, + }); + + ensureTool({ + id: 'text', + name: 'Text', + interaction: { exclusive: true, cursor: 'text' }, + matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.FREETEXT ? 10 : 0), + defaults: { + type: PdfAnnotationSubtype.FREETEXT, + textColor: '#111111', + fontSize: 14, + fontFamily: 'Helvetica', + opacity: 1, + interiorColor: '#fffef7', + contents: 'Text', + }, + behavior: { + deactivateToolAfterCreate: false, + selectAfterCreate: true, + }, + }); + + ensureTool({ + id: 'note', + name: 'Note', + interaction: { exclusive: true, cursor: 'pointer' }, + matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.FREETEXT ? 8 : 0), + defaults: { + type: PdfAnnotationSubtype.FREETEXT, + textColor: '#1b1b1b', + color: '#ffa000', + interiorColor: '#fff8e1', + opacity: 1, + contents: 'Note', + fontSize: 12, + }, + clickBehavior: { + enabled: true, + defaultSize: { width: 160, height: 100 }, + }, + behavior: { + deactivateToolAfterCreate: false, + selectAfterCreate: true, + }, + }); + + ensureTool({ + id: 'stamp', + name: 'Image Stamp', + interaction: { exclusive: false, cursor: 'copy' }, + matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.STAMP ? 5 : 0), + defaults: { + type: PdfAnnotationSubtype.STAMP, + }, + behavior: { + deactivateToolAfterCreate: true, + selectAfterCreate: true, + }, + }); + + ensureTool({ id: 'signatureStamp', name: 'Digital Signature', interaction: { exclusive: false, cursor: 'copy' }, @@ -258,7 +556,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur }, }); - annotationApi.addTool({ + ensureTool({ id: 'signatureInk', name: 'Signature Draw', interaction: { exclusive: true, cursor: 'crosshair' }, diff --git a/frontend/src/core/components/viewer/SignatureAPIBridge.tsx b/frontend/src/core/components/viewer/SignatureAPIBridge.tsx index 92d436e5d..050b0c10d 100644 --- a/frontend/src/core/components/viewer/SignatureAPIBridge.tsx +++ b/frontend/src/core/components/viewer/SignatureAPIBridge.tsx @@ -2,7 +2,7 @@ import { useImperativeHandle, forwardRef, useEffect, useCallback, useRef, useSta import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react'; import { PdfAnnotationSubtype, uuidV4 } from '@embedpdf/models'; import { useSignature } from '@app/contexts/SignatureContext'; -import type { SignatureAPI } from '@app/components/viewer/viewerTypes'; +import type { AnnotationToolId, AnnotationToolOptions, SignatureAPI } from '@app/components/viewer/viewerTypes'; import type { SignParameters } from '@app/hooks/tools/sign/useSignParameters'; import { useViewer } from '@app/contexts/ViewerContext'; @@ -199,12 +199,161 @@ export const SignatureAPIBridge = forwardRef(function SignatureAPI } }, [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults, cssToPdfSize]); + 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', + opacity: options?.opacity ?? 1, + borderWidth: options?.thickness ?? 2, + lineWidth: options?.thickness ?? 2, + strokeWidth: options?.thickness ?? 2, + }; + case 'inkHighlighter': + return { + type: PdfAnnotationSubtype.INK, + color: options?.color ?? '#ffd54f', + opacity: options?.opacity ?? 0.5, + borderWidth: options?.thickness ?? 6, + lineWidth: options?.thickness ?? 6, + strokeWidth: options?.thickness ?? 6, + }; + case 'text': + return { + type: PdfAnnotationSubtype.FREETEXT, + textColor: options?.color ?? '#111111', + fontSize: options?.fontSize ?? 14, + fontFamily: options?.fontFamily ?? 'Helvetica', + opacity: options?.opacity ?? 1, + interiorColor: options?.fillColor ?? '#fffef7', + borderWidth: options?.thickness ?? 1, + }; + case 'note': + return { + type: PdfAnnotationSubtype.FREETEXT, + textColor: options?.color ?? '#1b1b1b', + color: options?.color ?? '#ffa000', + interiorColor: options?.fillColor ?? '#fff8e1', + opacity: options?.opacity ?? 1, + fontSize: options?.fontSize ?? 12, + contents: 'Note', + }; + case 'square': + return { + type: PdfAnnotationSubtype.SQUARE, + color: options?.color ?? '#1565c0', + interiorColor: options?.fillColor ?? '#e3f2fd', + opacity: options?.opacity ?? 0.35, + borderWidth: options?.thickness ?? 2, + }; + case 'circle': + return { + type: PdfAnnotationSubtype.CIRCLE, + color: options?.color ?? '#1565c0', + interiorColor: options?.fillColor ?? '#e3f2fd', + opacity: options?.opacity ?? 0.35, + borderWidth: options?.thickness ?? 2, + }; + case 'line': + return { + type: PdfAnnotationSubtype.LINE, + color: options?.color ?? '#1565c0', + opacity: options?.opacity ?? 1, + borderWidth: options?.thickness ?? 2, + }; + case 'lineArrow': + return { + type: PdfAnnotationSubtype.LINE, + color: options?.color ?? '#1565c0', + opacity: options?.opacity ?? 1, + borderWidth: options?.thickness ?? 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?.thickness ?? 2, + }; + case 'polygon': + return { + type: PdfAnnotationSubtype.POLYGON, + color: options?.color ?? '#1565c0', + interiorColor: options?.fillColor ?? '#e3f2fd', + opacity: options?.opacity ?? 0.35, + borderWidth: options?.thickness ?? 2, + }; + case 'stamp': + return { + type: PdfAnnotationSubtype.STAMP, + }; + case 'select': + default: + return null; + } + }, + [] + ); + + const configureAnnotationTool = useCallback( + (toolId: AnnotationToolId, options?: AnnotationToolOptions) => { + if (!annotationApi) return; + + const defaults = buildAnnotationDefaults(toolId, options); + const api = annotationApi as any; + + if (defaults) { + api.setToolDefaults?.(toolId, defaults); + } + + api.setActiveTool?.(toolId === 'select' ? null : toolId); + }, + [annotationApi, buildAnnotationDefaults] + ); + // Enable keyboard deletion of selected annotations useEffect(() => { // Always enable delete key when we have annotation API and are in sign mode if (!annotationApi || (isPlacementMode === undefined)) return; const handleKeyDown = (event: KeyboardEvent) => { + // Skip delete/backspace while a text input/textarea is focused (e.g., editing textbox) + const target = event.target as HTMLElement | null; + const tag = target?.tagName?.toLowerCase(); + const editable = target?.getAttribute?.('contenteditable'); + if (tag === 'input' || tag === 'textarea' || editable === 'true') { + return; + } + if (event.key === 'Delete' || event.key === 'Backspace') { const selectedAnnotation = annotationApi.getSelectedAnnotation?.(); @@ -371,7 +520,23 @@ export const SignatureAPIBridge = forwardRef(function SignatureAPI return []; } }, - }), [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults]); + 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: () => { + return annotationApi?.getSelectedAnnotation?.() ?? null; + }, + updateAnnotation: (pageIndex: number, annotationId: string, patch: Partial) => { + annotationApi?.updateAnnotation?.(pageIndex, annotationId, patch); + }, + }), [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults, configureAnnotationTool, buildAnnotationDefaults]); useEffect(() => { if (!annotationApi?.onAnnotationEvent) { diff --git a/frontend/src/core/components/viewer/viewerTypes.ts b/frontend/src/core/components/viewer/viewerTypes.ts index 3d61377c7..088d94050 100644 --- a/frontend/src/core/components/viewer/viewerTypes.ts +++ b/frontend/src/core/components/viewer/viewerTypes.ts @@ -14,6 +14,10 @@ export interface SignatureAPI { updateDrawSettings: (color: string, size: number) => void; deactivateTools: () => void; getPageAnnotations: (pageIndex: number) => Promise; + activateAnnotationTool?: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => void; + setAnnotationStyle?: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => void; + getSelectedAnnotation?: () => any | null; + updateAnnotation?: (pageIndex: number, annotationId: string, patch: Partial) => void; } export interface HistoryAPI { @@ -23,3 +27,33 @@ export interface HistoryAPI { canRedo: () => boolean; subscribe?: (listener: () => void) => () => void; } + +export type AnnotationToolId = + | 'select' + | 'highlight' + | 'underline' + | 'strikeout' + | 'squiggly' + | 'ink' + | 'inkHighlighter' + | 'text' + | 'note' + | 'square' + | 'circle' + | 'line' + | 'lineArrow' + | 'polyline' + | 'polygon' + | 'stamp' + | 'signatureStamp' + | 'signatureInk'; + +export interface AnnotationToolOptions { + color?: string; + fillColor?: string; + opacity?: number; + thickness?: number; + fontSize?: number; + fontFamily?: string; + imageSrc?: string; +} diff --git a/frontend/src/core/data/useTranslatedToolRegistry.tsx b/frontend/src/core/data/useTranslatedToolRegistry.tsx index cda99dc40..9faff8c54 100644 --- a/frontend/src/core/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/core/data/useTranslatedToolRegistry.tsx @@ -48,6 +48,7 @@ import Crop from "@app/tools/Crop"; import Sign from "@app/tools/Sign"; import AddText from "@app/tools/AddText"; import AddImage from "@app/tools/AddImage"; +import Annotate from "@app/tools/Annotate"; import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation"; import { splitOperationConfig } from "@app/hooks/tools/split/useSplitOperation"; import { addPasswordOperationConfig } from "@app/hooks/tools/addPassword/useAddPasswordOperation"; @@ -226,6 +227,19 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { synonyms: getSynonyms(t, 'addImage'), supportsAutomate: false, }, + annotate: { + icon: , + name: t('home.annotate.title', 'Annotate'), + component: Annotate, + description: t('home.annotate.desc', 'Highlight, draw, add notes, and shapes directly in the viewer'), + categoryId: ToolCategoryId.STANDARD_TOOLS, + subcategoryId: SubcategoryId.EDIT, + workbench: 'viewer', + operationConfig: signOperationConfig, + automationSettings: null, + synonyms: getSynonyms(t, 'annotate'), + supportsAutomate: false, + }, // Document Security diff --git a/frontend/src/core/tools/Annotate.tsx b/frontend/src/core/tools/Annotate.tsx new file mode 100644 index 000000000..66116c858 --- /dev/null +++ b/frontend/src/core/tools/Annotate.tsx @@ -0,0 +1,589 @@ +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, NumberInput } from '@mantine/core'; + +import { createToolFlow } from '@app/components/tools/shared/createToolFlow'; +import { useNavigation } from '@app/contexts/NavigationContext'; +import { useFileSelection, useFileContext } from '@app/contexts/FileContext'; +import { BaseToolProps } from '@app/types/tool'; +import { useSignature } from '@app/contexts/SignatureContext'; +import { ViewerContext } from '@app/contexts/ViewerContext'; +import { ColorPicker, ColorSwatchButton } from '@app/components/annotation/shared/ColorPicker'; +import LocalIcon from '@app/components/shared/LocalIcon'; +import type { AnnotationToolId } from '@app/components/viewer/viewerTypes'; + +const Annotate = (_props: BaseToolProps) => { + const { t } = useTranslation(); + const { setToolAndWorkbench } = useNavigation(); + const { selectedFiles } = useFileSelection(); + const { selectors } = useFileContext(); + const { signatureApiRef } = useSignature(); + const viewerContext = useContext(ViewerContext); + + const [activeTool, setActiveTool] = useState('highlight'); + const [inkColor, setInkColor] = useState('#1f2933'); + const [inkWidth, setInkWidth] = useState(2); + const [highlightColor, setHighlightColor] = useState('#ffd54f'); + const [highlightOpacity, setHighlightOpacity] = useState(60); + const [underlineColor, setUnderlineColor] = useState('#ffb300'); + const [strikeoutColor, setStrikeoutColor] = useState('#e53935'); + const [squigglyColor, setSquigglyColor] = useState('#00acc1'); + const [textColor, setTextColor] = useState('#111111'); + const [textSize, setTextSize] = useState(14); + const [shapeStrokeColor, setShapeStrokeColor] = useState('#1565c0'); + const [shapeFillColor, setShapeFillColor] = useState('#e3f2fd'); + const [shapeOpacity, setShapeOpacity] = useState(35); + const [shapeThickness, setShapeThickness] = useState(2); + const [colorPickerTarget, setColorPickerTarget] = useState<'ink' | 'highlight' | 'underline' | 'strikeout' | 'squiggly' | 'text' | 'shapeStroke' | 'shapeFill' | null>(null); + const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); + const [selectedAnn, setSelectedAnn] = useState(null); + const [selectedAnnId, setSelectedAnnId] = useState(null); + const [selectedTextDraft, setSelectedTextDraft] = useState(''); + const [selectedFontSize, setSelectedFontSize] = useState(14); + const selectedUpdateTimer = useRef | null>(null); + const stampInputRef = useRef(null); + + const buildToolOptions = useCallback((toolId: AnnotationToolId) => { + switch (toolId) { + case 'ink': + return { color: inkColor, thickness: inkWidth }; + case 'inkHighlighter': + return { color: highlightColor, opacity: highlightOpacity / 100, thickness: 6 }; + case 'highlight': + return { color: highlightColor, opacity: highlightOpacity / 100 }; + case 'underline': + return { color: underlineColor, opacity: 1 }; + case 'strikeout': + return { color: strikeoutColor, opacity: 1 }; + case 'squiggly': + return { color: squigglyColor, opacity: 1 }; + case 'text': + return { color: textColor, fontSize: textSize }; + case 'note': + return { color: textColor }; + case 'square': + case 'circle': + case 'polygon': + return { + color: shapeStrokeColor, + interiorColor: shapeFillColor, + opacity: shapeOpacity / 100, + borderWidth: shapeThickness, + }; + case 'line': + case 'polyline': + case 'lineArrow': + return { + color: shapeStrokeColor, + opacity: shapeOpacity / 100, + borderWidth: shapeThickness, + }; + default: + return {}; + } + }, [highlightColor, highlightOpacity, inkColor, inkWidth, underlineColor, strikeoutColor, squigglyColor, textColor, textSize, shapeStrokeColor, shapeFillColor, shapeOpacity, shapeThickness]); + + useEffect(() => { + setToolAndWorkbench('annotate', 'viewer'); + }, [setToolAndWorkbench]); + + useEffect(() => { + if (!viewerContext) return; + if (viewerContext.isAnnotationMode) return; + + viewerContext.setAnnotationMode(true); + signatureApiRef?.current?.activateAnnotationTool?.(activeTool, buildToolOptions(activeTool)); + }, [viewerContext?.isAnnotationMode, signatureApiRef, activeTool, buildToolOptions]); + + const activateAnnotationTool = (toolId: AnnotationToolId) => { + viewerContext?.setAnnotationMode(true); + setActiveTool(toolId); + const options = buildToolOptions(toolId); + signatureApiRef?.current?.activateAnnotationTool?.(toolId, options); + + if (toolId === 'stamp') { + // Use existing add image flow for stamp assets + if (stampInputRef.current) { + stampInputRef.current.click(); + } + } + }; + + useEffect(() => { + // push style updates to EmbedPDF when sliders/colors change + signatureApiRef?.current?.setAnnotationStyle?.(activeTool, buildToolOptions(activeTool)); + }, [activeTool, buildToolOptions, signatureApiRef]); + + // Allow exiting multi-point tools with Escape (e.g., polyline) + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return; + if (['polyline', 'polygon'].includes(activeTool)) { + signatureApiRef?.current?.setAnnotationStyle?.(activeTool, buildToolOptions(activeTool)); + signatureApiRef?.current?.activateAnnotationTool?.(null as any); + setTimeout(() => { + signatureApiRef?.current?.activateAnnotationTool?.(activeTool, buildToolOptions(activeTool)); + }, 50); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [activeTool, buildToolOptions, signatureApiRef]); + + // Poll selected annotation to allow editing existing highlights/text + useEffect(() => { + const interval = setInterval(() => { + const ann = signatureApiRef?.current?.getSelectedAnnotation?.(); + const annId = ann?.object?.id ?? null; + setSelectedAnn(ann || null); + // Only reset drafts when selection changes + if (annId !== selectedAnnId) { + setSelectedAnnId(annId); + if (ann?.object?.contents !== undefined) { + setSelectedTextDraft(ann.object.contents ?? ''); + } + if (ann?.object?.fontSize !== undefined) { + setSelectedFontSize(ann.object.fontSize ?? 14); + } + } + }, 150); + return () => clearInterval(interval); + }, [signatureApiRef, selectedAnnId]); + + const annotationTools: { id: AnnotationToolId; label: string; icon: string }[] = [ + { id: 'highlight', label: t('annotation.highlight', 'Highlight'), icon: 'highlight' }, + { id: 'underline', label: t('annotation.underline', 'Underline'), icon: 'format-underlined' }, + { id: 'strikeout', label: t('annotation.strikeout', 'Strikeout'), icon: 'strikethrough-s' }, + { id: 'squiggly', label: t('annotation.squiggly', 'Squiggly'), icon: 'show-chart' }, + { id: 'ink', label: t('annotation.pen', 'Pen'), icon: 'edit' }, + { id: 'inkHighlighter', label: t('annotation.inkHighlighter', 'Ink Highlighter'), icon: 'brush' }, + { id: 'text', label: t('annotation.text', 'Text box'), icon: 'text-fields' }, + { id: 'note', label: t('annotation.note', 'Note'), icon: 'sticky-note-2' }, + { id: 'square', label: t('annotation.square', 'Square'), icon: 'crop-square' }, + { id: 'circle', label: t('annotation.circle', 'Circle'), icon: 'radio-button-unchecked' }, + { id: 'line', label: t('annotation.line', 'Line'), icon: 'show-chart' }, + { id: 'lineArrow', label: t('annotation.arrow', 'Arrow'), icon: 'trending-flat' }, + { id: 'polyline', label: t('annotation.polyline', 'Polyline'), icon: 'polyline' }, + { id: 'polygon', label: t('annotation.polygon', 'Polygon'), icon: 'change-history' }, + { id: 'stamp', label: t('annotation.stamp', 'Stamp'), icon: 'image' }, + ]; + + const activeColor = + colorPickerTarget === 'ink' + ? inkColor + : colorPickerTarget === 'highlight' || colorPickerTarget === 'inkHighlighter' + ? highlightColor + : colorPickerTarget === 'underline' + ? underlineColor + : colorPickerTarget === 'strikeout' + ? strikeoutColor + : colorPickerTarget === 'squiggly' + ? squigglyColor + : colorPickerTarget === 'shapeStroke' + ? shapeStrokeColor + : colorPickerTarget === 'shapeFill' + ? shapeFillColor + : textColor; + + const steps = useMemo(() => { + if (selectedFiles.length === 0) return []; + + const toolButtons = ( + + {annotationTools.map((tool) => ( + + activateAnnotationTool(tool.id)} + aria-label={tool.label} + > + + + + ))} + + ); + + const controls = ( + + + { + const file = e.target.files?.[0]; + if (!file) return; + try { + const dataUrl: string = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(file); + }); + // push into stamp defaults and activate stamp tool + signatureApiRef?.current?.setAnnotationStyle?.('stamp', { imageSrc: dataUrl as string }); + signatureApiRef?.current?.activateAnnotationTool?.('stamp', { imageSrc: dataUrl as string }); + setActiveTool('stamp'); + } catch (err) { + console.error('Failed to load stamp image', err); + } finally { + e.target.value = ''; + } + }} + /> + { + const target = + activeTool === 'ink' + ? 'ink' + : activeTool === 'highlight' || activeTool === 'inkHighlighter' + ? 'highlight' + : activeTool === 'underline' + ? 'underline' + : activeTool === 'strikeout' + ? 'strikeout' + : activeTool === 'squiggly' + ? 'squiggly' + : ['square', 'circle', 'line', 'lineArrow', 'polyline', 'polygon'].includes(activeTool) + ? 'shapeStroke' + : 'text'; + setColorPickerTarget(target); + setIsColorPickerOpen(true); + }} + /> + {['square', 'circle', 'polygon'].includes(activeTool) && ( + { + setColorPickerTarget('shapeFill'); + setIsColorPickerOpen(true); + }} + /> + )} + {activeTool === 'ink' && ( + <> + {t('annotation.strokeWidth', 'Width')} + + + )} + {(activeTool === 'highlight' || activeTool === 'inkHighlighter') && ( + <> + {t('annotation.opacity', 'Opacity')} + + + )} + {activeTool === 'text' && ( + <> + {t('annotation.fontSize', 'Font size')} + + + )} + {['square', 'circle', 'line', 'lineArrow', 'polyline', 'polygon'].includes(activeTool) && ( + <> + {t('annotation.opacity', 'Opacity')} + + {t('annotation.strokeWidth', 'Stroke')} + + + )} + + + + {t('annotation.tipPlace', 'Click anywhere on the PDF to place highlights, drawings, notes, or text.')} + + {selectedAnn && ( + + {t('annotation.editSelected', 'Edit selected annotation')} + {(selectedAnn.object?.type === 9 || selectedAnn.object?.type === 1 || selectedAnn.object?.type === 3 || selectedAnn.object?.type === 15) && ( + <> + {t('annotation.opacity', 'Opacity')} + { + signatureApiRef?.current?.updateAnnotation?.( + selectedAnn.object?.pageIndex ?? 0, + selectedAnn.object?.id, + { opacity: value / 100 } + ); + }} + /> + + )} + {(selectedAnn.object?.type === 9 || selectedAnn.object?.type === 15 || selectedAnn.object?.type === 3 || selectedAnn.object?.type === 1) && ( + { + setColorPickerTarget('highlight'); + setIsColorPickerOpen(true); + }} + /> + )} + {(selectedAnn.object?.type === 3 || selectedAnn.object?.type === 1) && ( + <> + { + const val = e.currentTarget.value; + setSelectedTextDraft(val); + if (selectedUpdateTimer.current) { + clearTimeout(selectedUpdateTimer.current); + } + selectedUpdateTimer.current = setTimeout(() => { + signatureApiRef?.current?.updateAnnotation?.( + selectedAnn.object?.pageIndex ?? 0, + selectedAnn.object?.id, + { contents: val, textColor: selectedAnn.object?.textColor ?? textColor } + ); + }, 120); + }} + /> + {selectedAnn.object?.type === 3 && ( + { + const size = typeof val === 'number' ? val : 14; + setSelectedFontSize(size); + signatureApiRef?.current?.updateAnnotation?.( + selectedAnn.object?.pageIndex ?? 0, + selectedAnn.object?.id, + { fontSize: size } + ); + }} + /> + )} + + )} + {['4','5','7','8','12','15'].includes(String(selectedAnn.object?.type)) && ( + <> + {t('annotation.opacity', 'Opacity')} + { + signatureApiRef?.current?.updateAnnotation?.( + selectedAnn.object?.pageIndex ?? 0, + selectedAnn.object?.id, + { opacity: value / 100 } + ); + }} + /> + {t('annotation.strokeWidth', 'Stroke')} + { + signatureApiRef?.current?.updateAnnotation?.( + selectedAnn.object?.pageIndex ?? 0, + selectedAnn.object?.id, + { borderWidth: value } + ); + setShapeThickness(value); + }} + /> + + { + setColorPickerTarget('shapeStroke'); + setIsColorPickerOpen(true); + }} + /> + {['4','5','7','8','12'].includes(String(selectedAnn.object?.type)) && ( + { + setColorPickerTarget('shapeFill'); + setIsColorPickerOpen(true); + }} + /> + )} + + + )} + + )} + + setIsColorPickerOpen(false)} + selectedColor={activeColor} + onColorChange={(color) => { + if (colorPickerTarget === 'ink') { + setInkColor(color); + if (activeTool === 'ink') { + signatureApiRef?.current?.setAnnotationStyle?.('ink', buildToolOptions('ink')); + } + if (selectedAnn?.object?.type === 15) { + signatureApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id, { color }); + } + } else if (colorPickerTarget === 'highlight') { + setHighlightColor(color); + if (activeTool === 'highlight' || activeTool === 'inkHighlighter') { + signatureApiRef?.current?.setAnnotationStyle?.(activeTool, buildToolOptions(activeTool)); + } + if (selectedAnn?.object?.type === 9 || selectedAnn?.object?.type === 15) { + signatureApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id, { color }); + } + } else if (colorPickerTarget === 'underline') { + setUnderlineColor(color); + signatureApiRef?.current?.setAnnotationStyle?.('underline', buildToolOptions('underline')); + if (selectedAnn?.object?.id) { + signatureApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id, { color }); + } + } else if (colorPickerTarget === 'strikeout') { + setStrikeoutColor(color); + signatureApiRef?.current?.setAnnotationStyle?.('strikeout', buildToolOptions('strikeout')); + if (selectedAnn?.object?.id) { + signatureApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id, { color }); + } + } else if (colorPickerTarget === 'squiggly') { + setSquigglyColor(color); + signatureApiRef?.current?.setAnnotationStyle?.('squiggly', buildToolOptions('squiggly')); + if (selectedAnn?.object?.id) { + signatureApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id, { color }); + } + } else { + setTextColor(color); + if (activeTool === 'text') { + signatureApiRef?.current?.setAnnotationStyle?.('text', buildToolOptions('text')); + } + if (selectedAnn?.object?.type === 3 || selectedAnn?.object?.type === 1) { + signatureApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id, { + textColor: color, + color, + }); + } + } + if (colorPickerTarget === 'shapeStroke' && ['square', 'circle', 'line', 'lineArrow', 'polyline', 'polygon'].includes(activeTool)) { + setShapeStrokeColor(color); + signatureApiRef?.current?.setAnnotationStyle?.(activeTool, buildToolOptions(activeTool)); + if (selectedAnn?.object?.id) { + signatureApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id, { + color, + strokeColor: color, + borderWidth: shapeThickness, + }); + } + } + if (colorPickerTarget === 'shapeFill' && ['square', 'circle', 'polygon'].includes(activeTool)) { + setShapeFillColor(color); + signatureApiRef?.current?.setAnnotationStyle?.(activeTool, buildToolOptions(activeTool)); + if (selectedAnn && (selectedAnn.object?.interiorColor !== undefined || ['4','5','7','8','12'].includes(String(selectedAnn.object?.type)))) { + signatureApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id, { + interiorColor: color, + fillColor: color, + borderWidth: shapeThickness, + }); + } + } + }} + title={t('annotation.chooseColor', 'Choose color')} + /> + + ); + + return [ + { + title: t('annotation.title', 'Annotate'), + isCollapsed: false, + onCollapsedClick: undefined, + content: ( + + + + {t('annotation.desc', 'Use highlight, pen, text, and notes. Changes stay live—no flattening required.')} + + + + {t('annotation.title', 'Annotate')} + {toolButtons} + + {controls} + + ), + }, + ]; + }, [ + activeTool, + annotationTools, + highlightColor, + highlightOpacity, + inkColor, + inkWidth, + selectedFiles.length, + t, + selectedAnn, + textColor, + textSize, + viewerContext?.exportActions, + selectors, + ]); + + return createToolFlow({ + files: { + selectedFiles, + isCollapsed: false, + }, + steps, + review: { + isVisible: false, + operation: { files: [], downloadUrl: null }, + title: '', + onFileClick: () => {}, + onUndo: () => {}, + }, + forceStepNumbers: true, + }); +}; + +export default Annotate; diff --git a/frontend/src/core/types/toolId.ts b/frontend/src/core/types/toolId.ts index 23b03a871..9a8dee3ba 100644 --- a/frontend/src/core/types/toolId.ts +++ b/frontend/src/core/types/toolId.ts @@ -25,6 +25,7 @@ export const CORE_REGULAR_TOOL_IDS = [ 'ocr', 'addImage', 'rotate', + 'annotate', 'scannerImageSplit', 'editTableOfContents', 'scannerEffect',