Polish and tests

This commit is contained in:
Reece 2025-12-17 13:04:51 +00:00
parent 39b55f7f19
commit aa78a41026
8 changed files with 337 additions and 51 deletions

View File

@ -4018,23 +4018,26 @@ deleteSelected = "Delete Selected Pages"
closePdf = "Close PDF"
exportAll = "Export PDF"
downloadSelected = "Download Selected Files"
downloadAll = "Download All"
saveAll = "Save All"
annotations = "Annotations"
exportSelected = "Export Selected Pages"
saveChanges = "Save Changes"
toggleTheme = "Toggle Theme"
toggleBookmarks = "Toggle Bookmarks"
language = "Language"
toggleAnnotations = "Toggle Annotations Visibility"
search = "Search PDF"
panMode = "Pan Mode"
rotateLeft = "Rotate Left"
rotateRight = "Rotate Right"
toggleSidebar = "Toggle Sidebar"
exportSelected = "Export Selected Pages"
toggleAnnotations = "Toggle Annotations Visibility"
annotationMode = "Toggle Annotation Mode"
toggleBookmarks = "Toggle Bookmarks"
print = "Print PDF"
draw = "Draw"
save = "Save"
saveChanges = "Save Changes"
downloadAll = "Download All"
saveAll = "Save All"
[textAlign]
left = "Left"
center = "Center"
right = "Right"
[annotation]
title = "Annotate"
@ -4060,6 +4063,7 @@ underline = "Underline"
strikeout = "Strikeout"
squiggly = "Squiggly"
inkHighlighter = "Freehand Highlighter"
freehandHighlighter = "Freehand Highlighter"
square = "Square"
circle = "Circle"
polygon = "Polygon"
@ -4086,6 +4090,19 @@ textAlignment = "Text Alignment"
noteIcon = "Note Icon"
imagePreview = "Preview"
contents = "Text"
backgroundColor = "Background colour"
clearBackground = "Remove background"
noBackground = "No background"
stampSettings = "Stamp Settings"
savingCopy = "Preparing download..."
saveFailed = "Unable to save copy"
saveReady = "Download ready"
resumeTooltip = "Resume placement"
resume = "Resume placement"
pauseTooltip = "Pause placement"
pause = "Pause placement"
undo = "Undo"
redo = "Redo"
[search]
title = "Search PDF"

View File

@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box } from '@mantine/core';
import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box, SegmentedControl } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ColorPicker } from '@app/components/annotation/shared/ColorPicker';
interface TextInputWithFontProps {
@ -43,6 +44,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
colorLabel,
onAnyChange
}) => {
const { t } = useTranslation();
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
const fontSizeCombobox = useCombobox();
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
@ -221,7 +223,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
{onTextAlignChange && (
<SegmentedControl
value={textAlign}
onChange={(value) => {
onChange={(value: string) => {
onTextAlignChange(value as 'left' | 'center' | 'right');
onAnyChange?.();
}}

View File

@ -126,9 +126,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
}),
// Register pan plugin (depends on Viewport, InteractionManager) - keep disabled to prevent drag panning
createPluginRegistration(PanPluginPackage, {
defaultMode: 'disabled',
}),
createPluginRegistration(PanPluginPackage, {}),
// Register zoom plugin with configuration
createPluginRegistration(ZoomPluginPackage, {
@ -265,7 +263,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
id: 'highlight',
name: 'Highlight',
interaction: { exclusive: true, cursor: 'text', textSelection: true },
matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.HIGHLIGHT ? 10 : 0),
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.HIGHLIGHT ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.HIGHLIGHT,
color: '#ffd54f',
@ -281,7 +279,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
id: 'underline',
name: 'Underline',
interaction: { exclusive: true, cursor: 'text', textSelection: true },
matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.UNDERLINE ? 10 : 0),
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.UNDERLINE ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.UNDERLINE,
color: '#ffb300',
@ -297,7 +295,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
id: 'strikeout',
name: 'Strikeout',
interaction: { exclusive: true, cursor: 'text', textSelection: true },
matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.STRIKEOUT ? 10 : 0),
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.STRIKEOUT ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.STRIKEOUT,
color: '#e53935',
@ -313,7 +311,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
id: 'squiggly',
name: 'Squiggly',
interaction: { exclusive: true, cursor: 'text', textSelection: true },
matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.SQUIGGLY ? 10 : 0),
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.SQUIGGLY ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.SQUIGGLY,
color: '#00acc1',
@ -329,7 +327,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
id: 'ink',
name: 'Pen',
interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.INK ? 10 : 0),
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.INK ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.INK,
color: '#1f2933',
@ -348,7 +346,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
id: 'inkHighlighter',
name: 'Ink Highlighter',
interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.INK && annotation.color === '#ffd54f' ? 8 : 0),
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.INK && annotation.color === '#ffd54f' ? 8 : 0),
defaults: {
type: PdfAnnotationSubtype.INK,
color: '#ffd54f',
@ -367,7 +365,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
id: 'square',
name: 'Square',
interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.SQUARE ? 10 : 0),
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.SQUARE ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.SQUARE,
color: '#0000ff', // fill color (blue)
@ -391,7 +389,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
id: 'circle',
name: 'Circle',
interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.CIRCLE ? 10 : 0),
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.CIRCLE ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.CIRCLE,
color: '#0000ff', // fill color (blue)
@ -415,7 +413,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
id: 'line',
name: 'Line',
interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.LINE ? 10 : 0),
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.LINE ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.LINE,
color: '#1565c0',
@ -439,7 +437,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
id: 'lineArrow',
name: 'Arrow',
interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.LINE && (annotation.endStyle === 'ClosedArrow' || annotation.lineEndingStyles?.end === 'ClosedArrow') ? 9 : 0),
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.LINE && (annotation.endStyle === 'ClosedArrow' || annotation.lineEndingStyles?.end === 'ClosedArrow') ? 9 : 0),
defaults: {
type: PdfAnnotationSubtype.LINE,
color: '#1565c0',
@ -464,7 +462,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
id: 'polyline',
name: 'Polyline',
interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.POLYLINE ? 10 : 0),
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.POLYLINE ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.POLYLINE,
color: '#1565c0',
@ -485,7 +483,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
id: 'polygon',
name: 'Polygon',
interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.POLYGON ? 10 : 0),
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.POLYGON ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.POLYGON,
color: '#0000ff', // fill color (blue)
@ -508,7 +506,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
id: 'text',
name: 'Text',
interaction: { exclusive: true, cursor: 'text' },
matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.FREETEXT ? 10 : 0),
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.FREETEXT ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.FREETEXT,
textColor: '#111111',
@ -528,7 +526,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
id: 'note',
name: 'Note',
interaction: { exclusive: true, cursor: 'pointer' },
matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.FREETEXT ? 8 : 0),
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.FREETEXT ? 8 : 0),
defaults: {
type: PdfAnnotationSubtype.FREETEXT,
textColor: '#1b1b1b',
@ -552,7 +550,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
id: 'stamp',
name: 'Image Stamp',
interaction: { exclusive: false, cursor: 'copy' },
matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.STAMP ? 5 : 0),
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.STAMP ? 5 : 0),
defaults: {
type: PdfAnnotationSubtype.STAMP,
},

View File

@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useMemo, useState, useEffect, useCallback } from 'react';
import { ActionIcon, Popover } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useViewer } from '@app/contexts/ViewerContext';
@ -9,6 +9,9 @@ import { SearchInterface } from '@app/components/viewer/SearchInterface';
import ViewerAnnotationControls from '@app/components/shared/rightRail/ViewerAnnotationControls';
import { useSidebarContext } from '@app/contexts/SidebarContext';
import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useNavigationState } from '@app/contexts/NavigationContext';
import { BASE_PATH, withBasePath } from '@app/constants/app';
export function useViewerRightRailButtons() {
const { t, i18n } = useTranslation();
@ -16,6 +19,32 @@ export function useViewerRightRailButtons() {
const [isPanning, setIsPanning] = useState<boolean>(() => viewer.getPanState()?.isPanning ?? false);
const { sidebarRefs } = useSidebarContext();
const { position: tooltipPosition } = useRightRailTooltipSide(sidebarRefs, 12);
const { handleToolSelect } = useToolWorkflow();
const { selectedTool } = useNavigationState();
const stripBasePath = useCallback((path: string) => {
if (BASE_PATH && path.startsWith(BASE_PATH)) {
return path.slice(BASE_PATH.length) || '/';
}
return path;
}, []);
const isAnnotationsPath = useCallback(() => {
const cleanPath = stripBasePath(window.location.pathname).toLowerCase();
return cleanPath === '/annotations' || cleanPath.endsWith('/annotations');
}, [stripBasePath]);
const [isAnnotationsActive, setIsAnnotationsActive] = useState<boolean>(() => isAnnotationsPath());
useEffect(() => {
setIsAnnotationsActive(isAnnotationsPath());
}, [selectedTool, isAnnotationsPath]);
useEffect(() => {
const handlePopState = () => setIsAnnotationsActive(isAnnotationsPath());
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [isAnnotationsPath]);
// Lift i18n labels out of memo for clarity
const searchLabel = t('rightRail.search', 'Search PDF');
@ -25,6 +54,7 @@ export function useViewerRightRailButtons() {
const sidebarLabel = t('rightRail.toggleSidebar', 'Toggle Sidebar');
const bookmarkLabel = t('rightRail.toggleBookmarks', 'Toggle Bookmarks');
const printLabel = t('rightRail.print', 'Print PDF');
const annotationsLabel = t('rightRail.annotations', 'Annotations');
const viewerButtons = useMemo<RightRailButtonWithAction[]>(() => {
return [
@ -147,6 +177,36 @@ export function useViewerRightRailButtons() {
viewer.printActions.print();
}
},
{
id: 'viewer-annotations',
tooltip: annotationsLabel,
ariaLabel: annotationsLabel,
section: 'top' as const,
order: 58,
render: ({ disabled }) => (
<Tooltip content={annotationsLabel} position={tooltipPosition} offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant={isAnnotationsActive ? 'default' : 'subtle'}
radius="md"
className="right-rail-icon"
onClick={() => {
if (disabled || isAnnotationsActive) return;
const targetPath = withBasePath('/annotations');
if (window.location.pathname !== targetPath) {
window.history.pushState(null, '', targetPath);
}
setIsAnnotationsActive(true);
handleToolSelect('annotate');
}}
disabled={disabled || isAnnotationsActive}
aria-pressed={isAnnotationsActive}
style={isAnnotationsActive ? { backgroundColor: 'var(--right-rail-pan-active-bg)' } : undefined}
>
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
)
},
{
id: 'viewer-annotation-controls',
section: 'top' as const,
@ -156,7 +216,7 @@ export function useViewerRightRailButtons() {
)
}
];
}, [t, i18n.language, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel, bookmarkLabel, printLabel, tooltipPosition]);
}, [t, i18n.language, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel, bookmarkLabel, printLabel, tooltipPosition, annotationsLabel, isAnnotationsActive, handleToolSelect]);
useRightRailButtons(viewerButtons);
}

View File

@ -66,10 +66,12 @@ export type AnnotationSelection = unknown;
export interface AnnotationToolOptions {
color?: string;
fillColor?: string;
strokeColor?: string;
opacity?: number;
strokeOpacity?: number;
fillOpacity?: number;
thickness?: number;
borderWidth?: number;
fontSize?: number;
fontFamily?: string;
textAlign?: number; // 0 = Left, 1 = Center, 2 = Right

View File

@ -253,7 +253,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
component: Annotate,
description: t('home.annotate.desc', 'Highlight, draw, add notes, and shapes directly in the viewer'),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.EDIT,
subcategoryId: SubcategoryId.GENERAL,
workbench: 'viewer',
operationConfig: signOperationConfig,
automationSettings: null,

View File

@ -1,6 +1,6 @@
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 { Text, Group, ActionIcon, Stack, Slider, Box, Tooltip as MantineTooltip, Button, Textarea, Tooltip, Paper } from '@mantine/core';
import { alert as showToast, updateToast } from '@app/components/toast';
import { createToolFlow } from '@app/components/tools/shared/createToolFlow';
@ -53,7 +53,7 @@ const Annotate = (_props: BaseToolProps) => {
setSignatureConfig,
setPlacementMode,
placementPreviewSize,
activateSignaturePlacementMode,
activateSignaturePlacementMode: _activateSignaturePlacementMode,
setPlacementPreviewSize,
} = useSignature();
const viewerContext = useContext(ViewerContext);
@ -72,6 +72,8 @@ const Annotate = (_props: BaseToolProps) => {
const [squigglyColor, setSquigglyColor] = useState('#00acc1');
const [squigglyOpacity, setSquigglyOpacity] = useState(100);
const [textColor, setTextColor] = useState('#111111');
const [textBackgroundColor, setTextBackgroundColor] = useState<string>('');
const [noteBackgroundColor, setNoteBackgroundColor] = useState('#ffd54f');
const [textSize, setTextSize] = useState(14);
const [textAlignment, setTextAlignment] = useState<'left' | 'center' | 'right'>('left');
const [shapeStrokeColor, setShapeStrokeColor] = useState('#cf5b5b');
@ -80,7 +82,9 @@ const Annotate = (_props: BaseToolProps) => {
const [shapeStrokeOpacity, setShapeStrokeOpacity] = useState(50);
const [shapeFillOpacity, setShapeFillOpacity] = useState(50);
const [shapeThickness, setShapeThickness] = useState(1);
const [colorPickerTarget, setColorPickerTarget] = useState<'ink' | 'highlight' | 'underline' | 'strikeout' | 'squiggly' | 'text' | 'shapeStroke' | 'shapeFill' | null>(null);
const [colorPickerTarget, setColorPickerTarget] = useState<
'ink' | 'highlight' | 'inkHighlighter' | 'underline' | 'strikeout' | 'squiggly' | 'text' | 'textBackground' | 'noteBackground' | 'shapeStroke' | 'shapeFill' | null
>(null);
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
const [selectedAnn, setSelectedAnn] = useState<any | null>(null);
const [selectedAnnId, setSelectedAnnId] = useState<string | null>(null);
@ -174,16 +178,25 @@ const Annotate = (_props: BaseToolProps) => {
return { color: strikeoutColor, opacity: strikeoutOpacity / 100, ...metadata };
case 'squiggly':
return { color: squigglyColor, opacity: squigglyOpacity / 100, ...metadata };
case 'text':
case 'text': {
const textAlignNumber = textAlignment === 'left' ? 0 : textAlignment === 'center' ? 1 : 2;
return { color: textColor, fontSize: textSize, textAlign: textAlignNumber, ...metadata };
case 'note':
return {
color: textColor,
fontSize: textSize,
textAlign: textAlignNumber,
...(textBackgroundColor ? { fillColor: textBackgroundColor } : {}),
...metadata,
};
}
case 'note': {
const noteFillColor = noteBackgroundColor || 'transparent';
return {
color: textColor, // text color
fillColor: highlightColor, // background color, shares highlight picker defaults
fillColor: noteFillColor,
opacity: 1,
...metadata,
};
}
case 'square':
case 'circle':
case 'polygon':
@ -217,7 +230,33 @@ const Annotate = (_props: BaseToolProps) => {
default:
return {};
}
}, [highlightColor, highlightOpacity, inkColor, inkWidth, freehandHighlighterWidth, underlineColor, underlineOpacity, strikeoutColor, strikeoutOpacity, squigglyColor, squigglyOpacity, textColor, textSize, textAlignment, shapeStrokeColor, shapeFillColor, shapeOpacity, shapeStrokeOpacity, shapeFillOpacity, shapeThickness, stampImageData, stampImageSize, cssToPdfSize]);
}, [
highlightColor,
highlightOpacity,
inkColor,
inkWidth,
freehandHighlighterWidth,
underlineColor,
underlineOpacity,
strikeoutColor,
strikeoutOpacity,
squigglyColor,
squigglyOpacity,
textColor,
textSize,
textAlignment,
textBackgroundColor,
noteBackgroundColor,
shapeStrokeColor,
shapeFillColor,
shapeOpacity,
shapeStrokeOpacity,
shapeFillOpacity,
shapeThickness,
stampImageData,
stampImageSize,
cssToPdfSize,
]);
useEffect(() => {
setToolAndWorkbench('annotate', 'viewer');
@ -433,6 +472,7 @@ const Annotate = (_props: BaseToolProps) => {
const applySelectionFromAnnotation = useCallback((ann: any | null) => {
const annObject = ann?.object ?? ann ?? null;
const type = annObject?.type;
const annId = annObject?.id ?? null;
selectedAnnIdRef.current = annId;
setSelectedAnnId(annId);
@ -444,8 +484,21 @@ const Annotate = (_props: BaseToolProps) => {
if (annObject?.fontSize !== undefined) {
setSelectedFontSize(annObject.fontSize ?? 14);
}
if (type === 3) {
const derivedTool = deriveToolFromAnnotation(annObject);
const background = annObject?.backgroundColor as string | undefined;
if (annObject?.textColor) {
setTextColor(annObject.textColor);
}
if (derivedTool === 'note') {
if (background) {
setNoteBackgroundColor(background);
}
} else {
setTextBackgroundColor(background || '');
}
}
// 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);
@ -536,7 +589,7 @@ const Annotate = (_props: BaseToolProps) => {
const otherTools: { id: AnnotationToolId; label: string; icon: string }[] = [
{ id: 'text', label: t('annotation.text', 'Text box'), icon: 'text-fields' },
{ id: 'note', label: t('annotation.note', 'Note'), icon: 'sticky-note-2' },
// { id: 'note', label: t('annotation.note', 'Note'), icon: 'sticky-note-2' },
{ id: 'stamp', label: t('annotation.stamp', 'Add Image'), icon: 'add-photo-alternate' },
];
@ -550,12 +603,16 @@ const Annotate = (_props: BaseToolProps) => {
: colorPickerTarget === 'strikeout'
? strikeoutColor
: colorPickerTarget === 'squiggly'
? squigglyColor
: colorPickerTarget === 'shapeStroke'
? shapeStrokeColor
: colorPickerTarget === 'shapeFill'
? squigglyColor
: colorPickerTarget === 'shapeStroke'
? shapeStrokeColor
: colorPickerTarget === 'shapeFill'
? shapeFillColor
: textColor;
: colorPickerTarget === 'textBackground'
? (textBackgroundColor || '#ffffff')
: colorPickerTarget === 'noteBackground'
? (noteBackgroundColor || '#ffffff')
: textColor;
const steps = useMemo(() => {
if (selectedFiles.length === 0) return [];
@ -767,9 +824,72 @@ const Annotate = (_props: BaseToolProps) => {
</ActionIcon>
</Group>
</Box>
<Box>
<Text size="xs" c="dimmed" mb={4}>{t('annotation.backgroundColor', 'Background color')}</Text>
<Group gap="xs" align="center">
<ColorSwatchButton
color={textBackgroundColor || '#ffffff'}
size={30}
onClick={() => {
setColorPickerTarget('textBackground');
setIsColorPickerOpen(true);
}}
/>
<Button
size="xs"
variant={textBackgroundColor ? 'light' : 'default'}
onClick={() => {
setTextBackgroundColor('');
annotationApiRef?.current?.setAnnotationStyle?.('text', buildToolOptions('text'));
if (selectedAnn?.object?.type === 3 && deriveToolFromAnnotation(selectedAnn.object) !== 'note') {
annotationApiRef?.current?.updateAnnotation?.(
selectedAnn.object?.pageIndex ?? 0,
selectedAnn.object?.id,
{ backgroundColor: 'transparent', fillColor: 'transparent' }
);
}
}}
>
{textBackgroundColor ? t('annotation.clearBackground', 'Remove background') : t('annotation.noBackground', 'No background')}
</Button>
</Group>
</Box>
</>
)}
{activeTool === 'note' && (
<Box>
<Text size="xs" c="dimmed" mb={4}>{t('annotation.backgroundColor', 'Background color')}</Text>
<Group gap="xs" align="center">
<ColorSwatchButton
color={noteBackgroundColor || '#ffffff'}
size={30}
onClick={() => {
setColorPickerTarget('noteBackground');
setIsColorPickerOpen(true);
}}
/>
<Button
size="xs"
variant={noteBackgroundColor ? 'light' : 'default'}
onClick={() => {
setNoteBackgroundColor('');
annotationApiRef?.current?.setAnnotationStyle?.('note', buildToolOptions('note'));
if (selectedAnn?.object?.type === 3 && deriveToolFromAnnotation(selectedAnn.object) === 'note') {
annotationApiRef?.current?.updateAnnotation?.(
selectedAnn.object?.pageIndex ?? 0,
selectedAnn.object?.id,
{ backgroundColor: 'transparent', fillColor: 'transparent' }
);
}
}}
>
{noteBackgroundColor ? t('annotation.clearBackground', 'Remove background') : t('annotation.noBackground', 'No background')}
</Button>
</Group>
</Box>
)}
{['square', 'circle', 'line', 'polygon'].includes(activeTool) && (
<>
<Box>
@ -895,10 +1015,15 @@ const Annotate = (_props: BaseToolProps) => {
// Type 3: Text box
if (type === 3) {
const derivedTool = deriveToolFromAnnotation(selectedAnn.object);
const isNote = derivedTool === 'note';
const selectedBackground =
selectedAnn.object?.backgroundColor ??
(isNote ? noteBackgroundColor || '#ffffff' : textBackgroundColor || '#ffffff');
return (
<Paper withBorder p="sm" radius="md">
<Stack gap="sm">
<Text size="sm" fw={600}>{t('annotation.editText', 'Edit Text Box')}</Text>
<Text size="sm" fw={600}>{isNote ? t('annotation.editNote', 'Edit Sticky Note') : t('annotation.editText', 'Edit Text Box')}</Text>
<Box>
<Text size="xs" c="dimmed" mb={4}>{t('annotation.color', 'Color')}</Text>
<ColorSwatchButton
@ -910,6 +1035,37 @@ const Annotate = (_props: BaseToolProps) => {
}}
/>
</Box>
<Box>
<Text size="xs" c="dimmed" mb={4}>{t('annotation.backgroundColor', 'Background color')}</Text>
<Group gap="xs" align="center">
<ColorSwatchButton
color={selectedBackground}
size={28}
onClick={() => {
setColorPickerTarget(isNote ? 'noteBackground' : 'textBackground');
setIsColorPickerOpen(true);
}}
/>
<Button
size="xs"
variant={selectedAnn.object?.backgroundColor ? 'light' : 'default'}
onClick={() => {
if (isNote) {
setNoteBackgroundColor('');
} else {
setTextBackgroundColor('');
}
annotationApiRef?.current?.updateAnnotation?.(
selectedAnn.object?.pageIndex ?? 0,
selectedAnn.object?.id,
{ backgroundColor: 'transparent', fillColor: 'transparent' }
);
}}
>
{t('annotation.clearBackground', 'Remove background')}
</Button>
</Group>
</Box>
<Textarea
label={t('annotation.text', 'Text')}
value={selectedTextDraft}
@ -1194,7 +1350,14 @@ const Annotate = (_props: BaseToolProps) => {
isOpen={isColorPickerOpen}
onClose={() => setIsColorPickerOpen(false)}
selectedColor={activeColor}
showOpacity={colorPickerTarget !== 'text' && colorPickerTarget !== 'shapeStroke' && colorPickerTarget !== 'shapeFill' && colorPickerTarget !== null}
showOpacity={
colorPickerTarget !== 'text' &&
colorPickerTarget !== 'textBackground' &&
colorPickerTarget !== 'noteBackground' &&
colorPickerTarget !== 'shapeStroke' &&
colorPickerTarget !== 'shapeFill' &&
colorPickerTarget !== null
}
opacity={
colorPickerTarget === 'highlight' ? highlightOpacity :
colorPickerTarget === 'underline' ? underlineOpacity :
@ -1285,6 +1448,30 @@ const Annotate = (_props: BaseToolProps) => {
if (selectedAnn?.object?.id) {
annotationApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id, { color });
}
} else if (colorPickerTarget === 'textBackground') {
setTextBackgroundColor(color);
if (activeTool === 'text') {
annotationApiRef?.current?.setAnnotationStyle?.('text', buildToolOptions('text'));
}
if (selectedAnn?.object?.type === 3 && deriveToolFromAnnotation(selectedAnn.object) !== 'note') {
annotationApiRef?.current?.updateAnnotation?.(
selectedAnn.object?.pageIndex ?? 0,
selectedAnn.object?.id,
{ backgroundColor: color, fillColor: color }
);
}
} else if (colorPickerTarget === 'noteBackground') {
setNoteBackgroundColor(color);
if (activeTool === 'note') {
annotationApiRef?.current?.setAnnotationStyle?.('note', buildToolOptions('note'));
}
if (selectedAnn?.object?.type === 3 && deriveToolFromAnnotation(selectedAnn.object) === 'note') {
annotationApiRef?.current?.updateAnnotation?.(
selectedAnn.object?.pageIndex ?? 0,
selectedAnn.object?.id,
{ backgroundColor: color, fillColor: color }
);
}
} else {
setTextColor(color);
if (activeTool === 'text') {
@ -1449,7 +1636,7 @@ const Annotate = (_props: BaseToolProps) => {
{colorPickerComponent}
{/* Suggested Tools */}
<SuggestedToolsSection currentTool="annotate" />
<SuggestedToolsSection />
</Stack>
),
},
@ -1470,8 +1657,13 @@ const Annotate = (_props: BaseToolProps) => {
squigglyOpacity,
inkColor,
inkWidth,
textBackgroundColor,
noteBackgroundColor,
textAlignment,
freehandHighlighterWidth,
shapeStrokeColor,
shapeFillColor,
shapeOpacity,
shapeStrokeOpacity,
shapeFillOpacity,
shapeThickness,
@ -1504,7 +1696,20 @@ const Annotate = (_props: BaseToolProps) => {
steps,
review: {
isVisible: false,
operation: { files: [], downloadUrl: null },
operation: {
files: [],
thumbnails: [],
isGeneratingThumbnails: false,
downloadUrl: null,
downloadFilename: '',
isLoading: false,
status: '',
errorMessage: null,
progress: null,
executeOperation: async () => {},
resetResults: () => {},
clearError: () => {},
},
title: '',
onFileClick: () => {},
onUndo: () => {},

View File

@ -70,6 +70,8 @@ export const URL_TO_TOOL_MAP: Record<string, ToolId> = {
'/scanner-image-split': 'scannerImageSplit',
// Annotation and content removal
'/annotations': 'annotate',
'/annotate': 'annotate',
'/remove-annotations': 'removeAnnotations',
'/remove-image': 'removeImage',