import { useState, useRef, useEffect } from 'react'; import * as fabric from 'fabric'; import type { Canvas } from 'fabric'; import { useEditorStore } from '../store'; import type { TextAnnotation, TextProps } from '../../../lib/annotations/types'; import type { ViewportParams } from '../../../lib/coords'; import { pdfRectToScreen } from '../../../lib/coords'; import { v4 as uuidv4 } from 'uuid'; import { Palette, Highlighter, Bold, Italic, Copy, Trash2, ChevronDown } from 'lucide-react'; interface TextFormatToolbarProps { annotationId: string; viewportParams: ViewportParams; canvas: Canvas; } const FONTS = ['Liberation Sans', 'Outfit', 'Plus Jakarta Sans', 'Arial', 'Times New Roman', 'Courier New']; const SIZES = [6, 7, 8, 10, 12, 14, 16, 18, 24, 36, 48, 72]; const COLORS = ['#000000', '#EF4444', '#3B82F6', '#10B981', '#F59E0B', '#ffffff']; const HIGHLIGHTS = ['transparent', '#FEF08A', '#BBF7D0', '#BFDBFE', '#FBCFE8', '#000000']; export function TextFormatToolbar({ annotationId, viewportParams, canvas }: TextFormatToolbarProps) { const { annotations, updateAnnotation, deleteAnnotation, addAnnotation, setDefaultTextProps, draftAnnotation } = useEditorStore(); const [activeDropdown, setActiveDropdown] = useState<'font' | 'size' | 'color' | 'highlight' | null>(null); const toolbarRef = useRef(null); useEffect(() => { function handleClickOutside(event: MouseEvent) { if (toolbarRef.current && !toolbarRef.current.contains(event.target as Node)) { setActiveDropdown(null); } } document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, []); const isDraft = draftAnnotation?.id === annotationId; const annotation = isDraft ? draftAnnotation : annotations.find(a => a.id === annotationId); if (!annotation || annotation.type !== 'text') return null; const textAnn = annotation as TextAnnotation; const props = textAnn.props; const pt = pdfRectToScreen(textAnn.rect, viewportParams); const top = pt.y - 48; // 48px above const left = pt.x; const applyStyle = (styleName: string, value: unknown, globalPropName: keyof TextProps, globalValue?: unknown) => { const activeObj = canvas.getActiveObject() as (fabric.Textbox & { id?: string; customHeight?: number }) | undefined; if (activeObj) { if (activeObj && activeObj.id === annotationId) { const isStructural = styleName === 'fontSize' || styleName === 'fontFamily'; if (isStructural) { // Structural properties MUST be applied to the base object. Fabric 7's bounding box calculations // frequently fail when inline styles are used for size/font. activeObj.set(styleName, value); // Obliterate any inline styles for this property so the base property strictly applies to all text if (activeObj.styles) { for (const line in activeObj.styles) { for (const char in activeObj.styles[line]) { if (activeObj.styles[line][char]) { delete activeObj.styles[line][char][styleName]; } } } } // If the box is empty and we are currently editing it, Fabric's invisible cursor // cache will still stubbornly hold the old size unless we violently wipe it. if (activeObj.isEditing && !activeObj.text) { activeObj.styles = {}; if (activeObj.hiddenTextarea) { if (styleName === 'fontSize') activeObj.hiddenTextarea.style.fontSize = `${value}px`; if (styleName === 'fontFamily') activeObj.hiddenTextarea.style.fontFamily = String(value); } } } else { // Cosmetic properties (bold, italic, color) work fine with inline styles if (activeObj.isEditing) { activeObj.setSelectionStyles({ [styleName]: value }); if (!activeObj.text) { activeObj.set(styleName, value); } } else { // If NOT editing, they selected the whole box. Update the base property. activeObj.set(styleName, value); // Clear any inline styles for this property so the base property actually takes effect! if (activeObj.styles) { for (const line in activeObj.styles) { for (const char in activeObj.styles[line]) { if (activeObj.styles[line][char]) { delete (activeObj.styles[line][char] as Record)[styleName]; } } } } } } // Critical: Fabric 7 heavily caches text. We MUST mark it dirty to force a redraw! activeObj.dirty = true; if ('_forceClearCache' in activeObj) { (activeObj as typeof activeObj & { _forceClearCache?: boolean })._forceClearCache = true; } // Remove manual height constraint so the box can grow with the new font size delete activeObj.customHeight; if (activeObj.initDimensions) activeObj.initDimensions(); activeObj.setCoords(); canvas.requestRenderAll(); } } const finalGlobalValue = globalValue !== undefined ? globalValue : value; const newProps = { [globalPropName]: finalGlobalValue } as Partial; setDefaultTextProps(newProps); // Always update store so the toolbar displays the new value if (!isDraft) { updateAnnotation(annotationId, { props: { ...props, ...newProps } }); } else { useEditorStore.getState().setDraftAnnotation({ ...textAnn, props: { ...props, ...newProps } }); } }; const handleDuplicate = () => { if (isDraft) return; // Can't duplicate draft const newId = uuidv4(); addAnnotation({ ...textAnn, id: newId, rect: { ...textAnn.rect, x: textAnn.rect.x + 20, y: textAnn.rect.y + 20 } }); }; const handleDelete = () => { if (isDraft) { useEditorStore.getState().setDraftAnnotation(null); const activeObj = canvas.getActiveObject(); if (activeObj && (activeObj as fabric.FabricObject & { id?: string }).id === annotationId) { canvas.remove(activeObj); } } else { deleteAnnotation(annotationId); } }; return (
{ // Prevent clicking the toolbar from stealing focus and deselecting the canvas object if ((e.target as HTMLElement).tagName !== 'INPUT') { e.preventDefault(); } }} > {/* Font Family Dropdown */}
{activeDropdown === 'font' && (
{FONTS.map(f => ( ))}
)}
{/* Font Size Dropdown */}
{activeDropdown === 'size' && (
{SIZES.map(s => ( ))}
)}
{/* Bold / Italic */}
{/* Text Color Dropdown */}
{activeDropdown === 'color' && (
{COLORS.map(c => (
Custom applyStyle('fill', e.target.value, 'color')} className="w-5 h-5 rounded cursor-pointer border-0 p-0" />
)}
{/* Highlight Color Dropdown */}
{activeDropdown === 'highlight' && (
{HIGHLIGHTS.map(c => (
Custom applyStyle('textBackgroundColor', e.target.value, 'highlightColor')} className="w-5 h-5 rounded cursor-pointer border-0 p-0" />
)}
{/* Duplicate */} {/* Delete */}
); }