From 7ff5c8130dcabd11b688cb1d45dd151bfeb49264 Mon Sep 17 00:00:00 2001 From: Elijah Date: Sat, 13 Jun 2026 12:55:30 -0700 Subject: [PATCH] Fix zoom resizing, add multi page support, fix text box bugs, fix broken upload button --- .../editor/canvas/AnnotationLayer.tsx | 46 +++++++---- .../editor/toolbar/TextFormatToolbar.tsx | 77 ++++++++++++++++--- .../src/features/library/LibraryHeader.tsx | 20 +++-- 3 files changed, 113 insertions(+), 30 deletions(-) diff --git a/frontend/src/features/editor/canvas/AnnotationLayer.tsx b/frontend/src/features/editor/canvas/AnnotationLayer.tsx index 34b8729..5bca331 100644 --- a/frontend/src/features/editor/canvas/AnnotationLayer.tsx +++ b/frontend/src/features/editor/canvas/AnnotationLayer.tsx @@ -21,6 +21,11 @@ export function AnnotationLayer({ pageNumber, width, height, viewportParams }: A const annotations = useEditorStore(state => state.annotations); + const latestViewportParams = useRef(viewportParams); + useEffect(() => { + latestViewportParams.current = viewportParams; + }, [viewportParams]); + // Initial setup and event binding useEffect(() => { if (!canvasRef.current) return; @@ -40,14 +45,15 @@ export function AnnotationLayer({ pageNumber, width, height, viewportParams }: A // Find existing annotation const ann = useEditorStore.getState().annotations.find(a => a.id === obj.id); if (ann) { - const pt = screenToPdf({ x: obj.left, y: obj.top }, viewportParams); + const currentVp = latestViewportParams.current; + const pt = screenToPdf({ x: obj.left, y: obj.top }, currentVp); const scaleX = obj.scaleX || 1; const scaleY = obj.scaleY || 1; // Box width/height in PDF space - const newWidth = (obj.width * scaleX) / (viewportParams.scale * (viewportParams.dpr || 1)); - const newHeight = (obj.height * scaleY) / (viewportParams.scale * (viewportParams.dpr || 1)); + const newWidth = (obj.width * scaleX) / (currentVp.scale * (currentVp.dpr || 1)); + const newHeight = (obj.height * scaleY) / (currentVp.scale * (currentVp.dpr || 1)); useEditorStore.getState().updateAnnotation(obj.id, { rect: { ...ann.rect, x: pt.x, y: pt.y, width: newWidth, height: newHeight } @@ -106,25 +112,30 @@ export function AnnotationLayer({ pageNumber, width, height, viewportParams }: A }; }, []); // Run once on mount + const prevScaleRef = useRef(viewportParams.scale); + // Handle dimensions and re-rendering annotations useEffect(() => { if (!fabricRef.current) return; const canvas = fabricRef.current; canvas.setDimensions({ width, height }); - // Simple naive sync: clear and re-render everything const activeObj = canvas.getActiveObject() as any; const activeId = activeObj?.id; const pageAnns = annotations.filter(a => a.page === pageNumber); const annIds = new Set(pageAnns.map(a => a.id)); - // Remove objects that are no longer in annotations + const scaleChanged = prevScaleRef.current !== viewportParams.scale; + prevScaleRef.current = viewportParams.scale; + + // Remove objects that are no longer in annotations, OR if the zoom scale changed canvas.getObjects().forEach((obj: any) => { - // Don't remove the active object unless it was deleted from the store - if (obj.id === activeId && annIds.has(activeId)) return; + // Don't remove the active object unless it was deleted from the store, + // UNLESS the scale changed (we must recreate everything on zoom) + if (!scaleChanged && obj.id === activeId && annIds.has(activeId)) return; - if (!annIds.has(obj.id)) { + if (scaleChanged || !annIds.has(obj.id)) { canvas.remove(obj); } }); @@ -132,7 +143,7 @@ export function AnnotationLayer({ pageNumber, width, height, viewportParams }: A // Add missing annotations const canvasObjIds = new Set(canvas.getObjects().map((o: any) => o.id)); pageAnns.forEach(ann => { - if (ann.id === activeId) return; // Skip rendering the active object + if (!scaleChanged && ann.id === activeId) return; // Skip rendering the active object if (!canvasObjIds.has(ann.id)) { const tool = getTool(ann.type); @@ -141,7 +152,14 @@ export function AnnotationLayer({ pageNumber, width, height, viewportParams }: A } } }); - }, [width, height, annotations, pageNumber]); // We ignore viewportParams identity for now to avoid loops + + if (scaleChanged && activeId && annIds.has(activeId)) { + const newlyRenderedActiveObj = canvas.getObjects().find((o: any) => o.id === activeId); + if (newlyRenderedActiveObj) { + canvas.setActiveObject(newlyRenderedActiveObj); + } + } + }, [width, height, annotations, pageNumber, viewportParams.scale]); // Handle tool activation/deactivation and event binding useEffect(() => { @@ -162,16 +180,16 @@ export function AnnotationLayer({ pageNumber, width, height, viewportParams }: A if (tool.onActivate) tool.onActivate(canvas); if (tool.onPointerDown) { - canvas.on('mouse:down', (e) => tool.onPointerDown!(e, canvas, viewportParams, pageNumber)); + canvas.on('mouse:down', (e) => tool.onPointerDown!(e, canvas, latestViewportParams.current, pageNumber)); } if (tool.onPointerMove) { - canvas.on('mouse:move', (e) => tool.onPointerMove!(e, canvas, viewportParams, pageNumber)); + canvas.on('mouse:move', (e) => tool.onPointerMove!(e, canvas, latestViewportParams.current, pageNumber)); } if (tool.onPointerUp) { - canvas.on('mouse:up', (e) => tool.onPointerUp!(e, canvas, viewportParams, pageNumber)); + canvas.on('mouse:up', (e) => tool.onPointerUp!(e, canvas, latestViewportParams.current, pageNumber)); } if (tool.onPathCreated) { - canvas.on('path:created', (e) => tool.onPathCreated!(e, canvas, viewportParams, pageNumber)); + canvas.on('path:created', (e) => tool.onPathCreated!(e, canvas, latestViewportParams.current, pageNumber)); } } diff --git a/frontend/src/features/editor/toolbar/TextFormatToolbar.tsx b/frontend/src/features/editor/toolbar/TextFormatToolbar.tsx index 075127b..bfdaf27 100644 --- a/frontend/src/features/editor/toolbar/TextFormatToolbar.tsx +++ b/frontend/src/features/editor/toolbar/TextFormatToolbar.tsx @@ -53,21 +53,71 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo 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) { - if (activeObj.selectionStart !== activeObj.selectionEnd) { - activeObj.setSelectionStyles({ [styleName]: value }); - didInline = true; - } else { - activeObj.set(styleName, value); + const isStructural = styleName === 'fontSize' || styleName === 'fontFamily'; + + if (isStructural) { + // Structural properties MUST be applied to the base object. Fabric 7's bounding box calculations + // frequently fail when inline styles are used for size/font. + activeObj.set(styleName, value); + + // Obliterate any inline styles for this property so the base property strictly applies to all text + if (activeObj.styles) { + for (const line in activeObj.styles) { + for (const char in activeObj.styles[line]) { + if (activeObj.styles[line][char]) { + delete activeObj.styles[line][char][styleName]; + } + } + } + } + + // If the box is empty and we are currently editing it, Fabric's invisible cursor + // cache will still stubbornly hold the old size unless we violently wipe it. + if (activeObj.isEditing && !activeObj.text) { + activeObj.styles = {}; + if (activeObj.hiddenTextarea) { + if (styleName === 'fontSize') activeObj.hiddenTextarea.style.fontSize = `${value}px`; + if (styleName === 'fontFamily') activeObj.hiddenTextarea.style.fontFamily = value; + } } } else { - activeObj.set(styleName, value); + // Cosmetic properties (bold, italic, color) work fine with inline styles + if (activeObj.isEditing) { + activeObj.setSelectionStyles({ [styleName]: value }); + if (!activeObj.text) { + activeObj.set(styleName, value); + } + } else { + // If NOT editing, they selected the whole box. Update the base property. + activeObj.set(styleName, value); + + // Clear any inline styles for this property so the base property actually takes effect! + if (activeObj.styles) { + for (const line in activeObj.styles) { + for (const char in activeObj.styles[line]) { + if (activeObj.styles[line][char]) { + delete activeObj.styles[line][char][styleName]; + } + } + } + } + } } + + // Critical: Fabric 7 heavily caches text. We MUST mark it dirty to force a redraw! + activeObj.dirty = true; + if ((activeObj as any)._forceClearCache !== undefined) { + (activeObj as any)._forceClearCache = true; + } + + // Remove manual height constraint so the box can grow with the new font size + delete activeObj.customHeight; + if (activeObj.initDimensions) activeObj.initDimensions(); + activeObj.setCoords(); canvas.requestRenderAll(); } } @@ -76,9 +126,10 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo const newProps = { [globalPropName]: finalGlobalValue } as any; setDefaultTextProps(newProps); - if (!didInline && !isDraft) { + // Always update store so the toolbar displays the new value + if (!isDraft) { updateAnnotation(annotationId, { props: { ...props, ...newProps } }); - } else if (!didInline && isDraft) { + } else { useEditorStore.getState().setDraftAnnotation({ ...textAnn, props: { ...props, ...newProps } @@ -120,6 +171,12 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo 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` }} + onMouseDown={(e) => { + // Prevent clicking the toolbar from stealing focus and deselecting the canvas object + if ((e.target as HTMLElement).tagName !== 'INPUT') { + e.preventDefault(); + } + }} > {/* Font Family Dropdown */}
diff --git a/frontend/src/features/library/LibraryHeader.tsx b/frontend/src/features/library/LibraryHeader.tsx index 047fd35..27a503c 100644 --- a/frontend/src/features/library/LibraryHeader.tsx +++ b/frontend/src/features/library/LibraryHeader.tsx @@ -138,12 +138,20 @@ export const LibraryHeader = ({ currentTab, onTabChange }: { currentTab: 'librar accept="application/pdf" multiple className="hidden" - onChange={(e) => { - const uploader = document.querySelector('input[type="file"][multiple]') as HTMLInputElement - if (uploader && uploader !== e.target) { - uploader.files = e.target.files - const event = new Event('change', { bubbles: true }) - uploader.dispatchEvent(event) + onChange={async (e) => { + if (e.target.files && e.target.files.length > 0) { + const files = e.target.files; + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (file.type === 'application/pdf') { + try { + await useLibrary.getState().uploadDocument(file); + } catch (err) { + console.error('Failed to upload', file.name, err); + } + } + } + e.target.value = ''; } }} />