Phase 3 implementation. Coordinate system implemented. Initial text box tool implemented.
This commit is contained in:
parent
c159ad4f37
commit
4cb038ec78
34 changed files with 6676 additions and 130 deletions
181
frontend/src/features/editor/canvas/AnnotationLayer.tsx
Normal file
181
frontend/src/features/editor/canvas/AnnotationLayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
frontend/src/features/editor/pages/PageRenderer.tsx
Normal file
88
frontend/src/features/editor/pages/PageRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
frontend/src/features/editor/pages/PageStack.tsx
Normal file
32
frontend/src/features/editor/pages/PageStack.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
frontend/src/features/editor/pages/PdfDocument.tsx
Normal file
53
frontend/src/features/editor/pages/PdfDocument.tsx
Normal 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} />;
|
||||
}
|
||||
50
frontend/src/features/editor/store.ts
Normal file
50
frontend/src/features/editor/store.ts
Normal 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 }),
|
||||
}));
|
||||
63
frontend/src/features/editor/toolbar/EditorToolbar.tsx
Normal file
63
frontend/src/features/editor/toolbar/EditorToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
148
frontend/src/features/editor/toolbar/TextFormatToolbar.tsx
Normal file
148
frontend/src/features/editor/toolbar/TextFormatToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
141
frontend/src/features/editor/tools/TextTool.ts
Normal file
141
frontend/src/features/editor/tools/TextTool.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
28
frontend/src/features/editor/tools/index.ts
Normal file
28
frontend/src/features/editor/tools/index.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
75
frontend/src/features/editor/useAutosave.ts
Normal file
75
frontend/src/features/editor/useAutosave.ts
Normal 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]);
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
|
|
|||
43
frontend/src/lib/annotations/registry.ts
Normal file
43
frontend/src/lib/annotations/registry.ts
Normal 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());
|
||||
}
|
||||
28
frontend/src/lib/annotations/types.ts
Normal file
28
frontend/src/lib/annotations/types.ts
Normal 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'];
|
||||
95
frontend/src/lib/coords.test.ts
Normal file
95
frontend/src/lib/coords.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
74
frontend/src/lib/coords.ts
Normal file
74
frontend/src/lib/coords.ts
Normal 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)
|
||||
};
|
||||
}
|
||||
|
|
@ -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
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
1499
frontend/src/types/api.ts
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue