mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-12-18 20:04:17 +01:00
Merge branch 'main' into add_telegram
This commit is contained in:
commit
b1d7415dad
@ -736,6 +736,11 @@ tags = "signature,autograph"
|
||||
title = "Sign"
|
||||
desc = "Adds signature to PDF by drawing, text or image"
|
||||
|
||||
[home.annotate]
|
||||
tags = "annotate,highlight,draw"
|
||||
title = "Annotate"
|
||||
desc = "Highlight, draw, add notes and shapes in the viewer"
|
||||
|
||||
[home.flatten]
|
||||
tags = "simplify,remove,interactive"
|
||||
title = "Flatten"
|
||||
@ -4013,23 +4018,92 @@ deleteSelected = "Delete Selected Pages"
|
||||
closePdf = "Close PDF"
|
||||
exportAll = "Export PDF"
|
||||
downloadSelected = "Download Selected Files"
|
||||
downloadAll = "Download All"
|
||||
saveAll = "Save All"
|
||||
annotations = "Annotations"
|
||||
exportSelected = "Export Selected Pages"
|
||||
saveChanges = "Save Changes"
|
||||
toggleTheme = "Toggle Theme"
|
||||
toggleBookmarks = "Toggle Bookmarks"
|
||||
language = "Language"
|
||||
toggleAnnotations = "Toggle Annotations Visibility"
|
||||
search = "Search PDF"
|
||||
panMode = "Pan Mode"
|
||||
rotateLeft = "Rotate Left"
|
||||
rotateRight = "Rotate Right"
|
||||
toggleSidebar = "Toggle Sidebar"
|
||||
exportSelected = "Export Selected Pages"
|
||||
toggleAnnotations = "Toggle Annotations Visibility"
|
||||
annotationMode = "Toggle Annotation Mode"
|
||||
toggleBookmarks = "Toggle Bookmarks"
|
||||
print = "Print PDF"
|
||||
draw = "Draw"
|
||||
save = "Save"
|
||||
saveChanges = "Save Changes"
|
||||
downloadAll = "Download All"
|
||||
saveAll = "Save All"
|
||||
|
||||
[textAlign]
|
||||
left = "Left"
|
||||
center = "Center"
|
||||
right = "Right"
|
||||
|
||||
[annotation]
|
||||
title = "Annotate"
|
||||
desc = "Use highlight, pen, text, and notes. Changes stay live—no flattening required."
|
||||
highlight = "Highlight"
|
||||
pen = "Pen"
|
||||
text = "Text box"
|
||||
note = "Note"
|
||||
rectangle = "Rectangle"
|
||||
ellipse = "Ellipse"
|
||||
select = "Select"
|
||||
exit = "Exit annotation mode"
|
||||
strokeWidth = "Width"
|
||||
opacity = "Opacity"
|
||||
strokeOpacity = "Stroke Opacity"
|
||||
fillOpacity = "Fill Opacity"
|
||||
fontSize = "Font size"
|
||||
chooseColor = "Choose colour"
|
||||
color = "Colour"
|
||||
strokeColor = "Stroke Colour"
|
||||
fillColor = "Fill Colour"
|
||||
underline = "Underline"
|
||||
strikeout = "Strikeout"
|
||||
squiggly = "Squiggly"
|
||||
inkHighlighter = "Freehand Highlighter"
|
||||
freehandHighlighter = "Freehand Highlighter"
|
||||
square = "Square"
|
||||
circle = "Circle"
|
||||
polygon = "Polygon"
|
||||
line = "Line"
|
||||
stamp = "Add Image"
|
||||
textMarkup = "Text Markup"
|
||||
drawing = "Drawing"
|
||||
shapes = "Shapes"
|
||||
notesStamps = "Notes & Stamps"
|
||||
settings = "Settings"
|
||||
borderOn = "Border: On"
|
||||
borderOff = "Border: Off"
|
||||
editInk = "Edit Pen"
|
||||
editLine = "Edit Line"
|
||||
editNote = "Edit Note"
|
||||
editText = "Edit Text Box"
|
||||
editTextMarkup = "Edit Text Markup"
|
||||
editSelected = "Edit Annotation"
|
||||
editSquare = "Edit Square"
|
||||
editCircle = "Edit Circle"
|
||||
editPolygon = "Edit Polygon"
|
||||
unsupportedType = "This annotation type is not fully supported for editing."
|
||||
textAlignment = "Text Alignment"
|
||||
noteIcon = "Note Icon"
|
||||
imagePreview = "Preview"
|
||||
contents = "Text"
|
||||
backgroundColor = "Background colour"
|
||||
clearBackground = "Remove background"
|
||||
noBackground = "No background"
|
||||
stampSettings = "Stamp Settings"
|
||||
savingCopy = "Preparing download..."
|
||||
saveFailed = "Unable to save copy"
|
||||
saveReady = "Download ready"
|
||||
selectAndMove = "Select and Edit"
|
||||
editSelectDescription = "Click an existing annotation to edit its colour, opacity, text, or size."
|
||||
editStampHint = "To change the image, delete this stamp and add a new one."
|
||||
editSwitchToSelect = "Switch to Select & Edit to edit this annotation."
|
||||
undo = "Undo"
|
||||
redo = "Redo"
|
||||
applyChanges = "Apply Changes"
|
||||
|
||||
[search]
|
||||
title = "Search PDF"
|
||||
|
||||
@ -12,6 +12,7 @@ import { AppConfigProvider, AppConfigProviderProps, AppConfigRetryOptions } from
|
||||
import { RightRailProvider } from "@app/contexts/RightRailContext";
|
||||
import { ViewerProvider } from "@app/contexts/ViewerContext";
|
||||
import { SignatureProvider } from "@app/contexts/SignatureContext";
|
||||
import { AnnotationProvider } from "@app/contexts/AnnotationContext";
|
||||
import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext";
|
||||
import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext";
|
||||
import { PageEditorProvider } from "@app/contexts/PageEditorContext";
|
||||
@ -95,13 +96,15 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
||||
<ViewerProvider>
|
||||
<PageEditorProvider>
|
||||
<SignatureProvider>
|
||||
<RightRailProvider>
|
||||
<TourOrchestrationProvider>
|
||||
<AdminTourOrchestrationProvider>
|
||||
{children}
|
||||
</AdminTourOrchestrationProvider>
|
||||
</TourOrchestrationProvider>
|
||||
</RightRailProvider>
|
||||
<AnnotationProvider>
|
||||
<RightRailProvider>
|
||||
<TourOrchestrationProvider>
|
||||
<AdminTourOrchestrationProvider>
|
||||
{children}
|
||||
</AdminTourOrchestrationProvider>
|
||||
</TourOrchestrationProvider>
|
||||
</RightRailProvider>
|
||||
</AnnotationProvider>
|
||||
</SignatureProvider>
|
||||
</PageEditorProvider>
|
||||
</ViewerProvider>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch } from '@mantine/core';
|
||||
import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch, Slider, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ColorPickerProps {
|
||||
@ -8,6 +8,10 @@ interface ColorPickerProps {
|
||||
selectedColor: string;
|
||||
onColorChange: (color: string) => void;
|
||||
title?: string;
|
||||
opacity?: number;
|
||||
onOpacityChange?: (opacity: number) => void;
|
||||
showOpacity?: boolean;
|
||||
opacityLabel?: string;
|
||||
}
|
||||
|
||||
export const ColorPicker: React.FC<ColorPickerProps> = ({
|
||||
@ -15,10 +19,15 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
|
||||
onClose,
|
||||
selectedColor,
|
||||
onColorChange,
|
||||
title
|
||||
title,
|
||||
opacity,
|
||||
onOpacityChange,
|
||||
showOpacity = false,
|
||||
opacityLabel,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const resolvedTitle = title ?? t('colorPicker.title', 'Choose colour');
|
||||
const resolvedOpacityLabel = opacityLabel ?? t('annotation.opacity', 'Opacity');
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@ -38,6 +47,23 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
|
||||
size="lg"
|
||||
fullWidth
|
||||
/>
|
||||
{showOpacity && onOpacityChange && opacity !== undefined && (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>{resolvedOpacityLabel}</Text>
|
||||
<Slider
|
||||
min={10}
|
||||
max={100}
|
||||
value={opacity}
|
||||
onChange={onOpacityChange}
|
||||
marks={[
|
||||
{ value: 25, label: '25%' },
|
||||
{ value: 50, label: '50%' },
|
||||
{ value: 75, label: '75%' },
|
||||
{ value: 100, label: '100%' },
|
||||
]}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={onClose}>
|
||||
{t('common.done', 'Done')}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box } from '@mantine/core';
|
||||
import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box, SegmentedControl } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ColorPicker } from '@app/components/annotation/shared/ColorPicker';
|
||||
|
||||
interface TextInputWithFontProps {
|
||||
@ -11,6 +12,8 @@ interface TextInputWithFontProps {
|
||||
onFontFamilyChange: (family: string) => void;
|
||||
textColor?: string;
|
||||
onTextColorChange?: (color: string) => void;
|
||||
textAlign?: 'left' | 'center' | 'right';
|
||||
onTextAlignChange?: (align: 'left' | 'center' | 'right') => void;
|
||||
disabled?: boolean;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
@ -30,6 +33,8 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
onFontFamilyChange,
|
||||
textColor = '#000000',
|
||||
onTextColorChange,
|
||||
textAlign = 'left',
|
||||
onTextAlignChange,
|
||||
disabled = false,
|
||||
label,
|
||||
placeholder,
|
||||
@ -39,6 +44,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
colorLabel,
|
||||
onAnyChange
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
|
||||
const fontSizeCombobox = useCombobox();
|
||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||
@ -212,6 +218,23 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Text Alignment */}
|
||||
{onTextAlignChange && (
|
||||
<SegmentedControl
|
||||
value={textAlign}
|
||||
onChange={(value: string) => {
|
||||
onTextAlignChange(value as 'left' | 'center' | 'right');
|
||||
onAnyChange?.();
|
||||
}}
|
||||
disabled={disabled}
|
||||
data={[
|
||||
{ label: t('textAlign.left', 'Left'), value: 'left' },
|
||||
{ label: t('textAlign.center', 'Center'), value: 'center' },
|
||||
{ label: t('textAlign.right', 'Right'), value: 'right' },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@ -25,6 +25,12 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
|
||||
const { selectedTool } = useNavigationState();
|
||||
const isSignMode = selectedTool === 'sign';
|
||||
|
||||
// Check if we're in any annotation tool that should disable the toggle
|
||||
const isInAnnotationTool = selectedTool === 'annotate' || selectedTool === 'sign' || selectedTool === 'addImage' || selectedTool === 'addText';
|
||||
|
||||
// Check if we're on annotate tool to highlight the button
|
||||
const isAnnotateActive = selectedTool === 'annotate';
|
||||
|
||||
// Don't show any annotation controls in sign mode
|
||||
if (isSignMode) {
|
||||
return null;
|
||||
@ -35,13 +41,14 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
|
||||
{/* Annotation Visibility Toggle */}
|
||||
<Tooltip content={t('rightRail.toggleAnnotations', 'Toggle Annotations Visibility')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
variant={isAnnotateActive ? "filled" : "subtle"}
|
||||
color="blue"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
viewerContext?.toggleAnnotationsVisibility();
|
||||
}}
|
||||
disabled={disabled || currentView !== 'viewer'}
|
||||
disabled={disabled || currentView !== 'viewer' || isInAnnotationTool}
|
||||
>
|
||||
<LocalIcon
|
||||
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "visibility-off-rounded"}
|
||||
|
||||
352
frontend/src/core/components/viewer/AnnotationAPIBridge.tsx
Normal file
352
frontend/src/core/components/viewer/AnnotationAPIBridge.tsx
Normal file
@ -0,0 +1,352 @@
|
||||
import { useImperativeHandle, forwardRef, useCallback } from 'react';
|
||||
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
|
||||
import { PdfAnnotationSubtype, PdfAnnotationIcon } from '@embedpdf/models';
|
||||
import type {
|
||||
AnnotationToolId,
|
||||
AnnotationToolOptions,
|
||||
AnnotationAPI,
|
||||
AnnotationEvent,
|
||||
AnnotationPatch,
|
||||
} from '@app/components/viewer/viewerTypes';
|
||||
|
||||
type NoteIcon = NonNullable<AnnotationToolOptions['icon']>;
|
||||
type AnnotationDefaults =
|
||||
| {
|
||||
type:
|
||||
| PdfAnnotationSubtype.HIGHLIGHT
|
||||
| PdfAnnotationSubtype.UNDERLINE
|
||||
| PdfAnnotationSubtype.STRIKEOUT
|
||||
| PdfAnnotationSubtype.SQUIGGLY;
|
||||
color: string;
|
||||
opacity: number;
|
||||
customData?: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
type: PdfAnnotationSubtype.INK;
|
||||
color: string;
|
||||
opacity?: number;
|
||||
borderWidth?: number;
|
||||
strokeWidth?: number;
|
||||
lineWidth?: number;
|
||||
customData?: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
type: PdfAnnotationSubtype.FREETEXT;
|
||||
fontColor?: string;
|
||||
fontSize?: number;
|
||||
fontFamily?: string;
|
||||
textAlign?: number;
|
||||
opacity?: number;
|
||||
backgroundColor?: string;
|
||||
borderWidth?: number;
|
||||
contents?: string;
|
||||
icon?: PdfAnnotationIcon;
|
||||
customData?: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
type: PdfAnnotationSubtype.SQUARE | PdfAnnotationSubtype.CIRCLE | PdfAnnotationSubtype.POLYGON;
|
||||
color: string;
|
||||
strokeColor: string;
|
||||
opacity: number;
|
||||
fillOpacity: number;
|
||||
strokeOpacity: number;
|
||||
borderWidth: number;
|
||||
strokeWidth: number;
|
||||
lineWidth: number;
|
||||
customData?: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
type: PdfAnnotationSubtype.LINE | PdfAnnotationSubtype.POLYLINE;
|
||||
color: string;
|
||||
strokeColor?: string;
|
||||
opacity: number;
|
||||
borderWidth?: number;
|
||||
strokeWidth?: number;
|
||||
lineWidth?: number;
|
||||
startStyle?: string;
|
||||
endStyle?: string;
|
||||
lineEndingStyles?: { start: string; end: string };
|
||||
customData?: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
type: PdfAnnotationSubtype.STAMP;
|
||||
imageSrc?: string;
|
||||
imageSize?: { width: number; height: number };
|
||||
customData?: Record<string, unknown>;
|
||||
}
|
||||
| null;
|
||||
|
||||
type AnnotationApiSurface = {
|
||||
setActiveTool: (toolId: AnnotationToolId | null) => void;
|
||||
getActiveTool?: () => { id: AnnotationToolId } | null;
|
||||
setToolDefaults?: (toolId: AnnotationToolId, defaults: AnnotationDefaults) => void;
|
||||
getSelectedAnnotation?: () => unknown | null;
|
||||
deselectAnnotation?: () => void;
|
||||
updateAnnotation?: (pageIndex: number, annotationId: string, patch: AnnotationPatch) => void;
|
||||
onAnnotationEvent?: (listener: (event: AnnotationEvent) => void) => void | (() => void);
|
||||
};
|
||||
|
||||
type ToolDefaultsBuilder = (options?: AnnotationToolOptions) => AnnotationDefaults;
|
||||
|
||||
const NOTE_ICON_MAP: Record<NoteIcon, PdfAnnotationIcon> = {
|
||||
Comment: PdfAnnotationIcon.Comment,
|
||||
Key: PdfAnnotationIcon.Key,
|
||||
Note: PdfAnnotationIcon.Note,
|
||||
Help: PdfAnnotationIcon.Help,
|
||||
NewParagraph: PdfAnnotationIcon.NewParagraph,
|
||||
Paragraph: PdfAnnotationIcon.Paragraph,
|
||||
Insert: PdfAnnotationIcon.Insert,
|
||||
};
|
||||
|
||||
const DEFAULTS = {
|
||||
highlight: '#ffd54f',
|
||||
underline: '#ffb300',
|
||||
strikeout: '#e53935',
|
||||
squiggly: '#00acc1',
|
||||
ink: '#1f2933',
|
||||
inkHighlighter: '#ffd54f',
|
||||
text: '#111111',
|
||||
note: '#ffd54f', // match highlight color
|
||||
shapeFill: '#0000ff',
|
||||
shapeStroke: '#cf5b5b',
|
||||
shapeOpacity: 0.5,
|
||||
};
|
||||
|
||||
const withCustomData = (options?: AnnotationToolOptions) =>
|
||||
options?.customData ? { customData: options.customData } : {};
|
||||
|
||||
const getIconEnum = (icon?: NoteIcon) => NOTE_ICON_MAP[icon ?? 'Comment'] ?? PdfAnnotationIcon.Comment;
|
||||
|
||||
const buildStampDefaults: ToolDefaultsBuilder = (options) => ({
|
||||
type: PdfAnnotationSubtype.STAMP,
|
||||
...(options?.imageSrc ? { imageSrc: options.imageSrc } : {}),
|
||||
...(options?.imageSize ? { imageSize: options.imageSize } : {}),
|
||||
...withCustomData(options),
|
||||
});
|
||||
|
||||
const buildInkDefaults = (options?: AnnotationToolOptions, opacityOverride?: number): AnnotationDefaults => ({
|
||||
type: PdfAnnotationSubtype.INK,
|
||||
color: options?.color ?? (opacityOverride ? DEFAULTS.inkHighlighter : DEFAULTS.ink),
|
||||
opacity: options?.opacity ?? opacityOverride ?? 1,
|
||||
borderWidth: options?.thickness ?? (opacityOverride ? 6 : 2),
|
||||
strokeWidth: options?.thickness ?? (opacityOverride ? 6 : 2),
|
||||
lineWidth: options?.thickness ?? (opacityOverride ? 6 : 2),
|
||||
...withCustomData(options),
|
||||
});
|
||||
|
||||
const TOOL_DEFAULT_BUILDERS: Record<AnnotationToolId, ToolDefaultsBuilder> = {
|
||||
select: () => null,
|
||||
highlight: (options) => ({
|
||||
type: PdfAnnotationSubtype.HIGHLIGHT,
|
||||
color: options?.color ?? DEFAULTS.highlight,
|
||||
opacity: options?.opacity ?? 0.6,
|
||||
...withCustomData(options),
|
||||
}),
|
||||
underline: (options) => ({
|
||||
type: PdfAnnotationSubtype.UNDERLINE,
|
||||
color: options?.color ?? DEFAULTS.underline,
|
||||
opacity: options?.opacity ?? 1,
|
||||
...withCustomData(options),
|
||||
}),
|
||||
strikeout: (options) => ({
|
||||
type: PdfAnnotationSubtype.STRIKEOUT,
|
||||
color: options?.color ?? DEFAULTS.strikeout,
|
||||
opacity: options?.opacity ?? 1,
|
||||
...withCustomData(options),
|
||||
}),
|
||||
squiggly: (options) => ({
|
||||
type: PdfAnnotationSubtype.SQUIGGLY,
|
||||
color: options?.color ?? DEFAULTS.squiggly,
|
||||
opacity: options?.opacity ?? 1,
|
||||
...withCustomData(options),
|
||||
}),
|
||||
ink: (options) => buildInkDefaults(options),
|
||||
inkHighlighter: (options) => buildInkDefaults(options, options?.opacity ?? 0.6),
|
||||
text: (options) => ({
|
||||
type: PdfAnnotationSubtype.FREETEXT,
|
||||
fontColor: options?.color ?? DEFAULTS.text,
|
||||
fontSize: options?.fontSize ?? 14,
|
||||
fontFamily: options?.fontFamily ?? 'Helvetica',
|
||||
textAlign: options?.textAlign ?? 0,
|
||||
opacity: options?.opacity ?? 1,
|
||||
borderWidth: options?.thickness ?? 1,
|
||||
...(options?.fillColor ? { backgroundColor: options.fillColor } : {}),
|
||||
...withCustomData(options),
|
||||
}),
|
||||
note: (options) => {
|
||||
const backgroundColor = options?.fillColor ?? DEFAULTS.note;
|
||||
const fontColor = options?.color ?? DEFAULTS.text;
|
||||
return {
|
||||
type: PdfAnnotationSubtype.FREETEXT,
|
||||
fontColor,
|
||||
color: fontColor,
|
||||
fontFamily: options?.fontFamily ?? 'Helvetica',
|
||||
textAlign: options?.textAlign ?? 0,
|
||||
fontSize: options?.fontSize ?? 12,
|
||||
opacity: options?.opacity ?? 1,
|
||||
backgroundColor,
|
||||
borderWidth: options?.thickness ?? 0,
|
||||
contents: options?.contents ?? 'Note',
|
||||
icon: getIconEnum(options?.icon),
|
||||
...withCustomData(options),
|
||||
};
|
||||
},
|
||||
square: (options) => ({
|
||||
type: PdfAnnotationSubtype.SQUARE,
|
||||
color: options?.color ?? DEFAULTS.shapeFill,
|
||||
strokeColor: options?.strokeColor ?? DEFAULTS.shapeStroke,
|
||||
opacity: options?.opacity ?? DEFAULTS.shapeOpacity,
|
||||
fillOpacity: options?.fillOpacity ?? DEFAULTS.shapeOpacity,
|
||||
strokeOpacity: options?.strokeOpacity ?? DEFAULTS.shapeOpacity,
|
||||
borderWidth: options?.borderWidth ?? 1,
|
||||
strokeWidth: options?.borderWidth ?? 1,
|
||||
lineWidth: options?.borderWidth ?? 1,
|
||||
...withCustomData(options),
|
||||
}),
|
||||
circle: (options) => ({
|
||||
type: PdfAnnotationSubtype.CIRCLE,
|
||||
color: options?.color ?? DEFAULTS.shapeFill,
|
||||
strokeColor: options?.strokeColor ?? DEFAULTS.shapeStroke,
|
||||
opacity: options?.opacity ?? DEFAULTS.shapeOpacity,
|
||||
fillOpacity: options?.fillOpacity ?? DEFAULTS.shapeOpacity,
|
||||
strokeOpacity: options?.strokeOpacity ?? DEFAULTS.shapeOpacity,
|
||||
borderWidth: options?.borderWidth ?? 1,
|
||||
strokeWidth: options?.borderWidth ?? 1,
|
||||
lineWidth: options?.borderWidth ?? 1,
|
||||
...withCustomData(options),
|
||||
}),
|
||||
line: (options) => ({
|
||||
type: PdfAnnotationSubtype.LINE,
|
||||
color: options?.color ?? '#1565c0',
|
||||
strokeColor: options?.color ?? '#1565c0',
|
||||
opacity: options?.opacity ?? 1,
|
||||
borderWidth: options?.borderWidth ?? 2,
|
||||
strokeWidth: options?.borderWidth ?? 2,
|
||||
lineWidth: options?.borderWidth ?? 2,
|
||||
...withCustomData(options),
|
||||
}),
|
||||
lineArrow: (options) => ({
|
||||
type: PdfAnnotationSubtype.LINE,
|
||||
color: options?.color ?? '#1565c0',
|
||||
strokeColor: options?.color ?? '#1565c0',
|
||||
opacity: options?.opacity ?? 1,
|
||||
borderWidth: options?.borderWidth ?? 2,
|
||||
strokeWidth: options?.borderWidth ?? 2,
|
||||
lineWidth: options?.borderWidth ?? 2,
|
||||
startStyle: 'None',
|
||||
endStyle: 'ClosedArrow',
|
||||
lineEndingStyles: { start: 'None', end: 'ClosedArrow' },
|
||||
...withCustomData(options),
|
||||
}),
|
||||
polyline: (options) => ({
|
||||
type: PdfAnnotationSubtype.POLYLINE,
|
||||
color: options?.color ?? '#1565c0',
|
||||
opacity: options?.opacity ?? 1,
|
||||
borderWidth: options?.borderWidth ?? 2,
|
||||
...withCustomData(options),
|
||||
}),
|
||||
polygon: (options) => ({
|
||||
type: PdfAnnotationSubtype.POLYGON,
|
||||
color: options?.color ?? DEFAULTS.shapeFill,
|
||||
strokeColor: options?.strokeColor ?? DEFAULTS.shapeStroke,
|
||||
opacity: options?.opacity ?? DEFAULTS.shapeOpacity,
|
||||
fillOpacity: options?.fillOpacity ?? DEFAULTS.shapeOpacity,
|
||||
strokeOpacity: options?.strokeOpacity ?? DEFAULTS.shapeOpacity,
|
||||
borderWidth: options?.borderWidth ?? 1,
|
||||
strokeWidth: options?.borderWidth ?? 1,
|
||||
lineWidth: options?.borderWidth ?? 1,
|
||||
...withCustomData(options),
|
||||
}),
|
||||
stamp: buildStampDefaults,
|
||||
signatureStamp: buildStampDefaults,
|
||||
signatureInk: (options) => buildInkDefaults(options),
|
||||
};
|
||||
|
||||
export const AnnotationAPIBridge = forwardRef<AnnotationAPI>(function AnnotationAPIBridge(_props, ref) {
|
||||
// Use the provided annotation API just like SignatureAPIBridge/HistoryAPIBridge
|
||||
const { provides: annotationApi } = useAnnotationCapability();
|
||||
|
||||
const buildAnnotationDefaults = useCallback(
|
||||
(toolId: AnnotationToolId, options?: AnnotationToolOptions) =>
|
||||
TOOL_DEFAULT_BUILDERS[toolId]?.(options) ?? null,
|
||||
[]
|
||||
);
|
||||
|
||||
const configureAnnotationTool = useCallback(
|
||||
(toolId: AnnotationToolId, options?: AnnotationToolOptions) => {
|
||||
const api = annotationApi as AnnotationApiSurface | undefined;
|
||||
if (!api?.setActiveTool) return;
|
||||
|
||||
const defaults = buildAnnotationDefaults(toolId, options);
|
||||
|
||||
// Reset tool first, then activate (like SignatureAPIBridge does)
|
||||
api.setActiveTool(null);
|
||||
api.setActiveTool(toolId === 'select' ? null : toolId);
|
||||
|
||||
// Verify tool was activated before setting defaults (like SignatureAPIBridge does)
|
||||
const activeTool = api.getActiveTool?.();
|
||||
if (activeTool && activeTool.id === toolId && defaults) {
|
||||
api.setToolDefaults?.(toolId, defaults);
|
||||
}
|
||||
},
|
||||
[annotationApi, buildAnnotationDefaults]
|
||||
);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
activateAnnotationTool: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => {
|
||||
configureAnnotationTool(toolId, options);
|
||||
},
|
||||
setAnnotationStyle: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => {
|
||||
const defaults = buildAnnotationDefaults(toolId, options);
|
||||
const api = annotationApi as AnnotationApiSurface | undefined;
|
||||
if (defaults && api?.setToolDefaults) {
|
||||
api.setToolDefaults(toolId, defaults);
|
||||
}
|
||||
},
|
||||
getSelectedAnnotation: () => {
|
||||
const api = annotationApi as AnnotationApiSurface | undefined;
|
||||
if (!api?.getSelectedAnnotation) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return api.getSelectedAnnotation();
|
||||
} catch (error) {
|
||||
// Some EmbedPDF builds expose getSelectedAnnotation with an internal
|
||||
// `this`/state dependency (e.g. reading `selectedUid` from undefined).
|
||||
// If that happens, fail gracefully and treat it as "no selection"
|
||||
// instead of crashing the entire annotations tool.
|
||||
console.error('[AnnotationAPIBridge] getSelectedAnnotation failed:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
deselectAnnotation: () => {
|
||||
const api = annotationApi as AnnotationApiSurface | undefined;
|
||||
api?.deselectAnnotation?.();
|
||||
},
|
||||
updateAnnotation: (pageIndex: number, annotationId: string, patch: AnnotationPatch) => {
|
||||
const api = annotationApi as AnnotationApiSurface | undefined;
|
||||
api?.updateAnnotation?.(pageIndex, annotationId, patch);
|
||||
},
|
||||
deactivateTools: () => {
|
||||
const api = annotationApi as AnnotationApiSurface | undefined;
|
||||
api?.setActiveTool?.(null);
|
||||
},
|
||||
onAnnotationEvent: (listener: (event: AnnotationEvent) => void) => {
|
||||
const api = annotationApi as AnnotationApiSurface | undefined;
|
||||
if (api?.onAnnotationEvent) {
|
||||
return api.onAnnotationEvent(listener);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
getActiveTool: () => {
|
||||
const api = annotationApi as AnnotationApiSurface | undefined;
|
||||
return api?.getActiveTool?.() ?? null;
|
||||
},
|
||||
}),
|
||||
[annotationApi, configureAnnotationTool, buildAnnotationDefaults]
|
||||
);
|
||||
|
||||
return null;
|
||||
});
|
||||
@ -56,9 +56,6 @@ const EmbedPdfViewerContent = ({
|
||||
exportActions,
|
||||
} = useViewer();
|
||||
|
||||
// Register viewer right-rail buttons
|
||||
useViewerRightRailButtons();
|
||||
|
||||
const scrollState = getScrollState();
|
||||
const rotationState = getRotationState();
|
||||
|
||||
@ -70,8 +67,13 @@ const EmbedPdfViewerContent = ({
|
||||
}
|
||||
}, [rotationState.rotation]);
|
||||
|
||||
// Get signature context
|
||||
const { signatureApiRef, historyApiRef, signatureConfig, isPlacementMode } = useSignature();
|
||||
// Get signature and annotation contexts
|
||||
const { signatureApiRef, annotationApiRef, historyApiRef, signatureConfig, isPlacementMode } = useSignature();
|
||||
|
||||
// Track whether there are unsaved annotation changes in this viewer session.
|
||||
// This is our source of truth for navigation guards; it is set when the
|
||||
// annotation history changes, and cleared after we successfully apply changes.
|
||||
const hasAnnotationChangesRef = useRef(false);
|
||||
|
||||
// Get current file from FileContext
|
||||
const { selectors, state } = useFileState();
|
||||
@ -85,8 +87,8 @@ const EmbedPdfViewerContent = ({
|
||||
|
||||
// Check if we're in an annotation tool
|
||||
const { selectedTool } = useNavigationState();
|
||||
// Tools that require the annotation layer (Sign, Add Text, Add Image)
|
||||
const isInAnnotationTool = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage';
|
||||
// Tools that require the annotation layer (Sign, Add Text, Add Image, Annotate)
|
||||
const isInAnnotationTool = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage' || selectedTool === 'annotate';
|
||||
|
||||
// Sync isAnnotationMode in ViewerContext with current tool
|
||||
useEffect(() => {
|
||||
@ -225,6 +227,31 @@ const EmbedPdfViewerContent = ({
|
||||
};
|
||||
}, [isViewerHovered, isSearchInterfaceVisible, zoomActions, searchInterfaceActions]);
|
||||
|
||||
// Watch the annotation history API to detect when the document becomes "dirty".
|
||||
// We treat any change that makes the history undoable as unsaved changes until
|
||||
// the user explicitly applies them via applyChanges.
|
||||
useEffect(() => {
|
||||
const historyApi = historyApiRef.current;
|
||||
if (!historyApi || !historyApi.subscribe) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateHasChanges = () => {
|
||||
const canUndo = historyApi.canUndo?.() ?? false;
|
||||
if (!hasAnnotationChangesRef.current && canUndo) {
|
||||
hasAnnotationChangesRef.current = true;
|
||||
setHasUnsavedChanges(true);
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribe = historyApi.subscribe(updateHasChanges);
|
||||
return () => {
|
||||
if (typeof unsubscribe === 'function') {
|
||||
unsubscribe();
|
||||
}
|
||||
};
|
||||
}, [historyApiRef.current, setHasUnsavedChanges]);
|
||||
|
||||
// Register checker for unsaved changes (annotations only for now)
|
||||
useEffect(() => {
|
||||
if (previewFile) {
|
||||
@ -232,39 +259,28 @@ const EmbedPdfViewerContent = ({
|
||||
}
|
||||
|
||||
const checkForChanges = () => {
|
||||
// Check for annotation changes via history
|
||||
const hasAnnotationChanges = historyApiRef.current?.canUndo() || false;
|
||||
|
||||
console.log('[Viewer] Checking for unsaved changes:', {
|
||||
hasAnnotationChanges
|
||||
});
|
||||
const hasAnnotationChanges = hasAnnotationChangesRef.current;
|
||||
return hasAnnotationChanges;
|
||||
};
|
||||
|
||||
console.log('[Viewer] Registering unsaved changes checker');
|
||||
registerUnsavedChangesChecker(checkForChanges);
|
||||
|
||||
return () => {
|
||||
console.log('[Viewer] Unregistering unsaved changes checker');
|
||||
unregisterUnsavedChangesChecker();
|
||||
};
|
||||
}, [historyApiRef, previewFile, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker]);
|
||||
}, [previewFile, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker]);
|
||||
|
||||
// Apply changes - save annotations to new file version
|
||||
const applyChanges = useCallback(async () => {
|
||||
if (!currentFile || activeFileIds.length === 0) return;
|
||||
|
||||
try {
|
||||
console.log('[Viewer] Applying changes - exporting PDF with annotations');
|
||||
|
||||
// Step 1: Export PDF with annotations using EmbedPDF
|
||||
const arrayBuffer = await exportActions.saveAsCopy();
|
||||
if (!arrayBuffer) {
|
||||
throw new Error('Failed to export PDF');
|
||||
}
|
||||
|
||||
console.log('[Viewer] Exported PDF size:', arrayBuffer.byteLength);
|
||||
|
||||
// Step 2: Convert ArrayBuffer to File
|
||||
const blob = new Blob([arrayBuffer], { type: 'application/pdf' });
|
||||
const filename = currentFile.name || 'document.pdf';
|
||||
@ -279,12 +295,29 @@ const EmbedPdfViewerContent = ({
|
||||
// Step 4: Consume files (replace in context)
|
||||
await actions.consumeFiles(activeFileIds, stirlingFiles, stubs);
|
||||
|
||||
// Mark annotations as saved so navigation away from the viewer is allowed.
|
||||
hasAnnotationChangesRef.current = false;
|
||||
setHasUnsavedChanges(false);
|
||||
} catch (error) {
|
||||
console.error('Apply changes failed:', error);
|
||||
}
|
||||
}, [currentFile, activeFileIds, exportActions, actions, selectors, setHasUnsavedChanges]);
|
||||
|
||||
// Expose annotation apply via a global event so tools (like Annotate) can
|
||||
// trigger saves from the left sidebar without tight coupling.
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
void applyChanges();
|
||||
};
|
||||
window.addEventListener('stirling-annotations-apply', handler);
|
||||
return () => {
|
||||
window.removeEventListener('stirling-annotations-apply', handler);
|
||||
};
|
||||
}, [applyChanges]);
|
||||
|
||||
// Register viewer right-rail buttons
|
||||
useViewerRightRailButtons();
|
||||
|
||||
const sidebarWidthRem = 15;
|
||||
const totalRightMargin =
|
||||
(isThumbnailSidebarVisible ? sidebarWidthRem : 0) + (isBookmarkSidebarVisible ? sidebarWidthRem : 0);
|
||||
@ -340,6 +373,7 @@ const EmbedPdfViewerContent = ({
|
||||
enableAnnotations={isAnnotationMode}
|
||||
showBakedAnnotations={isAnnotationsVisible}
|
||||
signatureApiRef={signatureApiRef as React.RefObject<any>}
|
||||
annotationApiRef={annotationApiRef as React.RefObject<any>}
|
||||
historyApiRef={historyApiRef as React.RefObject<any>}
|
||||
onSignatureAdded={() => {
|
||||
// Handle signature added - for debugging, enable console logs as needed
|
||||
|
||||
@ -38,8 +38,9 @@ import { SearchAPIBridge } from '@app/components/viewer/SearchAPIBridge';
|
||||
import { ThumbnailAPIBridge } from '@app/components/viewer/ThumbnailAPIBridge';
|
||||
import { RotateAPIBridge } from '@app/components/viewer/RotateAPIBridge';
|
||||
import { SignatureAPIBridge } from '@app/components/viewer/SignatureAPIBridge';
|
||||
import { AnnotationAPIBridge } from '@app/components/viewer/AnnotationAPIBridge';
|
||||
import { HistoryAPIBridge } from '@app/components/viewer/HistoryAPIBridge';
|
||||
import type { SignatureAPI, HistoryAPI } from '@app/components/viewer/viewerTypes';
|
||||
import type { SignatureAPI, AnnotationAPI, HistoryAPI } from '@app/components/viewer/viewerTypes';
|
||||
import { ExportAPIBridge } from '@app/components/viewer/ExportAPIBridge';
|
||||
import { BookmarkAPIBridge } from '@app/components/viewer/BookmarkAPIBridge';
|
||||
import { PrintAPIBridge } from '@app/components/viewer/PrintAPIBridge';
|
||||
@ -55,10 +56,11 @@ interface LocalEmbedPDFProps {
|
||||
showBakedAnnotations?: boolean;
|
||||
onSignatureAdded?: (annotation: any) => void;
|
||||
signatureApiRef?: React.RefObject<SignatureAPI>;
|
||||
annotationApiRef?: React.RefObject<AnnotationAPI>;
|
||||
historyApiRef?: React.RefObject<HistoryAPI>;
|
||||
}
|
||||
|
||||
export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedAnnotations = true, onSignatureAdded, signatureApiRef, historyApiRef }: LocalEmbedPDFProps) {
|
||||
export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedAnnotations = true, onSignatureAdded, signatureApiRef, annotationApiRef, historyApiRef }: LocalEmbedPDFProps) {
|
||||
const { t } = useTranslation();
|
||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||
const [, setAnnotations] = useState<Array<{id: string, pageIndex: number, rect: any}>>([]);
|
||||
@ -123,10 +125,8 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
|
||||
selectAfterCreate: true,
|
||||
}),
|
||||
|
||||
// Register pan plugin (depends on Viewport, InteractionManager)
|
||||
createPluginRegistration(PanPluginPackage, {
|
||||
defaultMode: 'mobile', // Try mobile mode which might be more permissive
|
||||
}),
|
||||
// Register pan plugin (depends on Viewport, InteractionManager) - keep disabled to prevent drag panning
|
||||
createPluginRegistration(PanPluginPackage, {}),
|
||||
|
||||
// Register zoom plugin with configuration
|
||||
createPluginRegistration(ZoomPluginPackage, {
|
||||
@ -252,7 +252,315 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
|
||||
if (!annotationApi) return;
|
||||
|
||||
if (enableAnnotations) {
|
||||
annotationApi.addTool({
|
||||
const ensureTool = (tool: any) => {
|
||||
const existing = annotationApi.getTool?.(tool.id);
|
||||
if (!existing) {
|
||||
annotationApi.addTool(tool);
|
||||
}
|
||||
};
|
||||
|
||||
ensureTool({
|
||||
id: 'highlight',
|
||||
name: 'Highlight',
|
||||
interaction: { exclusive: true, cursor: 'text', textSelection: true },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.HIGHLIGHT ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.HIGHLIGHT,
|
||||
color: '#ffd54f',
|
||||
opacity: 0.6,
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: false,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'underline',
|
||||
name: 'Underline',
|
||||
interaction: { exclusive: true, cursor: 'text', textSelection: true },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.UNDERLINE ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.UNDERLINE,
|
||||
color: '#ffb300',
|
||||
opacity: 1,
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: false,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'strikeout',
|
||||
name: 'Strikeout',
|
||||
interaction: { exclusive: true, cursor: 'text', textSelection: true },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.STRIKEOUT ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.STRIKEOUT,
|
||||
color: '#e53935',
|
||||
opacity: 1,
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: false,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'squiggly',
|
||||
name: 'Squiggly',
|
||||
interaction: { exclusive: true, cursor: 'text', textSelection: true },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.SQUIGGLY ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.SQUIGGLY,
|
||||
color: '#00acc1',
|
||||
opacity: 1,
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: false,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'ink',
|
||||
name: 'Pen',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.INK ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.INK,
|
||||
color: '#1f2933',
|
||||
opacity: 1,
|
||||
borderWidth: 2,
|
||||
lineWidth: 2,
|
||||
strokeWidth: 2,
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: false,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'inkHighlighter',
|
||||
name: 'Ink Highlighter',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.INK && annotation.color === '#ffd54f' ? 8 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.INK,
|
||||
color: '#ffd54f',
|
||||
opacity: 0.5,
|
||||
borderWidth: 6,
|
||||
lineWidth: 6,
|
||||
strokeWidth: 6,
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: false,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'square',
|
||||
name: 'Square',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.SQUARE ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.SQUARE,
|
||||
color: '#0000ff', // fill color (blue)
|
||||
strokeColor: '#cf5b5b', // border color (reddish pink)
|
||||
opacity: 0.5,
|
||||
borderWidth: 1,
|
||||
strokeWidth: 1,
|
||||
lineWidth: 1,
|
||||
},
|
||||
clickBehavior: {
|
||||
enabled: true,
|
||||
defaultSize: { width: 120, height: 90 },
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: true,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'circle',
|
||||
name: 'Circle',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.CIRCLE ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.CIRCLE,
|
||||
color: '#0000ff', // fill color (blue)
|
||||
strokeColor: '#cf5b5b', // border color (reddish pink)
|
||||
opacity: 0.5,
|
||||
borderWidth: 1,
|
||||
strokeWidth: 1,
|
||||
lineWidth: 1,
|
||||
},
|
||||
clickBehavior: {
|
||||
enabled: true,
|
||||
defaultSize: { width: 100, height: 100 },
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: true,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'line',
|
||||
name: 'Line',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.LINE ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.LINE,
|
||||
color: '#1565c0',
|
||||
opacity: 1,
|
||||
borderWidth: 2,
|
||||
strokeWidth: 2,
|
||||
lineWidth: 2,
|
||||
},
|
||||
clickBehavior: {
|
||||
enabled: true,
|
||||
defaultLength: 120,
|
||||
defaultAngle: 0,
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: true,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'lineArrow',
|
||||
name: 'Arrow',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.LINE && (annotation.endStyle === 'ClosedArrow' || annotation.lineEndingStyles?.end === 'ClosedArrow') ? 9 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.LINE,
|
||||
color: '#1565c0',
|
||||
opacity: 1,
|
||||
borderWidth: 2,
|
||||
startStyle: 'None',
|
||||
endStyle: 'ClosedArrow',
|
||||
lineEndingStyles: { start: 'None', end: 'ClosedArrow' },
|
||||
},
|
||||
clickBehavior: {
|
||||
enabled: true,
|
||||
defaultLength: 120,
|
||||
defaultAngle: 0,
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: true,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'polyline',
|
||||
name: 'Polyline',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.POLYLINE ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.POLYLINE,
|
||||
color: '#1565c0',
|
||||
opacity: 1,
|
||||
borderWidth: 2,
|
||||
},
|
||||
clickBehavior: {
|
||||
enabled: true,
|
||||
finishOnDoubleClick: true,
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: true,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'polygon',
|
||||
name: 'Polygon',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.POLYGON ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.POLYGON,
|
||||
color: '#0000ff', // fill color (blue)
|
||||
strokeColor: '#cf5b5b', // border color (reddish pink)
|
||||
opacity: 0.5,
|
||||
borderWidth: 1,
|
||||
},
|
||||
clickBehavior: {
|
||||
enabled: true,
|
||||
finishOnDoubleClick: true,
|
||||
defaultSize: { width: 140, height: 100 },
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: true,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'text',
|
||||
name: 'Text',
|
||||
interaction: { exclusive: true, cursor: 'text' },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.FREETEXT ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.FREETEXT,
|
||||
textColor: '#111111',
|
||||
fontSize: 14,
|
||||
fontFamily: 'Helvetica',
|
||||
opacity: 1,
|
||||
interiorColor: '#fffef7',
|
||||
contents: 'Text',
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: false,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'note',
|
||||
name: 'Note',
|
||||
interaction: { exclusive: true, cursor: 'pointer' },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.FREETEXT ? 8 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.FREETEXT,
|
||||
textColor: '#1b1b1b',
|
||||
color: '#ffa000',
|
||||
interiorColor: '#fff8e1',
|
||||
opacity: 1,
|
||||
contents: 'Note',
|
||||
fontSize: 12,
|
||||
},
|
||||
clickBehavior: {
|
||||
enabled: true,
|
||||
defaultSize: { width: 160, height: 100 },
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: false,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'stamp',
|
||||
name: 'Image Stamp',
|
||||
interaction: { exclusive: false, cursor: 'copy' },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.STAMP ? 5 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.STAMP,
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: true,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'signatureStamp',
|
||||
name: 'Digital Signature',
|
||||
interaction: { exclusive: false, cursor: 'copy' },
|
||||
@ -262,7 +570,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
|
||||
},
|
||||
});
|
||||
|
||||
annotationApi.addTool({
|
||||
ensureTool({
|
||||
id: 'signatureInk',
|
||||
name: 'Signature Draw',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
@ -310,6 +618,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
|
||||
<ThumbnailAPIBridge />
|
||||
<RotateAPIBridge />
|
||||
{enableAnnotations && <SignatureAPIBridge ref={signatureApiRef} />}
|
||||
{enableAnnotations && <AnnotationAPIBridge ref={annotationApiRef} />}
|
||||
{enableAnnotations && <HistoryAPIBridge ref={historyApiRef} />}
|
||||
<ExportAPIBridge />
|
||||
<BookmarkAPIBridge />
|
||||
|
||||
@ -104,12 +104,20 @@ const createTextStampImage = (
|
||||
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.font = `${fontSize}px ${fontFamily}`;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textAlign = config.textAlign || 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const horizontalPadding = paddingX;
|
||||
const verticalCenter = naturalHeight / 2;
|
||||
ctx.fillText(text, horizontalPadding, verticalCenter);
|
||||
|
||||
let xPosition = horizontalPadding;
|
||||
if (config.textAlign === 'center') {
|
||||
xPosition = naturalWidth / 2;
|
||||
} else if (config.textAlign === 'right') {
|
||||
xPosition = naturalWidth - horizontalPadding;
|
||||
}
|
||||
|
||||
ctx.fillText(text, xPosition, verticalCenter);
|
||||
|
||||
return {
|
||||
dataUrl: canvas.toDataURL('image/png'),
|
||||
@ -199,12 +207,21 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
|
||||
}
|
||||
}, [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults, cssToPdfSize]);
|
||||
|
||||
|
||||
// Enable keyboard deletion of selected annotations
|
||||
useEffect(() => {
|
||||
// Always enable delete key when we have annotation API and are in sign mode
|
||||
if (!annotationApi || (isPlacementMode === undefined)) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Skip delete/backspace while a text input/textarea is focused (e.g., editing textbox)
|
||||
const target = event.target as HTMLElement | null;
|
||||
const tag = target?.tagName?.toLowerCase();
|
||||
const editable = target?.getAttribute?.('contenteditable');
|
||||
if (tag === 'input' || tag === 'textarea' || editable === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||
const selectedAnnotation = annotationApi.getSelectedAnnotation?.();
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMemo, useState, useEffect, useCallback } from 'react';
|
||||
import { ActionIcon, Popover } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
@ -9,6 +9,9 @@ import { SearchInterface } from '@app/components/viewer/SearchInterface';
|
||||
import ViewerAnnotationControls from '@app/components/shared/rightRail/ViewerAnnotationControls';
|
||||
import { useSidebarContext } from '@app/contexts/SidebarContext';
|
||||
import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { useNavigationState } from '@app/contexts/NavigationContext';
|
||||
import { BASE_PATH, withBasePath } from '@app/constants/app';
|
||||
|
||||
export function useViewerRightRailButtons() {
|
||||
const { t, i18n } = useTranslation();
|
||||
@ -16,6 +19,32 @@ export function useViewerRightRailButtons() {
|
||||
const [isPanning, setIsPanning] = useState<boolean>(() => viewer.getPanState()?.isPanning ?? false);
|
||||
const { sidebarRefs } = useSidebarContext();
|
||||
const { position: tooltipPosition } = useRightRailTooltipSide(sidebarRefs, 12);
|
||||
const { handleToolSelect } = useToolWorkflow();
|
||||
const { selectedTool } = useNavigationState();
|
||||
|
||||
const stripBasePath = useCallback((path: string) => {
|
||||
if (BASE_PATH && path.startsWith(BASE_PATH)) {
|
||||
return path.slice(BASE_PATH.length) || '/';
|
||||
}
|
||||
return path;
|
||||
}, []);
|
||||
|
||||
const isAnnotationsPath = useCallback(() => {
|
||||
const cleanPath = stripBasePath(window.location.pathname).toLowerCase();
|
||||
return cleanPath === '/annotations' || cleanPath.endsWith('/annotations');
|
||||
}, [stripBasePath]);
|
||||
|
||||
const [isAnnotationsActive, setIsAnnotationsActive] = useState<boolean>(() => isAnnotationsPath());
|
||||
|
||||
useEffect(() => {
|
||||
setIsAnnotationsActive(isAnnotationsPath());
|
||||
}, [selectedTool, isAnnotationsPath]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePopState = () => setIsAnnotationsActive(isAnnotationsPath());
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, [isAnnotationsPath]);
|
||||
|
||||
// Lift i18n labels out of memo for clarity
|
||||
const searchLabel = t('rightRail.search', 'Search PDF');
|
||||
@ -25,9 +54,11 @@ export function useViewerRightRailButtons() {
|
||||
const sidebarLabel = t('rightRail.toggleSidebar', 'Toggle Sidebar');
|
||||
const bookmarkLabel = t('rightRail.toggleBookmarks', 'Toggle Bookmarks');
|
||||
const printLabel = t('rightRail.print', 'Print PDF');
|
||||
const annotationsLabel = t('rightRail.annotations', 'Annotations');
|
||||
const saveChangesLabel = t('rightRail.saveChanges', 'Save Changes');
|
||||
|
||||
const viewerButtons = useMemo<RightRailButtonWithAction[]>(() => {
|
||||
return [
|
||||
const buttons: RightRailButtonWithAction[] = [
|
||||
{
|
||||
id: 'viewer-search',
|
||||
tooltip: searchLabel,
|
||||
@ -147,6 +178,36 @@ export function useViewerRightRailButtons() {
|
||||
viewer.printActions.print();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'viewer-annotations',
|
||||
tooltip: annotationsLabel,
|
||||
ariaLabel: annotationsLabel,
|
||||
section: 'top' as const,
|
||||
order: 58,
|
||||
render: ({ disabled }) => (
|
||||
<Tooltip content={annotationsLabel} position={tooltipPosition} offset={12} arrow portalTarget={document.body}>
|
||||
<ActionIcon
|
||||
variant={isAnnotationsActive ? 'default' : 'subtle'}
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
if (disabled || isAnnotationsActive) return;
|
||||
const targetPath = withBasePath('/annotations');
|
||||
if (window.location.pathname !== targetPath) {
|
||||
window.history.pushState(null, '', targetPath);
|
||||
}
|
||||
setIsAnnotationsActive(true);
|
||||
handleToolSelect('annotate');
|
||||
}}
|
||||
disabled={disabled || isAnnotationsActive}
|
||||
aria-pressed={isAnnotationsActive}
|
||||
style={isAnnotationsActive ? { backgroundColor: 'var(--right-rail-pan-active-bg)' } : undefined}
|
||||
>
|
||||
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'viewer-annotation-controls',
|
||||
section: 'top' as const,
|
||||
@ -154,9 +215,30 @@ export function useViewerRightRailButtons() {
|
||||
render: ({ disabled }) => (
|
||||
<ViewerAnnotationControls currentView="viewer" disabled={disabled} />
|
||||
)
|
||||
}
|
||||
},
|
||||
];
|
||||
}, [t, i18n.language, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel, bookmarkLabel, printLabel, tooltipPosition]);
|
||||
|
||||
// Optional: Save button for annotations (always registered when this hook is used
|
||||
// with a save handler; uses a ref to avoid infinite re-registration loops).
|
||||
return buttons;
|
||||
}, [
|
||||
t,
|
||||
i18n.language,
|
||||
viewer,
|
||||
isPanning,
|
||||
searchLabel,
|
||||
panLabel,
|
||||
rotateLeftLabel,
|
||||
rotateRightLabel,
|
||||
sidebarLabel,
|
||||
bookmarkLabel,
|
||||
printLabel,
|
||||
tooltipPosition,
|
||||
annotationsLabel,
|
||||
saveChangesLabel,
|
||||
isAnnotationsActive,
|
||||
handleToolSelect,
|
||||
]);
|
||||
|
||||
useRightRailButtons(viewerButtons);
|
||||
}
|
||||
|
||||
@ -16,6 +16,17 @@ export interface SignatureAPI {
|
||||
getPageAnnotations: (pageIndex: number) => Promise<any[]>;
|
||||
}
|
||||
|
||||
export interface AnnotationAPI {
|
||||
activateAnnotationTool: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => void;
|
||||
setAnnotationStyle: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => void;
|
||||
getSelectedAnnotation: () => AnnotationSelection | null;
|
||||
deselectAnnotation: () => void;
|
||||
updateAnnotation: (pageIndex: number, annotationId: string, patch: AnnotationPatch) => void;
|
||||
deactivateTools: () => void;
|
||||
onAnnotationEvent?: (listener: (event: AnnotationEvent) => void) => void | (() => void);
|
||||
getActiveTool?: () => { id: AnnotationToolId } | null;
|
||||
}
|
||||
|
||||
export interface HistoryAPI {
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
@ -23,3 +34,50 @@ export interface HistoryAPI {
|
||||
canRedo: () => boolean;
|
||||
subscribe?: (listener: () => void) => () => void;
|
||||
}
|
||||
|
||||
export type AnnotationToolId =
|
||||
| 'select'
|
||||
| 'highlight'
|
||||
| 'underline'
|
||||
| 'strikeout'
|
||||
| 'squiggly'
|
||||
| 'ink'
|
||||
| 'inkHighlighter'
|
||||
| 'text'
|
||||
| 'note'
|
||||
| 'square'
|
||||
| 'circle'
|
||||
| 'line'
|
||||
| 'lineArrow'
|
||||
| 'polyline'
|
||||
| 'polygon'
|
||||
| 'stamp'
|
||||
| 'signatureStamp'
|
||||
| 'signatureInk';
|
||||
|
||||
export interface AnnotationEvent {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type AnnotationPatch = Record<string, unknown>;
|
||||
export type AnnotationSelection = unknown;
|
||||
|
||||
export interface AnnotationToolOptions {
|
||||
color?: string;
|
||||
fillColor?: string;
|
||||
strokeColor?: string;
|
||||
opacity?: number;
|
||||
strokeOpacity?: number;
|
||||
fillOpacity?: number;
|
||||
thickness?: number;
|
||||
borderWidth?: number;
|
||||
fontSize?: number;
|
||||
fontFamily?: string;
|
||||
textAlign?: number; // 0 = Left, 1 = Center, 2 = Right
|
||||
imageSrc?: string;
|
||||
imageSize?: { width: number; height: number };
|
||||
icon?: 'Comment' | 'Key' | 'Note' | 'Help' | 'NewParagraph' | 'Paragraph' | 'Insert';
|
||||
contents?: string;
|
||||
customData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
26
frontend/src/core/contexts/AnnotationContext.tsx
Normal file
26
frontend/src/core/contexts/AnnotationContext.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import React, { createContext, useContext, ReactNode, useRef } from 'react';
|
||||
import type { AnnotationAPI } from '@app/components/viewer/viewerTypes';
|
||||
|
||||
interface AnnotationContextValue {
|
||||
annotationApiRef: React.RefObject<AnnotationAPI | null>;
|
||||
}
|
||||
|
||||
const AnnotationContext = createContext<AnnotationContextValue | undefined>(undefined);
|
||||
|
||||
export const AnnotationProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const annotationApiRef = useRef<AnnotationAPI>(null);
|
||||
|
||||
const value: AnnotationContextValue = {
|
||||
annotationApiRef,
|
||||
};
|
||||
|
||||
return <AnnotationContext.Provider value={value}>{children}</AnnotationContext.Provider>;
|
||||
};
|
||||
|
||||
export const useAnnotation = (): AnnotationContextValue => {
|
||||
const context = useContext(AnnotationContext);
|
||||
if (!context) {
|
||||
throw new Error('useAnnotation must be used within an AnnotationProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { createContext, useContext, useState, ReactNode, useCallback, useRef } from 'react';
|
||||
import { SignParameters } from '@app/hooks/tools/sign/useSignParameters';
|
||||
import type { SignatureAPI, HistoryAPI } from '@app/components/viewer/viewerTypes';
|
||||
import type { SignatureAPI, HistoryAPI, AnnotationAPI } from '@app/components/viewer/viewerTypes';
|
||||
|
||||
// Signature state interface
|
||||
interface SignatureState {
|
||||
@ -34,6 +34,7 @@ interface SignatureActions {
|
||||
// Combined context interface
|
||||
interface SignatureContextValue extends SignatureState, SignatureActions {
|
||||
signatureApiRef: React.RefObject<SignatureAPI | null>;
|
||||
annotationApiRef: React.RefObject<AnnotationAPI | null>;
|
||||
historyApiRef: React.RefObject<HistoryAPI | null>;
|
||||
}
|
||||
|
||||
@ -52,6 +53,7 @@ const initialState: SignatureState = {
|
||||
export const SignatureProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [state, setState] = useState<SignatureState>(initialState);
|
||||
const signatureApiRef = useRef<SignatureAPI>(null);
|
||||
const annotationApiRef = useRef<AnnotationAPI>(null);
|
||||
const historyApiRef = useRef<HistoryAPI>(null);
|
||||
const imageDataStore = useRef<Map<string, string>>(new Map());
|
||||
|
||||
@ -157,6 +159,7 @@ export const SignatureProvider: React.FC<{ children: ReactNode }> = ({ children
|
||||
const contextValue: SignatureContextValue = {
|
||||
...state,
|
||||
signatureApiRef,
|
||||
annotationApiRef,
|
||||
historyApiRef,
|
||||
setSignatureConfig,
|
||||
setPlacementMode,
|
||||
|
||||
@ -51,6 +51,7 @@ import Crop from "@app/tools/Crop";
|
||||
import Sign from "@app/tools/Sign";
|
||||
import AddText from "@app/tools/AddText";
|
||||
import AddImage from "@app/tools/AddImage";
|
||||
import Annotate from "@app/tools/Annotate";
|
||||
import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation";
|
||||
import { splitOperationConfig } from "@app/hooks/tools/split/useSplitOperation";
|
||||
import { addPasswordOperationConfig } from "@app/hooks/tools/addPassword/useAddPasswordOperation";
|
||||
@ -246,6 +247,19 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
synonyms: getSynonyms(t, 'addImage'),
|
||||
supportsAutomate: false,
|
||||
},
|
||||
annotate: {
|
||||
icon: <LocalIcon icon="edit" width="1.5rem" height="1.5rem" />,
|
||||
name: t('home.annotate.title', 'Annotate'),
|
||||
component: Annotate,
|
||||
description: t('home.annotate.desc', 'Highlight, draw, add notes, and shapes directly in the viewer'),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
workbench: 'viewer',
|
||||
operationConfig: signOperationConfig,
|
||||
automationSettings: null,
|
||||
synonyms: getSynonyms(t, 'annotate'),
|
||||
supportsAutomate: false,
|
||||
},
|
||||
|
||||
// Document Security
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@ export interface SignParameters {
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
textColor?: string;
|
||||
textAlign?: 'left' | 'center' | 'right';
|
||||
}
|
||||
|
||||
export const DEFAULT_PARAMETERS: SignParameters = {
|
||||
@ -28,6 +29,7 @@ export const DEFAULT_PARAMETERS: SignParameters = {
|
||||
fontFamily: 'Helvetica',
|
||||
fontSize: 16,
|
||||
textColor: '#000000',
|
||||
textAlign: 'left',
|
||||
};
|
||||
|
||||
const validateSignParameters = (parameters: SignParameters): boolean => {
|
||||
|
||||
416
frontend/src/core/tools/Annotate.tsx
Normal file
416
frontend/src/core/tools/Annotate.tsx
Normal file
@ -0,0 +1,416 @@
|
||||
import { useEffect, useState, useContext, useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { createToolFlow } from '@app/components/tools/shared/createToolFlow';
|
||||
import { useNavigation } from '@app/contexts/NavigationContext';
|
||||
import { useFileSelection } from '@app/contexts/FileContext';
|
||||
import { BaseToolProps } from '@app/types/tool';
|
||||
import { useSignature } from '@app/contexts/SignatureContext';
|
||||
import { ViewerContext, useViewer } from '@app/contexts/ViewerContext';
|
||||
import type { AnnotationToolId } from '@app/components/viewer/viewerTypes';
|
||||
import { useAnnotationStyleState } from '@app/tools/annotate/useAnnotationStyleState';
|
||||
import { useAnnotationSelection } from '@app/tools/annotate/useAnnotationSelection';
|
||||
import { AnnotationPanel } from '@app/tools/annotate/AnnotationPanel';
|
||||
|
||||
const KNOWN_ANNOTATION_TOOLS: AnnotationToolId[] = [
|
||||
'select',
|
||||
'highlight',
|
||||
'underline',
|
||||
'strikeout',
|
||||
'squiggly',
|
||||
'ink',
|
||||
'inkHighlighter',
|
||||
'text',
|
||||
'note',
|
||||
'square',
|
||||
'circle',
|
||||
'line',
|
||||
'lineArrow',
|
||||
'polyline',
|
||||
'polygon',
|
||||
'stamp',
|
||||
'signatureStamp',
|
||||
'signatureInk',
|
||||
];
|
||||
|
||||
const isKnownAnnotationTool = (toolId: string | undefined | null): toolId is AnnotationToolId =>
|
||||
!!toolId && (KNOWN_ANNOTATION_TOOLS as string[]).includes(toolId);
|
||||
|
||||
const Annotate = (_props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { selectedTool, workbench, hasUnsavedChanges } = useNavigation();
|
||||
const { selectedFiles } = useFileSelection();
|
||||
const {
|
||||
signatureApiRef,
|
||||
annotationApiRef,
|
||||
historyApiRef,
|
||||
undo,
|
||||
redo,
|
||||
setSignatureConfig,
|
||||
setPlacementMode,
|
||||
placementPreviewSize,
|
||||
setPlacementPreviewSize,
|
||||
} = useSignature();
|
||||
const viewerContext = useContext(ViewerContext);
|
||||
const { getZoomState, registerImmediateZoomUpdate } = useViewer();
|
||||
|
||||
const [activeTool, setActiveTool] = useState<AnnotationToolId>('select');
|
||||
const activeToolRef = useRef<AnnotationToolId>('select');
|
||||
const wasAnnotateActiveRef = useRef<boolean>(false);
|
||||
const [selectedTextDraft, setSelectedTextDraft] = useState<string>('');
|
||||
const [selectedFontSize, setSelectedFontSize] = useState<number>(14);
|
||||
const [stampImageData, setStampImageData] = useState<string | undefined>();
|
||||
const [stampImageSize, setStampImageSize] = useState<{ width: number; height: number } | null>(null);
|
||||
const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false });
|
||||
const manualToolSwitch = useRef<boolean>(false);
|
||||
|
||||
// Zoom tracking for stamp size conversion
|
||||
const [currentZoom, setCurrentZoom] = useState(() => {
|
||||
const zoomState = getZoomState();
|
||||
if (!zoomState) return 1;
|
||||
if (typeof zoomState.zoomPercent === 'number') {
|
||||
return Math.max(zoomState.zoomPercent / 100, 0.01);
|
||||
}
|
||||
return Math.max(zoomState.currentZoom ?? 1, 0.01);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
return registerImmediateZoomUpdate((newZoomPercent) => {
|
||||
setCurrentZoom(Math.max(newZoomPercent / 100, 0.01));
|
||||
});
|
||||
}, [registerImmediateZoomUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
activeToolRef.current = activeTool;
|
||||
}, [activeTool]);
|
||||
|
||||
// CSS to PDF size conversion accounting for zoom
|
||||
const cssToPdfSize = useCallback(
|
||||
(size: { width: number; height: number }) => {
|
||||
const zoom = currentZoom || 1;
|
||||
const factor = 1 / zoom;
|
||||
return {
|
||||
width: size.width * factor,
|
||||
height: size.height * factor,
|
||||
};
|
||||
},
|
||||
[currentZoom]
|
||||
);
|
||||
|
||||
const computeStampDisplaySize = useCallback((natural: { width: number; height: number } | null) => {
|
||||
if (!natural) {
|
||||
return { width: 180, height: 120 };
|
||||
}
|
||||
const maxSide = 260;
|
||||
const minSide = 24;
|
||||
const { width, height } = natural;
|
||||
const largest = Math.max(width || maxSide, height || maxSide, 1);
|
||||
const scale = Math.min(1, maxSide / largest);
|
||||
return {
|
||||
width: Math.max(minSide, Math.round(width * scale)),
|
||||
height: Math.max(minSide, Math.round(height * scale)),
|
||||
};
|
||||
}, []);
|
||||
|
||||
const {
|
||||
styleState,
|
||||
styleActions,
|
||||
buildToolOptions,
|
||||
getActiveColor,
|
||||
} = useAnnotationStyleState(cssToPdfSize);
|
||||
|
||||
const {
|
||||
setInkWidth,
|
||||
setShapeThickness,
|
||||
setTextColor,
|
||||
setTextBackgroundColor,
|
||||
setNoteBackgroundColor,
|
||||
setInkColor,
|
||||
setHighlightColor,
|
||||
setHighlightOpacity,
|
||||
setFreehandHighlighterWidth,
|
||||
setUnderlineColor,
|
||||
setUnderlineOpacity,
|
||||
setStrikeoutColor,
|
||||
setStrikeoutOpacity,
|
||||
setSquigglyColor,
|
||||
setSquigglyOpacity,
|
||||
setShapeStrokeColor,
|
||||
setShapeFillColor,
|
||||
setShapeOpacity,
|
||||
setShapeStrokeOpacity,
|
||||
setShapeFillOpacity,
|
||||
setTextAlignment,
|
||||
} = styleActions;
|
||||
|
||||
const handleApplyChanges = useCallback(() => {
|
||||
window.dispatchEvent(new CustomEvent('stirling-annotations-apply'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const isAnnotateActive = workbench === 'viewer' && selectedTool === 'annotate';
|
||||
if (wasAnnotateActiveRef.current && !isAnnotateActive) {
|
||||
annotationApiRef?.current?.deactivateTools?.();
|
||||
signatureApiRef?.current?.deactivateTools?.();
|
||||
setPlacementMode(false);
|
||||
} else if (!wasAnnotateActiveRef.current && isAnnotateActive) {
|
||||
// When entering annotate mode, activate the select tool by default
|
||||
const toolOptions = buildToolOptions('select');
|
||||
annotationApiRef?.current?.activateAnnotationTool?.('select', toolOptions);
|
||||
}
|
||||
wasAnnotateActiveRef.current = isAnnotateActive;
|
||||
}, [workbench, selectedTool, annotationApiRef, signatureApiRef, setPlacementMode, buildToolOptions]);
|
||||
|
||||
// Monitor history state for undo/redo availability
|
||||
useEffect(() => {
|
||||
const historyApi = historyApiRef?.current;
|
||||
if (!historyApi) return;
|
||||
|
||||
const updateAvailability = () =>
|
||||
setHistoryAvailability({
|
||||
canUndo: historyApi.canUndo?.() ?? false,
|
||||
canRedo: historyApi.canRedo?.() ?? false,
|
||||
});
|
||||
|
||||
updateAvailability();
|
||||
|
||||
let interval: ReturnType<typeof setInterval> | undefined;
|
||||
if (!historyApi.subscribe) {
|
||||
// Fallback polling in case the history API doesn't support subscriptions
|
||||
interval = setInterval(updateAvailability, 350);
|
||||
} else {
|
||||
const unsubscribe = historyApi.subscribe(updateAvailability);
|
||||
return () => {
|
||||
if (typeof unsubscribe === 'function') {
|
||||
unsubscribe();
|
||||
}
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
}, [historyApiRef?.current]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewerContext) return;
|
||||
if (viewerContext.isAnnotationMode) return;
|
||||
|
||||
viewerContext.setAnnotationMode(true);
|
||||
const toolOptions =
|
||||
activeTool === 'stamp'
|
||||
? buildToolOptions('stamp', { stampImageData, stampImageSize })
|
||||
: buildToolOptions(activeTool);
|
||||
annotationApiRef?.current?.activateAnnotationTool?.(activeTool, toolOptions);
|
||||
}, [viewerContext?.isAnnotationMode, signatureApiRef, activeTool, buildToolOptions, stampImageData, stampImageSize]);
|
||||
|
||||
const activateAnnotationTool = (toolId: AnnotationToolId) => {
|
||||
// If leaving stamp tool, clean up placement mode
|
||||
if (activeTool === 'stamp' && toolId !== 'stamp') {
|
||||
setPlacementMode(false);
|
||||
setSignatureConfig(null);
|
||||
}
|
||||
|
||||
viewerContext?.setAnnotationMode(true);
|
||||
|
||||
// Mark as manual tool switch to prevent auto-switch back
|
||||
manualToolSwitch.current = true;
|
||||
|
||||
// Deselect annotation in the viewer first
|
||||
annotationApiRef?.current?.deselectAnnotation?.();
|
||||
|
||||
// Clear selection state to show default controls
|
||||
setSelectedAnn(null);
|
||||
setSelectedAnnId(null);
|
||||
|
||||
// Change the tool
|
||||
setActiveTool(toolId);
|
||||
const options =
|
||||
toolId === 'stamp'
|
||||
? buildToolOptions('stamp', { stampImageData, stampImageSize })
|
||||
: buildToolOptions(toolId);
|
||||
|
||||
// For stamp, apply the image if we have one
|
||||
annotationApiRef?.current?.setAnnotationStyle?.(toolId, options);
|
||||
annotationApiRef?.current?.activateAnnotationTool?.(toolId === 'stamp' ? 'stamp' : toolId, options);
|
||||
|
||||
// Reset flag after a short delay
|
||||
setTimeout(() => {
|
||||
manualToolSwitch.current = false;
|
||||
}, 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// push style updates to EmbedPDF when sliders/colors change
|
||||
if (activeTool === 'stamp') {
|
||||
const options = buildToolOptions('stamp', { stampImageData, stampImageSize });
|
||||
annotationApiRef?.current?.setAnnotationStyle?.('stamp', options);
|
||||
} else {
|
||||
annotationApiRef?.current?.setAnnotationStyle?.(activeTool, buildToolOptions(activeTool));
|
||||
}
|
||||
}, [activeTool, buildToolOptions, signatureApiRef, stampImageData, stampImageSize]);
|
||||
|
||||
// Sync preview size from overlay to annotation engine
|
||||
useEffect(() => {
|
||||
// When preview size changes, update stamp annotation sizing
|
||||
// The SignatureAPIBridge will use placementPreviewSize from SignatureContext
|
||||
// and apply the converted size to the stamp tool automatically
|
||||
if (activeTool === 'stamp' && stampImageData) {
|
||||
const size = placementPreviewSize ?? stampImageSize;
|
||||
const stampOptions = buildToolOptions('stamp', { stampImageData, stampImageSize: size ?? null });
|
||||
annotationApiRef?.current?.setAnnotationStyle?.('stamp', stampOptions);
|
||||
}
|
||||
}, [placementPreviewSize, activeTool, stampImageData, signatureApiRef, stampImageSize, cssToPdfSize, buildToolOptions]);
|
||||
|
||||
// Allow exiting multi-point tools with Escape (e.g., polyline)
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
if (['polyline', 'polygon'].includes(activeTool)) {
|
||||
annotationApiRef?.current?.setAnnotationStyle?.(activeTool, buildToolOptions(activeTool));
|
||||
annotationApiRef?.current?.activateAnnotationTool?.(null as any);
|
||||
setTimeout(() => {
|
||||
annotationApiRef?.current?.activateAnnotationTool?.(activeTool, buildToolOptions(activeTool));
|
||||
}, 50);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [activeTool, buildToolOptions, signatureApiRef]);
|
||||
|
||||
const deriveToolFromAnnotation = useCallback((annotation: any): AnnotationToolId | undefined => {
|
||||
if (!annotation) return undefined;
|
||||
const customToolId = annotation.customData?.toolId || annotation.customData?.annotationToolId;
|
||||
if (isKnownAnnotationTool(customToolId)) {
|
||||
return customToolId;
|
||||
}
|
||||
|
||||
const type = annotation.type ?? annotation.object?.type;
|
||||
switch (type) {
|
||||
case 3: return 'text'; // FREETEXT
|
||||
case 4: return 'line'; // LINE
|
||||
case 5: return 'square'; // SQUARE
|
||||
case 6: return 'circle'; // CIRCLE
|
||||
case 7: return 'polygon'; // POLYGON
|
||||
case 8: return 'polyline'; // POLYLINE
|
||||
case 9: return 'highlight'; // HIGHLIGHT
|
||||
case 10: return 'underline'; // UNDERLINE
|
||||
case 11: return 'squiggly'; // SQUIGGLY
|
||||
case 12: return 'strikeout'; // STRIKEOUT
|
||||
case 13: return 'stamp'; // STAMP
|
||||
case 15: return 'ink'; // INK
|
||||
default: return undefined;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const {
|
||||
selectedAnn,
|
||||
setSelectedAnn,
|
||||
setSelectedAnnId,
|
||||
} = useAnnotationSelection({
|
||||
annotationApiRef,
|
||||
deriveToolFromAnnotation,
|
||||
activeToolRef,
|
||||
manualToolSwitch,
|
||||
setActiveTool,
|
||||
setSelectedTextDraft,
|
||||
setSelectedFontSize,
|
||||
setInkWidth,
|
||||
setShapeThickness,
|
||||
setTextColor,
|
||||
setTextBackgroundColor,
|
||||
setNoteBackgroundColor,
|
||||
setInkColor,
|
||||
setHighlightColor,
|
||||
setHighlightOpacity,
|
||||
setFreehandHighlighterWidth,
|
||||
setUnderlineColor,
|
||||
setUnderlineOpacity,
|
||||
setStrikeoutColor,
|
||||
setStrikeoutOpacity,
|
||||
setSquigglyColor,
|
||||
setSquigglyOpacity,
|
||||
setShapeStrokeColor,
|
||||
setShapeFillColor,
|
||||
setShapeOpacity,
|
||||
setShapeStrokeOpacity,
|
||||
setShapeFillOpacity,
|
||||
setTextAlignment,
|
||||
});
|
||||
|
||||
const steps =
|
||||
selectedFiles.length === 0
|
||||
? []
|
||||
: [
|
||||
{
|
||||
title: t('annotation.title', 'Annotate'),
|
||||
isCollapsed: false,
|
||||
onCollapsedClick: undefined,
|
||||
content: (
|
||||
<AnnotationPanel
|
||||
activeTool={activeTool}
|
||||
activateAnnotationTool={activateAnnotationTool}
|
||||
styleState={styleState}
|
||||
styleActions={styleActions}
|
||||
getActiveColor={getActiveColor}
|
||||
buildToolOptions={buildToolOptions}
|
||||
deriveToolFromAnnotation={deriveToolFromAnnotation}
|
||||
selectedAnn={selectedAnn}
|
||||
selectedTextDraft={selectedTextDraft}
|
||||
setSelectedTextDraft={setSelectedTextDraft}
|
||||
selectedFontSize={selectedFontSize}
|
||||
setSelectedFontSize={setSelectedFontSize}
|
||||
annotationApiRef={annotationApiRef}
|
||||
signatureApiRef={signatureApiRef}
|
||||
viewerContext={viewerContext}
|
||||
setPlacementMode={setPlacementMode}
|
||||
setSignatureConfig={setSignatureConfig}
|
||||
computeStampDisplaySize={computeStampDisplaySize}
|
||||
stampImageData={stampImageData}
|
||||
setStampImageData={setStampImageData}
|
||||
stampImageSize={stampImageSize}
|
||||
setStampImageSize={setStampImageSize}
|
||||
setPlacementPreviewSize={setPlacementPreviewSize}
|
||||
undo={undo}
|
||||
redo={redo}
|
||||
historyAvailability={historyAvailability}
|
||||
onApplyChanges={handleApplyChanges}
|
||||
applyDisabled={!hasUnsavedChanges}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles,
|
||||
isCollapsed: false,
|
||||
},
|
||||
steps,
|
||||
review: {
|
||||
isVisible: false,
|
||||
operation: {
|
||||
files: [],
|
||||
thumbnails: [],
|
||||
isGeneratingThumbnails: false,
|
||||
downloadUrl: null,
|
||||
downloadFilename: '',
|
||||
isLoading: false,
|
||||
status: '',
|
||||
errorMessage: null,
|
||||
progress: null,
|
||||
executeOperation: async () => {},
|
||||
resetResults: () => {},
|
||||
clearError: () => {},
|
||||
cancelOperation: () => {},
|
||||
undoOperation: async () => {},
|
||||
},
|
||||
title: '',
|
||||
onFileClick: () => {},
|
||||
onUndo: () => {},
|
||||
},
|
||||
forceStepNumbers: true,
|
||||
});
|
||||
};
|
||||
|
||||
export default Annotate;
|
||||
1314
frontend/src/core/tools/annotate/AnnotationPanel.tsx
Normal file
1314
frontend/src/core/tools/annotate/AnnotationPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
383
frontend/src/core/tools/annotate/useAnnotationSelection.ts
Normal file
383
frontend/src/core/tools/annotate/useAnnotationSelection.ts
Normal file
@ -0,0 +1,383 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { AnnotationAPI, AnnotationToolId } from '@app/components/viewer/viewerTypes';
|
||||
|
||||
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;
|
||||
setInkWidth: (value: number) => void;
|
||||
setFreehandHighlighterWidth?: (value: number) => void;
|
||||
setShapeThickness: (value: number) => void;
|
||||
setTextColor: (value: string) => void;
|
||||
setTextBackgroundColor: (value: string) => void;
|
||||
setNoteBackgroundColor: (value: string) => void;
|
||||
setInkColor: (value: string) => void;
|
||||
setHighlightColor: (value: string) => void;
|
||||
setHighlightOpacity: (value: number) => void;
|
||||
setUnderlineColor: (value: string) => void;
|
||||
setUnderlineOpacity: (value: number) => void;
|
||||
setStrikeoutColor: (value: string) => void;
|
||||
setStrikeoutOpacity: (value: number) => void;
|
||||
setSquigglyColor: (value: string) => void;
|
||||
setSquigglyOpacity: (value: number) => void;
|
||||
setShapeStrokeColor: (value: string) => void;
|
||||
setShapeFillColor: (value: string) => void;
|
||||
setShapeOpacity: (value: number) => void;
|
||||
setShapeStrokeOpacity: (value: number) => void;
|
||||
setShapeFillOpacity: (value: number) => void;
|
||||
setTextAlignment: (value: 'left' | 'center' | 'right') => void;
|
||||
}
|
||||
|
||||
const MARKUP_TOOL_IDS = ['highlight', 'underline', 'strikeout', 'squiggly'] as const;
|
||||
const DRAWING_TOOL_IDS = ['ink', 'inkHighlighter'] as const;
|
||||
|
||||
const isTextMarkupAnnotation = (annotation: any): boolean => {
|
||||
const toolId =
|
||||
annotation?.customData?.annotationToolId ||
|
||||
annotation?.customData?.toolId ||
|
||||
annotation?.object?.customData?.annotationToolId ||
|
||||
annotation?.object?.customData?.toolId;
|
||||
if (toolId && MARKUP_TOOL_IDS.includes(toolId)) return true;
|
||||
|
||||
const type = annotation?.type ?? annotation?.object?.type;
|
||||
if (typeof type === 'number' && [9, 10, 11, 12].includes(type)) return true;
|
||||
|
||||
const subtype = annotation?.subtype ?? annotation?.object?.subtype;
|
||||
if (typeof subtype === 'string') {
|
||||
const lower = subtype.toLowerCase();
|
||||
if (MARKUP_TOOL_IDS.some((t) => lower.includes(t))) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const shouldStayOnPlacementTool = (annotation: any, derivedTool?: string | null | undefined): boolean => {
|
||||
const toolId =
|
||||
derivedTool ||
|
||||
annotation?.customData?.annotationToolId ||
|
||||
annotation?.customData?.toolId ||
|
||||
annotation?.object?.customData?.annotationToolId ||
|
||||
annotation?.object?.customData?.toolId;
|
||||
|
||||
if (toolId && (MARKUP_TOOL_IDS.includes(toolId as any) || DRAWING_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;
|
||||
return false;
|
||||
};
|
||||
|
||||
export function useAnnotationSelection({
|
||||
annotationApiRef,
|
||||
deriveToolFromAnnotation,
|
||||
activeToolRef,
|
||||
manualToolSwitch,
|
||||
setActiveTool,
|
||||
setSelectedTextDraft,
|
||||
setSelectedFontSize,
|
||||
setInkWidth,
|
||||
setShapeThickness,
|
||||
setTextColor,
|
||||
setTextBackgroundColor,
|
||||
setNoteBackgroundColor,
|
||||
setInkColor,
|
||||
setHighlightColor,
|
||||
setHighlightOpacity,
|
||||
setUnderlineColor,
|
||||
setUnderlineOpacity,
|
||||
setStrikeoutColor,
|
||||
setStrikeoutOpacity,
|
||||
setSquigglyColor,
|
||||
setSquigglyOpacity,
|
||||
setShapeStrokeColor,
|
||||
setShapeFillColor,
|
||||
setShapeOpacity,
|
||||
setShapeStrokeOpacity,
|
||||
setShapeFillOpacity,
|
||||
setTextAlignment,
|
||||
setFreehandHighlighterWidth,
|
||||
}: UseAnnotationSelectionParams) {
|
||||
const [selectedAnn, setSelectedAnn] = useState<any | null>(null);
|
||||
const [selectedAnnId, setSelectedAnnId] = useState<string | null>(null);
|
||||
const selectedAnnIdRef = useRef<string | null>(null);
|
||||
|
||||
const applySelectionFromAnnotation = useCallback(
|
||||
(ann: any | null) => {
|
||||
const annObject = ann?.object ?? ann ?? null;
|
||||
const annId = annObject?.id ?? null;
|
||||
const type = annObject?.type;
|
||||
const derivedTool = annObject ? deriveToolFromAnnotation(annObject) : undefined;
|
||||
selectedAnnIdRef.current = annId;
|
||||
setSelectedAnnId(annId);
|
||||
// Normalize selected annotation to always expose .object for edit panels
|
||||
const normalizedSelection = ann?.object ? ann : annObject ? { object: annObject } : null;
|
||||
setSelectedAnn(normalizedSelection);
|
||||
|
||||
if (annObject?.contents !== undefined) {
|
||||
setSelectedTextDraft(annObject.contents ?? '');
|
||||
}
|
||||
if (annObject?.fontSize !== undefined) {
|
||||
setSelectedFontSize(annObject.fontSize ?? 14);
|
||||
}
|
||||
if (annObject?.textAlign !== undefined) {
|
||||
const align = annObject.textAlign;
|
||||
if (typeof align === 'string') {
|
||||
const normalized = align === 'center' ? 'center' : align === 'right' ? 'right' : 'left';
|
||||
setTextAlignment(normalized);
|
||||
} else if (typeof align === 'number') {
|
||||
const normalized = align === 1 ? 'center' : align === 2 ? 'right' : 'left';
|
||||
setTextAlignment(normalized);
|
||||
}
|
||||
}
|
||||
if (type === 3) {
|
||||
const background =
|
||||
(annObject?.backgroundColor as string | undefined) ||
|
||||
(annObject?.fillColor as string | undefined) ||
|
||||
undefined;
|
||||
const textColor = (annObject?.textColor as string | undefined) || (annObject?.color as string | undefined);
|
||||
if (textColor) {
|
||||
setTextColor(textColor);
|
||||
}
|
||||
if (derivedTool === 'note') {
|
||||
setNoteBackgroundColor(background || '');
|
||||
} else {
|
||||
setTextBackgroundColor(background || '');
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 15) {
|
||||
const width =
|
||||
annObject?.strokeWidth ?? annObject?.borderWidth ?? annObject?.lineWidth ?? annObject?.thickness;
|
||||
if (derivedTool === 'inkHighlighter') {
|
||||
if (annObject?.color) setHighlightColor(annObject.color);
|
||||
if (annObject?.opacity !== undefined) {
|
||||
setHighlightOpacity(Math.round((annObject.opacity ?? 1) * 100));
|
||||
}
|
||||
if (width !== undefined && setFreehandHighlighterWidth) {
|
||||
setFreehandHighlighterWidth(width);
|
||||
}
|
||||
} else {
|
||||
if (width !== undefined) setInkWidth(width ?? 2);
|
||||
if (annObject?.color) {
|
||||
setInkColor(annObject.color);
|
||||
}
|
||||
}
|
||||
} else if (type >= 4 && type <= 8) {
|
||||
const width = annObject?.strokeWidth ?? annObject?.borderWidth ?? annObject?.lineWidth;
|
||||
if (width !== undefined) {
|
||||
setShapeThickness(width ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 9) {
|
||||
if (annObject?.color) setHighlightColor(annObject.color);
|
||||
if (annObject?.opacity !== undefined) setHighlightOpacity(Math.round((annObject.opacity ?? 1) * 100));
|
||||
} else if (type === 10) {
|
||||
if (annObject?.color) setUnderlineColor(annObject.color);
|
||||
if (annObject?.opacity !== undefined) setUnderlineOpacity(Math.round((annObject.opacity ?? 1) * 100));
|
||||
} else if (type === 12) {
|
||||
if (annObject?.color) setStrikeoutColor(annObject.color);
|
||||
if (annObject?.opacity !== undefined) setStrikeoutOpacity(Math.round((annObject.opacity ?? 1) * 100));
|
||||
} else if (type === 11) {
|
||||
if (annObject?.color) setSquigglyColor(annObject.color);
|
||||
if (annObject?.opacity !== undefined) setSquigglyOpacity(Math.round((annObject.opacity ?? 1) * 100));
|
||||
}
|
||||
|
||||
if ([4, 5, 6, 7, 8].includes(type)) {
|
||||
const stroke = (annObject?.strokeColor as string | undefined) ?? (annObject?.color as string | undefined);
|
||||
if (stroke) setShapeStrokeColor(stroke);
|
||||
if ([5, 6, 7].includes(type)) {
|
||||
const fill = (annObject?.color as string | undefined) ?? (annObject?.fillColor as string | undefined);
|
||||
if (fill) setShapeFillColor(fill);
|
||||
}
|
||||
const opacity =
|
||||
annObject?.opacity !== undefined ? Math.round((annObject.opacity ?? 1) * 100) : undefined;
|
||||
const strokeOpacityValue =
|
||||
annObject?.strokeOpacity !== undefined
|
||||
? Math.round((annObject.strokeOpacity ?? 1) * 100)
|
||||
: undefined;
|
||||
const fillOpacityValue =
|
||||
annObject?.fillOpacity !== undefined ? Math.round((annObject.fillOpacity ?? 1) * 100) : undefined;
|
||||
if (opacity !== undefined) {
|
||||
setShapeOpacity(opacity);
|
||||
setShapeStrokeOpacity(strokeOpacityValue ?? opacity);
|
||||
setShapeFillOpacity(fillOpacityValue ?? opacity);
|
||||
} else {
|
||||
if (strokeOpacityValue !== undefined) setShapeStrokeOpacity(strokeOpacityValue);
|
||||
if (fillOpacityValue !== undefined) setShapeFillOpacity(fillOpacityValue);
|
||||
}
|
||||
}
|
||||
|
||||
const matchingTool = derivedTool;
|
||||
const stayOnPlacement = shouldStayOnPlacementTool(annObject, matchingTool);
|
||||
if (matchingTool && activeToolRef.current !== 'select' && !stayOnPlacement) {
|
||||
activeToolRef.current = 'select';
|
||||
setActiveTool('select');
|
||||
// Immediately enable select tool to avoid re-entering placement after creation.
|
||||
annotationApiRef.current?.activateAnnotationTool?.('select');
|
||||
} else if (activeToolRef.current === 'select') {
|
||||
// Keep the viewer in Select mode so clicking existing annotations does not re-enable placement.
|
||||
annotationApiRef.current?.activateAnnotationTool?.('select');
|
||||
}
|
||||
},
|
||||
[
|
||||
activeToolRef,
|
||||
deriveToolFromAnnotation,
|
||||
manualToolSwitch,
|
||||
setActiveTool,
|
||||
setInkWidth,
|
||||
setNoteBackgroundColor,
|
||||
setSelectedFontSize,
|
||||
setSelectedTextDraft,
|
||||
setShapeThickness,
|
||||
setTextBackgroundColor,
|
||||
setTextColor,
|
||||
setInkColor,
|
||||
setHighlightColor,
|
||||
setHighlightOpacity,
|
||||
setUnderlineColor,
|
||||
setUnderlineOpacity,
|
||||
setStrikeoutColor,
|
||||
setStrikeoutOpacity,
|
||||
setSquigglyColor,
|
||||
setSquigglyOpacity,
|
||||
setShapeStrokeColor,
|
||||
setShapeFillColor,
|
||||
setShapeOpacity,
|
||||
setShapeStrokeOpacity,
|
||||
setShapeFillOpacity,
|
||||
setTextAlignment,
|
||||
setFreehandHighlighterWidth,
|
||||
shouldStayOnPlacementTool,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const api = annotationApiRef.current as any;
|
||||
if (!api) return;
|
||||
|
||||
const checkSelection = () => {
|
||||
let ann: any = null;
|
||||
if (typeof api.getSelectedAnnotation === 'function') {
|
||||
try {
|
||||
ann = api.getSelectedAnnotation();
|
||||
} catch (error) {
|
||||
// Some builds of the annotation plugin can throw when reading
|
||||
// internal selection state (e.g., accessing `selectedUid` on
|
||||
// an undefined object). Treat this as "no current selection"
|
||||
// instead of crashing the annotations tool.
|
||||
console.error('[useAnnotationSelection] getSelectedAnnotation failed:', error);
|
||||
ann = null;
|
||||
}
|
||||
}
|
||||
const currentId = ann?.object?.id ?? ann?.id ?? null;
|
||||
if (currentId !== selectedAnnIdRef.current) {
|
||||
applySelectionFromAnnotation(ann ?? null);
|
||||
}
|
||||
};
|
||||
|
||||
let interval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
if (typeof api.onAnnotationEvent === 'function') {
|
||||
const handler = (event: any) => {
|
||||
const ann = event?.annotation ?? event?.selectedAnnotation ?? null;
|
||||
const eventType = event?.type;
|
||||
switch (eventType) {
|
||||
case 'create':
|
||||
case 'add':
|
||||
case 'added':
|
||||
case 'created':
|
||||
case 'annotationCreated':
|
||||
case 'annotationAdded':
|
||||
case 'complete': {
|
||||
const eventAnn = ann ?? api.getSelectedAnnotation?.();
|
||||
applySelectionFromAnnotation(eventAnn);
|
||||
const currentTool = activeToolRef.current;
|
||||
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);
|
||||
if (activeToolRef.current !== 'select' && !stayOnPlacement) {
|
||||
activeToolRef.current = 'select';
|
||||
setActiveTool('select');
|
||||
annotationApiRef.current?.activateAnnotationTool?.('select');
|
||||
}
|
||||
// Re-read selection after the viewer updates to ensure we have the full annotation object for the edit panel.
|
||||
setTimeout(() => {
|
||||
const selected = api.getSelectedAnnotation?.();
|
||||
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);
|
||||
if (activeToolRef.current !== 'select' && !stayOnPlacementAfter) {
|
||||
activeToolRef.current = 'select';
|
||||
setActiveTool('select');
|
||||
annotationApiRef.current?.activateAnnotationTool?.('select');
|
||||
}
|
||||
}, 50);
|
||||
break;
|
||||
}
|
||||
case 'select':
|
||||
case 'selected':
|
||||
case 'annotationSelected':
|
||||
case 'annotationClicked':
|
||||
case 'annotationTapped':
|
||||
applySelectionFromAnnotation(ann ?? api.getSelectedAnnotation?.());
|
||||
break;
|
||||
case 'deselect':
|
||||
case 'clearSelection':
|
||||
applySelectionFromAnnotation(null);
|
||||
break;
|
||||
case 'delete':
|
||||
case 'remove':
|
||||
if (ann?.id && ann.id === selectedAnnIdRef.current) {
|
||||
applySelectionFromAnnotation(null);
|
||||
}
|
||||
break;
|
||||
case 'update':
|
||||
case 'change':
|
||||
if (selectedAnnIdRef.current) {
|
||||
const current = api.getSelectedAnnotation?.();
|
||||
if (current) {
|
||||
applySelectionFromAnnotation(current);
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribe = api.onAnnotationEvent(handler);
|
||||
interval = setInterval(checkSelection, 450);
|
||||
return () => {
|
||||
if (typeof unsubscribe === 'function') {
|
||||
unsubscribe();
|
||||
}
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
}
|
||||
|
||||
interval = setInterval(checkSelection, 350);
|
||||
return () => {
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
}, [annotationApiRef, applySelectionFromAnnotation]);
|
||||
|
||||
return {
|
||||
selectedAnn,
|
||||
selectedAnnId,
|
||||
selectedAnnIdRef,
|
||||
setSelectedAnn,
|
||||
setSelectedAnnId,
|
||||
applySelectionFromAnnotation,
|
||||
};
|
||||
}
|
||||
325
frontend/src/core/tools/annotate/useAnnotationStyleState.ts
Normal file
325
frontend/src/core/tools/annotate/useAnnotationStyleState.ts
Normal file
@ -0,0 +1,325 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import type { AnnotationToolId } from '@app/components/viewer/viewerTypes';
|
||||
|
||||
type Size = { width: number; height: number };
|
||||
|
||||
export type BuildToolOptionsExtras = {
|
||||
includeMetadata?: boolean;
|
||||
stampImageData?: string;
|
||||
stampImageSize?: Size | null;
|
||||
};
|
||||
|
||||
interface StyleState {
|
||||
inkColor: string;
|
||||
inkWidth: number;
|
||||
highlightColor: string;
|
||||
highlightOpacity: number;
|
||||
freehandHighlighterWidth: number;
|
||||
underlineColor: string;
|
||||
underlineOpacity: number;
|
||||
strikeoutColor: string;
|
||||
strikeoutOpacity: number;
|
||||
squigglyColor: string;
|
||||
squigglyOpacity: number;
|
||||
textColor: string;
|
||||
textSize: number;
|
||||
textAlignment: 'left' | 'center' | 'right';
|
||||
textBackgroundColor: string;
|
||||
noteBackgroundColor: string;
|
||||
shapeStrokeColor: string;
|
||||
shapeFillColor: string;
|
||||
shapeOpacity: number;
|
||||
shapeStrokeOpacity: number;
|
||||
shapeFillOpacity: number;
|
||||
shapeThickness: number;
|
||||
}
|
||||
|
||||
interface StyleActions {
|
||||
setInkColor: (value: string) => void;
|
||||
setInkWidth: (value: number) => void;
|
||||
setHighlightColor: (value: string) => void;
|
||||
setHighlightOpacity: (value: number) => void;
|
||||
setFreehandHighlighterWidth: (value: number) => void;
|
||||
setUnderlineColor: (value: string) => void;
|
||||
setUnderlineOpacity: (value: number) => void;
|
||||
setStrikeoutColor: (value: string) => void;
|
||||
setStrikeoutOpacity: (value: number) => void;
|
||||
setSquigglyColor: (value: string) => void;
|
||||
setSquigglyOpacity: (value: number) => void;
|
||||
setTextColor: (value: string) => void;
|
||||
setTextSize: (value: number) => void;
|
||||
setTextAlignment: (value: 'left' | 'center' | 'right') => void;
|
||||
setTextBackgroundColor: (value: string) => void;
|
||||
setNoteBackgroundColor: (value: string) => void;
|
||||
setShapeStrokeColor: (value: string) => void;
|
||||
setShapeFillColor: (value: string) => void;
|
||||
setShapeOpacity: (value: number) => void;
|
||||
setShapeStrokeOpacity: (value: number) => void;
|
||||
setShapeFillOpacity: (value: number) => void;
|
||||
setShapeThickness: (value: number) => void;
|
||||
}
|
||||
|
||||
export type BuildToolOptionsFn = (
|
||||
toolId: AnnotationToolId,
|
||||
extras?: BuildToolOptionsExtras
|
||||
) => Record<string, unknown>;
|
||||
|
||||
export interface AnnotationStyleStateReturn {
|
||||
styleState: StyleState;
|
||||
styleActions: StyleActions;
|
||||
buildToolOptions: BuildToolOptionsFn;
|
||||
getActiveColor: (target: string | null) => string;
|
||||
}
|
||||
|
||||
export const useAnnotationStyleState = (
|
||||
cssToPdfSize?: (size: Size) => Size
|
||||
): AnnotationStyleStateReturn => {
|
||||
const [inkColor, setInkColor] = useState('#1f2933');
|
||||
const [inkWidth, setInkWidth] = useState(2);
|
||||
const [highlightColor, setHighlightColor] = useState('#ffd54f');
|
||||
const [highlightOpacity, setHighlightOpacity] = useState(60);
|
||||
const [freehandHighlighterWidth, setFreehandHighlighterWidth] = useState(6);
|
||||
const [underlineColor, setUnderlineColor] = useState('#ffb300');
|
||||
const [underlineOpacity, setUnderlineOpacity] = useState(100);
|
||||
const [strikeoutColor, setStrikeoutColor] = useState('#e53935');
|
||||
const [strikeoutOpacity, setStrikeoutOpacity] = useState(100);
|
||||
const [squigglyColor, setSquigglyColor] = useState('#00acc1');
|
||||
const [squigglyOpacity, setSquigglyOpacity] = useState(100);
|
||||
const [textColor, setTextColor] = useState('#111111');
|
||||
const [textSize, setTextSize] = useState(14);
|
||||
const [textAlignment, setTextAlignment] = useState<'left' | 'center' | 'right'>('left');
|
||||
const [textBackgroundColor, setTextBackgroundColor] = useState<string>('');
|
||||
const [noteBackgroundColor, setNoteBackgroundColor] = useState('#ffd54f');
|
||||
const [shapeStrokeColor, setShapeStrokeColor] = useState('#cf5b5b');
|
||||
const [shapeFillColor, setShapeFillColor] = useState('#0000ff');
|
||||
const [shapeOpacity, setShapeOpacity] = useState(50);
|
||||
const [shapeStrokeOpacity, setShapeStrokeOpacity] = useState(50);
|
||||
const [shapeFillOpacity, setShapeFillOpacity] = useState(50);
|
||||
const [shapeThickness, setShapeThickness] = useState(2);
|
||||
|
||||
const buildToolOptions = useCallback<BuildToolOptionsFn>(
|
||||
(toolId, extras) => {
|
||||
const includeMetadata = extras?.includeMetadata ?? true;
|
||||
const metadata = includeMetadata
|
||||
? {
|
||||
customData: {
|
||||
toolId,
|
||||
annotationToolId: toolId,
|
||||
source: 'annotate',
|
||||
author: 'User',
|
||||
createdAt: new Date().toISOString(),
|
||||
modifiedAt: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
switch (toolId) {
|
||||
case 'ink':
|
||||
return { color: inkColor, thickness: inkWidth, ...metadata };
|
||||
case 'inkHighlighter':
|
||||
return {
|
||||
color: highlightColor,
|
||||
opacity: highlightOpacity / 100,
|
||||
thickness: freehandHighlighterWidth,
|
||||
...metadata,
|
||||
};
|
||||
case 'highlight':
|
||||
return { color: highlightColor, opacity: highlightOpacity / 100, ...metadata };
|
||||
case 'underline':
|
||||
return { color: underlineColor, opacity: underlineOpacity / 100, ...metadata };
|
||||
case 'strikeout':
|
||||
return { color: strikeoutColor, opacity: strikeoutOpacity / 100, ...metadata };
|
||||
case 'squiggly':
|
||||
return { color: squigglyColor, opacity: squigglyOpacity / 100, ...metadata };
|
||||
case 'text': {
|
||||
const textAlignNumber = textAlignment === 'left' ? 0 : textAlignment === 'center' ? 1 : 2;
|
||||
return {
|
||||
color: textColor,
|
||||
textColor: textColor,
|
||||
fontSize: textSize,
|
||||
textAlign: textAlignNumber,
|
||||
...(textBackgroundColor ? { fillColor: textBackgroundColor } : {}),
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
case 'note': {
|
||||
const noteFillColor = noteBackgroundColor || 'transparent';
|
||||
return {
|
||||
color: textColor,
|
||||
fillColor: noteFillColor,
|
||||
opacity: 1,
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
case 'square':
|
||||
case 'circle':
|
||||
case 'polygon':
|
||||
return {
|
||||
color: shapeFillColor,
|
||||
strokeColor: shapeStrokeColor,
|
||||
opacity: shapeOpacity / 100,
|
||||
strokeOpacity: shapeStrokeOpacity / 100,
|
||||
fillOpacity: shapeFillOpacity / 100,
|
||||
borderWidth: shapeThickness,
|
||||
...metadata,
|
||||
};
|
||||
case 'line':
|
||||
case 'polyline':
|
||||
case 'lineArrow':
|
||||
return {
|
||||
color: shapeStrokeColor,
|
||||
strokeColor: shapeStrokeColor,
|
||||
opacity: shapeStrokeOpacity / 100,
|
||||
borderWidth: shapeThickness,
|
||||
...metadata,
|
||||
};
|
||||
case 'stamp': {
|
||||
const pdfSize =
|
||||
extras?.stampImageSize && cssToPdfSize ? cssToPdfSize(extras.stampImageSize) : undefined;
|
||||
return {
|
||||
imageSrc: extras?.stampImageData,
|
||||
...(pdfSize ? { imageSize: pdfSize } : {}),
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return { ...metadata };
|
||||
}
|
||||
},
|
||||
[
|
||||
cssToPdfSize,
|
||||
freehandHighlighterWidth,
|
||||
highlightColor,
|
||||
highlightOpacity,
|
||||
inkColor,
|
||||
inkWidth,
|
||||
noteBackgroundColor,
|
||||
shapeFillColor,
|
||||
shapeFillOpacity,
|
||||
shapeOpacity,
|
||||
shapeStrokeColor,
|
||||
shapeStrokeOpacity,
|
||||
shapeThickness,
|
||||
squigglyColor,
|
||||
squigglyOpacity,
|
||||
strikeoutColor,
|
||||
strikeoutOpacity,
|
||||
textAlignment,
|
||||
textBackgroundColor,
|
||||
textColor,
|
||||
textSize,
|
||||
underlineColor,
|
||||
underlineOpacity,
|
||||
]
|
||||
);
|
||||
|
||||
const getActiveColor = useCallback(
|
||||
(target: string | null) => {
|
||||
if (target === 'ink') return inkColor;
|
||||
if (target === 'highlight' || target === 'inkHighlighter') return highlightColor;
|
||||
if (target === 'underline') return underlineColor;
|
||||
if (target === 'strikeout') return strikeoutColor;
|
||||
if (target === 'squiggly') return squigglyColor;
|
||||
if (target === 'shapeStroke') return shapeStrokeColor;
|
||||
if (target === 'shapeFill') return shapeFillColor;
|
||||
if (target === 'textBackground') return textBackgroundColor || '#ffffff';
|
||||
if (target === 'noteBackground') return noteBackgroundColor || '#ffffff';
|
||||
return textColor;
|
||||
},
|
||||
[
|
||||
highlightColor,
|
||||
inkColor,
|
||||
noteBackgroundColor,
|
||||
shapeFillColor,
|
||||
shapeStrokeColor,
|
||||
squigglyColor,
|
||||
strikeoutColor,
|
||||
textBackgroundColor,
|
||||
textColor,
|
||||
underlineColor,
|
||||
]
|
||||
);
|
||||
|
||||
const styleState: StyleState = useMemo(
|
||||
() => ({
|
||||
inkColor,
|
||||
inkWidth,
|
||||
highlightColor,
|
||||
highlightOpacity,
|
||||
freehandHighlighterWidth,
|
||||
underlineColor,
|
||||
underlineOpacity,
|
||||
strikeoutColor,
|
||||
strikeoutOpacity,
|
||||
squigglyColor,
|
||||
squigglyOpacity,
|
||||
textColor,
|
||||
textSize,
|
||||
textAlignment,
|
||||
textBackgroundColor,
|
||||
noteBackgroundColor,
|
||||
shapeStrokeColor,
|
||||
shapeFillColor,
|
||||
shapeOpacity,
|
||||
shapeStrokeOpacity,
|
||||
shapeFillOpacity,
|
||||
shapeThickness,
|
||||
}),
|
||||
[
|
||||
freehandHighlighterWidth,
|
||||
highlightColor,
|
||||
highlightOpacity,
|
||||
inkColor,
|
||||
inkWidth,
|
||||
noteBackgroundColor,
|
||||
shapeFillColor,
|
||||
shapeFillOpacity,
|
||||
shapeOpacity,
|
||||
shapeStrokeColor,
|
||||
shapeStrokeOpacity,
|
||||
shapeThickness,
|
||||
squigglyColor,
|
||||
squigglyOpacity,
|
||||
strikeoutColor,
|
||||
strikeoutOpacity,
|
||||
textAlignment,
|
||||
textBackgroundColor,
|
||||
textColor,
|
||||
textSize,
|
||||
underlineColor,
|
||||
underlineOpacity,
|
||||
]
|
||||
);
|
||||
|
||||
const styleActions: StyleActions = {
|
||||
setInkColor,
|
||||
setInkWidth,
|
||||
setHighlightColor,
|
||||
setHighlightOpacity,
|
||||
setFreehandHighlighterWidth,
|
||||
setUnderlineColor,
|
||||
setUnderlineOpacity,
|
||||
setStrikeoutColor,
|
||||
setStrikeoutOpacity,
|
||||
setSquigglyColor,
|
||||
setSquigglyOpacity,
|
||||
setTextColor,
|
||||
setTextSize,
|
||||
setTextAlignment,
|
||||
setTextBackgroundColor,
|
||||
setNoteBackgroundColor,
|
||||
setShapeStrokeColor,
|
||||
setShapeFillColor,
|
||||
setShapeOpacity,
|
||||
setShapeStrokeOpacity,
|
||||
setShapeFillOpacity,
|
||||
setShapeThickness,
|
||||
};
|
||||
|
||||
return {
|
||||
styleState,
|
||||
styleActions,
|
||||
buildToolOptions,
|
||||
getActiveColor,
|
||||
};
|
||||
};
|
||||
@ -25,6 +25,7 @@ export const CORE_REGULAR_TOOL_IDS = [
|
||||
'ocr',
|
||||
'addImage',
|
||||
'rotate',
|
||||
'annotate',
|
||||
'scannerImageSplit',
|
||||
'editTableOfContents',
|
||||
'scannerEffect',
|
||||
|
||||
@ -70,6 +70,8 @@ export const URL_TO_TOOL_MAP: Record<string, ToolId> = {
|
||||
'/scanner-image-split': 'scannerImageSplit',
|
||||
|
||||
// Annotation and content removal
|
||||
'/annotations': 'annotate',
|
||||
'/annotate': 'annotate',
|
||||
'/remove-annotations': 'removeAnnotations',
|
||||
'/remove-image': 'removeImage',
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user