diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 16660e79d..62e629f19 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -1439,13 +1439,16 @@ applyChanges = "Apply Changes" backgroundColor = "Background colour" borderOff = "Border: Off" borderOn = "Border: On" +changeColor = "Change Colour" chooseColor = "Choose colour" circle = "Circle" clearBackground = "Remove background" color = "Colour" contents = "Text" +delete = "Delete" desc = "Use highlight, pen, text, and notes. Changes stay live—no flattening required." drawing = "Drawing" +duplicate = "Duplicate" editCircle = "Edit Circle" editInk = "Edit Pen" editLine = "Edit Line" @@ -1475,6 +1478,7 @@ notesStamps = "Notes & Stamps" opacity = "Opacity" pen = "Pen" polygon = "Polygon" +properties = "Properties" rectangle = "Rectangle" redo = "Redo" saveChanges = "Save Changes" @@ -1500,6 +1504,7 @@ title = "Annotate" underline = "Underline" undo = "Undo" unsupportedType = "This annotation type is not fully supported for editing." +width = "Width" [app] description = "The Free Adobe Acrobat alternative (10M+ Downloads)" @@ -4230,6 +4235,56 @@ title = "Page Editor" zoomIn = "Zoom In" zoomOut = "Zoom Out" +[viewer] +cannotPreviewFile = "Cannot Preview File" +dualPageView = "Dual Page View" +firstPage = "First Page" +lastPage = "Last Page" +nextPage = "Next Page" +onlyPdfSupported = "The viewer only supports PDF files. This file appears to be a different format." +previousPage = "Previous Page" +singlePageView = "Single Page View" +unknownFile = "Unknown file" +zoomIn = "Zoom In" +zoomOut = "Zoom Out" + +[rightRail] +closeSelected = "Close Selected Files" +selectAll = "Select All" +deselectAll = "Deselect All" +selectByNumber = "Select by Page Numbers" +deleteSelected = "Delete Selected Pages" +closePdf = "Close PDF" +exportAll = "Export PDF" +downloadSelected = "Download Selected Files" +annotations = "Annotations" +exportSelected = "Export Selected Pages" +formFill = "Fill Form" +saveChanges = "Save Changes" +toggleAttachments = "Toggle Attachments" +toggleTheme = "Toggle Theme" +language = "Language" +toggleAnnotations = "Toggle Annotations Visibility" +search = "Search PDF" +panMode = "Pan Mode" +applyRedactionsFirst = "Apply redactions first" +rotateLeft = "Rotate Left" +rotateRight = "Rotate Right" +toggleSidebar = "Toggle Sidebar" +toggleBookmarks = "Toggle Bookmarks" +print = "Print PDF" +draw = "Draw" +redact = "Redact" +exitRedaction = "Exit Redaction Mode" +save = "Save" +downloadAll = "Download All" +saveAll = "Save All" + +[textAlign] +left = "Left" +center = "Center" +right = "Right" + [pageExtracter] header = "Extract Pages" placeholder = "(e.g. 1,2,8 or 4,7,12-16 or 2n-1)" @@ -5310,38 +5365,6 @@ title = "High Contrast" text = "Completely invert all colours in the PDF, creating a negative-like effect. Useful for creating dark mode versions of documents or reducing eye strain in low-light conditions." title = "Invert All Colours" -[rightRail] -annotations = "Annotations" -applyRedactionsFirst = "Apply redactions first" -closePdf = "Close PDF" -closeSelected = "Close Selected Files" -formFill = "Fill Form" -deleteSelected = "Delete Selected Pages" -deselectAll = "Deselect All" -downloadAll = "Download All" -downloadSelected = "Download Selected Files" -draw = "Draw" -exitRedaction = "Exit Redaction Mode" -exportAll = "Export PDF" -exportSelected = "Export Selected Pages" -language = "Language" -panMode = "Pan Mode" -print = "Print PDF" -redact = "Redact" -rotateLeft = "Rotate Left" -rotateRight = "Rotate Right" -save = "Save" -saveAll = "Save All" -saveChanges = "Save Changes" -search = "Search PDF" -selectAll = "Select All" -selectByNumber = "Select by Page Numbers" -toggleAnnotations = "Toggle Annotations Visibility" -toggleBookmarks = "Toggle Bookmarks" -toggleAttachments = "Toggle Attachments" -toggleSidebar = "Toggle Sidebar" -toggleTheme = "Toggle Theme" - [rotate] rotateLeft = "Rotate Anticlockwise" rotateRight = "Rotate Clockwise" @@ -6179,11 +6202,6 @@ title = "API Documentation" [tableExtraxt] tags = "CSV,Table Extraction,extract,convert" -[textAlign] -center = "Center" -left = "Left" -right = "Right" - [theme] toggle = "Toggle Theme" @@ -6459,19 +6477,6 @@ fileManager = "File Manager" pageEditor = "Page Editor" viewer = "Viewer" -[viewer] -cannotPreviewFile = "Cannot Preview File" -dualPageView = "Dual Page View" -firstPage = "First Page" -lastPage = "Last Page" -nextPage = "Next Page" -onlyPdfSupported = "The viewer only supports PDF files. This file appears to be a different format." -previousPage = "Previous Page" -singlePageView = "Single Page View" -unknownFile = "Unknown file" -zoomIn = "Zoom In" -zoomOut = "Zoom Out" - [viewer.attachments] title = "Attachments" searchPlaceholder = "Search attachments" diff --git a/frontend/src/core/components/annotation/shared/ColorControl.tsx b/frontend/src/core/components/annotation/shared/ColorControl.tsx new file mode 100644 index 000000000..51153c071 --- /dev/null +++ b/frontend/src/core/components/annotation/shared/ColorControl.tsx @@ -0,0 +1,59 @@ +import { ActionIcon, Tooltip, Popover, Stack, ColorSwatch, ColorPicker as MantineColorPicker } from '@mantine/core'; +import { useState } from 'react'; + +interface ColorControlProps { + value: string; + onChange: (color: string) => void; + label: string; + disabled?: boolean; +} + +export function ColorControl({ value, onChange, label, disabled = false }: ColorControlProps) { + const [opened, setOpened] = useState(false); + + return ( + + + + setOpened(!opened)} + disabled={disabled} + styles={{ + root: { + flexShrink: 0, + backgroundColor: 'var(--bg-raised)', + border: '1px solid var(--border-default)', + color: 'var(--text-secondary)', + '&:hover': { + backgroundColor: 'var(--hover-bg)', + borderColor: 'var(--border-strong)', + color: 'var(--text-primary)', + }, + }, + }} + > + + + + + + + + + + + ); +} diff --git a/frontend/src/core/components/annotation/shared/OpacityControl.tsx b/frontend/src/core/components/annotation/shared/OpacityControl.tsx new file mode 100644 index 000000000..27b1f10dd --- /dev/null +++ b/frontend/src/core/components/annotation/shared/OpacityControl.tsx @@ -0,0 +1,60 @@ +import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { useState } from 'react'; +import OpacityIcon from '@mui/icons-material/Opacity'; + +interface OpacityControlProps { + value: number; // 0-100 + onChange: (value: number) => void; + disabled?: boolean; +} + +export function OpacityControl({ value, onChange, disabled = false }: OpacityControlProps) { + const { t } = useTranslation(); + const [opened, setOpened] = useState(false); + + return ( + + + + setOpened(!opened)} + disabled={disabled} + styles={{ + root: { + flexShrink: 0, + backgroundColor: 'var(--bg-raised)', + border: '1px solid var(--border-default)', + color: 'var(--text-secondary)', + '&:hover': { + backgroundColor: 'var(--hover-bg)', + borderColor: 'var(--border-strong)', + color: 'var(--text-primary)', + }, + }, + }} + > + + + + + + + + {t('annotation.opacity', 'Opacity')} + + `${val}%`} + /> + + + + ); +} diff --git a/frontend/src/core/components/annotation/shared/PropertiesPopover.tsx b/frontend/src/core/components/annotation/shared/PropertiesPopover.tsx new file mode 100644 index 000000000..bde9609b5 --- /dev/null +++ b/frontend/src/core/components/annotation/shared/PropertiesPopover.tsx @@ -0,0 +1,211 @@ +import { ActionIcon, Tooltip, Popover, Stack, Slider, Text, Group, Button } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { useState } from 'react'; +import TuneIcon from '@mui/icons-material/Tune'; +import FormatAlignLeftIcon from '@mui/icons-material/FormatAlignLeft'; +import FormatAlignCenterIcon from '@mui/icons-material/FormatAlignCenter'; +import FormatAlignRightIcon from '@mui/icons-material/FormatAlignRight'; + +type AnnotationType = 'text' | 'note' | 'shape'; + +interface PropertiesPopoverProps { + annotationType: AnnotationType; + annotation: any; + onUpdate: (patch: Record) => void; + disabled?: boolean; +} + +export function PropertiesPopover({ + annotationType, + annotation, + onUpdate, + disabled = false, +}: PropertiesPopoverProps) { + const { t } = useTranslation(); + const [opened, setOpened] = useState(false); + + const obj = annotation?.object; + + // Get current values + const fontSize = obj?.fontSize ?? 14; + const textAlign = obj?.textAlign; + const currentAlign = + typeof textAlign === 'number' + ? textAlign === 1 + ? 'center' + : textAlign === 2 + ? 'right' + : 'left' + : textAlign === 'center' + ? 'center' + : textAlign === 'right' + ? 'right' + : 'left'; + + // For shapes + const opacity = Math.round((obj?.opacity ?? 1) * 100); + const strokeWidth = obj?.borderWidth ?? obj?.strokeWidth ?? 2; + const borderVisible = strokeWidth > 0; + + const renderTextNoteControls = () => ( + + {/* Font Size */} +
+ + {t('annotation.fontSize', 'Font size')} + + onUpdate({ fontSize: val })} + min={8} + max={32} + label={(val) => `${val}pt`} + /> +
+ + {/* Opacity */} +
+ + {t('annotation.opacity', 'Opacity')} + + onUpdate({ opacity: val / 100 })} + min={10} + max={100} + label={(val) => `${val}%`} + /> +
+ + {/* Text Alignment */} +
+ + {t('annotation.textAlignment', 'Text Alignment')} + + + onUpdate({ textAlign: 0 })} + size="md" + > + + + onUpdate({ textAlign: 1 })} + size="md" + > + + + onUpdate({ textAlign: 2 })} + size="md" + > + + + +
+
+ ); + + const renderShapeControls = () => ( + + {/* Opacity */} +
+ + {t('annotation.opacity', 'Opacity')} + + { + const newOpacity = val / 100; + onUpdate({ + opacity: newOpacity, + strokeOpacity: newOpacity, + fillOpacity: newOpacity, + }); + }} + min={10} + max={100} + label={(val) => `${val}%`} + /> +
+ + {/* Stroke Width */} +
+ +
+ + {t('annotation.strokeWidth', 'Stroke')} + + { + onUpdate({ + borderWidth: val, + strokeWidth: val, + lineWidth: val, + }); + }} + min={0} + max={12} + label={(val) => `${val}pt`} + /> +
+ +
+
+
+ ); + + return ( + + + + setOpened(!opened)} + disabled={disabled} + styles={{ + root: { + flexShrink: 0, + backgroundColor: 'var(--bg-raised)', + border: '1px solid var(--border-default)', + color: 'var(--text-secondary)', + '&:hover': { + backgroundColor: 'var(--hover-bg)', + borderColor: 'var(--border-strong)', + color: 'var(--text-primary)', + }, + }, + }} + > + + + + + + {(annotationType === 'text' || annotationType === 'note') && renderTextNoteControls()} + {annotationType === 'shape' && renderShapeControls()} + + + ); +} diff --git a/frontend/src/core/components/annotation/shared/WidthControl.tsx b/frontend/src/core/components/annotation/shared/WidthControl.tsx new file mode 100644 index 000000000..b99d35c99 --- /dev/null +++ b/frontend/src/core/components/annotation/shared/WidthControl.tsx @@ -0,0 +1,62 @@ +import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { useState } from 'react'; +import LineWeightIcon from '@mui/icons-material/LineWeight'; + +interface WidthControlProps { + value: number; + onChange: (value: number) => void; + min: number; // 1 for ink, 0 for shapes + max: number; // 12 for ink, 20 for highlighter + disabled?: boolean; +} + +export function WidthControl({ value, onChange, min, max, disabled = false }: WidthControlProps) { + const { t } = useTranslation(); + const [opened, setOpened] = useState(false); + + return ( + + + + setOpened(!opened)} + disabled={disabled} + styles={{ + root: { + flexShrink: 0, + backgroundColor: 'var(--bg-raised)', + border: '1px solid var(--border-default)', + color: 'var(--text-secondary)', + '&:hover': { + backgroundColor: 'var(--hover-bg)', + borderColor: 'var(--border-strong)', + color: 'var(--text-primary)', + }, + }, + }} + > + + + + + + + + {t('annotation.width', 'Width')} + + `${val}pt`} + /> + + + + ); +} diff --git a/frontend/src/core/components/viewer/AnnotationSelectionMenu.tsx b/frontend/src/core/components/viewer/AnnotationSelectionMenu.tsx new file mode 100644 index 000000000..088e31563 --- /dev/null +++ b/frontend/src/core/components/viewer/AnnotationSelectionMenu.tsx @@ -0,0 +1,620 @@ +import { ActionIcon, Tooltip, Group } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { createPortal } from 'react-dom'; +import { useEffect, useState, useRef, useCallback } from 'react'; +import DeleteIcon from '@mui/icons-material/Delete'; +import EditIcon from '@mui/icons-material/Edit'; +import { useAnnotation } from '@embedpdf/plugin-annotation/react'; +import { useActiveDocumentId } from '@app/components/viewer/useActiveDocumentId'; +import { OpacityControl } from '@app/components/annotation/shared/OpacityControl'; +import { WidthControl } from '@app/components/annotation/shared/WidthControl'; +import { PropertiesPopover } from '@app/components/annotation/shared/PropertiesPopover'; +import { ColorControl } from '@app/components/annotation/shared/ColorControl'; + +/** + * Props interface matching EmbedPDF's annotation selection menu pattern + * This matches the type from @embedpdf/plugin-annotation + */ +export interface AnnotationSelectionMenuProps { + documentId?: string; + context?: { + type: 'annotation'; + annotation: any; + pageIndex: number; + }; + selected: boolean; + menuWrapperProps?: { + ref?: (node: HTMLDivElement | null) => void; + style?: React.CSSProperties; + }; +} + +export function AnnotationSelectionMenu(props: AnnotationSelectionMenuProps) { + const activeDocumentId = useActiveDocumentId(); + + // Don't render until we have a valid document ID + if (!activeDocumentId) { + return null; + } + + return ( + + ); +} + +type AnnotationType = 'textMarkup' | 'ink' | 'inkHighlighter' | 'text' | 'note' | 'shape' | 'line' | 'stamp' | 'unknown'; + +function AnnotationSelectionMenuInner({ + documentId, + context, + selected, + menuWrapperProps, +}: AnnotationSelectionMenuProps & { documentId: string }) { + const annotation = context?.annotation; + const pageIndex = context?.pageIndex; + const { t } = useTranslation(); + const { provides } = useAnnotation(documentId); + const wrapperRef = useRef(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) => { + wrapperRef.current = node; + // Call the EmbedPDF ref callback + menuWrapperProps?.ref?.(node); + }, [menuWrapperProps]); + + // Type detection + const getAnnotationType = useCallback((): AnnotationType => { + const type = annotation?.object?.type; + const toolId = annotation?.object?.customData?.toolId; + + // Map type numbers to categories + if ([9, 10, 11, 12].includes(type)) return 'textMarkup'; + if (type === 15) { + return toolId === 'inkHighlighter' ? 'inkHighlighter' : 'ink'; + } + if (type === 3) { + return toolId === 'note' ? 'note' : 'text'; + } + if ([5, 6, 7].includes(type)) return 'shape'; + if ([4, 8].includes(type)) return 'line'; + if (type === 13) return 'stamp'; + + return 'unknown'; + }, [annotation]); + + // Calculate menu width based on annotation type + const calculateWidth = (annotationType: AnnotationType): number => { + switch (annotationType) { + case 'stamp': + return 80; + case 'inkHighlighter': + return 220; + case 'shape': + return 200; + default: + return 180; + } + }; + + // Get annotation properties + const obj = annotation?.object; + const annotationType = getAnnotationType(); + const annotationId = obj?.id; + + // Get current colors + const getCurrentColor = (): string => { + if (!obj) return '#000000'; + const type = obj.type; + // Text annotations use textColor + if (type === 3) return obj.textColor || obj.color || '#000000'; + // Shape annotations use strokeColor + if ([4, 5, 6, 7, 8].includes(type)) return obj.strokeColor || obj.color || '#000000'; + // Default to color property + return obj.color || obj.strokeColor || '#000000'; + }; + + const getStrokeColor = (): string => { + return obj?.strokeColor || obj?.color || '#000000'; + }; + + const getFillColor = (): string => { + return obj?.color || obj?.fillColor || '#0000ff'; + }; + + const getBackgroundColor = (): string => { + // Check multiple possible properties for background color + return obj?.backgroundColor || obj?.fillColor || obj?.color || '#ffffff'; + }; + + const getTextColor = (): string => { + return obj?.textColor || obj?.color || '#000000'; + }; + + const getOpacity = (): number => { + return Math.round((obj?.opacity ?? 1) * 100); + }; + + const getWidth = (): number => { + return obj?.strokeWidth ?? obj?.borderWidth ?? obj?.lineWidth ?? obj?.thickness ?? 2; + }; + + // Handlers + const handleDelete = useCallback(() => { + if (provides?.deleteAnnotation && annotationId && pageIndex !== undefined) { + provides.deleteAnnotation(pageIndex, annotationId); + } + }, [provides, annotationId, pageIndex]); + + const handleOpenTextEditor = useCallback(() => { + if (!annotation) return; + + // Try to find the annotation element in the DOM + const annotationElement = document.querySelector(`[data-annotation-id="${annotationId}"]`) as HTMLElement; + + let fontSize = (obj?.fontSize || 14) * 1.33; + let fontFamily = 'Helvetica'; + + if (annotationElement) { + const rect = annotationElement.getBoundingClientRect(); + + // Try multiple selectors to find the text element + const textElement = annotationElement.querySelector('text, [class*="text"], [class*="content"]') as HTMLElement; + if (textElement) { + const computedStyle = window.getComputedStyle(textElement); + const computedSize = parseFloat(computedStyle.fontSize); + if (computedSize && computedSize > 0) { + fontSize = computedSize; + } + fontFamily = computedStyle.fontFamily || fontFamily; + } + + setTextBoxPosition({ + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + fontSize: fontSize, + fontFamily: fontFamily, + }); + } else if (wrapperRef.current) { + // Fallback to wrapper position + const rect = wrapperRef.current.getBoundingClientRect(); + setTextBoxPosition({ + top: rect.top, + left: rect.left, + width: Math.max(rect.width, 200), + height: Math.max(rect.height, 50), + fontSize: fontSize, + fontFamily: fontFamily, + }); + } else { + return; + } + + setTextDraft(obj?.contents || ''); + setIsTextEditorOpen(true); + + // Focus the textarea after it renders + setTimeout(() => { + textareaRef.current?.focus(); + textareaRef.current?.select(); + }, 0); + }, [obj, annotation, annotationId]); + + const handleSaveText = useCallback(() => { + if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return; + + provides.updateAnnotation(pageIndex, annotationId, { + contents: textDraft, + }); + setIsTextEditorOpen(false); + setTextBoxPosition(null); + }, [provides, annotationId, pageIndex, textDraft]); + + const handleCloseTextEdit = useCallback(() => { + setIsTextEditorOpen(false); + setTextBoxPosition(null); + }, []); + + const handleColorChange = useCallback((color: string, target: 'main' | 'stroke' | 'fill' | 'text' | 'background') => { + if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return; + + const type = obj?.type; + const patch: any = {}; + + if (target === 'stroke') { + // Shape stroke - preserve fill color + patch.strokeColor = color; + patch.color = obj?.color || '#0000ff'; // Preserve fill + patch.strokeWidth = getWidth(); + } else if (target === 'fill') { + // Shape fill - preserve stroke color + patch.color = color; + patch.strokeColor = obj?.strokeColor || '#000000'; // Preserve stroke + patch.strokeWidth = getWidth(); + } else if (target === 'background') { + // Background color for text/note - set multiple properties for compatibility + patch.backgroundColor = color; + patch.fillColor = color; + patch.color = color; + } else if (target === 'text') { + // Text color for text/note - TRY PROPERTY COMBINATIONS + patch.textColor = color; + patch.fontColor = color; // EmbedPDF might expect this instead + + // Include font metadata (EmbedPDF might require these together) + patch.fontSize = obj?.fontSize ?? 14; + patch.fontFamily = obj?.fontFamily ?? 'Helvetica'; + + // Re-submit text content + patch.contents = obj?.contents ?? ''; + } else { + // Main color - for highlights, ink, etc. + patch.color = color; + + // For text markup annotations (highlight, underline, strikeout, squiggly) + if ([9, 10, 11, 12].includes(type)) { + patch.strokeColor = color; + patch.fillColor = color; + patch.opacity = obj?.opacity ?? 1; + } + + // For line annotations (type 4, 8), include stroke properties + if ([4, 8].includes(type)) { + patch.strokeColor = color; + patch.strokeWidth = obj?.strokeWidth ?? obj?.lineWidth ?? 2; + patch.lineWidth = obj?.lineWidth ?? obj?.strokeWidth ?? 2; + } + + // For ink annotations (type 15), include all stroke-related properties + if (type === 15) { + patch.strokeColor = color; + patch.strokeWidth = obj?.strokeWidth ?? obj?.thickness ?? 2; + patch.opacity = obj?.opacity ?? 1; + } + } + + provides.updateAnnotation(pageIndex, annotationId, patch); + }, [provides, annotationId, pageIndex, obj]); + + const handleOpacityChange = useCallback((opacity: number) => { + if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return; + + provides.updateAnnotation(pageIndex, annotationId, { + opacity: opacity / 100, + }); + }, [provides, annotationId, pageIndex]); + + const handleWidthChange = useCallback((width: number) => { + if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return; + + provides.updateAnnotation(pageIndex, annotationId, { + strokeWidth: width, + }); + }, [provides, annotationId, pageIndex]); + + const handlePropertiesUpdate = useCallback((patch: Record) => { + if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return; + + provides.updateAnnotation(pageIndex, annotationId, patch); + }, [provides, annotationId, pageIndex]); + + // Render button groups based on annotation type + const renderButtons = () => { + const commonButtonStyles = { + root: { + flexShrink: 0, + backgroundColor: 'var(--bg-raised)', + border: '1px solid var(--border-default)', + color: 'var(--text-secondary)', + '&:hover': { + backgroundColor: 'var(--hover-bg)', + borderColor: 'var(--border-strong)', + color: 'var(--text-primary)', + }, + }, + }; + + const EditTextButton = () => ( + + + + + + ); + + const DeleteButton = () => ( + + + + + + ); + + switch (annotationType) { + case 'textMarkup': + return ( + <> + handleColorChange(color, 'main')} + label={t('annotation.changeColor', 'Change Colour')} + /> + + + + ); + + case 'ink': + return ( + <> + handleColorChange(color, 'main')} + label={t('annotation.changeColor', 'Change Colour')} + /> + + + + ); + + case 'inkHighlighter': + return ( + <> + handleColorChange(color, 'main')} + label={t('annotation.changeColor', 'Change Colour')} + /> + + + + + ); + + case 'text': + case 'note': + return ( + <> + handleColorChange(color, 'text')} + label={t('annotation.color', 'Color')} + /> + handleColorChange(color, 'background')} + label={t('annotation.backgroundColor', 'Background color')} + /> + + + + + ); + + case 'shape': + return ( + <> + handleColorChange(color, 'stroke')} + label={t('annotation.strokeColor', 'Stroke Colour')} + /> + handleColorChange(color, 'fill')} + label={t('annotation.fillColor', 'Fill Colour')} + /> + + + + ); + + case 'line': + return ( + <> + handleColorChange(color, 'main')} + label={t('annotation.changeColor', 'Change Colour')} + /> + + + + ); + + case 'stamp': + return ; + + default: + return ( + <> + handleColorChange(color, 'main')} + label={t('annotation.changeColor', 'Change Colour')} + /> + + + ); + } + }; + + // Calculate position for portal based on wrapper element + useEffect(() => { + if (!selected || !annotation || !wrapperRef.current) { + setMenuPosition(null); + return; + } + + const updatePosition = () => { + const wrapper = wrapperRef.current; + if (!wrapper) { + setMenuPosition(null); + return; + } + + const wrapperRect = wrapper.getBoundingClientRect(); + // Position menu below the wrapper, centered + // Use getBoundingClientRect which gives viewport-relative coordinates + // Since we're using fixed positioning in the portal, we don't need to add scroll offsets + setMenuPosition({ + top: wrapperRect.bottom + 8, + left: wrapperRect.left + wrapperRect.width / 2, + }); + }; + + updatePosition(); + + // Update position on scroll/resize + window.addEventListener('scroll', updatePosition, true); + window.addEventListener('resize', updatePosition); + + return () => { + window.removeEventListener('scroll', updatePosition, true); + window.removeEventListener('resize', updatePosition); + }; + }, [selected, annotation]); + + // Early return AFTER all hooks have been called + if (!selected || !annotation) return null; + + const menuContent = menuPosition ? ( +
+ + {renderButtons()} + +
+ ) : null; + + const textEditorOverlay = isTextEditorOpen && textBoxPosition ? ( +
+