Fix zoom resizing, add multi page support, fix text box bugs, fix broken upload button
This commit is contained in:
parent
5b2140187f
commit
7ff5c8130d
3 changed files with 113 additions and 30 deletions
|
|
@ -21,6 +21,11 @@ export function AnnotationLayer({ pageNumber, width, height, viewportParams }: A
|
||||||
|
|
||||||
const annotations = useEditorStore(state => state.annotations);
|
const annotations = useEditorStore(state => state.annotations);
|
||||||
|
|
||||||
|
const latestViewportParams = useRef(viewportParams);
|
||||||
|
useEffect(() => {
|
||||||
|
latestViewportParams.current = viewportParams;
|
||||||
|
}, [viewportParams]);
|
||||||
|
|
||||||
// Initial setup and event binding
|
// Initial setup and event binding
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canvasRef.current) return;
|
if (!canvasRef.current) return;
|
||||||
|
|
@ -40,14 +45,15 @@ export function AnnotationLayer({ pageNumber, width, height, viewportParams }: A
|
||||||
// Find existing annotation
|
// Find existing annotation
|
||||||
const ann = useEditorStore.getState().annotations.find(a => a.id === obj.id);
|
const ann = useEditorStore.getState().annotations.find(a => a.id === obj.id);
|
||||||
if (ann) {
|
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 scaleX = obj.scaleX || 1;
|
||||||
const scaleY = obj.scaleY || 1;
|
const scaleY = obj.scaleY || 1;
|
||||||
|
|
||||||
// Box width/height in PDF space
|
// Box width/height in PDF space
|
||||||
const newWidth = (obj.width * scaleX) / (viewportParams.scale * (viewportParams.dpr || 1));
|
const newWidth = (obj.width * scaleX) / (currentVp.scale * (currentVp.dpr || 1));
|
||||||
const newHeight = (obj.height * scaleY) / (viewportParams.scale * (viewportParams.dpr || 1));
|
const newHeight = (obj.height * scaleY) / (currentVp.scale * (currentVp.dpr || 1));
|
||||||
|
|
||||||
useEditorStore.getState().updateAnnotation(obj.id, {
|
useEditorStore.getState().updateAnnotation(obj.id, {
|
||||||
rect: { ...ann.rect, x: pt.x, y: pt.y, width: newWidth, height: newHeight }
|
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
|
}, []); // Run once on mount
|
||||||
|
|
||||||
|
const prevScaleRef = useRef(viewportParams.scale);
|
||||||
|
|
||||||
// Handle dimensions and re-rendering annotations
|
// Handle dimensions and re-rendering annotations
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!fabricRef.current) return;
|
if (!fabricRef.current) return;
|
||||||
const canvas = fabricRef.current;
|
const canvas = fabricRef.current;
|
||||||
canvas.setDimensions({ width, height });
|
canvas.setDimensions({ width, height });
|
||||||
|
|
||||||
// Simple naive sync: clear and re-render everything
|
|
||||||
const activeObj = canvas.getActiveObject() as any;
|
const activeObj = canvas.getActiveObject() as any;
|
||||||
const activeId = activeObj?.id;
|
const activeId = activeObj?.id;
|
||||||
|
|
||||||
const pageAnns = annotations.filter(a => a.page === pageNumber);
|
const pageAnns = annotations.filter(a => a.page === pageNumber);
|
||||||
const annIds = new Set(pageAnns.map(a => a.id));
|
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) => {
|
canvas.getObjects().forEach((obj: any) => {
|
||||||
// Don't remove the active object unless it was deleted from the store
|
// Don't remove the active object unless it was deleted from the store,
|
||||||
if (obj.id === activeId && annIds.has(activeId)) return;
|
// 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);
|
canvas.remove(obj);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -132,7 +143,7 @@ export function AnnotationLayer({ pageNumber, width, height, viewportParams }: A
|
||||||
// Add missing annotations
|
// Add missing annotations
|
||||||
const canvasObjIds = new Set(canvas.getObjects().map((o: any) => o.id));
|
const canvasObjIds = new Set(canvas.getObjects().map((o: any) => o.id));
|
||||||
pageAnns.forEach(ann => {
|
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)) {
|
if (!canvasObjIds.has(ann.id)) {
|
||||||
const tool = getTool(ann.type);
|
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
|
// Handle tool activation/deactivation and event binding
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -162,16 +180,16 @@ export function AnnotationLayer({ pageNumber, width, height, viewportParams }: A
|
||||||
if (tool.onActivate) tool.onActivate(canvas);
|
if (tool.onActivate) tool.onActivate(canvas);
|
||||||
|
|
||||||
if (tool.onPointerDown) {
|
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) {
|
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) {
|
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) {
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,21 +53,71 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo
|
||||||
|
|
||||||
const applyStyle = (styleName: string, value: any, globalPropName: keyof TextProps, globalValue?: any) => {
|
const applyStyle = (styleName: string, value: any, globalPropName: keyof TextProps, globalValue?: any) => {
|
||||||
const canvas = (window as any).__fabricCanvas as any;
|
const canvas = (window as any).__fabricCanvas as any;
|
||||||
let didInline = false;
|
|
||||||
|
|
||||||
if (canvas) {
|
if (canvas) {
|
||||||
const activeObj = canvas.getActiveObject();
|
const activeObj = canvas.getActiveObject();
|
||||||
if (activeObj && activeObj.id === annotationId) {
|
if (activeObj && activeObj.id === annotationId) {
|
||||||
if (activeObj.isEditing) {
|
const isStructural = styleName === 'fontSize' || styleName === 'fontFamily';
|
||||||
if (activeObj.selectionStart !== activeObj.selectionEnd) {
|
|
||||||
activeObj.setSelectionStyles({ [styleName]: value });
|
if (isStructural) {
|
||||||
didInline = true;
|
// Structural properties MUST be applied to the base object. Fabric 7's bounding box calculations
|
||||||
} else {
|
// frequently fail when inline styles are used for size/font.
|
||||||
activeObj.set(styleName, value);
|
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 {
|
} 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();
|
canvas.requestRenderAll();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -76,9 +126,10 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo
|
||||||
const newProps = { [globalPropName]: finalGlobalValue } as any;
|
const newProps = { [globalPropName]: finalGlobalValue } as any;
|
||||||
setDefaultTextProps(newProps);
|
setDefaultTextProps(newProps);
|
||||||
|
|
||||||
if (!didInline && !isDraft) {
|
// Always update store so the toolbar displays the new value
|
||||||
|
if (!isDraft) {
|
||||||
updateAnnotation(annotationId, { props: { ...props, ...newProps } });
|
updateAnnotation(annotationId, { props: { ...props, ...newProps } });
|
||||||
} else if (!didInline && isDraft) {
|
} else {
|
||||||
useEditorStore.getState().setDraftAnnotation({
|
useEditorStore.getState().setDraftAnnotation({
|
||||||
...textAnn,
|
...textAnn,
|
||||||
props: { ...props, ...newProps }
|
props: { ...props, ...newProps }
|
||||||
|
|
@ -120,6 +171,12 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo
|
||||||
ref={toolbarRef}
|
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"
|
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` }}
|
||||||
|
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 */}
|
{/* Font Family Dropdown */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|
|
||||||
|
|
@ -138,12 +138,20 @@ export const LibraryHeader = ({ currentTab, onTabChange }: { currentTab: 'librar
|
||||||
accept="application/pdf"
|
accept="application/pdf"
|
||||||
multiple
|
multiple
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => {
|
onChange={async (e) => {
|
||||||
const uploader = document.querySelector('input[type="file"][multiple]') as HTMLInputElement
|
if (e.target.files && e.target.files.length > 0) {
|
||||||
if (uploader && uploader !== e.target) {
|
const files = e.target.files;
|
||||||
uploader.files = e.target.files
|
for (let i = 0; i < files.length; i++) {
|
||||||
const event = new Event('change', { bubbles: true })
|
const file = files[i];
|
||||||
uploader.dispatchEvent(event)
|
if (file.type === 'application/pdf') {
|
||||||
|
try {
|
||||||
|
await useLibrary.getState().uploadDocument(file);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to upload', file.name, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.target.value = '';
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue