From f273685cc12fd771f0b527b8695ff67649a832fa Mon Sep 17 00:00:00 2001 From: Elijah Date: Sat, 13 Jun 2026 12:15:22 -0700 Subject: [PATCH] Initial phase 4 extra tools, bug fixes, multi select changes. --- backend/app/api/v1/__init__.py | 4 + backend/app/api/v1/assets.py | 58 ++++ backend/app/schemas/annotations.py | 3 +- frontend/package-lock.json | 50 ++++ frontend/package.json | 5 + .../editor/canvas/AnnotationLayer.tsx | 82 +++--- frontend/src/features/editor/store.ts | 16 ++ .../features/editor/toolbar/EditorToolbar.tsx | 211 ++++++++++---- .../editor/toolbar/TextFormatToolbar.tsx | 8 +- .../src/features/editor/tools/DrawTool.ts | 135 +++++++++ .../src/features/editor/tools/ImageTool.ts | 131 +++++++++ .../src/features/editor/tools/ShapeTool.ts | 241 ++++++++++++++++ .../features/editor/tools/SignatureModal.tsx | 258 ++++++++++++++++++ .../features/editor/tools/SignatureTool.ts | 239 ++++++++++++++++ frontend/src/features/editor/tools/index.ts | 8 + frontend/src/lib/annotations/registry.ts | 10 +- frontend/src/lib/annotations/types.ts | 2 +- frontend/src/main.tsx | 6 + 18 files changed, 1379 insertions(+), 88 deletions(-) create mode 100644 backend/app/api/v1/assets.py create mode 100644 frontend/src/features/editor/tools/DrawTool.ts create mode 100644 frontend/src/features/editor/tools/ImageTool.ts create mode 100644 frontend/src/features/editor/tools/ShapeTool.ts create mode 100644 frontend/src/features/editor/tools/SignatureModal.tsx create mode 100644 frontend/src/features/editor/tools/SignatureTool.ts 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(