Text box redesign, floating text control bar, and numerous bug fixes
Some checks failed
Automated Container Build / build-and-push (push) Failing after 3s
CI / Backend (Python) (push) Failing after 16s
CI / Frontend (TypeScript) (push) Failing after 4m50s

This commit is contained in:
Elijah 2026-06-12 20:02:19 -07:00
parent 4cb038ec78
commit bce3c1b420
9 changed files with 433 additions and 133 deletions

View file

@ -1,39 +1,89 @@
import { useState, useRef, useEffect } from 'react';
import { useEditorStore } from '../store';
import type { TextAnnotation } from '../../../lib/annotations/types';
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;
}
const FONTS = ['Liberation Sans', 'Outfit', 'Plus Jakarta Sans'];
const SIZES = [8, 10, 12, 14, 16, 18, 24, 36, 48, 72];
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 }: TextFormatToolbarProps) {
const { annotations, updateAnnotation, deleteAnnotation, addAnnotation } = useEditorStore();
const { annotations, updateAnnotation, deleteAnnotation, addAnnotation, setDefaultTextProps, draftAnnotation } = useEditorStore();
const annotation = annotations.find(a => a.id === annotationId);
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;
// Calculate screen position
const pt = pdfRectToScreen(textAnn.rect, viewportParams);
// Place it just above the text box
const top = pt.y - 48; // 48px above
const left = pt.x;
const updateProps = (newProps: Partial<TextAnnotation['props']>) => {
updateAnnotation(annotationId, { props: { ...props, ...newProps } });
const applyStyle = (styleName: string, value: any, globalPropName: keyof TextProps, globalValue?: any) => {
const canvas = (window as any).__fabricCanvas as any;
let didInline = false;
if (canvas) {
const activeObj = canvas.getActiveObject();
if (activeObj && activeObj.id === annotationId) {
if (activeObj.isEditing) {
activeObj.setSelectionStyles({ [styleName]: value });
didInline = true;
} else {
activeObj.set(styleName, value);
}
canvas.requestRenderAll();
}
}
const finalGlobalValue = globalValue !== undefined ? globalValue : value;
const newProps = { [globalPropName]: finalGlobalValue } as any;
setDefaultTextProps(newProps);
if (!didInline && !isDraft) {
updateAnnotation(annotationId, { props: { ...props, ...newProps } });
} else if (!didInline && isDraft) {
useEditorStore.getState().setDraftAnnotation({
...textAnn,
props: { ...props, ...newProps }
});
}
};
const handleDuplicate = () => {
if (isDraft) return; // Can't duplicate draft
const newId = uuidv4();
addAnnotation({
...textAnn,
@ -44,104 +94,202 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo
y: textAnn.rect.y + 20
}
});
// Setting selection to new annotation requires it to be mounted, which is fine,
// but we can just leave selection on the old one for now.
};
const handleDelete = () => {
if (isDraft) {
useEditorStore.getState().setDraftAnnotation(null);
const canvas = (window as any).__fabricCanvas as any;
if (canvas) {
const activeObj = canvas.getActiveObject();
if (activeObj && activeObj.id === annotationId) {
canvas.remove(activeObj);
}
}
} else {
deleteAnnotation(annotationId);
}
};
return (
<div
className="absolute z-50 bg-white rounded-md shadow-lg border border-neutral-200 flex items-center p-1 gap-1"
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` }}
>
{/* Font Family */}
<select
value={props.fontFamily}
onChange={(e) => updateProps({ fontFamily: e.target.value })}
className="text-xs bg-neutral-100 hover:bg-neutral-200 rounded px-2 py-1 outline-none cursor-pointer"
>
{FONTS.map(f => <option key={f} value={f}>{f}</option>)}
</select>
{/* 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-1" />
<div className="w-px h-4 bg-neutral-200 mx-0.5" />
{/* Font Size */}
<select
value={props.fontSize}
onChange={(e) => updateProps({ fontSize: Number(e.target.value) })}
className="text-xs bg-neutral-100 hover:bg-neutral-200 rounded px-2 py-1 outline-none cursor-pointer"
>
{SIZES.map(s => <option key={s} value={s}>{s}</option>)}
</select>
{/* 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 * (viewportParams.dpr || 1), 'fontSize', s); setActiveDropdown(null); }}
>
{s}
</button>
))}
</div>
)}
</div>
<div className="w-px h-4 bg-neutral-200 mx-1" />
<div className="w-px h-4 bg-neutral-200 mx-0.5" />
{/* Bold / Italic */}
<button
onClick={() => updateProps({ bold: !props.bold })}
className={`w-6 h-6 flex items-center justify-center rounded text-sm font-bold ${props.bold ? 'bg-blue-100 text-blue-700' : 'hover:bg-neutral-100 text-neutral-700'}`}
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"
>
B
<Bold className="w-4 h-4" />
</button>
<button
onClick={() => updateProps({ italic: !props.italic })}
className={`w-6 h-6 flex items-center justify-center rounded text-sm italic font-serif ${props.italic ? 'bg-blue-100 text-blue-700' : 'hover:bg-neutral-100 text-neutral-700'}`}
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"
>
I
<Italic className="w-4 h-4" />
</button>
<div className="w-px h-4 bg-neutral-200 mx-1" />
<div className="w-px h-4 bg-neutral-200 mx-0.5" />
{/* Text Color */}
<div className="flex gap-0.5 items-center">
{COLORS.map(c => (
<button
key={c}
onClick={() => updateProps({ color: c })}
className={`w-4 h-4 rounded-full border ${props.color === c ? 'border-blue-500 scale-110' : 'border-neutral-300 hover:scale-110'}`}
style={{ backgroundColor: c }}
title="Text Color"
/>
))}
{/* 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>
<div className="w-px h-4 bg-neutral-200 mx-1" />
{/* Highlight Color */}
<div className="flex gap-0.5 items-center">
{HIGHLIGHTS.map(c => (
<button
key={c}
onClick={() => updateProps({ highlightColor: c === 'transparent' ? null : c })}
className={`w-4 h-4 rounded-sm border ${props.highlightColor === c || (c === 'transparent' && !props.highlightColor) ? 'border-blue-500 scale-110' : 'border-neutral-300 hover:scale-110'}`}
{/* 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: 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'
}}
title="Highlight Color"
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-1" />
<div className="w-px h-4 bg-neutral-200 mx-0.5" />
{/* Duplicate */}
<button
onClick={handleDuplicate}
className="text-xs px-2 py-1 rounded text-neutral-600 hover:bg-neutral-100 font-medium"
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
<Copy className="w-4 h-4" />
</button>
{/* Delete */}
<button
onClick={() => deleteAnnotation(annotationId)}
className="text-xs px-2 py-1 rounded text-red-600 hover:bg-red-50 font-medium ml-1"
onClick={handleDelete}
className="w-7 h-7 flex items-center justify-center rounded text-red-600 hover:bg-red-50"
title="Delete"
>
Delete
<Trash2 className="w-4 h-4" />
</button>
</div>
);