| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196 |
- 'use client';
- import { useRef, useEffect } from 'react';
- import { AnnotationData } from '../../lib/api';
- export 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' },
- ];
- export type Tool = 'pen' | 'arrow' | 'rect' | 'ellipse';
- // ── Standalone render function (used by both draw canvas and display canvas) ──
- export function drawShape(ctx: CanvasRenderingContext2D, ann: AnnotationData) {
- ctx.save();
- ctx.strokeStyle = ann.color;
- ctx.fillStyle = ann.color;
- ctx.lineWidth = 3;
- ctx.lineCap = 'round';
- ctx.lineJoin = 'round';
- if (ann.type === 'pen' && ann.points && ann.points.length >= 2) {
- 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;
- const angle = Math.atan2(ey - sy, ex - sx);
- const headLen = 16;
- ctx.beginPath();
- ctx.moveTo(sx, sy); ctx.lineTo(ex, ey); ctx.stroke();
- 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();
- }
- ctx.restore();
- }
- interface Props {
- isActive: boolean;
- tool: Tool;
- color: string;
- width: number;
- height: number;
- // Already-saved strokes to keep on canvas until Save/Undo
- pendingStrokes: AnnotationData[];
- onStrokeComplete: (stroke: AnnotationData) => void;
- }
- interface DrawState {
- type: Tool;
- color: string;
- startX: number;
- startY: number;
- points: [number, number][];
- }
- export function AnnotationCanvas({
- isActive,
- tool,
- color,
- width,
- height,
- pendingStrokes,
- onStrokeComplete,
- }: Props) {
- const canvasRef = useRef<HTMLCanvasElement>(null);
- const isDrawingRef = useRef(false);
- const drawRef = useRef<DrawState | null>(null);
- // ── Full canvas redraw (clear + all saved strokes + live stroke) ─────────────
- function redraw() {
- const canvas = canvasRef.current;
- if (!canvas) return;
- const ctx = canvas.getContext('2d');
- if (!ctx) return;
- ctx.clearRect(0, 0, canvas.width, canvas.height);
- // Draw all strokes that are still pending (not yet saved)
- for (const stroke of pendingStrokes) {
- drawShape(ctx, stroke);
- }
- // Draw the stroke currently being drawn
- if (drawRef.current) drawShape(ctx, toAnnotation(drawRef.current));
- }
- // ── Convert draw state → AnnotationData ────────────────────────────────────
- function toAnnotation(ds: DrawState): AnnotationData {
- if (ds.type === 'rect') {
- const minX = Math.min(ds.startX, ds.points[ds.points.length - 1][0]);
- const minY = Math.min(ds.startY, ds.points[ds.points.length - 1][1]);
- const maxX = Math.max(ds.startX, ds.points[ds.points.length - 1][0]);
- const maxY = Math.max(ds.startY, ds.points[ds.points.length - 1][1]);
- return { type: 'rect', color: ds.color, boundingBox: { x: minX, y: minY, width: maxX - minX, height: maxY - minY }, points: ds.points };
- }
- if (ds.type === 'ellipse') {
- const minX = Math.min(ds.startX, ds.points[ds.points.length - 1][0]);
- const minY = Math.min(ds.startY, ds.points[ds.points.length - 1][1]);
- const maxX = Math.max(ds.startX, ds.points[ds.points.length - 1][0]);
- const maxY = Math.max(ds.startY, ds.points[ds.points.length - 1][1]);
- return { type: 'ellipse', color: ds.color, boundingBox: { x: minX, y: minY, width: maxX - minX, height: maxY - minY }, points: ds.points };
- }
- return { type: ds.type === 'arrow' ? 'arrow' : 'pen', color: ds.color, points: ds.points };
- }
- // ── Canvas resize ────────────────────────────────────────────────────────────
- useEffect(() => {
- const canvas = canvasRef.current;
- if (!canvas) return;
- canvas.width = width;
- canvas.height = height;
- redraw();
- }, [width, height, pendingStrokes]);
- // ── Normalise mouse / touch position ────────────────────────────────────────
- function pos(e: React.MouseEvent<HTMLCanvasElement>): [number, number] {
- const rect = canvasRef.current!.getBoundingClientRect();
- return [
- (e.clientX - rect.left) / rect.width,
- (e.clientY - rect.top) / rect.height,
- ];
- }
- // ── Mouse handlers ───────────────────────────────────────────────────────────
- const onDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
- if (!isActive) return;
- e.preventDefault();
- e.stopPropagation();
- const [x, y] = pos(e);
- isDrawingRef.current = true;
- drawRef.current = { type: tool, color, startX: x, startY: y, points: [[x, y]] };
- };
- const onMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
- if (!isDrawingRef.current || !drawRef.current) return;
- e.preventDefault();
- e.stopPropagation();
- const [x, y] = pos(e);
- const pts = drawRef.current.points;
- drawRef.current = { ...drawRef.current, points: [...pts, [x, y]] };
- redraw();
- };
- const onUp = (e: React.MouseEvent<HTMLCanvasElement>) => {
- if (!isDrawingRef.current || !drawRef.current) return;
- e.preventDefault();
- e.stopPropagation();
- isDrawingRef.current = false;
- const [x, y] = pos(e);
- const pts = [...drawRef.current.points, [x, y] as [number, number]];
- drawRef.current = { ...drawRef.current, points: pts };
- onStrokeComplete(toAnnotation(drawRef.current));
- drawRef.current = null;
- redraw();
- };
- return (
- <canvas
- ref={canvasRef}
- className="absolute inset-0 z-10"
- style={{
- cursor: isActive ? 'crosshair' : 'default',
- pointerEvents: isActive ? 'auto' : 'none',
- }}
- onClick={e => { if (isActive) e.stopPropagation(); }}
- onMouseDown={onDown}
- onMouseMove={onMove}
- onMouseUp={onUp}
- onMouseLeave={onUp}
- />
- );
- }
|