Fix zoom resizing, add multi page support, fix text box bugs, fix broken upload button
Some checks failed
Automated Container Build / build-and-push (push) Failing after 3s
CI / Backend (Python) (push) Failing after 11s
CI / Frontend (TypeScript) (push) Failing after 4m49s

This commit is contained in:
Elijah 2026-06-13 12:55:30 -07:00
parent 5b2140187f
commit 7ff5c8130d
3 changed files with 113 additions and 30 deletions

View file

@ -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));
}
}

View file

@ -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 */}
<div className="relative">

View file

@ -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 = '';
}
}}
/>