Phase 3 implementation. Coordinate system implemented. Initial text box tool implemented.
Some checks failed
Automated Container Build / build-and-push (push) Failing after 4s
CI / Backend (Python) (push) Failing after 17s
CI / Frontend (TypeScript) (push) Failing after 4m48s

This commit is contained in:
Elijah 2026-06-12 16:30:07 -07:00
parent c159ad4f37
commit 4cb038ec78
34 changed files with 6676 additions and 130 deletions

View file

@ -0,0 +1,181 @@
import { useEffect, useRef } 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 { TextFormatToolbar } from '../toolbar/TextFormatToolbar';
interface AnnotationLayerProps {
pageNumber: number;
width: number;
height: number;
viewportParams: ViewportParams;
}
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);
// Initial setup and event binding
useEffect(() => {
if (!canvasRef.current) return;
const canvas = new fabric.Canvas(canvasRef.current, {
width,
height,
selection: activeToolName === 'select',
});
// 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 pt = screenToPdf({ x: obj.left, y: obj.top }, viewportParams);
if (ann.type === 'text') {
const scaleX = obj.scaleX || 1;
const scaleY = obj.scaleY || 1;
// Box width/height in PDF space
const newWidth = (obj.width * scaleX) / (viewportParams.scale * (viewportParams.dpr || 1));
const newHeight = (obj.height * scaleY) / (viewportParams.scale * (viewportParams.dpr || 1));
const textProps = ann.props as any;
const newFontSize = (textProps.fontSize || 14) * scaleY;
useEditorStore.getState().updateAnnotation(obj.id, {
rect: { ...ann.rect, x: pt.x, y: pt.y, width: newWidth, height: newHeight },
props: { ...ann.props, fontSize: newFontSize }
});
} else {
useEditorStore.getState().updateAnnotation(obj.id, {
rect: { ...ann.rect, x: pt.x, y: pt.y }
});
}
}
}
});
const updateSelection = (_e?: any) => {
const activeObj = canvas.getActiveObject() as any;
if (activeObj && activeObj.id) {
useEditorStore.getState().setSelection(activeObj.id);
} else {
useEditorStore.getState().setSelection(null);
}
};
canvas.on('selection:created', updateSelection);
canvas.on('selection:updated', updateSelection);
canvas.on('selection:cleared', updateSelection);
// Keyboard shortcut for delete
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Delete' || e.key === 'Backspace') {
const activeObj = canvas.getActiveObject() as any;
// Don't delete if we are actively editing text
if (activeObj && activeObj.isEditing === true) {
return;
}
if (activeObj && activeObj.id) {
useEditorStore.getState().deleteAnnotation(activeObj.id);
}
}
};
document.addEventListener('keydown', handleKeyDown);
fabricRef.current = canvas;
return () => {
document.removeEventListener('keydown', handleKeyDown);
canvas.dispose();
fabricRef.current = null;
};
}, []); // Run once on mount
// Handle dimensions and re-rendering annotations
useEffect(() => {
if (!fabricRef.current) return;
const canvas = fabricRef.current;
canvas.setDimensions({ width, height });
// Simple naive sync: clear and re-render everything
// We keep track of the currently active object ID to restore it
const activeObj = canvas.getActiveObject() as any;
const activeId = activeObj?.id;
canvas.clear();
annotations.forEach(ann => {
// Only render annotations for THIS page
if (ann.page === pageNumber) {
const tool = getTool(ann.type);
if (tool && tool.renderToFabric) {
tool.renderToFabric(ann, canvas, viewportParams);
}
}
});
if (activeId) {
const newActive = canvas.getObjects().find((o: any) => o.id === activeId);
if (newActive) canvas.setActiveObject(newActive);
}
}, [width, height, annotations, pageNumber]); // We ignore viewportParams identity for now to avoid loops
// Handle tool activation/deactivation and event binding
useEffect(() => {
const canvas = fabricRef.current;
if (!canvas) return;
// 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 tool = getTool(activeToolName);
if (tool) {
if (tool.onActivate) tool.onActivate(canvas);
if (tool.onPointerDown) {
canvas.on('mouse:down', (e) => tool.onPointerDown!(e, canvas, viewportParams, pageNumber));
}
if (tool.onPointerMove) {
canvas.on('mouse:move', (e) => tool.onPointerMove!(e, canvas, viewportParams, pageNumber));
}
if (tool.onPointerUp) {
canvas.on('mouse:up', (e) => tool.onPointerUp!(e, canvas, viewportParams, pageNumber));
}
}
return () => {
if (tool && tool.onDeactivate) {
tool.onDeactivate(canvas);
}
};
}, [activeToolName]);
const selection = useEditorStore(state => state.selection);
const isSelectedOnThisPage = annotations.find(a => a.id === selection && a.page === pageNumber);
return (
<div className="absolute top-0 left-0" style={{ width, height }}>
<canvas ref={canvasRef} />
{isSelectedOnThisPage && (
<TextFormatToolbar annotationId={selection!} viewportParams={viewportParams} />
)}
</div>
);
}

View file

@ -0,0 +1,88 @@
import { useEffect, useRef, useState } from 'react';
import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist';
import { AnnotationLayer } from '../canvas/AnnotationLayer';
interface PageRendererProps {
pdfDoc: PDFDocumentProxy;
pageNumber: number;
scale: number;
dpr: number;
}
export function PageRenderer({ pdfDoc, pageNumber, scale, dpr }: PageRendererProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [page, setPage] = useState<PDFPageProxy | null>(null);
useEffect(() => {
let active = true;
pdfDoc.getPage(pageNumber).then(p => {
if (active) setPage(p);
});
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();
};
}, [page, scale, dpr]);
if (!page) {
return (
<div
className="bg-white shadow-sm flex items-center justify-center text-neutral-400 border border-neutral-200"
style={{ width: 612 * scale, height: 792 * scale }}
>
Loading page {pageNumber}...
</div>
);
}
const baseViewport = page ? page.getViewport({ scale: 1, rotation: 0 }) : null;
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}
</div>
</div>
);
}

View file

@ -0,0 +1,32 @@
import type { PDFDocumentProxy } from 'pdfjs-dist';
import { PageRenderer } from './PageRenderer';
import { useEditorStore } from '../store';
interface PageStackProps {
pdfDoc: PDFDocumentProxy;
}
export function PageStack({ pdfDoc }: PageStackProps) {
const numPages = pdfDoc.numPages;
const pages = Array.from({ length: numPages }, (_, i) => i + 1);
const zoom = useEditorStore(state => state.zoom);
// For now, render all pages vertically. Virtualization comes in Phase 7.
const scale = zoom * 2.0;
const dpr = window.devicePixelRatio || 1;
return (
<div className="flex flex-col items-center gap-8 py-8 w-full max-w-full">
{pages.map(pageNum => (
<PageRenderer
key={pageNum}
pdfDoc={pdfDoc}
pageNumber={pageNum}
scale={scale}
dpr={dpr}
/>
))}
</div>
);
}

View file

@ -0,0 +1,53 @@
import { useEffect, useState } from 'react';
import * as pdfjsLib from 'pdfjs-dist';
import { PageStack } from './PageStack';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.mjs',
import.meta.url
).toString();
export function PdfDocument({ documentId }: { documentId: string }) {
const [pdfDoc, setPdfDoc] = useState<pdfjsLib.PDFDocumentProxy | null>(null);
const [error, setError] = useState<string | 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);
});
return () => {
active = false;
loadingTask.destroy();
};
}, [documentId]);
if (error) {
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>
);
}
if (!pdfDoc) {
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>
);
}
return <PageStack pdfDoc={pdfDoc} />;
}

View file

@ -0,0 +1,50 @@
import { create } from 'zustand';
import type { Annotation } from '../../lib/annotations/types';
export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
interface EditorState {
documentId: string | null;
annotations: Annotation[];
activeTool: string;
selection: string | null;
zoom: number;
saveStatus: SaveStatus;
// Actions
setDocumentId: (id: string | null) => void;
setAnnotations: (annotations: Annotation[]) => void;
addAnnotation: (annotation: Annotation) => void;
updateAnnotation: (id: string, updates: Partial<Annotation>) => void;
deleteAnnotation: (id: string) => void;
setActiveTool: (tool: string) => void;
setSelection: (id: string | null) => void;
setZoom: (zoom: number) => void;
setSaveStatus: (status: SaveStatus) => void;
}
export const useEditorStore = create<EditorState>((set) => ({
documentId: null,
annotations: [],
activeTool: 'select',
selection: null,
zoom: 1,
saveStatus: 'idle',
setDocumentId: (id) => set({ documentId: id }),
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
})),
setActiveTool: (tool) => set({ activeTool: tool }),
setSelection: (id) => set({ selection: id }),
setZoom: (zoom) => set({ zoom }),
setSaveStatus: (status) => set({ saveStatus: status }),
}));

View file

@ -0,0 +1,63 @@
import { useEditorStore } from '../store';
export function EditorToolbar() {
const { activeTool, setActiveTool, saveStatus, zoom, setZoom } = useEditorStore();
const handleZoomIn = () => setZoom(Math.min(zoom + 0.25, 3));
const handleZoomOut = () => setZoom(Math.max(zoom - 0.25, 0.5));
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
</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>
<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"
>
-
</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>
<div className="w-px h-6 bg-neutral-200 mx-2" />
<div className="text-xs font-medium text-neutral-400 min-w-16 text-center">
{saveStatus === 'saving' && 'Saving...'}
{saveStatus === 'saved' && 'Saved'}
{saveStatus === 'error' && <span className="text-red-500">Error saving</span>}
{saveStatus === 'idle' && ''}
</div>
</div>
);
}

View file

@ -0,0 +1,148 @@
import { useEditorStore } from '../store';
import type { TextAnnotation } from '../../../lib/annotations/types';
import type { ViewportParams } from '../../../lib/coords';
import { pdfRectToScreen } from '../../../lib/coords';
import { v4 as uuidv4 } from 'uuid';
interface TextFormatToolbarProps {
annotationId: string;
viewportParams: ViewportParams;
}
const FONTS = ['Liberation Sans', 'Outfit', 'Plus Jakarta Sans'];
const SIZES = [8, 10, 12, 14, 16, 18, 24, 36, 48, 72];
const COLORS = ['#000000', '#EF4444', '#3B82F6', '#10B981', '#F59E0B', '#ffffff'];
const HIGHLIGHTS = ['transparent', '#FEF08A', '#BBF7D0', '#BFDBFE', '#FBCFE8', '#000000'];
export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatToolbarProps) {
const { annotations, updateAnnotation, deleteAnnotation, addAnnotation } = useEditorStore();
const annotation = annotations.find(a => a.id === annotationId);
if (!annotation || annotation.type !== 'text') return null;
const textAnn = annotation as TextAnnotation;
const props = textAnn.props;
// Calculate screen position
const pt = pdfRectToScreen(textAnn.rect, viewportParams);
// Place it just above the text box
const top = pt.y - 48; // 48px above
const left = pt.x;
const updateProps = (newProps: Partial<TextAnnotation['props']>) => {
updateAnnotation(annotationId, { props: { ...props, ...newProps } });
};
const handleDuplicate = () => {
const newId = uuidv4();
addAnnotation({
...textAnn,
id: newId,
rect: {
...textAnn.rect,
x: textAnn.rect.x + 20,
y: textAnn.rect.y + 20
}
});
// Setting selection to new annotation requires it to be mounted, which is fine,
// but we can just leave selection on the old one for now.
};
return (
<div
className="absolute z-50 bg-white rounded-md shadow-lg border border-neutral-200 flex items-center p-1 gap-1"
style={{ top: `${Math.max(0, top)}px`, left: `${left}px` }}
>
{/* Font Family */}
<select
value={props.fontFamily}
onChange={(e) => updateProps({ fontFamily: e.target.value })}
className="text-xs bg-neutral-100 hover:bg-neutral-200 rounded px-2 py-1 outline-none cursor-pointer"
>
{FONTS.map(f => <option key={f} value={f}>{f}</option>)}
</select>
<div className="w-px h-4 bg-neutral-200 mx-1" />
{/* Font Size */}
<select
value={props.fontSize}
onChange={(e) => updateProps({ fontSize: Number(e.target.value) })}
className="text-xs bg-neutral-100 hover:bg-neutral-200 rounded px-2 py-1 outline-none cursor-pointer"
>
{SIZES.map(s => <option key={s} value={s}>{s}</option>)}
</select>
<div className="w-px h-4 bg-neutral-200 mx-1" />
{/* Bold / Italic */}
<button
onClick={() => updateProps({ bold: !props.bold })}
className={`w-6 h-6 flex items-center justify-center rounded text-sm font-bold ${props.bold ? 'bg-blue-100 text-blue-700' : 'hover:bg-neutral-100 text-neutral-700'}`}
title="Bold"
>
B
</button>
<button
onClick={() => updateProps({ italic: !props.italic })}
className={`w-6 h-6 flex items-center justify-center rounded text-sm italic font-serif ${props.italic ? 'bg-blue-100 text-blue-700' : 'hover:bg-neutral-100 text-neutral-700'}`}
title="Italic"
>
I
</button>
<div className="w-px h-4 bg-neutral-200 mx-1" />
{/* Text Color */}
<div className="flex gap-0.5 items-center">
{COLORS.map(c => (
<button
key={c}
onClick={() => updateProps({ color: c })}
className={`w-4 h-4 rounded-full border ${props.color === c ? 'border-blue-500 scale-110' : 'border-neutral-300 hover:scale-110'}`}
style={{ backgroundColor: c }}
title="Text Color"
/>
))}
</div>
<div className="w-px h-4 bg-neutral-200 mx-1" />
{/* Highlight Color */}
<div className="flex gap-0.5 items-center">
{HIGHLIGHTS.map(c => (
<button
key={c}
onClick={() => updateProps({ highlightColor: c === 'transparent' ? null : c })}
className={`w-4 h-4 rounded-sm border ${props.highlightColor === c || (c === 'transparent' && !props.highlightColor) ? 'border-blue-500 scale-110' : 'border-neutral-300 hover:scale-110'}`}
style={{
backgroundColor: c === 'transparent' ? '#ffffff' : c,
backgroundImage: c === 'transparent' ? 'linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%, #ccc), linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%, #ccc)' : 'none',
backgroundSize: '4px 4px',
backgroundPosition: '0 0, 2px 2px'
}}
title="Highlight Color"
/>
))}
</div>
<div className="w-px h-4 bg-neutral-200 mx-1" />
{/* Duplicate */}
<button
onClick={handleDuplicate}
className="text-xs px-2 py-1 rounded text-neutral-600 hover:bg-neutral-100 font-medium"
>
Copy
</button>
{/* Delete */}
<button
onClick={() => deleteAnnotation(annotationId)}
className="text-xs px-2 py-1 rounded text-red-600 hover:bg-red-50 font-medium ml-1"
>
Delete
</button>
</div>
);
}

View file

@ -0,0 +1,141 @@
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, TextAnnotation } from '../../../lib/annotations/types';
import { screenToPdf, pdfRectToScreen } from '../../../lib/coords';
import type { ViewportParams } from '../../../lib/coords';
export const TextTool: ToolHandler = {
name: 'text',
onActivate: (canvas: fabric.Canvas) => {
canvas.defaultCursor = 'text';
canvas.selection = false;
},
onDeactivate: (canvas: fabric.Canvas) => {
canvas.defaultCursor = 'default';
},
onPointerDown: (e: any, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
// If we clicked on an existing object, don't create a new one
if (e.target) return;
// Create the Fabric object first so the user can immediately type
const pointer = e.scenePoint || canvas.getScenePoint(e.e);
const textbox = new fabric.Textbox('', {
left: pointer.x,
top: pointer.y,
width: 150 * vp.scale,
fontFamily: 'Liberation Sans',
fontSize: 14 * vp.scale * (vp.dpr || 1), // scaled loosely for screen display
fontWeight: 'normal',
fontStyle: 'normal',
fill: '#000000',
backgroundColor: undefined,
originX: 'left',
originY: 'top',
transparentCorners: false,
cornerColor: '#3b82f6',
cornerStrokeColor: '#3b82f6',
borderColor: '#3b82f6',
cornerSize: 8,
padding: 5,
});
// Hide all corners and vertical resizers, leaving only middle-left and middle-right for width
textbox.setControlsVisibility({
tl: false,
tr: false,
bl: false,
br: false,
mt: false,
mb: false,
mtr: false // hide rotation handle for now
});
canvas.add(textbox);
canvas.setActiveObject(textbox);
textbox.enterEditing();
textbox.on('editing:exited', () => {
if (!textbox.text || textbox.text.trim() === '') {
canvas.remove(textbox);
return;
}
const pt = screenToPdf({ x: textbox.left!, y: textbox.top! }, vp);
const newAnn: TextAnnotation = {
id: uuidv4(),
page: pageNumber,
type: 'text',
rect: {
x: pt.x,
y: pt.y,
width: textbox.width! / (vp.scale * (vp.dpr || 1)),
height: textbox.height! / (vp.scale * (vp.dpr || 1))
},
rotation: 0,
z: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
props: {
text: textbox.text,
fontFamily: 'Liberation Sans',
fontSize: 14,
color: '#000000',
align: 'left',
bold: false,
italic: false,
lineHeight: 1.2,
highlightColor: null
}
};
// We attach the ID to the fabric object so we can link it
(textbox as any).id = newAnn.id;
useEditorStore.getState().addAnnotation(newAnn);
});
},
renderToFabric: (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => {
const textAnn = annotation as TextAnnotation;
const pt = pdfRectToScreen(textAnn.rect, vp);
const textbox = new fabric.Textbox(textAnn.props.text, {
left: pt.x,
top: pt.y,
width: textAnn.rect.width * vp.scale * (vp.dpr || 1),
fontFamily: textAnn.props.fontFamily,
fontSize: textAnn.props.fontSize * vp.scale * (vp.dpr || 1),
fontWeight: textAnn.props.bold ? 'bold' : 'normal',
fontStyle: textAnn.props.italic ? 'italic' : 'normal',
fill: textAnn.props.color,
backgroundColor: textAnn.props.highlightColor || undefined,
originX: 'left',
originY: 'top',
transparentCorners: false,
cornerColor: '#3b82f6',
cornerStrokeColor: '#3b82f6',
borderColor: '#3b82f6',
cornerSize: 8,
padding: 5,
});
textbox.setControlsVisibility({
tl: false,
tr: false,
bl: false,
br: false,
mt: false,
mb: false,
mtr: false
});
(textbox as any).id = annotation.id;
canvas.add(textbox);
}
};

View file

@ -0,0 +1,28 @@
import { registerTool } from '../../../lib/annotations/registry';
import { TextTool } from './TextTool';
// Register all tools here
registerTool(TextTool);
// SelectTool implementation
registerTool({
name: 'select',
onActivate: (canvas) => {
canvas.defaultCursor = 'default';
canvas.selection = true;
// Allow interacting with objects
canvas.forEachObject(obj => {
obj.set('selectable', true);
obj.set('evented', true);
});
},
onDeactivate: (canvas) => {
// Lock all objects
canvas.forEachObject(obj => {
obj.set('selectable', false);
obj.set('evented', false);
});
canvas.discardActiveObject();
}
});

View file

@ -0,0 +1,75 @@
import { useEffect, useRef } from 'react';
import { useEditorStore } from './store';
import type { Annotation } from '../../lib/annotations/types';
export function useAutosave() {
const { documentId, annotations, setSaveStatus, setAnnotations } = useEditorStore();
const timeoutRef = useRef<number | null>(null);
const isFirstLoad = useRef(true);
// Initial load
useEffect(() => {
if (!documentId) return;
let active = true;
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;
}
} 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;
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
})
});
if (!res.ok) throw new Error('Failed to save');
setSaveStatus('saved');
} catch (err) {
console.error('Error saving annotations:', err);
setSaveStatus('error');
}
}, 500); // 500ms debounce
return () => {
if (timeoutRef.current) window.clearTimeout(timeoutRef.current);
};
}, [annotations, documentId, setSaveStatus]);
}

View file

@ -6,7 +6,7 @@ import { formatDistanceToNow } from 'date-fns'
import { ContextMenu } from './ContextMenu'
export const DocumentGrid = ({ isTrash }: { isTrash: boolean }) => {
const { documents, selectedIds, toggleSelection, isLoading, error, deleteDocument, restoreDocument, updateDocument } = useLibrary()
const { documents, selectedIds, isLoading, error, deleteDocument, restoreDocument, updateDocument } = useLibrary()
const navigate = useNavigate()
const [contextMenu, setContextMenu] = useState<{ x: number, y: number, docId: string } | null>(null)

View file

@ -1,6 +1,6 @@
import { create } from 'zustand'
import { libraryApi } from './api'
import type { DocumentMeta } from './types'
import type { DocumentMeta, DocumentUpdateRequest } from './types'
interface LibraryState {
documents: DocumentMeta[]
@ -25,6 +25,7 @@ interface LibraryState {
bulkRestore: () => Promise<void>
emptyTrash: () => Promise<void>
setSearchQuery: (query: string) => void
updateDocument: (id: string, data: DocumentUpdateRequest) => Promise<void>
}
export const useLibrary = create<LibraryState>((set, get) => ({
@ -176,5 +177,19 @@ export const useLibrary = create<LibraryState>((set, get) => ({
set({ error: err.error?.message || err.message, isLoading: false })
throw err
}
},
updateDocument: async (id: string, data: DocumentUpdateRequest) => {
set({ isLoading: true, error: null })
try {
const updatedDoc = await libraryApi.updateDocument(id, data)
set((state) => ({
documents: state.documents.map(d => d.id === id ? updatedDoc : d),
isLoading: false
}))
} catch (err: any) {
set({ error: err.error?.message || err.message, isLoading: false })
throw err
}
}
}))

View file

@ -0,0 +1,43 @@
import type { Canvas } from 'fabric';
import type { Annotation } from './types';
import type { ViewportParams } from '../coords';
/**
* Interface that all annotation tools must implement.
*/
export interface ToolHandler {
/** Uniquely identifies the tool, usually matching the annotation type. */
name: string;
/** Called when the tool is selected. Use this to change cursors, etc. */
onActivate?: (canvas: Canvas) => void;
/** Called when the tool is deselected. */
onDeactivate?: (canvas: Canvas) => void;
/** Called when the user presses down on the canvas. */
onPointerDown?: (e: any, canvas: Canvas, viewportParams: ViewportParams, pageNumber: number) => void;
/** Called when the user drags the pointer. */
onPointerMove?: (e: any, canvas: Canvas, viewportParams: ViewportParams, pageNumber: number) => void;
/** Called when the user releases the pointer. */
onPointerUp?: (e: any, canvas: Canvas, viewportParams: ViewportParams, pageNumber: number) => void;
/** Renders a canonical Annotation onto the given Fabric Canvas. */
renderToFabric?: (annotation: Annotation, canvas: Canvas, viewportParams: ViewportParams) => void;
}
const registry = new Map<string, ToolHandler>();
export function registerTool(handler: ToolHandler) {
registry.set(handler.name, handler);
}
export function getTool(name: string): ToolHandler | undefined {
return registry.get(name);
}
export function getAllTools(): ToolHandler[] {
return Array.from(registry.values());
}

View file

@ -0,0 +1,28 @@
import type { components } from '../../types/api';
export type TextProps = components['schemas']['TextProps'] & { highlightColor?: string | null };
export type TextAnnotation = Omit<components['schemas']['TextAnnotation'], 'props'> & {
props: TextProps;
};
export type DrawAnnotation = components['schemas']['DrawAnnotation'];
export type SignatureAnnotation = components['schemas']['SignatureAnnotation'];
export type ImageAnnotation = components['schemas']['ImageAnnotation'];
export type HighlightAnnotation = components['schemas']['HighlightAnnotation'];
export type ShapeAnnotation = components['schemas']['ShapeAnnotation'];
export type Annotation =
| TextAnnotation
| DrawAnnotation
| SignatureAnnotation
| ImageAnnotation
| HighlightAnnotation
| ShapeAnnotation;
export type Rect = components['schemas']['Rect'];
export type DrawProps = components['schemas']['DrawProps'];
export type SignatureDrawProps = components['schemas']['SignatureDrawProps'];
export type SignatureTypeProps = components['schemas']['SignatureTypeProps'];
export type ImageProps = components['schemas']['ImageProps'];
export type HighlightProps = components['schemas']['HighlightProps'];
export type ShapeProps = components['schemas']['ShapeProps'];

View file

@ -0,0 +1,95 @@
import { describe, it, expect } from 'vitest';
import {
pdfToScreen,
screenToPdf,
pdfRectToScreen,
screenRectToPdf,
} from './coords';
import type {
ViewportParams,
PdfPoint,
PdfRect
} from './coords';
describe('Coordinate Transforms', () => {
const W = 612; // 8.5 inches at 72dpi
const H = 792; // 11 inches at 72dpi
const rotations = [0, 90, 180, 270];
const zooms = [0.5, 1.0, 2.0];
const dprs = [1, 2];
const testPoints: PdfPoint[] = [
{ x: 0, y: 0 },
{ x: W, y: 0 },
{ x: 0, y: H },
{ x: W, y: H },
{ x: W / 2, y: H / 2 },
{ x: 100, y: 150 },
];
const testRects: PdfRect[] = [
{ x: 0, y: 0, width: 0, height: 0 }, // zero-width
{ x: 10, y: 20, width: 100, height: 200 },
{ x: W - 50, y: H - 50, width: 50, height: 50 }, // boundary
];
it('should round-trip points accurately', () => {
for (const rotation of rotations) {
for (const scale of zooms) {
for (const dpr of dprs) {
const vp: ViewportParams = { scale, rotation, canonicalWidth: W, canonicalHeight: H, dpr };
for (const p of testPoints) {
const screen = pdfToScreen(p, vp);
const backToPdf = screenToPdf(screen, vp);
expect(backToPdf.x).toBeCloseTo(p.x, 2);
expect(backToPdf.y).toBeCloseTo(p.y, 2);
}
}
}
}
});
it('should round-trip rects accurately', () => {
for (const rotation of rotations) {
for (const scale of zooms) {
for (const dpr of dprs) {
const vp: ViewportParams = { scale, rotation, canonicalWidth: W, canonicalHeight: H, dpr };
for (const r of testRects) {
const screenR = pdfRectToScreen(r, vp);
const backToPdf = screenRectToPdf(screenR, vp);
expect(backToPdf.x).toBeCloseTo(r.x, 2);
expect(backToPdf.y).toBeCloseTo(r.y, 2);
expect(backToPdf.width).toBeCloseTo(r.width, 2);
expect(backToPdf.height).toBeCloseTo(r.height, 2);
}
}
}
}
});
it('should handle CropBox offset equivalently (since canonical space is relative to CropBox top-left)', () => {
const vp: ViewportParams = { scale: 1, rotation: 0, canonicalWidth: 612, canonicalHeight: 792 };
const p = { x: 306, y: 396 };
const s = pdfToScreen(p, vp);
expect(s.x).toBeCloseTo(306, 2);
expect(s.y).toBeCloseTo(396, 2);
});
it('should correctly rotate points (90 deg)', () => {
const vp: ViewportParams = { scale: 1, rotation: 90, canonicalWidth: W, canonicalHeight: H };
const tl = { x: 0, y: 0 };
const tlS = pdfToScreen(tl, vp);
expect(tlS.x).toBeCloseTo(H, 2);
expect(tlS.y).toBeCloseTo(0, 2);
const bl = { x: 0, y: H };
const blS = pdfToScreen(bl, vp);
expect(blS.x).toBeCloseTo(0, 2);
expect(blS.y).toBeCloseTo(0, 2);
});
});

View file

@ -0,0 +1,74 @@
export interface PdfPoint { x: number; y: number }
export interface ScreenPoint { x: number; y: number }
export interface PdfRect { x: number; y: number; width: number; height: number }
export interface ScreenRect { x: number; y: number; width: number; height: number }
export interface ViewportParams {
scale: number; // CSS Zoom level (1.0 = 100%)
rotation: number; // 0, 90, 180, 270 (clockwise)
canonicalWidth: number; // Unrotated CropBox width in PDF points
canonicalHeight: number;// Unrotated CropBox height in PDF points
dpr?: number; // Device Pixel Ratio (defaults to 1)
}
function getEffectiveScale(vp: ViewportParams): number {
return vp.scale * (vp.dpr || 1);
}
export function pdfToScreen(p: PdfPoint, vp: ViewportParams): ScreenPoint {
const s = getEffectiveScale(vp);
const { rotation, canonicalWidth: W, canonicalHeight: H } = vp;
const { x, y } = p;
const rot = ((rotation % 360) + 360) % 360;
switch (rot) {
case 0: return { x: x * s, y: y * s };
case 90: return { x: (H - y) * s, y: x * s };
case 180: return { x: (W - x) * s, y: (H - y) * s };
case 270: return { x: y * s, y: (W - x) * s };
default: throw new Error(`Invalid rotation: ${rotation}`);
}
}
export function screenToPdf(p: ScreenPoint, vp: ViewportParams): PdfPoint {
const s = getEffectiveScale(vp);
const { rotation, canonicalWidth: W, canonicalHeight: H } = vp;
const sx = p.x / s;
const sy = p.y / s;
const rot = ((rotation % 360) + 360) % 360;
switch (rot) {
case 0: return { x: sx, y: sy };
case 90: return { x: sy, y: H - sx };
case 180: return { x: W - sx, y: H - sy };
case 270: return { x: W - sy, y: sx };
default: throw new Error(`Invalid rotation: ${rotation}`);
}
}
export function pdfRectToScreen(r: PdfRect, vp: ViewportParams): ScreenRect {
const p1 = pdfToScreen({ x: r.x, y: r.y }, vp);
const p2 = pdfToScreen({ x: r.x + r.width, y: r.y + r.height }, vp);
return {
x: Math.min(p1.x, p2.x),
y: Math.min(p1.y, p2.y),
width: Math.abs(p2.x - p1.x),
height: Math.abs(p2.y - p1.y)
};
}
export function screenRectToPdf(r: ScreenRect, vp: ViewportParams): PdfRect {
const p1 = screenToPdf({ x: r.x, y: r.y }, vp);
const p2 = screenToPdf({ x: r.x + r.width, y: r.y + r.height }, vp);
return {
x: Math.min(p1.x, p2.x),
y: Math.min(p1.y, p2.y),
width: Math.abs(p2.x - p1.x),
height: Math.abs(p2.y - p1.y)
};
}

View file

@ -1,24 +1,38 @@
import { useParams } from 'react-router-dom'
import { useParams, Link } from 'react-router-dom'
import { useEffect } from 'react'
import { PdfDocument } from '../features/editor/pages/PdfDocument'
import { EditorToolbar } from '../features/editor/toolbar/EditorToolbar'
import { useEditorStore } from '../features/editor/store'
import { useAutosave } from '../features/editor/useAutosave'
import '../features/editor/tools'
/**
* Editor page PDF canvas workspace.
* Full implementation begins in Phase 2 (rendering) and Phase 3 (annotations).
*/
export function EditorPage() {
const { id } = useParams<{ id: string }>()
const { setDocumentId } = useEditorStore()
useAutosave()
useEffect(() => {
if (id) {
setDocumentId(id)
}
}, [id, setDocumentId])
if (!id) return <div>Invalid document ID</div>
return (
<div className="flex h-screen flex-col bg-neutral-100">
<div className="flex h-screen flex-col bg-neutral-100 overflow-hidden">
{/* Editor toolbar */}
<header className="flex items-center justify-between border-b border-neutral-200 bg-white px-4 py-2">
<header className="flex-none flex items-center justify-between border-b border-neutral-200 bg-white px-4 py-2 z-10 shadow-sm">
<div className="flex items-center gap-3">
<a
href="/"
className="rounded-md p-1.5 text-neutral-500 transition-colors hover:bg-neutral-100
hover:text-neutral-700"
<Link
to="/"
className="rounded-md p-1.5 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-neutral-700"
>
Back
</a>
</Link>
<span className="text-sm font-medium text-neutral-700">
Document {id}
</span>
@ -30,20 +44,18 @@ export function EditorPage() {
</span>
<button
type="button"
className="rounded-lg bg-accent-500 px-3 py-1.5 text-sm font-medium text-white
transition-colors hover:bg-accent-600"
className="rounded-lg bg-accent-500 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-accent-600"
>
Export PDF
</button>
</div>
</header>
{/* Canvas area */}
<main className="flex flex-1 items-center justify-center overflow-auto">
<div className="text-sm text-neutral-400">
PDF viewer will render here (Phase 2)
</div>
</main>
{/* Main workspace area */}
<div className="flex-1 overflow-hidden relative">
<EditorToolbar />
<PdfDocument documentId={id} />
</div>
</div>
)
}

1501
frontend/src/types/api.d.ts vendored Normal file

File diff suppressed because it is too large Load diff

1499
frontend/src/types/api.ts Normal file

File diff suppressed because it is too large Load diff