From bce3c1b42071be77c1554929cecf745fe7dcea64 Mon Sep 17 00:00:00 2001 From: Elijah Date: Fri, 12 Jun 2026 20:02:19 -0700 Subject: [PATCH] Text box redesign, floating text control bar, and numerous bug fixes --- backend/app/schemas/annotations.py | 1 + frontend/package-lock.json | 10 + frontend/package.json | 1 + .../editor/canvas/AnnotationLayer.tsx | 22 +- frontend/src/features/editor/store.ts | 12 +- .../editor/toolbar/TextFormatToolbar.tsx | 288 +++++++++++++----- .../src/features/editor/tools/TextTool.ts | 226 ++++++++++---- frontend/src/lib/annotations/types.ts | 2 +- frontend/src/types/api.d.ts | 4 + 9 files changed, 433 insertions(+), 133 deletions(-) diff --git a/backend/app/schemas/annotations.py b/backend/app/schemas/annotations.py index 577e459..2321105 100644 --- a/backend/app/schemas/annotations.py +++ b/backend/app/schemas/annotations.py @@ -29,6 +29,7 @@ class TextProps(BaseModel): italic: bool = False lineHeight: float = 1.2 highlightColor: Optional[str] = None + styles: Optional[dict] = None class TextAnnotation(AnnotationBase): type: Literal["text"] diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0671781..ef49a01 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "@types/uuid": "^10.0.0", "date-fns": "^4.4.0", "fabric": "^7.4.0", + "lucide-react": "^1.18.0", "pdfjs-dist": "^6.0.227", "react": "^19.2.6", "react-dom": "^19.2.6", @@ -4101,6 +4102,15 @@ "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": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", diff --git a/frontend/package.json b/frontend/package.json index 366c312..8265c09 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ "@types/uuid": "^10.0.0", "date-fns": "^4.4.0", "fabric": "^7.4.0", + "lucide-react": "^1.18.0", "pdfjs-dist": "^6.0.227", "react": "^19.2.6", "react-dom": "^19.2.6", diff --git a/frontend/src/features/editor/canvas/AnnotationLayer.tsx b/frontend/src/features/editor/canvas/AnnotationLayer.tsx index 2f47e87..5fff558 100644 --- a/frontend/src/features/editor/canvas/AnnotationLayer.tsx +++ b/frontend/src/features/editor/canvas/AnnotationLayer.tsx @@ -31,6 +31,8 @@ export function AnnotationLayer({ pageNumber, width, height, viewportParams }: A selection: activeToolName === 'select', }); + (window as any).__fabricCanvas = canvas; + // Sync fabric modifications back to Zustand canvas.on('object:modified', (e) => { 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 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 => { // Only render annotations for THIS page if (ann.page === pageNumber) { + if (ann.id === activeId) return; // Skip rendering the active object const tool = getTool(ann.type); if (tool && tool.renderToFabric) { 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 // Handle tool activation/deactivation and event binding @@ -168,13 +171,16 @@ export function AnnotationLayer({ pageNumber, width, height, viewportParams }: A }, [activeToolName]); const selection = useEditorStore(state => state.selection); + const draftAnnotation = useEditorStore(state => state.draftAnnotation); 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 (
- {isSelectedOnThisPage && ( - + {toolbarAnnotationId && ( + )}
); diff --git a/frontend/src/features/editor/store.ts b/frontend/src/features/editor/store.ts index 664dfe3..060eb4c 100644 --- a/frontend/src/features/editor/store.ts +++ b/frontend/src/features/editor/store.ts @@ -1,5 +1,5 @@ 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'; @@ -10,6 +10,8 @@ interface EditorState { selection: string | null; zoom: number; saveStatus: SaveStatus; + defaultTextProps: Partial; + draftAnnotation: Annotation | null; // Actions setDocumentId: (id: string | null) => void; @@ -21,6 +23,8 @@ interface EditorState { setSelection: (id: string | null) => void; setZoom: (zoom: number) => void; setSaveStatus: (status: SaveStatus) => void; + setDefaultTextProps: (props: Partial) => void; + setDraftAnnotation: (ann: Annotation | null) => void; } export const useEditorStore = create((set) => ({ @@ -30,8 +34,10 @@ export const useEditorStore = create((set) => ({ selection: null, zoom: 1, saveStatus: 'idle', + defaultTextProps: {}, + draftAnnotation: null, - setDocumentId: (id) => set({ documentId: id }), + setDocumentId: (id) => set({ documentId: id, defaultTextProps: {}, draftAnnotation: null }), setAnnotations: (annotations) => set({ annotations }), addAnnotation: (annotation) => set((state) => ({ annotations: [...state.annotations, annotation] @@ -47,4 +53,6 @@ export const useEditorStore = create((set) => ({ setSelection: (id) => set({ selection: id }), setZoom: (zoom) => set({ zoom }), setSaveStatus: (status) => set({ saveStatus: status }), + setDefaultTextProps: (props) => set((state) => ({ defaultTextProps: { ...state.defaultTextProps, ...props } })), + setDraftAnnotation: (ann) => set({ draftAnnotation: ann }), })); diff --git a/frontend/src/features/editor/toolbar/TextFormatToolbar.tsx b/frontend/src/features/editor/toolbar/TextFormatToolbar.tsx index 8681cb3..e5f6385 100644 --- a/frontend/src/features/editor/toolbar/TextFormatToolbar.tsx +++ b/frontend/src/features/editor/toolbar/TextFormatToolbar.tsx @@ -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(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) => { - 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 (
- {/* Font Family */} - + {/* Font Family Dropdown */} +
+ + {activeDropdown === 'font' && ( +
+ {FONTS.map(f => ( + + ))} +
+ )} +
-
+
- {/* Font Size */} - + {/* Font Size Dropdown */} +
+ + {activeDropdown === 'size' && ( +
+ {SIZES.map(s => ( + + ))} +
+ )} +
-
+
{/* Bold / Italic */} -
+
- {/* Text Color */} -
- {COLORS.map(c => ( - + {activeDropdown === 'color' && ( +
+
+ {COLORS.map(c => ( +
+
+ Custom + applyStyle('fill', e.target.value, 'color')} + className="w-5 h-5 rounded cursor-pointer border-0 p-0" + /> +
+
+ )}
-
- - {/* Highlight Color */} -
- {HIGHLIGHTS.map(c => ( - + {activeDropdown === 'highlight' && ( +
+
+ {HIGHLIGHTS.map(c => ( +
+
+ Custom + applyStyle('textBackgroundColor', e.target.value, 'highlightColor')} + className="w-5 h-5 rounded cursor-pointer border-0 p-0" + /> +
+
+ )}
-
+
{/* Duplicate */} {/* Delete */}
); diff --git a/frontend/src/features/editor/tools/TextTool.ts b/frontend/src/features/editor/tools/TextTool.ts index 4c54c23..a5acf4e 100644 --- a/frontend/src/features/editor/tools/TextTool.ts +++ b/frontend/src/features/editor/tools/TextTool.ts @@ -24,16 +24,26 @@ export const TextTool: ToolHandler = { // Create the Fabric object first so the user can immediately type 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('', { left: pointer.x, top: pointer.y, width: 150 * vp.scale, - fontFamily: 'Liberation Sans', - fontSize: 14 * vp.scale * (vp.dpr || 1), // scaled loosely for screen display - fontWeight: 'normal', - fontStyle: 'normal', - fill: '#000000', - backgroundColor: undefined, + fontFamily: fontFamily, + fontSize: fontSize * vp.scale * (vp.dpr || 1), + fontWeight: isBold ? 'bold' : 'normal', + fontStyle: isItalic ? 'italic' : 'normal', + fill: color, + textBackgroundColor: highlightColor || undefined, originX: 'left', originY: 'top', transparentCorners: false, @@ -44,77 +54,134 @@ export const TextTool: ToolHandler = { 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({ - tl: false, - tr: false, - bl: false, - br: false, - mt: false, - mb: false, - mtr: false // hide rotation handle for now + 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; + }; + } + }); + } + + // 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.setActiveObject(textbox); 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', () => { + // Clear draft + useEditorStore.getState().setDraftAnnotation(null); + if (!textbox.text || textbox.text.trim() === '') { canvas.remove(textbox); 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 = { - id: uuidv4(), - 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: textbox.text, - fontFamily: 'Liberation Sans', - fontSize: 14, - color: '#000000', - align: 'left', - bold: false, - italic: false, - lineHeight: 1.2, - highlightColor: null - } - }; + // Save inline styles if any exist + if ((textbox as any).styles && Object.keys((textbox as any).styles).length > 0) { + currentAnn.props.styles = JSON.parse(JSON.stringify((textbox as any).styles)); + } - // We attach the ID to the fabric object so we can link it - (textbox as any).id = newAnn.id; - - useEditorStore.getState().addAnnotation(newAnn); + useEditorStore.getState().addAnnotation(currentAnn); }); }, renderToFabric: (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => { const textAnn = annotation as TextAnnotation; const pt = pdfRectToScreen(textAnn.rect, vp); + const boxHeight = textAnn.rect.height * vp.scale * (vp.dpr || 1); const textbox = new fabric.Textbox(textAnn.props.text, { left: pt.x, top: pt.y, width: textAnn.rect.width * vp.scale * (vp.dpr || 1), + height: boxHeight, fontFamily: textAnn.props.fontFamily, fontSize: textAnn.props.fontSize * vp.scale * (vp.dpr || 1), fontWeight: textAnn.props.bold ? 'bold' : 'normal', fontStyle: textAnn.props.italic ? 'italic' : 'normal', fill: textAnn.props.color, - backgroundColor: textAnn.props.highlightColor || undefined, + textBackgroundColor: textAnn.props.highlightColor || undefined, originX: 'left', originY: 'top', transparentCorners: false, @@ -123,17 +190,72 @@ export const TextTool: ToolHandler = { borderColor: '#3b82f6', cornerSize: 8, 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({ - tl: false, - tr: false, - bl: false, - br: false, - mt: false, - mb: 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; canvas.add(textbox); diff --git a/frontend/src/lib/annotations/types.ts b/frontend/src/lib/annotations/types.ts index 4559b82..723cbf9 100644 --- a/frontend/src/lib/annotations/types.ts +++ b/frontend/src/lib/annotations/types.ts @@ -1,6 +1,6 @@ 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 | null }; export type TextAnnotation = Omit & { props: TextProps; diff --git a/frontend/src/types/api.d.ts b/frontend/src/types/api.d.ts index 49716a4..2f33baf 100644 --- a/frontend/src/types/api.d.ts +++ b/frontend/src/types/api.d.ts @@ -816,6 +816,10 @@ export interface components { lineHeight: number; /** Highlightcolor */ highlightColor?: string | null; + /** Styles */ + styles?: { + [key: string]: unknown; + } | null; }; /** ValidationError */ ValidationError: {