From c9d693f1eb402574b014ee7970c3627838005371 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:03:27 +0000 Subject: [PATCH] Improve annotations (#5919) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 ✅ --- .../annotation/shared/ColorControl.tsx | 34 +++- .../rightRail/ViewerAnnotationControls.tsx | 15 +- .../components/viewer/AnnotationAPIBridge.tsx | 27 ++- .../viewer/AnnotationSelectionMenu.tsx | 182 ++++-------------- .../core/components/viewer/EmbedPdfViewer.tsx | 2 + .../core/components/viewer/LocalEmbedPDF.tsx | 85 ++++---- .../viewer/LocalEmbedPDFWithAnnotations.tsx | 15 +- .../viewer/useViewerRightRailButtons.tsx | 4 +- .../src/core/components/viewer/viewerTypes.ts | 50 ++++- .../src/core/contexts/ToolWorkflowContext.tsx | 11 +- frontend/src/core/contexts/ViewerContext.tsx | 2 +- frontend/src/core/tools/Annotate.tsx | 168 ++++++++++++++-- .../core/tools/annotate/AnnotationPanel.tsx | 59 +++--- 13 files changed, 409 insertions(+), 245 deletions(-) diff --git a/frontend/src/core/components/annotation/shared/ColorControl.tsx b/frontend/src/core/components/annotation/shared/ColorControl.tsx index 51153c071..821d487e4 100644 --- a/frontend/src/core/components/annotation/shared/ColorControl.tsx +++ b/frontend/src/core/components/annotation/shared/ColorControl.tsx @@ -1,5 +1,15 @@ -import { ActionIcon, Tooltip, Popover, Stack, ColorSwatch, ColorPicker as MantineColorPicker } from '@mantine/core'; -import { useState } from 'react'; +import { ActionIcon, Tooltip, Popover, Stack, ColorSwatch, ColorPicker as MantineColorPicker, Group } from '@mantine/core'; +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 { value: string; @@ -11,6 +21,17 @@ interface ColorControlProps { export function ColorControl({ value, onChange, label, disabled = false }: ColorControlProps) { 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 ( @@ -52,6 +73,15 @@ export function ColorControl({ value, onChange, label, disabled = false }: Color swatchesPerRow={5} size="sm" /> + {supportsEyeDropper && ( + + + + + + + + )} diff --git a/frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx b/frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx index 2030fad85..23b7dfa82 100644 --- a/frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx +++ b/frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx @@ -45,7 +45,7 @@ export default function ViewerAnnotationControls({ currentView, disabled = false // Get redaction pending state and navigation guard const { isRedacting: _isRedacting } = useRedactionMode(); - const { requestNavigation, setHasUnsavedChanges } = useNavigationGuard(); + const { requestNavigation, setHasUnsavedChanges, hasUnsavedChanges } = useNavigationGuard(); 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 // NOTE: This early return is placed AFTER all hooks to satisfy React's rules of hooks if (isSignMode) { @@ -164,9 +173,7 @@ export default function ViewerAnnotationControls({ currentView, disabled = false color={annotationsHidden ? "blue" : undefined} radius="md" className="right-rail-icon" - onClick={() => { - viewerContext?.toggleAnnotationsVisibility(); - }} + onClick={handleToggleAnnotationsVisibility} disabled={disabled || currentView !== 'viewer' || (isInAnnotationTool && !isAnnotateActive) || isPlacementMode} data-active={annotationsHidden ? 'true' : undefined} aria-pressed={annotationsHidden} diff --git a/frontend/src/core/components/viewer/AnnotationAPIBridge.tsx b/frontend/src/core/components/viewer/AnnotationAPIBridge.tsx index 997d1b002..3bf970add 100644 --- a/frontend/src/core/components/viewer/AnnotationAPIBridge.tsx +++ b/frontend/src/core/components/viewer/AnnotationAPIBridge.tsx @@ -8,6 +8,7 @@ import type { AnnotationEvent, AnnotationPatch, AnnotationRect, + AnnotationSelection, } from '@app/components/viewer/viewerTypes'; import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady'; @@ -86,9 +87,13 @@ type AnnotationApiSurface = { setActiveTool: (toolId: AnnotationToolId | null) => void; getActiveTool?: () => { id: AnnotationToolId } | null; setToolDefaults?: (toolId: AnnotationToolId, defaults: AnnotationDefaults) => void; - getSelectedAnnotation?: () => unknown | null; + getSelectedAnnotation?: () => AnnotationSelection | null; deselectAnnotation?: () => 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) => void; + getSelectedAnnotations?: () => AnnotationSelection[]; onAnnotationEvent?: (listener: (event: AnnotationEvent) => void) => void | (() => void); purgeAnnotation?: (pageIndex: number, annotationId: string) => void; /** v2.7.0: move annotation without regenerating its appearance stream */ @@ -380,6 +385,26 @@ export const AnnotationAPIBridge = forwardRef(function Annotation 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) => { + 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) => { const api = annotationApi as unknown as AnnotationApiSurface | undefined; api?.purgeAnnotation?.(pageIndex, annotationId); diff --git a/frontend/src/core/components/viewer/AnnotationSelectionMenu.tsx b/frontend/src/core/components/viewer/AnnotationSelectionMenu.tsx index 088e31563..87601c4e8 100644 --- a/frontend/src/core/components/viewer/AnnotationSelectionMenu.tsx +++ b/frontend/src/core/components/viewer/AnnotationSelectionMenu.tsx @@ -5,6 +5,9 @@ import { useEffect, useState, useRef, useCallback } from 'react'; import DeleteIcon from '@mui/icons-material/Delete'; import EditIcon from '@mui/icons-material/Edit'; import { useAnnotation } from '@embedpdf/plugin-annotation/react'; +import 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 { OpacityControl } from '@app/components/annotation/shared/OpacityControl'; import { WidthControl } from '@app/components/annotation/shared/WidthControl'; @@ -19,7 +22,7 @@ export interface AnnotationSelectionMenuProps { documentId?: string; context?: { type: 'annotation'; - annotation: any; + annotation: TrackedAnnotation; pageIndex: number; }; selected: boolean; @@ -58,11 +61,7 @@ function AnnotationSelectionMenuInner({ const { t } = useTranslation(); const { provides } = useAnnotation(documentId); const wrapperRef = useRef(null); - const textareaRef = useRef(null); const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null); - const [isTextEditorOpen, setIsTextEditorOpen] = useState(false); - const [textDraft, setTextDraft] = useState(''); - const [textBoxPosition, setTextBoxPosition] = useState<{ top: number; left: number; width: number; height: number; fontSize: number; fontFamily: string } | null>(null); // Merge refs - menuWrapperProps.ref is a callback ref const setRef = useCallback((node: HTMLDivElement | null) => { @@ -74,18 +73,18 @@ function AnnotationSelectionMenuInner({ // Type detection const getAnnotationType = useCallback((): AnnotationType => { 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 - if ([9, 10, 11, 12].includes(type)) return 'textMarkup'; + if (type !== undefined && [9, 10, 11, 12].includes(type)) return 'textMarkup'; if (type === 15) { return toolId === 'inkHighlighter' ? 'inkHighlighter' : 'ink'; } if (type === 3) { return toolId === 'note' ? 'note' : 'text'; } - if ([5, 6, 7].includes(type)) return 'shape'; - if ([4, 8].includes(type)) return 'line'; + if (type !== undefined && [5, 6, 7].includes(type)) return 'shape'; + if (type !== undefined && [4, 8].includes(type)) return 'line'; if (type === 13) return 'stamp'; return 'unknown'; @@ -106,7 +105,7 @@ function AnnotationSelectionMenuInner({ }; // Get annotation properties - const obj = annotation?.object; + const obj = annotation?.object as AnnotationObject | undefined; const annotationType = getAnnotationType(); const annotationId = obj?.id; @@ -117,7 +116,7 @@ function AnnotationSelectionMenuInner({ // Text annotations use textColor if (type === 3) return obj.textColor || obj.color || '#000000'; // Shape annotations use strokeColor - if ([4, 5, 6, 7, 8].includes(type)) return obj.strokeColor || obj.color || '#000000'; + if (type !== undefined && [4, 5, 6, 7, 8].includes(type)) return obj.strokeColor || obj.color || '#000000'; // Default to color property return obj.color || obj.strokeColor || '#000000'; }; @@ -154,82 +153,22 @@ function AnnotationSelectionMenuInner({ } }, [provides, annotationId, pageIndex]); - const handleOpenTextEditor = useCallback(() => { - if (!annotation) return; - - // Try to find the annotation element in the DOM - const annotationElement = document.querySelector(`[data-annotation-id="${annotationId}"]`) as HTMLElement; - - let fontSize = (obj?.fontSize || 14) * 1.33; - let fontFamily = 'Helvetica'; - - if (annotationElement) { - const rect = annotationElement.getBoundingClientRect(); - - // Try multiple selectors to find the text element - const textElement = annotationElement.querySelector('text, [class*="text"], [class*="content"]') as HTMLElement; - if (textElement) { - const computedStyle = window.getComputedStyle(textElement); - const computedSize = parseFloat(computedStyle.fontSize); - if (computedSize && computedSize > 0) { - fontSize = computedSize; - } - fontFamily = computedStyle.fontFamily || fontFamily; - } - - setTextBoxPosition({ - top: rect.top, - left: rect.left, - width: rect.width, - height: rect.height, - fontSize: fontSize, - fontFamily: fontFamily, - }); - } else if (wrapperRef.current) { - // Fallback to wrapper position - const rect = wrapperRef.current.getBoundingClientRect(); - setTextBoxPosition({ - top: rect.top, - left: rect.left, - width: Math.max(rect.width, 200), - height: Math.max(rect.height, 50), - fontSize: fontSize, - fontFamily: fontFamily, - }); - } else { - return; - } - - setTextDraft(obj?.contents || ''); - setIsTextEditorOpen(true); - - // Focus the textarea after it renders - setTimeout(() => { - textareaRef.current?.focus(); - textareaRef.current?.select(); - }, 0); - }, [obj, annotation, annotationId]); - - const handleSaveText = useCallback(() => { - if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return; - - provides.updateAnnotation(pageIndex, annotationId, { - contents: textDraft, - }); - setIsTextEditorOpen(false); - setTextBoxPosition(null); - }, [provides, annotationId, pageIndex, textDraft]); - - const handleCloseTextEdit = useCallback(() => { - setIsTextEditorOpen(false); - setTextBoxPosition(null); + // Focus inline text input (same as double-clicking the note/text box). Dispatches dblclick on the + // annotation hit-area div (EmbedPDF's inner div with onDoubleClick) so built-in FreeText editing is used. + const handleFocusTextEdit = useCallback(() => { + const root = wrapperRef.current?.closest('[data-no-interaction]'); + const main = root?.firstElementChild; + // EmbedPDF puts onDoubleClick on the content div. For text/note (no rotation) it's the first child. + const hitArea = main?.lastElementChild ?? main?.firstElementChild; + if (!hitArea) return; + hitArea.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true, view: window })); }, []); const handleColorChange = useCallback((color: string, target: 'main' | 'stroke' | 'fill' | 'text' | 'background') => { if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return; const type = obj?.type; - const patch: any = {}; + const patch: AnnotationPatch = {}; if (target === 'stroke') { // Shape stroke - preserve fill color @@ -262,14 +201,14 @@ function AnnotationSelectionMenuInner({ patch.color = color; // 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.fillColor = color; patch.opacity = obj?.opacity ?? 1; } // 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.strokeWidth = obj?.strokeWidth ?? obj?.lineWidth ?? 2; patch.lineWidth = obj?.lineWidth ?? obj?.strokeWidth ?? 2; @@ -330,7 +269,7 @@ function AnnotationSelectionMenuInner({ variant="subtle" color="gray" size="md" - onClick={handleOpenTextEditor} + onClick={handleFocusTextEdit} styles={commonButtonStyles} > @@ -493,9 +432,6 @@ function AnnotationSelectionMenuInner({ } const wrapperRect = wrapper.getBoundingClientRect(); - // Position menu below the wrapper, centered - // Use getBoundingClientRect which gives viewport-relative coordinates - // Since we're using fixed positioning in the portal, we don't need to add scroll offsets setMenuPosition({ top: wrapperRect.bottom + 8, left: wrapperRect.left + wrapperRect.width / 2, @@ -504,11 +440,15 @@ function AnnotationSelectionMenuInner({ 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('resize', updatePosition); return () => { + observer.disconnect(); window.removeEventListener('scroll', updatePosition, true); window.removeEventListener('resize', updatePosition); }; @@ -519,6 +459,7 @@ function AnnotationSelectionMenuInner({ const menuContent = menuPosition ? (
) : null; - const textEditorOverlay = isTextEditorOpen && textBoxPosition ? ( -
-