/* global React, ReactDOM */
// ─────────────────────────────────────────────────────────────────
// Annotate tool — arm (cursor tooltip) → drag (marquee capture) →
// annotate modal (pencil / text tools) → save or discard.
// ─────────────────────────────────────────────────────────────────
const { useState, useEffect, useRef, useCallback, useLayoutEffect } = React;

const ANN_COLORS = [
  { key: "black", value: "#171717", label: "Black" },
  { key: "teal", value: "#3C8480", label: "Teal" },
  { key: "blue", value: "#007BE5", label: "Blue" },
  { key: "purple", value: "#4338CA", label: "Purple" }
];
const ANN_FONT = '700 24px/30px "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
const TEXT_MAX = 400, LINE_H = 30;

const AnnIcons = {
  x: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" width="24" height="24"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>,
  undo: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" width="24" height="24"><path d="M9 14 4 9l5-5" /><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5 5.5 5.5 0 0 1-5.5 5.5H11" /></svg>,
  redo: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" width="24" height="24"><path d="m15 14 5-5-5-5" /><path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5 5.5 5.5 0 0 0 9.5 20H13" /></svg>,
  pencil: <svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24"><path d="M21 19.25C21.4143 19.25 21.75 19.5858 21.75 20C21.75 20.4142 21.4143 20.75 21 20.75H12C11.5858 20.75 11.25 20.4142 11.25 20C11.25 19.5858 11.5858 19.25 12 19.25H21Z" /><path d="M19.2501 5.12329C19.2501 4.75929 19.1051 4.41001 18.8477 4.15259C18.5903 3.89515 18.2411 3.75024 17.877 3.75024C17.513 3.75025 17.1637 3.89516 16.9063 4.15259L4.89557 16.1633C4.74698 16.3118 4.63805 16.4958 4.57917 16.6975L3.86823 19.1311L6.3028 18.4211C6.50456 18.3623 6.68934 18.2533 6.83795 18.1047L18.8477 6.09399C19.1051 5.83663 19.25 5.48725 19.2501 5.12329ZM20.7501 5.12329C20.75 5.88508 20.4469 6.61587 19.9083 7.15454L7.8985 19.1653C7.57141 19.4924 7.16683 19.7312 6.72272 19.8606L3.85065 20.6985C3.63567 20.7611 3.4074 20.7658 3.19049 20.7102C2.97358 20.6546 2.77563 20.5414 2.61725 20.3831C2.4588 20.2246 2.34572 20.0259 2.2901 19.8088C2.23458 19.5919 2.23814 19.3637 2.30084 19.1487L3.13874 16.2766C3.26828 15.833 3.50815 15.4295 3.83502 15.1028L15.8458 3.09204C16.3845 2.5533 17.1151 2.25025 17.877 2.25024C18.6389 2.25024 19.3695 2.5533 19.9083 3.09204C20.4469 3.63077 20.7501 4.36146 20.7501 5.12329Z" /><path d="M14.4698 4.46971C14.7627 4.17681 15.2374 4.17681 15.5303 4.46971L18.5303 7.46971C18.8232 7.7626 18.8232 8.23736 18.5303 8.53025C18.2374 8.82315 17.7627 8.82315 17.4698 8.53025L14.4698 5.53025C14.1769 5.23736 14.1769 4.7626 14.4698 4.46971Z" /></svg>,
  text: <svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24"><path d="M6.99998 6.25C7.28406 6.25 7.54383 6.41095 7.67088 6.66504L11.6709 14.665C11.8558 15.0354 11.7053 15.4857 11.3349 15.6709C10.9645 15.8558 10.5143 15.7053 10.3291 15.335L6.99998 8.67676L3.67088 15.335C3.4857 15.7053 3.03543 15.8558 2.66502 15.6709C2.29466 15.4857 2.14414 15.0354 2.32908 14.665L6.32908 6.66504C6.45613 6.41095 6.7159 6.25 6.99998 6.25Z" /><path d="M9.99998 12.25C10.4142 12.25 10.75 12.5858 10.75 13C10.75 13.4142 10.4142 13.75 9.99998 13.75H3.99998C3.58577 13.75 3.24998 13.4142 3.24998 13C3.24998 12.5858 3.58577 12.25 3.99998 12.25H9.99998Z" /><path d="M20.25 12C20.25 10.7574 19.2426 9.75 18 9.75C16.7573 9.75 15.75 10.7574 15.75 12C15.75 13.2426 16.7573 14.25 18 14.25C19.2426 14.25 20.25 13.2426 20.25 12ZM21.75 12C21.75 14.0711 20.071 15.75 18 15.75C15.9289 15.75 14.25 14.0711 14.25 12C14.25 9.92893 15.9289 8.25 18 8.25C20.071 8.25 21.75 9.92893 21.75 12Z" /><path d="M20.25 15V9C20.25 8.58579 20.5858 8.25 21 8.25C21.4142 8.25 21.75 8.58579 21.75 9V15C21.75 15.4142 21.4142 15.75 21 15.75C20.5858 15.75 20.25 15.4142 20.25 15Z" /></svg>,
  trash: <svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24"><path d="M21 5.25C21.4142 5.25 21.75 5.58579 21.75 6C21.75 6.41421 21.4142 6.75 21 6.75H3C2.58579 6.75 2.25 6.41421 2.25 6C2.25 5.58579 2.58579 5.25 3 5.25H21Z" /><path d="M4.25 20V6C4.25 5.58579 4.58579 5.25 5 5.25C5.41421 5.25 5.75 5.58579 5.75 6V20C5.75 20.2561 5.88553 20.575 6.15527 20.8447C6.42501 21.1145 6.74392 21.25 7 21.25H17C17.2561 21.25 17.575 21.1145 17.8447 20.8447C18.1145 20.575 18.25 20.2561 18.25 20V6C18.25 5.58579 18.5858 5.25 19 5.25C19.4142 5.25 19.75 5.58579 19.75 6V20C19.75 20.7439 19.3855 21.425 18.9053 21.9053C18.425 22.3855 17.7439 22.75 17 22.75H7C6.25608 22.75 5.57499 22.3855 5.09473 21.9053C4.61447 21.425 4.25 20.7439 4.25 20Z" /><path d="M15.25 6V4C15.25 3.74392 15.1145 3.42501 14.8447 3.15527C14.575 2.88553 14.2561 2.75 14 2.75H10C9.74392 2.75 9.42501 2.88553 9.15527 3.15527C8.88553 3.42501 8.75 3.74392 8.75 4V6C8.75 6.41421 8.41421 6.75 8 6.75C7.58579 6.75 7.25 6.41421 7.25 6V4C7.25 3.25608 7.61447 2.57499 8.09473 2.09473C8.57499 1.61447 9.25608 1.25 10 1.25H14C14.7439 1.25 15.425 1.61447 15.9053 2.09473C16.3855 2.57499 16.75 3.25608 16.75 4V6C16.75 6.41421 16.4142 6.75 16 6.75C15.5858 6.75 15.25 6.41421 15.25 6Z" /><path d="M9.25 17V11C9.25 10.5858 9.58579 10.25 10 10.25C10.4142 10.25 10.75 10.5858 10.75 11V17C10.75 17.4142 10.4142 17.75 10 17.75C9.58579 17.75 9.25 17.4142 9.25 17Z" /><path d="M13.25 17V11C13.25 10.5858 13.5858 10.25 14 10.25C14.4142 10.25 14.75 10.5858 14.75 11V17C14.75 17.4142 14.4142 17.75 14 17.75C13.5858 17.75 13.25 17.4142 13.25 17Z" /></svg>
};

// Composite the marquee area: canvas-area background + the WebGL canvas.
function captureRect(rect) {
  const area = document.querySelector(".canvas-area");
  if (!area || rect.w < 4 || rect.h < 4) return null;
  const ab = area.getBoundingClientRect();
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
  const c = document.createElement("canvas");
  c.width = Math.round(rect.w * dpr);
  c.height = Math.round(rect.h * dpr);
  const ctx = c.getContext("2d");
  const offY = (rect.y - ab.top) * dpr;
  const g = ctx.createLinearGradient(0, -offY, 0, -offY + ab.height * dpr);
  g.addColorStop(0, "#F5F5F5");
  g.addColorStop(1, "#E6E6E6");
  ctx.fillStyle = g;
  ctx.fillRect(0, 0, c.width, c.height);
  const gl = area.querySelector("canvas");
  if (gl) {
    const gb = gl.getBoundingClientRect();
    const kx = gl.width / gb.width, ky = gl.height / gb.height;
    ctx.drawImage(gl,
      (rect.x - gb.left) * kx, (rect.y - gb.top) * ky, rect.w * kx, rect.h * ky,
      0, 0, c.width, c.height);
  }
  return c.toDataURL("image/png");
}

// A stroke is a list of points with nulls marking gaps (cursor left the image).
function subpaths(points) {
  const out = [];
  let cur = [];
  points.forEach((p) => {
    if (p === null) { if (cur.length) out.push(cur); cur = []; }
    else cur.push(p);
  });
  if (cur.length) out.push(cur);
  return out;
}

function pathBox(points) {
  const pts = points.filter(Boolean);
  if (!pts.length) return null;
  const xs = pts.map((p) => p[0]), ys = pts.map((p) => p[1]);
  const x = Math.min(...xs), y = Math.min(...ys);
  return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y };
}

// Wrap a text layer the way the DOM does (word wrap, 400px max) so the
// flattened PNG matches what the user saw.
function wrapLines(ctx, text, max) {
  const out = [];
  text.split("\n").forEach((para) => {
    const words = para.split(" ");
    let line = "";
    words.forEach((w) => {
      const next = line ? line + " " + w : w;
      if (ctx.measureText(next).width > max && line) { out.push(line); line = w; }
      else line = next;
    });
    out.push(line);
  });
  return out;
}

// Flatten screenshot + annotation layers into a single PNG.
function flatten(shot, layers, size) {
  return new Promise((resolve) => {
    const im = new Image();
    im.onload = () => {
      const dpr = 2;
      const c = document.createElement("canvas");
      c.width = size.w * dpr; c.height = size.h * dpr;
      const ctx = c.getContext("2d");
      ctx.scale(dpr, dpr);
      ctx.drawImage(im, 0, 0, size.w, size.h);
      ctx.lineWidth = 4; ctx.lineCap = "round"; ctx.lineJoin = "round";
      layers.forEach((l) => {
        if (l.type === "path") {
          ctx.strokeStyle = l.color;
          subpaths(l.points).forEach((seg) => {
            ctx.beginPath();
            seg.forEach((p, i) => i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]));
            if (seg.length === 1) ctx.lineTo(seg[0][0] + 0.01, seg[0][1]);
            ctx.stroke();
          });
        } else {
          ctx.fillStyle = l.color;
          ctx.font = ANN_FONT.replace("/30px", "").replace("700 24px", "700 24px");
          ctx.textBaseline = "alphabetic";
          const lines = wrapLines(ctx, l.text, TEXT_MAX);
          lines.forEach((line, i) => ctx.fillText(line, l.x, l.y - 15 + LINE_H * i + 22));
        }
      });
      resolve(c.toDataURL("image/png"));
    };
    im.src = shot;
  });
}

function AnnotateTool({ onExit, onCapturingChange, onSave, initialShot }) {
  const [step, setStep] = useState(initialShot ? "review" : "arm"); // arm | drag | review
  const [cursor, setCursor] = useState(null);
  const [hoverToolbar, setHoverToolbar] = useState(false);
  const [rect, setRect] = useState(null);
  const [shot, setShot] = useState(initialShot || null);
  const [discard, setDiscard] = useState(false);
  const startRef = useRef(null);

  // ── editor state ──
  const [tool, setTool] = useState("pencil");       // null | pencil | text
  const [color, setColor] = useState(ANN_COLORS[0].value);
  const [layers, setLayers] = useState([]);
  const [history, setHistory] = useState([]);      // undo stack: a full layers snapshot per distinct action
  const [redoStack, setRedoStack] = useState([]);  // redo stack: same shape as history
  const [draft, setDraft] = useState(null);         // in-progress stroke
  const [editing, setEditing] = useState(null);     // { x, y, text }
  const [selected, setSelected] = useState(null);   // index of a selected text layer
  const [nat, setNat] = useState(null);             // natural screenshot size
  const [fit, setFit] = useState(null);             // displayed size
  const stageRef = useRef(null);
  const canvasRef = useRef(null);
  const editRef = useRef(null);
  const dragRef = useRef(null);
  const caretRef = useRef(null);
  // Pre-edit snapshot, set only when re-editing an existing text layer (see
  // onLayerUp/commitText) so the whole edit — old text to new — is one step.
  const editOrigLayersRef = useRef(null);

  const capturing = step === "drag";
  useEffect(() => { onCapturingChange(capturing); }, [capturing]);

  const norm = (a, b) => ({
    x: Math.min(a.x, b.x), y: Math.min(a.y, b.y),
    w: Math.abs(a.x - b.x), h: Math.abs(a.y - b.y)
  });

  // ── Arm / marquee ──
  useEffect(() => {
    if (step === "review") return;
    const inCanvas = (e) => !!e.target.closest && !!e.target.closest(".canvas-area");
    // The bottom viewport toolbar (view presets, annotate toggle, etc.) sits
    // visually inside .canvas-area, so without this the arm/marquee logic
    // below would treat clicking it as "start a capture" instead of letting
    // the click reach the toolbar's own buttons.
    const overToolbar = (e) => !!e.target.closest && !!e.target.closest(".vpt-wrap");
    const onMove = (e) => {
      setCursor({ x: e.clientX, y: e.clientY });
      setHoverToolbar(overToolbar(e));
      if (startRef.current) setRect(norm(startRef.current, { x: e.clientX, y: e.clientY }));
    };
    const onDown = (e) => {
      if (step !== "arm" || (e.pointerType === "mouse" && e.button !== 0) || !inCanvas(e) || overToolbar(e)) return;
      e.preventDefault();
      startRef.current = { x: e.clientX, y: e.clientY };
      setRect({ x: e.clientX, y: e.clientY, w: 0, h: 0 });
      setStep("drag");
    };
    const onUp = () => {
      if (!startRef.current) return;
      const r = rect;
      startRef.current = null;
      if (!r || r.w < 8 || r.h < 8) { setRect(null); setStep("arm"); return; }
      requestAnimationFrame(() => {
        const data = captureRect(r);
        setRect(null);
        if (!data) { setStep("arm"); return; }
        setShot(data);
        setStep("review");
      });
    };
    // Pointer events (not mouse) so the marquee works under touch too.
    window.addEventListener("pointermove", onMove);
    window.addEventListener("pointerdown", onDown, true);
    window.addEventListener("pointerup", onUp, true);
    window.addEventListener("pointercancel", onUp, true);
    return () => {
      window.removeEventListener("pointermove", onMove);
      window.removeEventListener("pointerdown", onDown, true);
      window.removeEventListener("pointerup", onUp, true);
      window.removeEventListener("pointercancel", onUp, true);
    };
  }, [step, rect]);

  // Natural screenshot size, then fit it into the stage.
  useEffect(() => {
    if (!shot) { setNat(null); return; }
    const im = new Image();
    im.onload = () => setNat({ w: im.width, h: im.height });
    im.src = shot;
  }, [shot]);
  useLayoutEffect(() => {
    const measure = () => {
      const st = stageRef.current;
      if (!st || !nat) return;
      const s = Math.min(st.clientWidth / nat.w, st.clientHeight / nat.h);
      setFit({ w: Math.round(nat.w * s), h: Math.round(nat.h * s) });
    };
    measure();
    window.addEventListener("resize", measure);
    return () => window.removeEventListener("resize", measure);
  }, [nat, step]);

  // Esc: cancel the tool, close a text edit, or ask before dropping a capture.
  useEffect(() => {
    const onKey = (e) => {
      if ((e.key === "Delete" || e.key === "Backspace") && selected != null && !editing) {
        e.preventDefault();
        deleteSelected();
        return;
      }
      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "z" && !editing) {
        e.preventDefault();
        if (e.shiftKey) redo(); else undo();
        return;
      }
      if (e.key !== "Escape") return;
      if (editing) { commitText(); return; }
      if (selected != null) { setSelected(null); return; }
      if (step === "review") { if (!discard) askDiscard(); return; }
      onExit();
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [step, onExit, editing, discard, selected, history.length, redoStack.length]);

  const retake = useCallback(() => {
    setShot(null); setLayers([]); setHistory([]); setRedoStack([]); setEditing(null); setSelected(null); setTool("pencil"); setStep("arm");
  }, []);

  // Every distinct user action (draw a stroke, delete a layer, recolor a
  // layer, finish dragging one) snapshots the layers array as it stood right
  // before that action onto `history`, so undo/redo step through actions one
  // at a time instead of only ever reverting whole-layer additions.
  const mutate = (fn) => {
    setLayers((ls) => {
      const next = fn(ls);
      setHistory((h) => h.concat([ls]));
      setRedoStack([]);
      return next;
    });
  };
  const undo = () => {
    setHistory((h) => {
      if (!h.length) return h;
      const prev = h[h.length - 1];
      setLayers((ls) => { setRedoStack((r) => r.concat([ls])); return prev; });
      return h.slice(0, -1);
    });
  };
  const redo = () => {
    setRedoStack((r) => {
      if (!r.length) return r;
      const next = r[r.length - 1];
      setLayers((ls) => { setHistory((h) => h.concat([ls])); return next; });
      return r.slice(0, -1);
    });
  };

  const pickTool = (name) => { setTool((t) => t === name ? null : name); setSelected(null); };
  // Picking a color implies you want to mark up: arm the pencil if no tool is
  // active. With a text frame selected, the color re-styles that frame.
  const pickColor = (value) => {
    setColor(value);
    if (selected != null) mutate((ls) => ls.map((l, i) => i === selected ? { ...l, color: value } : l));
    else if (editing) setEditing((ed) => ed && { ...ed, color: value });
    else if (!tool) setTool("pencil");
  };

  const localPt = (e, clamp) => {
    const r = canvasRef.current.getBoundingClientRect();
    let x = e.clientX - r.left, y = e.clientY - r.top;
    if (clamp) { x = Math.max(0, Math.min(r.width, x)); y = Math.max(0, Math.min(r.height, y)); }
    return { x, y };
  };

  const commitText = () => {
    const ed = editing;
    if (!ed) return null;
    const text = (editRef.current ? editRef.current.innerText : ed.text).replace(/\u00a0/g, " ").replace(/\n+$/, "");
    setEditing(null);
    // Re-editing an existing layer provisionally removed it (see onLayerUp)
    // without touching history, so the whole edit — old text to new text, or
    // old text to gone if cleared — lands as a single undoable step here.
    const before = editOrigLayersRef.current;
    editOrigLayersRef.current = null;
    if (!text.trim()) {
      if (before === null) return null;
      setLayers((ls) => { setHistory((h) => h.concat([before])); setRedoStack([]); return ls; });
      return null;
    }
    const layer = { type: "text", x: ed.x, y: ed.y, color: ed.color, text };
    setLayers((ls) => {
      setHistory((h) => h.concat([before !== null ? before : ls]));
      setRedoStack([]);
      return ls.concat([layer]);
    });
    return layer;
  };

  // ── Drawing on the screenshot ──
  const shiftRef = useRef(false);
  useEffect(() => {
    const down = (e) => { if (e.key === "Shift") shiftRef.current = true; };
    const up = (e) => { if (e.key === "Shift") shiftRef.current = false; };
    window.addEventListener("keydown", down);
    window.addEventListener("keyup", up);
    window.addEventListener("blur", up);
    return () => {
      window.removeEventListener("keydown", down);
      window.removeEventListener("keyup", up);
      window.removeEventListener("blur", up);
    };
  }, []);
  const onCanvasDown = (e) => {
    if (e.button !== 0) return;
    setSelected(null);
    if (editing) { commitText(); return; }
    if (tool === "pencil") {
      e.preventDefault();
      const p = localPt(e);
      setDraft({ type: "path", color, points: [[p.x, p.y]] });
      try { canvasRef.current.setPointerCapture(e.pointerId); } catch (err) { /* no active pointer */ }
    } else if (tool === "text") {
      // preventDefault keeps the browser's default mousedown focus/selection
      // from stealing focus back off the caret we're about to mount.
      e.preventDefault();
      const p = localPt(e);
      setEditing({ x: p.x, y: p.y, text: "", color });
    }
  };
  const onCanvasMove = (e) => {
    if (!draftRef.current) return;
    const r = canvasRef.current.getBoundingClientRect();
    const x = e.clientX - r.left, y = e.clientY - r.top;
    const outside = x < 0 || y < 0 || x > r.width || y > r.height;
    setDraft((d) => {
      if (!d) return d;
      const last = d.points[d.points.length - 1];
      if (outside) return last === null ? d : { ...d, points: d.points.concat([null]) };
      // Shift held: snap to a perfectly straight horizontal/vertical line from
      // the stroke's start point, dropping any freehand wobble already drawn.
      if (shiftRef.current) {
        const start = d.points[0];
        if (start) {
          const dx = x - start[0], dy = y - start[1];
          const p = Math.abs(dx) >= Math.abs(dy) ? [x, start[1]] : [start[0], y];
          return { ...d, points: [start, p] };
        }
      }
      return { ...d, points: d.points.concat([[x, y]]) };
    });
  };
  // Stroke ends are handled by the window-level listeners below so a pointer
  // release outside the image (or a lost capture) can never leave it drawing.
  const onCanvasUp = () => {};

  const draftRef = useRef(null);
  draftRef.current = draft;
  useEffect(() => {
    if (!draft) return;
    const end = () => {
      const d = draftRef.current;
      draftRef.current = null;
      if (d && subpaths(d.points).length) mutate((ls) => ls.concat([d]));
      setDraft(null);
    };
    const move = (e) => { if (e.buttons === 0) end(); };
    window.addEventListener("pointerup", end, true);
    window.addEventListener("pointercancel", end, true);
    window.addEventListener("lostpointercapture", end, true);
    window.addEventListener("blur", end);
    window.addEventListener("pointermove", move, true);
    return () => {
      window.removeEventListener("pointerup", end, true);
      window.removeEventListener("pointercancel", end, true);
      window.removeEventListener("lostpointercapture", end, true);
      window.removeEventListener("blur", end);
      window.removeEventListener("pointermove", move, true);
    };
  }, [draft]);

  useEffect(() => {
    if (editing && editRef.current) {
      const el = editRef.current;
      el.focus();
      const sel = window.getSelection();
      const pt = caretRef.current;
      caretRef.current = null;
      let r = null;
      if (pt && document.caretRangeFromPoint) r = document.caretRangeFromPoint(pt.x, pt.y);
      if (!r || !el.contains(r.startContainer)) {
        r = document.createRange();
        r.selectNodeContents(el); r.collapse(false);
      }
      sel.removeAllRanges(); sel.addRange(r);
    }
  }, [editing]);

  // Select / drag any placed markup — text frames and pencil strokes alike.
  // Grabbing an existing layer takes priority over the active tool (pencil
  // included), so the user doesn't have to switch to the text tool just to
  // move or restyle something they already placed.
  const onLayerDown = (i) => (e) => {
    e.stopPropagation(); e.preventDefault();
    const l = layers[i];
    if (selected !== i) { setSelected(i); setColor(l.color); }
    dragRef.current = { i, sx: e.clientX, sy: e.clientY, orig: l, moved: 0, wasSelected: selected === i };
    try { e.currentTarget.setPointerCapture(e.pointerId); } catch (err) { /* no active pointer */ }
  };
  const onLayerMove = (e) => {
    const d = dragRef.current;
    if (!d) return;
    const dx = e.clientX - d.sx, dy = e.clientY - d.sy;
    d.moved = Math.max(d.moved, Math.abs(dx) + Math.abs(dy));
    setLayers((ls) => ls.map((l, idx) => {
      if (idx !== d.i) return l;
      if (d.orig.type === "text") return { ...l, x: d.orig.x + dx, y: d.orig.y + dy };
      return { ...l, points: d.orig.points.map((p) => p === null ? null : [p[0] + dx, p[1] + dy]) };
    }));
  };
  const onLayerUp = (e) => {
    const d = dragRef.current;
    dragRef.current = null;
    if (!d) return;
    if (d.moved >= 3) {
      // A real drag: record the pre-drag position as its own undo step —
      // onLayerMove itself never touches history, or every intermediate
      // pixel of the drag would count as a separate action.
      setLayers((ls) => {
        const before = ls.map((l, idx) => idx === d.i ? d.orig : l);
        setHistory((h) => h.concat([before]));
        setRedoStack([]);
        return ls;
      });
      return;
    }
    if (d.wasSelected && d.orig.type === "text") {
      // Second click on an already-selected frame drops a caret where clicked.
      const l = layers[d.i];
      caretRef.current = { x: e.clientX, y: e.clientY };
      setSelected(null);
      setLayers((ls) => {
        editOrigLayersRef.current = ls;
        return ls.filter((_, idx) => idx !== d.i);
      });
      setEditing({ x: l.x, y: l.y, text: l.text, color: l.color });
    }
  };
  // Re-editing a saved annotation with no new markup: nothing to lose, so
  // close without the discard prompt.
  const askDiscard = () => { if (initialShot && !layers.length) onExit(); else setDiscard(true); };
  const deleteSelected = () => {
    if (selected == null) return;
    mutate((ls) => ls.filter((_, i) => i !== selected));
    setSelected(null);
  };

  // A text edit in progress is committed inline, so fold it in here too —
  // React state won't have caught up by the time we flatten.
  const doSave = async (pending) => {
    const all = pending ? layers.concat([pending]) : layers;
    const data = await flatten(shot, all, fit || { w: nat.w, h: nat.h });
    onSave && onSave(data);
    onExit();
  };

  const strokes = draft ? layers.concat([draft]) : layers;

  return (
    <React.Fragment>
      {step === "arm" && cursor && !hoverToolbar &&
        <div className="ann-cursor-tip" style={{ left: cursor.x, top: cursor.y }}>
          <span className="tip-label">Click and drag to capture an area</span>
          <span className="tip-key">ESC</span>
        </div>
      }
      {(step === "arm" || step === "drag") &&
        <div className="ann-touch-hint">Touch and drag to capture an area</div>
      }
      {rect &&
        <div className="ann-marquee" style={{ left: rect.x, top: rect.y, width: rect.w, height: rect.h }} />
      }
      {step === "review" && ReactDOM.createPortal(
        <div className="ann-overlay">
          <div className="ann-modal">
            <div className="ann-modal-head">
              <div className="ann-modal-headtext">
                <h2 className="ann-modal-title">{initialShot ? "Edit Annotation" : "Annotate Screenshot"}</h2>
                <p className="ann-modal-sub">Use the annotation tools to mark up the design with any notes and comments.</p>
              </div>
              <div className="ann-head-actions">
                <button className="ann-modal-x" aria-label="Undo" disabled={!history.length} onClick={undo}>{AnnIcons.undo}</button>
                <button className="ann-modal-x" aria-label="Redo" disabled={!redoStack.length} onClick={redo}>{AnnIcons.redo}</button>
                <button className="ann-modal-x" aria-label="Close" onClick={askDiscard}>{AnnIcons.x}</button>
              </div>
            </div>
            <div className="ann-stage" ref={stageRef}>
              {shot && fit &&
                <div
                  ref={canvasRef}
                  className={"ann-canvas" + (tool ? " tool-" + tool : "")}
                  style={{ width: fit.w, height: fit.h }}
                  onPointerDown={onCanvasDown}
                  onPointerMove={onCanvasMove}
                  onPointerUp={onCanvasUp}
                  onPointerCancel={onCanvasUp}>
                  <img src={shot} alt="Captured design screenshot" draggable={false} />
                  <svg className="ann-strokes" width={fit.w} height={fit.h} viewBox={`0 0 ${fit.w} ${fit.h}`}>
                    {strokes.map((l, i) => l.type === "path" &&
                      subpaths(l.points).map((seg, j) =>
                        <polyline key={i + "-" + j}
                          points={seg.map((p) => p[0] + "," + p[1]).join(" ")}
                          fill="none" stroke={l.color} strokeWidth="4"
                          strokeLinecap="round" strokeLinejoin="round" />
                      )
                    )}
                    {layers.map((l, i) => l.type === "path" &&
                      subpaths(l.points).map((seg, j) =>
                        <polyline key={"hit" + i + "-" + j}
                          className="ann-hit"
                          points={seg.map((p) => p[0] + "," + p[1]).join(" ")}
                          fill="none" stroke="transparent" strokeWidth="18"
                          strokeLinecap="round" strokeLinejoin="round"
                          onPointerDown={onLayerDown(i)}
                          onPointerMove={onLayerMove}
                          onPointerUp={onLayerUp} />
                      )
                    )}
                    {selected != null && layers[selected] && layers[selected].type === "path" && (() => {
                      const b = pathBox(layers[selected].points);
                      return b && <rect className="ann-selbox" x={b.x - 6} y={b.y - 6} width={b.w + 12} height={b.h + 12} rx="4" />;
                    })()}
                  </svg>
                  {layers.map((l, i) => l.type === "text" &&
                    <div key={i}
                      className={"ann-text draggable" + (selected === i ? " selected" : "")}
                      style={{ left: l.x, top: l.y - 15, color: l.color }}
                      onPointerDown={onLayerDown(i)}
                      onPointerMove={onLayerMove}
                      onPointerUp={onLayerUp}>{l.text}</div>
                  )}
                  {editing &&
                    <div
                      ref={editRef}
                      className="ann-text ann-text-edit"
                      style={{ left: editing.x, top: editing.y - 15, color: editing.color, caretColor: editing.color }}
                      contentEditable
                      suppressContentEditableWarning
                      onPointerDown={(e) => e.stopPropagation()}
                      onBlur={commitText}>{editing.text}</div>
                  }
                </div>
              }
              <div className="ann-toolbar">
                <div className="ann-tool-group">
                  <button className={"ann-tool" + (tool === "pencil" ? " active" : "")} onClick={() => pickTool("pencil")} aria-pressed={tool === "pencil"}>
                    {AnnIcons.pencil}
                    <span className="ann-tip">Pencil Tool</span>
                  </button>
                  <button className={"ann-tool" + (tool === "text" ? " active" : "")} onClick={() => pickTool("text")} aria-pressed={tool === "text"}>
                    {AnnIcons.text}
                    <span className="ann-tip">Text Tool</span>
                  </button>
                </div>
                <div className="ann-tool-divider" />
                <div className="ann-tool-group">
                  {ANN_COLORS.map((c) =>
                    <button key={c.key}
                      className={"ann-tool ann-swatch" + (color === c.value ? " active" : "")}
                      onClick={() => pickColor(c.value)}
                      aria-pressed={color === c.value}>
                      <span className="ann-dot" style={{ background: c.value }} />
                      <span className="ann-tip">{c.label}</span>
                    </button>
                  )}
                </div>
                <div className="ann-tool-divider" />
                <div className="ann-tool-group">
                  <button className="ann-tool" disabled={selected == null} aria-label="Delete" onClick={deleteSelected}>
                    {AnnIcons.trash}
                    <span className="ann-tip">Delete</span>
                  </button>
                </div>
              </div>
            </div>
            <div className="ann-modal-foot">
              <button className="ann-btn ann-btn-primary" disabled={!!initialShot && !layers.length} onClick={() => doSave(commitText())}>{initialShot ? "Save Changes" : "Save Annotation"}</button>
              <button className="ann-btn" onClick={initialShot ? askDiscard : retake}>{initialShot ? "Cancel" : "Retake Screenshot"}</button>
            </div>
          </div>
          {discard &&
            <div className="modal-overlay ann-confirm" onClick={() => setDiscard(false)}>
              <div className="modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
                <div className="modal-head">
                  <div className="modal-headtext">
                    <h2 className="modal-title">{initialShot ? "Discard changes?" : "Discard screenshot?"}</h2>
                  </div>
                  <button className="icon-btn modal-x" onClick={() => setDiscard(false)} aria-label="Close">{AnnIcons.x}</button>
                </div>
                <div className="modal-body">
                  <p className="modal-note" style={{ marginTop: 0 }}>{initialShot ? "Unsaved changes will be lost and this screenshot will revert to its last saved version." : "Your screenshot and any annotations on it will not be saved if you proceed."}</p>
                </div>
                <div className="modal-foot">
                  <button className="btn btn-lg ann-btn-destructive" onClick={() => { setDiscard(false); onExit(); }}>Discard</button>
                  <button className="btn btn-neutral btn-lg" onClick={() => setDiscard(false)}>Cancel</button>
                </div>
              </div>
            </div>
          }
        </div>,
        document.querySelector(".app") || document.body)}
    </React.Fragment>);
}

Object.assign(window, { AnnotateTool });
