|
|
@@ -0,0 +1,305 @@
|
|
|
+'use client';
|
|
|
+
|
|
|
+import { useRef, useEffect, useCallback, useState } from 'react';
|
|
|
+import { AnnotationData } from '@/lib/api';
|
|
|
+
|
|
|
+const COLORS = [
|
|
|
+ { name: 'Red', value: '#ef4444' },
|
|
|
+ { name: 'Orange', value: '#f97316' },
|
|
|
+ { name: 'Yellow', value: '#eab308' },
|
|
|
+ { name: 'Green', value: '#22c55e' },
|
|
|
+ { name: 'Blue', value: '#3b82f6' },
|
|
|
+ { name: 'Purple', value: '#a855f7' },
|
|
|
+ { name: 'White', value: '#ffffff' },
|
|
|
+];
|
|
|
+
|
|
|
+type Tool = 'pen' | 'arrow' | 'rect' | 'ellipse' | 'eraser';
|
|
|
+
|
|
|
+interface Props {
|
|
|
+ isDrawingMode: boolean;
|
|
|
+ tool: Tool;
|
|
|
+ color: string;
|
|
|
+ annotations: AnnotationData[];
|
|
|
+ width: number;
|
|
|
+ height: number;
|
|
|
+ onAnnotationCreated: (annotation: AnnotationData) => void;
|
|
|
+}
|
|
|
+
|
|
|
+interface DrawState {
|
|
|
+ type: Tool;
|
|
|
+ color: string;
|
|
|
+ startX: number;
|
|
|
+ startY: number;
|
|
|
+ points: [number, number][];
|
|
|
+}
|
|
|
+
|
|
|
+export function AnnotationCanvas({
|
|
|
+ isDrawingMode,
|
|
|
+ tool,
|
|
|
+ color,
|
|
|
+ annotations,
|
|
|
+ width,
|
|
|
+ height,
|
|
|
+ onAnnotationCreated,
|
|
|
+}: Props) {
|
|
|
+ const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
|
+ const [isDrawing, setIsDrawing] = useState(false);
|
|
|
+ const [drawState, setDrawState] = useState<DrawState | null>(null);
|
|
|
+ const historyRef = useRef<AnnotationData[]>([]);
|
|
|
+
|
|
|
+ // Render all annotations
|
|
|
+ const render = useCallback(() => {
|
|
|
+ const canvas = canvasRef.current;
|
|
|
+ if (!canvas) return;
|
|
|
+ const ctx = canvas.getContext('2d');
|
|
|
+ if (!ctx) return;
|
|
|
+
|
|
|
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
+
|
|
|
+ for (const ann of historyRef.current) {
|
|
|
+ drawAnnotation(ctx, ann);
|
|
|
+ }
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ // Update history and re-render when annotations change
|
|
|
+ useEffect(() => {
|
|
|
+ historyRef.current = [...annotations];
|
|
|
+ render();
|
|
|
+ }, [annotations, render]);
|
|
|
+
|
|
|
+ // Re-render on resize
|
|
|
+ useEffect(() => {
|
|
|
+ const canvas = canvasRef.current;
|
|
|
+ if (!canvas) return;
|
|
|
+ canvas.width = width;
|
|
|
+ canvas.height = height;
|
|
|
+ render();
|
|
|
+ }, [width, height, render]);
|
|
|
+
|
|
|
+ function getPos(e: React.MouseEvent<HTMLCanvasElement>): [number, number] {
|
|
|
+ const canvas = canvasRef.current!;
|
|
|
+ const rect = canvas.getBoundingClientRect();
|
|
|
+ return [
|
|
|
+ (e.clientX - rect.left) / rect.width,
|
|
|
+ (e.clientY - rect.top) / rect.height,
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ function drawAnnotation(ctx: CanvasRenderingContext2D, ann: AnnotationData) {
|
|
|
+ ctx.strokeStyle = ann.color;
|
|
|
+ ctx.fillStyle = ann.color;
|
|
|
+ ctx.lineWidth = 3;
|
|
|
+ ctx.lineCap = 'round';
|
|
|
+ ctx.lineJoin = 'round';
|
|
|
+ ctx.shadowBlur = 0;
|
|
|
+
|
|
|
+ if (ann.type === 'pen' && ann.points) {
|
|
|
+ if (ann.points.length < 2) return;
|
|
|
+ ctx.beginPath();
|
|
|
+ ctx.moveTo(ann.points[0][0] * ctx.canvas.width, ann.points[0][1] * ctx.canvas.height);
|
|
|
+ for (let i = 1; i < ann.points.length; i++) {
|
|
|
+ ctx.lineTo(ann.points[i][0] * ctx.canvas.width, ann.points[i][1] * ctx.canvas.height);
|
|
|
+ }
|
|
|
+ ctx.stroke();
|
|
|
+ } else if (ann.type === 'arrow' && ann.points && ann.points.length >= 2) {
|
|
|
+ const [x1, y1] = ann.points[0];
|
|
|
+ const [x2, y2] = ann.points[ann.points.length - 1];
|
|
|
+ const sx = x1 * ctx.canvas.width, sy = y1 * ctx.canvas.height;
|
|
|
+ const ex = x2 * ctx.canvas.width, ey = y2 * ctx.canvas.height;
|
|
|
+
|
|
|
+ // Line
|
|
|
+ ctx.beginPath();
|
|
|
+ ctx.moveTo(sx, sy);
|
|
|
+ ctx.lineTo(ex, ey);
|
|
|
+ ctx.stroke();
|
|
|
+
|
|
|
+ // Arrowhead
|
|
|
+ const angle = Math.atan2(ey - sy, ex - sx);
|
|
|
+ const headLen = 16;
|
|
|
+ ctx.beginPath();
|
|
|
+ ctx.moveTo(ex, ey);
|
|
|
+ ctx.lineTo(ex - headLen * Math.cos(angle - Math.PI / 6), ey - headLen * Math.sin(angle - Math.PI / 6));
|
|
|
+ ctx.moveTo(ex, ey);
|
|
|
+ ctx.lineTo(ex - headLen * Math.cos(angle + Math.PI / 6), ey - headLen * Math.sin(angle + Math.PI / 6));
|
|
|
+ ctx.stroke();
|
|
|
+ } else if (ann.type === 'rect' && ann.boundingBox) {
|
|
|
+ const { x, y, width: w, height: h } = ann.boundingBox;
|
|
|
+ ctx.strokeRect(
|
|
|
+ x * ctx.canvas.width,
|
|
|
+ y * ctx.canvas.height,
|
|
|
+ w * ctx.canvas.width,
|
|
|
+ h * ctx.canvas.height
|
|
|
+ );
|
|
|
+ } else if (ann.type === 'ellipse' && ann.boundingBox) {
|
|
|
+ const { x, y, width: w, height: h } = ann.boundingBox;
|
|
|
+ ctx.beginPath();
|
|
|
+ ctx.ellipse(
|
|
|
+ (x + w / 2) * ctx.canvas.width,
|
|
|
+ (y + h / 2) * ctx.canvas.height,
|
|
|
+ (w / 2) * ctx.canvas.width,
|
|
|
+ (h / 2) * ctx.canvas.height,
|
|
|
+ 0, 0, 2 * Math.PI
|
|
|
+ );
|
|
|
+ ctx.stroke();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function drawLivePreview(ctx: CanvasRenderingContext2D) {
|
|
|
+ if (!drawState) return;
|
|
|
+ ctx.strokeStyle = drawState.color;
|
|
|
+ ctx.fillStyle = drawState.color;
|
|
|
+ ctx.lineWidth = 3;
|
|
|
+ ctx.lineCap = 'round';
|
|
|
+ ctx.lineJoin = 'round';
|
|
|
+
|
|
|
+ const { type, startX, startY, points } = drawState;
|
|
|
+ const w = ctx.canvas.width;
|
|
|
+ const h = ctx.canvas.height;
|
|
|
+
|
|
|
+ if (type === 'pen' && points.length >= 2) {
|
|
|
+ ctx.beginPath();
|
|
|
+ ctx.moveTo(points[0][0] * w, points[0][1] * h);
|
|
|
+ for (let i = 1; i < points.length; i++) {
|
|
|
+ ctx.lineTo(points[i][0] * w, points[i][1] * h);
|
|
|
+ }
|
|
|
+ ctx.stroke();
|
|
|
+ } else if (type === 'arrow' && points.length >= 2) {
|
|
|
+ const [x1, y1] = points[0];
|
|
|
+ const [x2, y2] = points[points.length - 1];
|
|
|
+ const sx = x1 * w, sy = y1 * h;
|
|
|
+ const ex = x2 * w, ey = y2 * h;
|
|
|
+
|
|
|
+ ctx.beginPath();
|
|
|
+ ctx.moveTo(sx, sy);
|
|
|
+ ctx.lineTo(ex, ey);
|
|
|
+ ctx.stroke();
|
|
|
+
|
|
|
+ const angle = Math.atan2(ey - sy, ex - sx);
|
|
|
+ const headLen = 16;
|
|
|
+ ctx.beginPath();
|
|
|
+ ctx.moveTo(ex, ey);
|
|
|
+ ctx.lineTo(ex - headLen * Math.cos(angle - Math.PI / 6), ey - headLen * Math.sin(angle - Math.PI / 6));
|
|
|
+ ctx.moveTo(ex, ey);
|
|
|
+ ctx.lineTo(ex - headLen * Math.cos(angle + Math.PI / 6), ey - headLen * Math.sin(angle + Math.PI / 6));
|
|
|
+ ctx.stroke();
|
|
|
+ } else if (type === 'rect' && points.length >= 2) {
|
|
|
+ const sx = startX * w, sy = startY * h;
|
|
|
+ const ex = points[points.length - 1][0] * w;
|
|
|
+ const ey = points[points.length - 1][1] * h;
|
|
|
+ ctx.strokeRect(sx, sy, ex - sx, ey - sy);
|
|
|
+ } else if (type === 'ellipse' && points.length >= 2) {
|
|
|
+ const sx = startX * w, sy = startY * h;
|
|
|
+ const ex = points[points.length - 1][0] * w;
|
|
|
+ const ey = points[points.length - 1][1] * h;
|
|
|
+ ctx.beginPath();
|
|
|
+ ctx.ellipse(
|
|
|
+ (sx + ex) / 2, (sy + ey) / 2,
|
|
|
+ Math.abs(ex - sx) / 2, Math.abs(ey - sy) / 2,
|
|
|
+ 0, 0, 2 * Math.PI
|
|
|
+ );
|
|
|
+ ctx.stroke();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
|
+ if (!isDrawingMode) return;
|
|
|
+ const [x, y] = getPos(e);
|
|
|
+ if (tool === 'eraser') {
|
|
|
+ // Erase: remove annotations near click
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ setIsDrawing(true);
|
|
|
+ setDrawState({ type: tool, color, startX: x, startY: y, points: [[x, y]] });
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
|
+ if (!isDrawing || !drawState) return;
|
|
|
+ const [x, y] = getPos(e);
|
|
|
+ setDrawState(prev => {
|
|
|
+ if (!prev) return prev;
|
|
|
+ const points = [...prev.points, [x, y] as [number, number]];
|
|
|
+
|
|
|
+ // Live render
|
|
|
+ const canvas = canvasRef.current;
|
|
|
+ if (!canvas) return prev;
|
|
|
+ const ctx = canvas.getContext('2d');
|
|
|
+ if (!ctx) return prev;
|
|
|
+
|
|
|
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
+ for (const ann of historyRef.current) drawAnnotation(ctx, ann);
|
|
|
+ drawLivePreview(ctx);
|
|
|
+ return { ...prev, points };
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleMouseUp = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
|
+ if (!isDrawing || !drawState) return;
|
|
|
+ setIsDrawing(false);
|
|
|
+
|
|
|
+ const [x, y] = getPos(e);
|
|
|
+ const allPoints = [...drawState.points, [x, y] as [number, number]];
|
|
|
+
|
|
|
+ let annotation: AnnotationData;
|
|
|
+
|
|
|
+ if (drawState.type === 'rect') {
|
|
|
+ const minX = Math.min(drawState.startX, x);
|
|
|
+ const minY = Math.min(drawState.startY, y);
|
|
|
+ const maxX = Math.max(drawState.startX, x);
|
|
|
+ const maxY = Math.max(drawState.startY, y);
|
|
|
+ annotation = {
|
|
|
+ type: 'rect',
|
|
|
+ color: drawState.color,
|
|
|
+ boundingBox: { x: minX, y: minY, width: maxX - minX, height: maxY - minY },
|
|
|
+ points: allPoints,
|
|
|
+ };
|
|
|
+ } else if (drawState.type === 'ellipse') {
|
|
|
+ const minX = Math.min(drawState.startX, x);
|
|
|
+ const minY = Math.min(drawState.startY, y);
|
|
|
+ const maxX = Math.max(drawState.startX, x);
|
|
|
+ const maxY = Math.max(drawState.startY, y);
|
|
|
+ annotation = {
|
|
|
+ type: 'ellipse',
|
|
|
+ color: drawState.color,
|
|
|
+ boundingBox: { x: minX, y: minY, width: maxX - minX, height: maxY - minY },
|
|
|
+ points: allPoints,
|
|
|
+ };
|
|
|
+ } else {
|
|
|
+ annotation = {
|
|
|
+ type: drawState.type === 'arrow' ? 'arrow' : 'pen',
|
|
|
+ color: drawState.color,
|
|
|
+ points: allPoints,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ onAnnotationCreated(annotation);
|
|
|
+ setDrawState(null);
|
|
|
+
|
|
|
+ // Re-render final
|
|
|
+ const canvas = canvasRef.current;
|
|
|
+ if (canvas) {
|
|
|
+ const ctx = canvas.getContext('2d');
|
|
|
+ if (ctx) {
|
|
|
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
+ for (const ann of historyRef.current) drawAnnotation(ctx, ann);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ return (
|
|
|
+ <canvas
|
|
|
+ ref={canvasRef}
|
|
|
+ className="absolute inset-0 z-10"
|
|
|
+ style={{
|
|
|
+ cursor: isDrawingMode ? 'crosshair' : 'default',
|
|
|
+ pointerEvents: isDrawingMode ? 'auto' : 'none',
|
|
|
+ }}
|
|
|
+ onMouseDown={handleMouseDown}
|
|
|
+ onMouseMove={handleMouseMove}
|
|
|
+ onMouseUp={handleMouseUp}
|
|
|
+ onMouseLeave={handleMouseUp}
|
|
|
+ />
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+export { COLORS };
|
|
|
+export type { Tool };
|