Phase 3 implementation. Coordinate system implemented. Initial text box tool implemented.
Some checks failed
Automated Container Build / build-and-push (push) Failing after 4s
CI / Backend (Python) (push) Failing after 17s
CI / Frontend (TypeScript) (push) Failing after 4m48s

This commit is contained in:
Elijah 2026-06-12 16:30:07 -07:00
parent c159ad4f37
commit 4cb038ec78
34 changed files with 6676 additions and 130 deletions

View file

@ -0,0 +1,148 @@
import { useEditorStore } from '../store';
import type { TextAnnotation } from '../../../lib/annotations/types';
import type { ViewportParams } from '../../../lib/coords';
import { pdfRectToScreen } from '../../../lib/coords';
import { v4 as uuidv4 } from 'uuid';
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 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 annotation = 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 handleDuplicate = () => {
const newId = uuidv4();
addAnnotation({
...textAnn,
id: newId,
rect: {
...textAnn.rect,
x: textAnn.rect.x + 20,
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.
};
return (
<div
className="absolute z-50 bg-white rounded-md shadow-lg border border-neutral-200 flex items-center p-1 gap-1"
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>
<div className="w-px h-4 bg-neutral-200 mx-1" />
{/* 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>
<div className="w-px h-4 bg-neutral-200 mx-1" />
{/* 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'}`}
title="Bold"
>
B
</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'}`}
title="Italic"
>
I
</button>
<div className="w-px h-4 bg-neutral-200 mx-1" />
{/* 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"
/>
))}
</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'}`}
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"
/>
))}
</div>
<div className="w-px h-4 bg-neutral-200 mx-1" />
{/* Duplicate */}
<button
onClick={handleDuplicate}
className="text-xs px-2 py-1 rounded text-neutral-600 hover:bg-neutral-100 font-medium"
>
Copy
</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"
>
Delete
</button>
</div>
);
}