Initial phase 4 extra tools, bug fixes, multi select changes.
Some checks failed
Automated Container Build / build-and-push (push) Failing after 3s
CI / Backend (Python) (push) Failing after 18s
CI / Frontend (TypeScript) (push) Failing after 4m52s

This commit is contained in:
Elijah 2026-06-13 12:15:22 -07:00
parent bce3c1b420
commit f273685cc1
18 changed files with 1379 additions and 88 deletions

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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",

View file

@ -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",

View file

@ -42,7 +42,6 @@ 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;
@ -50,18 +49,9 @@ export function AnnotationLayer({ pageNumber, width, height, viewportParams }: A
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 }
rect: { ...ann.rect, x: pt.x, y: pt.y, width: newWidth, height: newHeight }
});
} else {
useEditorStore.getState().updateAnnotation(obj.id, {
rect: { ...ann.rect, x: pt.x, y: pt.y }
});
}
}
}
});
@ -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) {
// 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 (
<div className="absolute top-0 left-0" style={{ width, height }}>
<canvas ref={canvasRef} />
{toolbarAnnotationId && (
<TextFormatToolbar annotationId={toolbarAnnotationId} viewportParams={viewportParams} />
{toolbarAnnotationId && activeAnn && (
<>
{activeAnn.type === 'text' && <TextFormatToolbar annotationId={toolbarAnnotationId} viewportParams={viewportParams} />}
{/* We will add ShapeControls and DrawControls here once they are implemented */}
</>
)}
</div>
);

View file

@ -12,6 +12,9 @@ interface EditorState {
saveStatus: SaveStatus;
defaultTextProps: Partial<TextProps>;
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<TextProps>) => 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<EditorState>((set) => ({
@ -36,6 +44,10 @@ export const useEditorStore = create<EditorState>((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<EditorState>((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 }),
}));

View file

@ -1,12 +1,62 @@
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<HTMLInputElement>(null);
const [isUploadingImage, setIsUploadingImage] = useState(false);
const handleZoomIn = () => setZoom(Math.min(zoom + 0.25, 3));
const handleZoomOut = () => setZoom(Math.max(zoom - 0.25, 0.5));
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !documentId) return;
setIsUploadingImage(true);
try {
const formData = new FormData();
formData.append('file', file);
const data = await api.upload<any>(`/documents/${documentId}/assets`, formData);
// We don't have natural width/height immediately, we'll let the tool figure it out or pass placeholders
// A better way is to read image locally to get dimensions, but we can also just let fabric.Image.fromURL do it.
// We'll pass 0 for now and let the tool set scale to max-width.
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 (
<>
<div className="fixed top-4 left-1/2 -translate-x-1/2 bg-white rounded-lg shadow-xl border border-neutral-200 px-2 py-2 flex items-center gap-2 z-50">
<button
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
@ -30,6 +80,67 @@ export function EditorToolbar() {
Add Text
</button>
<button
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
activeTool === 'draw'
? 'bg-blue-100 text-blue-700'
: 'text-neutral-600 hover:bg-neutral-100'
}`}
onClick={() => setActiveTool('draw')}
>
Draw
</button>
<div className="flex items-center gap-1">
<select
value={useEditorStore.getState().activeShapeKind}
onChange={(e) => {
useEditorStore.getState().setActiveShapeKind(e.target.value as any);
setActiveTool('shape');
}}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors border-none focus:ring-0 ${
activeTool === 'shape'
? 'bg-blue-100 text-blue-700'
: 'text-neutral-600 hover:bg-neutral-100 bg-transparent'
}`}
>
<option value="rect">Rectangle</option>
<option value="ellipse">Ellipse</option>
<option value="line">Line</option>
</select>
</div>
<button
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
activeTool === 'signature'
? 'bg-blue-100 text-blue-700'
: 'text-neutral-600 hover:bg-neutral-100'
}`}
onClick={() => setIsSignatureModalOpen(true)}
>
Signature
</button>
<button
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
activeTool === 'image' || isUploadingImage
? 'bg-blue-100 text-blue-700'
: 'text-neutral-600 hover:bg-neutral-100'
}`}
onClick={() => fileInputRef.current?.click()}
disabled={isUploadingImage}
>
{isUploadingImage ? 'Uploading...' : 'Image'}
</button>
<input
type="file"
ref={fileInputRef}
accept="image/*, image/webp"
className="hidden"
onChange={handleImageUpload}
/>
<div className="w-px h-6 bg-neutral-200 mx-2" />
<div className="flex items-center gap-1">
@ -59,5 +170,13 @@ export function EditorToolbar() {
{saveStatus === 'idle' && ''}
</div>
</div>
{isSignatureModalOpen && (
<SignatureModal
onClose={() => setIsSignatureModalOpen(false)}
onConfirm={handleSignatureConfirm}
/>
)}
</>
);
}

View file

@ -59,11 +59,15 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo
const activeObj = canvas.getActiveObject();
if (activeObj && activeObj.id === annotationId) {
if (activeObj.isEditing) {
if (activeObj.selectionStart !== activeObj.selectionEnd) {
activeObj.setSelectionStyles({ [styleName]: value });
didInline = true;
} else {
activeObj.set(styleName, value);
}
} else {
activeObj.set(styleName, value);
}
canvas.requestRenderAll();
}
}

View file

@ -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);
}
};

View file

@ -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);
}
};

View file

@ -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);
}
};

View file

@ -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<HTMLCanvasElement>(null);
const [isDrawing, setIsDrawing] = useState(false);
const [hasDrawn, setHasDrawn] = useState(false);
const ctxRef = useRef<CanvasRenderingContext2D | null>(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<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) => {
setIsDrawing(true);
setHasDrawn(true);
const pos = getPos(e);
ctxRef.current?.beginPath();
ctxRef.current?.moveTo(pos.x, pos.y);
};
const draw = (e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) => {
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<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) => {
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<any>(`/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 (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-2xl overflow-hidden flex flex-col max-h-[90vh]">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-100">
<h2 className="text-lg font-semibold text-gray-800">Create Signature</h2>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-lg transition-colors">
<X size={20} className="text-gray-500" />
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-gray-100 p-2 gap-2 bg-gray-50">
<button
onClick={() => setTab('type')}
className={`flex-1 flex items-center justify-center gap-2 py-2 px-4 rounded-lg font-medium transition-colors ${
tab === 'type' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'
}`}
>
<Type size={18} />
Type
</button>
<button
onClick={() => setTab('draw')}
className={`flex-1 flex items-center justify-center gap-2 py-2 px-4 rounded-lg font-medium transition-colors ${
tab === 'draw' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'
}`}
>
<Pencil size={18} />
Draw
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6 bg-gray-50">
{tab === 'type' && (
<div className="space-y-6">
<input
type="text"
value={text}
onChange={(e) => 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"
/>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{FONTS.map(font => (
<button
key={font}
onClick={() => handleTypeSubmit(font)}
className="flex flex-col items-center justify-center p-6 bg-white rounded-xl border border-gray-100 shadow-sm hover:border-blue-300 hover:shadow-md transition-all group"
>
<span
style={{ fontFamily: `"${font}", cursive` }}
className="text-4xl text-gray-800 group-hover:text-blue-600 truncate w-full text-center mb-4"
>
{text || 'Signature'}
</span>
<span className="text-xs text-gray-400 uppercase tracking-wider font-medium">{font}</span>
</button>
))}
</div>
</div>
)}
{tab === 'draw' && (
<div className="space-y-4">
<div className="relative bg-white rounded-xl border-2 border-dashed border-gray-200 overflow-hidden shadow-sm">
<canvas
ref={canvasRef}
className="w-full h-64 touch-none cursor-crosshair"
onMouseDown={startDrawing}
onMouseMove={draw}
onMouseUp={stopDrawing}
onMouseLeave={stopDrawing}
onTouchStart={startDrawing}
onTouchMove={draw}
onTouchEnd={stopDrawing}
/>
{hasDrawn && (
<button
onClick={clearCanvas}
className="absolute top-4 right-4 p-2 bg-red-50 text-red-600 rounded-lg hover:bg-red-100 transition-colors flex items-center gap-2 text-sm font-medium"
>
<Trash2 size={16} />
Clear
</button>
)}
{!hasDrawn && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span className="text-gray-400 font-medium">Draw your signature here</span>
</div>
)}
</div>
<div className="flex justify-end">
<button
onClick={handleDrawSubmit}
disabled={!hasDrawn || loading}
className="px-6 py-3 bg-blue-600 text-white rounded-xl font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-sm"
>
{loading ? 'Saving...' : 'Use Signature'}
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -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);
}
};

View file

@ -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({

View file

@ -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;
}

View file

@ -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'];

View file

@ -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(
<StrictMode>
<BrowserRouter>