mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
Chore/v2/improve annotation UI (#5724)
This commit is contained in:
@@ -1439,13 +1439,16 @@ applyChanges = "Apply Changes"
|
|||||||
backgroundColor = "Background colour"
|
backgroundColor = "Background colour"
|
||||||
borderOff = "Border: Off"
|
borderOff = "Border: Off"
|
||||||
borderOn = "Border: On"
|
borderOn = "Border: On"
|
||||||
|
changeColor = "Change Colour"
|
||||||
chooseColor = "Choose colour"
|
chooseColor = "Choose colour"
|
||||||
circle = "Circle"
|
circle = "Circle"
|
||||||
clearBackground = "Remove background"
|
clearBackground = "Remove background"
|
||||||
color = "Colour"
|
color = "Colour"
|
||||||
contents = "Text"
|
contents = "Text"
|
||||||
|
delete = "Delete"
|
||||||
desc = "Use highlight, pen, text, and notes. Changes stay live—no flattening required."
|
desc = "Use highlight, pen, text, and notes. Changes stay live—no flattening required."
|
||||||
drawing = "Drawing"
|
drawing = "Drawing"
|
||||||
|
duplicate = "Duplicate"
|
||||||
editCircle = "Edit Circle"
|
editCircle = "Edit Circle"
|
||||||
editInk = "Edit Pen"
|
editInk = "Edit Pen"
|
||||||
editLine = "Edit Line"
|
editLine = "Edit Line"
|
||||||
@@ -1475,6 +1478,7 @@ notesStamps = "Notes & Stamps"
|
|||||||
opacity = "Opacity"
|
opacity = "Opacity"
|
||||||
pen = "Pen"
|
pen = "Pen"
|
||||||
polygon = "Polygon"
|
polygon = "Polygon"
|
||||||
|
properties = "Properties"
|
||||||
rectangle = "Rectangle"
|
rectangle = "Rectangle"
|
||||||
redo = "Redo"
|
redo = "Redo"
|
||||||
saveChanges = "Save Changes"
|
saveChanges = "Save Changes"
|
||||||
@@ -1500,6 +1504,7 @@ title = "Annotate"
|
|||||||
underline = "Underline"
|
underline = "Underline"
|
||||||
undo = "Undo"
|
undo = "Undo"
|
||||||
unsupportedType = "This annotation type is not fully supported for editing."
|
unsupportedType = "This annotation type is not fully supported for editing."
|
||||||
|
width = "Width"
|
||||||
|
|
||||||
[app]
|
[app]
|
||||||
description = "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
description = "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||||
@@ -4230,6 +4235,56 @@ title = "Page Editor"
|
|||||||
zoomIn = "Zoom In"
|
zoomIn = "Zoom In"
|
||||||
zoomOut = "Zoom Out"
|
zoomOut = "Zoom Out"
|
||||||
|
|
||||||
|
[viewer]
|
||||||
|
cannotPreviewFile = "Cannot Preview File"
|
||||||
|
dualPageView = "Dual Page View"
|
||||||
|
firstPage = "First Page"
|
||||||
|
lastPage = "Last Page"
|
||||||
|
nextPage = "Next Page"
|
||||||
|
onlyPdfSupported = "The viewer only supports PDF files. This file appears to be a different format."
|
||||||
|
previousPage = "Previous Page"
|
||||||
|
singlePageView = "Single Page View"
|
||||||
|
unknownFile = "Unknown file"
|
||||||
|
zoomIn = "Zoom In"
|
||||||
|
zoomOut = "Zoom Out"
|
||||||
|
|
||||||
|
[rightRail]
|
||||||
|
closeSelected = "Close Selected Files"
|
||||||
|
selectAll = "Select All"
|
||||||
|
deselectAll = "Deselect All"
|
||||||
|
selectByNumber = "Select by Page Numbers"
|
||||||
|
deleteSelected = "Delete Selected Pages"
|
||||||
|
closePdf = "Close PDF"
|
||||||
|
exportAll = "Export PDF"
|
||||||
|
downloadSelected = "Download Selected Files"
|
||||||
|
annotations = "Annotations"
|
||||||
|
exportSelected = "Export Selected Pages"
|
||||||
|
formFill = "Fill Form"
|
||||||
|
saveChanges = "Save Changes"
|
||||||
|
toggleAttachments = "Toggle Attachments"
|
||||||
|
toggleTheme = "Toggle Theme"
|
||||||
|
language = "Language"
|
||||||
|
toggleAnnotations = "Toggle Annotations Visibility"
|
||||||
|
search = "Search PDF"
|
||||||
|
panMode = "Pan Mode"
|
||||||
|
applyRedactionsFirst = "Apply redactions first"
|
||||||
|
rotateLeft = "Rotate Left"
|
||||||
|
rotateRight = "Rotate Right"
|
||||||
|
toggleSidebar = "Toggle Sidebar"
|
||||||
|
toggleBookmarks = "Toggle Bookmarks"
|
||||||
|
print = "Print PDF"
|
||||||
|
draw = "Draw"
|
||||||
|
redact = "Redact"
|
||||||
|
exitRedaction = "Exit Redaction Mode"
|
||||||
|
save = "Save"
|
||||||
|
downloadAll = "Download All"
|
||||||
|
saveAll = "Save All"
|
||||||
|
|
||||||
|
[textAlign]
|
||||||
|
left = "Left"
|
||||||
|
center = "Center"
|
||||||
|
right = "Right"
|
||||||
|
|
||||||
[pageExtracter]
|
[pageExtracter]
|
||||||
header = "Extract Pages"
|
header = "Extract Pages"
|
||||||
placeholder = "(e.g. 1,2,8 or 4,7,12-16 or 2n-1)"
|
placeholder = "(e.g. 1,2,8 or 4,7,12-16 or 2n-1)"
|
||||||
@@ -5310,38 +5365,6 @@ title = "High Contrast"
|
|||||||
text = "Completely invert all colours in the PDF, creating a negative-like effect. Useful for creating dark mode versions of documents or reducing eye strain in low-light conditions."
|
text = "Completely invert all colours in the PDF, creating a negative-like effect. Useful for creating dark mode versions of documents or reducing eye strain in low-light conditions."
|
||||||
title = "Invert All Colours"
|
title = "Invert All Colours"
|
||||||
|
|
||||||
[rightRail]
|
|
||||||
annotations = "Annotations"
|
|
||||||
applyRedactionsFirst = "Apply redactions first"
|
|
||||||
closePdf = "Close PDF"
|
|
||||||
closeSelected = "Close Selected Files"
|
|
||||||
formFill = "Fill Form"
|
|
||||||
deleteSelected = "Delete Selected Pages"
|
|
||||||
deselectAll = "Deselect All"
|
|
||||||
downloadAll = "Download All"
|
|
||||||
downloadSelected = "Download Selected Files"
|
|
||||||
draw = "Draw"
|
|
||||||
exitRedaction = "Exit Redaction Mode"
|
|
||||||
exportAll = "Export PDF"
|
|
||||||
exportSelected = "Export Selected Pages"
|
|
||||||
language = "Language"
|
|
||||||
panMode = "Pan Mode"
|
|
||||||
print = "Print PDF"
|
|
||||||
redact = "Redact"
|
|
||||||
rotateLeft = "Rotate Left"
|
|
||||||
rotateRight = "Rotate Right"
|
|
||||||
save = "Save"
|
|
||||||
saveAll = "Save All"
|
|
||||||
saveChanges = "Save Changes"
|
|
||||||
search = "Search PDF"
|
|
||||||
selectAll = "Select All"
|
|
||||||
selectByNumber = "Select by Page Numbers"
|
|
||||||
toggleAnnotations = "Toggle Annotations Visibility"
|
|
||||||
toggleBookmarks = "Toggle Bookmarks"
|
|
||||||
toggleAttachments = "Toggle Attachments"
|
|
||||||
toggleSidebar = "Toggle Sidebar"
|
|
||||||
toggleTheme = "Toggle Theme"
|
|
||||||
|
|
||||||
[rotate]
|
[rotate]
|
||||||
rotateLeft = "Rotate Anticlockwise"
|
rotateLeft = "Rotate Anticlockwise"
|
||||||
rotateRight = "Rotate Clockwise"
|
rotateRight = "Rotate Clockwise"
|
||||||
@@ -6179,11 +6202,6 @@ title = "API Documentation"
|
|||||||
[tableExtraxt]
|
[tableExtraxt]
|
||||||
tags = "CSV,Table Extraction,extract,convert"
|
tags = "CSV,Table Extraction,extract,convert"
|
||||||
|
|
||||||
[textAlign]
|
|
||||||
center = "Center"
|
|
||||||
left = "Left"
|
|
||||||
right = "Right"
|
|
||||||
|
|
||||||
[theme]
|
[theme]
|
||||||
toggle = "Toggle Theme"
|
toggle = "Toggle Theme"
|
||||||
|
|
||||||
@@ -6459,19 +6477,6 @@ fileManager = "File Manager"
|
|||||||
pageEditor = "Page Editor"
|
pageEditor = "Page Editor"
|
||||||
viewer = "Viewer"
|
viewer = "Viewer"
|
||||||
|
|
||||||
[viewer]
|
|
||||||
cannotPreviewFile = "Cannot Preview File"
|
|
||||||
dualPageView = "Dual Page View"
|
|
||||||
firstPage = "First Page"
|
|
||||||
lastPage = "Last Page"
|
|
||||||
nextPage = "Next Page"
|
|
||||||
onlyPdfSupported = "The viewer only supports PDF files. This file appears to be a different format."
|
|
||||||
previousPage = "Previous Page"
|
|
||||||
singlePageView = "Single Page View"
|
|
||||||
unknownFile = "Unknown file"
|
|
||||||
zoomIn = "Zoom In"
|
|
||||||
zoomOut = "Zoom Out"
|
|
||||||
|
|
||||||
[viewer.attachments]
|
[viewer.attachments]
|
||||||
title = "Attachments"
|
title = "Attachments"
|
||||||
searchPlaceholder = "Search attachments"
|
searchPlaceholder = "Search attachments"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,620 @@
|
|||||||
|
import { ActionIcon, Tooltip, Group } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
|
import { useAnnotation } from '@embedpdf/plugin-annotation/react';
|
||||||
|
import { useActiveDocumentId } from '@app/components/viewer/useActiveDocumentId';
|
||||||
|
import { OpacityControl } from '@app/components/annotation/shared/OpacityControl';
|
||||||
|
import { WidthControl } from '@app/components/annotation/shared/WidthControl';
|
||||||
|
import { PropertiesPopover } from '@app/components/annotation/shared/PropertiesPopover';
|
||||||
|
import { ColorControl } from '@app/components/annotation/shared/ColorControl';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props interface matching EmbedPDF's annotation selection menu pattern
|
||||||
|
* This matches the type from @embedpdf/plugin-annotation
|
||||||
|
*/
|
||||||
|
export interface AnnotationSelectionMenuProps {
|
||||||
|
documentId?: string;
|
||||||
|
context?: {
|
||||||
|
type: 'annotation';
|
||||||
|
annotation: any;
|
||||||
|
pageIndex: number;
|
||||||
|
};
|
||||||
|
selected: boolean;
|
||||||
|
menuWrapperProps?: {
|
||||||
|
ref?: (node: HTMLDivElement | null) => void;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnnotationSelectionMenu(props: AnnotationSelectionMenuProps) {
|
||||||
|
const activeDocumentId = useActiveDocumentId();
|
||||||
|
|
||||||
|
// Don't render until we have a valid document ID
|
||||||
|
if (!activeDocumentId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnnotationSelectionMenuInner
|
||||||
|
documentId={activeDocumentId}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnnotationType = 'textMarkup' | 'ink' | 'inkHighlighter' | 'text' | 'note' | 'shape' | 'line' | 'stamp' | 'unknown';
|
||||||
|
|
||||||
|
function AnnotationSelectionMenuInner({
|
||||||
|
documentId,
|
||||||
|
context,
|
||||||
|
selected,
|
||||||
|
menuWrapperProps,
|
||||||
|
}: AnnotationSelectionMenuProps & { documentId: string }) {
|
||||||
|
const annotation = context?.annotation;
|
||||||
|
const pageIndex = context?.pageIndex;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { provides } = useAnnotation(documentId);
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null);
|
||||||
|
const [isTextEditorOpen, setIsTextEditorOpen] = useState(false);
|
||||||
|
const [textDraft, setTextDraft] = useState('');
|
||||||
|
const [textBoxPosition, setTextBoxPosition] = useState<{ top: number; left: number; width: number; height: number; fontSize: number; fontFamily: string } | null>(null);
|
||||||
|
|
||||||
|
// Merge refs - menuWrapperProps.ref is a callback ref
|
||||||
|
const setRef = useCallback((node: HTMLDivElement | null) => {
|
||||||
|
wrapperRef.current = node;
|
||||||
|
// Call the EmbedPDF ref callback
|
||||||
|
menuWrapperProps?.ref?.(node);
|
||||||
|
}, [menuWrapperProps]);
|
||||||
|
|
||||||
|
// Type detection
|
||||||
|
const getAnnotationType = useCallback((): AnnotationType => {
|
||||||
|
const type = annotation?.object?.type;
|
||||||
|
const toolId = annotation?.object?.customData?.toolId;
|
||||||
|
|
||||||
|
// Map type numbers to categories
|
||||||
|
if ([9, 10, 11, 12].includes(type)) return 'textMarkup';
|
||||||
|
if (type === 15) {
|
||||||
|
return toolId === 'inkHighlighter' ? 'inkHighlighter' : 'ink';
|
||||||
|
}
|
||||||
|
if (type === 3) {
|
||||||
|
return toolId === 'note' ? 'note' : 'text';
|
||||||
|
}
|
||||||
|
if ([5, 6, 7].includes(type)) return 'shape';
|
||||||
|
if ([4, 8].includes(type)) return 'line';
|
||||||
|
if (type === 13) return 'stamp';
|
||||||
|
|
||||||
|
return 'unknown';
|
||||||
|
}, [annotation]);
|
||||||
|
|
||||||
|
// Calculate menu width based on annotation type
|
||||||
|
const calculateWidth = (annotationType: AnnotationType): number => {
|
||||||
|
switch (annotationType) {
|
||||||
|
case 'stamp':
|
||||||
|
return 80;
|
||||||
|
case 'inkHighlighter':
|
||||||
|
return 220;
|
||||||
|
case 'shape':
|
||||||
|
return 200;
|
||||||
|
default:
|
||||||
|
return 180;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get annotation properties
|
||||||
|
const obj = annotation?.object;
|
||||||
|
const annotationType = getAnnotationType();
|
||||||
|
const annotationId = obj?.id;
|
||||||
|
|
||||||
|
// Get current colors
|
||||||
|
const getCurrentColor = (): string => {
|
||||||
|
if (!obj) return '#000000';
|
||||||
|
const type = obj.type;
|
||||||
|
// Text annotations use textColor
|
||||||
|
if (type === 3) return obj.textColor || obj.color || '#000000';
|
||||||
|
// Shape annotations use strokeColor
|
||||||
|
if ([4, 5, 6, 7, 8].includes(type)) return obj.strokeColor || obj.color || '#000000';
|
||||||
|
// Default to color property
|
||||||
|
return obj.color || obj.strokeColor || '#000000';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStrokeColor = (): string => {
|
||||||
|
return obj?.strokeColor || obj?.color || '#000000';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFillColor = (): string => {
|
||||||
|
return obj?.color || obj?.fillColor || '#0000ff';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBackgroundColor = (): string => {
|
||||||
|
// Check multiple possible properties for background color
|
||||||
|
return obj?.backgroundColor || obj?.fillColor || obj?.color || '#ffffff';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTextColor = (): string => {
|
||||||
|
return obj?.textColor || obj?.color || '#000000';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOpacity = (): number => {
|
||||||
|
return Math.round((obj?.opacity ?? 1) * 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getWidth = (): number => {
|
||||||
|
return obj?.strokeWidth ?? obj?.borderWidth ?? obj?.lineWidth ?? obj?.thickness ?? 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
if (provides?.deleteAnnotation && annotationId && pageIndex !== undefined) {
|
||||||
|
provides.deleteAnnotation(pageIndex, annotationId);
|
||||||
|
}
|
||||||
|
}, [provides, annotationId, pageIndex]);
|
||||||
|
|
||||||
|
const handleOpenTextEditor = useCallback(() => {
|
||||||
|
if (!annotation) return;
|
||||||
|
|
||||||
|
// Try to find the annotation element in the DOM
|
||||||
|
const annotationElement = document.querySelector(`[data-annotation-id="${annotationId}"]`) as HTMLElement;
|
||||||
|
|
||||||
|
let fontSize = (obj?.fontSize || 14) * 1.33;
|
||||||
|
let fontFamily = 'Helvetica';
|
||||||
|
|
||||||
|
if (annotationElement) {
|
||||||
|
const rect = annotationElement.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Try multiple selectors to find the text element
|
||||||
|
const textElement = annotationElement.querySelector('text, [class*="text"], [class*="content"]') as HTMLElement;
|
||||||
|
if (textElement) {
|
||||||
|
const computedStyle = window.getComputedStyle(textElement);
|
||||||
|
const computedSize = parseFloat(computedStyle.fontSize);
|
||||||
|
if (computedSize && computedSize > 0) {
|
||||||
|
fontSize = computedSize;
|
||||||
|
}
|
||||||
|
fontFamily = computedStyle.fontFamily || fontFamily;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTextBoxPosition({
|
||||||
|
top: rect.top,
|
||||||
|
left: rect.left,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
fontSize: fontSize,
|
||||||
|
fontFamily: fontFamily,
|
||||||
|
});
|
||||||
|
} else if (wrapperRef.current) {
|
||||||
|
// Fallback to wrapper position
|
||||||
|
const rect = wrapperRef.current.getBoundingClientRect();
|
||||||
|
setTextBoxPosition({
|
||||||
|
top: rect.top,
|
||||||
|
left: rect.left,
|
||||||
|
width: Math.max(rect.width, 200),
|
||||||
|
height: Math.max(rect.height, 50),
|
||||||
|
fontSize: fontSize,
|
||||||
|
fontFamily: fontFamily,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTextDraft(obj?.contents || '');
|
||||||
|
setIsTextEditorOpen(true);
|
||||||
|
|
||||||
|
// Focus the textarea after it renders
|
||||||
|
setTimeout(() => {
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
textareaRef.current?.select();
|
||||||
|
}, 0);
|
||||||
|
}, [obj, annotation, annotationId]);
|
||||||
|
|
||||||
|
const handleSaveText = useCallback(() => {
|
||||||
|
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
|
||||||
|
|
||||||
|
provides.updateAnnotation(pageIndex, annotationId, {
|
||||||
|
contents: textDraft,
|
||||||
|
});
|
||||||
|
setIsTextEditorOpen(false);
|
||||||
|
setTextBoxPosition(null);
|
||||||
|
}, [provides, annotationId, pageIndex, textDraft]);
|
||||||
|
|
||||||
|
const handleCloseTextEdit = useCallback(() => {
|
||||||
|
setIsTextEditorOpen(false);
|
||||||
|
setTextBoxPosition(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleColorChange = useCallback((color: string, target: 'main' | 'stroke' | 'fill' | 'text' | 'background') => {
|
||||||
|
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
|
||||||
|
|
||||||
|
const type = obj?.type;
|
||||||
|
const patch: any = {};
|
||||||
|
|
||||||
|
if (target === 'stroke') {
|
||||||
|
// Shape stroke - preserve fill color
|
||||||
|
patch.strokeColor = color;
|
||||||
|
patch.color = obj?.color || '#0000ff'; // Preserve fill
|
||||||
|
patch.strokeWidth = getWidth();
|
||||||
|
} else if (target === 'fill') {
|
||||||
|
// Shape fill - preserve stroke color
|
||||||
|
patch.color = color;
|
||||||
|
patch.strokeColor = obj?.strokeColor || '#000000'; // Preserve stroke
|
||||||
|
patch.strokeWidth = getWidth();
|
||||||
|
} else if (target === 'background') {
|
||||||
|
// Background color for text/note - set multiple properties for compatibility
|
||||||
|
patch.backgroundColor = color;
|
||||||
|
patch.fillColor = color;
|
||||||
|
patch.color = color;
|
||||||
|
} else if (target === 'text') {
|
||||||
|
// Text color for text/note - TRY PROPERTY COMBINATIONS
|
||||||
|
patch.textColor = color;
|
||||||
|
patch.fontColor = color; // EmbedPDF might expect this instead
|
||||||
|
|
||||||
|
// Include font metadata (EmbedPDF might require these together)
|
||||||
|
patch.fontSize = obj?.fontSize ?? 14;
|
||||||
|
patch.fontFamily = obj?.fontFamily ?? 'Helvetica';
|
||||||
|
|
||||||
|
// Re-submit text content
|
||||||
|
patch.contents = obj?.contents ?? '';
|
||||||
|
} else {
|
||||||
|
// Main color - for highlights, ink, etc.
|
||||||
|
patch.color = color;
|
||||||
|
|
||||||
|
// For text markup annotations (highlight, underline, strikeout, squiggly)
|
||||||
|
if ([9, 10, 11, 12].includes(type)) {
|
||||||
|
patch.strokeColor = color;
|
||||||
|
patch.fillColor = color;
|
||||||
|
patch.opacity = obj?.opacity ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For line annotations (type 4, 8), include stroke properties
|
||||||
|
if ([4, 8].includes(type)) {
|
||||||
|
patch.strokeColor = color;
|
||||||
|
patch.strokeWidth = obj?.strokeWidth ?? obj?.lineWidth ?? 2;
|
||||||
|
patch.lineWidth = obj?.lineWidth ?? obj?.strokeWidth ?? 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For ink annotations (type 15), include all stroke-related properties
|
||||||
|
if (type === 15) {
|
||||||
|
patch.strokeColor = color;
|
||||||
|
patch.strokeWidth = obj?.strokeWidth ?? obj?.thickness ?? 2;
|
||||||
|
patch.opacity = obj?.opacity ?? 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
provides.updateAnnotation(pageIndex, annotationId, patch);
|
||||||
|
}, [provides, annotationId, pageIndex, obj]);
|
||||||
|
|
||||||
|
const handleOpacityChange = useCallback((opacity: number) => {
|
||||||
|
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
|
||||||
|
|
||||||
|
provides.updateAnnotation(pageIndex, annotationId, {
|
||||||
|
opacity: opacity / 100,
|
||||||
|
});
|
||||||
|
}, [provides, annotationId, pageIndex]);
|
||||||
|
|
||||||
|
const handleWidthChange = useCallback((width: number) => {
|
||||||
|
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
|
||||||
|
|
||||||
|
provides.updateAnnotation(pageIndex, annotationId, {
|
||||||
|
strokeWidth: width,
|
||||||
|
});
|
||||||
|
}, [provides, annotationId, pageIndex]);
|
||||||
|
|
||||||
|
const handlePropertiesUpdate = useCallback((patch: Record<string, any>) => {
|
||||||
|
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
|
||||||
|
|
||||||
|
provides.updateAnnotation(pageIndex, annotationId, patch);
|
||||||
|
}, [provides, annotationId, pageIndex]);
|
||||||
|
|
||||||
|
// Render button groups based on annotation type
|
||||||
|
const renderButtons = () => {
|
||||||
|
const commonButtonStyles = {
|
||||||
|
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)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const EditTextButton = () => (
|
||||||
|
<Tooltip label={t('annotation.editText', 'Edit Text')}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="md"
|
||||||
|
onClick={handleOpenTextEditor}
|
||||||
|
styles={commonButtonStyles}
|
||||||
|
>
|
||||||
|
<EditIcon style={{ fontSize: 18 }} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
|
||||||
|
const DeleteButton = () => (
|
||||||
|
<Tooltip label={t('annotation.delete', 'Delete')}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
size="md"
|
||||||
|
onClick={handleDelete}
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
...commonButtonStyles.root,
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'var(--mantine-color-red-1)',
|
||||||
|
borderColor: 'var(--mantine-color-red-4)',
|
||||||
|
color: 'var(--mantine-color-red-7)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteIcon style={{ fontSize: 18 }} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (annotationType) {
|
||||||
|
case 'textMarkup':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ColorControl
|
||||||
|
value={getCurrentColor()}
|
||||||
|
onChange={(color) => handleColorChange(color, 'main')}
|
||||||
|
label={t('annotation.changeColor', 'Change Colour')}
|
||||||
|
/>
|
||||||
|
<OpacityControl value={getOpacity()} onChange={handleOpacityChange} />
|
||||||
|
<DeleteButton />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'ink':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ColorControl
|
||||||
|
value={getCurrentColor()}
|
||||||
|
onChange={(color) => handleColorChange(color, 'main')}
|
||||||
|
label={t('annotation.changeColor', 'Change Colour')}
|
||||||
|
/>
|
||||||
|
<WidthControl value={getWidth()} onChange={handleWidthChange} min={1} max={12} />
|
||||||
|
<DeleteButton />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'inkHighlighter':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ColorControl
|
||||||
|
value={getCurrentColor()}
|
||||||
|
onChange={(color) => handleColorChange(color, 'main')}
|
||||||
|
label={t('annotation.changeColor', 'Change Colour')}
|
||||||
|
/>
|
||||||
|
<WidthControl value={getWidth()} onChange={handleWidthChange} min={1} max={20} />
|
||||||
|
<OpacityControl value={getOpacity()} onChange={handleOpacityChange} />
|
||||||
|
<DeleteButton />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'text':
|
||||||
|
case 'note':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ColorControl
|
||||||
|
value={getTextColor()}
|
||||||
|
onChange={(color) => handleColorChange(color, 'text')}
|
||||||
|
label={t('annotation.color', 'Color')}
|
||||||
|
/>
|
||||||
|
<ColorControl
|
||||||
|
value={getBackgroundColor()}
|
||||||
|
onChange={(color) => handleColorChange(color, 'background')}
|
||||||
|
label={t('annotation.backgroundColor', 'Background color')}
|
||||||
|
/>
|
||||||
|
<EditTextButton />
|
||||||
|
<PropertiesPopover
|
||||||
|
annotationType={annotationType}
|
||||||
|
annotation={annotation}
|
||||||
|
onUpdate={handlePropertiesUpdate}
|
||||||
|
/>
|
||||||
|
<DeleteButton />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'shape':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ColorControl
|
||||||
|
value={getStrokeColor()}
|
||||||
|
onChange={(color) => handleColorChange(color, 'stroke')}
|
||||||
|
label={t('annotation.strokeColor', 'Stroke Colour')}
|
||||||
|
/>
|
||||||
|
<ColorControl
|
||||||
|
value={getFillColor()}
|
||||||
|
onChange={(color) => handleColorChange(color, 'fill')}
|
||||||
|
label={t('annotation.fillColor', 'Fill Colour')}
|
||||||
|
/>
|
||||||
|
<PropertiesPopover
|
||||||
|
annotationType="shape"
|
||||||
|
annotation={annotation}
|
||||||
|
onUpdate={handlePropertiesUpdate}
|
||||||
|
/>
|
||||||
|
<DeleteButton />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'line':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ColorControl
|
||||||
|
value={getCurrentColor()}
|
||||||
|
onChange={(color) => handleColorChange(color, 'main')}
|
||||||
|
label={t('annotation.changeColor', 'Change Colour')}
|
||||||
|
/>
|
||||||
|
<WidthControl value={getWidth()} onChange={handleWidthChange} min={1} max={12} />
|
||||||
|
<DeleteButton />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'stamp':
|
||||||
|
return <DeleteButton />;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ColorControl
|
||||||
|
value={getCurrentColor()}
|
||||||
|
onChange={(color) => handleColorChange(color, 'main')}
|
||||||
|
label={t('annotation.changeColor', 'Change Colour')}
|
||||||
|
/>
|
||||||
|
<DeleteButton />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate position for portal based on wrapper element
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selected || !annotation || !wrapperRef.current) {
|
||||||
|
setMenuPosition(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePosition = () => {
|
||||||
|
const wrapper = wrapperRef.current;
|
||||||
|
if (!wrapper) {
|
||||||
|
setMenuPosition(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrapperRect = wrapper.getBoundingClientRect();
|
||||||
|
// Position menu below the wrapper, centered
|
||||||
|
// Use getBoundingClientRect which gives viewport-relative coordinates
|
||||||
|
// Since we're using fixed positioning in the portal, we don't need to add scroll offsets
|
||||||
|
setMenuPosition({
|
||||||
|
top: wrapperRect.bottom + 8,
|
||||||
|
left: wrapperRect.left + wrapperRect.width / 2,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
updatePosition();
|
||||||
|
|
||||||
|
// Update position on scroll/resize
|
||||||
|
window.addEventListener('scroll', updatePosition, true);
|
||||||
|
window.addEventListener('resize', updatePosition);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', updatePosition, true);
|
||||||
|
window.removeEventListener('resize', updatePosition);
|
||||||
|
};
|
||||||
|
}, [selected, annotation]);
|
||||||
|
|
||||||
|
// Early return AFTER all hooks have been called
|
||||||
|
if (!selected || !annotation) return null;
|
||||||
|
|
||||||
|
const menuContent = menuPosition ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: `${menuPosition.top}px`,
|
||||||
|
left: `${menuPosition.left}px`,
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
zIndex: 10000, // Very high z-index to appear above everything
|
||||||
|
backgroundColor: 'var(--mantine-color-body)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '8px 12px',
|
||||||
|
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.25)',
|
||||||
|
border: '1px solid var(--mantine-color-default-border)',
|
||||||
|
fontSize: '14px',
|
||||||
|
minWidth: `${calculateWidth(annotationType)}px`,
|
||||||
|
transition: 'min-width 0.2s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap="sm" wrap="nowrap" justify="center">
|
||||||
|
{renderButtons()}
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const textEditorOverlay = isTextEditorOpen && textBoxPosition ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: `${textBoxPosition.top}px`,
|
||||||
|
left: `${textBoxPosition.left}px`,
|
||||||
|
width: `${textBoxPosition.width}px`,
|
||||||
|
height: `${textBoxPosition.height}px`,
|
||||||
|
zIndex: 10001,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={textDraft}
|
||||||
|
onChange={(e) => setTextDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
handleCloseTextEdit();
|
||||||
|
} else if (e.key === 'Enter' && e.ctrlKey) {
|
||||||
|
handleSaveText();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={handleSaveText}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
minHeight: '0',
|
||||||
|
minWidth: '0',
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '100%',
|
||||||
|
fontSize: `${textBoxPosition.fontSize}px`,
|
||||||
|
fontFamily: textBoxPosition.fontFamily,
|
||||||
|
lineHeight: '1.2',
|
||||||
|
color: '#000000',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
border: '2px solid var(--mantine-color-blue-5)',
|
||||||
|
borderRadius: '0',
|
||||||
|
padding: '0',
|
||||||
|
margin: '0',
|
||||||
|
resize: 'none',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
outline: 'none',
|
||||||
|
overflow: 'hidden',
|
||||||
|
wordWrap: 'break-word',
|
||||||
|
overflowWrap: 'break-word',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const canClickToEdit = selected && (annotationType === 'text' || annotationType === 'note') && !isTextEditorOpen;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Invisible wrapper that provides positioning - uses EmbedPDF's menuWrapperProps */}
|
||||||
|
<div
|
||||||
|
ref={setRef}
|
||||||
|
onClick={canClickToEdit ? handleOpenTextEditor : undefined}
|
||||||
|
style={{
|
||||||
|
// Use EmbedPDF's positioning styles
|
||||||
|
...menuWrapperProps?.style,
|
||||||
|
// Keep the wrapper invisible but still occupying space for positioning
|
||||||
|
opacity: 0,
|
||||||
|
pointerEvents: canClickToEdit ? 'auto' : 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{typeof document !== 'undefined' && menuContent
|
||||||
|
? createPortal(menuContent, document.body)
|
||||||
|
: null}
|
||||||
|
{typeof document !== 'undefined' && textEditorOverlay
|
||||||
|
? createPortal(textEditorOverlay, document.body)
|
||||||
|
: null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -50,6 +50,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { LinkLayer } from '@app/components/viewer/LinkLayer';
|
import { LinkLayer } from '@app/components/viewer/LinkLayer';
|
||||||
import { TextSelectionHandler } from '@app/components/viewer/TextSelectionHandler';
|
import { TextSelectionHandler } from '@app/components/viewer/TextSelectionHandler';
|
||||||
import { RedactionSelectionMenu } from '@app/components/viewer/RedactionSelectionMenu';
|
import { RedactionSelectionMenu } from '@app/components/viewer/RedactionSelectionMenu';
|
||||||
|
import { AnnotationSelectionMenu } from '@app/components/viewer/AnnotationSelectionMenu';
|
||||||
import { RedactionPendingTracker, RedactionPendingTrackerAPI } from '@app/components/viewer/RedactionPendingTracker';
|
import { RedactionPendingTracker, RedactionPendingTrackerAPI } from '@app/components/viewer/RedactionPendingTracker';
|
||||||
import { RedactionAPIBridge } from '@app/components/viewer/RedactionAPIBridge';
|
import { RedactionAPIBridge } from '@app/components/viewer/RedactionAPIBridge';
|
||||||
import { DocumentPermissionsAPIBridge } from '@app/components/viewer/DocumentPermissionsAPIBridge';
|
import { DocumentPermissionsAPIBridge } from '@app/components/viewer/DocumentPermissionsAPIBridge';
|
||||||
@@ -752,7 +753,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
|||||||
documentId={documentId}
|
documentId={documentId}
|
||||||
pageIndex={pageIndex}
|
pageIndex={pageIndex}
|
||||||
selectionOutlineColor="#007ACC"
|
selectionOutlineColor="#007ACC"
|
||||||
selectionMenu={(props) => <RedactionSelectionMenu {...props} />}
|
selectionMenu={(props) => <AnnotationSelectionMenu {...props} />}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -372,7 +372,6 @@ const Annotate = (_props: BaseToolProps) => {
|
|||||||
annotationApiRef,
|
annotationApiRef,
|
||||||
deriveToolFromAnnotation,
|
deriveToolFromAnnotation,
|
||||||
activeToolRef,
|
activeToolRef,
|
||||||
manualToolSwitch,
|
|
||||||
setActiveTool,
|
setActiveTool,
|
||||||
setSelectedTextDraft,
|
setSelectedTextDraft,
|
||||||
setSelectedFontSize,
|
setSelectedFontSize,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo, useRef, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 LocalIcon from '@app/components/shared/LocalIcon';
|
||||||
import { ColorPicker, ColorSwatchButton } from '@app/components/annotation/shared/ColorPicker';
|
import { ColorPicker, ColorSwatchButton } from '@app/components/annotation/shared/ColorPicker';
|
||||||
import { ImageUploader } from '@app/components/annotation/shared/ImageUploader';
|
import { ImageUploader } from '@app/components/annotation/shared/ImageUploader';
|
||||||
@@ -111,7 +111,6 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [colorPickerTarget, setColorPickerTarget] = useState<ColorTarget>(null);
|
const [colorPickerTarget, setColorPickerTarget] = useState<ColorTarget>(null);
|
||||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||||
const selectedUpdateTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
activeTool,
|
activeTool,
|
||||||
@@ -122,10 +121,6 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
|
|||||||
buildToolOptions,
|
buildToolOptions,
|
||||||
deriveToolFromAnnotation,
|
deriveToolFromAnnotation,
|
||||||
selectedAnn,
|
selectedAnn,
|
||||||
selectedTextDraft,
|
|
||||||
setSelectedTextDraft,
|
|
||||||
selectedFontSize,
|
|
||||||
setSelectedFontSize,
|
|
||||||
annotationApiRef,
|
annotationApiRef,
|
||||||
viewerContext,
|
viewerContext,
|
||||||
setPlacementMode,
|
setPlacementMode,
|
||||||
@@ -549,512 +544,6 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
|
|||||||
</Paper>
|
</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 = (
|
const colorPickerComponent = (
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
isOpen={isColorPickerOpen}
|
isOpen={isColorPickerOpen}
|
||||||
@@ -1287,11 +776,7 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
|
|||||||
{renderToolButtons(otherTools)}
|
{renderToolButtons(otherTools)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{activeTool !== 'select' && defaultStyleControls}
|
{activeTool === 'stamp' && defaultStyleControls}
|
||||||
|
|
||||||
{activeTool === 'select' && selectedAnn && selectedAnnotationControls}
|
|
||||||
|
|
||||||
{activeTool === 'select' && !selectedAnn && defaultStyleControls}
|
|
||||||
|
|
||||||
{colorPickerComponent}
|
{colorPickerComponent}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ interface UseAnnotationSelectionParams {
|
|||||||
annotationApiRef: React.RefObject<AnnotationAPI | null>;
|
annotationApiRef: React.RefObject<AnnotationAPI | null>;
|
||||||
deriveToolFromAnnotation: (annotation: any) => AnnotationToolId | undefined;
|
deriveToolFromAnnotation: (annotation: any) => AnnotationToolId | undefined;
|
||||||
activeToolRef: React.MutableRefObject<AnnotationToolId>;
|
activeToolRef: React.MutableRefObject<AnnotationToolId>;
|
||||||
manualToolSwitch: React.MutableRefObject<boolean>;
|
|
||||||
setActiveTool: (toolId: AnnotationToolId) => void;
|
setActiveTool: (toolId: AnnotationToolId) => void;
|
||||||
setSelectedTextDraft: (text: string) => void;
|
setSelectedTextDraft: (text: string) => void;
|
||||||
setSelectedFontSize: (size: number) => void;
|
setSelectedFontSize: (size: number) => void;
|
||||||
@@ -34,6 +33,7 @@ interface UseAnnotationSelectionParams {
|
|||||||
|
|
||||||
const MARKUP_TOOL_IDS = ['highlight', 'underline', 'strikeout', 'squiggly'] as const;
|
const MARKUP_TOOL_IDS = ['highlight', 'underline', 'strikeout', 'squiggly'] as const;
|
||||||
const DRAWING_TOOL_IDS = ['ink', 'inkHighlighter'] 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 isTextMarkupAnnotation = (annotation: any): boolean => {
|
||||||
const toolId =
|
const toolId =
|
||||||
@@ -55,6 +55,9 @@ const isTextMarkupAnnotation = (annotation: any): boolean => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const shouldStayOnPlacementTool = (annotation: any, derivedTool?: string | null | undefined): 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 =
|
const toolId =
|
||||||
derivedTool ||
|
derivedTool ||
|
||||||
annotation?.customData?.annotationToolId ||
|
annotation?.customData?.annotationToolId ||
|
||||||
@@ -62,12 +65,17 @@ const shouldStayOnPlacementTool = (annotation: any, derivedTool?: string | null
|
|||||||
annotation?.object?.customData?.annotationToolId ||
|
annotation?.object?.customData?.annotationToolId ||
|
||||||
annotation?.object?.customData?.toolId;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
const type = annotation?.type ?? annotation?.object?.type;
|
|
||||||
if (typeof type === 'number' && type === 15) return true; // ink family
|
// Check if it's a markup annotation by type/subtype
|
||||||
if (isTextMarkupAnnotation(annotation)) return true;
|
if (isTextMarkupAnnotation(annotation)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// All other tools (text, note, shapes, lines, stamps) switch to select
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -75,7 +83,6 @@ export function useAnnotationSelection({
|
|||||||
annotationApiRef,
|
annotationApiRef,
|
||||||
deriveToolFromAnnotation,
|
deriveToolFromAnnotation,
|
||||||
activeToolRef,
|
activeToolRef,
|
||||||
manualToolSwitch,
|
|
||||||
setActiveTool,
|
setActiveTool,
|
||||||
setSelectedTextDraft,
|
setSelectedTextDraft,
|
||||||
setSelectedFontSize,
|
setSelectedFontSize,
|
||||||
@@ -226,8 +233,8 @@ export function useAnnotationSelection({
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
activeToolRef,
|
activeToolRef,
|
||||||
|
annotationApiRef,
|
||||||
deriveToolFromAnnotation,
|
deriveToolFromAnnotation,
|
||||||
manualToolSwitch,
|
|
||||||
setActiveTool,
|
setActiveTool,
|
||||||
setInkWidth,
|
setInkWidth,
|
||||||
setNoteBackgroundColor,
|
setNoteBackgroundColor,
|
||||||
@@ -252,7 +259,6 @@ export function useAnnotationSelection({
|
|||||||
setShapeFillOpacity,
|
setShapeFillOpacity,
|
||||||
setTextAlignment,
|
setTextAlignment,
|
||||||
setFreehandHighlighterWidth,
|
setFreehandHighlighterWidth,
|
||||||
shouldStayOnPlacementTool,
|
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -304,9 +310,7 @@ export function useAnnotationSelection({
|
|||||||
const tool =
|
const tool =
|
||||||
deriveToolFromAnnotation((eventAnn as any)?.object ?? eventAnn ?? api.getSelectedAnnotation?.()) ||
|
deriveToolFromAnnotation((eventAnn as any)?.object ?? eventAnn ?? api.getSelectedAnnotation?.()) ||
|
||||||
currentTool;
|
currentTool;
|
||||||
const stayOnPlacement =
|
const stayOnPlacement = shouldStayOnPlacementTool(eventAnn, tool);
|
||||||
shouldStayOnPlacementTool(eventAnn, tool) ||
|
|
||||||
(tool ? DRAWING_TOOL_IDS.includes(tool as any) : false);
|
|
||||||
if (activeToolRef.current !== 'select' && !stayOnPlacement) {
|
if (activeToolRef.current !== 'select' && !stayOnPlacement) {
|
||||||
activeToolRef.current = 'select';
|
activeToolRef.current = 'select';
|
||||||
setActiveTool('select');
|
setActiveTool('select');
|
||||||
@@ -318,9 +322,7 @@ export function useAnnotationSelection({
|
|||||||
applySelectionFromAnnotation(selected ?? eventAnn ?? null);
|
applySelectionFromAnnotation(selected ?? eventAnn ?? null);
|
||||||
const derivedAfter =
|
const derivedAfter =
|
||||||
deriveToolFromAnnotation((selected as any)?.object ?? selected ?? eventAnn ?? null) || activeToolRef.current;
|
deriveToolFromAnnotation((selected as any)?.object ?? selected ?? eventAnn ?? null) || activeToolRef.current;
|
||||||
const stayOnPlacementAfter =
|
const stayOnPlacementAfter = shouldStayOnPlacementTool(selected ?? eventAnn ?? null, derivedAfter);
|
||||||
shouldStayOnPlacementTool(selected ?? eventAnn ?? null, derivedAfter) ||
|
|
||||||
(derivedAfter ? DRAWING_TOOL_IDS.includes(derivedAfter as any) : false);
|
|
||||||
if (activeToolRef.current !== 'select' && !stayOnPlacementAfter) {
|
if (activeToolRef.current !== 'select' && !stayOnPlacementAfter) {
|
||||||
activeToolRef.current = 'select';
|
activeToolRef.current = 'select';
|
||||||
setActiveTool('select');
|
setActiveTool('select');
|
||||||
|
|||||||
Reference in New Issue
Block a user