Initial phase 4 extra tools, bug fixes, multi select changes.
This commit is contained in:
parent
bce3c1b420
commit
f273685cc1
18 changed files with 1379 additions and 88 deletions
|
|
@ -14,6 +14,10 @@ router.include_router(auth_router)
|
||||||
# Documents
|
# Documents
|
||||||
router.include_router(documents_router)
|
router.include_router(documents_router)
|
||||||
|
|
||||||
|
# Assets
|
||||||
|
from app.api.v1.assets import router as assets_router
|
||||||
|
router.include_router(assets_router)
|
||||||
|
|
||||||
# Annotations
|
# Annotations
|
||||||
from app.api.v1.annotations import router as annotations_router
|
from app.api.v1.annotations import router as annotations_router
|
||||||
router.include_router(annotations_router)
|
router.include_router(annotations_router)
|
||||||
|
|
|
||||||
58
backend/app/api/v1/assets.py
Normal file
58
backend/app/api/v1/assets.py
Normal 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)
|
||||||
|
|
@ -36,7 +36,8 @@ class TextAnnotation(AnnotationBase):
|
||||||
props: TextProps
|
props: TextProps
|
||||||
|
|
||||||
class DrawProps(BaseModel):
|
class DrawProps(BaseModel):
|
||||||
paths: List[Tuple[float, float]]
|
paths: List[Tuple[float, float]] = []
|
||||||
|
svgPath: Optional[str] = None
|
||||||
strokeColor: str = "#000000"
|
strokeColor: str = "#000000"
|
||||||
strokeWidth: float = 2
|
strokeWidth: float = 2
|
||||||
opacity: float = 1.0
|
opacity: float = 1.0
|
||||||
|
|
|
||||||
50
frontend/package-lock.json
generated
50
frontend/package-lock.json
generated
|
|
@ -8,8 +8,13 @@
|
||||||
"name": "paperjet",
|
"name": "paperjet",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"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/outfit": "^5.2.8",
|
||||||
"@fontsource/plus-jakarta-sans": "^5.2.8",
|
"@fontsource/plus-jakarta-sans": "^5.2.8",
|
||||||
|
"@fontsource/sacramento": "^5.2.8",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"date-fns": "^4.4.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": {
|
"node_modules/@fontsource/outfit": {
|
||||||
"version": "5.2.8",
|
"version": "5.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@fontsource/outfit/-/outfit-5.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@fontsource/outfit/-/outfit-5.2.8.tgz",
|
||||||
|
|
@ -679,6 +720,15 @@
|
||||||
"url": "https://github.com/sponsors/ayuhito"
|
"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": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.2",
|
"version": "0.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,13 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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/outfit": "^5.2.8",
|
||||||
"@fontsource/plus-jakarta-sans": "^5.2.8",
|
"@fontsource/plus-jakarta-sans": "^5.2.8",
|
||||||
|
"@fontsource/sacramento": "^5.2.8",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
|
|
|
||||||
|
|
@ -42,26 +42,16 @@ export function AnnotationLayer({ pageNumber, width, height, viewportParams }: A
|
||||||
if (ann) {
|
if (ann) {
|
||||||
const pt = screenToPdf({ x: obj.left, y: obj.top }, viewportParams);
|
const pt = screenToPdf({ x: obj.left, y: obj.top }, viewportParams);
|
||||||
|
|
||||||
if (ann.type === 'text') {
|
const scaleX = obj.scaleX || 1;
|
||||||
const scaleX = obj.scaleX || 1;
|
const scaleY = obj.scaleY || 1;
|
||||||
const scaleY = obj.scaleY || 1;
|
|
||||||
|
// Box width/height in PDF space
|
||||||
// Box width/height in PDF space
|
const newWidth = (obj.width * scaleX) / (viewportParams.scale * (viewportParams.dpr || 1));
|
||||||
const newWidth = (obj.width * scaleX) / (viewportParams.scale * (viewportParams.dpr || 1));
|
const newHeight = (obj.height * scaleY) / (viewportParams.scale * (viewportParams.dpr || 1));
|
||||||
const newHeight = (obj.height * scaleY) / (viewportParams.scale * (viewportParams.dpr || 1));
|
|
||||||
|
useEditorStore.getState().updateAnnotation(obj.id, {
|
||||||
const textProps = ann.props as any;
|
rect: { ...ann.rect, x: pt.x, y: pt.y, width: newWidth, height: newHeight }
|
||||||
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 }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -81,14 +71,26 @@ export function AnnotationLayer({ pageNumber, width, height, viewportParams }: A
|
||||||
|
|
||||||
// Keyboard shortcut for delete
|
// Keyboard shortcut for delete
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
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') {
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||||
const activeObj = canvas.getActiveObject() as any;
|
const activeObj = canvas.getActiveObject() as any;
|
||||||
// Don't delete if we are actively editing text
|
|
||||||
if (activeObj && activeObj.isEditing === true) {
|
if (activeObj && activeObj.isEditing === true) {
|
||||||
return;
|
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 });
|
canvas.setDimensions({ width, height });
|
||||||
|
|
||||||
// Simple naive sync: clear and re-render everything
|
// 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 activeObj = canvas.getActiveObject() as any;
|
||||||
const activeId = activeObj?.id;
|
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) => {
|
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);
|
canvas.remove(obj);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
annotations.forEach(ann => {
|
// Add missing annotations
|
||||||
// Only render annotations for THIS page
|
const canvasObjIds = new Set(canvas.getObjects().map((o: any) => o.id));
|
||||||
if (ann.page === pageNumber) {
|
pageAnns.forEach(ann => {
|
||||||
if (ann.id === activeId) return; // Skip rendering the active object
|
if (ann.id === activeId) return; // Skip rendering the active object
|
||||||
|
|
||||||
|
if (!canvasObjIds.has(ann.id)) {
|
||||||
const tool = getTool(ann.type);
|
const tool = getTool(ann.type);
|
||||||
if (tool && tool.renderToFabric) {
|
if (tool && tool.renderToFabric) {
|
||||||
tool.renderToFabric(ann, canvas, viewportParams);
|
tool.renderToFabric(ann, canvas, viewportParams);
|
||||||
|
|
@ -161,6 +170,9 @@ export function AnnotationLayer({ pageNumber, width, height, viewportParams }: A
|
||||||
if (tool.onPointerUp) {
|
if (tool.onPointerUp) {
|
||||||
canvas.on('mouse:up', (e) => tool.onPointerUp!(e, canvas, viewportParams, pageNumber));
|
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 () => {
|
return () => {
|
||||||
|
|
@ -174,13 +186,17 @@ export function AnnotationLayer({ pageNumber, width, height, viewportParams }: A
|
||||||
const draftAnnotation = useEditorStore(state => state.draftAnnotation);
|
const draftAnnotation = useEditorStore(state => state.draftAnnotation);
|
||||||
const isSelectedOnThisPage = annotations.find(a => a.id === selection && a.page === pageNumber);
|
const isSelectedOnThisPage = annotations.find(a => a.id === selection && a.page === pageNumber);
|
||||||
const isDraftOnThisPage = draftAnnotation?.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 (
|
return (
|
||||||
<div className="absolute top-0 left-0" style={{ width, height }}>
|
<div className="absolute top-0 left-0" style={{ width, height }}>
|
||||||
<canvas ref={canvasRef} />
|
<canvas ref={canvasRef} />
|
||||||
{toolbarAnnotationId && (
|
{toolbarAnnotationId && activeAnn && (
|
||||||
<TextFormatToolbar annotationId={toolbarAnnotationId} viewportParams={viewportParams} />
|
<>
|
||||||
|
{activeAnn.type === 'text' && <TextFormatToolbar annotationId={toolbarAnnotationId} viewportParams={viewportParams} />}
|
||||||
|
{/* We will add ShapeControls and DrawControls here once they are implemented */}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ interface EditorState {
|
||||||
saveStatus: SaveStatus;
|
saveStatus: SaveStatus;
|
||||||
defaultTextProps: Partial<TextProps>;
|
defaultTextProps: Partial<TextProps>;
|
||||||
draftAnnotation: Annotation | null;
|
draftAnnotation: Annotation | null;
|
||||||
|
isSignatureModalOpen: boolean;
|
||||||
|
pendingSignatureProps: any | null;
|
||||||
|
pendingImageRef: { ref: string, width: number, height: number } | null;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
setDocumentId: (id: string | null) => void;
|
setDocumentId: (id: string | null) => void;
|
||||||
|
|
@ -25,6 +28,11 @@ interface EditorState {
|
||||||
setSaveStatus: (status: SaveStatus) => void;
|
setSaveStatus: (status: SaveStatus) => void;
|
||||||
setDefaultTextProps: (props: Partial<TextProps>) => void;
|
setDefaultTextProps: (props: Partial<TextProps>) => void;
|
||||||
setDraftAnnotation: (ann: Annotation | null) => 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) => ({
|
export const useEditorStore = create<EditorState>((set) => ({
|
||||||
|
|
@ -36,6 +44,10 @@ export const useEditorStore = create<EditorState>((set) => ({
|
||||||
saveStatus: 'idle',
|
saveStatus: 'idle',
|
||||||
defaultTextProps: {},
|
defaultTextProps: {},
|
||||||
draftAnnotation: null,
|
draftAnnotation: null,
|
||||||
|
isSignatureModalOpen: false,
|
||||||
|
pendingSignatureProps: null,
|
||||||
|
pendingImageRef: null,
|
||||||
|
activeShapeKind: 'rect',
|
||||||
|
|
||||||
setDocumentId: (id) => set({ documentId: id, defaultTextProps: {}, draftAnnotation: null }),
|
setDocumentId: (id) => set({ documentId: id, defaultTextProps: {}, draftAnnotation: null }),
|
||||||
setAnnotations: (annotations) => set({ annotations }),
|
setAnnotations: (annotations) => set({ annotations }),
|
||||||
|
|
@ -55,4 +67,8 @@ export const useEditorStore = create<EditorState>((set) => ({
|
||||||
setSaveStatus: (status) => set({ saveStatus: status }),
|
setSaveStatus: (status) => set({ saveStatus: status }),
|
||||||
setDefaultTextProps: (props) => set((state) => ({ defaultTextProps: { ...state.defaultTextProps, ...props } })),
|
setDefaultTextProps: (props) => set((state) => ({ defaultTextProps: { ...state.defaultTextProps, ...props } })),
|
||||||
setDraftAnnotation: (ann) => set({ draftAnnotation: ann }),
|
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 }),
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -1,63 +1,182 @@
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
import { useEditorStore } from '../store';
|
import { useEditorStore } from '../store';
|
||||||
|
import { SignatureModal } from '../tools/SignatureModal';
|
||||||
|
import { api } from '../../../lib/api/client';
|
||||||
|
|
||||||
export function EditorToolbar() {
|
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 handleZoomIn = () => setZoom(Math.min(zoom + 0.25, 3));
|
||||||
const handleZoomOut = () => setZoom(Math.max(zoom - 0.25, 0.5));
|
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 (
|
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
|
<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">
|
||||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
<button
|
||||||
activeTool === 'select'
|
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||||
? 'bg-blue-100 text-blue-700'
|
activeTool === 'select'
|
||||||
: 'text-neutral-600 hover:bg-neutral-100'
|
? 'bg-blue-100 text-blue-700'
|
||||||
}`}
|
: 'text-neutral-600 hover:bg-neutral-100'
|
||||||
onClick={() => setActiveTool('select')}
|
}`}
|
||||||
>
|
onClick={() => setActiveTool('select')}
|
||||||
Select
|
>
|
||||||
</button>
|
Select
|
||||||
|
</button>
|
||||||
<button
|
|
||||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
<button
|
||||||
activeTool === 'text'
|
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||||
? 'bg-blue-100 text-blue-700'
|
activeTool === 'text'
|
||||||
: 'text-neutral-600 hover:bg-neutral-100'
|
? 'bg-blue-100 text-blue-700'
|
||||||
}`}
|
: 'text-neutral-600 hover:bg-neutral-100'
|
||||||
onClick={() => setActiveTool('text')}
|
}`}
|
||||||
>
|
onClick={() => setActiveTool('text')}
|
||||||
Add Text
|
>
|
||||||
</button>
|
Add Text
|
||||||
|
</button>
|
||||||
|
|
||||||
<div className="w-px h-6 bg-neutral-200 mx-2" />
|
<button
|
||||||
|
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||||
<div className="flex items-center gap-1">
|
activeTool === 'draw'
|
||||||
<button
|
? 'bg-blue-100 text-blue-700'
|
||||||
onClick={handleZoomOut}
|
: 'text-neutral-600 hover:bg-neutral-100'
|
||||||
className="px-2 py-1 text-sm font-medium text-neutral-600 hover:bg-neutral-100 rounded"
|
}`}
|
||||||
|
onClick={() => setActiveTool('draw')}
|
||||||
>
|
>
|
||||||
-
|
Draw
|
||||||
</button>
|
</button>
|
||||||
<span className="text-sm font-medium text-neutral-600 w-12 text-center">
|
|
||||||
{Math.round(zoom * 100)}%
|
<div className="flex items-center gap-1">
|
||||||
</span>
|
<select
|
||||||
<button
|
value={useEditorStore.getState().activeShapeKind}
|
||||||
onClick={handleZoomIn}
|
onChange={(e) => {
|
||||||
className="px-2 py-1 text-sm font-medium text-neutral-600 hover:bg-neutral-100 rounded"
|
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>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<button
|
||||||
|
onClick={handleZoomOut}
|
||||||
|
className="px-2 py-1 text-sm font-medium text-neutral-600 hover:bg-neutral-100 rounded"
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
<span className="text-sm font-medium text-neutral-600 w-12 text-center">
|
||||||
|
{Math.round(zoom * 100)}%
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleZoomIn}
|
||||||
|
className="px-2 py-1 text-sm font-medium text-neutral-600 hover:bg-neutral-100 rounded"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-neutral-200 mx-2" />
|
||||||
|
|
||||||
|
<div className="text-xs font-medium text-neutral-400 min-w-16 text-center">
|
||||||
|
{saveStatus === 'saving' && 'Saving...'}
|
||||||
|
{saveStatus === 'saved' && 'Saved'}
|
||||||
|
{saveStatus === 'error' && <span className="text-red-500">Error saving</span>}
|
||||||
|
{saveStatus === 'idle' && ''}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-px h-6 bg-neutral-200 mx-2" />
|
{isSignatureModalOpen && (
|
||||||
|
<SignatureModal
|
||||||
<div className="text-xs font-medium text-neutral-400 min-w-16 text-center">
|
onClose={() => setIsSignatureModalOpen(false)}
|
||||||
{saveStatus === 'saving' && 'Saving...'}
|
onConfirm={handleSignatureConfirm}
|
||||||
{saveStatus === 'saved' && 'Saved'}
|
/>
|
||||||
{saveStatus === 'error' && <span className="text-red-500">Error saving</span>}
|
)}
|
||||||
{saveStatus === 'idle' && ''}
|
</>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,8 +59,12 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo
|
||||||
const activeObj = canvas.getActiveObject();
|
const activeObj = canvas.getActiveObject();
|
||||||
if (activeObj && activeObj.id === annotationId) {
|
if (activeObj && activeObj.id === annotationId) {
|
||||||
if (activeObj.isEditing) {
|
if (activeObj.isEditing) {
|
||||||
activeObj.setSelectionStyles({ [styleName]: value });
|
if (activeObj.selectionStart !== activeObj.selectionEnd) {
|
||||||
didInline = true;
|
activeObj.setSelectionStyles({ [styleName]: value });
|
||||||
|
didInline = true;
|
||||||
|
} else {
|
||||||
|
activeObj.set(styleName, value);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
activeObj.set(styleName, value);
|
activeObj.set(styleName, value);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
135
frontend/src/features/editor/tools/DrawTool.ts
Normal file
135
frontend/src/features/editor/tools/DrawTool.ts
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
131
frontend/src/features/editor/tools/ImageTool.ts
Normal file
131
frontend/src/features/editor/tools/ImageTool.ts
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
241
frontend/src/features/editor/tools/ShapeTool.ts
Normal file
241
frontend/src/features/editor/tools/ShapeTool.ts
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
258
frontend/src/features/editor/tools/SignatureModal.tsx
Normal file
258
frontend/src/features/editor/tools/SignatureModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
239
frontend/src/features/editor/tools/SignatureTool.ts
Normal file
239
frontend/src/features/editor/tools/SignatureTool.ts
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1,8 +1,16 @@
|
||||||
import { registerTool } from '../../../lib/annotations/registry';
|
import { registerTool } from '../../../lib/annotations/registry';
|
||||||
import { TextTool } from './TextTool';
|
import { TextTool } from './TextTool';
|
||||||
|
import { SignatureTool } from './SignatureTool';
|
||||||
|
import { ImageTool } from './ImageTool';
|
||||||
|
import { DrawTool } from './DrawTool';
|
||||||
|
import { ShapeTool } from './ShapeTool';
|
||||||
|
|
||||||
// Register all tools here
|
// Register all tools here
|
||||||
registerTool(TextTool);
|
registerTool(TextTool);
|
||||||
|
registerTool(SignatureTool);
|
||||||
|
registerTool(ImageTool);
|
||||||
|
registerTool(DrawTool);
|
||||||
|
registerTool(ShapeTool);
|
||||||
|
|
||||||
// SelectTool implementation
|
// SelectTool implementation
|
||||||
registerTool({
|
registerTool({
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import * as fabric from 'fabric';
|
||||||
import type { Canvas } from 'fabric';
|
import type { Canvas } from 'fabric';
|
||||||
import type { Annotation } from './types';
|
import type { Annotation } from './types';
|
||||||
import type { ViewportParams } from '../coords';
|
import type { ViewportParams } from '../coords';
|
||||||
|
|
@ -19,12 +20,11 @@ export interface ToolHandler {
|
||||||
onPointerDown?: (e: any, canvas: Canvas, viewportParams: ViewportParams, pageNumber: number) => void;
|
onPointerDown?: (e: any, canvas: Canvas, viewportParams: ViewportParams, pageNumber: number) => void;
|
||||||
|
|
||||||
/** Called when the user drags the pointer. */
|
/** 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. */
|
// Fabric to/from Annotation serialization
|
||||||
onPointerUp?: (e: any, canvas: Canvas, viewportParams: ViewportParams, pageNumber: number) => void;
|
|
||||||
|
|
||||||
/** Renders a canonical Annotation onto the given Fabric Canvas. */
|
|
||||||
renderToFabric?: (annotation: Annotation, canvas: Canvas, viewportParams: ViewportParams) => void;
|
renderToFabric?: (annotation: Annotation, canvas: Canvas, viewportParams: ViewportParams) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ export type Annotation =
|
||||||
| ShapeAnnotation;
|
| ShapeAnnotation;
|
||||||
|
|
||||||
export type Rect = components['schemas']['Rect'];
|
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 SignatureDrawProps = components['schemas']['SignatureDrawProps'];
|
||||||
export type SignatureTypeProps = components['schemas']['SignatureTypeProps'];
|
export type SignatureTypeProps = components['schemas']['SignatureTypeProps'];
|
||||||
export type ImageProps = components['schemas']['ImageProps'];
|
export type ImageProps = components['schemas']['ImageProps'];
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,12 @@ import { BrowserRouter } from 'react-router-dom'
|
||||||
import { App } from './app/App'
|
import { App } from './app/App'
|
||||||
import './index.css'
|
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(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue