355 lines
15 KiB
TypeScript
355 lines
15 KiB
TypeScript
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<HTMLDivElement>(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<string, unknown>)[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<TextProps>;
|
|
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 (
|
|
<div
|
|
ref={toolbarRef}
|
|
className="absolute z-50 bg-white rounded-md shadow-lg border border-neutral-200 flex items-center p-1 gap-1 h-10"
|
|
style={{ top: `${Math.max(0, top)}px`, left: `${left}px` }}
|
|
onMouseDown={(e) => {
|
|
// Prevent clicking the toolbar from stealing focus and deselecting the canvas object
|
|
if ((e.target as HTMLElement).tagName !== 'INPUT') {
|
|
e.preventDefault();
|
|
}
|
|
}}
|
|
>
|
|
{/* Font Family Dropdown */}
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => setActiveDropdown(activeDropdown === 'font' ? null : 'font')}
|
|
className={`flex items-center gap-1 text-xs px-2 py-1.5 rounded hover:bg-neutral-100 ${activeDropdown === 'font' ? 'bg-neutral-100' : ''}`}
|
|
>
|
|
<span className="truncate w-24 text-left">{props.fontFamily}</span>
|
|
<ChevronDown className="w-3 h-3 text-neutral-500" />
|
|
</button>
|
|
{activeDropdown === 'font' && (
|
|
<div className="absolute top-full left-0 mt-1 w-40 bg-white border border-neutral-200 shadow-xl rounded py-1 z-[60]">
|
|
{FONTS.map(f => (
|
|
<button
|
|
key={f}
|
|
className={`w-full text-left px-3 py-1.5 text-xs hover:bg-blue-50 ${props.fontFamily === f ? 'bg-blue-50 text-blue-600 font-medium' : ''}`}
|
|
style={{ fontFamily: f }}
|
|
onClick={() => { applyStyle('fontFamily', f, 'fontFamily'); setActiveDropdown(null); }}
|
|
>
|
|
{f}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="w-px h-4 bg-neutral-200 mx-0.5" />
|
|
|
|
{/* Font Size Dropdown */}
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => setActiveDropdown(activeDropdown === 'size' ? null : 'size')}
|
|
className={`flex items-center gap-1 text-xs px-2 py-1.5 rounded hover:bg-neutral-100 ${activeDropdown === 'size' ? 'bg-neutral-100' : ''}`}
|
|
>
|
|
<span className="w-4 text-center">{props.fontSize}</span>
|
|
<ChevronDown className="w-3 h-3 text-neutral-500" />
|
|
</button>
|
|
{activeDropdown === 'size' && (
|
|
<div className="absolute top-full left-0 mt-1 w-16 bg-white border border-neutral-200 shadow-xl rounded py-1 z-[60] max-h-48 overflow-y-auto">
|
|
{SIZES.map(s => (
|
|
<button
|
|
key={s}
|
|
className={`w-full text-center px-3 py-1 text-xs hover:bg-blue-50 ${props.fontSize === s ? 'bg-blue-50 text-blue-600 font-medium' : ''}`}
|
|
onClick={() => { applyStyle('fontSize', s * viewportParams.scale, 'fontSize', s); setActiveDropdown(null); }}
|
|
>
|
|
{s}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="w-px h-4 bg-neutral-200 mx-0.5" />
|
|
|
|
{/* Bold / Italic */}
|
|
<button
|
|
onClick={() => applyStyle('fontWeight', props.bold ? 'normal' : 'bold', 'bold')}
|
|
className={`w-7 h-7 flex items-center justify-center rounded ${props.bold ? 'bg-blue-100 text-blue-700' : 'hover:bg-neutral-100 text-neutral-700'}`}
|
|
title="Bold"
|
|
>
|
|
<Bold className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => applyStyle('fontStyle', props.italic ? 'normal' : 'italic', 'italic')}
|
|
className={`w-7 h-7 flex items-center justify-center rounded ${props.italic ? 'bg-blue-100 text-blue-700' : 'hover:bg-neutral-100 text-neutral-700'}`}
|
|
title="Italic"
|
|
>
|
|
<Italic className="w-4 h-4" />
|
|
</button>
|
|
|
|
<div className="w-px h-4 bg-neutral-200 mx-0.5" />
|
|
|
|
{/* Text Color Dropdown */}
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => setActiveDropdown(activeDropdown === 'color' ? null : 'color')}
|
|
className={`w-7 h-7 flex items-center justify-center rounded hover:bg-neutral-100 relative ${activeDropdown === 'color' ? 'bg-neutral-100' : ''}`}
|
|
title="Text Color"
|
|
>
|
|
<Palette className="w-4 h-4 text-neutral-700" />
|
|
<div className="absolute bottom-1 right-1 w-2 h-2 rounded-full border border-neutral-300" style={{ backgroundColor: props.color }} />
|
|
</button>
|
|
{activeDropdown === 'color' && (
|
|
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-1 p-2 bg-white border border-neutral-200 shadow-xl rounded z-[60] flex flex-col gap-2">
|
|
<div className="grid grid-cols-3 gap-1">
|
|
{COLORS.map(c => (
|
|
<button
|
|
key={c}
|
|
onClick={() => { applyStyle('fill', c, 'color'); setActiveDropdown(null); }}
|
|
className={`w-6 h-6 rounded-full border ${props.color === c ? 'border-blue-500 scale-110' : 'border-neutral-300 hover:scale-110'}`}
|
|
style={{ backgroundColor: c }}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div className="flex items-center gap-2 border-t pt-2 mt-1">
|
|
<span className="text-[10px] text-neutral-500 uppercase tracking-wider">Custom</span>
|
|
<input
|
|
type="color"
|
|
value={props.color}
|
|
onChange={(e) => applyStyle('fill', e.target.value, 'color')}
|
|
className="w-5 h-5 rounded cursor-pointer border-0 p-0"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Highlight Color Dropdown */}
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => setActiveDropdown(activeDropdown === 'highlight' ? null : 'highlight')}
|
|
className={`w-7 h-7 flex items-center justify-center rounded hover:bg-neutral-100 relative ${activeDropdown === 'highlight' ? 'bg-neutral-100' : ''}`}
|
|
title="Highlight Color"
|
|
>
|
|
<Highlighter className="w-4 h-4 text-neutral-700" />
|
|
<div
|
|
className="absolute bottom-1 right-1 w-2 h-2 rounded-sm border border-neutral-300"
|
|
style={{
|
|
backgroundColor: props.highlightColor || '#ffffff',
|
|
backgroundImage: !props.highlightColor ? 'linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%, #ccc), linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%, #ccc)' : 'none',
|
|
backgroundSize: '2px 2px',
|
|
backgroundPosition: '0 0, 1px 1px'
|
|
}}
|
|
/>
|
|
</button>
|
|
{activeDropdown === 'highlight' && (
|
|
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-1 p-2 bg-white border border-neutral-200 shadow-xl rounded z-[60] flex flex-col gap-2">
|
|
<div className="grid grid-cols-3 gap-1">
|
|
{HIGHLIGHTS.map(c => (
|
|
<button
|
|
key={c}
|
|
onClick={() => { applyStyle('textBackgroundColor', c === 'transparent' ? undefined : c, 'highlightColor'); setActiveDropdown(null); }}
|
|
className={`w-6 h-6 rounded-sm border ${props.highlightColor === c || (c === 'transparent' && !props.highlightColor) ? 'border-blue-500 scale-110' : 'border-neutral-300 hover:scale-110'}`}
|
|
style={{
|
|
backgroundColor: c === 'transparent' ? '#ffffff' : c,
|
|
backgroundImage: c === 'transparent' ? 'linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%, #ccc), linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%, #ccc)' : 'none',
|
|
backgroundSize: '4px 4px',
|
|
backgroundPosition: '0 0, 2px 2px'
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div className="flex items-center gap-2 border-t pt-2 mt-1">
|
|
<span className="text-[10px] text-neutral-500 uppercase tracking-wider">Custom</span>
|
|
<input
|
|
type="color"
|
|
value={props.highlightColor || '#ffffff'}
|
|
onChange={(e) => applyStyle('textBackgroundColor', e.target.value, 'highlightColor')}
|
|
className="w-5 h-5 rounded cursor-pointer border-0 p-0"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="w-px h-4 bg-neutral-200 mx-0.5" />
|
|
|
|
{/* Duplicate */}
|
|
<button
|
|
onClick={handleDuplicate}
|
|
className="w-7 h-7 flex items-center justify-center rounded text-neutral-600 hover:bg-neutral-100 disabled:opacity-50"
|
|
title="Duplicate"
|
|
disabled={isDraft}
|
|
>
|
|
<Copy className="w-4 h-4" />
|
|
</button>
|
|
|
|
{/* Delete */}
|
|
<button
|
|
onClick={handleDelete}
|
|
className="w-7 h-7 flex items-center justify-center rounded text-red-600 hover:bg-red-50"
|
|
title="Delete"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|