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
|
||||
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)
|
||||
|
|
|
|||
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
|
||||
|
||||
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
|
||||
|
|
|
|||
50
frontend/package-lock.json
generated
50
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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 { 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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue