Annotations set up

This commit is contained in:
Reece 2025-12-02 14:24:12 +00:00
parent f3cc30d0c2
commit 9f54df290d
9 changed files with 1154 additions and 116 deletions

View File

@ -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..."

View File

@ -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
</ActionIcon>
</Tooltip>
{/* Annotation Mode Toggle with Drawing Controls */}
{viewerContext?.isAnnotationMode ? (
// When active: Show color picker on hover
<div
onMouseEnter={() => setIsHoverColorPickerOpen(true)}
onMouseLeave={() => setIsHoverColorPickerOpen(false)}
style={{ display: 'inline-flex' }}
{/* Launch Annotate tool in the left panel */}
<Tooltip content={t('rightRail.draw', 'Draw')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => setToolAndWorkbench('annotate', 'viewer')}
disabled={disabled}
aria-label={typeof t === 'function' ? t('rightRail.draw', 'Draw') : 'Draw'}
>
<Popover
opened={isHoverColorPickerOpen}
onClose={() => setIsHoverColorPickerOpen(false)}
position="left"
withArrow
shadow="md"
offset={8}
>
<Popover.Target>
<ActionIcon
variant="filled"
color="blue"
radius="md"
className="right-rail-icon"
onClick={() => {
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"
>
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown>
<div style={{ minWidth: '8rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem', padding: '0.5rem' }}>
<div style={{ fontSize: '0.8rem', fontWeight: 500 }}>Drawing Color</div>
<ColorSwatchButton
color={selectedColor}
size={32}
onClick={() => {
setIsHoverColorPickerOpen(false); // Close hover picker
setIsColorPickerOpen(true); // Open main color picker modal
}}
/>
</div>
</div>
</Popover.Dropdown>
</Popover>
</div>
) : (
// When inactive: Show "Draw" tooltip
<Tooltip content={t('rightRail.draw', 'Draw')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => {
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'}
>
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
)}
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
{/* Save PDF with Annotations */}
<Tooltip content={t('rightRail.save', 'Save')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
@ -213,25 +148,6 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
<LocalIcon icon="save" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
{/* Color Picker Modal */}
<ColorPicker
isOpen={isColorPickerOpen}
onClose={() => 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"
/>
</>
);
}

View File

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

View File

@ -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' },

View File

@ -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<SignatureAPI>(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<SignatureAPI>(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<any>) => {
annotationApi?.updateAnnotation?.(pageIndex, annotationId, patch);
},
}), [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults, configureAnnotationTool, buildAnnotationDefaults]);
useEffect(() => {
if (!annotationApi?.onAnnotationEvent) {

View File

@ -14,6 +14,10 @@ export interface SignatureAPI {
updateDrawSettings: (color: string, size: number) => void;
deactivateTools: () => void;
getPageAnnotations: (pageIndex: number) => Promise<any[]>;
activateAnnotationTool?: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => void;
setAnnotationStyle?: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => void;
getSelectedAnnotation?: () => any | null;
updateAnnotation?: (pageIndex: number, annotationId: string, patch: Partial<any>) => 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;
}

View File

@ -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: <LocalIcon icon="edit" width="1.5rem" height="1.5rem" />,
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

View File

@ -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<AnnotationToolId>('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<any | null>(null);
const [selectedAnnId, setSelectedAnnId] = useState<string | null>(null);
const [selectedTextDraft, setSelectedTextDraft] = useState<string>('');
const [selectedFontSize, setSelectedFontSize] = useState<number>(14);
const selectedUpdateTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const stampInputRef = useRef<HTMLInputElement | null>(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 = (
<Group gap="xs">
{annotationTools.map((tool) => (
<MantineTooltip key={tool.id} label={tool.label} withArrow>
<ActionIcon
variant={activeTool === tool.id ? 'filled' : 'subtle'}
color={activeTool === tool.id ? 'blue' : undefined}
radius="md"
onClick={() => activateAnnotationTool(tool.id)}
aria-label={tool.label}
>
<LocalIcon icon={tool.icon} width="1.25rem" height="1.25rem" />
</ActionIcon>
</MantineTooltip>
))}
</Group>
);
const controls = (
<Stack gap="sm">
<Group gap="sm" align="center">
<input
ref={stampInputRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={async (e) => {
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 = '';
}
}}
/>
<ColorSwatchButton
color={
activeTool === 'ink'
? inkColor
: activeTool === 'highlight' || activeTool === 'inkHighlighter'
? highlightColor
: activeTool === 'underline'
? underlineColor
: activeTool === 'strikeout'
? strikeoutColor
: activeTool === 'squiggly'
? squigglyColor
: shapeStrokeColor
}
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', 'lineArrow', 'polyline', 'polygon'].includes(activeTool)
? 'shapeStroke'
: 'text';
setColorPickerTarget(target);
setIsColorPickerOpen(true);
}}
/>
{['square', 'circle', 'polygon'].includes(activeTool) && (
<ColorSwatchButton
color={shapeFillColor}
size={30}
onClick={() => {
setColorPickerTarget('shapeFill');
setIsColorPickerOpen(true);
}}
/>
)}
{activeTool === 'ink' && (
<>
<Text size="sm" c="dimmed">{t('annotation.strokeWidth', 'Width')}</Text>
<Slider min={1} max={12} value={inkWidth} onChange={setInkWidth} w={140} />
</>
)}
{(activeTool === 'highlight' || activeTool === 'inkHighlighter') && (
<>
<Text size="sm" c="dimmed">{t('annotation.opacity', 'Opacity')}</Text>
<Slider min={10} max={100} value={highlightOpacity} onChange={setHighlightOpacity} w={140} />
</>
)}
{activeTool === 'text' && (
<>
<Text size="sm" c="dimmed">{t('annotation.fontSize', 'Font size')}</Text>
<Slider min={8} max={32} value={textSize} onChange={setTextSize} w={140} />
</>
)}
{['square', 'circle', 'line', 'lineArrow', 'polyline', 'polygon'].includes(activeTool) && (
<>
<Text size="sm" c="dimmed">{t('annotation.opacity', 'Opacity')}</Text>
<Slider min={10} max={100} value={shapeOpacity} onChange={setShapeOpacity} w={140} />
<Text size="sm" c="dimmed">{t('annotation.strokeWidth', 'Stroke')}</Text>
<Slider min={1} max={12} value={shapeThickness} onChange={setShapeThickness} w={140} />
</>
)}
</Group>
<Divider />
<Text size="sm" c="dimmed">
{t('annotation.tipPlace', 'Click anywhere on the PDF to place highlights, drawings, notes, or text.')}
</Text>
{selectedAnn && (
<Stack gap="xs">
<Text size="sm" fw={600}>{t('annotation.editSelected', 'Edit selected annotation')}</Text>
{(selectedAnn.object?.type === 9 || selectedAnn.object?.type === 1 || selectedAnn.object?.type === 3 || selectedAnn.object?.type === 15) && (
<>
<Text size="xs" c="dimmed">{t('annotation.opacity', 'Opacity')}</Text>
<Slider
min={10}
max={100}
value={Math.round(((selectedAnn.object?.opacity ?? 1) * 100) || 100)}
onChange={(value) => {
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) && (
<ColorSwatchButton
color={selectedAnn.object?.color ?? selectedAnn.object?.textColor ?? highlightColor}
size={28}
onClick={() => {
setColorPickerTarget('highlight');
setIsColorPickerOpen(true);
}}
/>
)}
{(selectedAnn.object?.type === 3 || selectedAnn.object?.type === 1) && (
<>
<TextInput
label={t('annotation.text', 'Text')}
value={selectedTextDraft}
onChange={(e) => {
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 && (
<NumberInput
label={t('annotation.fontSize', 'Font size')}
min={6}
max={72}
value={selectedFontSize}
onChange={(val) => {
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)) && (
<>
<Text size="xs" c="dimmed">{t('annotation.opacity', 'Opacity')}</Text>
<Slider
min={10}
max={100}
value={Math.round(((selectedAnn.object?.opacity ?? 1) * 100) || 100)}
onChange={(value) => {
signatureApiRef?.current?.updateAnnotation?.(
selectedAnn.object?.pageIndex ?? 0,
selectedAnn.object?.id,
{ opacity: value / 100 }
);
}}
/>
<Text size="xs" c="dimmed">{t('annotation.strokeWidth', 'Stroke')}</Text>
<Slider
min={1}
max={12}
value={selectedAnn.object?.borderWidth ?? shapeThickness}
onChange={(value) => {
signatureApiRef?.current?.updateAnnotation?.(
selectedAnn.object?.pageIndex ?? 0,
selectedAnn.object?.id,
{ borderWidth: value }
);
setShapeThickness(value);
}}
/>
<Group gap="xs">
<ColorSwatchButton
color={selectedAnn.object?.color ?? shapeStrokeColor}
size={28}
onClick={() => {
setColorPickerTarget('shapeStroke');
setIsColorPickerOpen(true);
}}
/>
{['4','5','7','8','12'].includes(String(selectedAnn.object?.type)) && (
<ColorSwatchButton
color={selectedAnn.object?.interiorColor ?? shapeFillColor}
size={28}
onClick={() => {
setColorPickerTarget('shapeFill');
setIsColorPickerOpen(true);
}}
/>
)}
</Group>
</>
)}
</Stack>
)}
<Button
variant="light"
leftSection={<LocalIcon icon="save" width="1rem" height="1rem" />}
onClick={async () => {
try {
const pdfArrayBuffer = await viewerContext?.exportActions?.saveAsCopy?.();
if (!pdfArrayBuffer) return;
const blob = new Blob([pdfArrayBuffer], { type: 'application/pdf' });
const fileName = selectors.getFiles()[0]?.name || 'annotated.pdf';
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = fileName;
link.click();
URL.revokeObjectURL(link.href);
} catch (error) {
console.error('Failed to save annotated PDF', error);
}
}}
>
{t('rightRail.save', 'Save')}
</Button>
<ColorPicker
isOpen={isColorPickerOpen}
onClose={() => 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')}
/>
</Stack>
);
return [
{
title: t('annotation.title', 'Annotate'),
isCollapsed: false,
onCollapsedClick: undefined,
content: (
<Stack gap="md">
<Alert color="blue" radius="md">
<Text size="sm" fw={600}>
{t('annotation.desc', 'Use highlight, pen, text, and notes. Changes stay live—no flattening required.')}
</Text>
</Alert>
<Box>
<Text size="sm" mb="xs" fw={600}>{t('annotation.title', 'Annotate')}</Text>
{toolButtons}
</Box>
{controls}
</Stack>
),
},
];
}, [
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;

View File

@ -25,6 +25,7 @@ export const CORE_REGULAR_TOOL_IDS = [
'ocr',
'addImage',
'rotate',
'annotate',
'scannerImageSplit',
'editTableOfContents',
'scannerEffect',