Chore/v2/improve annotation UI (#5724)

This commit is contained in:
Reece Browne
2026-02-16 22:01:15 +00:00
committed by GitHub
parent 558c75a2b1
commit 757a666f5e
10 changed files with 1088 additions and 584 deletions
-1
View File
@@ -372,7 +372,6 @@ const Annotate = (_props: BaseToolProps) => {
annotationApiRef,
deriveToolFromAnnotation,
activeToolRef,
manualToolSwitch,
setActiveTool,
setSelectedTextDraft,
setSelectedFontSize,
@@ -1,7 +1,7 @@
import { useMemo, useRef, useState } from 'react';
import { useMemo, useState } from 'react';
import type React from 'react';
import { useTranslation } from 'react-i18next';
import { Text, Group, ActionIcon, Stack, Slider, Box, Tooltip as MantineTooltip, Button, Textarea, Tooltip, Paper } from '@mantine/core';
import { Text, Group, ActionIcon, Stack, Slider, Box, Tooltip as MantineTooltip, Button, Tooltip, Paper } from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { ColorPicker, ColorSwatchButton } from '@app/components/annotation/shared/ColorPicker';
import { ImageUploader } from '@app/components/annotation/shared/ImageUploader';
@@ -111,7 +111,6 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
const { t } = useTranslation();
const [colorPickerTarget, setColorPickerTarget] = useState<ColorTarget>(null);
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
const selectedUpdateTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const {
activeTool,
@@ -122,10 +121,6 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
buildToolOptions,
deriveToolFromAnnotation,
selectedAnn,
selectedTextDraft,
setSelectedTextDraft,
selectedFontSize,
setSelectedFontSize,
annotationApiRef,
viewerContext,
setPlacementMode,
@@ -549,512 +544,6 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
</Paper>
);
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 (
<Paper withBorder p="sm" radius="md">
<Stack gap="sm">
<Text size="sm" fw={600}>{t('annotation.editTextMarkup', 'Edit Text Markup')}</Text>
<Box>
<Text size="xs" c="dimmed" mb={4}>{t('annotation.color', 'Color')}</Text>
<ColorSwatchButton
color={selectedAnn.object?.color ?? highlightColor}
size={28}
onClick={() => {
setColorPickerTarget('highlight');
setIsColorPickerOpen(true);
}}
/>
</Box>
<Box>
<Text size="xs" c="dimmed" mb={4}>{t('annotation.opacity', 'Opacity')}</Text>
<Slider
min={10}
max={100}
value={Math.round(((selectedAnn.object?.opacity ?? 1) * 100) || 100)}
onChange={(value) => {
annotationApiRef?.current?.updateAnnotation?.(
selectedAnn.object?.pageIndex ?? 0,
selectedAnn.object?.id,
{ opacity: value / 100 }
);
}}
/>
</Box>
</Stack>
</Paper>
);
}
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 (
<Paper withBorder p="sm" radius="md">
<Stack gap="sm">
<Text size="sm" fw={600}>
{isHighlighter ? t('annotation.freehandHighlighter', 'Freehand Highlighter') : t('annotation.editInk', 'Edit Pen')}
</Text>
<Box>
<Text size="xs" c="dimmed" mb={4}>{t('annotation.color', 'Color')}</Text>
<ColorSwatchButton
color={colorValue}
size={28}
onClick={() => {
setColorPickerTarget(isHighlighter ? 'highlight' : 'ink');
setIsColorPickerOpen(true);
}}
/>
</Box>
{isHighlighter && (
<Box>
<Text size="xs" c="dimmed" mb={4}>{t('annotation.opacity', 'Opacity')}</Text>
<Slider
min={10}
max={100}
value={opacityValue}
onChange={(value) => {
setHighlightOpacity(value);
annotationApiRef?.current?.updateAnnotation?.(
selectedAnn.object?.pageIndex ?? 0,
selectedAnn.object?.id,
{ opacity: value / 100 }
);
}}
/>
</Box>
)}
<Box>
<Text size="xs" c="dimmed" mb={4}>{t('annotation.strokeWidth', 'Width')}</Text>
<Slider
min={1}
max={isHighlighter ? 20 : 12}
value={thicknessValue}
onChange={(value) => {
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);
}
}}
/>
</Box>
</Stack>
</Paper>
);
}
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 (
<Paper withBorder p="sm" radius="md">
<Stack gap="sm">
<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
color={selectedAnn.object?.textColor ?? selectedAnn.object?.color ?? textColor}
size={28}
onClick={() => {
setColorPickerTarget('text');
setIsColorPickerOpen(true);
}}
/>
</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}
minRows={3}
maxRows={8}
autosize
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
const target = e.currentTarget;
const start = target.selectionStart;
const end = target.selectionEnd;
const val = selectedTextDraft;
const newVal = val.substring(0, start) + '\r\n' + val.substring(end);
setSelectedTextDraft(newVal);
setTimeout(() => {
target.selectionStart = target.selectionEnd = start + 2;
}, 0);
if (selectedUpdateTimer.current) {
clearTimeout(selectedUpdateTimer.current);
}
selectedUpdateTimer.current = setTimeout(() => {
annotationApiRef?.current?.updateAnnotation?.(
selectedAnn.object?.pageIndex ?? 0,
selectedAnn.object?.id,
{ contents: newVal, textColor: selectedAnn.object?.textColor ?? textColor }
);
}, 120);
}
}}
onChange={(e) => {
const val = e.currentTarget.value;
setSelectedTextDraft(val);
if (selectedUpdateTimer.current) {
clearTimeout(selectedUpdateTimer.current);
}
selectedUpdateTimer.current = setTimeout(() => {
annotationApiRef?.current?.updateAnnotation?.(
selectedAnn.object?.pageIndex ?? 0,
selectedAnn.object?.id,
{ contents: val, textColor: selectedAnn.object?.textColor ?? textColor }
);
}, 120);
}}
/>
<Box>
<Text size="xs" c="dimmed" mb={4}>{t('annotation.fontSize', 'Font size')}</Text>
<Slider
min={8}
max={32}
value={selectedFontSize}
onChange={(size) => {
setSelectedFontSize(size);
annotationApiRef?.current?.updateAnnotation?.(
selectedAnn.object?.pageIndex ?? 0,
selectedAnn.object?.id,
{ fontSize: size }
);
}}
/>
</Box>
<Box>
<Text size="xs" c="dimmed" mb={4}>{t('annotation.textAlignment', 'Text Alignment')}</Text>
<Group gap="xs">
<ActionIcon
variant={currentAlign === 'left' ? 'filled' : 'default'}
onClick={() => {
setTextAlignment('left');
annotationApiRef?.current?.updateAnnotation?.(
selectedAnn.object?.pageIndex ?? 0,
selectedAnn.object?.id,
{ textAlign: 0 }
);
}}
size="md"
>
<LocalIcon icon="format-align-left" width={18} height={18} />
</ActionIcon>
<ActionIcon
variant={currentAlign === 'center' ? 'filled' : 'default'}
onClick={() => {
setTextAlignment('center');
annotationApiRef?.current?.updateAnnotation?.(
selectedAnn.object?.pageIndex ?? 0,
selectedAnn.object?.id,
{ textAlign: 1 }
);
}}
size="md"
>
<LocalIcon icon="format-align-center" width={18} height={18} />
</ActionIcon>
<ActionIcon
variant={currentAlign === 'right' ? 'filled' : 'default'}
onClick={() => {
setTextAlignment('right');
annotationApiRef?.current?.updateAnnotation?.(
selectedAnn.object?.pageIndex ?? 0,
selectedAnn.object?.id,
{ textAlign: 2 }
);
}}
size="md"
>
<LocalIcon icon="format-align-right" width={18} height={18} />
</ActionIcon>
</Group>
</Box>
</Stack>
</Paper>
);
}
if (type === 13 || toolId === 'stamp') {
const imageSrc = selectedAnn.object?.imageSrc || selectedAnn.object?.data || selectedAnn.object?.url;
return (
<Paper withBorder p="sm" radius="md">
<Stack gap="sm">
<Text size="sm" fw={600}>{t('annotation.stamp', 'Add Image')}</Text>
{imageSrc ? (
<Stack gap="xs">
<Text size="xs" c="dimmed">{t('annotation.imagePreview', 'Preview')}</Text>
<img
src={imageSrc}
alt={t('annotation.stamp', 'Add Image')}
style={{ maxWidth: '100%', maxHeight: '180px', objectFit: 'contain', border: '1px solid #ccc', borderRadius: '4px' }}
/>
</Stack>
) : (
<Text size="xs" c="dimmed">
{t('annotation.unsupportedType', 'This annotation type is not fully supported for editing.')}
</Text>
)}
<Text size="xs" c="dimmed">
{t('annotation.editStampHint', 'To change the image, delete this stamp and add a new one.')}
</Text>
</Stack>
</Paper>
);
}
if ((type !== undefined && [4, 8].includes(type)) || toolId === 'line' || toolId === 'polyline') {
return (
<Paper withBorder p="sm" radius="md">
<Stack gap="sm">
<Text size="sm" fw={600}>{t('annotation.editLine', 'Edit Line')}</Text>
<Box>
<Text size="xs" c="dimmed" mb={4}>{t('annotation.color', 'Color')}</Text>
<ColorSwatchButton
color={selectedAnn.object?.strokeColor ?? shapeStrokeColor}
size={28}
onClick={() => {
setColorPickerTarget('shapeStroke');
setIsColorPickerOpen(true);
}}
/>
</Box>
<Box>
<Text size="xs" c="dimmed" mb={4}>{t('annotation.opacity', 'Opacity')}</Text>
<Slider
min={10}
max={100}
value={Math.round(((selectedAnn.object?.opacity ?? 1) * 100) || 100)}
onChange={(value) => {
annotationApiRef?.current?.updateAnnotation?.(
selectedAnn.object?.pageIndex ?? 0,
selectedAnn.object?.id,
{ opacity: value / 100 }
);
}}
/>
</Box>
<Box>
<Text size="xs" c="dimmed" mb={4}>{t('annotation.strokeWidth', 'Width')}</Text>
<Slider
min={1}
max={12}
value={selectedAnn.object?.borderWidth ?? shapeThickness}
onChange={(value) => {
annotationApiRef?.current?.updateAnnotation?.(
selectedAnn.object?.pageIndex ?? 0,
selectedAnn.object?.id,
{
borderWidth: value,
strokeWidth: value,
lineWidth: value,
}
);
setShapeThickness(value);
}}
/>
</Box>
</Stack>
</Paper>
);
}
if ((type !== undefined && [5, 6, 7].includes(type)) || toolId === 'square' || toolId === 'circle' || toolId === 'polygon') {
const shapeName = type === 5 ? 'Square' : type === 6 ? 'Circle' : 'Polygon';
const strokeColorValue = selectedAnn.object?.strokeColor ?? shapeStrokeColor;
const fillColorValue = selectedAnn.object?.color ?? shapeFillColor;
const opacityValue = Math.round(((selectedAnn.object?.opacity ?? shapeOpacity / 100) * 100) || 100);
const pageIndex = selectedAnn.object?.pageIndex ?? 0;
const annId = selectedAnn.object?.id;
return (
<Paper withBorder p="sm" radius="md">
<Stack gap="sm">
<Text size="sm" fw={600}>{t(`annotation.edit${shapeName}`, `Edit ${shapeName}`)}</Text>
<Group gap="md">
<Stack gap={4} align="center">
<Text size="xs" c="dimmed">{t('annotation.strokeColor', 'Stroke Color')}</Text>
<ColorSwatchButton
color={strokeColorValue}
size={28}
onClick={() => {
setColorPickerTarget('shapeStroke');
setIsColorPickerOpen(true);
}}
/>
</Stack>
<Stack gap={4} align="center">
<Text size="xs" c="dimmed">{t('annotation.fillColor', 'Fill Color')}</Text>
<ColorSwatchButton
color={fillColorValue}
size={28}
onClick={() => {
setColorPickerTarget('shapeFill');
setIsColorPickerOpen(true);
}}
/>
</Stack>
</Group>
<Box>
<Text size="xs" c="dimmed" mb={4}>{t('annotation.opacity', 'Opacity')}</Text>
<Slider
min={10}
max={100}
value={opacityValue}
onChange={(value) => {
setShapeOpacity(value);
setShapeStrokeOpacity(value);
setShapeFillOpacity(value);
if (annId) {
annotationApiRef?.current?.updateAnnotation?.(pageIndex, annId, {
opacity: value / 100,
strokeOpacity: value / 100,
fillOpacity: value / 100,
});
}
}}
/>
</Box>
<Group gap="xs" align="flex-end">
<Box style={{ flex: 1 }}>
<Text size="xs" c="dimmed" mb={4}>{t('annotation.strokeWidth', 'Stroke')}</Text>
<Slider
min={0}
max={12}
value={selectedAnn.object?.borderWidth ?? shapeThickness}
onChange={(value) => {
if (annId) {
annotationApiRef?.current?.updateAnnotation?.(pageIndex, annId, {
borderWidth: value,
strokeWidth: value,
lineWidth: value,
});
}
setShapeThickness(value);
}}
/>
</Box>
<Button
size="xs"
variant={(selectedAnn.object?.borderWidth ?? shapeThickness) === 0 ? 'filled' : 'light'}
onClick={() => {
const newValue = (selectedAnn.object?.borderWidth ?? shapeThickness) === 0 ? 1 : 0;
if (annId) {
annotationApiRef?.current?.updateAnnotation?.(pageIndex, annId, {
borderWidth: newValue,
strokeWidth: newValue,
lineWidth: newValue,
});
}
setShapeThickness(newValue);
}}
>
{(selectedAnn.object?.borderWidth ?? shapeThickness) === 0
? t('annotation.borderOff', 'Border: Off')
: t('annotation.borderOn', 'Border: On')
}
</Button>
</Group>
</Stack>
</Paper>
);
}
return (
<Paper withBorder p="sm" radius="md">
<Stack gap="sm">
<Text size="sm" fw={600}>{t('annotation.editSelected', 'Edit Annotation')}</Text>
<Text size="xs" c="dimmed">{t('annotation.unsupportedType', 'This annotation type is not fully supported for editing.')}</Text>
</Stack>
</Paper>
);
})();
const colorPickerComponent = (
<ColorPicker
isOpen={isColorPickerOpen}
@@ -1287,11 +776,7 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
{renderToolButtons(otherTools)}
</Box>
{activeTool !== 'select' && defaultStyleControls}
{activeTool === 'select' && selectedAnn && selectedAnnotationControls}
{activeTool === 'select' && !selectedAnn && defaultStyleControls}
{activeTool === 'stamp' && defaultStyleControls}
{colorPickerComponent}
@@ -5,7 +5,6 @@ interface UseAnnotationSelectionParams {
annotationApiRef: React.RefObject<AnnotationAPI | null>;
deriveToolFromAnnotation: (annotation: any) => AnnotationToolId | undefined;
activeToolRef: React.MutableRefObject<AnnotationToolId>;
manualToolSwitch: React.MutableRefObject<boolean>;
setActiveTool: (toolId: AnnotationToolId) => void;
setSelectedTextDraft: (text: string) => void;
setSelectedFontSize: (size: number) => void;
@@ -34,6 +33,7 @@ interface UseAnnotationSelectionParams {
const MARKUP_TOOL_IDS = ['highlight', 'underline', 'strikeout', 'squiggly'] as const;
const DRAWING_TOOL_IDS = ['ink', 'inkHighlighter'] as const;
const STAY_ACTIVE_TOOL_IDS = [...MARKUP_TOOL_IDS, ...DRAWING_TOOL_IDS] as const;
const isTextMarkupAnnotation = (annotation: any): boolean => {
const toolId =
@@ -55,6 +55,9 @@ const isTextMarkupAnnotation = (annotation: any): boolean => {
};
const shouldStayOnPlacementTool = (annotation: any, derivedTool?: string | null | undefined): boolean => {
// Text markup tools (highlight, underline, strikeout, squiggly) and drawing tools (ink, inkHighlighter) stay active
// All other tools switch to select mode after placement
const toolId =
derivedTool ||
annotation?.customData?.annotationToolId ||
@@ -62,12 +65,17 @@ const shouldStayOnPlacementTool = (annotation: any, derivedTool?: string | null
annotation?.object?.customData?.annotationToolId ||
annotation?.object?.customData?.toolId;
if (toolId && (MARKUP_TOOL_IDS.includes(toolId as any) || DRAWING_TOOL_IDS.includes(toolId as any))) {
// Check if it's a tool that should stay active
if (toolId && STAY_ACTIVE_TOOL_IDS.includes(toolId as any)) {
return true;
}
const type = annotation?.type ?? annotation?.object?.type;
if (typeof type === 'number' && type === 15) return true; // ink family
if (isTextMarkupAnnotation(annotation)) return true;
// Check if it's a markup annotation by type/subtype
if (isTextMarkupAnnotation(annotation)) {
return true;
}
// All other tools (text, note, shapes, lines, stamps) switch to select
return false;
};
@@ -75,7 +83,6 @@ export function useAnnotationSelection({
annotationApiRef,
deriveToolFromAnnotation,
activeToolRef,
manualToolSwitch,
setActiveTool,
setSelectedTextDraft,
setSelectedFontSize,
@@ -226,8 +233,8 @@ export function useAnnotationSelection({
},
[
activeToolRef,
annotationApiRef,
deriveToolFromAnnotation,
manualToolSwitch,
setActiveTool,
setInkWidth,
setNoteBackgroundColor,
@@ -252,7 +259,6 @@ export function useAnnotationSelection({
setShapeFillOpacity,
setTextAlignment,
setFreehandHighlighterWidth,
shouldStayOnPlacementTool,
]
);
@@ -304,9 +310,7 @@ export function useAnnotationSelection({
const tool =
deriveToolFromAnnotation((eventAnn as any)?.object ?? eventAnn ?? api.getSelectedAnnotation?.()) ||
currentTool;
const stayOnPlacement =
shouldStayOnPlacementTool(eventAnn, tool) ||
(tool ? DRAWING_TOOL_IDS.includes(tool as any) : false);
const stayOnPlacement = shouldStayOnPlacementTool(eventAnn, tool);
if (activeToolRef.current !== 'select' && !stayOnPlacement) {
activeToolRef.current = 'select';
setActiveTool('select');
@@ -318,9 +322,7 @@ export function useAnnotationSelection({
applySelectionFromAnnotation(selected ?? eventAnn ?? null);
const derivedAfter =
deriveToolFromAnnotation((selected as any)?.object ?? selected ?? eventAnn ?? null) || activeToolRef.current;
const stayOnPlacementAfter =
shouldStayOnPlacementTool(selected ?? eventAnn ?? null, derivedAfter) ||
(derivedAfter ? DRAWING_TOOL_IDS.includes(derivedAfter as any) : false);
const stayOnPlacementAfter = shouldStayOnPlacementTool(selected ?? eventAnn ?? null, derivedAfter);
if (activeToolRef.current !== 'select' && !stayOnPlacementAfter) {
activeToolRef.current = 'select';
setActiveTool('select');