Text box redesign, floating text control bar, and numerous bug fixes
This commit is contained in:
parent
4cb038ec78
commit
bce3c1b420
9 changed files with 433 additions and 133 deletions
|
|
@ -29,6 +29,7 @@ class TextProps(BaseModel):
|
||||||
italic: bool = False
|
italic: bool = False
|
||||||
lineHeight: float = 1.2
|
lineHeight: float = 1.2
|
||||||
highlightColor: Optional[str] = None
|
highlightColor: Optional[str] = None
|
||||||
|
styles: Optional[dict] = None
|
||||||
|
|
||||||
class TextAnnotation(AnnotationBase):
|
class TextAnnotation(AnnotationBase):
|
||||||
type: Literal["text"]
|
type: Literal["text"]
|
||||||
|
|
|
||||||
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
|
|
@ -14,6 +14,7 @@
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
"fabric": "^7.4.0",
|
"fabric": "^7.4.0",
|
||||||
|
"lucide-react": "^1.18.0",
|
||||||
"pdfjs-dist": "^6.0.227",
|
"pdfjs-dist": "^6.0.227",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
|
|
@ -4101,6 +4102,15 @@
|
||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-react": {
|
||||||
|
"version": "1.18.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.18.0.tgz",
|
||||||
|
"integrity": "sha512-LZDb7H/0YfM+RJncD0hDQRCAu+vSGODqpe35TuVI8EuXaRjkczbsx7p8dY4J87F/MUSj6bpYqeI8nw8qXaAdmA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
"fabric": "^7.4.0",
|
"fabric": "^7.4.0",
|
||||||
|
"lucide-react": "^1.18.0",
|
||||||
"pdfjs-dist": "^6.0.227",
|
"pdfjs-dist": "^6.0.227",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ export function AnnotationLayer({ pageNumber, width, height, viewportParams }: A
|
||||||
selection: activeToolName === 'select',
|
selection: activeToolName === 'select',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
(window as any).__fabricCanvas = canvas;
|
||||||
|
|
||||||
// Sync fabric modifications back to Zustand
|
// Sync fabric modifications back to Zustand
|
||||||
canvas.on('object:modified', (e) => {
|
canvas.on('object:modified', (e) => {
|
||||||
const obj = e.target as any;
|
const obj = e.target as any;
|
||||||
|
|
@ -113,22 +115,23 @@ export function AnnotationLayer({ pageNumber, width, height, viewportParams }: A
|
||||||
const activeObj = canvas.getActiveObject() as any;
|
const activeObj = canvas.getActiveObject() as any;
|
||||||
const activeId = activeObj?.id;
|
const activeId = activeObj?.id;
|
||||||
|
|
||||||
canvas.clear();
|
// Remove all objects EXCEPT the active one
|
||||||
|
canvas.getObjects().forEach((obj: any) => {
|
||||||
|
if (obj.id !== activeId) {
|
||||||
|
canvas.remove(obj);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
annotations.forEach(ann => {
|
annotations.forEach(ann => {
|
||||||
// Only render annotations for THIS page
|
// Only render annotations for THIS page
|
||||||
if (ann.page === pageNumber) {
|
if (ann.page === pageNumber) {
|
||||||
|
if (ann.id === activeId) return; // Skip rendering the active object
|
||||||
const tool = getTool(ann.type);
|
const tool = getTool(ann.type);
|
||||||
if (tool && tool.renderToFabric) {
|
if (tool && tool.renderToFabric) {
|
||||||
tool.renderToFabric(ann, canvas, viewportParams);
|
tool.renderToFabric(ann, canvas, viewportParams);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (activeId) {
|
|
||||||
const newActive = canvas.getObjects().find((o: any) => o.id === activeId);
|
|
||||||
if (newActive) canvas.setActiveObject(newActive);
|
|
||||||
}
|
|
||||||
}, [width, height, annotations, pageNumber]); // We ignore viewportParams identity for now to avoid loops
|
}, [width, height, annotations, pageNumber]); // We ignore viewportParams identity for now to avoid loops
|
||||||
|
|
||||||
// Handle tool activation/deactivation and event binding
|
// Handle tool activation/deactivation and event binding
|
||||||
|
|
@ -168,13 +171,16 @@ export function AnnotationLayer({ pageNumber, width, height, viewportParams }: A
|
||||||
}, [activeToolName]);
|
}, [activeToolName]);
|
||||||
|
|
||||||
const selection = useEditorStore(state => state.selection);
|
const selection = useEditorStore(state => state.selection);
|
||||||
|
const draftAnnotation = useEditorStore(state => state.draftAnnotation);
|
||||||
const isSelectedOnThisPage = annotations.find(a => a.id === selection && a.page === pageNumber);
|
const isSelectedOnThisPage = annotations.find(a => a.id === selection && a.page === pageNumber);
|
||||||
|
const isDraftOnThisPage = draftAnnotation?.page === pageNumber;
|
||||||
|
const toolbarAnnotationId = isDraftOnThisPage ? draftAnnotation.id : (isSelectedOnThisPage ? selection : null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute top-0 left-0" style={{ width, height }}>
|
<div className="absolute top-0 left-0" style={{ width, height }}>
|
||||||
<canvas ref={canvasRef} />
|
<canvas ref={canvasRef} />
|
||||||
{isSelectedOnThisPage && (
|
{toolbarAnnotationId && (
|
||||||
<TextFormatToolbar annotationId={selection!} viewportParams={viewportParams} />
|
<TextFormatToolbar annotationId={toolbarAnnotationId} viewportParams={viewportParams} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { Annotation } from '../../lib/annotations/types';
|
import type { Annotation, TextProps } from '../../lib/annotations/types';
|
||||||
|
|
||||||
export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
|
export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
|
||||||
|
|
||||||
|
|
@ -10,6 +10,8 @@ interface EditorState {
|
||||||
selection: string | null;
|
selection: string | null;
|
||||||
zoom: number;
|
zoom: number;
|
||||||
saveStatus: SaveStatus;
|
saveStatus: SaveStatus;
|
||||||
|
defaultTextProps: Partial<TextProps>;
|
||||||
|
draftAnnotation: Annotation | null;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
setDocumentId: (id: string | null) => void;
|
setDocumentId: (id: string | null) => void;
|
||||||
|
|
@ -21,6 +23,8 @@ interface EditorState {
|
||||||
setSelection: (id: string | null) => void;
|
setSelection: (id: string | null) => void;
|
||||||
setZoom: (zoom: number) => void;
|
setZoom: (zoom: number) => void;
|
||||||
setSaveStatus: (status: SaveStatus) => void;
|
setSaveStatus: (status: SaveStatus) => void;
|
||||||
|
setDefaultTextProps: (props: Partial<TextProps>) => void;
|
||||||
|
setDraftAnnotation: (ann: Annotation | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useEditorStore = create<EditorState>((set) => ({
|
export const useEditorStore = create<EditorState>((set) => ({
|
||||||
|
|
@ -30,8 +34,10 @@ export const useEditorStore = create<EditorState>((set) => ({
|
||||||
selection: null,
|
selection: null,
|
||||||
zoom: 1,
|
zoom: 1,
|
||||||
saveStatus: 'idle',
|
saveStatus: 'idle',
|
||||||
|
defaultTextProps: {},
|
||||||
|
draftAnnotation: null,
|
||||||
|
|
||||||
setDocumentId: (id) => set({ documentId: id }),
|
setDocumentId: (id) => set({ documentId: id, defaultTextProps: {}, draftAnnotation: null }),
|
||||||
setAnnotations: (annotations) => set({ annotations }),
|
setAnnotations: (annotations) => set({ annotations }),
|
||||||
addAnnotation: (annotation) => set((state) => ({
|
addAnnotation: (annotation) => set((state) => ({
|
||||||
annotations: [...state.annotations, annotation]
|
annotations: [...state.annotations, annotation]
|
||||||
|
|
@ -47,4 +53,6 @@ export const useEditorStore = create<EditorState>((set) => ({
|
||||||
setSelection: (id) => set({ selection: id }),
|
setSelection: (id) => set({ selection: id }),
|
||||||
setZoom: (zoom) => set({ zoom }),
|
setZoom: (zoom) => set({ zoom }),
|
||||||
setSaveStatus: (status) => set({ saveStatus: status }),
|
setSaveStatus: (status) => set({ saveStatus: status }),
|
||||||
|
setDefaultTextProps: (props) => set((state) => ({ defaultTextProps: { ...state.defaultTextProps, ...props } })),
|
||||||
|
setDraftAnnotation: (ann) => set({ draftAnnotation: ann }),
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,89 @@
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { useEditorStore } from '../store';
|
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 type { ViewportParams } from '../../../lib/coords';
|
||||||
import { pdfRectToScreen } from '../../../lib/coords';
|
import { pdfRectToScreen } from '../../../lib/coords';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import {
|
||||||
|
Palette,
|
||||||
|
Highlighter,
|
||||||
|
Bold,
|
||||||
|
Italic,
|
||||||
|
Copy,
|
||||||
|
Trash2,
|
||||||
|
ChevronDown
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface TextFormatToolbarProps {
|
interface TextFormatToolbarProps {
|
||||||
annotationId: string;
|
annotationId: string;
|
||||||
viewportParams: ViewportParams;
|
viewportParams: ViewportParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FONTS = ['Liberation Sans', 'Outfit', 'Plus Jakarta Sans'];
|
const FONTS = ['Liberation Sans', 'Outfit', 'Plus Jakarta Sans', 'Arial', 'Times New Roman', 'Courier New'];
|
||||||
const SIZES = [8, 10, 12, 14, 16, 18, 24, 36, 48, 72];
|
const SIZES = [6, 7, 8, 10, 12, 14, 16, 18, 24, 36, 48, 72];
|
||||||
const COLORS = ['#000000', '#EF4444', '#3B82F6', '#10B981', '#F59E0B', '#ffffff'];
|
const COLORS = ['#000000', '#EF4444', '#3B82F6', '#10B981', '#F59E0B', '#ffffff'];
|
||||||
const HIGHLIGHTS = ['transparent', '#FEF08A', '#BBF7D0', '#BFDBFE', '#FBCFE8', '#000000'];
|
const HIGHLIGHTS = ['transparent', '#FEF08A', '#BBF7D0', '#BFDBFE', '#FBCFE8', '#000000'];
|
||||||
|
|
||||||
export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatToolbarProps) {
|
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;
|
if (!annotation || annotation.type !== 'text') return null;
|
||||||
|
|
||||||
const textAnn = annotation as TextAnnotation;
|
const textAnn = annotation as TextAnnotation;
|
||||||
const props = textAnn.props;
|
const props = textAnn.props;
|
||||||
|
|
||||||
// Calculate screen position
|
|
||||||
const pt = pdfRectToScreen(textAnn.rect, viewportParams);
|
const pt = pdfRectToScreen(textAnn.rect, viewportParams);
|
||||||
// Place it just above the text box
|
|
||||||
const top = pt.y - 48; // 48px above
|
const top = pt.y - 48; // 48px above
|
||||||
const left = pt.x;
|
const left = pt.x;
|
||||||
|
|
||||||
const updateProps = (newProps: Partial<TextAnnotation['props']>) => {
|
const applyStyle = (styleName: string, value: any, globalPropName: keyof TextProps, globalValue?: any) => {
|
||||||
updateAnnotation(annotationId, { props: { ...props, ...newProps } });
|
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 = () => {
|
const handleDuplicate = () => {
|
||||||
|
if (isDraft) return; // Can't duplicate draft
|
||||||
const newId = uuidv4();
|
const newId = uuidv4();
|
||||||
addAnnotation({
|
addAnnotation({
|
||||||
...textAnn,
|
...textAnn,
|
||||||
|
|
@ -44,104 +94,202 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo
|
||||||
y: textAnn.rect.y + 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.
|
|
||||||
|
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 (
|
return (
|
||||||
<div
|
<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` }}
|
style={{ top: `${Math.max(0, top)}px`, left: `${left}px` }}
|
||||||
>
|
>
|
||||||
{/* Font Family */}
|
{/* Font Family Dropdown */}
|
||||||
<select
|
<div className="relative">
|
||||||
value={props.fontFamily}
|
<button
|
||||||
onChange={(e) => updateProps({ fontFamily: e.target.value })}
|
onClick={() => setActiveDropdown(activeDropdown === 'font' ? null : 'font')}
|
||||||
className="text-xs bg-neutral-100 hover:bg-neutral-200 rounded px-2 py-1 outline-none cursor-pointer"
|
className={`flex items-center gap-1 text-xs px-2 py-1.5 rounded hover:bg-neutral-100 ${activeDropdown === 'font' ? 'bg-neutral-100' : ''}`}
|
||||||
>
|
>
|
||||||
{FONTS.map(f => <option key={f} value={f}>{f}</option>)}
|
<span className="truncate w-24 text-left">{props.fontFamily}</span>
|
||||||
</select>
|
<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 */}
|
{/* Font Size Dropdown */}
|
||||||
<select
|
<div className="relative">
|
||||||
value={props.fontSize}
|
<button
|
||||||
onChange={(e) => updateProps({ fontSize: Number(e.target.value) })}
|
onClick={() => setActiveDropdown(activeDropdown === 'size' ? null : 'size')}
|
||||||
className="text-xs bg-neutral-100 hover:bg-neutral-200 rounded px-2 py-1 outline-none cursor-pointer"
|
className={`flex items-center gap-1 text-xs px-2 py-1.5 rounded hover:bg-neutral-100 ${activeDropdown === 'size' ? 'bg-neutral-100' : ''}`}
|
||||||
>
|
>
|
||||||
{SIZES.map(s => <option key={s} value={s}>{s}</option>)}
|
<span className="w-4 text-center">{props.fontSize}</span>
|
||||||
</select>
|
<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 */}
|
{/* Bold / Italic */}
|
||||||
<button
|
<button
|
||||||
onClick={() => updateProps({ bold: !props.bold })}
|
onClick={() => applyStyle('fontWeight', props.bold ? 'normal' : 'bold', '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'}`}
|
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"
|
title="Bold"
|
||||||
>
|
>
|
||||||
B
|
<Bold className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => updateProps({ italic: !props.italic })}
|
onClick={() => applyStyle('fontStyle', props.italic ? 'normal' : 'italic', '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'}`}
|
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"
|
title="Italic"
|
||||||
>
|
>
|
||||||
I
|
<Italic className="w-4 h-4" />
|
||||||
</button>
|
</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 */}
|
{/* Text Color Dropdown */}
|
||||||
<div className="flex gap-0.5 items-center">
|
<div className="relative">
|
||||||
{COLORS.map(c => (
|
<button
|
||||||
<button
|
onClick={() => setActiveDropdown(activeDropdown === 'color' ? null : 'color')}
|
||||||
key={c}
|
className={`w-7 h-7 flex items-center justify-center rounded hover:bg-neutral-100 relative ${activeDropdown === 'color' ? 'bg-neutral-100' : ''}`}
|
||||||
onClick={() => updateProps({ color: c })}
|
title="Text Color"
|
||||||
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 }}
|
<Palette className="w-4 h-4 text-neutral-700" />
|
||||||
title="Text Color"
|
<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>
|
||||||
|
|
||||||
<div className="w-px h-4 bg-neutral-200 mx-1" />
|
{/* Highlight Color Dropdown */}
|
||||||
|
<div className="relative">
|
||||||
{/* Highlight Color */}
|
<button
|
||||||
<div className="flex gap-0.5 items-center">
|
onClick={() => setActiveDropdown(activeDropdown === 'highlight' ? null : 'highlight')}
|
||||||
{HIGHLIGHTS.map(c => (
|
className={`w-7 h-7 flex items-center justify-center rounded hover:bg-neutral-100 relative ${activeDropdown === 'highlight' ? 'bg-neutral-100' : ''}`}
|
||||||
<button
|
title="Highlight Color"
|
||||||
key={c}
|
>
|
||||||
onClick={() => updateProps({ highlightColor: c === 'transparent' ? null : c })}
|
<Highlighter className="w-4 h-4 text-neutral-700" />
|
||||||
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'}`}
|
<div
|
||||||
|
className="absolute bottom-1 right-1 w-2 h-2 rounded-sm border border-neutral-300"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: c === 'transparent' ? '#ffffff' : c,
|
backgroundColor: props.highlightColor || '#ffffff',
|
||||||
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',
|
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: '4px 4px',
|
backgroundSize: '2px 2px',
|
||||||
backgroundPosition: '0 0, 2px 2px'
|
backgroundPosition: '0 0, 1px 1px'
|
||||||
}}
|
}}
|
||||||
title="Highlight Color"
|
|
||||||
/>
|
/>
|
||||||
))}
|
</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>
|
||||||
|
|
||||||
<div className="w-px h-4 bg-neutral-200 mx-1" />
|
<div className="w-px h-4 bg-neutral-200 mx-0.5" />
|
||||||
|
|
||||||
{/* Duplicate */}
|
{/* Duplicate */}
|
||||||
<button
|
<button
|
||||||
onClick={handleDuplicate}
|
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>
|
</button>
|
||||||
|
|
||||||
{/* Delete */}
|
{/* Delete */}
|
||||||
<button
|
<button
|
||||||
onClick={() => deleteAnnotation(annotationId)}
|
onClick={handleDelete}
|
||||||
className="text-xs px-2 py-1 rounded text-red-600 hover:bg-red-50 font-medium ml-1"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -24,16 +24,26 @@ export const TextTool: ToolHandler = {
|
||||||
|
|
||||||
// Create the Fabric object first so the user can immediately type
|
// Create the Fabric object first so the user can immediately type
|
||||||
const pointer = e.scenePoint || canvas.getScenePoint(e.e);
|
const pointer = e.scenePoint || canvas.getScenePoint(e.e);
|
||||||
|
// Get default text props from store
|
||||||
|
const storeState = useEditorStore.getState();
|
||||||
|
const defaults = storeState.defaultTextProps;
|
||||||
|
const fontFamily = defaults.fontFamily || 'Liberation Sans';
|
||||||
|
const fontSize = defaults.fontSize || 14;
|
||||||
|
const color = defaults.color || '#000000';
|
||||||
|
const highlightColor = defaults.highlightColor || null;
|
||||||
|
const isBold = defaults.bold || false;
|
||||||
|
const isItalic = defaults.italic || false;
|
||||||
|
|
||||||
const textbox = new fabric.Textbox('', {
|
const textbox = new fabric.Textbox('', {
|
||||||
left: pointer.x,
|
left: pointer.x,
|
||||||
top: pointer.y,
|
top: pointer.y,
|
||||||
width: 150 * vp.scale,
|
width: 150 * vp.scale,
|
||||||
fontFamily: 'Liberation Sans',
|
fontFamily: fontFamily,
|
||||||
fontSize: 14 * vp.scale * (vp.dpr || 1), // scaled loosely for screen display
|
fontSize: fontSize * vp.scale * (vp.dpr || 1),
|
||||||
fontWeight: 'normal',
|
fontWeight: isBold ? 'bold' : 'normal',
|
||||||
fontStyle: 'normal',
|
fontStyle: isItalic ? 'italic' : 'normal',
|
||||||
fill: '#000000',
|
fill: color,
|
||||||
backgroundColor: undefined,
|
textBackgroundColor: highlightColor || undefined,
|
||||||
originX: 'left',
|
originX: 'left',
|
||||||
originY: 'top',
|
originY: 'top',
|
||||||
transparentCorners: false,
|
transparentCorners: false,
|
||||||
|
|
@ -44,77 +54,134 @@ export const TextTool: ToolHandler = {
|
||||||
padding: 5,
|
padding: 5,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Hide all corners and vertical resizers, leaving only middle-left and middle-right for width
|
// Allow manual height control
|
||||||
|
(textbox as any).customHeight = textbox.height;
|
||||||
|
const originalInitDimensions = textbox.initDimensions.bind(textbox);
|
||||||
|
textbox.initDimensions = function() {
|
||||||
|
originalInitDimensions();
|
||||||
|
if ((this as any).customHeight !== undefined) {
|
||||||
|
this.height = (this as any).customHeight;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Enable all handles except rotation
|
||||||
textbox.setControlsVisibility({
|
textbox.setControlsVisibility({
|
||||||
tl: false,
|
mtr: false
|
||||||
tr: false,
|
|
||||||
bl: false,
|
|
||||||
br: false,
|
|
||||||
mt: false,
|
|
||||||
mb: false,
|
|
||||||
mtr: false // hide rotation handle for now
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Make corners resize freely (non-proportionally)
|
||||||
|
if (textbox.controls) {
|
||||||
|
['tl', 'tr', 'bl', 'br'].forEach(corner => {
|
||||||
|
const control = textbox.controls[corner];
|
||||||
|
if (control && control.actionHandler) {
|
||||||
|
const originalAction = control.actionHandler;
|
||||||
|
control.actionHandler = (eventData, transform, x, y) => {
|
||||||
|
const canvas = transform.target.canvas;
|
||||||
|
if (!canvas) return originalAction(eventData, transform, x, y);
|
||||||
|
|
||||||
|
const oldUniform = canvas.uniformScaling;
|
||||||
|
canvas.uniformScaling = false; // Disable proportional scaling for text box corners
|
||||||
|
const result = originalAction(eventData, transform, x, y);
|
||||||
|
canvas.uniformScaling = oldUniform;
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce real-time text wrapping and allow manual sizing
|
||||||
|
textbox.on('scaling', function() {
|
||||||
|
const w = textbox.width! * textbox.scaleX!;
|
||||||
|
const h = textbox.height! * textbox.scaleY!;
|
||||||
|
|
||||||
|
(textbox as any).customHeight = h;
|
||||||
|
|
||||||
|
textbox.set({
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
scaleX: 1,
|
||||||
|
scaleY: 1
|
||||||
|
});
|
||||||
|
textbox.setCoords();
|
||||||
|
});
|
||||||
|
|
||||||
|
const newId = uuidv4();
|
||||||
|
(textbox as any).id = newId;
|
||||||
|
|
||||||
canvas.add(textbox);
|
canvas.add(textbox);
|
||||||
canvas.setActiveObject(textbox);
|
canvas.setActiveObject(textbox);
|
||||||
textbox.enterEditing();
|
textbox.enterEditing();
|
||||||
|
|
||||||
|
// Add to store immediately so the toolbar shows up instantly
|
||||||
|
const pt = screenToPdf({ x: textbox.left!, y: textbox.top! }, vp);
|
||||||
|
const newAnn: TextAnnotation = {
|
||||||
|
id: newId,
|
||||||
|
page: pageNumber,
|
||||||
|
type: 'text',
|
||||||
|
rect: {
|
||||||
|
x: pt.x,
|
||||||
|
y: pt.y,
|
||||||
|
width: textbox.width! / (vp.scale * (vp.dpr || 1)),
|
||||||
|
height: textbox.height! / (vp.scale * (vp.dpr || 1))
|
||||||
|
},
|
||||||
|
rotation: 0,
|
||||||
|
z: 0,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
props: {
|
||||||
|
text: '',
|
||||||
|
fontFamily: fontFamily,
|
||||||
|
fontSize: fontSize,
|
||||||
|
color: color,
|
||||||
|
align: 'left',
|
||||||
|
bold: isBold,
|
||||||
|
italic: isItalic,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
highlightColor: highlightColor
|
||||||
|
}
|
||||||
|
};
|
||||||
|
storeState.setDraftAnnotation(newAnn);
|
||||||
|
|
||||||
textbox.on('editing:exited', () => {
|
textbox.on('editing:exited', () => {
|
||||||
|
// Clear draft
|
||||||
|
useEditorStore.getState().setDraftAnnotation(null);
|
||||||
|
|
||||||
if (!textbox.text || textbox.text.trim() === '') {
|
if (!textbox.text || textbox.text.trim() === '') {
|
||||||
canvas.remove(textbox);
|
canvas.remove(textbox);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pt = screenToPdf({ x: textbox.left!, y: textbox.top! }, vp);
|
// Get fresh annotation in case it was modified (e.g. bold/italic via toolbar)
|
||||||
|
const currentAnn = (useEditorStore.getState().annotations.find(a => a.id === newId) as TextAnnotation) || newAnn;
|
||||||
|
|
||||||
|
// Update the text and push to main store
|
||||||
|
currentAnn.props.text = textbox.text;
|
||||||
|
|
||||||
const newAnn: TextAnnotation = {
|
// Save inline styles if any exist
|
||||||
id: uuidv4(),
|
if ((textbox as any).styles && Object.keys((textbox as any).styles).length > 0) {
|
||||||
page: pageNumber,
|
currentAnn.props.styles = JSON.parse(JSON.stringify((textbox as any).styles));
|
||||||
type: 'text',
|
}
|
||||||
rect: {
|
|
||||||
x: pt.x,
|
|
||||||
y: pt.y,
|
|
||||||
width: textbox.width! / (vp.scale * (vp.dpr || 1)),
|
|
||||||
height: textbox.height! / (vp.scale * (vp.dpr || 1))
|
|
||||||
},
|
|
||||||
rotation: 0,
|
|
||||||
z: 0,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
props: {
|
|
||||||
text: textbox.text,
|
|
||||||
fontFamily: 'Liberation Sans',
|
|
||||||
fontSize: 14,
|
|
||||||
color: '#000000',
|
|
||||||
align: 'left',
|
|
||||||
bold: false,
|
|
||||||
italic: false,
|
|
||||||
lineHeight: 1.2,
|
|
||||||
highlightColor: null
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// We attach the ID to the fabric object so we can link it
|
useEditorStore.getState().addAnnotation(currentAnn);
|
||||||
(textbox as any).id = newAnn.id;
|
|
||||||
|
|
||||||
useEditorStore.getState().addAnnotation(newAnn);
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
renderToFabric: (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => {
|
renderToFabric: (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => {
|
||||||
const textAnn = annotation as TextAnnotation;
|
const textAnn = annotation as TextAnnotation;
|
||||||
const pt = pdfRectToScreen(textAnn.rect, vp);
|
const pt = pdfRectToScreen(textAnn.rect, vp);
|
||||||
|
const boxHeight = textAnn.rect.height * vp.scale * (vp.dpr || 1);
|
||||||
|
|
||||||
const textbox = new fabric.Textbox(textAnn.props.text, {
|
const textbox = new fabric.Textbox(textAnn.props.text, {
|
||||||
left: pt.x,
|
left: pt.x,
|
||||||
top: pt.y,
|
top: pt.y,
|
||||||
width: textAnn.rect.width * vp.scale * (vp.dpr || 1),
|
width: textAnn.rect.width * vp.scale * (vp.dpr || 1),
|
||||||
|
height: boxHeight,
|
||||||
fontFamily: textAnn.props.fontFamily,
|
fontFamily: textAnn.props.fontFamily,
|
||||||
fontSize: textAnn.props.fontSize * vp.scale * (vp.dpr || 1),
|
fontSize: textAnn.props.fontSize * vp.scale * (vp.dpr || 1),
|
||||||
fontWeight: textAnn.props.bold ? 'bold' : 'normal',
|
fontWeight: textAnn.props.bold ? 'bold' : 'normal',
|
||||||
fontStyle: textAnn.props.italic ? 'italic' : 'normal',
|
fontStyle: textAnn.props.italic ? 'italic' : 'normal',
|
||||||
fill: textAnn.props.color,
|
fill: textAnn.props.color,
|
||||||
backgroundColor: textAnn.props.highlightColor || undefined,
|
textBackgroundColor: textAnn.props.highlightColor || undefined,
|
||||||
originX: 'left',
|
originX: 'left',
|
||||||
originY: 'top',
|
originY: 'top',
|
||||||
transparentCorners: false,
|
transparentCorners: false,
|
||||||
|
|
@ -123,17 +190,72 @@ export const TextTool: ToolHandler = {
|
||||||
borderColor: '#3b82f6',
|
borderColor: '#3b82f6',
|
||||||
cornerSize: 8,
|
cornerSize: 8,
|
||||||
padding: 5,
|
padding: 5,
|
||||||
|
styles: textAnn.props.styles ? JSON.parse(JSON.stringify(textAnn.props.styles)) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Allow manual height control
|
||||||
|
(textbox as any).customHeight = boxHeight;
|
||||||
|
const originalInitDimensions = textbox.initDimensions.bind(textbox);
|
||||||
|
textbox.initDimensions = function() {
|
||||||
|
originalInitDimensions();
|
||||||
|
if ((this as any).customHeight !== undefined) {
|
||||||
|
this.height = (this as any).customHeight;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Enable all handles except rotation
|
||||||
textbox.setControlsVisibility({
|
textbox.setControlsVisibility({
|
||||||
tl: false,
|
|
||||||
tr: false,
|
|
||||||
bl: false,
|
|
||||||
br: false,
|
|
||||||
mt: false,
|
|
||||||
mb: false,
|
|
||||||
mtr: false
|
mtr: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Make corners resize freely (non-proportionally)
|
||||||
|
if (textbox.controls) {
|
||||||
|
['tl', 'tr', 'bl', 'br'].forEach(corner => {
|
||||||
|
const control = textbox.controls[corner];
|
||||||
|
if (control && control.actionHandler) {
|
||||||
|
const originalAction = control.actionHandler;
|
||||||
|
control.actionHandler = (eventData, transform, x, y) => {
|
||||||
|
const canvas = transform.target.canvas;
|
||||||
|
if (!canvas) return originalAction(eventData, transform, x, y);
|
||||||
|
|
||||||
|
const oldUniform = canvas.uniformScaling;
|
||||||
|
canvas.uniformScaling = false; // Disable proportional scaling for text box corners
|
||||||
|
const result = originalAction(eventData, transform, x, y);
|
||||||
|
canvas.uniformScaling = oldUniform;
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
textbox.on('scaling', function() {
|
||||||
|
const w = textbox.width! * textbox.scaleX!;
|
||||||
|
const h = textbox.height! * textbox.scaleY!;
|
||||||
|
|
||||||
|
(textbox as any).customHeight = h;
|
||||||
|
|
||||||
|
textbox.set({
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
scaleX: 1,
|
||||||
|
scaleY: 1
|
||||||
|
});
|
||||||
|
textbox.setCoords();
|
||||||
|
});
|
||||||
|
|
||||||
|
textbox.on('editing:exited', () => {
|
||||||
|
// Fetch fresh annotation to avoid stale closure
|
||||||
|
const currentAnn = useEditorStore.getState().annotations.find(a => a.id === annotation.id) as TextAnnotation | undefined;
|
||||||
|
if (!currentAnn) return;
|
||||||
|
|
||||||
|
const updates: any = { text: textbox.text };
|
||||||
|
if ((textbox as any).styles && Object.keys((textbox as any).styles).length > 0) {
|
||||||
|
updates.styles = JSON.parse(JSON.stringify((textbox as any).styles));
|
||||||
|
}
|
||||||
|
useEditorStore.getState().updateAnnotation(annotation.id, {
|
||||||
|
props: { ...currentAnn.props, ...updates }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
(textbox as any).id = annotation.id;
|
(textbox as any).id = annotation.id;
|
||||||
canvas.add(textbox);
|
canvas.add(textbox);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { components } from '../../types/api';
|
import type { components } from '../../types/api';
|
||||||
|
|
||||||
export type TextProps = components['schemas']['TextProps'] & { highlightColor?: string | null };
|
export type TextProps = components['schemas']['TextProps'] & { highlightColor?: string | null, styles?: Record<string, any> | null };
|
||||||
|
|
||||||
export type TextAnnotation = Omit<components['schemas']['TextAnnotation'], 'props'> & {
|
export type TextAnnotation = Omit<components['schemas']['TextAnnotation'], 'props'> & {
|
||||||
props: TextProps;
|
props: TextProps;
|
||||||
|
|
|
||||||
4
frontend/src/types/api.d.ts
vendored
4
frontend/src/types/api.d.ts
vendored
|
|
@ -816,6 +816,10 @@ export interface components {
|
||||||
lineHeight: number;
|
lineHeight: number;
|
||||||
/** Highlightcolor */
|
/** Highlightcolor */
|
||||||
highlightColor?: string | null;
|
highlightColor?: string | null;
|
||||||
|
/** Styles */
|
||||||
|
styles?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
} | null;
|
||||||
};
|
};
|
||||||
/** ValidationError */
|
/** ValidationError */
|
||||||
ValidationError: {
|
ValidationError: {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue