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
@@ -0,0 +1,59 @@
import { ActionIcon, Tooltip, Popover, Stack, ColorSwatch, ColorPicker as MantineColorPicker } from '@mantine/core';
import { useState } from 'react';
interface ColorControlProps {
value: string;
onChange: (color: string) => void;
label: string;
disabled?: boolean;
}
export function ColorControl({ value, onChange, label, disabled = false }: ColorControlProps) {
const [opened, setOpened] = useState(false);
return (
<Popover opened={opened} onChange={setOpened} position="bottom" withArrow withinPortal>
<Popover.Target>
<Tooltip label={label}>
<ActionIcon
variant="subtle"
color="gray"
size="md"
onClick={() => setOpened(!opened)}
disabled={disabled}
styles={{
root: {
flexShrink: 0,
backgroundColor: 'var(--bg-raised)',
border: '1px solid var(--border-default)',
color: 'var(--text-secondary)',
'&:hover': {
backgroundColor: 'var(--hover-bg)',
borderColor: 'var(--border-strong)',
color: 'var(--text-primary)',
},
},
}}
>
<ColorSwatch color={value} size={18} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs">
<MantineColorPicker
format="hex"
value={value}
onChange={onChange}
swatches={[
'#000000', '#ffffff', '#ff0000', '#00ff00', '#0000ff',
'#ffff00', '#ff00ff', '#00ffff', '#ffa500', 'transparent'
]}
swatchesPerRow={5}
size="sm"
/>
</Stack>
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,60 @@
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import OpacityIcon from '@mui/icons-material/Opacity';
interface OpacityControlProps {
value: number; // 0-100
onChange: (value: number) => void;
disabled?: boolean;
}
export function OpacityControl({ value, onChange, disabled = false }: OpacityControlProps) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
return (
<Popover opened={opened} onChange={setOpened} position="top" withArrow>
<Popover.Target>
<Tooltip label={t('annotation.opacity', 'Opacity')}>
<ActionIcon
variant="subtle"
color="gray"
size="md"
onClick={() => setOpened(!opened)}
disabled={disabled}
styles={{
root: {
flexShrink: 0,
backgroundColor: 'var(--bg-raised)',
border: '1px solid var(--border-default)',
color: 'var(--text-secondary)',
'&:hover': {
backgroundColor: 'var(--hover-bg)',
borderColor: 'var(--border-strong)',
color: 'var(--text-primary)',
},
},
}}
>
<OpacityIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs" style={{ minWidth: 150 }}>
<Text size="xs" fw={500}>
{t('annotation.opacity', 'Opacity')}
</Text>
<Slider
value={value}
onChange={onChange}
min={10}
max={100}
label={(val) => `${val}%`}
/>
</Stack>
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,211 @@
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text, Group, Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import TuneIcon from '@mui/icons-material/Tune';
import FormatAlignLeftIcon from '@mui/icons-material/FormatAlignLeft';
import FormatAlignCenterIcon from '@mui/icons-material/FormatAlignCenter';
import FormatAlignRightIcon from '@mui/icons-material/FormatAlignRight';
type AnnotationType = 'text' | 'note' | 'shape';
interface PropertiesPopoverProps {
annotationType: AnnotationType;
annotation: any;
onUpdate: (patch: Record<string, any>) => void;
disabled?: boolean;
}
export function PropertiesPopover({
annotationType,
annotation,
onUpdate,
disabled = false,
}: PropertiesPopoverProps) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const obj = annotation?.object;
// Get current values
const fontSize = obj?.fontSize ?? 14;
const textAlign = obj?.textAlign;
const currentAlign =
typeof textAlign === 'number'
? textAlign === 1
? 'center'
: textAlign === 2
? 'right'
: 'left'
: textAlign === 'center'
? 'center'
: textAlign === 'right'
? 'right'
: 'left';
// For shapes
const opacity = Math.round((obj?.opacity ?? 1) * 100);
const strokeWidth = obj?.borderWidth ?? obj?.strokeWidth ?? 2;
const borderVisible = strokeWidth > 0;
const renderTextNoteControls = () => (
<Stack gap="md" style={{ minWidth: 280 }}>
{/* Font Size */}
<div>
<Text size="xs" fw={500} mb={4}>
{t('annotation.fontSize', 'Font size')}
</Text>
<Slider
value={fontSize}
onChange={(val) => onUpdate({ fontSize: val })}
min={8}
max={32}
label={(val) => `${val}pt`}
/>
</div>
{/* Opacity */}
<div>
<Text size="xs" fw={500} mb={4}>
{t('annotation.opacity', 'Opacity')}
</Text>
<Slider
value={Math.round((obj?.opacity ?? 1) * 100)}
onChange={(val) => onUpdate({ opacity: val / 100 })}
min={10}
max={100}
label={(val) => `${val}%`}
/>
</div>
{/* Text Alignment */}
<div>
<Text size="xs" fw={500} mb={4}>
{t('annotation.textAlignment', 'Text Alignment')}
</Text>
<Group gap="xs">
<ActionIcon
variant={currentAlign === 'left' ? 'filled' : 'default'}
onClick={() => onUpdate({ textAlign: 0 })}
size="md"
>
<FormatAlignLeftIcon style={{ fontSize: 18 }} />
</ActionIcon>
<ActionIcon
variant={currentAlign === 'center' ? 'filled' : 'default'}
onClick={() => onUpdate({ textAlign: 1 })}
size="md"
>
<FormatAlignCenterIcon style={{ fontSize: 18 }} />
</ActionIcon>
<ActionIcon
variant={currentAlign === 'right' ? 'filled' : 'default'}
onClick={() => onUpdate({ textAlign: 2 })}
size="md"
>
<FormatAlignRightIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Group>
</div>
</Stack>
);
const renderShapeControls = () => (
<Stack gap="md" style={{ minWidth: 250 }}>
{/* Opacity */}
<div>
<Text size="xs" fw={500} mb={4}>
{t('annotation.opacity', 'Opacity')}
</Text>
<Slider
value={opacity}
onChange={(val) => {
const newOpacity = val / 100;
onUpdate({
opacity: newOpacity,
strokeOpacity: newOpacity,
fillOpacity: newOpacity,
});
}}
min={10}
max={100}
label={(val) => `${val}%`}
/>
</div>
{/* Stroke Width */}
<div>
<Group gap="xs" align="flex-end">
<div style={{ flex: 1 }}>
<Text size="xs" fw={500} mb={4}>
{t('annotation.strokeWidth', 'Stroke')}
</Text>
<Slider
value={strokeWidth}
onChange={(val) => {
onUpdate({
borderWidth: val,
strokeWidth: val,
lineWidth: val,
});
}}
min={0}
max={12}
label={(val) => `${val}pt`}
/>
</div>
<Button
size="xs"
variant={!borderVisible ? 'filled' : 'light'}
onClick={() => {
const newValue = borderVisible ? 0 : 1;
onUpdate({
borderWidth: newValue,
strokeWidth: newValue,
lineWidth: newValue,
});
}}
>
{borderVisible
? t('annotation.borderOn', 'Border: On')
: t('annotation.borderOff', 'Border: Off')}
</Button>
</Group>
</div>
</Stack>
);
return (
<Popover opened={opened} onChange={setOpened} position="bottom" withArrow>
<Popover.Target>
<Tooltip label={t('annotation.properties', 'Properties')}>
<ActionIcon
variant="subtle"
color="gray"
size="md"
onClick={() => setOpened(!opened)}
disabled={disabled}
styles={{
root: {
flexShrink: 0,
backgroundColor: 'var(--bg-raised)',
border: '1px solid var(--border-default)',
color: 'var(--text-secondary)',
'&:hover': {
backgroundColor: 'var(--hover-bg)',
borderColor: 'var(--border-strong)',
color: 'var(--text-primary)',
},
},
}}
>
<TuneIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
{(annotationType === 'text' || annotationType === 'note') && renderTextNoteControls()}
{annotationType === 'shape' && renderShapeControls()}
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,62 @@
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import LineWeightIcon from '@mui/icons-material/LineWeight';
interface WidthControlProps {
value: number;
onChange: (value: number) => void;
min: number; // 1 for ink, 0 for shapes
max: number; // 12 for ink, 20 for highlighter
disabled?: boolean;
}
export function WidthControl({ value, onChange, min, max, disabled = false }: WidthControlProps) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
return (
<Popover opened={opened} onChange={setOpened} position="top" withArrow>
<Popover.Target>
<Tooltip label={t('annotation.width', 'Width')}>
<ActionIcon
variant="subtle"
color="gray"
size="md"
onClick={() => setOpened(!opened)}
disabled={disabled}
styles={{
root: {
flexShrink: 0,
backgroundColor: 'var(--bg-raised)',
border: '1px solid var(--border-default)',
color: 'var(--text-secondary)',
'&:hover': {
backgroundColor: 'var(--hover-bg)',
borderColor: 'var(--border-strong)',
color: 'var(--text-primary)',
},
},
}}
>
<LineWeightIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs" style={{ minWidth: 150 }}>
<Text size="xs" fw={500}>
{t('annotation.width', 'Width')}
</Text>
<Slider
value={value}
onChange={onChange}
min={min}
max={max}
label={(val) => `${val}pt`}
/>
</Stack>
</Popover.Dropdown>
</Popover>
);
}