Improve annotations (#5919)

* Text box/notes movement improvements 
* Fix the issue where hiding, then showing annotations looses progress 
* Fix the issue where hidig/showing annotations jumps you back up to the
top of your open document 
* Support ctrl+c and  ctrl+v and backspace to delete 
* Better handling when moving to different tool from annotate 
* Added a color picker eyedropper button  
* Auto-switch to Select after note/text placement, so users can quickly
place and type 
This commit is contained in:
EthanHealy01
2026-03-13 14:03:27 +00:00
committed by GitHub
parent 44e036da5a
commit c9d693f1eb
13 changed files with 409 additions and 245 deletions
@@ -1,5 +1,15 @@
import { ActionIcon, Tooltip, Popover, Stack, ColorSwatch, ColorPicker as MantineColorPicker } from '@mantine/core'; import { ActionIcon, Tooltip, Popover, Stack, ColorSwatch, ColorPicker as MantineColorPicker, Group } from '@mantine/core';
import { useState } from 'react'; import { useState, useCallback } from 'react';
import ColorizeIcon from '@mui/icons-material/Colorize';
// safari and firefox do not support the eye dropper API, only edge, chrome and opera do.
// the button is hidden in the UI if the API is not supported.
const supportsEyeDropper = typeof window !== 'undefined' && 'EyeDropper' in window;
interface EyeDropper {
open(): Promise<{ sRGBHex: string }>;
}
declare const EyeDropper: { new(): EyeDropper };
interface ColorControlProps { interface ColorControlProps {
value: string; value: string;
@@ -11,6 +21,17 @@ interface ColorControlProps {
export function ColorControl({ value, onChange, label, disabled = false }: ColorControlProps) { export function ColorControl({ value, onChange, label, disabled = false }: ColorControlProps) {
const [opened, setOpened] = useState(false); const [opened, setOpened] = useState(false);
const handleEyeDropper = useCallback(async () => {
if (!supportsEyeDropper) return;
try {
const eyeDropper = new EyeDropper();
const result = await eyeDropper.open();
onChange(result.sRGBHex);
} catch {
// User cancelled or browser error — no-op
}
}, [onChange]);
return ( return (
<Popover opened={opened} onChange={setOpened} position="bottom" withArrow withinPortal> <Popover opened={opened} onChange={setOpened} position="bottom" withArrow withinPortal>
<Popover.Target> <Popover.Target>
@@ -52,6 +73,15 @@ export function ColorControl({ value, onChange, label, disabled = false }: Color
swatchesPerRow={5} swatchesPerRow={5}
size="sm" size="sm"
/> />
{supportsEyeDropper && (
<Group justify="flex-end">
<Tooltip label="Pick colour from screen">
<ActionIcon variant="subtle" color="gray" size="sm" onClick={handleEyeDropper}>
<ColorizeIcon style={{ fontSize: 16 }} />
</ActionIcon>
</Tooltip>
</Group>
)}
</Stack> </Stack>
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>
@@ -45,7 +45,7 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
// Get redaction pending state and navigation guard // Get redaction pending state and navigation guard
const { isRedacting: _isRedacting } = useRedactionMode(); const { isRedacting: _isRedacting } = useRedactionMode();
const { requestNavigation, setHasUnsavedChanges } = useNavigationGuard(); const { requestNavigation, setHasUnsavedChanges, hasUnsavedChanges } = useNavigationGuard();
const { setRedactionMode, activateRedact, setRedactionConfig, setRedactionsApplied, redactionApiRef, setActiveType } = useRedaction(); const { setRedactionMode, activateRedact, setRedactionConfig, setRedactionsApplied, redactionApiRef, setActiveType } = useRedaction();
@@ -131,6 +131,15 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
} }
}; };
const handleToggleAnnotationsVisibility = useCallback(() => {
// When going from visible → hidden with unsaved changes, prompt to save first
if (!annotationsHidden && hasUnsavedChanges) {
requestNavigation(() => viewerContext?.toggleAnnotationsVisibility());
} else {
viewerContext?.toggleAnnotationsVisibility();
}
}, [annotationsHidden, hasUnsavedChanges, requestNavigation, viewerContext]);
// Don't show any annotation controls in sign mode // Don't show any annotation controls in sign mode
// NOTE: This early return is placed AFTER all hooks to satisfy React's rules of hooks // NOTE: This early return is placed AFTER all hooks to satisfy React's rules of hooks
if (isSignMode) { if (isSignMode) {
@@ -164,9 +173,7 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
color={annotationsHidden ? "blue" : undefined} color={annotationsHidden ? "blue" : undefined}
radius="md" radius="md"
className="right-rail-icon" className="right-rail-icon"
onClick={() => { onClick={handleToggleAnnotationsVisibility}
viewerContext?.toggleAnnotationsVisibility();
}}
disabled={disabled || currentView !== 'viewer' || (isInAnnotationTool && !isAnnotateActive) || isPlacementMode} disabled={disabled || currentView !== 'viewer' || (isInAnnotationTool && !isAnnotateActive) || isPlacementMode}
data-active={annotationsHidden ? 'true' : undefined} data-active={annotationsHidden ? 'true' : undefined}
aria-pressed={annotationsHidden} aria-pressed={annotationsHidden}
@@ -8,6 +8,7 @@ import type {
AnnotationEvent, AnnotationEvent,
AnnotationPatch, AnnotationPatch,
AnnotationRect, AnnotationRect,
AnnotationSelection,
} from '@app/components/viewer/viewerTypes'; } from '@app/components/viewer/viewerTypes';
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady'; import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
@@ -86,9 +87,13 @@ type AnnotationApiSurface = {
setActiveTool: (toolId: AnnotationToolId | null) => void; setActiveTool: (toolId: AnnotationToolId | null) => void;
getActiveTool?: () => { id: AnnotationToolId } | null; getActiveTool?: () => { id: AnnotationToolId } | null;
setToolDefaults?: (toolId: AnnotationToolId, defaults: AnnotationDefaults) => void; setToolDefaults?: (toolId: AnnotationToolId, defaults: AnnotationDefaults) => void;
getSelectedAnnotation?: () => unknown | null; getSelectedAnnotation?: () => AnnotationSelection | null;
deselectAnnotation?: () => void; deselectAnnotation?: () => void;
updateAnnotation?: (pageIndex: number, annotationId: string, patch: AnnotationPatch) => void; updateAnnotation?: (pageIndex: number, annotationId: string, patch: AnnotationPatch) => void;
deleteAnnotation?: (pageIndex: number, annotationId: string) => void;
deleteAnnotations?: (annotations: Array<{ pageIndex: number; id: string }>) => void;
createAnnotation?: (pageIndex: number, annotation: Record<string, unknown>) => void;
getSelectedAnnotations?: () => AnnotationSelection[];
onAnnotationEvent?: (listener: (event: AnnotationEvent) => void) => void | (() => void); onAnnotationEvent?: (listener: (event: AnnotationEvent) => void) => void | (() => void);
purgeAnnotation?: (pageIndex: number, annotationId: string) => void; purgeAnnotation?: (pageIndex: number, annotationId: string) => void;
/** v2.7.0: move annotation without regenerating its appearance stream */ /** v2.7.0: move annotation without regenerating its appearance stream */
@@ -380,6 +385,26 @@ export const AnnotationAPIBridge = forwardRef<AnnotationAPI>(function Annotation
return api?.getActiveTool?.() ?? null; return api?.getActiveTool?.() ?? null;
}, },
deleteAnnotation: (pageIndex: number, annotationId: string) => {
const api = annotationApi as unknown as AnnotationApiSurface | undefined;
api?.deleteAnnotation?.(pageIndex, annotationId);
},
deleteAnnotations: (annotations: Array<{ pageIndex: number; id: string }>) => {
const api = annotationApi as unknown as AnnotationApiSurface | undefined;
api?.deleteAnnotations?.(annotations);
},
createAnnotation: (pageIndex: number, annotation: Record<string, unknown>) => {
const api = annotationApi as unknown as AnnotationApiSurface | undefined;
api?.createAnnotation?.(pageIndex, annotation);
},
getSelectedAnnotations: () => {
const api = annotationApi as unknown as AnnotationApiSurface | undefined;
return api?.getSelectedAnnotations?.() ?? [];
},
purgeAnnotation: (pageIndex: number, annotationId: string) => { purgeAnnotation: (pageIndex: number, annotationId: string) => {
const api = annotationApi as unknown as AnnotationApiSurface | undefined; const api = annotationApi as unknown as AnnotationApiSurface | undefined;
api?.purgeAnnotation?.(pageIndex, annotationId); api?.purgeAnnotation?.(pageIndex, annotationId);
@@ -5,6 +5,9 @@ import { useEffect, useState, useRef, useCallback } from 'react';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit'; import EditIcon from '@mui/icons-material/Edit';
import { useAnnotation } from '@embedpdf/plugin-annotation/react'; import { useAnnotation } from '@embedpdf/plugin-annotation/react';
import type { TrackedAnnotation } from '@embedpdf/plugin-annotation';
import type { PdfAnnotationObject } from '@embedpdf/models';
import type { AnnotationPatch, AnnotationObject } from '@app/components/viewer/viewerTypes';
import { useActiveDocumentId } from '@app/components/viewer/useActiveDocumentId'; import { useActiveDocumentId } from '@app/components/viewer/useActiveDocumentId';
import { OpacityControl } from '@app/components/annotation/shared/OpacityControl'; import { OpacityControl } from '@app/components/annotation/shared/OpacityControl';
import { WidthControl } from '@app/components/annotation/shared/WidthControl'; import { WidthControl } from '@app/components/annotation/shared/WidthControl';
@@ -19,7 +22,7 @@ export interface AnnotationSelectionMenuProps {
documentId?: string; documentId?: string;
context?: { context?: {
type: 'annotation'; type: 'annotation';
annotation: any; annotation: TrackedAnnotation<PdfAnnotationObject>;
pageIndex: number; pageIndex: number;
}; };
selected: boolean; selected: boolean;
@@ -58,11 +61,7 @@ function AnnotationSelectionMenuInner({
const { t } = useTranslation(); const { t } = useTranslation();
const { provides } = useAnnotation(documentId); const { provides } = useAnnotation(documentId);
const wrapperRef = useRef<HTMLDivElement>(null); const wrapperRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null); const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null);
const [isTextEditorOpen, setIsTextEditorOpen] = useState(false);
const [textDraft, setTextDraft] = useState('');
const [textBoxPosition, setTextBoxPosition] = useState<{ top: number; left: number; width: number; height: number; fontSize: number; fontFamily: string } | null>(null);
// Merge refs - menuWrapperProps.ref is a callback ref // Merge refs - menuWrapperProps.ref is a callback ref
const setRef = useCallback((node: HTMLDivElement | null) => { const setRef = useCallback((node: HTMLDivElement | null) => {
@@ -74,18 +73,18 @@ function AnnotationSelectionMenuInner({
// Type detection // Type detection
const getAnnotationType = useCallback((): AnnotationType => { const getAnnotationType = useCallback((): AnnotationType => {
const type = annotation?.object?.type; const type = annotation?.object?.type;
const toolId = annotation?.object?.customData?.toolId; const toolId = (annotation?.object as AnnotationObject | undefined)?.customData?.toolId;
// Map type numbers to categories // Map type numbers to categories
if ([9, 10, 11, 12].includes(type)) return 'textMarkup'; if (type !== undefined && [9, 10, 11, 12].includes(type)) return 'textMarkup';
if (type === 15) { if (type === 15) {
return toolId === 'inkHighlighter' ? 'inkHighlighter' : 'ink'; return toolId === 'inkHighlighter' ? 'inkHighlighter' : 'ink';
} }
if (type === 3) { if (type === 3) {
return toolId === 'note' ? 'note' : 'text'; return toolId === 'note' ? 'note' : 'text';
} }
if ([5, 6, 7].includes(type)) return 'shape'; if (type !== undefined && [5, 6, 7].includes(type)) return 'shape';
if ([4, 8].includes(type)) return 'line'; if (type !== undefined && [4, 8].includes(type)) return 'line';
if (type === 13) return 'stamp'; if (type === 13) return 'stamp';
return 'unknown'; return 'unknown';
@@ -106,7 +105,7 @@ function AnnotationSelectionMenuInner({
}; };
// Get annotation properties // Get annotation properties
const obj = annotation?.object; const obj = annotation?.object as AnnotationObject | undefined;
const annotationType = getAnnotationType(); const annotationType = getAnnotationType();
const annotationId = obj?.id; const annotationId = obj?.id;
@@ -117,7 +116,7 @@ function AnnotationSelectionMenuInner({
// Text annotations use textColor // Text annotations use textColor
if (type === 3) return obj.textColor || obj.color || '#000000'; if (type === 3) return obj.textColor || obj.color || '#000000';
// Shape annotations use strokeColor // Shape annotations use strokeColor
if ([4, 5, 6, 7, 8].includes(type)) return obj.strokeColor || obj.color || '#000000'; if (type !== undefined && [4, 5, 6, 7, 8].includes(type)) return obj.strokeColor || obj.color || '#000000';
// Default to color property // Default to color property
return obj.color || obj.strokeColor || '#000000'; return obj.color || obj.strokeColor || '#000000';
}; };
@@ -154,82 +153,22 @@ function AnnotationSelectionMenuInner({
} }
}, [provides, annotationId, pageIndex]); }, [provides, annotationId, pageIndex]);
const handleOpenTextEditor = useCallback(() => { // Focus inline text input (same as double-clicking the note/text box). Dispatches dblclick on the
if (!annotation) return; // annotation hit-area div (EmbedPDF's inner div with onDoubleClick) so built-in FreeText editing is used.
const handleFocusTextEdit = useCallback(() => {
// Try to find the annotation element in the DOM const root = wrapperRef.current?.closest('[data-no-interaction]');
const annotationElement = document.querySelector(`[data-annotation-id="${annotationId}"]`) as HTMLElement; const main = root?.firstElementChild;
// EmbedPDF puts onDoubleClick on the content div. For text/note (no rotation) it's the first child.
let fontSize = (obj?.fontSize || 14) * 1.33; const hitArea = main?.lastElementChild ?? main?.firstElementChild;
let fontFamily = 'Helvetica'; if (!hitArea) return;
hitArea.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true, view: window }));
if (annotationElement) {
const rect = annotationElement.getBoundingClientRect();
// Try multiple selectors to find the text element
const textElement = annotationElement.querySelector('text, [class*="text"], [class*="content"]') as HTMLElement;
if (textElement) {
const computedStyle = window.getComputedStyle(textElement);
const computedSize = parseFloat(computedStyle.fontSize);
if (computedSize && computedSize > 0) {
fontSize = computedSize;
}
fontFamily = computedStyle.fontFamily || fontFamily;
}
setTextBoxPosition({
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
fontSize: fontSize,
fontFamily: fontFamily,
});
} else if (wrapperRef.current) {
// Fallback to wrapper position
const rect = wrapperRef.current.getBoundingClientRect();
setTextBoxPosition({
top: rect.top,
left: rect.left,
width: Math.max(rect.width, 200),
height: Math.max(rect.height, 50),
fontSize: fontSize,
fontFamily: fontFamily,
});
} else {
return;
}
setTextDraft(obj?.contents || '');
setIsTextEditorOpen(true);
// Focus the textarea after it renders
setTimeout(() => {
textareaRef.current?.focus();
textareaRef.current?.select();
}, 0);
}, [obj, annotation, annotationId]);
const handleSaveText = useCallback(() => {
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
provides.updateAnnotation(pageIndex, annotationId, {
contents: textDraft,
});
setIsTextEditorOpen(false);
setTextBoxPosition(null);
}, [provides, annotationId, pageIndex, textDraft]);
const handleCloseTextEdit = useCallback(() => {
setIsTextEditorOpen(false);
setTextBoxPosition(null);
}, []); }, []);
const handleColorChange = useCallback((color: string, target: 'main' | 'stroke' | 'fill' | 'text' | 'background') => { const handleColorChange = useCallback((color: string, target: 'main' | 'stroke' | 'fill' | 'text' | 'background') => {
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return; if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
const type = obj?.type; const type = obj?.type;
const patch: any = {}; const patch: AnnotationPatch = {};
if (target === 'stroke') { if (target === 'stroke') {
// Shape stroke - preserve fill color // Shape stroke - preserve fill color
@@ -262,14 +201,14 @@ function AnnotationSelectionMenuInner({
patch.color = color; patch.color = color;
// For text markup annotations (highlight, underline, strikeout, squiggly) // For text markup annotations (highlight, underline, strikeout, squiggly)
if ([9, 10, 11, 12].includes(type)) { if (type !== undefined && [9, 10, 11, 12].includes(type)) {
patch.strokeColor = color; patch.strokeColor = color;
patch.fillColor = color; patch.fillColor = color;
patch.opacity = obj?.opacity ?? 1; patch.opacity = obj?.opacity ?? 1;
} }
// For line annotations (type 4, 8), include stroke properties // For line annotations (type 4, 8), include stroke properties
if ([4, 8].includes(type)) { if (type !== undefined && [4, 8].includes(type)) {
patch.strokeColor = color; patch.strokeColor = color;
patch.strokeWidth = obj?.strokeWidth ?? obj?.lineWidth ?? 2; patch.strokeWidth = obj?.strokeWidth ?? obj?.lineWidth ?? 2;
patch.lineWidth = obj?.lineWidth ?? obj?.strokeWidth ?? 2; patch.lineWidth = obj?.lineWidth ?? obj?.strokeWidth ?? 2;
@@ -330,7 +269,7 @@ function AnnotationSelectionMenuInner({
variant="subtle" variant="subtle"
color="gray" color="gray"
size="md" size="md"
onClick={handleOpenTextEditor} onClick={handleFocusTextEdit}
styles={commonButtonStyles} styles={commonButtonStyles}
> >
<EditIcon style={{ fontSize: 18 }} /> <EditIcon style={{ fontSize: 18 }} />
@@ -493,9 +432,6 @@ function AnnotationSelectionMenuInner({
} }
const wrapperRect = wrapper.getBoundingClientRect(); const wrapperRect = wrapper.getBoundingClientRect();
// Position menu below the wrapper, centered
// Use getBoundingClientRect which gives viewport-relative coordinates
// Since we're using fixed positioning in the portal, we don't need to add scroll offsets
setMenuPosition({ setMenuPosition({
top: wrapperRect.bottom + 8, top: wrapperRect.bottom + 8,
left: wrapperRect.left + wrapperRect.width / 2, left: wrapperRect.left + wrapperRect.width / 2,
@@ -504,11 +440,15 @@ function AnnotationSelectionMenuInner({
updatePosition(); updatePosition();
// Update position on scroll/resize // MutationObserver catches EmbedPDF updating the wrapper's inline style during drag
const observer = new MutationObserver(updatePosition);
observer.observe(wrapperRef.current, { attributes: true, attributeFilter: ['style'] });
window.addEventListener('scroll', updatePosition, true); window.addEventListener('scroll', updatePosition, true);
window.addEventListener('resize', updatePosition); window.addEventListener('resize', updatePosition);
return () => { return () => {
observer.disconnect();
window.removeEventListener('scroll', updatePosition, true); window.removeEventListener('scroll', updatePosition, true);
window.removeEventListener('resize', updatePosition); window.removeEventListener('resize', updatePosition);
}; };
@@ -519,6 +459,7 @@ function AnnotationSelectionMenuInner({
const menuContent = menuPosition ? ( const menuContent = menuPosition ? (
<div <div
data-annotation-selection-menu
style={{ style={{
position: 'fixed', position: 'fixed',
top: `${menuPosition.top}px`, top: `${menuPosition.top}px`,
@@ -542,79 +483,22 @@ function AnnotationSelectionMenuInner({
</div> </div>
) : null; ) : null;
const textEditorOverlay = isTextEditorOpen && textBoxPosition ? (
<div
style={{
position: 'fixed',
top: `${textBoxPosition.top}px`,
left: `${textBoxPosition.left}px`,
width: `${textBoxPosition.width}px`,
height: `${textBoxPosition.height}px`,
zIndex: 10001,
pointerEvents: 'auto',
}}
>
<textarea
ref={textareaRef}
value={textDraft}
onChange={(e) => setTextDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
handleCloseTextEdit();
} else if (e.key === 'Enter' && e.ctrlKey) {
handleSaveText();
}
}}
onBlur={handleSaveText}
style={{
width: '100%',
height: '100%',
minHeight: '0',
minWidth: '0',
maxWidth: '100%',
maxHeight: '100%',
fontSize: `${textBoxPosition.fontSize}px`,
fontFamily: textBoxPosition.fontFamily,
lineHeight: '1.2',
color: '#000000',
backgroundColor: '#ffffff',
border: '2px solid var(--mantine-color-blue-5)',
borderRadius: '0',
padding: '0',
margin: '0',
resize: 'none',
boxSizing: 'border-box',
outline: 'none',
overflow: 'hidden',
wordWrap: 'break-word',
overflowWrap: 'break-word',
}}
/>
</div>
) : null;
const canClickToEdit = selected && (annotationType === 'text' || annotationType === 'note') && !isTextEditorOpen;
return ( return (
<> <>
{/* Invisible wrapper that provides positioning - uses EmbedPDF's menuWrapperProps */} {/* Invisible wrapper that provides positioning - uses EmbedPDF's menuWrapperProps.
Must stay pointerEvents:none so EmbedPDF's internal drag handlers receive events.
Edit Text button dispatches dblclick on this wrapper's parent to use EmbedPDF's built-in inline editing. */}
<div <div
ref={setRef} ref={setRef}
onClick={canClickToEdit ? handleOpenTextEditor : undefined}
style={{ style={{
// Use EmbedPDF's positioning styles
...menuWrapperProps?.style, ...menuWrapperProps?.style,
// Keep the wrapper invisible but still occupying space for positioning
opacity: 0, opacity: 0,
pointerEvents: canClickToEdit ? 'auto' : 'none', pointerEvents: 'none',
}} }}
/> />
{typeof document !== 'undefined' && menuContent {typeof document !== 'undefined' && menuContent
? createPortal(menuContent, document.body) ? createPortal(menuContent, document.body)
: null} : null}
{typeof document !== 'undefined' && textEditorOverlay
? createPortal(textEditorOverlay, document.body)
: null}
</> </>
); );
} }
@@ -1066,6 +1066,8 @@ const EmbedPdfViewerContent = ({
onDiscardAndContinue={async () => { onDiscardAndContinue={async () => {
// Save applied redactions (if any) while discarding pending ones // Save applied redactions (if any) while discarding pending ones
await discardAndSaveApplied(); await discardAndSaveApplied();
// Reset annotation changes ref so future show/hide doesn't re-prompt
hasAnnotationChangesRef.current = false;
}} }}
/> />
)} )}
@@ -1,5 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { createPluginRegistration } from '@embedpdf/core'; import { createPluginRegistration } from '@embedpdf/core';
import type { PluginRegistry } from '@embedpdf/core';
import { EmbedPDF } from '@embedpdf/core/react'; import { EmbedPDF } from '@embedpdf/core/react';
import { usePdfiumEngine } from '@embedpdf/engines/react'; import { usePdfiumEngine } from '@embedpdf/engines/react';
import { PrivateContent } from '@app/components/shared/PrivateContent'; import { PrivateContent } from '@app/components/shared/PrivateContent';
@@ -24,7 +25,9 @@ import { AttachmentPluginPackage } from '@embedpdf/plugin-attachment/react';
import { PrintPluginPackage } from '@embedpdf/plugin-print/react'; import { PrintPluginPackage } from '@embedpdf/plugin-print/react';
import { HistoryPluginPackage } from '@embedpdf/plugin-history/react'; import { HistoryPluginPackage } from '@embedpdf/plugin-history/react';
import { AnnotationLayer, AnnotationPluginPackage } from '@embedpdf/plugin-annotation/react'; import { AnnotationLayer, AnnotationPluginPackage } from '@embedpdf/plugin-annotation/react';
import type { AnnotationTool, AnnotationEvent } from '@embedpdf/plugin-annotation';
import { PdfAnnotationSubtype } from '@embedpdf/models'; import { PdfAnnotationSubtype } from '@embedpdf/models';
import type { PdfAnnotationObject, Rect } from '@embedpdf/models';
import { RedactionPluginPackage, RedactionLayer } from '@embedpdf/plugin-redaction/react'; import { RedactionPluginPackage, RedactionLayer } from '@embedpdf/plugin-redaction/react';
import { CustomSearchLayer } from '@app/components/viewer/CustomSearchLayer'; import { CustomSearchLayer } from '@app/components/viewer/CustomSearchLayer';
import { ZoomAPIBridge } from '@app/components/viewer/ZoomAPIBridge'; import { ZoomAPIBridge } from '@app/components/viewer/ZoomAPIBridge';
@@ -68,7 +71,7 @@ interface LocalEmbedPDFProps {
enableFormFill?: boolean; enableFormFill?: boolean;
isManualRedactionMode?: boolean; isManualRedactionMode?: boolean;
showBakedAnnotations?: boolean; showBakedAnnotations?: boolean;
onSignatureAdded?: (annotation: any) => void; onSignatureAdded?: (annotation: PdfAnnotationObject) => void;
signatureApiRef?: React.RefObject<SignatureAPI>; signatureApiRef?: React.RefObject<SignatureAPI>;
annotationApiRef?: React.RefObject<AnnotationAPI>; annotationApiRef?: React.RefObject<AnnotationAPI>;
historyApiRef?: React.RefObject<HistoryAPI>; historyApiRef?: React.RefObject<HistoryAPI>;
@@ -80,7 +83,7 @@ interface LocalEmbedPDFProps {
export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false, enableRedaction = false, enableFormFill = false, isManualRedactionMode = false, showBakedAnnotations = true, onSignatureAdded, signatureApiRef, annotationApiRef, historyApiRef, redactionTrackerRef, fileId }: LocalEmbedPDFProps) { export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false, enableRedaction = false, enableFormFill = false, isManualRedactionMode = false, showBakedAnnotations = true, onSignatureAdded, signatureApiRef, annotationApiRef, historyApiRef, redactionTrackerRef, fileId }: LocalEmbedPDFProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [pdfUrl, setPdfUrl] = useState<string | null>(null); const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [, setAnnotations] = useState<Array<{id: string, pageIndex: number, rect: any}>>([]); const [, setAnnotations] = useState<Array<{id: string, pageIndex: number, rect: Rect}>>([]);
// Convert File to URL if needed // Convert File to URL if needed
useEffect(() => { useEffect(() => {
@@ -125,7 +128,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
createPluginRegistration(ScrollPluginPackage), createPluginRegistration(ScrollPluginPackage),
createPluginRegistration(RenderPluginPackage, { createPluginRegistration(RenderPluginPackage, {
withForms: !enableFormFill, withForms: !enableFormFill,
withAnnotations: showBakedAnnotations && !enableAnnotations, // Show baked annotations only when: visibility is ON and annotation layer is OFF withAnnotations: !enableAnnotations, // Show baked annotations only when annotation layer is OFF; live layer visibility is controlled via CSS
}), }),
// Register interaction manager (required for zoom and selection features) // Register interaction manager (required for zoom and selection features)
@@ -203,7 +206,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
// Register print plugin for printing PDFs // Register print plugin for printing PDFs
createPluginRegistration(PrintPluginPackage), createPluginRegistration(PrintPluginPackage),
]; ];
}, [pdfUrl, enableAnnotations, showBakedAnnotations, fileName, file, url]); }, [pdfUrl, enableAnnotations, fileName, file, url]);
// Initialize the engine with the React hook - use local WASM for offline support // Initialize the engine with the React hook - use local WASM for offline support
const { engine, isLoading, error } = usePdfiumEngine({ const { engine, isLoading, error } = usePdfiumEngine({
@@ -280,7 +283,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
<EmbedPDF <EmbedPDF
engine={engine} engine={engine}
plugins={plugins} plugins={plugins}
onInitialized={async (registry: any) => { onInitialized={async (registry: PluginRegistry) => {
// v2.0: Use registry.getPlugin() to access plugin APIs // v2.0: Use registry.getPlugin() to access plugin APIs
const annotationPlugin = registry.getPlugin('annotation'); const annotationPlugin = registry.getPlugin('annotation');
if (!annotationPlugin || !annotationPlugin.provides) return; if (!annotationPlugin || !annotationPlugin.provides) return;
@@ -289,10 +292,22 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
if (!annotationApi) return; if (!annotationApi) return;
if (enableAnnotations) { if (enableAnnotations) {
const ensureTool = (tool: any) => { // LooseAnnotationTool bypasses strict Partial<T> defaults typing from the library —
// EmbedPDF accepts extra runtime properties (borderWidth, textColor, finishOnDoubleClick,
// etc.) that aren't reflected in the TypeScript model types.
type LooseAnnotationTool = {
id: string;
name: string;
interaction?: { exclusive: boolean; cursor: string; textSelection?: boolean; isRotatable?: boolean };
matchScore?: (annotation: PdfAnnotationObject) => number;
defaults?: Record<string, unknown>;
clickBehavior?: Record<string, unknown>;
behavior?: { deactivateToolAfterCreate?: boolean; selectAfterCreate?: boolean };
};
const ensureTool = (tool: LooseAnnotationTool) => {
const existing = annotationApi.getTool?.(tool.id); const existing = annotationApi.getTool?.(tool.id);
if (!existing) { if (!existing) {
annotationApi.addTool(tool); annotationApi.addTool(tool as unknown as AnnotationTool);
} }
}; };
@@ -300,7 +315,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
id: 'highlight', id: 'highlight',
name: 'Highlight', name: 'Highlight',
interaction: { exclusive: true, cursor: 'text', textSelection: true }, interaction: { exclusive: true, cursor: 'text', textSelection: true },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.HIGHLIGHT ? 10 : 0), matchScore: (annotation: PdfAnnotationObject) => (annotation.type === PdfAnnotationSubtype.HIGHLIGHT ? 10 : 0),
defaults: { defaults: {
type: PdfAnnotationSubtype.HIGHLIGHT, type: PdfAnnotationSubtype.HIGHLIGHT,
strokeColor: '#ffd54f', strokeColor: '#ffd54f',
@@ -317,7 +332,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
id: 'underline', id: 'underline',
name: 'Underline', name: 'Underline',
interaction: { exclusive: true, cursor: 'text', textSelection: true }, interaction: { exclusive: true, cursor: 'text', textSelection: true },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.UNDERLINE ? 10 : 0), matchScore: (annotation: PdfAnnotationObject) => (annotation.type === PdfAnnotationSubtype.UNDERLINE ? 10 : 0),
defaults: { defaults: {
type: PdfAnnotationSubtype.UNDERLINE, type: PdfAnnotationSubtype.UNDERLINE,
strokeColor: '#ffb300', strokeColor: '#ffb300',
@@ -334,7 +349,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
id: 'strikeout', id: 'strikeout',
name: 'Strikeout', name: 'Strikeout',
interaction: { exclusive: true, cursor: 'text', textSelection: true }, interaction: { exclusive: true, cursor: 'text', textSelection: true },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.STRIKEOUT ? 10 : 0), matchScore: (annotation: PdfAnnotationObject) => (annotation.type === PdfAnnotationSubtype.STRIKEOUT ? 10 : 0),
defaults: { defaults: {
type: PdfAnnotationSubtype.STRIKEOUT, type: PdfAnnotationSubtype.STRIKEOUT,
strokeColor: '#e53935', strokeColor: '#e53935',
@@ -351,7 +366,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
id: 'squiggly', id: 'squiggly',
name: 'Squiggly', name: 'Squiggly',
interaction: { exclusive: true, cursor: 'text', textSelection: true }, interaction: { exclusive: true, cursor: 'text', textSelection: true },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.SQUIGGLY ? 10 : 0), matchScore: (annotation: PdfAnnotationObject) => (annotation.type === PdfAnnotationSubtype.SQUIGGLY ? 10 : 0),
defaults: { defaults: {
type: PdfAnnotationSubtype.SQUIGGLY, type: PdfAnnotationSubtype.SQUIGGLY,
strokeColor: '#00acc1', strokeColor: '#00acc1',
@@ -368,7 +383,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
id: 'ink', id: 'ink',
name: 'Pen', name: 'Pen',
interaction: { exclusive: true, cursor: 'crosshair' }, interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.INK ? 10 : 0), matchScore: (annotation: PdfAnnotationObject) => (annotation.type === PdfAnnotationSubtype.INK ? 10 : 0),
defaults: { defaults: {
type: PdfAnnotationSubtype.INK, type: PdfAnnotationSubtype.INK,
strokeColor: '#1f2933', strokeColor: '#1f2933',
@@ -388,7 +403,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
id: 'inkHighlighter', id: 'inkHighlighter',
name: 'Ink Highlighter', name: 'Ink Highlighter',
interaction: { exclusive: true, cursor: 'crosshair' }, interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.INK && (annotation.strokeColor === '#ffd54f' || annotation.color === '#ffd54f') ? 8 : 0), matchScore: (annotation: PdfAnnotationObject) => (annotation.type === PdfAnnotationSubtype.INK && (annotation.strokeColor === '#ffd54f' || annotation.color === '#ffd54f') ? 8 : 0),
defaults: { defaults: {
type: PdfAnnotationSubtype.INK, type: PdfAnnotationSubtype.INK,
strokeColor: '#ffd54f', strokeColor: '#ffd54f',
@@ -408,7 +423,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
id: 'square', id: 'square',
name: 'Square', name: 'Square',
interaction: { exclusive: true, cursor: 'crosshair' }, interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.SQUARE ? 10 : 0), matchScore: (annotation: PdfAnnotationObject) => (annotation.type === PdfAnnotationSubtype.SQUARE ? 10 : 0),
defaults: { defaults: {
type: PdfAnnotationSubtype.SQUARE, type: PdfAnnotationSubtype.SQUARE,
color: '#0000ff', // fill color (blue) color: '#0000ff', // fill color (blue)
@@ -432,7 +447,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
id: 'circle', id: 'circle',
name: 'Circle', name: 'Circle',
interaction: { exclusive: true, cursor: 'crosshair' }, interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.CIRCLE ? 10 : 0), matchScore: (annotation: PdfAnnotationObject) => (annotation.type === PdfAnnotationSubtype.CIRCLE ? 10 : 0),
defaults: { defaults: {
type: PdfAnnotationSubtype.CIRCLE, type: PdfAnnotationSubtype.CIRCLE,
color: '#0000ff', // fill color (blue) color: '#0000ff', // fill color (blue)
@@ -456,7 +471,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
id: 'line', id: 'line',
name: 'Line', name: 'Line',
interaction: { exclusive: true, cursor: 'crosshair' }, interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.LINE ? 10 : 0), matchScore: (annotation: PdfAnnotationObject) => (annotation.type === PdfAnnotationSubtype.LINE ? 10 : 0),
defaults: { defaults: {
type: PdfAnnotationSubtype.LINE, type: PdfAnnotationSubtype.LINE,
color: '#1565c0', color: '#1565c0',
@@ -480,7 +495,12 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
id: 'lineArrow', id: 'lineArrow',
name: 'Arrow', name: 'Arrow',
interaction: { exclusive: true, cursor: 'crosshair' }, interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.LINE && (annotation.endStyle === 'ClosedArrow' || annotation.lineEndingStyles?.end === 'ClosedArrow') ? 9 : 0), matchScore: (annotation: PdfAnnotationObject) => {
if (annotation.type !== PdfAnnotationSubtype.LINE) return 0;
// EmbedPDF stores endStyle/lineEndingStyles at runtime; library types use lineEndings
const ann = annotation as PdfAnnotationObject & { endStyle?: string; lineEndingStyles?: { end?: string } };
return (ann.endStyle === 'ClosedArrow' || ann.lineEndingStyles?.end === 'ClosedArrow') ? 9 : 0;
},
defaults: { defaults: {
type: PdfAnnotationSubtype.LINE, type: PdfAnnotationSubtype.LINE,
color: '#1565c0', color: '#1565c0',
@@ -505,7 +525,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
id: 'polyline', id: 'polyline',
name: 'Polyline', name: 'Polyline',
interaction: { exclusive: true, cursor: 'crosshair' }, interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.POLYLINE ? 10 : 0), matchScore: (annotation: PdfAnnotationObject) => (annotation.type === PdfAnnotationSubtype.POLYLINE ? 10 : 0),
defaults: { defaults: {
type: PdfAnnotationSubtype.POLYLINE, type: PdfAnnotationSubtype.POLYLINE,
color: '#1565c0', color: '#1565c0',
@@ -526,7 +546,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
id: 'polygon', id: 'polygon',
name: 'Polygon', name: 'Polygon',
interaction: { exclusive: true, cursor: 'crosshair' }, interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.POLYGON ? 10 : 0), matchScore: (annotation: PdfAnnotationObject) => (annotation.type === PdfAnnotationSubtype.POLYGON ? 10 : 0),
defaults: { defaults: {
type: PdfAnnotationSubtype.POLYGON, type: PdfAnnotationSubtype.POLYGON,
color: '#0000ff', // fill color (blue) color: '#0000ff', // fill color (blue)
@@ -548,8 +568,8 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
ensureTool({ ensureTool({
id: 'text', id: 'text',
name: 'Text', name: 'Text',
interaction: { exclusive: true, cursor: 'text' }, interaction: { exclusive: true, cursor: 'text', isRotatable: false },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.FREETEXT ? 10 : 0), matchScore: (annotation: PdfAnnotationObject) => (annotation.type === PdfAnnotationSubtype.FREETEXT ? 10 : 0),
defaults: { defaults: {
type: PdfAnnotationSubtype.FREETEXT, type: PdfAnnotationSubtype.FREETEXT,
textColor: '#111111', textColor: '#111111',
@@ -560,7 +580,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
contents: 'Text', contents: 'Text',
}, },
behavior: { behavior: {
deactivateToolAfterCreate: false, deactivateToolAfterCreate: true,
selectAfterCreate: true, selectAfterCreate: true,
}, },
}); });
@@ -568,8 +588,8 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
ensureTool({ ensureTool({
id: 'note', id: 'note',
name: 'Note', name: 'Note',
interaction: { exclusive: true, cursor: 'pointer' }, interaction: { exclusive: true, cursor: 'pointer', isRotatable: false },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.FREETEXT ? 8 : 0), matchScore: (annotation: PdfAnnotationObject) => (annotation.type === PdfAnnotationSubtype.FREETEXT ? 8 : 0),
defaults: { defaults: {
type: PdfAnnotationSubtype.FREETEXT, type: PdfAnnotationSubtype.FREETEXT,
textColor: '#1b1b1b', textColor: '#1b1b1b',
@@ -584,7 +604,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
defaultSize: { width: 160, height: 100 }, defaultSize: { width: 160, height: 100 },
}, },
behavior: { behavior: {
deactivateToolAfterCreate: false, deactivateToolAfterCreate: true,
selectAfterCreate: true, selectAfterCreate: true,
}, },
}); });
@@ -593,7 +613,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
id: 'stamp', id: 'stamp',
name: 'Image Stamp', name: 'Image Stamp',
interaction: { exclusive: false, cursor: 'copy' }, interaction: { exclusive: false, cursor: 'copy' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.STAMP ? 5 : 0), matchScore: (annotation: PdfAnnotationObject) => (annotation.type === PdfAnnotationSubtype.STAMP ? 5 : 0),
defaults: { defaults: {
type: PdfAnnotationSubtype.STAMP, type: PdfAnnotationSubtype.STAMP,
}, },
@@ -627,7 +647,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
}, },
}); });
annotationApi.onAnnotationEvent((event: any) => { annotationApi.onAnnotationEvent((event: AnnotationEvent) => {
if (event.type === 'create' && event.committed) { if (event.type === 'create' && event.committed) {
setAnnotations(prev => [...prev, { setAnnotations(prev => [...prev, {
id: event.annotation.id, id: event.annotation.id,
@@ -635,19 +655,11 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
rect: event.annotation.rect rect: event.annotation.rect
}]); }]);
if (onSignatureAdded) { if (onSignatureAdded) {
onSignatureAdded(event.annotation); onSignatureAdded(event.annotation);
} }
} else if (event.type === 'delete' && event.committed) { } else if (event.type === 'delete' && event.committed) {
setAnnotations(prev => prev.filter(ann => ann.id !== event.annotation.id)); setAnnotations(prev => prev.filter(ann => ann.id !== event.annotation.id));
} else if (event.type === 'loaded') {
const loadedAnnotations = event.annotations || [];
setAnnotations(loadedAnnotations.map((ann: any) => ({
id: ann.id,
pageIndex: ann.pageIndex || 0,
rect: ann.rect
})));
} }
}); });
} }
@@ -750,8 +762,9 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
<AnnotationLayer <AnnotationLayer
documentId={documentId} documentId={documentId}
pageIndex={pageIndex} pageIndex={pageIndex}
selectionOutlineColor="#007ACC" selectionOutline={{ color: "#007ACC" }}
selectionMenu={(props) => <AnnotationSelectionMenu {...props} />} selectionMenu={(props) => <AnnotationSelectionMenu {...props} />}
style={!showBakedAnnotations ? { opacity: 0, pointerEvents: 'none' } : undefined}
/> />
)} )}
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { createPluginRegistration } from '@embedpdf/core'; import { createPluginRegistration } from '@embedpdf/core';
import type { PluginRegistry } from '@embedpdf/core';
import { EmbedPDF } from '@embedpdf/core/react'; import { EmbedPDF } from '@embedpdf/core/react';
import { usePdfiumEngine } from '@embedpdf/engines/react'; import { usePdfiumEngine } from '@embedpdf/engines/react';
@@ -18,6 +19,8 @@ import { SearchPluginPackage } from '@embedpdf/plugin-search/react';
import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react'; import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react';
import { RotatePluginPackage, Rotate } from '@embedpdf/plugin-rotate/react'; import { RotatePluginPackage, Rotate } from '@embedpdf/plugin-rotate/react';
import { Rotation, PdfAnnotationSubtype } from '@embedpdf/models'; import { Rotation, PdfAnnotationSubtype } from '@embedpdf/models';
import type { PdfAnnotationObject } from '@embedpdf/models';
import type { AnnotationEvent } from '@embedpdf/plugin-annotation';
// Import annotation plugins // Import annotation plugins
import { HistoryPluginPackage } from '@embedpdf/plugin-history/react'; import { HistoryPluginPackage } from '@embedpdf/plugin-history/react';
@@ -41,7 +44,7 @@ const DOCUMENT_NAME = 'stirling-pdf-signing-viewer';
interface LocalEmbedPDFWithAnnotationsProps { interface LocalEmbedPDFWithAnnotationsProps {
file?: File | Blob; file?: File | Blob;
url?: string | null; url?: string | null;
onAnnotationChange?: (annotations: any[]) => void; onAnnotationChange?: (annotations: PdfAnnotationObject[]) => void;
} }
export function LocalEmbedPDFWithAnnotations({ export function LocalEmbedPDFWithAnnotations({
@@ -187,7 +190,7 @@ export function LocalEmbedPDFWithAnnotations({
<EmbedPDF <EmbedPDF
engine={engine} engine={engine}
plugins={plugins} plugins={plugins}
onInitialized={async (registry: any) => { onInitialized={async (registry: PluginRegistry) => {
// v2.0: Use registry.getPlugin() to access plugin APIs // v2.0: Use registry.getPlugin() to access plugin APIs
const annotationPlugin = registry.getPlugin('annotation'); const annotationPlugin = registry.getPlugin('annotation');
if (!annotationPlugin || !annotationPlugin.provides) return; if (!annotationPlugin || !annotationPlugin.provides) return;
@@ -223,10 +226,8 @@ export function LocalEmbedPDFWithAnnotations({
// Listen for annotation events to notify parent // Listen for annotation events to notify parent
if (onAnnotationChange) { if (onAnnotationChange) {
annotationApi.onAnnotationEvent((event: any) => { annotationApi.onAnnotationEvent((event: AnnotationEvent) => {
if (event.committed) { if (event.type !== 'loaded' && event.committed) {
// Get all annotations and notify parent
// This is a simplified approach - in reality you'd need to get all annotations
onAnnotationChange([event.annotation]); onAnnotationChange([event.annotation]);
} }
}); });
@@ -295,7 +296,7 @@ export function LocalEmbedPDFWithAnnotations({
<AnnotationLayer <AnnotationLayer
documentId={documentId} documentId={documentId}
pageIndex={pageIndex} pageIndex={pageIndex}
selectionOutlineColor="#007ACC" selectionOutline={{ color: "#007ACC" }}
/> />
</div> </div>
</PagePointerProvider> </PagePointerProvider>
@@ -58,9 +58,11 @@ export function useViewerRightRailButtons(
useEffect(() => { useEffect(() => {
if (selectedTool === 'annotate') { if (selectedTool === 'annotate') {
setIsAnnotationsActive(true); setIsAnnotationsActive(true);
} else if (selectedTool === 'redact') { } else if (selectedTool) {
// Any other tool is active — annotate button should not be highlighted
setIsAnnotationsActive(false); setIsAnnotationsActive(false);
} else { } else {
// No tool selected — fall back to URL path check
setIsAnnotationsActive(isAnnotationsPath()); setIsAnnotationsActive(isAnnotationsPath());
} }
}, [selectedTool, isAnnotationsPath]); }, [selectedTool, isAnnotationsPath]);
@@ -28,6 +28,10 @@ export interface AnnotationAPI {
getSelectedAnnotation: () => AnnotationSelection | null; getSelectedAnnotation: () => AnnotationSelection | null;
deselectAnnotation: () => void; deselectAnnotation: () => void;
updateAnnotation: (pageIndex: number, annotationId: string, patch: AnnotationPatch) => void; updateAnnotation: (pageIndex: number, annotationId: string, patch: AnnotationPatch) => void;
deleteAnnotation?: (pageIndex: number, annotationId: string) => void;
deleteAnnotations?: (annotations: Array<{ pageIndex: number; id: string }>) => void;
createAnnotation?: (pageIndex: number, annotation: Record<string, unknown>) => void;
getSelectedAnnotations?: () => AnnotationSelection[];
deactivateTools: () => void; deactivateTools: () => void;
onAnnotationEvent?: (listener: (event: AnnotationEvent) => void) => void | (() => void); onAnnotationEvent?: (listener: (event: AnnotationEvent) => void) => void | (() => void);
getActiveTool?: () => { id: AnnotationToolId } | null; getActiveTool?: () => { id: AnnotationToolId } | null;
@@ -77,13 +81,49 @@ export type AnnotationToolId =
| 'signatureStamp' | 'signatureStamp'
| 'signatureInk'; | 'signatureInk';
export interface AnnotationEvent { // Import for internal use within this file, and re-export for external consumers
type: string; import type { AnnotationEvent } from '@embedpdf/plugin-annotation';
[key: string]: unknown; export type { AnnotationEvent } from '@embedpdf/plugin-annotation';
} export type { PdfAnnotationObject } from '@embedpdf/models';
export type AnnotationPatch = Record<string, unknown>; export type AnnotationPatch = Record<string, unknown>;
export type AnnotationSelection = unknown;
/** Annotation object as returned by the EmbedPDF annotation API */
export interface AnnotationObject {
id?: string;
uid?: string;
pageIndex?: number;
type?: number;
subtype?: number;
inkList?: unknown;
color?: string;
strokeColor?: string;
fillColor?: string;
backgroundColor?: string;
textColor?: string;
opacity?: number;
strokeWidth?: number;
borderWidth?: number;
lineWidth?: number;
thickness?: number;
fontSize?: number;
fontFamily?: string;
fontColor?: string;
interiorColor?: string;
textAlign?: number;
endStyle?: string;
startStyle?: string;
lineEndingStyles?: { start?: string; end?: string };
customData?: { toolId?: string; annotationToolId?: string };
rect?: AnnotationRect;
contents?: string;
}
/**
* Selection returned by getSelectedAnnotation — EmbedPDF may wrap the annotation
* in an `.object` property or surface fields directly on the selection.
*/
export type AnnotationSelection = AnnotationObject & { object?: AnnotationObject };
export interface AnnotationToolOptions { export interface AnnotationToolOptions {
color?: string; color?: string;
@@ -269,6 +269,15 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
if (toolId !== 'read' && toolId !== 'multiTool' && isExplicitlyDisabled) { if (toolId !== 'read' && toolId !== 'multiTool' && isExplicitlyDisabled) {
return; return;
} }
// Guard: if there are unsaved changes and we're switching away from the current tool,
// show the save modal before proceeding.
const hasUnsavedChanges = navigationState.hasUnsavedChanges;
if (hasUnsavedChanges && navigationState.selectedTool && navigationState.selectedTool !== toolId) {
actions.requestNavigation(() => handleToolSelect(toolId));
return;
}
// If we're currently on a custom workbench (e.g., Validate Signature report), // If we're currently on a custom workbench (e.g., Validate Signature report),
// selecting any tool should take the user back to the default file manager view. // selecting any tool should take the user back to the default file manager view.
const wasInCustomWorkbench = !isBaseWorkbench(navigationState.workbench); const wasInCustomWorkbench = !isBaseWorkbench(navigationState.workbench);
@@ -310,7 +319,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
setSearchQuery(''); setSearchQuery('');
setLeftPanelView('toolContent'); setLeftPanelView('toolContent');
setReaderMode(false); // Disable read mode when selecting tools setReaderMode(false); // Disable read mode when selecting tools
}, [actions, getSelectedTool, navigationState.workbench, setLeftPanelView, setReaderMode, setSearchQuery, toolAvailability]); }, [actions, getSelectedTool, navigationState.workbench, navigationState.hasUnsavedChanges, navigationState.selectedTool, setLeftPanelView, setReaderMode, setSearchQuery, toolAvailability]);
const handleBackToTools = useCallback(() => { const handleBackToTools = useCallback(() => {
setLeftPanelView('toolPicker'); setLeftPanelView('toolPicker');
+1 -1
View File
@@ -77,7 +77,7 @@ function useImmediateNotifier<Args extends unknown[]>() {
* - Actions call EmbedPDF APIs directly through bridge references * - Actions call EmbedPDF APIs directly through bridge references
* - No circular dependencies - bridges don't call back into this context * - No circular dependencies - bridges don't call back into this context
*/ */
interface ViewerContextType { export interface ViewerContextType {
// UI state managed by this context // UI state managed by this context
isThumbnailSidebarVisible: boolean; isThumbnailSidebarVisible: boolean;
toggleThumbnailSidebar: () => void; toggleThumbnailSidebar: () => void;
+156 -12
View File
@@ -7,7 +7,7 @@ import { useFileSelection } from '@app/contexts/FileContext';
import { BaseToolProps } from '@app/types/tool'; import { BaseToolProps } from '@app/types/tool';
import { useSignature } from '@app/contexts/SignatureContext'; import { useSignature } from '@app/contexts/SignatureContext';
import { ViewerContext, useViewer } from '@app/contexts/ViewerContext'; import { ViewerContext, useViewer } from '@app/contexts/ViewerContext';
import type { AnnotationToolId } from '@app/components/viewer/viewerTypes'; import type { AnnotationToolId, AnnotationEvent, AnnotationSelection, AnnotationRect } from '@app/components/viewer/viewerTypes';
import { useAnnotationStyleState } from '@app/tools/annotate/useAnnotationStyleState'; import { useAnnotationStyleState } from '@app/tools/annotate/useAnnotationStyleState';
import { useAnnotationSelection } from '@app/tools/annotate/useAnnotationSelection'; import { useAnnotationSelection } from '@app/tools/annotate/useAnnotationSelection';
import { AnnotationPanel } from '@app/tools/annotate/AnnotationPanel'; import { AnnotationPanel } from '@app/tools/annotate/AnnotationPanel';
@@ -73,6 +73,8 @@ const Annotate = (_props: BaseToolProps) => {
setPlacementPreviewSize, setPlacementPreviewSize,
} = useSignature(); } = useSignature();
const viewerContext = useContext(ViewerContext); const viewerContext = useContext(ViewerContext);
const viewerContextRef = useRef(viewerContext);
useEffect(() => { viewerContextRef.current = viewerContext; }, [viewerContext]);
const { getZoomState, registerImmediateZoomUpdate, applyChanges, activeFileIndex, panActions } = useViewer(); const { getZoomState, registerImmediateZoomUpdate, applyChanges, activeFileIndex, panActions } = useViewer();
const [activeTool, setActiveTool] = useState<AnnotationToolId>('select'); const [activeTool, setActiveTool] = useState<AnnotationToolId>('select');
@@ -173,19 +175,34 @@ const Annotate = (_props: BaseToolProps) => {
} }
}, [applyChanges]); }, [applyChanges]);
// Deactivate all annotation tools when the Annotate component unmounts (e.g. switching to Sign tool).
// The dep-change effect below only fires while mounted, so unmount needs its own cleanup.
useEffect(() => {
return () => {
annotationApiRef?.current?.deactivateTools?.();
signatureApiRef?.current?.deactivateTools?.();
setPlacementMode(false);
viewerContextRef.current?.setAnnotationMode(false);
};
}, []);
useEffect(() => { useEffect(() => {
const isAnnotateActive = workbench === 'viewer' && selectedTool === 'annotate'; const isAnnotateActive = workbench === 'viewer' && selectedTool === 'annotate';
if (wasAnnotateActiveRef.current && !isAnnotateActive) { if (wasAnnotateActiveRef.current && !isAnnotateActive) {
annotationApiRef?.current?.deactivateTools?.(); annotationApiRef?.current?.deactivateTools?.();
signatureApiRef?.current?.deactivateTools?.(); signatureApiRef?.current?.deactivateTools?.();
setPlacementMode(false); setPlacementMode(false);
viewerContext?.setAnnotationMode(false);
} else if (!wasAnnotateActiveRef.current && isAnnotateActive) { } else if (!wasAnnotateActiveRef.current && isAnnotateActive) {
// When entering annotate mode, activate the select tool by default // When entering annotate mode, activate the select tool by default
// Also reset React state to match — EmbedPDF always starts at 'select' here
setActiveTool('select');
activeToolRef.current = 'select';
const toolOptions = buildToolOptions('select'); const toolOptions = buildToolOptions('select');
annotationApiRef?.current?.activateAnnotationTool?.('select', toolOptions); annotationApiRef?.current?.activateAnnotationTool?.('select', toolOptions);
} }
wasAnnotateActiveRef.current = isAnnotateActive; wasAnnotateActiveRef.current = isAnnotateActive;
}, [workbench, selectedTool, annotationApiRef, signatureApiRef, setPlacementMode, buildToolOptions]); }, [workbench, selectedTool, annotationApiRef, signatureApiRef, setPlacementMode, buildToolOptions, viewerContext]);
// Monitor history state for undo/redo availability // Monitor history state for undo/redo availability
useEffect(() => { useEffect(() => {
@@ -323,23 +340,150 @@ const Annotate = (_props: BaseToolProps) => {
} }
}, [placementPreviewSize, activeTool, stampImageData, signatureApiRef, stampImageSize, cssToPdfSize, buildToolOptions]); }, [placementPreviewSize, activeTool, stampImageData, signatureApiRef, stampImageSize, cssToPdfSize, buildToolOptions]);
// Allow exiting multi-point tools with Escape (e.g., polyline) // Auto-switch to 'select' after placing a note or text annotation
// EmbedPDF fires 'create' + committed:true when placement is finalised
useEffect(() => { useEffect(() => {
const unsubscribe = annotationApiRef?.current?.onAnnotationEvent?.((event: AnnotationEvent) => {
if (event.type === 'create' && event.committed) {
const toolId = activeToolRef.current;
if (toolId === 'text' || toolId === 'note') {
setActiveTool('select');
activeToolRef.current = 'select';
annotationApiRef?.current?.activateAnnotationTool?.('select');
}
}
});
return () => {
if (typeof unsubscribe === 'function') unsubscribe();
};
}, [annotationApiRef?.current]);
// Clipboard ref for copy/paste — no re-render needed
const clipboardRef = useRef<{ pageIndex: number; annotation: Record<string, unknown> } | null>(null);
// Click-outside to blur FreeText/note inline editing so user can then drag the annotation.
// When clicking the selection menu (e.g. Properties), only blur — do not deselect so the menu/popover can respond.
useEffect(() => {
const handleCapture = (e: MouseEvent) => {
const active = document.activeElement as HTMLElement | null;
if (!active?.isContentEditable) return;
const pageEl = active.closest('[data-page-index]') as HTMLElement | null;
if (!pageEl) return;
const editingWrapper = active.parentElement;
const target = e.target as Node;
if (editingWrapper?.contains(target)) return;
active.blur();
const onSelectionMenu = (target as HTMLElement).closest?.('[data-annotation-selection-menu]');
if (onSelectionMenu) return;
const pageParent = pageEl.parentElement;
if (!pageParent) return;
const synthetic = new PointerEvent('pointerdown', {
bubbles: true,
cancelable: true,
clientX: e.clientX,
clientY: e.clientY,
pointerId: 1,
pointerType: 'mouse',
});
pageParent.dispatchEvent(synthetic);
};
document.addEventListener('mousedown', handleCapture, true);
return () => document.removeEventListener('mousedown', handleCapture, true);
}, []);
// Keyboard shortcuts: Escape (cancel drawing), Backspace/Delete (delete selected), Ctrl+C/V (copy/paste)
useEffect(() => {
const isInputFocused = () => {
const el = document.activeElement;
if (!el) return false;
const tag = (el as HTMLElement).tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || (el as HTMLElement).isContentEditable;
};
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return; if (isInputFocused()) return;
if (['polyline', 'polygon'].includes(activeTool)) {
annotationApiRef?.current?.setAnnotationStyle?.(activeTool, buildToolOptions(activeTool)); // Backspace / Delete: delete selected annotation(s)
annotationApiRef?.current?.activateAnnotationTool?.(null as any); if (e.key === 'Backspace' || e.key === 'Delete') {
setTimeout(() => { const multiSelected = annotationApiRef?.current?.getSelectedAnnotations?.();
annotationApiRef?.current?.activateAnnotationTool?.(activeTool, buildToolOptions(activeTool)); if (multiSelected && multiSelected.length > 0) {
}, 50); e.preventDefault();
const toDelete = multiSelected.map((s: AnnotationSelection) => {
const ann = s.object ?? s;
return {
pageIndex: ann.pageIndex ?? 0,
id: ann.id ?? ann.uid ?? '',
};
}).filter((a: { id: string }) => a.id);
if (toDelete.length > 0) {
annotationApiRef?.current?.deleteAnnotations?.(toDelete);
}
return;
}
const selected: AnnotationSelection | null = annotationApiRef?.current?.getSelectedAnnotation?.() ?? null;
if (!selected) return;
e.preventDefault();
const pageIndex: number = selected.pageIndex ?? selected.object?.pageIndex ?? 0;
const annotationId: string = selected.id ?? selected.object?.id ?? selected.uid ?? selected.object?.uid ?? '';
if (annotationId != null) {
annotationApiRef?.current?.deleteAnnotation?.(pageIndex, annotationId);
}
return;
}
// Ctrl+C: copy selected annotation
if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
const selected: AnnotationSelection | null = annotationApiRef?.current?.getSelectedAnnotation?.() ?? null;
if (!selected) return;
e.preventDefault();
const ann = selected.object ?? selected;
const pageIndex: number = ann.pageIndex ?? 0;
clipboardRef.current = { pageIndex, annotation: { ...(ann as Record<string, unknown>) } };
return;
}
// Ctrl+V: paste copied annotation (offset by ~20 PDF pts)
if ((e.ctrlKey || e.metaKey) && e.key === 'v') {
const clip = clipboardRef.current;
if (!clip) return;
e.preventDefault();
const { pageIndex, annotation } = clip;
const OFFSET = 20;
const pasted: Record<string, unknown> = { ...annotation };
// Assign a new id so EmbedPDF tracks the copy and delete works on it
pasted.id = typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
: `paste-${Date.now()}-${Math.random().toString(36).slice(2)}`;
delete pasted.uid;
// Remove appearance stream reference — the copy needs its own rendering
delete pasted.appearanceModes;
// Shift the EmbedPDF Rect: { origin: { x, y }, size: { width, height } }
// PDF coords have y=0 at bottom, so down = smaller y; right = larger x
if (pasted.rect && typeof pasted.rect === 'object') {
const r = pasted.rect as AnnotationRect;
if (r.origin && typeof r.origin === 'object') {
pasted.rect = {
...r,
origin: { x: r.origin.x + OFFSET, y: r.origin.y - OFFSET },
};
}
}
annotationApiRef?.current?.createAnnotation?.(pageIndex, pasted);
return;
} }
}; };
window.addEventListener('keydown', handler); window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler);
}, [activeTool, buildToolOptions, signatureApiRef]); }, [buildToolOptions, annotationApiRef]);
const deriveToolFromAnnotation = useCallback((annotation: any): AnnotationToolId | undefined => { const deriveToolFromAnnotation = useCallback((annotation: AnnotationSelection | null | undefined): AnnotationToolId | undefined => {
if (!annotation) return undefined; if (!annotation) return undefined;
const customToolId = annotation.customData?.toolId || annotation.customData?.annotationToolId; const customToolId = annotation.customData?.toolId || annotation.customData?.annotationToolId;
if (isKnownAnnotationTool(customToolId)) { if (isKnownAnnotationTool(customToolId)) {
@@ -7,7 +7,10 @@ import { ColorPicker, ColorSwatchButton } from '@app/components/annotation/share
import { ImageUploader } from '@app/components/annotation/shared/ImageUploader'; import { ImageUploader } from '@app/components/annotation/shared/ImageUploader';
import { SuggestedToolsSection } from '@app/components/tools/shared/SuggestedToolsSection'; import { SuggestedToolsSection } from '@app/components/tools/shared/SuggestedToolsSection';
import { DrawingControls } from '@app/components/annotation/shared/DrawingControls'; import { DrawingControls } from '@app/components/annotation/shared/DrawingControls';
import type { AnnotationToolId, AnnotationAPI } from '@app/components/viewer/viewerTypes'; import type { AnnotationToolId, AnnotationAPI, SignatureAPI, AnnotationObject } from '@app/components/viewer/viewerTypes';
import type { ViewerContextType } from '@app/contexts/ViewerContext';
import type { SignParameters } from '@app/hooks/tools/sign/useSignParameters';
import type { BuildToolOptionsExtras } from '@app/tools/annotate/useAnnotationStyleState';
interface StyleState { interface StyleState {
inkColor: string; inkColor: string;
@@ -59,7 +62,7 @@ interface StyleActions {
setShapeThickness: (value: number) => void; setShapeThickness: (value: number) => void;
} }
type BuildToolOptionsFn = (toolId: AnnotationToolId, extras?: any) => Record<string, unknown>; type BuildToolOptionsFn = (toolId: AnnotationToolId, extras?: BuildToolOptionsExtras) => Record<string, unknown>;
type ColorTarget = type ColorTarget =
| 'ink' | 'ink'
@@ -82,17 +85,17 @@ interface AnnotationPanelProps {
styleActions: StyleActions; styleActions: StyleActions;
getActiveColor: (tool: AnnotationToolId) => string; getActiveColor: (tool: AnnotationToolId) => string;
buildToolOptions: BuildToolOptionsFn; buildToolOptions: BuildToolOptionsFn;
deriveToolFromAnnotation: (annotation: any) => AnnotationToolId | undefined; deriveToolFromAnnotation: (annotation: AnnotationObject | null | undefined) => AnnotationToolId | undefined;
selectedAnn: any | null; selectedAnn: { object: AnnotationObject } | null;
selectedTextDraft: string; selectedTextDraft: string;
setSelectedTextDraft: (text: string) => void; setSelectedTextDraft: (text: string) => void;
selectedFontSize: number; selectedFontSize: number;
setSelectedFontSize: (size: number) => void; setSelectedFontSize: (size: number) => void;
annotationApiRef: React.RefObject<AnnotationAPI | null>; annotationApiRef: React.RefObject<AnnotationAPI | null>;
signatureApiRef: React.RefObject<any>; signatureApiRef: React.RefObject<SignatureAPI | null>;
viewerContext: any; viewerContext: ViewerContextType | null;
setPlacementMode: (value: boolean) => void; setPlacementMode: (value: boolean) => void;
setSignatureConfig: (config: any) => void; setSignatureConfig: (config: SignParameters | null) => void;
computeStampDisplaySize: (natural: { width: number; height: number } | null) => { width: number; height: number }; computeStampDisplaySize: (natural: { width: number; height: number } | null) => { width: number; height: number };
stampImageData?: string; stampImageData?: string;
setStampImageData: (value: string | undefined) => void; setStampImageData: (value: string | undefined) => void;
@@ -214,15 +217,18 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
const activeColor = useMemo(() => colorPickerTarget ? getActiveColor(colorPickerTarget as AnnotationToolId) : '#000000', [colorPickerTarget, getActiveColor]); const activeColor = useMemo(() => colorPickerTarget ? getActiveColor(colorPickerTarget as AnnotationToolId) : '#000000', [colorPickerTarget, getActiveColor]);
const annotationsVisible = viewerContext?.isAnnotationsVisible ?? true;
const renderToolButtons = (tools: { id: AnnotationToolId; label: string; icon: string }[]) => ( const renderToolButtons = (tools: { id: AnnotationToolId; label: string; icon: string }[]) => (
<Group gap="xs"> <Group gap="xs">
{tools.map((tool) => ( {tools.map((tool) => (
<MantineTooltip key={tool.id} label={tool.label} withArrow> <MantineTooltip key={tool.id} label={tool.label} withArrow>
<ActionIcon <ActionIcon
variant={activeTool === tool.id ? 'filled' : 'subtle'} variant={activeTool === tool.id && annotationsVisible ? 'filled' : 'subtle'}
color={activeTool === tool.id ? 'blue' : undefined} color={activeTool === tool.id && annotationsVisible ? 'blue' : undefined}
radius="md" radius="md"
onClick={() => activateAnnotationTool(tool.id)} onClick={() => activateAnnotationTool(tool.id)}
disabled={!annotationsVisible}
aria-label={tool.label} aria-label={tool.label}
> >
<LocalIcon icon={tool.icon} width="1.25rem" height="1.25rem" /> <LocalIcon icon={tool.icon} width="1.25rem" height="1.25rem" />
@@ -450,7 +456,7 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
if (selectedAnn?.object?.type === 3 && deriveToolFromAnnotation(selectedAnn.object) !== 'note') { if (selectedAnn?.object?.type === 3 && deriveToolFromAnnotation(selectedAnn.object) !== 'note') {
annotationApiRef?.current?.updateAnnotation?.( annotationApiRef?.current?.updateAnnotation?.(
selectedAnn.object?.pageIndex ?? 0, selectedAnn.object?.pageIndex ?? 0,
selectedAnn.object?.id, selectedAnn.object.id as string,
{ backgroundColor: 'transparent', fillColor: 'transparent' } { backgroundColor: 'transparent', fillColor: 'transparent' }
); );
} }
@@ -484,7 +490,7 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
if (selectedAnn?.object?.type === 3 && deriveToolFromAnnotation(selectedAnn.object) === 'note') { if (selectedAnn?.object?.type === 3 && deriveToolFromAnnotation(selectedAnn.object) === 'note') {
annotationApiRef?.current?.updateAnnotation?.( annotationApiRef?.current?.updateAnnotation?.(
selectedAnn.object?.pageIndex ?? 0, selectedAnn.object?.pageIndex ?? 0,
selectedAnn.object?.id, selectedAnn.object.id as string,
{ backgroundColor: 'transparent', fillColor: 'transparent' } { backgroundColor: 'transparent', fillColor: 'transparent' }
); );
} }
@@ -579,25 +585,25 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
annotationApiRef?.current?.setAnnotationStyle?.(activeTool, buildToolOptions(activeTool)); annotationApiRef?.current?.setAnnotationStyle?.(activeTool, buildToolOptions(activeTool));
} }
if (selectedAnn?.object?.id && (selectedAnn.object?.type === 9 || selectedAnn.object?.type === 15)) { if (selectedAnn?.object?.id && (selectedAnn.object?.type === 9 || selectedAnn.object?.type === 15)) {
annotationApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id, { opacity: opacity / 100 }); annotationApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id as string, { opacity: opacity / 100 });
} }
} else if (colorPickerTarget === 'underline') { } else if (colorPickerTarget === 'underline') {
setUnderlineOpacity(opacity); setUnderlineOpacity(opacity);
annotationApiRef?.current?.setAnnotationStyle?.('underline', buildToolOptions('underline')); annotationApiRef?.current?.setAnnotationStyle?.('underline', buildToolOptions('underline'));
if (selectedAnn?.object?.id && selectedAnn.object?.type === 10) { if (selectedAnn?.object?.id && selectedAnn.object?.type === 10) {
annotationApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id, { opacity: opacity / 100 }); annotationApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id as string, { opacity: opacity / 100 });
} }
} else if (colorPickerTarget === 'strikeout') { } else if (colorPickerTarget === 'strikeout') {
setStrikeoutOpacity(opacity); setStrikeoutOpacity(opacity);
annotationApiRef?.current?.setAnnotationStyle?.('strikeout', buildToolOptions('strikeout')); annotationApiRef?.current?.setAnnotationStyle?.('strikeout', buildToolOptions('strikeout'));
if (selectedAnn?.object?.id && selectedAnn.object?.type === 12) { if (selectedAnn?.object?.id && selectedAnn.object?.type === 12) {
annotationApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id, { opacity: opacity / 100 }); annotationApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id as string, { opacity: opacity / 100 });
} }
} else if (colorPickerTarget === 'squiggly') { } else if (colorPickerTarget === 'squiggly') {
setSquigglyOpacity(opacity); setSquigglyOpacity(opacity);
annotationApiRef?.current?.setAnnotationStyle?.('squiggly', buildToolOptions('squiggly')); annotationApiRef?.current?.setAnnotationStyle?.('squiggly', buildToolOptions('squiggly'));
if (selectedAnn?.object?.id && selectedAnn.object?.type === 11) { if (selectedAnn?.object?.id && selectedAnn.object?.type === 11) {
annotationApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id, { opacity: opacity / 100 }); annotationApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id as string, { opacity: opacity / 100 });
} }
} else if (colorPickerTarget === 'shapeStroke') { } else if (colorPickerTarget === 'shapeStroke') {
setShapeStrokeOpacity(opacity); setShapeStrokeOpacity(opacity);
@@ -620,7 +626,7 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
annotationApiRef?.current?.setAnnotationStyle?.('ink', buildToolOptions('ink')); annotationApiRef?.current?.setAnnotationStyle?.('ink', buildToolOptions('ink'));
} }
if (selectedAnn?.object?.type === 15) { if (selectedAnn?.object?.type === 15) {
annotationApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id, { color }); annotationApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id as string, { color });
} }
} else if (colorPickerTarget === 'highlight') { } else if (colorPickerTarget === 'highlight') {
setHighlightColor(color); setHighlightColor(color);
@@ -628,25 +634,25 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
annotationApiRef?.current?.setAnnotationStyle?.(activeTool, buildToolOptions(activeTool)); annotationApiRef?.current?.setAnnotationStyle?.(activeTool, buildToolOptions(activeTool));
} }
if (selectedAnn?.object?.type === 9 || selectedAnn?.object?.type === 15) { if (selectedAnn?.object?.type === 9 || selectedAnn?.object?.type === 15) {
annotationApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id, { color }); annotationApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id as string, { color });
} }
} else if (colorPickerTarget === 'underline') { } else if (colorPickerTarget === 'underline') {
setUnderlineColor(color); setUnderlineColor(color);
annotationApiRef?.current?.setAnnotationStyle?.('underline', buildToolOptions('underline')); annotationApiRef?.current?.setAnnotationStyle?.('underline', buildToolOptions('underline'));
if (selectedAnn?.object?.id) { if (selectedAnn?.object?.id) {
annotationApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id, { color }); annotationApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id as string, { color });
} }
} else if (colorPickerTarget === 'strikeout') { } else if (colorPickerTarget === 'strikeout') {
setStrikeoutColor(color); setStrikeoutColor(color);
annotationApiRef?.current?.setAnnotationStyle?.('strikeout', buildToolOptions('strikeout')); annotationApiRef?.current?.setAnnotationStyle?.('strikeout', buildToolOptions('strikeout'));
if (selectedAnn?.object?.id) { if (selectedAnn?.object?.id) {
annotationApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id, { color }); annotationApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id as string, { color });
} }
} else if (colorPickerTarget === 'squiggly') { } else if (colorPickerTarget === 'squiggly') {
setSquigglyColor(color); setSquigglyColor(color);
annotationApiRef?.current?.setAnnotationStyle?.('squiggly', buildToolOptions('squiggly')); annotationApiRef?.current?.setAnnotationStyle?.('squiggly', buildToolOptions('squiggly'));
if (selectedAnn?.object?.id) { if (selectedAnn?.object?.id) {
annotationApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id, { color }); annotationApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id as string, { color });
} }
} else if (colorPickerTarget === 'textBackground') { } else if (colorPickerTarget === 'textBackground') {
setTextBackgroundColor(color); setTextBackgroundColor(color);
@@ -656,7 +662,7 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
if (selectedAnn?.object?.type === 3 && deriveToolFromAnnotation(selectedAnn.object) !== 'note') { if (selectedAnn?.object?.type === 3 && deriveToolFromAnnotation(selectedAnn.object) !== 'note') {
annotationApiRef?.current?.updateAnnotation?.( annotationApiRef?.current?.updateAnnotation?.(
selectedAnn.object?.pageIndex ?? 0, selectedAnn.object?.pageIndex ?? 0,
selectedAnn.object?.id, selectedAnn.object.id as string,
{ backgroundColor: color, fillColor: color } { backgroundColor: color, fillColor: color }
); );
} }
@@ -668,7 +674,7 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
if (selectedAnn?.object?.type === 3 && deriveToolFromAnnotation(selectedAnn.object) === 'note') { if (selectedAnn?.object?.type === 3 && deriveToolFromAnnotation(selectedAnn.object) === 'note') {
annotationApiRef?.current?.updateAnnotation?.( annotationApiRef?.current?.updateAnnotation?.(
selectedAnn.object?.pageIndex ?? 0, selectedAnn.object?.pageIndex ?? 0,
selectedAnn.object?.id, selectedAnn.object.id as string,
{ backgroundColor: color, fillColor: color } { backgroundColor: color, fillColor: color }
); );
} }
@@ -678,7 +684,7 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
annotationApiRef?.current?.setAnnotationStyle?.('text', buildToolOptions('text')); annotationApiRef?.current?.setAnnotationStyle?.('text', buildToolOptions('text'));
} }
if (selectedAnn?.object?.type === 3 || selectedAnn?.object?.type === 1) { if (selectedAnn?.object?.type === 3 || selectedAnn?.object?.type === 1) {
annotationApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id, { annotationApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id as string, {
textColor: color, textColor: color,
color, color,
}); });
@@ -695,7 +701,7 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
annotationApiRef?.current?.setAnnotationStyle?.(styleTool, buildToolOptions(styleTool)); annotationApiRef?.current?.setAnnotationStyle?.(styleTool, buildToolOptions(styleTool));
} }
if (selectedAnn?.object?.id) { if (selectedAnn?.object?.id) {
annotationApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id, { annotationApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id as string, {
strokeColor: color, strokeColor: color,
color: selectedAnn.object?.color ?? shapeFillColor, color: selectedAnn.object?.color ?? shapeFillColor,
borderWidth: shapeThickness, borderWidth: shapeThickness,
@@ -709,7 +715,7 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
annotationApiRef?.current?.setAnnotationStyle?.(styleTool, buildToolOptions(styleTool)); annotationApiRef?.current?.setAnnotationStyle?.(styleTool, buildToolOptions(styleTool));
} }
if (selectedAnn?.object?.id) { if (selectedAnn?.object?.id) {
annotationApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id, { annotationApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id as string, {
color, color,
strokeColor: selectedAnn.object?.strokeColor ?? shapeStrokeColor, strokeColor: selectedAnn.object?.strokeColor ?? shapeStrokeColor,
borderWidth: shapeThickness, borderWidth: shapeThickness,
@@ -726,8 +732,9 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
<Group gap="xs" wrap="nowrap" align="center"> <Group gap="xs" wrap="nowrap" align="center">
<Tooltip label={t('annotation.selectAndMove', 'Select and edit annotations')}> <Tooltip label={t('annotation.selectAndMove', 'Select and edit annotations')}>
<ActionIcon <ActionIcon
variant={activeTool === 'select' ? 'filled' : 'default'} variant={activeTool === 'select' && annotationsVisible ? 'filled' : 'default'}
size="lg" size="lg"
disabled={!annotationsVisible}
onClick={() => { onClick={() => {
activateAnnotationTool('select'); activateAnnotationTool('select');
}} }}