AnnotationCanvas.tsx 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. 'use client';
  2. import { useRef, useEffect } from 'react';
  3. import { AnnotationData } from '../../lib/api';
  4. export const COLORS = [
  5. { name: 'Red', value: '#ef4444' },
  6. { name: 'Orange', value: '#f97316' },
  7. { name: 'Yellow', value: '#eab308' },
  8. { name: 'Green', value: '#22c55e' },
  9. { name: 'Blue', value: '#3b82f6' },
  10. { name: 'Purple', value: '#a855f7' },
  11. { name: 'White', value: '#ffffff' },
  12. ];
  13. export type Tool = 'pen' | 'arrow' | 'rect' | 'ellipse';
  14. // ── Standalone render function (used by both draw canvas and display canvas) ──
  15. export function drawShape(ctx: CanvasRenderingContext2D, ann: AnnotationData) {
  16. ctx.save();
  17. ctx.strokeStyle = ann.color;
  18. ctx.fillStyle = ann.color;
  19. ctx.lineWidth = 3;
  20. ctx.lineCap = 'round';
  21. ctx.lineJoin = 'round';
  22. if (ann.type === 'pen' && ann.points && ann.points.length >= 2) {
  23. ctx.beginPath();
  24. ctx.moveTo(ann.points[0][0] * ctx.canvas.width, ann.points[0][1] * ctx.canvas.height);
  25. for (let i = 1; i < ann.points.length; i++) {
  26. ctx.lineTo(ann.points[i][0] * ctx.canvas.width, ann.points[i][1] * ctx.canvas.height);
  27. }
  28. ctx.stroke();
  29. } else if (ann.type === 'arrow' && ann.points && ann.points.length >= 2) {
  30. const [x1, y1] = ann.points[0];
  31. const [x2, y2] = ann.points[ann.points.length - 1];
  32. const sx = x1 * ctx.canvas.width, sy = y1 * ctx.canvas.height;
  33. const ex = x2 * ctx.canvas.width, ey = y2 * ctx.canvas.height;
  34. const angle = Math.atan2(ey - sy, ex - sx);
  35. const headLen = 16;
  36. ctx.beginPath();
  37. ctx.moveTo(sx, sy); ctx.lineTo(ex, ey); ctx.stroke();
  38. ctx.beginPath();
  39. ctx.moveTo(ex, ey);
  40. ctx.lineTo(ex - headLen * Math.cos(angle - Math.PI / 6), ey - headLen * Math.sin(angle - Math.PI / 6));
  41. ctx.moveTo(ex, ey);
  42. ctx.lineTo(ex - headLen * Math.cos(angle + Math.PI / 6), ey - headLen * Math.sin(angle + Math.PI / 6));
  43. ctx.stroke();
  44. } else if (ann.type === 'rect' && ann.boundingBox) {
  45. const { x, y, width: w, height: h } = ann.boundingBox;
  46. ctx.strokeRect(x * ctx.canvas.width, y * ctx.canvas.height, w * ctx.canvas.width, h * ctx.canvas.height);
  47. } else if (ann.type === 'ellipse' && ann.boundingBox) {
  48. const { x, y, width: w, height: h } = ann.boundingBox;
  49. ctx.beginPath();
  50. ctx.ellipse(
  51. (x + w / 2) * ctx.canvas.width, (y + h / 2) * ctx.canvas.height,
  52. (w / 2) * ctx.canvas.width, (h / 2) * ctx.canvas.height, 0, 0, 2 * Math.PI
  53. );
  54. ctx.stroke();
  55. }
  56. ctx.restore();
  57. }
  58. interface Props {
  59. isActive: boolean;
  60. tool: Tool;
  61. color: string;
  62. width: number;
  63. height: number;
  64. // Already-saved strokes to keep on canvas until Save/Undo
  65. pendingStrokes: AnnotationData[];
  66. onStrokeComplete: (stroke: AnnotationData) => void;
  67. }
  68. interface DrawState {
  69. type: Tool;
  70. color: string;
  71. startX: number;
  72. startY: number;
  73. points: [number, number][];
  74. }
  75. export function AnnotationCanvas({
  76. isActive,
  77. tool,
  78. color,
  79. width,
  80. height,
  81. pendingStrokes,
  82. onStrokeComplete,
  83. }: Props) {
  84. const canvasRef = useRef<HTMLCanvasElement>(null);
  85. const isDrawingRef = useRef(false);
  86. const drawRef = useRef<DrawState | null>(null);
  87. // ── Full canvas redraw (clear + all saved strokes + live stroke) ─────────────
  88. function redraw() {
  89. const canvas = canvasRef.current;
  90. if (!canvas) return;
  91. const ctx = canvas.getContext('2d');
  92. if (!ctx) return;
  93. ctx.clearRect(0, 0, canvas.width, canvas.height);
  94. // Draw all strokes that are still pending (not yet saved)
  95. for (const stroke of pendingStrokes) {
  96. drawShape(ctx, stroke);
  97. }
  98. // Draw the stroke currently being drawn
  99. if (drawRef.current) drawShape(ctx, toAnnotation(drawRef.current));
  100. }
  101. // ── Convert draw state → AnnotationData ────────────────────────────────────
  102. function toAnnotation(ds: DrawState): AnnotationData {
  103. if (ds.type === 'rect') {
  104. const minX = Math.min(ds.startX, ds.points[ds.points.length - 1][0]);
  105. const minY = Math.min(ds.startY, ds.points[ds.points.length - 1][1]);
  106. const maxX = Math.max(ds.startX, ds.points[ds.points.length - 1][0]);
  107. const maxY = Math.max(ds.startY, ds.points[ds.points.length - 1][1]);
  108. return { type: 'rect', color: ds.color, boundingBox: { x: minX, y: minY, width: maxX - minX, height: maxY - minY }, points: ds.points };
  109. }
  110. if (ds.type === 'ellipse') {
  111. const minX = Math.min(ds.startX, ds.points[ds.points.length - 1][0]);
  112. const minY = Math.min(ds.startY, ds.points[ds.points.length - 1][1]);
  113. const maxX = Math.max(ds.startX, ds.points[ds.points.length - 1][0]);
  114. const maxY = Math.max(ds.startY, ds.points[ds.points.length - 1][1]);
  115. return { type: 'ellipse', color: ds.color, boundingBox: { x: minX, y: minY, width: maxX - minX, height: maxY - minY }, points: ds.points };
  116. }
  117. return { type: ds.type === 'arrow' ? 'arrow' : 'pen', color: ds.color, points: ds.points };
  118. }
  119. // ── Canvas resize ────────────────────────────────────────────────────────────
  120. useEffect(() => {
  121. const canvas = canvasRef.current;
  122. if (!canvas) return;
  123. canvas.width = width;
  124. canvas.height = height;
  125. redraw();
  126. }, [width, height, pendingStrokes]);
  127. // ── Normalise mouse / touch position ────────────────────────────────────────
  128. function pos(e: React.MouseEvent<HTMLCanvasElement>): [number, number] {
  129. const rect = canvasRef.current!.getBoundingClientRect();
  130. return [
  131. (e.clientX - rect.left) / rect.width,
  132. (e.clientY - rect.top) / rect.height,
  133. ];
  134. }
  135. // ── Mouse handlers ───────────────────────────────────────────────────────────
  136. const onDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
  137. if (!isActive) return;
  138. e.preventDefault();
  139. e.stopPropagation();
  140. const [x, y] = pos(e);
  141. isDrawingRef.current = true;
  142. drawRef.current = { type: tool, color, startX: x, startY: y, points: [[x, y]] };
  143. };
  144. const onMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
  145. if (!isDrawingRef.current || !drawRef.current) return;
  146. e.preventDefault();
  147. e.stopPropagation();
  148. const [x, y] = pos(e);
  149. const pts = drawRef.current.points;
  150. drawRef.current = { ...drawRef.current, points: [...pts, [x, y]] };
  151. redraw();
  152. };
  153. const onUp = (e: React.MouseEvent<HTMLCanvasElement>) => {
  154. if (!isDrawingRef.current || !drawRef.current) return;
  155. e.preventDefault();
  156. e.stopPropagation();
  157. isDrawingRef.current = false;
  158. const [x, y] = pos(e);
  159. const pts = [...drawRef.current.points, [x, y] as [number, number]];
  160. drawRef.current = { ...drawRef.current, points: pts };
  161. onStrokeComplete(toAnnotation(drawRef.current));
  162. drawRef.current = null;
  163. redraw();
  164. };
  165. return (
  166. <canvas
  167. ref={canvasRef}
  168. className="absolute inset-0 z-10"
  169. style={{
  170. cursor: isActive ? 'crosshair' : 'default',
  171. pointerEvents: isActive ? 'auto' : 'none',
  172. }}
  173. onClick={e => { if (isActive) e.stopPropagation(); }}
  174. onMouseDown={onDown}
  175. onMouseMove={onMove}
  176. onMouseUp={onUp}
  177. onMouseLeave={onUp}
  178. />
  179. );
  180. }