Phase 3 implementation. Coordinate system implemented. Initial text box tool implemented.
This commit is contained in:
parent
c159ad4f37
commit
4cb038ec78
34 changed files with 6676 additions and 130 deletions
148
frontend/src/features/editor/toolbar/TextFormatToolbar.tsx
Normal file
148
frontend/src/features/editor/toolbar/TextFormatToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue