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

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

View file

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