diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 4f81d64a3..5d9f08467 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -736,6 +736,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" @@ -4013,23 +4018,92 @@ 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" +desc = "Use highlight, pen, text, and notes. Changes stay live—no flattening required." +highlight = "Highlight" +pen = "Pen" +text = "Text box" +note = "Note" +rectangle = "Rectangle" +ellipse = "Ellipse" +select = "Select" +exit = "Exit annotation mode" +strokeWidth = "Width" +opacity = "Opacity" +strokeOpacity = "Stroke Opacity" +fillOpacity = "Fill Opacity" +fontSize = "Font size" +chooseColor = "Choose colour" +color = "Colour" +strokeColor = "Stroke Colour" +fillColor = "Fill Colour" +underline = "Underline" +strikeout = "Strikeout" +squiggly = "Squiggly" +inkHighlighter = "Freehand Highlighter" +freehandHighlighter = "Freehand Highlighter" +square = "Square" +circle = "Circle" +polygon = "Polygon" +line = "Line" +stamp = "Add Image" +textMarkup = "Text Markup" +drawing = "Drawing" +shapes = "Shapes" +notesStamps = "Notes & Stamps" +settings = "Settings" +borderOn = "Border: On" +borderOff = "Border: Off" +editInk = "Edit Pen" +editLine = "Edit Line" +editNote = "Edit Note" +editText = "Edit Text Box" +editTextMarkup = "Edit Text Markup" +editSelected = "Edit Annotation" +editSquare = "Edit Square" +editCircle = "Edit Circle" +editPolygon = "Edit Polygon" +unsupportedType = "This annotation type is not fully supported for editing." +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" +selectAndMove = "Select and Edit" +editSelectDescription = "Click an existing annotation to edit its colour, opacity, text, or size." +editStampHint = "To change the image, delete this stamp and add a new one." +editSwitchToSelect = "Switch to Select & Edit to edit this annotation." +undo = "Undo" +redo = "Redo" +applyChanges = "Apply Changes" [search] title = "Search PDF" diff --git a/frontend/src/core/components/AppProviders.tsx b/frontend/src/core/components/AppProviders.tsx index 5fa8d7cb9..f40bd7954 100644 --- a/frontend/src/core/components/AppProviders.tsx +++ b/frontend/src/core/components/AppProviders.tsx @@ -12,6 +12,7 @@ import { AppConfigProvider, AppConfigProviderProps, AppConfigRetryOptions } from import { RightRailProvider } from "@app/contexts/RightRailContext"; import { ViewerProvider } from "@app/contexts/ViewerContext"; import { SignatureProvider } from "@app/contexts/SignatureContext"; +import { AnnotationProvider } from "@app/contexts/AnnotationContext"; import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext"; import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext"; import { PageEditorProvider } from "@app/contexts/PageEditorContext"; @@ -95,13 +96,15 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide - - - - {children} - - - + + + + + {children} + + + + diff --git a/frontend/src/core/components/annotation/shared/ColorPicker.tsx b/frontend/src/core/components/annotation/shared/ColorPicker.tsx index 04ae501bb..21656b1f2 100644 --- a/frontend/src/core/components/annotation/shared/ColorPicker.tsx +++ b/frontend/src/core/components/annotation/shared/ColorPicker.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch } from '@mantine/core'; +import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch, Slider, Text } from '@mantine/core'; import { useTranslation } from 'react-i18next'; interface ColorPickerProps { @@ -8,6 +8,10 @@ interface ColorPickerProps { selectedColor: string; onColorChange: (color: string) => void; title?: string; + opacity?: number; + onOpacityChange?: (opacity: number) => void; + showOpacity?: boolean; + opacityLabel?: string; } export const ColorPicker: React.FC = ({ @@ -15,10 +19,15 @@ export const ColorPicker: React.FC = ({ onClose, selectedColor, onColorChange, - title + title, + opacity, + onOpacityChange, + showOpacity = false, + opacityLabel, }) => { const { t } = useTranslation(); const resolvedTitle = title ?? t('colorPicker.title', 'Choose colour'); + const resolvedOpacityLabel = opacityLabel ?? t('annotation.opacity', 'Opacity'); return ( = ({ size="lg" fullWidth /> + {showOpacity && onOpacityChange && opacity !== undefined && ( + + {resolvedOpacityLabel} + + + )} + + + + )} + + {activeTool === 'note' && ( + + {t('annotation.backgroundColor', 'Background color')} + + { + setColorPickerTarget('noteBackground'); + setIsColorPickerOpen(true); + }} + /> + + + + )} + + {['square', 'circle', 'line', 'polygon'].includes(activeTool) && ( + <> + + {t('annotation.opacity', 'Opacity')} + { + setShapeOpacity(value); + setShapeStrokeOpacity(value); + setShapeFillOpacity(value); + }} + /> + + + {activeTool === 'line' ? ( + <> + {t('annotation.strokeWidth', 'Width')} + + + ) : ( + + + {t('annotation.strokeWidth', 'Stroke')} + + + + + )} + + + )} + + )} + + + ); + + const selectedDerivedTool = selectedAnn?.object ? deriveToolFromAnnotation(selectedAnn.object) : undefined; + + const selectedAnnotationControls = selectedAnn && (() => { + const rawType = selectedAnn.object?.type; + const toolId = selectedDerivedTool ?? deriveToolFromAnnotation(selectedAnn.object); + const derivedType = + toolId === 'highlight' ? 9 + : toolId === 'underline' ? 10 + : toolId === 'squiggly' ? 11 + : toolId === 'strikeout' ? 12 + : toolId === 'line' ? 4 + : toolId === 'square' ? 5 + : toolId === 'circle' ? 6 + : toolId === 'polygon' ? 7 + : toolId === 'polyline' ? 8 + : toolId === 'text' ? 3 + : toolId === 'note' ? 3 + : toolId === 'stamp' ? 13 + : toolId === 'ink' ? 15 + : undefined; + const type = typeof rawType === 'number' ? rawType : derivedType; + + if (toolId && ['highlight', 'underline', 'strikeout', 'squiggly'].includes(toolId)) { + return ( + + + {t('annotation.editTextMarkup', 'Edit Text Markup')} + + {t('annotation.color', 'Color')} + { + setColorPickerTarget('highlight'); + setIsColorPickerOpen(true); + }} + /> + + + {t('annotation.opacity', 'Opacity')} + { + annotationApiRef?.current?.updateAnnotation?.( + selectedAnn.object?.pageIndex ?? 0, + selectedAnn.object?.id, + { opacity: value / 100 } + ); + }} + /> + + + + ); + } + + if (type === 15 || toolId === 'inkHighlighter' || toolId === 'ink') { + const isHighlighter = toolId === 'inkHighlighter'; + const thicknessValue = + selectedAnn.object?.strokeWidth ?? + selectedAnn.object?.borderWidth ?? + selectedAnn.object?.lineWidth ?? + selectedAnn.object?.thickness ?? + (isHighlighter ? freehandHighlighterWidth : inkWidth); + const colorValue = selectedAnn.object?.color ?? (isHighlighter ? highlightColor : inkColor); + const opacityValue = Math.round(((selectedAnn.object?.opacity ?? 1) * 100) || (isHighlighter ? highlightOpacity : 100)); + return ( + + + + {isHighlighter ? t('annotation.freehandHighlighter', 'Freehand Highlighter') : t('annotation.editInk', 'Edit Pen')} + + + {t('annotation.color', 'Color')} + { + setColorPickerTarget(isHighlighter ? 'highlight' : 'ink'); + setIsColorPickerOpen(true); + }} + /> + + {isHighlighter && ( + + {t('annotation.opacity', 'Opacity')} + { + setHighlightOpacity(value); + annotationApiRef?.current?.updateAnnotation?.( + selectedAnn.object?.pageIndex ?? 0, + selectedAnn.object?.id, + { opacity: value / 100 } + ); + }} + /> + + )} + + {t('annotation.strokeWidth', 'Width')} + { + annotationApiRef?.current?.updateAnnotation?.( + selectedAnn.object?.pageIndex ?? 0, + selectedAnn.object?.id, + { + strokeWidth: value, + borderWidth: value, + lineWidth: value, + thickness: value, + } + ); + if (isHighlighter) { + setFreehandHighlighterWidth?.(value); + } else { + setInkWidth(value); + } + }} + /> + + + + ); + } + + if (type === 3 || toolId === 'text' || toolId === 'note') { + const isNote = toolId === 'note'; + const selectedBackground = + selectedAnn.object?.backgroundColor ?? + (isNote ? noteBackgroundColor || '#ffffff' : textBackgroundColor || '#ffffff'); + const alignValue = selectedAnn.object?.textAlign; + const currentAlign = + typeof alignValue === 'number' + ? alignValue === 1 + ? 'center' + : alignValue === 2 + ? 'right' + : 'left' + : alignValue === 'center' + ? 'center' + : alignValue === 'right' + ? 'right' + : 'left'; + + return ( + + + {isNote ? t('annotation.editNote', 'Edit Sticky Note') : t('annotation.editText', 'Edit Text Box')} + + {t('annotation.color', 'Color')} + { + setColorPickerTarget('text'); + setIsColorPickerOpen(true); + }} + /> + + + {t('annotation.backgroundColor', 'Background color')} + + { + setColorPickerTarget(isNote ? 'noteBackground' : 'textBackground'); + setIsColorPickerOpen(true); + }} + /> + + + +