Rescue Paperjet v1 implementation

This commit is contained in:
Elijah 2026-08-14 21:05:22 -07:00
parent 7ff5c8130d
commit db9e2ca51b
83 changed files with 6186 additions and 3894 deletions

View file

@ -1,9 +1,9 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import * as fabric from 'fabric';
import { useEditorStore } from '../store';
import { getTool } from '../../../lib/annotations/registry';
import type { ViewportParams } from '../../../lib/coords';
import { screenToPdf } from '../../../lib/coords';
import { screenRectToPdf } from '../../../lib/coords';
import { TextFormatToolbar } from '../toolbar/TextFormatToolbar';
interface AnnotationLayerProps {
@ -13,208 +13,201 @@ interface AnnotationLayerProps {
viewportParams: ViewportParams;
}
type AnnotationObject = fabric.FabricObject & { id?: string };
export function AnnotationLayer({ pageNumber, width, height, viewportParams }: AnnotationLayerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const fabricRef = useRef<fabric.Canvas | null>(null);
const activeToolName = useEditorStore(state => state.activeTool);
const annotations = useEditorStore(state => state.annotations);
const renderingIds = useRef(new Set<string>());
const renderGeneration = useRef(0);
const latestViewportParams = useRef(viewportParams);
const previousDimensions = useRef({ width, height });
const [canvasInstance, setCanvasInstance] = useState<fabric.Canvas | null>(null);
const activeToolName = useEditorStore((state) => state.activeTool);
const annotations = useEditorStore((state) => state.annotations);
const selection = useEditorStore((state) => state.selection);
const draftAnnotation = useEditorStore((state) => state.draftAnnotation);
useEffect(() => {
latestViewportParams.current = viewportParams;
}, [viewportParams]);
// Initial setup and event binding
useEffect(() => {
if (!canvasRef.current) return;
const pendingRenderIds = renderingIds.current;
const canvas = new fabric.Canvas(canvasRef.current, {
width,
height,
selection: activeToolName === 'select',
enableRetinaScaling: true,
});
(window as any).__fabricCanvas = canvas;
// Sync fabric modifications back to Zustand
canvas.on('object:modified', (e) => {
const obj = e.target as any;
if (obj && obj.id) {
// Find existing annotation
const ann = useEditorStore.getState().annotations.find(a => a.id === obj.id);
if (ann) {
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) / (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 }
});
}
}
fabricRef.current = canvas;
setCanvasInstance(canvas);
canvas.on('object:modified', (event) => {
const object = event.target as AnnotationObject | undefined;
if (!object?.id) return;
const annotation = useEditorStore.getState().annotations.find((item) => item.id === object.id);
if (!annotation) return;
const bounds = object.getBoundingRect();
useEditorStore.getState().updateAnnotation(object.id, {
rect: screenRectToPdf(
{ x: bounds.left, y: bounds.top, width: bounds.width, height: bounds.height },
latestViewportParams.current,
),
});
});
const updateSelection = (_e?: any) => {
const activeObj = canvas.getActiveObject() as any;
if (activeObj && activeObj.id) {
useEditorStore.getState().setSelection(activeObj.id);
} else {
useEditorStore.getState().setSelection(null);
}
const updateSelection = () => {
const activeObject = canvas.getActiveObject() as AnnotationObject | undefined;
useEditorStore.getState().setSelection(activeObject?.id ?? null);
};
canvas.on('selection:created', updateSelection);
canvas.on('selection:updated', updateSelection);
canvas.on('selection:cleared', updateSelection);
// Keyboard shortcut for delete
const handleKeyDown = (e: KeyboardEvent) => {
// Don't intercept if user is typing in an input field outside of canvas
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
}
if (e.key === 'Delete' || e.key === 'Backspace') {
const activeObj = canvas.getActiveObject() as any;
if (activeObj && activeObj.isEditing === true) {
return;
}
const activeObjects = canvas.getActiveObjects();
if (activeObjects && activeObjects.length > 0) {
activeObjects.forEach((obj: any) => {
if (obj.id) {
useEditorStore.getState().deleteAnnotation(obj.id);
}
});
canvas.discardActiveObject();
canvas.requestRenderAll();
e.preventDefault();
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) return;
if (event.key !== 'Delete' && event.key !== 'Backspace') return;
const activeObject = canvas.getActiveObject() as AnnotationObject | undefined;
if (activeObject && 'isEditing' in activeObject && activeObject.isEditing) return;
const activeObjects = canvas.getActiveObjects() as AnnotationObject[];
if (!activeObjects.length) return;
for (const object of activeObjects) {
if (object.id) useEditorStore.getState().deleteAnnotation(object.id);
}
canvas.discardActiveObject();
canvas.requestRenderAll();
event.preventDefault();
};
document.addEventListener('keydown', handleKeyDown);
fabricRef.current = canvas;
return () => {
document.removeEventListener('keydown', handleKeyDown);
pendingRenderIds.clear();
renderGeneration.current += 1;
canvas.dispose();
fabricRef.current = null;
setCanvasInstance(null);
};
}, []); // Run once on mount
// The canvas is created once for this page. Dimension and tool changes are
// handled by the effects below.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const prevScaleRef = useRef(viewportParams.scale);
// Handle dimensions and re-rendering annotations
useEffect(() => {
if (!fabricRef.current) return;
const canvas = fabricRef.current;
canvas.setDimensions({ width, height });
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));
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,
// UNLESS the scale changed (we must recreate everything on zoom)
if (!scaleChanged && obj.id === activeId && annIds.has(activeId)) return;
if (scaleChanged || !annIds.has(obj.id)) {
canvas.remove(obj);
}
});
// Add missing annotations
const canvasObjIds = new Set(canvas.getObjects().map((o: any) => o.id));
pageAnns.forEach(ann => {
if (!scaleChanged && ann.id === activeId) return; // Skip rendering the active object
if (!canvasObjIds.has(ann.id)) {
const tool = getTool(ann.type);
if (tool && tool.renderToFabric) {
tool.renderToFabric(ann, canvas, viewportParams);
}
}
});
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(() => {
const canvas = fabricRef.current;
if (!canvas) return;
const generation = ++renderGeneration.current;
renderingIds.current.clear();
canvas.setDimensions({ width, height });
// First deactivate any previous tool logic
// This is a bit tricky if we don't remember the previous tool,
// but we can just clear event listeners.
canvas.off('mouse:down');
canvas.off('mouse:move');
canvas.off('mouse:up');
canvas.selection = activeToolName === 'select';
const pageAnnotations = annotations.filter((annotation) => annotation.page === pageNumber);
const annotationIds = new Set(pageAnnotations.map((annotation) => annotation.id));
const activeObject = canvas.getActiveObject() as AnnotationObject | undefined;
const activeId = activeObject?.id;
const scaleChanged = previousDimensions.current.width !== width || previousDimensions.current.height !== height;
previousDimensions.current = { width, height };
const tool = getTool(activeToolName);
if (tool) {
if (tool.onActivate) tool.onActivate(canvas);
if (tool.onPointerDown) {
canvas.on('mouse:down', (e) => tool.onPointerDown!(e, canvas, latestViewportParams.current, pageNumber));
}
if (tool.onPointerMove) {
canvas.on('mouse:move', (e) => tool.onPointerMove!(e, canvas, latestViewportParams.current, pageNumber));
}
if (tool.onPointerUp) {
canvas.on('mouse:up', (e) => tool.onPointerUp!(e, canvas, latestViewportParams.current, pageNumber));
}
if (tool.onPathCreated) {
canvas.on('path:created', (e) => tool.onPathCreated!(e, canvas, latestViewportParams.current, pageNumber));
for (const object of canvas.getObjects() as AnnotationObject[]) {
if (!object.id || !annotationIds.has(object.id) || scaleChanged) {
canvas.remove(object);
}
}
return () => {
if (tool && tool.onDeactivate) {
tool.onDeactivate(canvas);
}
};
}, [activeToolName]);
for (const annotation of pageAnnotations) {
if (annotation.id === activeId && !scaleChanged) continue;
if (canvas.getObjects().some((object) => (object as AnnotationObject).id === annotation.id)) continue;
if (renderingIds.current.has(annotation.id)) continue;
const tool = getTool(annotation.type);
if (!tool?.renderToFabric) continue;
renderingIds.current.add(annotation.id);
const objectsBeforeRender = new Set(canvas.getObjects());
void Promise.resolve()
.then(() => tool.renderToFabric?.(annotation, canvas, viewportParams))
.then(() => {
const addedObjects = canvas.getObjects().filter(
(object) => !objectsBeforeRender.has(object) && (object as AnnotationObject).id === annotation.id,
);
if (generation !== renderGeneration.current) {
for (const object of addedObjects) canvas.remove(object);
return;
}
if (activeId) {
const renderedActive = canvas.getObjects().find(
(object) => (object as AnnotationObject).id === activeId,
);
if (renderedActive) canvas.setActiveObject(renderedActive);
}
canvas.requestRenderAll();
})
.catch((error: unknown) => {
console.error(`Failed to render ${annotation.type} annotation`, error);
})
.finally(() => {
renderingIds.current.delete(annotation.id);
});
}
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 activeAnn = isDraftOnThisPage ? draftAnnotation : isSelectedOnThisPage;
const toolbarAnnotationId = activeAnn ? activeAnn.id : null;
if (activeId) {
const renderedActive = canvas.getObjects().find(
(object) => (object as AnnotationObject).id === activeId,
);
if (renderedActive) canvas.setActiveObject(renderedActive);
}
canvas.requestRenderAll();
}, [annotations, height, pageNumber, viewportParams, width]);
useEffect(() => {
const canvas = fabricRef.current;
if (!canvas) return;
canvas.off('mouse:down');
canvas.off('mouse:move');
canvas.off('mouse:up');
canvas.off('path:created');
canvas.selection = activeToolName === 'select';
const tool = getTool(activeToolName);
if (!tool) return;
tool.onActivate?.(canvas);
if (tool.onPointerDown) {
canvas.on('mouse:down', (event) =>
tool.onPointerDown?.(event, canvas, latestViewportParams.current, pageNumber),
);
}
if (tool.onPointerMove) {
canvas.on('mouse:move', (event) =>
tool.onPointerMove?.(event, canvas, latestViewportParams.current, pageNumber),
);
}
if (tool.onPointerUp) {
canvas.on('mouse:up', (event) =>
tool.onPointerUp?.(event, canvas, latestViewportParams.current, pageNumber),
);
}
if (tool.onPathCreated) {
canvas.on('path:created', (event) =>
tool.onPathCreated?.(event, canvas, latestViewportParams.current, pageNumber),
);
}
return () => tool.onDeactivate?.(canvas);
}, [activeToolName, pageNumber]);
const activeAnnotation =
draftAnnotation?.page === pageNumber
? draftAnnotation
: annotations.find((annotation) => annotation.id === selection && annotation.page === pageNumber);
return (
<div className="absolute top-0 left-0" style={{ width, height }}>
<div className="absolute left-0 top-0" style={{ width, height }}>
<canvas ref={canvasRef} />
{toolbarAnnotationId && activeAnn && (
<>
{activeAnn.type === 'text' && <TextFormatToolbar annotationId={toolbarAnnotationId} viewportParams={viewportParams} />}
{/* We will add ShapeControls and DrawControls here once they are implemented */}
</>
{activeAnnotation?.type === 'text' && canvasInstance && (
<TextFormatToolbar
annotationId={activeAnnotation.id}
viewportParams={viewportParams}
canvas={canvasInstance}
/>
)}
</div>
);

View file

@ -1,87 +1,127 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist';
import { AnnotationLayer } from '../canvas/AnnotationLayer';
interface PageRendererProps {
pdfDoc: PDFDocumentProxy;
pageNumber: number;
pageIndex: number;
scale: number;
dpr: number;
}
export function PageRenderer({ pdfDoc, pageNumber, scale, dpr }: PageRendererProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
export function PageRenderer({ pdfDoc, pageIndex, scale, dpr }: PageRendererProps) {
const [page, setPage] = useState<PDFPageProxy | null>(null);
useEffect(() => {
let active = true;
pdfDoc.getPage(pageNumber).then(p => {
if (active) setPage(p);
const pageNumber = pageIndex + 1;
pdfDoc.getPage(pageNumber).then((nextPage) => {
if (active) setPage(nextPage);
});
return () => { active = false; };
}, [pdfDoc, pageNumber]);
useEffect(() => {
if (!page || !canvasRef.current) return;
const viewport = page.getViewport({ scale: scale * dpr });
const canvas = canvasRef.current;
const context = canvas.getContext('2d');
if (!context) return;
canvas.width = viewport.width;
canvas.height = viewport.height;
canvas.style.width = `${viewport.width / dpr}px`;
canvas.style.height = `${viewport.height / dpr}px`;
const renderContext = {
canvas: canvas,
viewport: viewport,
};
let renderTask = page.render(renderContext);
return () => {
renderTask.cancel();
active = false;
};
}, [page, scale, dpr]);
}, [pdfDoc, pageIndex]);
if (!page) {
return (
<div
className="bg-white shadow-sm flex items-center justify-center text-neutral-400 border border-neutral-200"
<div
className="flex items-center justify-center border border-neutral-200 bg-white text-neutral-400 shadow-sm"
style={{ width: 612 * scale, height: 792 * scale }}
>
Loading page {pageNumber}...
Loading page {pageIndex + 1}...
</div>
);
}
const baseViewport = page ? page.getViewport({ scale: 1, rotation: 0 }) : null;
const canonicalViewport = page.getViewport({ scale: 1, rotation: 0 });
const renderedViewport = page.getViewport({
scale: scale * dpr,
rotation: page.rotate,
});
const cssWidth = renderedViewport.width / dpr;
const cssHeight = renderedViewport.height / dpr;
return (
<div className="relative shadow-xl bg-white border border-neutral-200 group">
<canvas ref={canvasRef} className="block" />
{baseViewport && (
<AnnotationLayer
pageNumber={pageNumber}
width={baseViewport.width * scale}
height={baseViewport.height * scale}
viewportParams={{
scale,
rotation: page.rotate,
canonicalWidth: baseViewport.width,
canonicalHeight: baseViewport.height,
dpr
}}
/>
)}
<div className="absolute -left-10 top-2 text-xs font-bold text-neutral-400 bg-white/80 rounded-md px-1.5 py-0.5 shadow-sm border border-neutral-200">
{pageNumber}
<RenderedPage
page={page}
pageIndex={pageIndex}
scale={scale}
dpr={dpr}
canonicalWidth={canonicalViewport.width}
canonicalHeight={canonicalViewport.height}
renderedWidth={renderedViewport.width}
renderedHeight={renderedViewport.height}
cssWidth={cssWidth}
cssHeight={cssHeight}
/>
);
}
interface RenderedPageProps {
page: PDFPageProxy;
pageIndex: number;
scale: number;
dpr: number;
canonicalWidth: number;
canonicalHeight: number;
renderedWidth: number;
renderedHeight: number;
cssWidth: number;
cssHeight: number;
}
function RenderedPage({
page,
pageIndex,
scale,
dpr,
canonicalWidth,
canonicalHeight,
renderedWidth,
renderedHeight,
cssWidth,
cssHeight,
}: RenderedPageProps) {
const viewportParams = useMemo(() => ({
scale,
rotation: page.rotate,
canonicalWidth,
canonicalHeight,
dpr,
}), [canonicalHeight, canonicalWidth, dpr, page.rotate, scale]);
useEffect(() => {
const canvas = document.querySelector<HTMLCanvasElement>(
`[data-paperjet-page="${pageIndex}"]`,
);
if (!canvas) return;
const context = canvas.getContext('2d');
if (!context) return;
canvas.width = renderedWidth;
canvas.height = renderedHeight;
canvas.style.width = `${cssWidth}px`;
canvas.style.height = `${cssHeight}px`;
const renderTask = page.render({ canvas, viewport: page.getViewport({ scale: scale * dpr, rotation: page.rotate }) });
renderTask.promise.catch(() => undefined);
return () => {
renderTask.cancel();
};
}, [page, pageIndex, scale, dpr, renderedWidth, renderedHeight, cssWidth, cssHeight]);
return (
<div className="group relative border border-neutral-200 bg-white shadow-xl" style={{ width: cssWidth, height: cssHeight }}>
<canvas data-paperjet-page={pageIndex} className="block" />
<AnnotationLayer
pageNumber={pageIndex}
width={cssWidth}
height={cssHeight}
viewportParams={viewportParams}
/>
<div className="absolute -left-10 top-2 rounded-md border border-neutral-200 bg-white/80 px-1.5 py-0.5 text-xs font-bold text-neutral-400 shadow-sm">
{pageIndex + 1}
</div>
</div>
);

View file

@ -8,12 +8,13 @@ interface PageStackProps {
export function PageStack({ pdfDoc }: PageStackProps) {
const numPages = pdfDoc.numPages;
const pages = Array.from({ length: numPages }, (_, i) => i + 1);
const pages = Array.from({ length: numPages }, (_, i) => i);
const zoom = useEditorStore(state => state.zoom);
// For now, render all pages vertically. Virtualization comes in Phase 7.
const scale = zoom * 2.0;
// PDF points map directly to CSS pixels at 100%. Device pixel ratio is
// applied only to the PDF.js backing canvas, not the annotation overlay.
const scale = zoom;
const dpr = window.devicePixelRatio || 1;
return (
@ -22,7 +23,7 @@ export function PageStack({ pdfDoc }: PageStackProps) {
<PageRenderer
key={pageNum}
pdfDoc={pdfDoc}
pageNumber={pageNum}
pageIndex={pageNum}
scale={scale}
dpr={dpr}
/>

View file

@ -4,50 +4,61 @@ import { PageStack } from './PageStack';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.mjs',
import.meta.url
import.meta.url,
).toString();
interface LoadedDocument {
id: string;
document: pdfjsLib.PDFDocumentProxy;
}
interface DocumentError {
id: string;
message: string;
}
export function PdfDocument({ documentId }: { documentId: string }) {
const [pdfDoc, setPdfDoc] = useState<pdfjsLib.PDFDocumentProxy | null>(null);
const [error, setError] = useState<string | null>(null);
const [loaded, setLoaded] = useState<LoadedDocument | null>(null);
const [error, setError] = useState<DocumentError | null>(null);
useEffect(() => {
let active = true;
const url = `/api/v1/documents/${documentId}/file`;
const loadingTask = pdfjsLib.getDocument({ url });
loadingTask.promise.then((doc) => {
if (active) setPdfDoc(doc);
}).catch(err => {
console.error('Failed to load PDF', err);
if (active) setError(err.message);
const loadingTask = pdfjsLib.getDocument({ url: `/api/v1/documents/${documentId}/file` });
loadingTask.promise.then((document) => {
if (active) setLoaded({ id: documentId, document });
}).catch((reason: unknown) => {
if (active) {
const message = reason instanceof Error ? reason.message : 'Unable to load this PDF.';
setError({ id: documentId, message });
}
});
return () => {
active = false;
loadingTask.destroy();
void loadingTask.destroy();
};
}, [documentId]);
if (error) {
const currentError = error?.id === documentId ? error.message : null;
const currentDocument = loaded?.id === documentId ? loaded.document : null;
if (currentError) {
return (
<div className="flex items-center justify-center h-full w-full">
<div className="text-red-500 bg-red-50 px-4 py-3 rounded-lg border border-red-200">
Error loading PDF: {error}
</div>
<div className="flex h-full w-full items-center justify-center">
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-red-600">Error loading PDF: {currentError}</div>
</div>
);
}
if (!pdfDoc) {
if (!currentDocument) {
return (
<div className="flex flex-col items-center justify-center h-full w-full gap-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-accent-500"></div>
<div className="text-neutral-500 font-medium animate-pulse">Loading document...</div>
<div className="flex h-full w-full flex-col items-center justify-center gap-4">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-accent-500" />
<div className="font-medium animate-pulse text-neutral-500">Loading document</div>
</div>
);
}
return <PageStack pdfDoc={pdfDoc} />;
return <PageStack pdfDoc={currentDocument} />;
}

View file

@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { useEditorStore } from './store';
import type { TextAnnotation } from '../../lib/annotations/types';
const annotation: TextAnnotation = {
id: 'note-1',
page: 0,
type: 'text',
rect: { x: 10, y: 20, width: 100, height: 30 },
rotation: 0,
z: 0,
props: {
text: 'Original',
fontFamily: 'Liberation Sans',
fontSize: 14,
color: '#000000',
align: 'left',
bold: false,
italic: false,
lineHeight: 1.2,
},
createdAt: '2026-08-14T00:00:00Z',
updatedAt: '2026-08-14T00:00:00Z',
};
beforeEach(() => {
useEditorStore.getState().setDocumentId(null);
});
describe('editor history', () => {
it('undoes and redoes annotation mutations without sharing mutable state', () => {
const store = useEditorStore.getState();
store.setDocumentId('document-1');
store.addAnnotation(annotation);
store.updateAnnotation('note-1', { props: { ...annotation.props, text: 'Updated' } });
expect(useEditorStore.getState().annotations[0].props).toMatchObject({ text: 'Updated' });
useEditorStore.getState().undo();
expect(useEditorStore.getState().annotations[0].props).toMatchObject({ text: 'Original' });
useEditorStore.getState().redo();
expect(useEditorStore.getState().annotations[0].props).toMatchObject({ text: 'Updated' });
});
it('clears working state and history when switching documents', () => {
const store = useEditorStore.getState();
store.setDocumentId('document-1');
store.addAnnotation(annotation);
store.setDocumentId('document-2');
const next = useEditorStore.getState();
expect(next.documentId).toBe('document-2');
expect(next.annotations).toEqual([]);
expect(next.past).toEqual([]);
expect(next.future).toEqual([]);
});
});

View file

@ -1,74 +1,171 @@
import { create } from 'zustand';
import type { Annotation, TextProps } from '../../lib/annotations/types';
import type {
Annotation,
SignatureDrawProps,
SignatureTypeProps,
TextProps,
} from '../../lib/annotations/types';
export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
export type SaveStatus = 'idle' | 'loading' | 'saving' | 'saved' | 'error';
type SignatureProps = SignatureDrawProps | SignatureTypeProps;
type PendingImage = { ref: string; width: number; height: number };
interface EditorState {
documentId: string | null;
annotations: Annotation[];
activeTool: string;
past: Annotation[][];
future: Annotation[][];
selection: string | null;
activeTool: string;
activeShapeKind: 'rect' | 'ellipse' | 'line' | 'arrow';
zoom: number;
saveStatus: SaveStatus;
annotationUpdatedAt: string | null;
defaultTextProps: Partial<TextProps>;
draftAnnotation: Annotation | null;
isSignatureModalOpen: boolean;
pendingSignatureProps: any | null;
pendingImageRef: { ref: string, width: number, height: number } | null;
// Actions
pendingSignatureProps: SignatureProps | null;
pendingImageRef: PendingImage | null;
setDocumentId: (id: string | null) => void;
setAnnotations: (annotations: Annotation[]) => void;
setAnnotationUpdatedAt: (updatedAt: string | null) => void;
addAnnotation: (annotation: Annotation) => void;
updateAnnotation: (id: string, updates: Partial<Annotation>) => void;
deleteAnnotation: (id: string) => void;
undo: () => void;
redo: () => void;
canUndo: () => boolean;
canRedo: () => boolean;
setActiveTool: (tool: string) => void;
setSelection: (id: string | null) => void;
setZoom: (zoom: number) => void;
setSaveStatus: (status: SaveStatus) => void;
setDefaultTextProps: (props: Partial<TextProps>) => void;
setDraftAnnotation: (ann: Annotation | null) => void;
setDraftAnnotation: (annotation: Annotation | null) => void;
setIsSignatureModalOpen: (isOpen: boolean) => void;
setPendingSignatureProps: (props: any | null) => void;
setPendingImageRef: (imgRef: { ref: string, width: number, height: number } | null) => void;
activeShapeKind: 'rect' | 'ellipse' | 'line';
setActiveShapeKind: (kind: 'rect' | 'ellipse' | 'line') => void;
setPendingSignatureProps: (props: SignatureProps | null) => void;
setPendingImageRef: (image: PendingImage | null) => void;
setActiveShapeKind: (kind: EditorState['activeShapeKind']) => void;
}
export const useEditorStore = create<EditorState>((set) => ({
const MAX_HISTORY = 100;
function cloneAnnotations(annotations: Annotation[]): Annotation[] {
return annotations.map((annotation) => ({
...annotation,
rect: { ...annotation.rect },
props: structuredClone(annotation.props),
})) as Annotation[];
}
function withHistory(state: EditorState, annotations: Annotation[]): Partial<EditorState> {
return {
annotations,
past: [...state.past, cloneAnnotations(state.annotations)].slice(-MAX_HISTORY),
future: [],
};
}
export const useEditorStore = create<EditorState>((set, get) => ({
documentId: null,
annotations: [],
activeTool: 'select',
past: [],
future: [],
selection: null,
activeTool: 'select',
activeShapeKind: 'rect',
zoom: 1,
saveStatus: 'idle',
annotationUpdatedAt: null,
defaultTextProps: {},
draftAnnotation: null,
isSignatureModalOpen: false,
pendingSignatureProps: null,
pendingImageRef: null,
activeShapeKind: 'rect',
setDocumentId: (id) => set({ documentId: id, defaultTextProps: {}, draftAnnotation: null }),
setAnnotations: (annotations) => set({ annotations }),
addAnnotation: (annotation) => set((state) => ({
annotations: [...state.annotations, annotation]
})),
updateAnnotation: (id, updates) => set((state) => ({
annotations: state.annotations.map(a => a.id === id ? { ...a, ...updates } as Annotation : a)
})),
deleteAnnotation: (id) => set((state) => ({
annotations: state.annotations.filter(a => a.id !== id),
selection: state.selection === id ? null : state.selection
})),
setDocumentId: (id) =>
set({
documentId: id,
annotations: [],
past: [],
future: [],
selection: null,
saveStatus: id ? 'loading' : 'idle',
annotationUpdatedAt: null,
defaultTextProps: {},
draftAnnotation: null,
pendingSignatureProps: null,
pendingImageRef: null,
}),
setAnnotations: (annotations) =>
set({
annotations: cloneAnnotations(annotations),
past: [],
future: [],
selection: null,
draftAnnotation: null,
}),
setAnnotationUpdatedAt: (updatedAt) => set({ annotationUpdatedAt: updatedAt }),
addAnnotation: (annotation) =>
set((state) => withHistory(state, cloneAnnotations([...state.annotations, annotation]))),
updateAnnotation: (id, updates) =>
set((state) => {
const current = state.annotations.find((annotation) => annotation.id === id);
if (!current) return state;
const next = state.annotations.map((annotation) =>
annotation.id === id
? ({
...annotation,
...updates,
props: updates.props ? { ...annotation.props, ...updates.props } : annotation.props,
updatedAt: new Date().toISOString(),
} as Annotation)
: annotation,
);
return withHistory(state, next);
}),
deleteAnnotation: (id) =>
set((state) => {
if (!state.annotations.some((annotation) => annotation.id === id)) return state;
return {
...withHistory(state, state.annotations.filter((annotation) => annotation.id !== id)),
selection: state.selection === id ? null : state.selection,
};
}),
undo: () =>
set((state) => {
const previous = state.past.at(-1);
if (!previous) return state;
return {
annotations: cloneAnnotations(previous),
past: state.past.slice(0, -1),
future: [cloneAnnotations(state.annotations), ...state.future].slice(0, MAX_HISTORY),
selection: null,
};
}),
redo: () =>
set((state) => {
const next = state.future[0];
if (!next) return state;
return {
annotations: cloneAnnotations(next),
past: [...state.past, cloneAnnotations(state.annotations)].slice(-MAX_HISTORY),
future: state.future.slice(1),
selection: null,
};
}),
canUndo: () => get().past.length > 0,
canRedo: () => get().future.length > 0,
setActiveTool: (tool) => set({ activeTool: tool }),
setSelection: (id) => set({ selection: id }),
setZoom: (zoom) => set({ zoom }),
setZoom: (zoom) => set({ zoom: Math.max(0.5, Math.min(3, zoom)) }),
setSaveStatus: (status) => set({ saveStatus: status }),
setDefaultTextProps: (props) => set((state) => ({ defaultTextProps: { ...state.defaultTextProps, ...props } })),
setDraftAnnotation: (ann) => set({ draftAnnotation: ann }),
setDefaultTextProps: (props) =>
set((state) => ({ defaultTextProps: { ...state.defaultTextProps, ...props } })),
setDraftAnnotation: (annotation) => set({ draftAnnotation: annotation }),
setIsSignatureModalOpen: (isOpen) => set({ isSignatureModalOpen: isOpen }),
setPendingSignatureProps: (props) => set({ pendingSignatureProps: props }),
setPendingImageRef: (imgRef) => set({ pendingImageRef: imgRef }),
setPendingImageRef: (image) => set({ pendingImageRef: image }),
setActiveShapeKind: (kind) => set({ activeShapeKind: kind }),
}));

View file

@ -1,161 +1,162 @@
import { useRef, useState } from 'react';
import { useEditorStore } from '../store';
import { useRef, useState, type ChangeEvent } from 'react';
import {
Download,
History,
Highlighter,
ImagePlus,
Minus,
MousePointer2,
PenLine,
Redo2,
Signature,
Square,
Type,
Undo2,
ZoomIn,
} from 'lucide-react';
import { SignatureModal } from '../tools/SignatureModal';
import { api } from '../../../lib/api/client';
import { useEditorStore } from '../store';
import type { SignatureDrawProps, SignatureTypeProps } from '../../../lib/annotations/types';
export function EditorToolbar() {
const {
activeTool,
setActiveTool,
saveStatus,
zoom,
interface EditorToolbarProps {
onExport: () => void;
onOpenVersions: () => void;
isExporting: boolean;
}
interface AssetUploadResponse {
ref: string;
}
const toolButton = 'inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs font-semibold transition-colors';
const inactive = 'text-neutral-600 hover:bg-neutral-100';
const active = 'bg-accent-100 text-accent-700';
export function EditorToolbar({ onExport, onOpenVersions, isExporting }: EditorToolbarProps) {
const {
activeTool,
setActiveTool,
activeShapeKind,
setActiveShapeKind,
saveStatus,
zoom,
setZoom,
isSignatureModalOpen,
setIsSignatureModalOpen,
setPendingSignatureProps,
setPendingImageRef,
documentId
documentId,
past,
future,
undo,
redo,
} = useEditorStore();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isUploadingImage, setIsUploadingImage] = useState(false);
const handleZoomIn = () => setZoom(Math.min(zoom + 0.25, 3));
const handleZoomOut = () => setZoom(Math.max(zoom - 0.25, 0.5));
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
const handleImageUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file || !documentId) return;
setIsUploadingImage(true);
try {
const formData = new FormData();
formData.append('file', file);
const data = await api.upload<any>(`/documents/${documentId}/assets`, formData);
// We don't have natural width/height immediately, we'll let the tool figure it out or pass placeholders
// A better way is to read image locally to get dimensions, but we can also just let fabric.Image.fromURL do it.
// We'll pass 0 for now and let the tool set scale to max-width.
const data = await api.upload<AssetUploadResponse>(`/documents/${documentId}/assets`, formData);
setPendingImageRef({ ref: data.ref, width: 0, height: 0 });
setActiveTool('image');
} catch (err) {
console.error(err);
alert('Failed to upload image');
} catch (reason) {
console.error('Failed to upload image', reason);
window.alert(reason instanceof Error ? reason.message : 'Failed to upload image.');
} finally {
setIsUploadingImage(false);
// Reset input
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const handleSignatureConfirm = (props: any) => {
const handleSignatureConfirm = (props: SignatureDrawProps | SignatureTypeProps) => {
setPendingSignatureProps(props);
setIsSignatureModalOpen(false);
setActiveTool('signature');
};
const isActive = (name: string) => activeTool === name ? active : inactive;
return (
<>
<div className="fixed top-4 left-1/2 -translate-x-1/2 bg-white rounded-lg shadow-xl border border-neutral-200 px-2 py-2 flex items-center gap-2 z-50">
<button
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
activeTool === 'select'
? 'bg-blue-100 text-blue-700'
: 'text-neutral-600 hover:bg-neutral-100'
}`}
onClick={() => setActiveTool('select')}
>
Select
<div className="sticky top-3 z-30 mx-auto flex w-fit max-w-[calc(100%-2rem)] flex-wrap items-center justify-center gap-1 rounded-xl border border-neutral-200 bg-white/95 px-2 py-2 shadow-lg backdrop-blur">
<button type="button" className={`${toolButton} ${isActive('select')}`} onClick={() => setActiveTool('select')} title="Select and move annotations">
<MousePointer2 className="h-4 w-4" /> Select
</button>
<button
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
activeTool === 'text'
? 'bg-blue-100 text-blue-700'
: 'text-neutral-600 hover:bg-neutral-100'
}`}
onClick={() => setActiveTool('text')}
>
Add Text
<button type="button" className={`${toolButton} ${isActive('text')}`} onClick={() => setActiveTool('text')} title="Add text">
<Type className="h-4 w-4" /> Text
</button>
<button
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
activeTool === 'draw'
? 'bg-blue-100 text-blue-700'
: 'text-neutral-600 hover:bg-neutral-100'
}`}
onClick={() => setActiveTool('draw')}
>
Draw
<button type="button" className={`${toolButton} ${isActive('draw')}`} onClick={() => setActiveTool('draw')} title="Draw freehand">
<PenLine className="h-4 w-4" /> Draw
</button>
<button
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
activeTool === 'signature'
? 'bg-blue-100 text-blue-700'
: 'text-neutral-600 hover:bg-neutral-100'
}`}
onClick={() => setIsSignatureModalOpen(true)}
>
Signature
<button type="button" className={`${toolButton} ${isActive('highlight')}`} onClick={() => setActiveTool('highlight')} title="Highlight an area">
<Highlighter className="h-4 w-4" /> Highlight
</button>
<button
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
activeTool === 'image' || isUploadingImage
? 'bg-blue-100 text-blue-700'
: 'text-neutral-600 hover:bg-neutral-100'
}`}
onClick={() => fileInputRef.current?.click()}
disabled={isUploadingImage}
>
{isUploadingImage ? 'Uploading...' : 'Image'}
<button type="button" className={`${toolButton} ${isActive('shape')}`} onClick={() => setActiveTool('shape')} title="Draw a shape">
<Square className="h-4 w-4" /> Shape
</button>
<input
type="file"
ref={fileInputRef}
accept="image/*, image/webp"
className="hidden"
onChange={handleImageUpload}
/>
<div className="w-px h-6 bg-neutral-200 mx-2" />
<div className="flex items-center gap-1">
<button
onClick={handleZoomOut}
className="px-2 py-1 text-sm font-medium text-neutral-600 hover:bg-neutral-100 rounded"
{activeTool === 'shape' && (
<select
value={activeShapeKind}
onChange={(event) => setActiveShapeKind(event.target.value as typeof activeShapeKind)}
className="h-8 rounded-md border border-neutral-200 bg-neutral-50 px-1.5 text-xs font-medium text-neutral-700 outline-none focus:border-accent-500"
aria-label="Shape type"
>
-
</button>
<span className="text-sm font-medium text-neutral-600 w-12 text-center">
{Math.round(zoom * 100)}%
</span>
<button
onClick={handleZoomIn}
className="px-2 py-1 text-sm font-medium text-neutral-600 hover:bg-neutral-100 rounded"
>
+
</button>
</div>
<option value="rect">Rectangle</option>
<option value="ellipse">Ellipse</option>
<option value="line">Line</option>
<option value="arrow">Arrow</option>
</select>
)}
<button type="button" className={`${toolButton} ${isActive('signature')}`} onClick={() => setIsSignatureModalOpen(true)} title="Add a signature">
<Signature className="h-4 w-4" /> Sign
</button>
<button type="button" className={`${toolButton} ${activeTool === 'image' || isUploadingImage ? active : inactive}`} onClick={() => fileInputRef.current?.click()} disabled={isUploadingImage} title="Add an image">
<ImagePlus className="h-4 w-4" /> {isUploadingImage ? 'Uploading…' : 'Image'}
</button>
<input ref={fileInputRef} type="file" accept="image/png,image/jpeg,image/gif,image/webp" className="hidden" onChange={(event) => void handleImageUpload(event)} />
<div className="w-px h-6 bg-neutral-200 mx-2" />
<span className="mx-1 h-6 w-px bg-neutral-200" />
<button type="button" className={`${toolButton} ${past.length ? inactive : 'cursor-not-allowed text-neutral-300'}`} onClick={undo} disabled={!past.length} title="Undo (Ctrl/Cmd+Z)">
<Undo2 className="h-4 w-4" />
</button>
<button type="button" className={`${toolButton} ${future.length ? inactive : 'cursor-not-allowed text-neutral-300'}`} onClick={redo} disabled={!future.length} title="Redo (Ctrl/Cmd+Shift+Z)">
<Redo2 className="h-4 w-4" />
</button>
<div className="text-xs font-medium text-neutral-400 min-w-16 text-center">
{saveStatus === 'saving' && 'Saving...'}
<span className="mx-1 h-6 w-px bg-neutral-200" />
<button type="button" className={`${toolButton} ${inactive}`} onClick={() => setZoom(zoom - 0.25)} disabled={zoom <= 0.5} title="Zoom out">
<Minus className="h-4 w-4" />
</button>
<span className="min-w-12 text-center text-xs font-semibold text-neutral-600">{Math.round(zoom * 100)}%</span>
<button type="button" className={`${toolButton} ${inactive}`} onClick={() => setZoom(zoom + 0.25)} disabled={zoom >= 3} title="Zoom in">
<ZoomIn className="h-4 w-4" />
</button>
<span className="mx-1 h-6 w-px bg-neutral-200" />
<button type="button" className={`${toolButton} ${inactive}`} onClick={onOpenVersions} title="Open checkpoints">
<History className="h-4 w-4" /> History
</button>
<button type="button" className="inline-flex h-8 items-center gap-1.5 rounded-md bg-accent-600 px-3 text-xs font-bold text-white transition-colors hover:bg-accent-700 disabled:cursor-wait disabled:opacity-60" onClick={onExport} disabled={isExporting} title="Export flattened PDF">
<Download className="h-4 w-4" /> {isExporting ? 'Exporting…' : 'Export'}
</button>
<span className="ml-1 min-w-16 text-center text-[11px] font-semibold text-neutral-500">
{saveStatus === 'loading' && 'Loading…'}
{saveStatus === 'saving' && 'Saving…'}
{saveStatus === 'saved' && 'Saved'}
{saveStatus === 'error' && <span className="text-red-500">Error saving</span>}
{saveStatus === 'idle' && ''}
</div>
{saveStatus === 'error' && <span className="text-red-600">Save failed</span>}
</span>
</div>
{isSignatureModalOpen && (
<SignatureModal
<SignatureModal
onClose={() => setIsSignatureModalOpen(false)}
onConfirm={handleSignatureConfirm}
/>

View file

@ -1,4 +1,6 @@
import { useState, useRef, useEffect } from 'react';
import * as fabric from 'fabric';
import type { Canvas } from 'fabric';
import { useEditorStore } from '../store';
import type { TextAnnotation, TextProps } from '../../../lib/annotations/types';
import type { ViewportParams } from '../../../lib/coords';
@ -17,6 +19,7 @@ import {
interface TextFormatToolbarProps {
annotationId: string;
viewportParams: ViewportParams;
canvas: Canvas;
}
const FONTS = ['Liberation Sans', 'Outfit', 'Plus Jakarta Sans', 'Arial', 'Times New Roman', 'Courier New'];
@ -24,7 +27,7 @@ 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) {
export function TextFormatToolbar({ annotationId, viewportParams, canvas }: TextFormatToolbarProps) {
const { annotations, updateAnnotation, deleteAnnotation, addAnnotation, setDefaultTextProps, draftAnnotation } = useEditorStore();
const [activeDropdown, setActiveDropdown] = useState<'font' | 'size' | 'color' | 'highlight' | null>(null);
@ -51,11 +54,9 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo
const top = pt.y - 48; // 48px above
const left = pt.x;
const applyStyle = (styleName: string, value: any, globalPropName: keyof TextProps, globalValue?: any) => {
const canvas = (window as any).__fabricCanvas as any;
if (canvas) {
const activeObj = canvas.getActiveObject();
const applyStyle = (styleName: string, value: unknown, globalPropName: keyof TextProps, globalValue?: unknown) => {
const activeObj = canvas.getActiveObject() as (fabric.Textbox & { id?: string; customHeight?: number }) | undefined;
if (activeObj) {
if (activeObj && activeObj.id === annotationId) {
const isStructural = styleName === 'fontSize' || styleName === 'fontFamily';
@ -81,7 +82,7 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo
activeObj.styles = {};
if (activeObj.hiddenTextarea) {
if (styleName === 'fontSize') activeObj.hiddenTextarea.style.fontSize = `${value}px`;
if (styleName === 'fontFamily') activeObj.hiddenTextarea.style.fontFamily = value;
if (styleName === 'fontFamily') activeObj.hiddenTextarea.style.fontFamily = String(value);
}
}
} else {
@ -100,7 +101,7 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo
for (const line in activeObj.styles) {
for (const char in activeObj.styles[line]) {
if (activeObj.styles[line][char]) {
delete activeObj.styles[line][char][styleName];
delete (activeObj.styles[line][char] as Record<string, unknown>)[styleName];
}
}
}
@ -110,8 +111,8 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo
// 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;
if ('_forceClearCache' in activeObj) {
(activeObj as typeof activeObj & { _forceClearCache?: boolean })._forceClearCache = true;
}
// Remove manual height constraint so the box can grow with the new font size
@ -123,7 +124,7 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo
}
const finalGlobalValue = globalValue !== undefined ? globalValue : value;
const newProps = { [globalPropName]: finalGlobalValue } as any;
const newProps = { [globalPropName]: finalGlobalValue } as Partial<TextProps>;
setDefaultTextProps(newProps);
// Always update store so the toolbar displays the new value
@ -154,12 +155,9 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo
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);
}
const activeObj = canvas.getActiveObject();
if (activeObj && (activeObj as fabric.FabricObject & { id?: string }).id === annotationId) {
canvas.remove(activeObj);
}
} else {
deleteAnnotation(annotationId);
@ -220,7 +218,7 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo
<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); }}
onClick={() => { applyStyle('fontSize', s * viewportParams.scale, 'fontSize', s); setActiveDropdown(null); }}
>
{s}
</button>

View file

@ -6,6 +6,12 @@ import type { Annotation, DrawAnnotation, DrawProps } from '../../../lib/annotat
import { screenRectToPdf, pdfRectToScreen } from '../../../lib/coords';
import type { ViewportParams } from '../../../lib/coords';
type FabricAnnotationObject = fabric.FabricObject & {
id?: string;
annotationType?: string;
annotationProps?: DrawProps;
};
export const DrawTool: ToolHandler = {
name: 'draw',
@ -21,7 +27,7 @@ export const DrawTool: ToolHandler = {
canvas.isDrawingMode = false;
},
onPathCreated: (e: any, _canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
onPathCreated: (e: fabric.CanvasEvents['path:created'], _canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
const pathObj = e.path as fabric.Path;
pathObj.set({
@ -33,27 +39,16 @@ export const DrawTool: ToolHandler = {
padding: 5,
});
(pathObj as any).id = uuidv4();
(pathObj as any).annotationType = 'draw';
const annotatedPath = pathObj as FabricAnnotationObject;
annotatedPath.id = uuidv4();
annotatedPath.annotationType = 'draw';
// The path data is stored in `pathObj.path` array of commands, e.g. [['M', x, y], ['Q', cx, cy, x, y]]
// We can serialize it via pathObj.toObject().path or complexPathToString(pathObj.path)
// fabric 7 has toObject().path or you can just rely on the object's serialization
// Convert paths to string representation
// Keep the SVG path for faithful browser re-rendering and also store a
// canonical point list for the server export renderer.
const pathStr = Array.isArray(pathObj.path)
? pathObj.path.map(cmd => cmd.join(' ')).join(' ')
: pathObj.path;
const props: DrawProps = {
paths: [], // we ignore points array for now and use svgPath
svgPath: pathStr as string,
strokeColor: pathObj.stroke as string,
strokeWidth: pathObj.strokeWidth,
opacity: pathObj.opacity
};
(pathObj as any).annotationProps = props;
const bounds = pathObj.getBoundingRect();
const pdfRect = screenRectToPdf({
x: bounds.left,
@ -61,9 +56,36 @@ export const DrawTool: ToolHandler = {
width: bounds.width,
height: bounds.height
}, vp);
const rawPoints = Array.isArray(pathObj.path)
? pathObj.path.flatMap((command) => {
const values = command.slice(1).filter((value): value is number => typeof value === 'number');
const points: [number, number][] = [];
for (let index = 0; index + 1 < values.length; index += 2) {
points.push([values[index], values[index + 1]]);
}
return points;
})
: [];
const minX = rawPoints.length ? Math.min(...rawPoints.map(([x]) => x)) : 0;
const maxX = rawPoints.length ? Math.max(...rawPoints.map(([x]) => x)) : 1;
const minY = rawPoints.length ? Math.min(...rawPoints.map(([, y]) => y)) : 0;
const maxY = rawPoints.length ? Math.max(...rawPoints.map(([, y]) => y)) : 1;
const sourceWidth = Math.max(maxX - minX, 1e-6);
const sourceHeight = Math.max(maxY - minY, 1e-6);
const props: DrawProps = {
paths: rawPoints.map(([x, y]) => [
((x - minX) / sourceWidth) * pdfRect.width,
((y - minY) / sourceHeight) * pdfRect.height,
]),
svgPath: pathStr as string,
strokeColor: pathObj.stroke as string,
strokeWidth: pathObj.strokeWidth,
opacity: pathObj.opacity,
};
annotatedPath.annotationProps = props;
const annotation: DrawAnnotation = {
id: (pathObj as any).id,
id: annotatedPath.id as string,
page: pageNumber,
type: 'draw',
rect: pdfRect,
@ -81,7 +103,7 @@ export const DrawTool: ToolHandler = {
},
renderToFabric: async (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => {
if (annotation.type !== 'draw') return null;
if (annotation.type !== 'draw') return;
const drawAnn = annotation as DrawAnnotation;
const props = drawAnn.props as DrawProps;
const screenRect = pdfRectToScreen(drawAnn.rect, vp);
@ -103,6 +125,8 @@ export const DrawTool: ToolHandler = {
pathObj.set({
left: screenRect.x,
top: screenRect.y,
originX: 'left',
originY: 'top',
scaleX: screenRect.width / pathObj.width!,
scaleY: screenRect.height / pathObj.height!,
});
@ -111,6 +135,8 @@ export const DrawTool: ToolHandler = {
pathObj = new fabric.Path('M 0 0', {
left: screenRect.x,
top: screenRect.y,
originX: 'left',
originY: 'top',
width: screenRect.width,
height: screenRect.height,
stroke: props.strokeColor || '#000000',
@ -118,17 +144,18 @@ export const DrawTool: ToolHandler = {
});
}
const annotatedPath = pathObj as FabricAnnotationObject;
annotatedPath.id = drawAnn.id;
annotatedPath.annotationType = 'draw';
annotatedPath.annotationProps = drawAnn.props;
pathObj.set({
id: drawAnn.id,
annotationType: 'draw',
annotationProps: drawAnn.props,
transparentCorners: false,
cornerColor: '#3b82f6',
cornerStrokeColor: '#3b82f6',
borderColor: '#3b82f6',
cornerSize: 8,
padding: 5,
} as any);
});
canvas.add(pathObj);
}

View file

@ -0,0 +1,120 @@
import * as fabric from 'fabric';
import { v4 as uuidv4 } from 'uuid';
import { useEditorStore } from '../store';
import type { ToolHandler } from '../../../lib/annotations/registry';
import type { Annotation, HighlightAnnotation, HighlightProps } from '../../../lib/annotations/types';
import { pdfRectToScreen, screenRectToPdf } from '../../../lib/coords';
import type { ViewportParams } from '../../../lib/coords';
type PointerEvent = fabric.TPointerEventInfo;
type Draft = { object: fabric.Rect; start: { x: number; y: number } };
const drafts = new WeakMap<fabric.Canvas, Draft>();
function point(event: PointerEvent, canvas: fabric.Canvas) {
return event.scenePoint ?? canvas.getScenePoint(event.e);
}
function configure(object: fabric.Rect) {
object.set({
originX: 'left',
originY: 'top',
fill: '#facc15',
opacity: 0.35,
stroke: undefined,
transparentCorners: false,
cornerColor: '#3b82f6',
cornerStrokeColor: '#3b82f6',
borderColor: '#3b82f6',
cornerSize: 8,
});
}
export const HighlightTool: ToolHandler = {
name: 'highlight',
onActivate: (canvas) => {
canvas.defaultCursor = 'crosshair';
canvas.selection = false;
},
onDeactivate: (canvas) => {
const draft = drafts.get(canvas);
if (draft) canvas.remove(draft.object);
drafts.delete(canvas);
canvas.defaultCursor = 'default';
},
onPointerDown: (event: PointerEvent, canvas) => {
if (event.target) return;
const start = point(event, canvas);
const object = new fabric.Rect({ left: start.x, top: start.y, width: 0, height: 0 });
configure(object);
drafts.set(canvas, { object, start });
canvas.add(object);
},
onPointerMove: (event: PointerEvent, canvas) => {
const draft = drafts.get(canvas);
if (!draft) return;
const current = point(event, canvas);
draft.object.set({
left: Math.min(draft.start.x, current.x),
top: Math.min(draft.start.y, current.y),
width: Math.abs(current.x - draft.start.x),
height: Math.abs(current.y - draft.start.y),
});
draft.object.setCoords();
canvas.requestRenderAll();
},
onPointerUp: (event: PointerEvent, canvas, vp: ViewportParams, pageNumber) => {
const draft = drafts.get(canvas);
if (!draft) return;
const current = point(event, canvas);
draft.object.set({
left: Math.min(draft.start.x, current.x),
top: Math.min(draft.start.y, current.y),
width: Math.abs(current.x - draft.start.x),
height: Math.abs(current.y - draft.start.y),
});
draft.object.setCoords();
const bounds = draft.object.getBoundingRect();
drafts.delete(canvas);
if (bounds.width < 2 || bounds.height < 2) {
canvas.remove(draft.object);
return;
}
const id = uuidv4();
draft.object.set({ id });
const props: HighlightProps = { color: '#facc15', opacity: 0.35 };
useEditorStore.getState().addAnnotation({
id,
page: pageNumber,
type: 'highlight',
rect: screenRectToPdf(
{ x: bounds.left, y: bounds.top, width: bounds.width, height: bounds.height },
vp,
),
rotation: 0,
z: 0,
props,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as HighlightAnnotation);
canvas.setActiveObject(draft.object);
canvas.requestRenderAll();
},
renderToFabric: (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => {
if (annotation.type !== 'highlight') return;
const highlight = annotation as HighlightAnnotation;
const rect = pdfRectToScreen(highlight.rect, vp);
const object = new fabric.Rect({
left: rect.x,
top: rect.y,
width: rect.width,
height: rect.height,
});
configure(object);
object.set({
id: highlight.id,
fill: highlight.props.color,
opacity: highlight.props.opacity,
});
canvas.add(object);
},
};

View file

@ -6,6 +6,12 @@ import type { Annotation, ImageAnnotation, ImageProps } from '../../../lib/annot
import { screenRectToPdf, pdfRectToScreen } from '../../../lib/coords';
import type { ViewportParams } from '../../../lib/coords';
type FabricAnnotationObject = fabric.FabricObject & {
id?: string;
annotationType?: string;
annotationProps?: ImageProps;
};
export const ImageTool: ToolHandler = {
name: 'image',
@ -18,7 +24,7 @@ export const ImageTool: ToolHandler = {
canvas.defaultCursor = 'default';
},
onPointerDown: async (e: any, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
onPointerDown: async (e: fabric.TPointerEventInfo, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
if (e.target) return;
const storeState = useEditorStore.getState();
@ -45,8 +51,8 @@ export const ImageTool: ToolHandler = {
img.set({
left: pointer.x,
top: pointer.y,
originX: 'center',
originY: 'center',
originX: 'left',
originY: 'top',
transparentCorners: false,
cornerColor: '#3b82f6',
cornerStrokeColor: '#3b82f6',
@ -55,15 +61,16 @@ export const ImageTool: ToolHandler = {
padding: 5,
});
(img as any).id = uuidv4();
(img as any).annotationType = 'image';
const annotatedImage = img as FabricAnnotationObject;
annotatedImage.id = uuidv4();
annotatedImage.annotationType = 'image';
const props: ImageProps = {
ref: pendingImage.ref,
naturalWidth: pendingImage.width,
naturalHeight: pendingImage.height
};
(img as any).annotationProps = props;
annotatedImage.annotationProps = props;
canvas.add(img);
canvas.setActiveObject(img);
@ -78,7 +85,7 @@ export const ImageTool: ToolHandler = {
}, vp);
const annotation: ImageAnnotation = {
id: (img as any).id,
id: annotatedImage.id as string,
page: pageNumber,
type: 'image',
rect: pdfRect,
@ -97,7 +104,7 @@ export const ImageTool: ToolHandler = {
},
renderToFabric: async (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => {
if (annotation.type !== 'image') return null;
if (annotation.type !== 'image') return;
const imgAnn = annotation as ImageAnnotation;
const screenRect = pdfRectToScreen(imgAnn.rect, vp);
@ -107,24 +114,27 @@ export const ImageTool: ToolHandler = {
img = await fabric.Image.fromURL(url);
} catch (e) {
console.error("Failed to load asset", e);
return null;
return;
}
const annotatedImage = img as FabricAnnotationObject;
annotatedImage.id = imgAnn.id;
annotatedImage.annotationType = 'image';
annotatedImage.annotationProps = imgAnn.props;
img.set({
left: screenRect.x,
top: screenRect.y,
originX: 'left',
originY: 'top',
scaleX: screenRect.width / img.width!,
scaleY: screenRect.height / img.height!,
id: imgAnn.id,
annotationType: 'image',
annotationProps: imgAnn.props,
transparentCorners: false,
cornerColor: '#3b82f6',
cornerStrokeColor: '#3b82f6',
borderColor: '#3b82f6',
cornerSize: 8,
padding: 5,
} as any);
});
canvas.add(img);
}

View file

@ -0,0 +1,210 @@
import * as fabric from 'fabric';
import { v4 as uuidv4 } from 'uuid';
import { useEditorStore } from '../store';
import type { ToolHandler } from '../../../lib/annotations/registry';
import type { Annotation, ShapeAnnotation, ShapeProps } from '../../../lib/annotations/types';
import { pdfRectToScreen, screenRectToPdf } from '../../../lib/coords';
import type { ViewportParams } from '../../../lib/coords';
type PointerEvent = fabric.TPointerEventInfo;
type Draft = { object: fabric.FabricObject; start: { x: number; y: number }; kind: ShapeProps['kind'] };
const drafts = new WeakMap<fabric.Canvas, Draft>();
function pointer(event: PointerEvent, canvas: fabric.Canvas) {
return event.scenePoint ?? canvas.getScenePoint(event.e);
}
function style(object: fabric.FabricObject) {
object.set({
originX: 'left',
originY: 'top',
transparentCorners: false,
cornerColor: '#3b82f6',
cornerStrokeColor: '#3b82f6',
borderColor: '#3b82f6',
cornerSize: 8,
});
}
function arrowGeometry(start: { x: number; y: number }, end: { x: number; y: number }) {
const angle = Math.atan2(end.y - start.y, end.x - start.x);
const length = Math.min(12, Math.max(5, Math.hypot(end.x - start.x, end.y - start.y) * 0.2));
const left = {
x: end.x - length * Math.cos(angle - Math.PI / 6),
y: end.y - length * Math.sin(angle - Math.PI / 6),
};
const right = {
x: end.x - length * Math.cos(angle + Math.PI / 6),
y: end.y - length * Math.sin(angle + Math.PI / 6),
};
const minX = Math.min(start.x, end.x, left.x, right.x);
const minY = Math.min(start.y, end.y, left.y, right.y);
const point = (value: { x: number; y: number }) => `${value.x - minX} ${value.y - minY}`;
return {
left: minX,
top: minY,
path: `M ${point(start)} L ${point(end)} M ${point(end)} L ${point(left)} M ${point(end)} L ${point(right)}`,
};
}
function createArrow(start: { x: number; y: number }, end: { x: number; y: number }) {
const geometry = arrowGeometry(start, end);
return new fabric.Path(geometry.path, {
left: geometry.left,
top: geometry.top,
fill: 'transparent',
stroke: '#111827',
strokeWidth: 2,
originX: 'left',
originY: 'top',
});
}
function createObject(kind: ShapeProps['kind'], start: { x: number; y: number }) {
const common = { left: start.x, top: start.y, fill: 'transparent', stroke: '#111827', strokeWidth: 2 };
if (kind === 'ellipse') return new fabric.Ellipse({ ...common, rx: 0, ry: 0 });
if (kind === 'arrow') return createArrow(start, start);
if (kind === 'line') return new fabric.Line([0, 0, 0, 0], common);
return new fabric.Rect({ ...common, width: 0, height: 0 });
}
function updateObject(draft: Draft, current: { x: number; y: number }, canvas: fabric.Canvas) {
const { object, start, kind } = draft;
if (kind === 'arrow') {
const replacement = createArrow(start, current);
style(replacement);
canvas.remove(object);
canvas.add(replacement);
draft.object = replacement;
return;
}
const width = current.x - start.x;
const height = current.y - start.y;
if (kind === 'line') {
object.set({ x2: width, y2: height });
} else {
object.set({
left: Math.min(start.x, current.x),
top: Math.min(start.y, current.y),
...(kind === 'ellipse'
? { rx: Math.abs(width) / 2, ry: Math.abs(height) / 2 }
: { width: Math.abs(width), height: Math.abs(height) }),
});
}
object.setCoords();
}
export const ShapeTool: ToolHandler = {
name: 'shape',
onActivate: (canvas) => {
canvas.defaultCursor = 'crosshair';
canvas.selection = false;
},
onDeactivate: (canvas) => {
const draft = drafts.get(canvas);
if (draft) canvas.remove(draft.object);
drafts.delete(canvas);
canvas.defaultCursor = 'default';
},
onPointerDown: (event: PointerEvent, canvas) => {
if (event.target) return;
const start = pointer(event, canvas);
const kind = useEditorStore.getState().activeShapeKind;
const object = createObject(kind, start);
style(object);
drafts.set(canvas, { object, start, kind });
canvas.add(object);
},
onPointerMove: (event: PointerEvent, canvas) => {
const draft = drafts.get(canvas);
if (!draft) return;
updateObject(draft, pointer(event, canvas), canvas);
canvas.requestRenderAll();
},
onPointerUp: (event: PointerEvent, canvas, vp: ViewportParams, pageNumber) => {
const draft = drafts.get(canvas);
if (!draft) return;
updateObject(draft, pointer(event, canvas), canvas);
const bounds = draft.object.getBoundingRect();
drafts.delete(canvas);
if (bounds.width < 2 && bounds.height < 2) {
canvas.remove(draft.object);
return;
}
const id = uuidv4();
draft.object.set({ id });
const props: ShapeProps = {
kind: draft.kind,
strokeColor: '#111827',
fillColor: 'transparent',
strokeWidth: 2,
};
if (draft.kind === 'line' || draft.kind === 'arrow') {
const width = Math.max(bounds.width, 1);
const height = Math.max(bounds.height, 1);
const current = pointer(event, canvas);
props.start = [
(draft.start.x - bounds.left) / width,
(draft.start.y - bounds.top) / height,
];
props.end = [
(current.x - bounds.left) / width,
(current.y - bounds.top) / height,
];
}
useEditorStore.getState().addAnnotation({
id,
page: pageNumber,
type: 'shape',
rect: screenRectToPdf(
{ x: bounds.left, y: bounds.top, width: bounds.width, height: bounds.height },
vp,
),
rotation: 0,
z: 0,
props,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as ShapeAnnotation);
canvas.setActiveObject(draft.object);
canvas.requestRenderAll();
},
renderToFabric: (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => {
if (annotation.type !== 'shape') return;
const shape = annotation as ShapeAnnotation;
const rect = pdfRectToScreen(shape.rect, vp);
const props = shape.props;
const common = {
fill: props.fillColor === 'transparent' ? 'transparent' : props.fillColor,
stroke: props.strokeColor,
strokeWidth: props.strokeWidth,
};
let object: fabric.FabricObject;
if (props.kind === 'ellipse') {
object = new fabric.Ellipse({ ...common, left: rect.x, top: rect.y, rx: rect.width / 2, ry: rect.height / 2 });
} else if (props.kind === 'line') {
const start = props.start ?? [0, 0];
const end = props.end ?? [1, 1];
const startPoint = { x: rect.x + start[0] * rect.width, y: rect.y + start[1] * rect.height };
const endPoint = { x: rect.x + end[0] * rect.width, y: rect.y + end[1] * rect.height };
const left = Math.min(startPoint.x, endPoint.x);
const top = Math.min(startPoint.y, endPoint.y);
object = new fabric.Line(
[startPoint.x - left, startPoint.y - top, endPoint.x - left, endPoint.y - top],
{ ...common, left, top },
);
} else if (props.kind === 'arrow') {
const start = props.start ?? [0, 0];
const end = props.end ?? [1, 1];
const startPoint = { x: rect.x + start[0] * rect.width, y: rect.y + start[1] * rect.height };
const endPoint = { x: rect.x + end[0] * rect.width, y: rect.y + end[1] * rect.height };
object = createArrow(startPoint, endPoint);
object.set(common);
} else {
object = new fabric.Rect({ ...common, left: rect.x, top: rect.y, width: rect.width, height: rect.height });
}
style(object);
object.set({ id: shape.id });
canvas.add(object);
},
};

View file

@ -17,6 +17,10 @@ const FONTS = [
'Caveat'
];
interface AssetUploadResponse {
ref: string;
}
export function SignatureModal({ onClose, onConfirm }: SignatureModalProps) {
const [tab, setTab] = useState<'type' | 'draw'>('type');
const [text, setText] = useState('John Doe');
@ -116,7 +120,7 @@ export function SignatureModal({ onClose, onConfirm }: SignatureModalProps) {
const formData = new FormData();
formData.append('file', blob, 'signature.png');
const data = await api.upload<any>(`/documents/${documentId}/assets`, formData);
const data = await api.upload<AssetUploadResponse>(`/documents/${documentId}/assets`, formData);
onConfirm({
mode: 'draw',

View file

@ -6,11 +6,18 @@ import type { Annotation, SignatureAnnotation, SignatureDrawProps, SignatureType
import { screenRectToPdf, pdfRectToScreen } from '../../../lib/coords';
import type { ViewportParams } from '../../../lib/coords';
type SignatureProps = SignatureDrawProps | SignatureTypeProps;
type FabricSignatureObject = fabric.FabricObject & {
id?: string;
annotationType?: string;
annotationProps?: SignatureProps;
};
let previewObj: fabric.FabricObject | null = null;
let currentPreviewCanvas: fabric.Canvas | null = null;
let isCreatingPreview = false;
const createPreview = async (canvas: fabric.Canvas, vp: ViewportParams, props: any) => {
const createPreview = async (canvas: fabric.Canvas, vp: ViewportParams, props: SignatureProps) => {
isCreatingPreview = true;
const storeState = useEditorStore.getState();
if (props.mode === 'draw') {
@ -23,11 +30,13 @@ const createPreview = async (canvas: fabric.Canvas, vp: ViewportParams, props: a
}
img.set({ originX: 'center', originY: 'center', opacity: 0.5, evented: false });
previewObj = img;
} catch (e) {}
} catch {
previewObj = null;
}
} else {
previewObj = new fabric.Text(props.text, {
fontFamily: props.fontFamily,
fontSize: 48 * vp.scale * (vp.dpr || 1),
fontSize: 48 * vp.scale,
fill: props.color,
originX: 'center',
originY: 'center',
@ -61,7 +70,7 @@ export const SignatureTool: ToolHandler = {
isCreatingPreview = false;
},
onPointerMove: (e: any, canvas: fabric.Canvas, vp: ViewportParams, _pageNumber: number) => {
onPointerMove: (e: fabric.TPointerEventInfo, canvas: fabric.Canvas, vp: ViewportParams) => {
const props = useEditorStore.getState().pendingSignatureProps;
if (!props) return;
@ -86,7 +95,7 @@ export const SignatureTool: ToolHandler = {
}
},
onPointerDown: async (e: any, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
onPointerDown: async (e: fabric.TPointerEventInfo, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
if (e.target && e.target !== previewObj) return;
const storeState = useEditorStore.getState();
@ -120,7 +129,7 @@ export const SignatureTool: ToolHandler = {
originY: 'center',
});
// We set id on the object so it can be identified
(img as any).id = uuidv4();
(img as FabricSignatureObject).id = uuidv4();
fabricObj = img;
} catch (err) {
console.error("Failed to load signature image", err);
@ -132,12 +141,12 @@ export const SignatureTool: ToolHandler = {
left: pointer.x,
top: pointer.y,
fontFamily: typeProps.fontFamily,
fontSize: 48 * vp.scale * (vp.dpr || 1),
fontSize: 48 * vp.scale,
fill: typeProps.color,
originX: 'center',
originY: 'center',
});
(textObj as any).id = uuidv4();
(textObj as FabricSignatureObject).id = uuidv4();
fabricObj = textObj;
}
@ -150,8 +159,9 @@ export const SignatureTool: ToolHandler = {
padding: 5,
});
(fabricObj as any).annotationType = 'signature';
(fabricObj as any).annotationProps = props;
const annotatedObject = fabricObj as FabricSignatureObject;
annotatedObject.annotationType = 'signature';
annotatedObject.annotationProps = props;
canvas.add(fabricObj);
canvas.setActiveObject(fabricObj);
@ -167,7 +177,7 @@ export const SignatureTool: ToolHandler = {
}, vp);
const annotation: SignatureAnnotation = {
id: (fabricObj as any).id,
id: annotatedObject.id as string,
page: pageNumber,
type: 'signature',
rect: pdfRect,
@ -186,7 +196,7 @@ export const SignatureTool: ToolHandler = {
},
renderToFabric: async (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => {
if (annotation.type !== 'signature') return null;
if (annotation.type !== 'signature') return;
const sigAnn = annotation as SignatureAnnotation;
const screenRect = pdfRectToScreen(sigAnn.rect, vp);
@ -199,19 +209,23 @@ export const SignatureTool: ToolHandler = {
img.set({
left: screenRect.x,
top: screenRect.y,
originX: 'left',
originY: 'top',
scaleX: screenRect.width / img.width!,
scaleY: screenRect.height / img.height!,
});
fabricObj = img;
} catch (e) {
console.error("Failed to load signature asset", e);
return null;
return;
}
} else {
const typeProps = sigAnn.props as SignatureTypeProps;
fabricObj = new fabric.Text(typeProps.text, {
left: screenRect.x,
top: screenRect.y,
originX: 'left',
originY: 'top',
fontFamily: typeProps.fontFamily,
fill: typeProps.color,
});
@ -222,17 +236,18 @@ export const SignatureTool: ToolHandler = {
});
}
const annotatedObject = fabricObj as FabricSignatureObject;
annotatedObject.id = sigAnn.id;
annotatedObject.annotationType = 'signature';
annotatedObject.annotationProps = sigAnn.props;
fabricObj.set({
id: sigAnn.id,
annotationType: 'signature',
annotationProps: sigAnn.props,
transparentCorners: false,
cornerColor: '#3b82f6',
cornerStrokeColor: '#3b82f6',
borderColor: '#3b82f6',
cornerSize: 8,
padding: 5,
} as any);
});
canvas.add(fabricObj);
}

View file

@ -3,9 +3,14 @@ import { v4 as uuidv4 } from 'uuid';
import { useEditorStore } from '../store';
import type { ToolHandler } from '../../../lib/annotations/registry';
import type { Annotation, TextAnnotation } from '../../../lib/annotations/types';
import { screenToPdf, pdfRectToScreen } from '../../../lib/coords';
import { pdfRectToScreen, screenRectToPdf } from '../../../lib/coords';
import type { ViewportParams } from '../../../lib/coords';
type FabricTextObject = fabric.Textbox & {
id?: string;
customHeight?: number;
};
export const TextTool: ToolHandler = {
name: 'text',
@ -18,7 +23,7 @@ export const TextTool: ToolHandler = {
canvas.defaultCursor = 'default';
},
onPointerDown: (e: any, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
onPointerDown: (e: fabric.TPointerEventInfo, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
// If we clicked on an existing object, don't create a new one
if (e.target) return;
@ -39,7 +44,7 @@ export const TextTool: ToolHandler = {
top: pointer.y,
width: 150 * vp.scale,
fontFamily: fontFamily,
fontSize: fontSize * vp.scale * (vp.dpr || 1),
fontSize: fontSize * vp.scale,
fontWeight: isBold ? 'bold' : 'normal',
fontStyle: isItalic ? 'italic' : 'normal',
fill: color,
@ -55,12 +60,13 @@ export const TextTool: ToolHandler = {
});
// Allow manual height control
(textbox as any).customHeight = textbox.height;
const annotatedTextbox = textbox as FabricTextObject;
annotatedTextbox.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;
if ((this as FabricTextObject).customHeight !== undefined) {
this.height = (this as FabricTextObject).customHeight as number;
}
};
@ -94,7 +100,7 @@ export const TextTool: ToolHandler = {
const w = textbox.width! * textbox.scaleX!;
const h = textbox.height! * textbox.scaleY!;
(textbox as any).customHeight = h;
annotatedTextbox.customHeight = h;
textbox.set({
width: w,
@ -106,24 +112,27 @@ export const TextTool: ToolHandler = {
});
const newId = uuidv4();
(textbox as any).id = newId;
annotatedTextbox.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 initialBounds = textbox.getBoundingRect();
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))
},
rect: screenRectToPdf(
{
x: initialBounds.left,
y: initialBounds.top,
width: initialBounds.width,
height: initialBounds.height,
},
vp,
),
rotation: 0,
z: 0,
createdAt: new Date().toISOString(),
@ -143,41 +152,49 @@ export const TextTool: ToolHandler = {
storeState.setDraftAnnotation(newAnn);
textbox.on('editing:exited', () => {
// Clear draft
useEditorStore.getState().setDraftAnnotation(null);
if (!textbox.text || textbox.text.trim() === '') {
useEditorStore.getState().setDraftAnnotation(null);
canvas.remove(textbox);
return;
}
// 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 state = useEditorStore.getState();
const currentAnn = (state.annotations.find(a => a.id === newId) as TextAnnotation | undefined)
?? state.draftAnnotation as TextAnnotation | null
?? newAnn;
const bounds = textbox.getBoundingRect();
const nextAnnotation: TextAnnotation = {
...currentAnn,
rect: screenRectToPdf(
{ x: bounds.left, y: bounds.top, width: bounds.width, height: bounds.height },
vp,
),
props: { ...currentAnn.props, text: textbox.text },
updatedAt: new Date().toISOString(),
};
// 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));
if (textbox.styles && Object.keys(textbox.styles).length > 0) {
nextAnnotation.props.styles = JSON.parse(JSON.stringify(textbox.styles));
}
useEditorStore.getState().addAnnotation(currentAnn);
state.setDraftAnnotation(null);
state.addAnnotation(nextAnnotation);
});
},
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 boxHeight = textAnn.rect.height * vp.scale;
const textbox = new fabric.Textbox(textAnn.props.text, {
left: pt.x,
top: pt.y,
width: textAnn.rect.width * vp.scale * (vp.dpr || 1),
width: textAnn.rect.width * vp.scale,
height: boxHeight,
fontFamily: textAnn.props.fontFamily,
fontSize: textAnn.props.fontSize * vp.scale * (vp.dpr || 1),
fontSize: textAnn.props.fontSize * vp.scale,
fontWeight: textAnn.props.bold ? 'bold' : 'normal',
fontStyle: textAnn.props.italic ? 'italic' : 'normal',
fill: textAnn.props.color,
@ -194,12 +211,13 @@ export const TextTool: ToolHandler = {
});
// Allow manual height control
(textbox as any).customHeight = boxHeight;
const annotatedTextbox = textbox as FabricTextObject;
annotatedTextbox.customHeight = boxHeight;
const originalInitDimensions = textbox.initDimensions.bind(textbox);
textbox.initDimensions = function() {
originalInitDimensions();
if ((this as any).customHeight !== undefined) {
this.height = (this as any).customHeight;
if ((this as FabricTextObject).customHeight !== undefined) {
this.height = (this as FabricTextObject).customHeight as number;
}
};
@ -232,7 +250,7 @@ export const TextTool: ToolHandler = {
const w = textbox.width! * textbox.scaleX!;
const h = textbox.height! * textbox.scaleY!;
(textbox as any).customHeight = h;
annotatedTextbox.customHeight = h;
textbox.set({
width: w,
@ -248,16 +266,16 @@ export const TextTool: ToolHandler = {
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));
const updates: Partial<TextAnnotation['props']> = { text: textbox.text };
if (textbox.styles && Object.keys(textbox.styles).length > 0) {
updates.styles = JSON.parse(JSON.stringify(textbox.styles)) as Record<string, unknown>;
}
useEditorStore.getState().updateAnnotation(annotation.id, {
props: { ...currentAnn.props, ...updates }
});
});
(textbox as any).id = annotation.id;
annotatedTextbox.id = annotation.id;
canvas.add(textbox);
}
};

View file

@ -3,12 +3,16 @@ import { TextTool } from './TextTool';
import { SignatureTool } from './SignatureTool';
import { ImageTool } from './ImageTool';
import { DrawTool } from './DrawTool';
import { HighlightTool } from './HighlightTool';
import { ShapeTool } from './ShapeTool';
// Register all tools here
registerTool(TextTool);
registerTool(SignatureTool);
registerTool(ImageTool);
registerTool(DrawTool);
registerTool(HighlightTool);
registerTool(ShapeTool);
// SelectTool implementation
registerTool({

View file

@ -1,75 +1,135 @@
import { useEffect, useRef } from 'react';
import { useEditorStore } from './store';
import { useCallback, useEffect, useRef } from 'react';
import { api, ApiRequestError } from '../../lib/api/client';
import type { Annotation } from '../../lib/annotations/types';
import { useEditorStore } from './store';
export function useAutosave() {
const { documentId, annotations, setSaveStatus, setAnnotations } = useEditorStore();
interface AnnotationResponse {
data: Annotation[];
updatedAt: string;
}
interface AnnotationUpdateResponse {
updatedAt: string;
}
export function useAutosave(documentId: string | undefined) {
const loadedDocumentId = useRef<string | null>(null);
const skipNextSave = useRef(false);
const timeoutRef = useRef<number | null>(null);
const isFirstLoad = useRef(true);
const saveGeneration = useRef(0);
const setDocumentId = useEditorStore((state) => state.setDocumentId);
const setAnnotations = useEditorStore((state) => state.setAnnotations);
const setAnnotationUpdatedAt = useEditorStore((state) => state.setAnnotationUpdatedAt);
const setSaveStatus = useEditorStore((state) => state.setSaveStatus);
const storeDocumentId = useEditorStore((state) => state.documentId);
const annotations = useEditorStore((state) => state.annotations);
// Initial load
useEffect(() => {
if (!documentId) return;
saveGeneration.current += 1;
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
setDocumentId(documentId ?? null);
loadedDocumentId.current = null;
skipNextSave.current = true;
}, [documentId, setDocumentId]);
useEffect(() => {
if (!documentId || storeDocumentId !== documentId) return;
let active = true;
setSaveStatus('loading');
async function load() {
try {
const res = await fetch(`/api/v1/documents/${documentId}/annotations`);
if (!res.ok) throw new Error('Failed to load annotations');
const data = await res.json();
if (active) {
setAnnotations(data.data as Annotation[]);
setSaveStatus('saved');
isFirstLoad.current = false;
const response = await api.get<AnnotationResponse>(`/documents/${documentId}/annotations`);
if (!active || useEditorStore.getState().documentId !== documentId) return;
skipNextSave.current = true;
setAnnotations(response.data);
setAnnotationUpdatedAt(response.updatedAt);
loadedDocumentId.current = documentId;
setSaveStatus('saved');
} catch (error) {
if (active && useEditorStore.getState().documentId === documentId) {
console.error('Error loading annotations:', error);
setSaveStatus('error');
}
} catch (err) {
console.error('Error loading annotations:', err);
if (active) setSaveStatus('error');
}
}
load();
return () => { active = false; };
}, [documentId, setAnnotations, setSaveStatus]);
// Debounced save
useEffect(() => {
// Don't save on the initial load!
if (isFirstLoad.current) return;
if (!documentId) return;
void load();
return () => {
active = false;
};
}, [documentId, setAnnotationUpdatedAt, setAnnotations, setSaveStatus, storeDocumentId]);
const flush = useCallback(async () => {
if (!documentId || useEditorStore.getState().documentId !== documentId || loadedDocumentId.current !== documentId) {
return false;
}
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
const generation = ++saveGeneration.current;
const snapshot = useEditorStore.getState();
const payload = {
data: snapshot.annotations,
baseUpdatedAt: snapshot.annotationUpdatedAt ?? undefined,
};
setSaveStatus('saving');
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current);
}
timeoutRef.current = window.setTimeout(async () => {
try {
const res = await fetch(`/api/v1/documents/${documentId}/annotations`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({
data: annotations
})
try {
const response = await api.put<AnnotationUpdateResponse>(`/documents/${documentId}/annotations`, payload)
.catch(async (error: unknown) => {
if (error instanceof ApiRequestError && error.status === 409) {
return api.put<AnnotationUpdateResponse>(`/documents/${documentId}/annotations`, {
data: snapshot.annotations,
});
}
throw error;
});
if (!res.ok) throw new Error('Failed to save');
if (saveGeneration.current === generation && useEditorStore.getState().documentId === documentId) {
setAnnotationUpdatedAt(response.updatedAt);
setSaveStatus('saved');
} catch (err) {
console.error('Error saving annotations:', err);
}
return true;
} catch (error) {
if (saveGeneration.current === generation && useEditorStore.getState().documentId === documentId) {
console.error('Error saving annotations:', error);
setSaveStatus('error');
}
}, 500); // 500ms debounce
return false;
}
}, [documentId, setAnnotationUpdatedAt, setSaveStatus]);
useEffect(() => {
if (!documentId || storeDocumentId !== documentId || loadedDocumentId.current !== documentId) return;
if (skipNextSave.current) {
skipNextSave.current = false;
return;
}
setSaveStatus('saving');
if (timeoutRef.current !== null) window.clearTimeout(timeoutRef.current);
timeoutRef.current = window.setTimeout(() => {
timeoutRef.current = null;
void flush();
}, 500);
return () => {
if (timeoutRef.current) window.clearTimeout(timeoutRef.current);
saveGeneration.current += 1;
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, [annotations, documentId, setSaveStatus]);
}, [annotations, documentId, flush, setSaveStatus, storeDocumentId]);
useEffect(() => () => {
saveGeneration.current += 1;
if (timeoutRef.current !== null) window.clearTimeout(timeoutRef.current);
}, []);
return { flush };
}

View file

@ -0,0 +1,236 @@
import { useCallback, useEffect, useState } from 'react';
import { Clock3, RotateCcw, Save, Trash2, X } from 'lucide-react';
import { api } from '../../../lib/api/client';
import type { Annotation } from '../../../lib/annotations/types';
import { useEditorStore } from '../store';
interface VersionMeta {
id: string;
documentId: string;
label: string | null;
kind: 'manual' | 'auto';
createdAt: string;
annotationCount: number;
}
interface VersionListResponse {
items: VersionMeta[];
}
interface AnnotationResponse {
data: Annotation[];
updatedAt: string;
}
interface VersionPanelProps {
documentId: string;
onClose: () => void;
}
function formatVersionDate(value: string) {
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
}
export function VersionPanel({ documentId, onClose }: VersionPanelProps) {
const [versions, setVersions] = useState<VersionMeta[]>([]);
const [label, setLabel] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const setAnnotations = useEditorStore((state) => state.setAnnotations);
const setAnnotationUpdatedAt = useEditorStore((state) => state.setAnnotationUpdatedAt);
const loadVersions = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await api.get<VersionListResponse>(`/documents/${documentId}/versions`);
setVersions(response.items);
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Unable to load checkpoints.');
} finally {
setIsLoading(false);
}
}, [documentId]);
useEffect(() => {
const timer = window.setTimeout(() => void loadVersions(), 0);
return () => window.clearTimeout(timer);
}, [loadVersions]);
const createCheckpoint = async () => {
setBusyId('create');
setError(null);
try {
await api.post<VersionMeta>(`/documents/${documentId}/versions`, {
label: label.trim() || null,
kind: 'manual',
});
setLabel('');
await loadVersions();
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Unable to create checkpoint.');
} finally {
setBusyId(null);
}
};
const restoreVersion = async (version: VersionMeta) => {
if (!window.confirm(`Restore “${version.label || 'Automatic checkpoint'}”? Your current work will be saved first.`)) {
return;
}
setBusyId(version.id);
setError(null);
try {
const restored = await api.post<{ updatedAt: string }>(
`/documents/${documentId}/versions/${version.id}/restore`,
);
const current = await api.get<AnnotationResponse>(`/documents/${documentId}/annotations`);
setAnnotations(current.data);
setAnnotationUpdatedAt(restored.updatedAt || current.updatedAt);
await loadVersions();
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Unable to restore checkpoint.');
} finally {
setBusyId(null);
}
};
const deleteVersion = async (version: VersionMeta) => {
if (!window.confirm('Delete this checkpoint permanently?')) return;
setBusyId(version.id);
setError(null);
try {
await api.delete(`/documents/${documentId}/versions/${version.id}`);
setVersions((current) => current.filter((item) => item.id !== version.id));
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Unable to delete checkpoint.');
} finally {
setBusyId(null);
}
};
return (
<aside className="fixed inset-y-0 right-0 z-[80] flex w-full max-w-md flex-col border-l border-neutral-200 bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-neutral-200 px-5 py-4">
<div>
<h2 className="flex items-center gap-2 text-lg font-semibold text-neutral-900">
<Clock3 className="h-5 w-5 text-accent-600" />
Checkpoints
</h2>
<p className="mt-0.5 text-xs text-neutral-500">Recover annotation work without changing the original PDF.</p>
</div>
<button type="button" onClick={onClose} className="rounded-lg p-2 text-neutral-500 hover:bg-neutral-100" aria-label="Close checkpoints">
<X className="h-5 w-5" />
</button>
</div>
<div className="border-b border-neutral-200 bg-neutral-50 px-5 py-4">
<label className="block text-xs font-semibold uppercase tracking-wide text-neutral-500" htmlFor="checkpoint-label">
Save current work
</label>
<div className="mt-2 flex gap-2">
<input
id="checkpoint-label"
value={label}
onChange={(event) => setLabel(event.target.value)}
maxLength={120}
placeholder="Optional label"
className="min-w-0 flex-1 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm outline-none focus:border-accent-500 focus:ring-2 focus:ring-accent-100"
/>
<button
type="button"
onClick={() => void createCheckpoint()}
disabled={busyId !== null}
className="inline-flex items-center gap-1.5 rounded-lg bg-accent-600 px-3 py-2 text-sm font-semibold text-white hover:bg-accent-700 disabled:cursor-wait disabled:opacity-60"
>
<Save className="h-4 w-4" />
Save
</button>
</div>
<button
type="button"
onClick={async () => {
if (!window.confirm('Clear every annotation? A recovery checkpoint will be created first.')) return;
setBusyId('clear');
setError(null);
try {
await api.post(`/documents/${documentId}/versions`, { label: 'Before clear all', kind: 'auto' });
setAnnotations([]);
await loadVersions();
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Unable to clear annotations.');
} finally {
setBusyId(null);
}
}}
disabled={busyId !== null}
className="mt-3 inline-flex items-center gap-1.5 text-xs font-semibold text-red-600 hover:text-red-700 disabled:opacity-50"
>
<Trash2 className="h-3.5 w-3.5" />
Clear all annotations
</button>
</div>
{error && <div className="mx-5 mt-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{error}</div>}
<div className="flex-1 overflow-y-auto px-5 py-4">
{isLoading ? (
<div className="py-12 text-center text-sm text-neutral-500">Loading checkpoints</div>
) : versions.length === 0 ? (
<div className="rounded-xl border border-dashed border-neutral-300 px-5 py-10 text-center">
<Clock3 className="mx-auto h-8 w-8 text-neutral-300" />
<p className="mt-3 text-sm font-medium text-neutral-700">No checkpoints yet</p>
<p className="mt-1 text-xs text-neutral-500">Save one before a major edit or export.</p>
</div>
) : (
<div className="space-y-3">
{versions.map((version) => {
const busy = busyId === version.id;
return (
<div key={version.id} className="rounded-xl border border-neutral-200 bg-white p-3 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-neutral-800">{version.label || 'Automatic checkpoint'}</p>
<p className="mt-1 text-xs text-neutral-500">
{formatVersionDate(version.createdAt)} · {version.annotationCount} annotation{version.annotationCount === 1 ? '' : 's'}
</p>
</div>
<span className={`rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${version.kind === 'manual' ? 'bg-accent-100 text-accent-700' : 'bg-neutral-100 text-neutral-500'}`}>
{version.kind}
</span>
</div>
<div className="mt-3 flex items-center gap-2">
<button
type="button"
onClick={() => void restoreVersion(version)}
disabled={busyId !== null}
className="inline-flex items-center gap-1.5 rounded-lg border border-neutral-300 px-2.5 py-1.5 text-xs font-semibold text-neutral-700 hover:bg-neutral-50 disabled:opacity-50"
>
<RotateCcw className="h-3.5 w-3.5" />
{busy ? 'Restoring…' : 'Restore'}
</button>
{version.kind === 'manual' && (
<button
type="button"
onClick={() => void deleteVersion(version)}
disabled={busyId !== null}
className="rounded-lg p-1.5 text-neutral-400 hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
aria-label="Delete checkpoint"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
);
})}
</div>
)}
</div>
</aside>
);
}