import { useEffect, useRef, useState } from "react"; import { useT } from "../i18n"; import { toast } from "../dialogs"; import { Icon } from "./Icon"; type Tool = "pen" | "arrow" | "rect"; interface Shape { tool: Tool; color: string; /** pen: all points; arrow/rect: start and end. */ pts: { x: number; y: number }[]; } const COLORS = ["#ff3b30", "#ffcc00", "#34c759", "#0a84ff", "#ffffff", "#000000"]; const MAX_SIDE = 2000; function paint(ctx: CanvasRenderingContext2D, shapes: Shape[], w: number) { const lw = Math.max(3, Math.round(w / 280)); for (const s of shapes) { ctx.strokeStyle = s.color; ctx.fillStyle = s.color; ctx.lineWidth = lw; ctx.lineCap = "round"; ctx.lineJoin = "round"; const a = s.pts[0]; const b = s.pts[s.pts.length - 1]; ctx.beginPath(); if (s.tool === "pen") { ctx.moveTo(a.x, a.y); for (const p of s.pts.slice(1)) ctx.lineTo(p.x, p.y); if (s.pts.length === 1) ctx.lineTo(a.x + 0.1, a.y); ctx.stroke(); } else if (s.tool === "rect") { ctx.strokeRect(a.x, a.y, b.x - a.x, b.y - a.y); } else { // Arrow: line with a filled head at the end. const ang = Math.atan2(b.y - a.y, b.x - a.x); const head = lw * 5; ctx.moveTo(a.x, a.y); ctx.lineTo(b.x - Math.cos(ang) * head * 0.6, b.y - Math.sin(ang) * head * 0.6); ctx.stroke(); ctx.beginPath(); ctx.moveTo(b.x, b.y); ctx.lineTo(b.x - head * Math.cos(ang - 0.45), b.y - head * Math.sin(ang - 0.45)); ctx.lineTo(b.x - head * Math.cos(ang + 0.45), b.y - head * Math.sin(ang + 0.45)); ctx.closePath(); ctx.fill(); } } } /** Draw on an image (pen, arrow, rectangle) and hand the result back as a PNG file. */ export function ImageAnnotator({ src, name, onDone, onCancel }: { src: string; name: string; onDone: (file: File) => void; onCancel: () => void }) { const t = useT(); const canvas = useRef(null); const bitmap = useRef(undefined); const [shapes, setShapes] = useState([]); const [tool, setTool] = useState("pen"); const [color, setColor] = useState(COLORS[0]); const [ready, setReady] = useState(false); const drawing = useRef(null); const redraw = (extra?: Shape | null) => { const c = canvas.current; const img = bitmap.current; if (!c || !img) return; const ctx = c.getContext("2d")!; ctx.clearRect(0, 0, c.width, c.height); ctx.drawImage(img, 0, 0, c.width, c.height); paint(ctx, extra ? [...shapes, extra] : shapes, c.width); }; useEffect(() => { const img = new Image(); img.crossOrigin = "anonymous"; img.onload = () => { const c = canvas.current; if (!c) return; const scale = Math.min(1, MAX_SIDE / Math.max(img.naturalWidth || 1, img.naturalHeight || 1)); c.width = Math.max(1, Math.round((img.naturalWidth || 300) * scale)); c.height = Math.max(1, Math.round((img.naturalHeight || 200) * scale)); bitmap.current = img; setReady(true); }; img.onerror = () => { toast(t("viewer.annotateFailed")); onCancel(); }; img.src = src; }, [src]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => redraw(), [shapes, ready]); // eslint-disable-line react-hooks/exhaustive-deps const point = (e: React.PointerEvent) => { const c = canvas.current!; const r = c.getBoundingClientRect(); return { x: ((e.clientX - r.left) / r.width) * c.width, y: ((e.clientY - r.top) / r.height) * c.height }; }; const finish = () => { const c = canvas.current; if (!c) return; c.toBlob((blob) => { if (!blob) return toast(t("viewer.annotateFailed")); const base = name.replace(/\.[a-z0-9]{1,5}$/i, "") || "image"; onDone(new File([blob], `${base}-marked.png`, { type: "image/png" })); }, "image/png"); }; return (
e.stopPropagation()} onWheel={(e) => e.stopPropagation()}> { e.currentTarget.setPointerCapture(e.pointerId); drawing.current = { tool, color, pts: [point(e)] }; }} onPointerMove={(e) => { const d = drawing.current; if (!d) return; const p = point(e); d.pts = d.tool === "pen" ? [...d.pts, p] : [d.pts[0], p]; redraw(d); }} onPointerUp={() => { const d = drawing.current; drawing.current = null; if (d) setShapes((s) => [...s, d]); }} onPointerCancel={() => { drawing.current = null; redraw(); }} />
{(["pen", "arrow", "rect"] as Tool[]).map((k) => ( ))} {COLORS.map((c) => (
); }