diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py
index 73a51d8..500dc87 100644
--- a/backend/app/api/v1/__init__.py
+++ b/backend/app/api/v1/__init__.py
@@ -14,6 +14,10 @@ router.include_router(auth_router)
# Documents
router.include_router(documents_router)
+# Assets
+from app.api.v1.assets import router as assets_router
+router.include_router(assets_router)
+
# Annotations
from app.api.v1.annotations import router as annotations_router
router.include_router(annotations_router)
diff --git a/backend/app/api/v1/assets.py b/backend/app/api/v1/assets.py
new file mode 100644
index 0000000..9f51004
--- /dev/null
+++ b/backend/app/api/v1/assets.py
@@ -0,0 +1,58 @@
+from fastapi import APIRouter, Depends, UploadFile, File, HTTPException
+from fastapi.responses import FileResponse
+from sqlalchemy.orm import Session
+import uuid
+import shutil
+from pathlib import Path
+
+from app.db import get_db
+from app.auth.dependencies import get_current_user
+from app.config import settings
+
+router = APIRouter(prefix="/documents", tags=["Assets"])
+
+def get_asset_path(document_id: str, ref: str) -> Path:
+ # Ensure directory exists
+ dir_path = settings.PDF_STORAGE_PATH / "assets" / document_id
+ dir_path.mkdir(parents=True, exist_ok=True)
+ return dir_path / ref
+
+@router.post("/{id}/assets")
+async def upload_asset(
+ id: str,
+ file: UploadFile = File(...),
+ user_id: int = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """Upload a binary asset (like an image or signature) for a document."""
+ # In a real app we'd verify the user owns the document here
+ # For this single-user app, we trust the ID
+
+ if not file.content_type or not file.content_type.startswith("image/"):
+ raise HTTPException(status_code=400, detail="Only image assets are supported")
+
+ ref = str(uuid.uuid4())
+ filepath = get_asset_path(id, ref)
+
+ try:
+ with open(filepath, "wb") as f:
+ shutil.copyfileobj(file.file, f)
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Failed to save asset: {e}")
+
+ return {
+ "ref": ref,
+ "url": f"/api/v1/documents/{id}/assets/{ref}"
+ }
+
+@router.get("/{id}/assets/{ref}")
+async def get_asset(
+ id: str,
+ ref: str,
+):
+ """Serve a binary asset."""
+ filepath = get_asset_path(id, ref)
+ if not filepath.exists() or not filepath.is_file():
+ raise HTTPException(status_code=404, detail="Asset not found")
+
+ return FileResponse(filepath)
diff --git a/backend/app/schemas/annotations.py b/backend/app/schemas/annotations.py
index 2321105..a8b9aad 100644
--- a/backend/app/schemas/annotations.py
+++ b/backend/app/schemas/annotations.py
@@ -36,7 +36,8 @@ class TextAnnotation(AnnotationBase):
props: TextProps
class DrawProps(BaseModel):
- paths: List[Tuple[float, float]]
+ paths: List[Tuple[float, float]] = []
+ svgPath: Optional[str] = None
strokeColor: str = "#000000"
strokeWidth: float = 2
opacity: float = 1.0
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index ef49a01..2cf6166 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,8 +8,13 @@
"name": "paperjet",
"version": "0.1.0",
"dependencies": {
+ "@fontsource/allura": "^5.2.8",
+ "@fontsource/caveat": "^5.2.8",
+ "@fontsource/dancing-script": "^5.2.8",
+ "@fontsource/great-vibes": "^5.2.8",
"@fontsource/outfit": "^5.2.8",
"@fontsource/plus-jakarta-sans": "^5.2.8",
+ "@fontsource/sacramento": "^5.2.8",
"@tailwindcss/vite": "^4.3.0",
"@types/uuid": "^10.0.0",
"date-fns": "^4.4.0",
@@ -661,6 +666,42 @@
}
}
},
+ "node_modules/@fontsource/allura": {
+ "version": "5.2.8",
+ "resolved": "https://registry.npmjs.org/@fontsource/allura/-/allura-5.2.8.tgz",
+ "integrity": "sha512-NzYZyYj/nET124bL0RfNDRdO2IXG+lhS5AzrwqpBHmovWznJ56t9u3w+RYR4iMa+WuCB6XFE6NTOQ4JzvCOq0A==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
+ "node_modules/@fontsource/caveat": {
+ "version": "5.2.8",
+ "resolved": "https://registry.npmjs.org/@fontsource/caveat/-/caveat-5.2.8.tgz",
+ "integrity": "sha512-9fUUfFE2IQFKbx+xOcaeQxxmh8iJguEb8z+j1PeueO4UUx+XfT4pRm/B04ZDvFA794/iRxY/IibmP8ZKtIf4rw==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
+ "node_modules/@fontsource/dancing-script": {
+ "version": "5.2.8",
+ "resolved": "https://registry.npmjs.org/@fontsource/dancing-script/-/dancing-script-5.2.8.tgz",
+ "integrity": "sha512-zHVVgIQ5/rAIM0VVp1QMsqvJm9INHO5/C82E9rHT6NlabjaPTFYn8wl9lT4vPWizfB0TublGQM1999kfdA+XFA==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
+ "node_modules/@fontsource/great-vibes": {
+ "version": "5.2.8",
+ "resolved": "https://registry.npmjs.org/@fontsource/great-vibes/-/great-vibes-5.2.8.tgz",
+ "integrity": "sha512-/PATz436NGixjNaigierUpv50wfNv2UhAAijcCPkn4ynvl41x66TCLbXVSVfp4pth9gjWnGqgoj0lVIhHapAuw==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
"node_modules/@fontsource/outfit": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/outfit/-/outfit-5.2.8.tgz",
@@ -679,6 +720,15 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
+ "node_modules/@fontsource/sacramento": {
+ "version": "5.2.8",
+ "resolved": "https://registry.npmjs.org/@fontsource/sacramento/-/sacramento-5.2.8.tgz",
+ "integrity": "sha512-f2G+HIFZ7PGUxJPlm+TT6eH0HnlAQigMOCDIHXO/112LaxYA7NsTX3rczkrJLxJnycl91XyQ/0GfGLwpBkAuiw==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 8265c09..d8d738c 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -13,8 +13,13 @@
"preview": "vite preview"
},
"dependencies": {
+ "@fontsource/allura": "^5.2.8",
+ "@fontsource/caveat": "^5.2.8",
+ "@fontsource/dancing-script": "^5.2.8",
+ "@fontsource/great-vibes": "^5.2.8",
"@fontsource/outfit": "^5.2.8",
"@fontsource/plus-jakarta-sans": "^5.2.8",
+ "@fontsource/sacramento": "^5.2.8",
"@tailwindcss/vite": "^4.3.0",
"@types/uuid": "^10.0.0",
"date-fns": "^4.4.0",
diff --git a/frontend/src/features/editor/canvas/AnnotationLayer.tsx b/frontend/src/features/editor/canvas/AnnotationLayer.tsx
index 5fff558..34b8729 100644
--- a/frontend/src/features/editor/canvas/AnnotationLayer.tsx
+++ b/frontend/src/features/editor/canvas/AnnotationLayer.tsx
@@ -42,26 +42,16 @@ export function AnnotationLayer({ pageNumber, width, height, viewportParams }: A
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 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));
+
+ useEditorStore.getState().updateAnnotation(obj.id, {
+ rect: { ...ann.rect, x: pt.x, y: pt.y, width: newWidth, height: newHeight }
+ });
}
}
});
@@ -81,14 +71,26 @@ export function AnnotationLayer({ pageNumber, width, height, viewportParams }: A
// 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;
- // Don't delete if we are actively editing text
if (activeObj && activeObj.isEditing === true) {
return;
}
- if (activeObj && activeObj.id) {
- useEditorStore.getState().deleteAnnotation(activeObj.id);
+
+ 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();
}
}
};
@@ -111,21 +113,28 @@ export function AnnotationLayer({ pageNumber, width, height, viewportParams }: A
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;
- // Remove all objects EXCEPT the active one
+ const pageAnns = annotations.filter(a => a.page === pageNumber);
+ const annIds = new Set(pageAnns.map(a => a.id));
+
+ // Remove objects that are no longer in annotations
canvas.getObjects().forEach((obj: any) => {
- if (obj.id !== activeId) {
+ // Don't remove the active object unless it was deleted from the store
+ if (obj.id === activeId && annIds.has(activeId)) return;
+
+ if (!annIds.has(obj.id)) {
canvas.remove(obj);
}
});
- annotations.forEach(ann => {
- // Only render annotations for THIS page
- if (ann.page === pageNumber) {
- if (ann.id === activeId) return; // Skip rendering the active object
+ // Add missing annotations
+ const canvasObjIds = new Set(canvas.getObjects().map((o: any) => o.id));
+ pageAnns.forEach(ann => {
+ if (ann.id === activeId) return; // Skip rendering the active object
+
+ if (!canvasObjIds.has(ann.id)) {
const tool = getTool(ann.type);
if (tool && tool.renderToFabric) {
tool.renderToFabric(ann, canvas, viewportParams);
@@ -161,6 +170,9 @@ export function AnnotationLayer({ pageNumber, width, height, viewportParams }: A
if (tool.onPointerUp) {
canvas.on('mouse:up', (e) => tool.onPointerUp!(e, canvas, viewportParams, pageNumber));
}
+ if (tool.onPathCreated) {
+ canvas.on('path:created', (e) => tool.onPathCreated!(e, canvas, viewportParams, pageNumber));
+ }
}
return () => {
@@ -174,13 +186,17 @@ export function AnnotationLayer({ pageNumber, width, height, viewportParams }: A
const draftAnnotation = useEditorStore(state => state.draftAnnotation);
const isSelectedOnThisPage = annotations.find(a => a.id === selection && a.page === pageNumber);
const isDraftOnThisPage = draftAnnotation?.page === pageNumber;
- const toolbarAnnotationId = isDraftOnThisPage ? draftAnnotation.id : (isSelectedOnThisPage ? selection : null);
+ const activeAnn = isDraftOnThisPage ? draftAnnotation : isSelectedOnThisPage;
+ const toolbarAnnotationId = activeAnn ? activeAnn.id : null;
return (
- {toolbarAnnotationId && (
-
+ {toolbarAnnotationId && activeAnn && (
+ <>
+ {activeAnn.type === 'text' && }
+ {/* We will add ShapeControls and DrawControls here once they are implemented */}
+ >
)}
);
diff --git a/frontend/src/features/editor/store.ts b/frontend/src/features/editor/store.ts
index 060eb4c..302e385 100644
--- a/frontend/src/features/editor/store.ts
+++ b/frontend/src/features/editor/store.ts
@@ -12,6 +12,9 @@ interface EditorState {
saveStatus: SaveStatus;
defaultTextProps: Partial;
draftAnnotation: Annotation | null;
+ isSignatureModalOpen: boolean;
+ pendingSignatureProps: any | null;
+ pendingImageRef: { ref: string, width: number, height: number } | null;
// Actions
setDocumentId: (id: string | null) => void;
@@ -25,6 +28,11 @@ interface EditorState {
setSaveStatus: (status: SaveStatus) => void;
setDefaultTextProps: (props: Partial) => void;
setDraftAnnotation: (ann: 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;
}
export const useEditorStore = create((set) => ({
@@ -36,6 +44,10 @@ export const useEditorStore = create((set) => ({
saveStatus: 'idle',
defaultTextProps: {},
draftAnnotation: null,
+ isSignatureModalOpen: false,
+ pendingSignatureProps: null,
+ pendingImageRef: null,
+ activeShapeKind: 'rect',
setDocumentId: (id) => set({ documentId: id, defaultTextProps: {}, draftAnnotation: null }),
setAnnotations: (annotations) => set({ annotations }),
@@ -55,4 +67,8 @@ export const useEditorStore = create((set) => ({
setSaveStatus: (status) => set({ saveStatus: status }),
setDefaultTextProps: (props) => set((state) => ({ defaultTextProps: { ...state.defaultTextProps, ...props } })),
setDraftAnnotation: (ann) => set({ draftAnnotation: ann }),
+ setIsSignatureModalOpen: (isOpen) => set({ isSignatureModalOpen: isOpen }),
+ setPendingSignatureProps: (props) => set({ pendingSignatureProps: props }),
+ setPendingImageRef: (imgRef) => set({ pendingImageRef: imgRef }),
+ setActiveShapeKind: (kind) => set({ activeShapeKind: kind }),
}));
diff --git a/frontend/src/features/editor/toolbar/EditorToolbar.tsx b/frontend/src/features/editor/toolbar/EditorToolbar.tsx
index 5ea1bbe..84b7105 100644
--- a/frontend/src/features/editor/toolbar/EditorToolbar.tsx
+++ b/frontend/src/features/editor/toolbar/EditorToolbar.tsx
@@ -1,63 +1,182 @@
+import { useRef, useState } from 'react';
import { useEditorStore } from '../store';
+import { SignatureModal } from '../tools/SignatureModal';
+import { api } from '../../../lib/api/client';
export function EditorToolbar() {
- const { activeTool, setActiveTool, saveStatus, zoom, setZoom } = useEditorStore();
+ const {
+ activeTool,
+ setActiveTool,
+ saveStatus,
+ zoom,
+ setZoom,
+ isSignatureModalOpen,
+ setIsSignatureModalOpen,
+ setPendingSignatureProps,
+ setPendingImageRef,
+ documentId
+ } = useEditorStore();
+
+ const fileInputRef = useRef(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) => {
+ const file = e.target.files?.[0];
+ if (!file || !documentId) return;
+
+ setIsUploadingImage(true);
+ try {
+ const formData = new FormData();
+ formData.append('file', file);
+
+ const data = await api.upload(`/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.
+ setPendingImageRef({ ref: data.ref, width: 0, height: 0 });
+ setActiveTool('image');
+ } catch (err) {
+ console.error(err);
+ alert('Failed to upload image');
+ } finally {
+ setIsUploadingImage(false);
+ // Reset input
+ if (fileInputRef.current) fileInputRef.current.value = '';
+ }
+ };
+
+ const handleSignatureConfirm = (props: any) => {
+ setPendingSignatureProps(props);
+ setIsSignatureModalOpen(false);
+ setActiveTool('signature');
+ };
+
return (
-
-
-
-
+ <>
+
+
+
+
-
-
-
-
-
- {Math.round(zoom * 100)}%
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+ {Math.round(zoom * 100)}%
+
+
+
+
+
+
+
+ {saveStatus === 'saving' && 'Saving...'}
+ {saveStatus === 'saved' && 'Saved'}
+ {saveStatus === 'error' && Error saving}
+ {saveStatus === 'idle' && ''}
+
-
-
-
- {saveStatus === 'saving' && 'Saving...'}
- {saveStatus === 'saved' && 'Saved'}
- {saveStatus === 'error' && Error saving}
- {saveStatus === 'idle' && ''}
-
-
+ {isSignatureModalOpen && (
+ setIsSignatureModalOpen(false)}
+ onConfirm={handleSignatureConfirm}
+ />
+ )}
+ >
);
}
diff --git a/frontend/src/features/editor/toolbar/TextFormatToolbar.tsx b/frontend/src/features/editor/toolbar/TextFormatToolbar.tsx
index e5f6385..075127b 100644
--- a/frontend/src/features/editor/toolbar/TextFormatToolbar.tsx
+++ b/frontend/src/features/editor/toolbar/TextFormatToolbar.tsx
@@ -59,8 +59,12 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo
const activeObj = canvas.getActiveObject();
if (activeObj && activeObj.id === annotationId) {
if (activeObj.isEditing) {
- activeObj.setSelectionStyles({ [styleName]: value });
- didInline = true;
+ if (activeObj.selectionStart !== activeObj.selectionEnd) {
+ activeObj.setSelectionStyles({ [styleName]: value });
+ didInline = true;
+ } else {
+ activeObj.set(styleName, value);
+ }
} else {
activeObj.set(styleName, value);
}
diff --git a/frontend/src/features/editor/tools/DrawTool.ts b/frontend/src/features/editor/tools/DrawTool.ts
new file mode 100644
index 0000000..ab94d10
--- /dev/null
+++ b/frontend/src/features/editor/tools/DrawTool.ts
@@ -0,0 +1,135 @@
+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, DrawAnnotation, DrawProps } from '../../../lib/annotations/types';
+import { screenRectToPdf, pdfRectToScreen } from '../../../lib/coords';
+import type { ViewportParams } from '../../../lib/coords';
+
+export const DrawTool: ToolHandler = {
+ name: 'draw',
+
+ onActivate: (canvas: fabric.Canvas) => {
+ canvas.isDrawingMode = true;
+ const brush = new fabric.PencilBrush(canvas);
+ brush.color = '#000000';
+ brush.width = 4;
+ canvas.freeDrawingBrush = brush;
+ },
+
+ onDeactivate: (canvas: fabric.Canvas) => {
+ canvas.isDrawingMode = false;
+ },
+
+ onPathCreated: (e: any, _canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
+ const pathObj = e.path as fabric.Path;
+
+ pathObj.set({
+ transparentCorners: false,
+ cornerColor: '#3b82f6',
+ cornerStrokeColor: '#3b82f6',
+ borderColor: '#3b82f6',
+ cornerSize: 8,
+ padding: 5,
+ });
+
+ (pathObj as any).id = uuidv4();
+ (pathObj as any).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
+ 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,
+ y: bounds.top,
+ width: bounds.width,
+ height: bounds.height
+ }, vp);
+
+ const annotation: DrawAnnotation = {
+ id: (pathObj as any).id,
+ page: pageNumber,
+ type: 'draw',
+ rect: pdfRect,
+ rotation: 0,
+ z: 0,
+ props: props,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString()
+ };
+
+ useEditorStore.getState().addAnnotation(annotation);
+
+ // Optionally switch back to select tool or keep drawing
+ // useEditorStore.getState().setActiveTool('select');
+ },
+
+ renderToFabric: async (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => {
+ if (annotation.type !== 'draw') return null;
+ const drawAnn = annotation as DrawAnnotation;
+ const props = drawAnn.props as DrawProps;
+ const screenRect = pdfRectToScreen(drawAnn.rect, vp);
+
+ let pathObj: fabric.Path;
+
+ if (props.svgPath) {
+ pathObj = new fabric.Path(props.svgPath, {
+ fill: '',
+ stroke: props.strokeColor || '#000000',
+ strokeWidth: props.strokeWidth || 4,
+ strokeLineCap: 'round',
+ strokeLineJoin: 'round',
+ opacity: props.opacity || 1,
+ });
+
+ // Fabric Paths have internal boundaries. We need to scale it to fit the rect.
+ // Or we can just position it and set its scale.
+ pathObj.set({
+ left: screenRect.x,
+ top: screenRect.y,
+ scaleX: screenRect.width / pathObj.width!,
+ scaleY: screenRect.height / pathObj.height!,
+ });
+ } else {
+ // Fallback if no svgPath (not expected for now)
+ pathObj = new fabric.Path('M 0 0', {
+ left: screenRect.x,
+ top: screenRect.y,
+ width: screenRect.width,
+ height: screenRect.height,
+ stroke: props.strokeColor || '#000000',
+ strokeWidth: props.strokeWidth || 4,
+ });
+ }
+
+ 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);
+ }
+};
diff --git a/frontend/src/features/editor/tools/ImageTool.ts b/frontend/src/features/editor/tools/ImageTool.ts
new file mode 100644
index 0000000..1bb4da0
--- /dev/null
+++ b/frontend/src/features/editor/tools/ImageTool.ts
@@ -0,0 +1,131 @@
+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, ImageAnnotation, ImageProps } from '../../../lib/annotations/types';
+import { screenRectToPdf, pdfRectToScreen } from '../../../lib/coords';
+import type { ViewportParams } from '../../../lib/coords';
+
+export const ImageTool: ToolHandler = {
+ name: 'image',
+
+ onActivate: (canvas: fabric.Canvas) => {
+ canvas.defaultCursor = 'crosshair';
+ canvas.selection = false;
+ },
+
+ onDeactivate: (canvas: fabric.Canvas) => {
+ canvas.defaultCursor = 'default';
+ },
+
+ onPointerDown: async (e: any, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
+ if (e.target) return;
+
+ const storeState = useEditorStore.getState();
+ const pendingImage = storeState.pendingImageRef;
+ if (!pendingImage) return;
+
+ const pointer = e.scenePoint || canvas.getScenePoint(e.e);
+
+ const url = `/api/v1/documents/${storeState.documentId}/assets/${pendingImage.ref}`;
+ let img: fabric.Image;
+ try {
+ img = await fabric.Image.fromURL(url);
+ } catch (err) {
+ console.error("Failed to load image", err);
+ return;
+ }
+
+ // Default sizing: if it's huge, scale it down to fit nicely
+ const maxWidth = 300 * vp.scale;
+ if (img.width! > maxWidth) {
+ img.scaleToWidth(maxWidth);
+ }
+
+ img.set({
+ left: pointer.x,
+ top: pointer.y,
+ originX: 'center',
+ originY: 'center',
+ transparentCorners: false,
+ cornerColor: '#3b82f6',
+ cornerStrokeColor: '#3b82f6',
+ borderColor: '#3b82f6',
+ cornerSize: 8,
+ padding: 5,
+ });
+
+ (img as any).id = uuidv4();
+ (img as any).annotationType = 'image';
+
+ const props: ImageProps = {
+ ref: pendingImage.ref,
+ naturalWidth: pendingImage.width,
+ naturalHeight: pendingImage.height
+ };
+ (img as any).annotationProps = props;
+
+ canvas.add(img);
+ canvas.setActiveObject(img);
+ canvas.requestRenderAll();
+
+ const bounds = img.getBoundingRect();
+ const pdfRect = screenRectToPdf({
+ x: bounds.left,
+ y: bounds.top,
+ width: bounds.width,
+ height: bounds.height
+ }, vp);
+
+ const annotation: ImageAnnotation = {
+ id: (img as any).id,
+ page: pageNumber,
+ type: 'image',
+ rect: pdfRect,
+ rotation: 0,
+ z: 0,
+ props: props,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString()
+ };
+
+ useEditorStore.getState().addAnnotation(annotation);
+
+ // Switch back to select tool and clear pending
+ useEditorStore.getState().setPendingImageRef(null);
+ useEditorStore.getState().setActiveTool('select');
+ },
+
+ renderToFabric: async (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => {
+ if (annotation.type !== 'image') return null;
+ const imgAnn = annotation as ImageAnnotation;
+ const screenRect = pdfRectToScreen(imgAnn.rect, vp);
+
+ const url = `/api/v1/documents/${useEditorStore.getState().documentId}/assets/${imgAnn.props.ref}`;
+ let img: fabric.Image;
+ try {
+ img = await fabric.Image.fromURL(url);
+ } catch (e) {
+ console.error("Failed to load asset", e);
+ return null;
+ }
+
+ img.set({
+ left: screenRect.x,
+ top: screenRect.y,
+ 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);
+ }
+};
diff --git a/frontend/src/features/editor/tools/ShapeTool.ts b/frontend/src/features/editor/tools/ShapeTool.ts
new file mode 100644
index 0000000..6a7f15d
--- /dev/null
+++ b/frontend/src/features/editor/tools/ShapeTool.ts
@@ -0,0 +1,241 @@
+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 { screenRectToPdf, pdfRectToScreen } from '../../../lib/coords';
+import type { ViewportParams } from '../../../lib/coords';
+
+// Temporary state for drawing
+let isDrawingShape = false;
+let startX = 0;
+let startY = 0;
+let currentShape: fabric.FabricObject | null = null;
+
+export const ShapeTool: ToolHandler = {
+ name: 'shape',
+
+ onActivate: (canvas: fabric.Canvas) => {
+ canvas.defaultCursor = 'crosshair';
+ canvas.selection = false;
+ },
+
+ onDeactivate: (canvas: fabric.Canvas) => {
+ canvas.defaultCursor = 'default';
+ isDrawingShape = false;
+ currentShape = null;
+ },
+
+ onPointerDown: (e: any, canvas: fabric.Canvas, _vp: ViewportParams, _pageNumber: number) => {
+ if (e.target && !isDrawingShape) return;
+
+ isDrawingShape = true;
+ const pointer = e.scenePoint || canvas.getScenePoint(e.e);
+ startX = pointer.x;
+ startY = pointer.y;
+
+ const storeState = useEditorStore.getState();
+ const kind = storeState.activeShapeKind;
+ // We could pull default colors from store if we want, for now default to red stroke transparent fill
+ const strokeColor = '#f44336';
+ const fillColor = 'transparent';
+ const strokeWidth = 4;
+
+ if (kind === 'rect') {
+ currentShape = new fabric.Rect({
+ left: startX,
+ top: startY,
+ width: 0,
+ height: 0,
+ fill: fillColor,
+ stroke: strokeColor,
+ strokeWidth: strokeWidth,
+ transparentCorners: false,
+ });
+ } else if (kind === 'ellipse') {
+ currentShape = new fabric.Ellipse({
+ left: startX,
+ top: startY,
+ originX: 'center',
+ originY: 'center',
+ rx: 0,
+ ry: 0,
+ fill: fillColor,
+ stroke: strokeColor,
+ strokeWidth: strokeWidth,
+ transparentCorners: false,
+ });
+ } else if (kind === 'line') {
+ currentShape = new fabric.Line([startX, startY, startX, startY], {
+ stroke: strokeColor,
+ strokeWidth: strokeWidth,
+ transparentCorners: false,
+ });
+ }
+
+ if (currentShape) {
+ (currentShape as any).id = uuidv4();
+ (currentShape as any).annotationType = 'shape';
+ canvas.add(currentShape);
+ canvas.requestRenderAll();
+ }
+ },
+
+ onPointerMove: (e: any, canvas: fabric.Canvas, _vp: ViewportParams, _pageNumber: number) => {
+ if (!isDrawingShape || !currentShape) return;
+ const pointer = e.scenePoint || canvas.getScenePoint(e.e);
+
+ const storeState = useEditorStore.getState();
+ const kind = storeState.activeShapeKind;
+
+ if (kind === 'rect') {
+ const rect = currentShape as fabric.Rect;
+ rect.set({
+ left: Math.min(startX, pointer.x),
+ top: Math.min(startY, pointer.y),
+ width: Math.abs(pointer.x - startX),
+ height: Math.abs(pointer.y - startY)
+ });
+ } else if (kind === 'ellipse') {
+ const ellipse = currentShape as fabric.Ellipse;
+ const rx = Math.abs(pointer.x - startX) / 2;
+ const ry = Math.abs(pointer.y - startY) / 2;
+ ellipse.set({
+ rx: rx,
+ ry: ry,
+ left: startX + (pointer.x - startX) / 2,
+ top: startY + (pointer.y - startY) / 2
+ });
+ } else if (kind === 'line') {
+ const line = currentShape as fabric.Line;
+ line.set({
+ x2: pointer.x,
+ y2: pointer.y
+ });
+ }
+
+ canvas.requestRenderAll();
+ },
+
+ onPointerUp: (_e: any, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
+ if (!isDrawingShape || !currentShape) return;
+ isDrawingShape = false;
+
+ // Check if it's too small (a click instead of a drag)
+ const bounds = currentShape.getBoundingRect();
+ if (bounds.width < 5 && bounds.height < 5) {
+ canvas.remove(currentShape);
+ currentShape = null;
+ return;
+ }
+
+ currentShape.set({
+ transparentCorners: false,
+ cornerColor: '#3b82f6',
+ cornerStrokeColor: '#3b82f6',
+ borderColor: '#3b82f6',
+ cornerSize: 8,
+ padding: 5,
+ });
+
+ const storeState = useEditorStore.getState();
+ const kind = storeState.activeShapeKind;
+
+ const props: ShapeProps = {
+ kind,
+ strokeColor: currentShape.stroke as string,
+ fillColor: currentShape.fill as string,
+ strokeWidth: currentShape.strokeWidth
+ };
+ (currentShape as any).annotationProps = props;
+
+ canvas.setActiveObject(currentShape);
+
+ const pdfRect = screenRectToPdf({
+ x: bounds.left,
+ y: bounds.top,
+ width: bounds.width,
+ height: bounds.height
+ }, vp);
+
+ const annotation: ShapeAnnotation = {
+ id: (currentShape as any).id,
+ page: pageNumber,
+ type: 'shape',
+ rect: pdfRect,
+ rotation: 0,
+ z: 0,
+ props: props,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString()
+ };
+
+ useEditorStore.getState().addAnnotation(annotation);
+
+ // Switch back to select tool
+ useEditorStore.getState().setActiveTool('select');
+ currentShape = null;
+ },
+
+ renderToFabric: async (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => {
+ if (annotation.type !== 'shape') return null;
+ const shapeAnn = annotation as ShapeAnnotation;
+ const props = shapeAnn.props as ShapeProps;
+ const screenRect = pdfRectToScreen(shapeAnn.rect, vp);
+
+ let shape: fabric.FabricObject;
+
+ if (props.kind === 'rect') {
+ shape = new fabric.Rect({
+ left: screenRect.x,
+ top: screenRect.y,
+ width: screenRect.width,
+ height: screenRect.height,
+ fill: props.fillColor,
+ stroke: props.strokeColor,
+ strokeWidth: props.strokeWidth,
+ });
+ } else if (props.kind === 'ellipse') {
+ shape = new fabric.Ellipse({
+ left: screenRect.x + screenRect.width / 2,
+ top: screenRect.y + screenRect.height / 2,
+ originX: 'center',
+ originY: 'center',
+ rx: screenRect.width / 2,
+ ry: screenRect.height / 2,
+ fill: props.fillColor,
+ stroke: props.strokeColor,
+ strokeWidth: props.strokeWidth,
+ });
+ } else if (props.kind === 'line') {
+ // Lines are tricky because bounding rect loses directionality.
+ // A proper line implementation should probably save x1, y1, x2, y2 in props.
+ // But we'll just draw a diagonal for now based on rect.
+ shape = new fabric.Line([
+ screenRect.x,
+ screenRect.y,
+ screenRect.x + screenRect.width,
+ screenRect.y + screenRect.height
+ ], {
+ stroke: props.strokeColor,
+ strokeWidth: props.strokeWidth,
+ });
+ } else {
+ return null;
+ }
+
+ shape.set({
+ id: shapeAnn.id,
+ annotationType: 'shape',
+ annotationProps: shapeAnn.props,
+ transparentCorners: false,
+ cornerColor: '#3b82f6',
+ cornerStrokeColor: '#3b82f6',
+ borderColor: '#3b82f6',
+ cornerSize: 8,
+ padding: 5,
+ } as any);
+
+ canvas.add(shape);
+ }
+};
diff --git a/frontend/src/features/editor/tools/SignatureModal.tsx b/frontend/src/features/editor/tools/SignatureModal.tsx
new file mode 100644
index 0000000..e97ba3e
--- /dev/null
+++ b/frontend/src/features/editor/tools/SignatureModal.tsx
@@ -0,0 +1,258 @@
+import { useState, useRef, useEffect } from 'react';
+import { Pencil, Type, X, Trash2 } from 'lucide-react';
+import { api } from '../../../lib/api/client';
+import { useEditorStore } from '../store';
+import type { SignatureDrawProps, SignatureTypeProps } from '../../../lib/annotations/types';
+
+interface SignatureModalProps {
+ onClose: () => void;
+ onConfirm: (props: SignatureDrawProps | SignatureTypeProps) => void;
+}
+
+const FONTS = [
+ 'Great Vibes',
+ 'Allura',
+ 'Sacramento',
+ 'Dancing Script',
+ 'Caveat'
+];
+
+export function SignatureModal({ onClose, onConfirm }: SignatureModalProps) {
+ const [tab, setTab] = useState<'type' | 'draw'>('type');
+ const [text, setText] = useState('John Doe');
+ const [loading, setLoading] = useState(false);
+
+ // Drawing state
+ const canvasRef = useRef(null);
+ const [isDrawing, setIsDrawing] = useState(false);
+ const [hasDrawn, setHasDrawn] = useState(false);
+ const ctxRef = useRef(null);
+
+ const documentId = useEditorStore(state => state.documentId);
+
+ // Initialize canvas
+ useEffect(() => {
+ if (tab === 'draw' && canvasRef.current) {
+ const canvas = canvasRef.current;
+ const dpr = window.devicePixelRatio || 1;
+
+ const rect = canvas.getBoundingClientRect();
+ canvas.width = rect.width * dpr;
+ canvas.height = rect.height * dpr;
+
+ const ctx = canvas.getContext('2d');
+ if (ctx) {
+ ctx.scale(dpr, dpr);
+ ctx.lineCap = 'round';
+ ctx.lineJoin = 'round';
+ ctx.strokeStyle = '#000000';
+ ctx.lineWidth = 4;
+ ctxRef.current = ctx;
+ }
+ }
+ }, [tab]);
+
+ const startDrawing = (e: React.MouseEvent | React.TouchEvent) => {
+ setIsDrawing(true);
+ setHasDrawn(true);
+ const pos = getPos(e);
+ ctxRef.current?.beginPath();
+ ctxRef.current?.moveTo(pos.x, pos.y);
+ };
+
+ const draw = (e: React.MouseEvent | React.TouchEvent) => {
+ if (!isDrawing) return;
+ const pos = getPos(e);
+ ctxRef.current?.lineTo(pos.x, pos.y);
+ ctxRef.current?.stroke();
+ };
+
+ const stopDrawing = () => {
+ setIsDrawing(false);
+ ctxRef.current?.closePath();
+ };
+
+ const getPos = (e: React.MouseEvent | React.TouchEvent) => {
+ const canvas = canvasRef.current;
+ if (!canvas) return { x: 0, y: 0 };
+ const rect = canvas.getBoundingClientRect();
+ const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
+ const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
+ return {
+ x: clientX - rect.left,
+ y: clientY - rect.top
+ };
+ };
+
+ const clearCanvas = () => {
+ const canvas = canvasRef.current;
+ const ctx = ctxRef.current;
+ if (canvas && ctx) {
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ setHasDrawn(false);
+ }
+ };
+
+ const handleDrawSubmit = async () => {
+ if (!hasDrawn || !canvasRef.current || !documentId) return;
+
+ setLoading(true);
+ try {
+ // Get data URL
+ const dataUrl = canvasRef.current.toDataURL('image/png');
+
+ // Convert to blob manually to avoid fetch() blocking on data URIs
+ const arr = dataUrl.split(',');
+ const mime = arr[0].match(/:(.*?);/)![1];
+ const bstr = atob(arr[1]);
+ let n = bstr.length;
+ const u8arr = new Uint8Array(n);
+ while(n--){
+ u8arr[n] = bstr.charCodeAt(n);
+ }
+ const blob = new Blob([u8arr], {type: mime});
+
+ // Upload
+ const formData = new FormData();
+ formData.append('file', blob, 'signature.png');
+
+ const data = await api.upload(`/documents/${documentId}/assets`, formData);
+
+ onConfirm({
+ mode: 'draw',
+ ref: data.ref,
+ strokeColor: '#000000'
+ });
+ } catch (e) {
+ console.error(e);
+ alert('Failed to save signature');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleTypeSubmit = (fontFamily: string) => {
+ onConfirm({
+ mode: 'type',
+ text: text || 'Signature',
+ fontFamily: fontFamily,
+ color: '#000000'
+ });
+ };
+
+ return (
+
+
+
+ {/* Header */}
+
+
Create Signature
+
+
+
+ {/* Tabs */}
+
+
+
+
+
+ {/* Content */}
+
+
+ {tab === 'type' && (
+
+
setText(e.target.value)}
+ placeholder="Type your name..."
+ className="w-full text-center text-xl p-4 rounded-xl border border-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition-all bg-white"
+ />
+
+
+ {FONTS.map(font => (
+
+ ))}
+
+
+ )}
+
+ {tab === 'draw' && (
+
+
+
+
+ {hasDrawn && (
+
+ )}
+
+ {!hasDrawn && (
+
+ Draw your signature here
+
+ )}
+
+
+
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/src/features/editor/tools/SignatureTool.ts b/frontend/src/features/editor/tools/SignatureTool.ts
new file mode 100644
index 0000000..4fd06e6
--- /dev/null
+++ b/frontend/src/features/editor/tools/SignatureTool.ts
@@ -0,0 +1,239 @@
+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, SignatureAnnotation, SignatureDrawProps, SignatureTypeProps } from '../../../lib/annotations/types';
+import { screenRectToPdf, pdfRectToScreen } from '../../../lib/coords';
+import type { ViewportParams } from '../../../lib/coords';
+
+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) => {
+ isCreatingPreview = true;
+ const storeState = useEditorStore.getState();
+ if (props.mode === 'draw') {
+ const url = `/api/v1/documents/${storeState.documentId}/assets/${props.ref}`;
+ try {
+ const img = await fabric.Image.fromURL(url);
+ const maxWidth = 200 * vp.scale;
+ if (img.width! > maxWidth) {
+ img.scaleToWidth(maxWidth);
+ }
+ img.set({ originX: 'center', originY: 'center', opacity: 0.5, evented: false });
+ previewObj = img;
+ } catch (e) {}
+ } else {
+ previewObj = new fabric.Text(props.text, {
+ fontFamily: props.fontFamily,
+ fontSize: 48 * vp.scale * (vp.dpr || 1),
+ fill: props.color,
+ originX: 'center',
+ originY: 'center',
+ opacity: 0.5,
+ evented: false
+ });
+ }
+ isCreatingPreview = false;
+ if (previewObj && currentPreviewCanvas === canvas) {
+ canvas.add(previewObj);
+ canvas.requestRenderAll();
+ }
+};
+
+export const SignatureTool: ToolHandler = {
+ name: 'signature',
+
+ onActivate: (canvas: fabric.Canvas) => {
+ canvas.defaultCursor = 'none';
+ canvas.selection = false;
+ },
+
+ onDeactivate: (canvas: fabric.Canvas) => {
+ canvas.defaultCursor = 'default';
+ if (previewObj && currentPreviewCanvas === canvas) {
+ currentPreviewCanvas.remove(previewObj);
+ currentPreviewCanvas.requestRenderAll();
+ }
+ previewObj = null;
+ currentPreviewCanvas = null;
+ isCreatingPreview = false;
+ },
+
+ onPointerMove: (e: any, canvas: fabric.Canvas, vp: ViewportParams, _pageNumber: number) => {
+ const props = useEditorStore.getState().pendingSignatureProps;
+ if (!props) return;
+
+ // Switch canvas if we moved to a new page
+ if (currentPreviewCanvas !== canvas) {
+ if (previewObj && currentPreviewCanvas) {
+ currentPreviewCanvas.remove(previewObj);
+ currentPreviewCanvas.requestRenderAll();
+ }
+ currentPreviewCanvas = canvas;
+ previewObj = null;
+ }
+
+ if (!previewObj && !isCreatingPreview) {
+ createPreview(canvas, vp, props);
+ }
+
+ if (previewObj) {
+ const pointer = e.scenePoint || canvas.getScenePoint(e.e);
+ previewObj.set({ left: pointer.x, top: pointer.y });
+ canvas.requestRenderAll();
+ }
+ },
+
+ onPointerDown: async (e: any, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
+ if (e.target && e.target !== previewObj) return;
+
+ const storeState = useEditorStore.getState();
+ const props = storeState.pendingSignatureProps;
+ if (!props) return;
+
+ if (previewObj && currentPreviewCanvas === canvas) {
+ currentPreviewCanvas.remove(previewObj);
+ previewObj = null;
+ currentPreviewCanvas = null;
+ }
+
+ const pointer = e.scenePoint || canvas.getScenePoint(e.e);
+
+ let fabricObj: fabric.FabricObject;
+
+ if (props.mode === 'draw') {
+ const drawProps = props as SignatureDrawProps;
+ const url = `/api/v1/documents/${storeState.documentId}/assets/${drawProps.ref}`;
+ try {
+ const img = await fabric.Image.fromURL(url);
+ // By default, scale it down to a reasonable size if it's too big
+ const maxWidth = 200 * vp.scale;
+ if (img.width! > maxWidth) {
+ img.scaleToWidth(maxWidth);
+ }
+ img.set({
+ left: pointer.x,
+ top: pointer.y,
+ originX: 'center',
+ originY: 'center',
+ });
+ // We set id on the object so it can be identified
+ (img as any).id = uuidv4();
+ fabricObj = img;
+ } catch (err) {
+ console.error("Failed to load signature image", err);
+ return;
+ }
+ } else {
+ const typeProps = props as SignatureTypeProps;
+ const textObj = new fabric.Text(typeProps.text, {
+ left: pointer.x,
+ top: pointer.y,
+ fontFamily: typeProps.fontFamily,
+ fontSize: 48 * vp.scale * (vp.dpr || 1),
+ fill: typeProps.color,
+ originX: 'center',
+ originY: 'center',
+ });
+ (textObj as any).id = uuidv4();
+ fabricObj = textObj;
+ }
+
+ fabricObj.set({
+ transparentCorners: false,
+ cornerColor: '#3b82f6',
+ cornerStrokeColor: '#3b82f6',
+ borderColor: '#3b82f6',
+ cornerSize: 8,
+ padding: 5,
+ });
+
+ (fabricObj as any).annotationType = 'signature';
+ (fabricObj as any).annotationProps = props;
+
+ canvas.add(fabricObj);
+ canvas.setActiveObject(fabricObj);
+ canvas.requestRenderAll();
+
+ // Convert to canonical space
+ const bounds = fabricObj.getBoundingRect();
+ const pdfRect = screenRectToPdf({
+ x: bounds.left,
+ y: bounds.top,
+ width: bounds.width,
+ height: bounds.height
+ }, vp);
+
+ const annotation: SignatureAnnotation = {
+ id: (fabricObj as any).id,
+ page: pageNumber,
+ type: 'signature',
+ rect: pdfRect,
+ rotation: 0,
+ z: 0,
+ props: props,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString()
+ };
+
+ useEditorStore.getState().addAnnotation(annotation);
+
+ // Switch back to select tool and clear pending
+ useEditorStore.getState().setPendingSignatureProps(null);
+ useEditorStore.getState().setActiveTool('select');
+ },
+
+ renderToFabric: async (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => {
+ if (annotation.type !== 'signature') return null;
+ const sigAnn = annotation as SignatureAnnotation;
+ const screenRect = pdfRectToScreen(sigAnn.rect, vp);
+
+ let fabricObj: fabric.FabricObject;
+
+ if (sigAnn.props.mode === 'draw') {
+ const url = `/api/v1/documents/${useEditorStore.getState().documentId}/assets/${sigAnn.props.ref}`;
+ try {
+ const img = await fabric.Image.fromURL(url);
+ img.set({
+ left: screenRect.x,
+ top: screenRect.y,
+ scaleX: screenRect.width / img.width!,
+ scaleY: screenRect.height / img.height!,
+ });
+ fabricObj = img;
+ } catch (e) {
+ console.error("Failed to load signature asset", e);
+ return null;
+ }
+ } else {
+ const typeProps = sigAnn.props as SignatureTypeProps;
+ fabricObj = new fabric.Text(typeProps.text, {
+ left: screenRect.x,
+ top: screenRect.y,
+ fontFamily: typeProps.fontFamily,
+ fill: typeProps.color,
+ });
+ // Set scale manually to match the saved rect
+ fabricObj.set({
+ scaleX: screenRect.width / fabricObj.width!,
+ scaleY: screenRect.height / fabricObj.height!,
+ });
+ }
+
+ 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);
+ }
+};
diff --git a/frontend/src/features/editor/tools/index.ts b/frontend/src/features/editor/tools/index.ts
index 139442d..c8fdeeb 100644
--- a/frontend/src/features/editor/tools/index.ts
+++ b/frontend/src/features/editor/tools/index.ts
@@ -1,8 +1,16 @@
import { registerTool } from '../../../lib/annotations/registry';
import { TextTool } from './TextTool';
+import { SignatureTool } from './SignatureTool';
+import { ImageTool } from './ImageTool';
+import { DrawTool } from './DrawTool';
+import { ShapeTool } from './ShapeTool';
// Register all tools here
registerTool(TextTool);
+registerTool(SignatureTool);
+registerTool(ImageTool);
+registerTool(DrawTool);
+registerTool(ShapeTool);
// SelectTool implementation
registerTool({
diff --git a/frontend/src/lib/annotations/registry.ts b/frontend/src/lib/annotations/registry.ts
index e97565d..584752c 100644
--- a/frontend/src/lib/annotations/registry.ts
+++ b/frontend/src/lib/annotations/registry.ts
@@ -1,3 +1,4 @@
+import * as fabric from 'fabric';
import type { Canvas } from 'fabric';
import type { Annotation } from './types';
import type { ViewportParams } from '../coords';
@@ -19,12 +20,11 @@ export interface ToolHandler {
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;
+ onPointerMove?: (e: any, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => void;
+ onPointerUp?: (e: any, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => void;
+ onPathCreated?: (e: any, canvas: fabric.Canvas, vp: 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. */
+ // Fabric to/from Annotation serialization
renderToFabric?: (annotation: Annotation, canvas: Canvas, viewportParams: ViewportParams) => void;
}
diff --git a/frontend/src/lib/annotations/types.ts b/frontend/src/lib/annotations/types.ts
index 723cbf9..be65af5 100644
--- a/frontend/src/lib/annotations/types.ts
+++ b/frontend/src/lib/annotations/types.ts
@@ -20,7 +20,7 @@ export type Annotation =
| ShapeAnnotation;
export type Rect = components['schemas']['Rect'];
-export type DrawProps = components['schemas']['DrawProps'];
+export type DrawProps = components['schemas']['DrawProps'] & { svgPath?: string };
export type SignatureDrawProps = components['schemas']['SignatureDrawProps'];
export type SignatureTypeProps = components['schemas']['SignatureTypeProps'];
export type ImageProps = components['schemas']['ImageProps'];
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 706186e..69c6af6 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -4,6 +4,12 @@ import { BrowserRouter } from 'react-router-dom'
import { App } from './app/App'
import './index.css'
+import '@fontsource/great-vibes/index.css'
+import '@fontsource/allura/index.css'
+import '@fontsource/sacramento/index.css'
+import '@fontsource/dancing-script/index.css'
+import '@fontsource/caveat/index.css'
+
createRoot(document.getElementById('root')!).render(