← Whiteboard results

Whiteboard

Deepseek v4.1 Flash · full

Initial view of Whiteboard

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Imported historical artifact. Its exact original instructions and lineage are not known.

Generation prompt · exact text unavailable

See the current prompt for reference. It may differ from the instructions used to generate this artifact.

Inspect original source 61,807 bytes · SHA-256 8232a17c4f51
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Slate — Whiteboard</title>
<style>
  /* ============================================================
     APP CHROME / GLOBAL STYLES
     ============================================================ */
  :root{
    --chrome:#e9ebef;
    --chrome-2:#f6f7f9;
    --canvas-bg:#fbfbfa;
    --ink:#1b1d21;
    --muted:#6b7280;
    --line:#d6d9df;
    --line-soft:#e3e6ea;
    --accent:#5b5bd6;
    --accent-soft:#ececfb;
    --shadow-sm:0 1px 2px rgba(16,20,30,.08), 0 1px 3px rgba(16,20,30,.06);
    --shadow-md:0 4px 14px rgba(16,20,30,.10), 0 1px 3px rgba(16,20,30,.07);
    --shadow-lg:0 12px 40px rgba(16,20,30,.16), 0 2px 8px rgba(16,20,30,.08);
    --radius:10px;
  }
  *{box-sizing:border-box}
  html,body{height:100%;margin:0;overflow:hidden}
  body{
    font-family:"Inter",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
    color:var(--ink);
    background:var(--chrome);
    -webkit-font-smoothing:antialiased;
    font-size:13px;
    user-select:none;
  }
  #stage{position:fixed;inset:0}
  #board{position:absolute;inset:0;display:block;touch-action:none;cursor:crosshair}
  #board.tool-select{cursor:default}
  #board.panning{cursor:grabbing}
  #board.pannable{cursor:grab}

  /* ---------- Floating toolbar ---------- */
  .toolbar{
    position:fixed;top:70px;left:14px;
    display:flex;align-items:center;gap:2px;flex-wrap:wrap;
    background:#fff;border:1px solid var(--line);
    border-radius:12px;box-shadow:var(--shadow-md);
    padding:5px;z-index:20;max-width:calc(100vw - 28px);
  }
  .toolbar .sep{width:1px;height:22px;background:var(--line-soft);margin:0 5px}
  .tbtn{
    width:36px;height:36px;border:0;background:transparent;border-radius:8px;
    display:flex;align-items:center;justify-content:center;cursor:pointer;color:#41454d;
    position:relative;transition:background .12s ease,color .12s ease;
  }
  .tbtn:hover{background:#f1f2f5;color:var(--ink)}
  .tbtn:active{transform:translateY(1px)}
  .tbtn.active{background:var(--accent-soft);color:var(--accent)}
  .tbtn.active::after{
    content:"";position:absolute;bottom:-1px;left:50%;transform:translateX(-50%);
    width:14px;height:2px;border-radius:2px;background:var(--accent);
  }
  .tbtn[disabled]{opacity:.3;pointer-events:none}
  .tbtn svg{width:19px;height:19px;display:block}
  .tbtn .kbd{
    position:absolute;right:3px;bottom:2px;font-size:8px;color:#a7abb4;font-weight:600;
    letter-spacing:.2px;
  }
  .tbtn.active .kbd{color:#b0b0f0}

  /* ---------- Properties bar ---------- */
  .props{
    position:fixed;top:14px;left:14px;
    display:flex;align-items:center;gap:10px;height:46px;
    background:#fff;border:1px solid var(--line);border-radius:12px;
    box-shadow:var(--shadow-md);padding:0 12px;z-index:20;
  }
  .brand{display:flex;align-items:center;gap:8px;padding-right:10px;margin-right:2px;border-right:1px solid var(--line-soft)}
  .brand .dot{width:11px;height:11px;border-radius:3px;background:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
  .brand span{font-weight:650;letter-spacing:-.2px;font-size:13.5px}
  .swatches{display:flex;gap:3px}
  .sw{
    width:19px;height:19px;border-radius:50%;border:1.5px solid rgba(0,0,0,.10);
    cursor:pointer;padding:0;position:relative;transition:transform .1s ease;
  }
  .sw:hover{transform:scale(1.14)}
  .sw.sel{outline:2px solid var(--accent);outline-offset:1.5px}
  .sw.none{background:#fff;overflow:hidden}
  .sw.none::after{
    content:"";position:absolute;left:50%;top:50%;width:150%;height:1.5px;background:#e05252;
    transform:translate(-50%,-50%) rotate(-45deg);
  }
  .pgroup{display:flex;align-items:center;gap:6px}
  .plabel{color:var(--muted);font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px}
  .widths{display:flex;gap:3px;align-items:center}
  .wb{
    width:26px;height:26px;border:0;background:transparent;border-radius:7px;cursor:pointer;
    display:flex;align-items:center;justify-content:center;
  }
  .wb:hover{background:#f1f2f5}
  .wb.sel{background:var(--accent-soft)}
  .wb i{display:block;background:#41454d;border-radius:99px}
  .wb.sel i{background:var(--accent)}

  /* ---------- Zoom indicator ---------- */
  .zoombar{
    position:fixed;bottom:14px;right:14px;display:flex;align-items:center;gap:1px;
    background:#fff;border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow-md);
    padding:3px;z-index:20;
  }
  .zbtn{
    height:30px;min-width:30px;padding:0 8px;border:0;background:transparent;border-radius:7px;
    cursor:pointer;color:#41454d;font-size:12px;font-weight:600;display:flex;align-items:center;gap:5px;
    justify-content:center;
  }
  .zbtn:hover{background:#f1f2f5;color:var(--ink)}
  .zval{min-width:56px;text-align:center;font-variant-numeric:tabular-nums;color:var(--ink)}
  .zbtn svg{width:15px;height:15px}

  /* ---------- Hint / status ---------- */
  .hint{
    position:fixed;bottom:14px;left:14px;color:var(--muted);z-index:20;
    background:rgba(255,255,255,.82);backdrop-filter:blur(6px);
    border:1px solid var(--line-soft);border-radius:9px;padding:7px 11px;
    font-size:11.5px;box-shadow:var(--shadow-sm);
  }
  .hint b{color:#41454d;font-weight:600}
  .hint kbd{
    font-family:inherit;font-size:10.5px;background:#f1f2f5;border:1px solid var(--line-soft);
    border-radius:4px;padding:1px 4px;color:#41454d;font-weight:600;
  }

  /* ---------- Text editor overlay ---------- */
  #textEditor{
    position:fixed;display:none;z-index:30;margin:0;padding:0;border:0;outline:0;
    background:transparent;resize:none;overflow:hidden;white-space:pre;line-height:1.25;
    caret-color:var(--accent);
  }
  #textEditor::placeholder{color:#c2c6cd}
  .caret-guide{position:fixed;pointer-events:none;z-index:29;border:1.5px dashed var(--accent);border-radius:6px;opacity:.55}

  .toast{
    position:fixed;bottom:70px;left:50%;transform:translateX(-50%) translateY(8px);
    background:#1b1d21;color:#fff;padding:8px 14px;border-radius:9px;font-size:12px;font-weight:500;
    box-shadow:var(--shadow-lg);opacity:0;pointer-events:none;transition:opacity .2s ease,transform .2s ease;z-index:40;
  }
  .toast.on{opacity:.95;transform:translateX(-50%) translateY(0)}
</style>
</head>
<body>
<div id="stage">
  <canvas id="board"></canvas>
</div>

<!-- ============ Properties bar ============ -->
<div class="props" id="propsBar">
  <div class="brand"><div class="dot"></div><span>Slate</span></div>
  <div class="pgroup">
    <span class="plabel">Stroke</span>
    <div class="swatches" id="strokeSwatches"></div>
  </div>
  <div class="pgroup">
    <span class="plabel">Fill</span>
    <div class="swatches" id="fillSwatches"></div>
  </div>
  <div class="pgroup">
    <span class="plabel">Width</span>
    <div class="widths" id="widthBtns"></div>
  </div>
</div>

<!-- ============ Toolbar ============ -->
<div class="toolbar" id="toolbar"></div>

<!-- ============ Zoom bar ============ -->
<div class="zoombar">
  <button class="zbtn" id="zoomOut" title="Zoom out">
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M5 12h14"/></svg>
  </button>
  <button class="zbtn zval" id="zoomVal" title="Click to reset to 100%">100%</button>
  <button class="zbtn" id="zoomIn" title="Zoom in">
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
  </button>
  <div class="sep" style="width:1px;height:20px;background:var(--line-soft);margin:0 3px"></div>
  <button class="zbtn" id="resetView" title="Reset view (Shift+1)">
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 4v5h5"/></svg>
    Reset
  </button>
</div>

<div class="hint" id="hint"></div>
<div class="toast" id="toast"></div>
<textarea id="textEditor" spellcheck="false"></textarea>
<div class="caret-guide" id="caretGuide" style="display:none"></div>

<script>
"use strict";
/* ============================================================================
   0. TUNABLE CONSTANTS
   ========================================================================= */
const CFG = {
  ZOOM_MIN: 0.25,
  ZOOM_MAX: 4.0,
  ZOOM_STEP: 1.15,          // wheel / button zoom factor
  GRID_BASE: 20,            // world-space grid spacing at zoom 1
  HIT_TOLERANCE: 6,         // world units of slack for stroke / border hit tests
  HANDLE_SIZE: 8,           // screen px corner handles
  HANDLE_HIT: 11,           // screen px hit radius for handles
  MIN_SHAPE: 2,             // min drag size before a shape is created
  TEXT_SIZE: 20,            // default font size (world units)
  TEXT_LINE: 1.25,
  TEXT_MIN_W: 40,
  NUDGE: 1,
  NUDGE_BIG: 10,
  PEN_CLOSE: 2.0,           // min world distance between captured pen points
  SMOOTH: 0.45,             // ink smoothing (0 = raw, 1 = heavy)
  DPR_CAP: 2,
  STORAGE_KEY: "slate.board.v1",
  ERASER_R: 12,             // world radius of eraser head
};

const PALETTE = {
  strokes: ["#1e1e1e", "#e03131", "#2f6fed", "#2f9e44", "#e8590c", "#9c36b5", "#868e96"],
  fills:   ["none", "#ffc9c9", "#a5c9ff", "#b2f2bb", "#ffd8a8", "#e5c8f5", "#e9ecef"],
};

/* ============================================================================
   1. UTILITIES
   ========================================================================= */
const uid = () => Math.random().toString(36).slice(2, 9) + Date.now().toString(36).slice(-4);
const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
const lerp = (a, b, t) => a + (b - a) * t;
const dist2 = (x1, y1, x2, y2) => { const dx = x2 - x1, dy = y2 - y1; return dx * dx + dy * dy; };
const dist = (x1, y1, x2, y2) => Math.sqrt(dist2(x1, y1, x2, y2));
const round = (v, p = 2) => Math.round(v * 10 ** p) / 10 ** p;
function clone(o) { return JSON.parse(JSON.stringify(o)); }

/* ============================================================================
   2. SCENE / STATE MODEL
   ---------------------------------------------------------------------------
   Every drawable is one serializable object in `scene.objects`:
     { id, type:'rect'|'ellipse'|'line'|'arrow'|'pen'|'text', ... }
   Bounds are stored explicitly (x,y,w,h) — for freehand we keep the raw
   world-space points and derive bounds from those (recomputed on resize).
   ========================================================================= */
let scene = { objects: [] };

const state = {
  tool: "select",                    // active tool
  view: { x: 0, y: 0, z: 1 },        // world->screen is: s = (w - view.x) * z ? NO
  selection: new Set(),              // ids of selected objects
  style: { stroke: PALETTE.strokes[0], fill: "none", width: 2 },
  draft: null,                       // in-progress shape/pen being drawn
  marquee: null,                     // rubber band rect in world coords
  drag: null,                        // active move/resize interaction
  spaceDown: false,
  editing: null,                     // text editing session
  hoverId: null,
  erasing: false,
};

/* viewport helpers ---------------------------------------------------------
   world -> screen:  s = w * z + t          (t = pan offset in screen px)
   screen -> world:  w = (s - t) / z
   view.x / view.y ARE the translation t (screen px).                        */
const view = state.view;
function w2s(wx, wy) { return [wx * view.z + view.x, wy * view.z + view.y]; }
function s2w(sx, sy) { return [(sx - view.x) / view.z, (sy - view.y) / view.z]; }

/* ============================================================================
   3. OBJECT CONSTRUCTION / GEOMETRY
   ========================================================================= */
function makeObject(type, x, y, w, h, extra) {
  const o = {
    id: uid(), type,
    x: round(x), y: round(y), w: round(w), h: round(h),
    stroke: state.style.stroke,
    fill: type === "pen" || type === "line" || type === "arrow" ? "none" : state.style.fill,
    sw: state.style.width,
    seed: (Math.random() * 1000) | 0,
  };
  if (extra) Object.assign(o, extra);
  return o;
}

function normRect(x0, y0, x1, y1) {
  return { x: Math.min(x0, x1), y: Math.min(y0, y1), w: Math.abs(x1 - x0), h: Math.abs(y1 - y0) };
}

// bounds of an object in world coords
function objBounds(o) {
  if (o.type === "pen" || o.type === "line" || o.type === "arrow") {
    if (o.type === "pen" && o.pts) {
      let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
      for (const p of o.pts) {
        if (p[0] < minx) minx = p[0]; if (p[0] > maxx) maxx = p[0];
        if (p[1] < miny) miny = p[1]; if (p[1] > maxy) maxy = p[1];
      }
      return { x: minx, y: miny, w: maxx - minx, h: maxy - miny };
    }
    // line / arrow: stored as x,y and x2,y2 (absolute endpoints)
    return normRect(o.x, o.y, o.x2, o.y2);
  }
  return { x: o.x, y: o.y, w: o.w, h: o.h };
}

function unionBounds(objs) {
  if (!objs.length) return null;
  let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
  for (const o of objs) {
    const b = objBounds(o);
    minx = Math.min(minx, b.x); miny = Math.min(miny, b.y);
    maxx = Math.max(maxx, b.x + b.w); maxy = Math.max(maxy, b.y + b.h);
  }
  // pad by stroke width so selection box encloses the ink
  const pad = 0;
  return { x: minx - pad, y: miny - pad, w: maxx - minx + pad * 2, h: maxy - miny + pad * 2 };
}

function getById(id) { return scene.objects.find(o => o.id === id); }
function selectedObjects() { return scene.objects.filter(o => state.selection.has(o.id)); }

/* ============================================================================
   4. HIT TESTING  (all in world coordinates)
   ========================================================================= */
function pointInRect(px, py, b, tol) {
  tol = tol || 0;
  return px >= b.x - tol && px <= b.x + b.w + tol && py >= b.y - tol && py <= b.y + b.h + tol;
}
function pointInEllipse(px, py, b, tol) {
  tol = tol || 0;
  const cx = b.x + b.w / 2, cy = b.y + b.h / 2;
  const rx = b.w / 2 + tol, ry = b.h / 2 + tol;
  if (rx <= 0 || ry <= 0) return false;
  return ((px - cx) / rx) ** 2 + ((py - cy) / ry) ** 2 <= 1;
}
function distToSegment(px, py, x1, y1, x2, y2) {
  const dx = x2 - x1, dy = y2 - y1;
  const L2 = dx * dx + dy * dy;
  if (L2 === 0) return dist(px, py, x1, y1);
  let t = ((px - x1) * dx + (py - y1) * dy) / L2;
  t = clamp(t, 0, 1);
  return dist(px, py, x1 + t * dx, y1 + t * dy);
}
function nearPolyline(px, py, pts, tol) {
  for (let i = 1; i < pts.length; i++) {
    if (distToSegment(px, py, pts[i - 1][0], pts[i - 1][1], pts[i][0], pts[i][1]) <= tol) return true;
  }
  if (pts.length === 1) return dist(px, py, pts[0][0], pts[0][1]) <= tol;
  return false;
}
// convex-ish outline test for rect/ellipse stroke borders
function hitObject(o, px, py, tol) {
  tol = tol || 0;
  const b = objBounds(o);
  switch (o.type) {
    case "rect":
      // a rectangle's hit area *is* its interior — corners included
      return pointInRect(px, py, b, tol);
    case "ellipse":
      // proper ellipse test: the bounding-box corner region is NOT a hit
      return pointInEllipse(px, py, b, tol);
    case "line":
    case "arrow":
      return distToSegment(px, py, o.x, o.y, o.x2, o.y2) <= tol + o.sw / 2;
    case "pen":
      return nearPolyline(px, py, o.pts, tol + o.sw / 2);
    case "text": {
      const pad = 4;
      const bb = { x: b.x - pad, y: b.y - pad, w: b.w + pad * 2, h: b.h + pad * 2 };
      if (pointInRect(px, py, bb, tol)) return true;
      if (o.fill && o.fill !== "none") return false;
      return false;
    }
  }
  return false;
}
// topmost object under a world point
function objectAt(wx, wy) {
  const tol = CFG.HIT_TOLERANCE / view.z;   // constant screen tolerance
  for (let i = scene.objects.length - 1; i >= 0; i--) {
    if (hitObject(scene.objects[i], wx, wy, tol)) return scene.objects[i];
  }
  return null;
}
function rectsIntersect(a, b) {
  return !(a.x + a.w < b.x || b.x + b.w < a.x || a.y + a.h < b.y || b.y + b.h < a.y);
}

/* ============================================================================
   5. RENDERING
   ========================================================================= */
const canvas = document.getElementById("board");
const ctx = canvas.getContext("2d");
let VIEW_W = 0, VIEW_H = 0, DPR = 1;

function resize() {
  DPR = Math.min(window.devicePixelRatio || 1, CFG.DPR_CAP);
  VIEW_W = window.innerWidth;
  VIEW_H = window.innerHeight;
  canvas.width = Math.round(VIEW_W * DPR);
  canvas.height = Math.round(VIEW_H * DPR);
  canvas.style.width = VIEW_W + "px";
  canvas.style.height = VIEW_H + "px";
  requestRender();
}

let needsRender = true;
function requestRender() { needsRender = true; }

/* ---- grid ---------------------------------------------------------------- */
function paintGrid(c) {
  const z = view.z;
  let step = CFG.GRID_BASE;
  while (step * z < 16) step *= 2;
  while (step * z > 90) step /= 2;
  const sx = step * z;
  let ox = view.x % sx; if (ox > 0) ox -= sx;
  let oy = view.y % sx; if (oy > 0) oy -= sx;

  // dots
  const r = z > 2.2 ? 1.35 : 1.1;
  c.fillStyle = "rgba(120,128,145,.38)";
  if (sx < 14) {
    // too dense — draw faint lines instead
    c.strokeStyle = "rgba(120,128,145,.13)";
    c.lineWidth = 1;
    c.beginPath();
    for (let x = ox; x < VIEW_W; x += sx) { c.moveTo(Math.round(x) + .5, 0); c.lineTo(Math.round(x) + .5, VIEW_H); }
    for (let y = oy; y < VIEW_H; y += sx) { c.moveTo(0, Math.round(y) + .5); c.lineTo(VIEW_W, Math.round(y) + .5); }
    c.stroke();
    return;
  }
  c.beginPath();
  for (let x = ox; x < VIEW_W + sx; x += sx) {
    for (let y = oy; y < VIEW_H + sx; y += sx) {
      c.moveTo(x + r, y);
      c.arc(x, y, r, 0, Math.PI * 2);
    }
  }
  c.fill();
}

/* ---- shape drawing (screen space after transform applied externally) ----- */
// We render in *screen space* with a manual transform to keep line widths and
// handle crispness under our control: every world coordinate goes through w2s.

function pathRoundRect(c, x, y, w, h, r) {
  r = Math.min(r, Math.abs(w) / 2, Math.abs(h) / 2);
  c.beginPath();
  c.moveTo(x + r, y);
  c.arcTo(x + w, y, x + w, y + h, r);
  c.arcTo(x + w, y + h, x, y + h, r);
  c.arcTo(x, y + h, x, y, r);
  c.arcTo(x, y, x + w, y, r);
  c.closePath();
}

// Hand-drawn wobble: seeded pseudo-random offsets baked per object so the line
// is stable across frames (Excalidraw-ish), scaled down for small shapes.
function seeded(seed) {
  let s = seed % 2147483647; if (s <= 0) s += 2147483646;
  return () => (s = (s * 16807) % 2147483647) / 2147483647;
}
function wobbleRect(c, x, y, w, h, seed, amp) {
  const rnd = seeded(seed);
  const j = () => (rnd() - 0.5) * amp;
  const pts = [[x, y], [x + w, y], [x + w, y + h], [x, y + h]];
  c.beginPath();
  for (let k = 0; k < 4; k++) {
    const a = pts[k], b = pts[(k + 1) % 4];
    const steps = Math.max(2, Math.round(dist(a[0], a[1], b[0], b[1]) / 26));
    for (let i = 0; i <= steps; i++) {
      const t = i / steps;
      const px = lerp(a[0], b[0], t) + j();
      const py = lerp(a[1], b[1], t) + j();
      if (k === 0 && i === 0) c.moveTo(px, py); else c.lineTo(px, py);
    }
  }
  c.closePath();
}
function wobbleEllipse(c, cx, cy, rx, ry, seed, amp) {
  const rnd = seeded(seed);
  const j = () => (rnd() - 0.5) * amp;
  const N = 34;
  c.beginPath();
  for (let i = 0; i <= N; i++) {
    const t = (i / N) * Math.PI * 2;
    const px = cx + Math.cos(t) * (rx + j());
    const py = cy + Math.sin(t) * (ry + j());
    if (i === 0) c.moveTo(px, py); else c.lineTo(px, py);
  }
  c.closePath();
}

function applyInk(c, o) {
  c.strokeStyle = o.stroke;
  c.lineWidth = Math.max(0.6, o.sw * view.z);
  c.lineJoin = "round";
  c.lineCap = "round";
}

function drawArrowHead(c, x1, y1, x2, y2, w, color) {
  const a = Math.atan2(y2 - y1, x2 - x1);
  const len = clamp(w * 4.5, 8, 22);
  const spread = 0.42;
  c.fillStyle = color;
  c.beginPath();
  c.moveTo(x2, y2);
  c.lineTo(x2 - Math.cos(a - spread) * len, y2 - Math.sin(a - spread) * len);
  c.lineTo(x2 - Math.cos(a + spread) * len, y2 - Math.sin(a + spread) * len);
  c.closePath();
  c.fill();
}

function drawObject(c, o, scaleHint) {
  const b = objBounds(o);
  const [sx, sy] = w2s(b.x, b.y);
  const sw = b.w * view.z, sh = b.h * view.z;
  c.save();
  const amp = clamp(1.6 * Math.min(view.z, 1.6), 0.5, 3);

  switch (o.type) {
    case "rect": {
      const rad = Math.min(6, sw * .06, sh * .06);
      if (o.fill && o.fill !== "none") {
        c.fillStyle = o.fill;
        if (o.stroke === "transparent") pathRoundRect(c, sx, sy, sw, sh, rad);
        else wobbleRect(c, sx, sy, sw, sh, o.seed, amp);
        c.fill();
      }
      if (o.stroke !== "transparent") {
        applyInk(c, o);
        if (o.fill && o.fill !== "none" && o.stroke === "transparent") { /* skip */ }
        wobbleRect(c, sx, sy, sw, sh, o.seed, amp);
        c.stroke();
      }
      break;
    }
    case "ellipse": {
      const cx = sx + sw / 2, cy = sy + sh / 2, rx = sw / 2, ry = sh / 2;
      if (o.fill && o.fill !== "none") {
        c.fillStyle = o.fill;
        wobbleEllipse(c, cx, cy, rx, ry, o.seed, amp);
        c.fill();
      }
      if (o.stroke !== "transparent") {
        applyInk(c, o);
        wobbleEllipse(c, cx, cy, rx, ry, o.seed + 7, amp);
        c.stroke();
      }
      break;
    }
    case "line":
    case "arrow": {
      applyInk(c, o);
      const [x1, y1] = w2s(o.x, o.y), [x2, y2] = w2s(o.x2, o.y2);
      c.beginPath();
      c.moveTo(x1, y1);
      c.lineTo(x2, y2);
      c.stroke();
      if (o.type === "arrow") drawArrowHead(c, x1, y1, x2, y2, Math.max(1.4, o.sw * view.z), o.stroke);
      break;
    }
    case "pen": {
      applyInk(c, o);
      drawPenPath(c, o.pts.map(p => w2s(p[0], p[1])), o.seed, o.smooth !== false);
      break;
    }
    case "text": {
      const fs = (o.size || CFG.TEXT_SIZE) * view.z;
      c.fillStyle = o.stroke;
      c.textBaseline = "top";
      c.font = `${o.bold ? "700 " : ""}${fs}px Inter, -apple-system, "Segoe UI", sans-serif`;
      const lh = fs * CFG.TEXT_LINE;
      const lines = (o.text || "").split("\n");
      let yy = sy + Math.max(0, (sh - lines.length * lh) / 2);
      for (const ln of lines) {
        c.fillText(ln, sx, yy);
        yy += lh;
      }
      break;
    }
  }
  c.restore();
}

// smooth polyline through screen-space points (quadratic midpoint method)
function drawPenPath(c, pts, seed, smooth) {
  if (!pts.length) return;
  c.beginPath();
  if (pts.length < 3 || !smooth) {
    c.moveTo(pts[0][0], pts[0][1]);
    for (let i = 1; i < pts.length; i++) c.lineTo(pts[i][0], pts[i][1]);
  } else {
    c.moveTo(pts[0][0], pts[0][1]);
    for (let i = 1; i < pts.length - 1; i++) {
      const mx = (pts[i][0] + pts[i + 1][0]) / 2;
      const my = (pts[i][1] + pts[i + 1][1]) / 2;
      c.quadraticCurveTo(pts[i][0], pts[i][1], mx, my);
    }
    const n = pts.length - 1;
    c.lineTo(pts[n][0], pts[n][1]);
  }
  c.stroke();
}

/* ---- selection chrome ---------------------------------------------------- */
function selectionBounds() {
  const objs = selectedObjects();
  if (!objs.length) return null;
  return unionBounds(objs);
}
function handlePositions(b) {
  return {
    nw: [b.x, b.y], ne: [b.x + b.w, b.y],
    se: [b.x + b.w, b.y + b.h], sw: [b.x, b.y + b.h],
  };
}
function drawSelectionOverlay(c) {
  const objs = selectedObjects();

  if (objs.length) {
    const b = selectionBounds();
    const [sx, sy] = w2s(b.x, b.y);
    const sw = b.w * view.z, sh = b.h * view.z;
    c.save();
    c.strokeStyle = "#5b5bd6";
    c.lineWidth = 1.5;
    c.setLineDash([]);
    c.strokeRect(round(sx) + .5, round(sy) + .5, round(sw), round(sh));
    // handles
    const h = CFG.HANDLE_SIZE;
    const hs = handlePositions(b);
    for (const k in hs) {
      const [hx, hy] = w2s(hs[k][0], hs[k][1]);
      c.fillStyle = "#fff";
      c.strokeStyle = "#5b5bd6";
      c.lineWidth = 1.6;
      c.beginPath();
      c.rect(round(hx - h / 2) + .5, round(hy - h / 2) + .5, h, h);
      c.fill(); c.stroke();
    }
    // individual outline for multi-select
    if (objs.length > 1) {
      c.strokeStyle = "rgba(91,91,214,.35)";
      c.lineWidth = 1;
      for (const o of objs) {
        const ob = objBounds(o);
        const [ox, oy] = w2s(ob.x, ob.y);
        c.strokeRect(round(ox) + .5, round(oy) + .5, round(ob.w * view.z), round(ob.h * view.z));
      }
    }
    c.restore();
  }

  // hover highlight
  if (state.hoverId && !state.selection.has(state.hoverId) && state.tool === "select") {
    const o = getById(state.hoverId);
    if (o) {
      const ob = objBounds(o);
      const [ox, oy] = w2s(ob.x, ob.y);
      c.save();
      c.strokeStyle = "rgba(91,91,214,.45)";
      c.lineWidth = 1;
      c.setLineDash([4, 3]);
      c.strokeRect(round(ox) + .5, round(oy) + .5, round(ob.w * view.z) + 4, round(ob.h * view.z) + 4);
      c.restore();
    }
  }

  // marquee
  if (state.marquee) {
    const m = state.marquee;
    const [mx, my] = w2s(m.x, m.y);
    const mw = m.w * view.z, mh = m.h * view.z;
    c.save();
    c.fillStyle = "rgba(91,91,214,.10)";
    c.strokeStyle = "#5b5bd6";
    c.lineWidth = 1;
    c.fillRect(mx, my, mw, mh);
    c.strokeRect(round(mx) + .5, round(my) + .5, round(mw), round(mh));
    c.restore();
  }

  // eraser head
  if (state.erasing) {
    const p = state.eraserPos;
    const r = CFG.ERASER_R * view.z;
    c.save();
    c.strokeStyle = "rgba(224,49,49,.85)";
    c.fillStyle = "rgba(224,49,49,.10)";
    c.lineWidth = 1.5;
    c.beginPath(); c.arc(p[0], p[1], r, 0, Math.PI * 2); c.fill(); c.stroke();
    c.restore();
  }
}

/* ---- main render loop ---------------------------------------------------- */
function render() {
  if (!needsRender) return;
  needsRender = false;

  ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
  ctx.clearRect(0, 0, VIEW_W, VIEW_H);
  ctx.fillStyle = "#fbfbfa";
  ctx.fillRect(0, 0, VIEW_W, VIEW_H);

  paintGrid(ctx);

  // cull offscreen objects
  for (const o of scene.objects) {
    const b = objBounds(o);
    const [sx, sy] = w2s(b.x, b.y);
    const w = b.w * view.z, h = b.h * view.z;
    const pad = 40;
    if (sx + w < -pad || sy + h < -pad || sx > VIEW_W + pad || sy > VIEW_H + pad) continue;
    drawObject(ctx, o);
  }

  // in-progress draft
  if (state.draft) drawObject(ctx, state.draft);

  drawSelectionOverlay(ctx);
  updateZoomLabel();
  updateHint();
}

function frame() {
  if (needsRender) render();
  requestAnimationFrame(frame);
}


/* ============================================================================
   6. HISTORY (undo / redo)
   ---------------------------------------------------------------------------
   Snapshot based: we push a JSON snapshot of the object array *before* a
   mutation. A multi-object drag pushes exactly one snapshot (at drag start).
   ========================================================================= */
const history = { past: [], future: [], limit: 120 };

function snapshot() { return JSON.stringify(scene.objects); }
function restoreSnap(s) {
  scene.objects = JSON.parse(s);
  // drop ids that no longer exist
  const ids = new Set(scene.objects.map(o => o.id));
  for (const id of Array.from(state.selection)) if (!ids.has(id)) state.selection.delete(id);
  requestRender();
}
function pushUndo() {
  history.past.push(snapshot());
  if (history.past.length > history.limit) history.past.shift();
  history.future.length = 0;
  updateUndoButtons();
}
function undo() {
  if (!history.past.length) return;
  history.future.push(snapshot());
  restoreSnap(history.past.pop());
  cancelEdit();
  scheduleSave();
  updateUndoButtons();
  toast("Undo");
}
function redo() {
  if (!history.future.length) return;
  history.past.push(snapshot());
  restoreSnap(history.future.pop());
  cancelEdit();
  scheduleSave();
  updateUndoButtons();
  toast("Redo");
}
function updateUndoButtons() {
  const u = document.getElementById("undoBtn"), r = document.getElementById("redoBtn");
  if (u) u.disabled = history.past.length === 0;
  if (r) r.disabled = history.future.length === 0;
}

/* ============================================================================
   7. PERSISTENCE
   ========================================================================= */
let saveTimer = null;
function scheduleSave() {
  clearTimeout(saveTimer);
  saveTimer = setTimeout(saveNow, 220);
}
function saveNow() {
  try {
    localStorage.setItem(CFG.STORAGE_KEY, JSON.stringify({
      v: 1, objects: scene.objects, view: { x: view.x, y: view.y, z: view.z },
    }));
  } catch (e) { /* storage full / disabled — prototype keeps running */ }
}
function loadBoard() {
  try {
    const raw = localStorage.getItem(CFG.STORAGE_KEY);
    if (!raw) return false;
    const data = JSON.parse(raw);
    if (!data || !Array.isArray(data.objects)) return false;
    scene.objects = data.objects;
    if (data.view && isFinite(data.view.z)) {
      view.x = data.view.x || 0; view.y = data.view.y || 0;
      view.z = clamp(data.view.z, CFG.ZOOM_MIN, CFG.ZOOM_MAX);
    }
    return true;
  } catch (e) { return false; }
}

/* ============================================================================
   8. TEXT MEASUREMENT
   ========================================================================= */
function textFont(size, bold) {
  return `${bold ? "700 " : ""}${size}px Inter, -apple-system, "Segoe UI", sans-serif`;
}
function measureTextObj(o) {
  const lines = (o.text || " ").split("\n");
  let w = 0;
  ctx.setTransform(1, 0, 0, 1, 0, 0);
  ctx.font = textFont(o.size || CFG.TEXT_SIZE, o.bold);
  for (const l of lines) w = Math.max(w, ctx.measureText(l || " ").width);
  const fs = o.size || CFG.TEXT_SIZE;
  return {
    w: Math.max(CFG.TEXT_MIN_W, Math.ceil(w)),
    h: Math.max(fs, lines.length * fs * CFG.TEXT_LINE),
  };
}
function syncTextBounds(o) {
  const m = measureTextObj(o);
  o.w = round(m.w); o.h = round(m.h);
}

/* ============================================================================
   9. TEXT EDITING (overlay textarea)
   ========================================================================= */
const editor = document.getElementById("textEditor");
const caretGuide = document.getElementById("caretGuide");

function beginEdit(o, isNew) {
  state.editing = { id: o.id, isNew: !!isNew, original: o.text || "" };
  editor.value = o.text || "";
  editor.style.display = "block";
  layoutEditor(o);
  caretGuide.style.display = "block";
  editor.focus();
  // place caret at end
  editor.setSelectionRange(editor.value.length, editor.value.length);
  requestRender();
}
function layoutEditor(o) {
  const fs = (o.size || CFG.TEXT_SIZE) * view.z;
  const [sx, sy] = w2s(o.x, o.y);
  editor.style.left = sx + "px";
  editor.style.top = sy + "px";
  editor.style.font = textFont(fs, o.bold);
  editor.style.lineHeight = CFG.TEXT_LINE;
  editor.style.width = Math.max(CFG.TEXT_MIN_W * view.z, editor.scrollWidth + 4) + "px";
  editor.style.height = Math.max(fs, editor.scrollHeight) + "px";
  const pad = 5;
  caretGuide.style.left = (sx - pad) + "px";
  caretGuide.style.top = (sy - pad * .6) + "px";
  caretGuide.style.width = (parseFloat(editor.style.width) + pad) + "px";
  caretGuide.style.height = (parseFloat(editor.style.height) + pad) + "px";
  caretGuide.style.borderRadius = (6 * view.z) + "px";
}
function commitEdit() {
  if (!state.editing) return;
  const { id, isNew, original } = state.editing;
  const o = getById(id);
  const val = editor.value;
  state.editing = null;
  editor.style.display = "none";
  caretGuide.style.display = "none";
  if (!o) { requestRender(); return; }
  if (isNew) {
    // the object was already pushed to the scene with an undo snapshot
    if (val.trim() === "") {
      scene.objects = scene.objects.filter(x => x.id !== o.id);
      history.past.pop();               // nothing actually happened
      updateUndoButtons();
    } else {
      o.text = val;
      syncTextBounds(o);
    }
  } else {
    if (val === original) { /* no-op — nothing to record */ }
    else {
      // one undoable step for a text edit: snapshot the pre-edit text
      pushUndo();
      o.text = val;
      syncTextBounds(o);
    }
  }
  scheduleSave();
  requestRender();
}
function cancelEdit() {
  if (!state.editing) return;
  const { id, isNew, original } = state.editing;
  const idx = scene.objects.findIndex(o => o.id === id);
  state.editing = null;
  editor.style.display = "none";
  caretGuide.style.display = "none";
  if (isNew && idx >= 0) { scene.objects.splice(idx, 1); }
  else { const o = getById(id); if (o) { o.text = original; syncTextBounds(o); } }
  requestRender();
}
editor.addEventListener("input", () => {
  if (!state.editing) return;
  const o = getById(state.editing.id);
  if (o) { o.text = editor.value; syncTextBounds(o); layoutEditor(o); requestRender(); }
});
editor.addEventListener("keydown", (e) => {
  e.stopPropagation();
  if (e.key === "Escape") { e.preventDefault(); cancelEdit(); scheduleSave(); }
  else if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); commitEdit(); }
});
editor.addEventListener("blur", () => { commitEdit(); });
editor.addEventListener("pointerdown", (e) => e.stopPropagation());

/* ============================================================================
   10. POINTER INTERACTION
   ========================================================================= */
let pointer = { x: 0, y: 0, down: false, id: null };
let panState = null;

function localPoint(e) {
  const r = canvas.getBoundingClientRect();
  return [e.clientX - r.left, e.clientY - r.top];
}

function setCursor() {
  canvas.classList.toggle("tool-select", state.tool === "select");
  canvas.classList.toggle("pannable", state.spaceDown && state.tool !== "pan");
  if (state.spaceDown || state.tool === "pan") canvas.style.cursor = "grab";
  else if (state.tool === "text") canvas.style.cursor = "text";
  else if (state.tool === "select") canvas.style.cursor = "default";
  else canvas.style.cursor = "crosshair";
}

canvas.addEventListener("contextmenu", e => e.preventDefault());

canvas.addEventListener("pointerdown", (e) => {
  if (state.editing) commitEdit();
  const [sx, sy] = localPoint(e);
  pointer.x = sx; pointer.y = sy; pointer.down = true; pointer.id = e.pointerId;
  canvas.setPointerCapture && (function(){ try { canvas.setPointerCapture(e.pointerId); } catch (_) {} })();
  const [wx, wy] = s2w(sx, sy);

  const wantPan = e.button === 1 || state.spaceDown || state.tool === "pan";
  if (wantPan) {
    e.preventDefault();
    panState = { sx, sy, vx: view.x, vy: view.y };
    canvas.classList.add("panning");
    canvas.style.cursor = "grabbing";
    return;
  }
  if (e.button !== 0) return;

  switch (state.tool) {
    case "select":   onSelectDown(wx, wy, sx, sy, e); break;
    case "pen":      onPenDown(wx, wy); break;
    case "rect":
    case "ellipse":
    case "line":
    case "arrow":    onShapeDown(wx, wy); break;
    case "text":     onTextDown(wx, wy); break;
    case "eraser":   onEraseDown(wx, wy); break;
  }
  requestRender();
});

canvas.addEventListener("pointermove", (e) => {
  const [sx, sy] = localPoint(e);
  pointer.x = sx; pointer.y = sy;
  const [wx, wy] = s2w(sx, sy);

  if (panState) {
    view.x = panState.vx + (sx - panState.sx);
    view.y = panState.vy + (sy - panState.sy);
    requestRender(); return;
  }

  if (!pointer.down) {
    // hover feedback for the select tool
    if (state.tool === "select") {
      const o = handleAtScreen(sx, sy) ? null : objectAt(wx, wy);
      const id = o ? o.id : null;
      if (id !== state.hoverId) { state.hoverId = id; requestRender(); }
      const hh = handleAtScreen(sx, sy);
      if (!state.spaceDown) canvas.style.cursor = hh ? cursorForHandle(hh) : (o ? "move" : "default");
    } else if (state.tool === "eraser") {
      state.eraserPos = [sx, sy]; state.erasing = true; requestRender();
      if (state.erasing && state.hoverId) state.hoverId = null;
    }
    return;
  }

  switch (state.drag && state.drag.mode) {
    case "move":   doMove(wx, wy, e); break;
    case "resize": doResize(wx, wy, e); break;
    case "marquee": doMarquee(wx, wy, e); break;
    case "pen":    doPenMove(wx, wy); break;
    case "shape":  doShapeMove(wx, wy, e); break;
    case "erase":  doErase(wx, wy); break;
  }
  requestRender();
});

function endPointer(e) {
  if (!pointer.down && !panState) return;
  pointer.down = false;
  try { canvas.releasePointerCapture(e.pointerId); } catch (_) {}
  if (panState) { panState = null; canvas.classList.remove("panning"); setCursor(); scheduleSave(); return; }

  const d = state.drag;
  state.drag = null;
  if (!d) return;

  if (d.mode === "shape") commitShape();
  else if (d.mode === "pen") commitPen();
  else if (d.mode === "move" || d.mode === "resize") { if (d.changed) { scheduleSave(); } else { history.past.pop(); updateUndoButtons(); } }
  else if (d.mode === "marquee") { state.marquee = null; }
  else if (d.mode === "erase") { state.erasing = false; scheduleSave(); }
  requestRender();
}
canvas.addEventListener("pointerup", endPointer);
canvas.addEventListener("pointercancel", endPointer);
canvas.addEventListener("pointerleave", () => {
  if (pointer.down) return;
  state.erasing = false;
  state.hoverId = null;
  requestRender();
});
canvas.addEventListener("dblclick", (e) => {
  const [sx, sy] = localPoint(e);
  const [wx, wy] = s2w(sx, sy);
  const o = objectAt(wx, wy);
  if (o && o.type === "text") { selectOnly(o.id); beginEdit(o, false); }
  else if (o && state.tool === "select") { /* nothing */ }
});

/* ---- SELECT tool --------------------------------------------------------- */
function handleAtScreen(sx, sy) {
  const b = selectionBounds();
  if (!b) return null;
  const hs = handlePositions(b);
  for (const k in hs) {
    const [hx, hy] = w2s(hs[k][0], hs[k][1]);
    if (Math.abs(sx - hx) <= CFG.HANDLE_HIT && Math.abs(sy - hy) <= CFG.HANDLE_HIT) return k;
  }
  return null;
}
function cursorForHandle(k) {
  return { nw: "nwse-resize", se: "nwse-resize", ne: "nesw-resize", sw: "nesw-resize" }[k] || "pointer";
}
function selectOnly(id) { state.selection.clear(); if (id) state.selection.add(id); }
function snapshotGeom(o) {
  return { id: o.id, x: o.x, y: o.y, w: o.w, h: o.h, size: o.size,
           x2: o.x2, y2: o.y2, pts: o.pts ? o.pts.map(p => [p[0], p[1]]) : null };
}

function onSelectDown(wx, wy, sx, sy, e) {
  // 1) resize handle?
  const h = handleAtScreen(sx, sy);
  if (h) {
    const objs = selectedObjects();
    pushUndo();
    state.drag = {
      mode: "resize", corner: h, b0: unionBounds(objs),
      geoms: objs.map(snapshotGeom), changed: false,
      bStart: unionBounds(objs).w * unionBounds(objs).h,
    };
    return;
  }
  // 2) object hit?
  const o = objectAt(wx, wy);
  if (o) {
    if (e.shiftKey) {
      if (state.selection.has(o.id)) state.selection.delete(o.id); else state.selection.add(o.id);
      requestRender();
      return;
    }
    if (!state.selection.has(o.id)) selectOnly(o.id);
    const objs = selectedObjects();
    pushUndo();
    state.drag = {
      mode: "move", startW: [wx, wy], geoms: objs.map(snapshotGeom),
      changed: false, axis: null,
    };
    requestRender();
    return;
  }
  // 3) empty space -> marquee
  if (!e.shiftKey) state.selection.clear();
  state.drag = { mode: "marquee", startW: [wx, wy], base: new Set(state.selection), changed: false };
}

function doMove(wx, wy, e) {
  const d = state.drag;
  let dx = wx - d.startW[0], dy = wy - d.startW[1];
  if (e && e.shiftKey) { if (Math.abs(dx) > Math.abs(dy)) dy = 0; else dx = 0; }
  if (dx === 0 && dy === 0 && !d.changed) return;
  d.changed = true;
  for (const g of d.geoms) {
    const o = getById(g.id); if (!o) continue;
    if (o.type === "pen") { o.pts = g.pts.map(p => [round(p[0] + dx), round(p[1] + dy)]); }
    else if (o.type === "line" || o.type === "arrow") {
      o.x = round(g.x + dx); o.y = round(g.y + dy); o.x2 = round(g.x2 + dx); o.y2 = round(g.y2 + dy);
    } else { o.x = round(g.x + dx); o.y = round(g.y + dy); }
  }
}

function doResize(wx, wy, e) {
  const d = state.drag;
  const b0 = d.b0;
  const c = d.corner;
  const ax = c.includes("w") ? b0.x + b0.w : b0.x;   // anchor on opposite x side
  const ay = c.includes("n") ? b0.y + b0.h : b0.y;
  let nW = Math.abs(wx - ax), nH = Math.abs(wy - ay);
  nW = Math.max(4, nW); nH = Math.max(4, nH);
  if (e && e.shiftKey && b0.w > 0.5 && b0.h > 0.5) {
    const s = Math.max(nW / b0.w, nH / b0.h);
    nW = b0.w * s; nH = b0.h * s;
  }
  const ox = c.includes("w") ? ax - nW : ax;
  const oy = c.includes("n") ? ay - nH : ay;
  const sx = b0.w > 0.5 ? nW / b0.w : 1;
  const sy = b0.h > 0.5 ? nH / b0.h : 1;
  d.changed = true;
  for (const g of d.geoms) {
    const o = getById(g.id); if (!o) continue;
    if (o.type === "pen") {
      o.pts = g.pts.map(p => [round(ox + (p[0] - b0.x) * sx), round(oy + (p[1] - b0.y) * sy)]);
    } else if (o.type === "line" || o.type === "arrow") {
      o.x = round(ox + (g.x - b0.x) * sx); o.y = round(oy + (g.y - b0.y) * sy);
      o.x2 = round(ox + (g.x2 - b0.x) * sx); o.y2 = round(oy + (g.y2 - b0.y) * sy);
    } else if (o.type === "text") {
      const s = clamp((sx + sy) / 2, 0.15, 6);
      o.size = round(clamp((g.size || CFG.TEXT_SIZE) * s, 6, 300), 1);
      syncTextBounds(o);
      o.x = round(ox + (g.x - b0.x) * sx); o.y = round(oy + (g.y - b0.y) * sy);
    } else {
      o.x = round(ox + (g.x - b0.x) * sx);
      o.y = round(oy + (g.y - b0.y) * sy);
      o.w = round(Math.max(2, g.w * sx));
      o.h = round(Math.max(2, g.h * sy));
    }
  }
}

function doMarquee(wx, wy, e) {
  const d = state.drag;
  const r = normRect(d.startW[0], d.startW[1], wx, wy);
  state.marquee = r;
  const sel = new Set(d.base);
  for (const o of scene.objects) {
    if (rectsIntersect(r, objBounds(o))) sel.add(o.id);
  }
  state.selection = sel;
}

/* ---- PEN ----------------------------------------------------------------- */
function onPenDown(wx, wy) {
  const o = makeObject("pen", wx, wy, 0, 0, { pts: [[round(wx), round(wy)]], sw: state.style.width, stroke: state.style.stroke });
  state.draft = o;
  state.drag = { mode: "pen", changed: false };
}
function doPenMove(wx, wy) {
  const o = state.draft; if (!o) return;
  const last = o.pts[o.pts.length - 1];
  const minD = Math.max(0.5, 1.6 / view.z);
  if (dist(wx, wy, last[0], last[1]) < minD) return;
  o.pts.push([round(wx), round(wy)]);
  state.drag.changed = true;
}
function commitPen() {
  const o = state.draft; state.draft = null;
  if (!o) return;
  if (!o.pts || o.pts.length < 2) { return; }
  pushUndo();
  scene.objects.push(o);
  // derive bounds so persistence + selection box are consistent
  const b = objBounds(o);
  o.x = round(b.x); o.y = round(b.y); o.w = round(b.w); o.h = round(b.h);
  scheduleSave();
}

/* ---- SHAPES -------------------------------------------------------------- */
function onShapeDown(wx, wy) {
  const t = state.tool;
  const o = makeObject(t, wx, wy, 0, 0, t === "line" || t === "arrow" ? { x2: round(wx), y2: round(wy) } : null);
  state.draft = o;
  state.drag = { mode: "shape", startW: [wx, wy], changed: false };
}
function doShapeMove(wx, wy, e) {
  const o = state.draft; if (!o) return;
  const [x0, y0] = state.drag.startW;
  let x1 = wx, y1 = wy;
  if (e && e.shiftKey) {
    if (o.type === "line" || o.type === "arrow") {
      // snap to 15° increments
      const a = Math.atan2(y1 - y0, x1 - x0);
      const step = Math.PI / 12;
      const a2 = Math.round(a / step) * step;
      const L = dist(x0, y0, x1, y1);
      x1 = x0 + Math.cos(a2) * L; y1 = y0 + Math.sin(a2) * L;
    } else {
      const s = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0));
      x1 = x0 + Math.sign(x1 - x0 || 1) * s;
      y1 = y0 + Math.sign(y1 - y0 || 1) * s;
    }
  }
  if (o.type === "line" || o.type === "arrow") {
    o.x = round(x0); o.y = round(y0); o.x2 = round(x1); o.y2 = round(y1);
  } else {
    const r = normRect(x0, y0, x1, y1);
    o.x = round(r.x); o.y = round(r.y); o.w = round(r.w); o.h = round(r.h);
  }
  state.drag.changed = true;
}
function commitShape() {
  const o = state.draft; state.draft = null;
  if (!o) return;
  const big = (o.type === "line" || o.type === "arrow")
    ? dist(o.x, o.y, o.x2, o.y2) >= CFG.MIN_SHAPE
    : (o.w >= CFG.MIN_SHAPE || o.h >= CFG.MIN_SHAPE);
  if (!big) return;
  pushUndo();
  scene.objects.push(o);
  selectOnly(o.id);
  scheduleSave();
}

/* ---- TEXT ---------------------------------------------------------------- */
function onTextDown(wx, wy) {
  const o = makeObject("text", wx, wy - CFG.TEXT_SIZE / 2, 0, 0, {
    text: "", size: CFG.TEXT_SIZE, stroke: state.style.stroke,
  });
  o.w = CFG.TEXT_MIN_W; o.h = CFG.TEXT_SIZE * CFG.TEXT_LINE;
  pushUndo();
  scene.objects.push(o);
  selectOnly(o.id);
  beginEdit(o, true);
}

/* ---- ERASER -------------------------------------------------------------- */
function onEraseDown(wx, wy) {
  pushUndo();
  state.drag = { mode: "erase", changed: false };
  state.erasing = true;
  state.eraserPos = w2s(wx, wy);
  doErase(wx, wy);
}
function doErase(wx, wy) {
  const r = CFG.ERASER_R / view.z;
  const hit = scene.objects.filter(o => {
    const b = objBounds(o);
    // cheap AABB reject then precise-ish test
    if (wx < b.x - r || wx > b.x + b.w + r || wy < b.y - r || wy > b.y + b.h + r) return false;
    return hitObject(o, wx, wy, r);
  });
  if (hit.length) {
    scene.objects = scene.objects.filter(o => !hit.includes(o));
    state.drag.changed = true;
    for (const o of hit) state.selection.delete(o.id);
    requestRender();
  }
}

/* ---- ZOOM ---------------------------------------------------------------- */
function zoomAt(screenX, screenY, factor) {
  const newZ = clamp(view.z * factor, CFG.ZOOM_MIN, CFG.ZOOM_MAX);
  if (Math.abs(newZ - view.z) < 1e-6) return;
  const [wx, wy] = s2w(screenX, screenY);
  view.z = newZ;
  view.x = screenX - wx * view.z;
  view.y = screenY - wy * view.z;
  if (state.editing) { const o = getById(state.editing.id); if (o) layoutEditor(o); }
  requestRender();
}
canvas.addEventListener("wheel", (e) => {
  e.preventDefault();
  const [sx, sy] = localPoint(e);
  let d = e.deltaY;
  if (e.deltaMode === 1) d *= 16;
  const factor = Math.pow(0.9988, d * (e.ctrlKey ? 3.2 : 1));
  zoomAt(sx, sy, factor);
  scheduleSave();
}, { passive: false });

function resetView() {
  view.x = VIEW_W / 2; view.y = VIEW_H / 2; view.z = 1;
  requestRender(); scheduleSave(); toast("View reset");
}

/* ============================================================================
   11. TOOLBAR / PROPERTIES BAR UI
   ========================================================================= */
const ICONS = {
  select: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"><path d="M5 3l6.5 16 2.2-6.3L20 10.5z"/></svg>',
  pen: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20l3.2-.7L19 7.5 16.5 5 4.7 16.8z"/><path d="M14.6 6.9l2.5 2.5"/></svg>',
  rect: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"><rect x="4" y="5.5" width="16" height="13" rx="2"/></svg>',
  ellipse: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><ellipse cx="12" cy="12" rx="8" ry="6.6"/></svg>',
  line: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M5 19L19 5"/></svg>',
  arrow: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M5 19L19 5"/><path d="M11.5 5H19v7.5"/></svg>',
  text: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M5 6h14M12 6v13M9 19h6"/></svg>',
  eraser: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"><path d="M8.5 19.5H19"/><path d="M5.6 15.4l6-6a2 2 0 0 1 2.8 0l3.2 3.2a2 2 0 0 1 0 2.8l-3.6 3.6H9.2z"/></svg>',
  undo: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M9 14L4 9l5-5"/><path d="M4 9h9a6 6 0 0 1 0 12H8"/></svg>',
  redo: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M15 14l5-5-5-5"/><path d="M20 9h-9a6 6 0 0 0 0 12h5"/></svg>',
  trash: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13"/></svg>',
  copy: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M15 5.5A2 2 0 0 0 13 4H6a2 2 0 0 0-2 2v7a2 2 0 0 0 1.5 1.94"/></svg>',
};

const TOOLS = [
  { id: "select",  key: "V", label: "Select" },
  { id: "pen",     key: "P", label: "Draw" },
  { id: "rect",    key: "R", label: "Rectangle" },
  { id: "ellipse", key: "O", label: "Ellipse" },
  { id: "arrow",   key: "L", label: "Arrow" },
  { id: "line",    key: "",  label: "Line" },
  { id: "text",    key: "T", label: "Text" },
  { id: "eraser",  key: "E", label: "Eraser" },
];

function buildToolbar() {
  const tb = document.getElementById("toolbar");
  let html = "";
  for (const t of TOOLS) {
    html += `<button class="tbtn" data-tool="${t.id}" title="${t.label}${t.key ? " (" + t.key + ")" : ""}">
      ${ICONS[t.id]}${t.key ? `<span class="kbd">${t.key}</span>` : ""}</button>`;
  }
  html += '<div class="sep"></div>';
  html += `<button class="tbtn" id="undoBtn" title="Undo (Ctrl+Z)">${ICONS.undo}</button>`;
  html += `<button class="tbtn" id="redoBtn" title="Redo (Ctrl+Shift+Z)">${ICONS.redo}</button>`;
  html += `<button class="tbtn" id="dupBtn" title="Duplicate (Ctrl+D)">${ICONS.copy}</button>`;
  html += `<button class="tbtn" id="delBtn" title="Delete (Del)">${ICONS.trash}</button>`;
  tb.innerHTML = html;

  tb.querySelectorAll("[data-tool]").forEach(b => {
    b.addEventListener("click", () => { setTool(b.dataset.tool); b.blur(); });
  });
  document.getElementById("undoBtn").addEventListener("click", undo);
  document.getElementById("redoBtn").addEventListener("click", redo);
  document.getElementById("dupBtn").addEventListener("click", duplicateSelection);
  document.getElementById("delBtn").addEventListener("click", deleteSelection);
  updateUndoButtons();
}

function setTool(t) {
  if (state.editing) commitEdit();
  if (state.tool === "eraser" && t !== "eraser") { state.erasing = false; }
  state.tool = t;
  state.hoverId = null;
  document.querySelectorAll(".tbtn[data-tool]").forEach(b => {
    b.classList.toggle("active", b.dataset.tool === t);
  });
  setCursor();
  requestRender();
}

function buildSwatches() {
  const mk = (host, colors, kind) => {
    host.innerHTML = "";
    colors.forEach(c => {
      const b = document.createElement("button");
      b.className = "sw" + (c === "none" ? " none" : "");
      b.dataset.color = c;
      b.title = c === "none" ? "No fill" : c;
      if (c !== "none") b.style.background = c;
      b.addEventListener("click", () => setStyle(kind, c));
      host.appendChild(b);
    });
  };
  mk(document.getElementById("strokeSwatches"), PALETTE.strokes, "stroke");
  mk(document.getElementById("fillSwatches"), PALETTE.fills, "fill");
  syncStyleUI();
}

function buildWidths() {
  const host = document.getElementById("widthBtns");
  host.innerHTML = "";
  [[1, 3], [2, 5], [4, 8], [6, 11]].forEach(([w, px]) => {
    const b = document.createElement("button");
    b.className = "wb"; b.dataset.w = w; b.title = w + "px stroke";
    b.innerHTML = `<i style="width:${px + 7}px;height:${px}px"></i>`;
    b.addEventListener("click", () => setStyle("width", w));
    host.appendChild(b);
  });
  syncStyleUI();
}

/* Styling applies to the current selection, and always becomes the default
   for newly created objects. One undo step per change. */
function setStyle(kind, value) {
  const objs = selectedObjects();
  if (objs.length) pushUndo();
  if (kind === "stroke") {
    state.style.stroke = value;
    objs.forEach(o => o.stroke = value);
  } else if (kind === "fill") {
    state.style.fill = value;
    objs.forEach(o => { if (o.type !== "pen") o.fill = value; });
  } else {
    state.style.width = value;
    objs.forEach(o => o.sw = value);
  }
  if (objs.length) scheduleSave();
  syncStyleUI();
  requestRender();
}

function syncStyleUI() {
  const objs = selectedObjects();
  const eff = { ...state.style };
  if (objs.length) {
    eff.stroke = objs[0].stroke;
    eff.fill = objs[0].fill || "none";
    eff.width = objs[0].sw;
  }
  document.querySelectorAll("#strokeSwatches .sw").forEach(b =>
    b.classList.toggle("sel", b.dataset.color === eff.stroke));
  document.querySelectorAll("#fillSwatches .sw").forEach(b =>
    b.classList.toggle("sel", b.dataset.color === eff.fill));
  document.querySelectorAll("#widthBtns .wb").forEach(b =>
    b.classList.toggle("sel", +b.dataset.w === eff.width));
}

/* ---- zoom + misc UI ------------------------------------------------------ */
function updateZoomLabel() {
  const el = document.getElementById("zoomVal");
  if (el) el.textContent = Math.round(view.z * 100) + "%";
}
const HINTS = {
  select: "<b>Drag</b> to move · <b>Shift-drag</b> rubber band · <b>Double-click</b> text to edit",
  pen: "<b>Drag</b> to draw",
  rect: "<b>Drag</b> to draw a rectangle · <b>Shift</b> for a square",
  ellipse: "<b>Drag</b> to draw an ellipse · <b>Shift</b> for a circle",
  arrow: "<b>Drag</b> for an arrow · <b>Shift</b> to snap the angle",
  line: "<b>Drag</b> for a line · <b>Shift</b> to snap the angle",
  text: "<b>Click</b> to place text · <b>Enter</b> or click away to commit",
  eraser: "<b>Drag</b> over objects to erase them",
  pan: "<b>Drag</b> to pan the canvas",
};
function updateHint() {
  const el = document.getElementById("hint");
  if (!el) return;
  const base = HINTS[state.tool] || "";
  const extras = " &nbsp;·&nbsp; <kbd>Space</kbd>+drag pan &nbsp;·&nbsp; <kbd>Ctrl</kbd>+scroll zoom";
  const html = (state.spaceDown ? "<b>Drag to pan the canvas</b>" : base) + extras;
  if (el.dataset.h !== html) { el.innerHTML = html; el.dataset.h = html; }
}

let toastTimer = null;
function toast(msg) {
  const el = document.getElementById("toast");
  el.textContent = msg;
  el.classList.add("on");
  clearTimeout(toastTimer);
  toastTimer = setTimeout(() => el.classList.remove("on"), 900);
}

/* ============================================================================
   12. SELECTION COMMANDS
   ========================================================================= */
function deleteSelection() {
  if (!state.selection.size) return;
  pushUndo();
  scene.objects = scene.objects.filter(o => !state.selection.has(o.id));
  state.selection.clear();
  scheduleSave(); requestRender(); syncStyleUI();
}
function duplicateSelection() {
  const objs = selectedObjects();
  if (!objs.length) return;
  pushUndo();
  const copies = objs.map(o => {
    const c = clone(o);
    c.id = uid();
    const off = 16;
    if (c.type === "pen") c.pts = c.pts.map(p => [round(p[0] + off), round(p[1] + off)]);
    else if (c.type === "line" || c.type === "arrow") { c.x += off; c.y += off; c.x2 += off; c.y2 += off; }
    else { c.x += off; c.y += off; }
    return c;
  });
  scene.objects.push(...copies);
  state.selection = new Set(copies.map(o => o.id));
  scheduleSave(); requestRender();
  toast(copies.length + " duplicated");
}
function nudgeSelection(dx, dy, big) {
  if (!state.selection.size) return;
  const now = performance.now();
  if (!state._lastNudge || now - state._lastNudge > 700) pushUndo();
  state._lastNudge = now;
  const step = big ? CFG.NUDGE_BIG : CFG.NUDGE;
  for (const o of selectedObjects()) {
    if (o.type === "pen") o.pts = o.pts.map(p => [round(p[0] + dx * step), round(p[1] + dy * step)]);
    else if (o.type === "line" || o.type === "arrow") { o.x += dx * step; o.y += dy * step; o.x2 += dx * step; o.y2 += dy * step; }
    else { o.x = round(o.x + dx * step); o.y = round(o.y + dy * step); }
  }
  scheduleSave(); requestRender();
}
function selectAll() {
  state.selection = new Set(scene.objects.map(o => o.id));
  syncStyleUI(); requestRender();
}

/* ============================================================================
   13. KEYBOARD
   ========================================================================= */
window.addEventListener("keydown", (e) => {
  const tag = (e.target && e.target.tagName) || "";
  if (tag === "TEXTAREA" || tag === "INPUT" || state.editing) return;

  const mod = e.ctrlKey || e.metaKey;
  const k = e.key;

  if (mod && (k === "z" || k === "Z")) { e.preventDefault(); e.shiftKey ? redo() : undo(); return; }
  if (mod && (k === "y" || k === "Y")) { e.preventDefault(); redo(); return; }
  if (mod && (k === "d" || k === "D")) { e.preventDefault(); duplicateSelection(); return; }
  if (mod && (k === "a" || k === "A")) { e.preventDefault(); selectAll(); return; }
  if (mod) return;

  if (k === " " && !state.spaceDown) {
    state.spaceDown = true; setCursor(); requestRender();
    e.preventDefault();
    return;
  }
  if (k === "Delete" || k === "Backspace") { e.preventDefault(); deleteSelection(); return; }
  if (k === "Escape") {
    if (state.draft) { state.draft = null; state.drag = null; }
    state.selection.clear(); syncStyleUI(); requestRender(); return;
  }
  if (k.startsWith("Arrow")) {
    e.preventDefault();
    const s = e.shiftKey;
    if (k === "ArrowLeft") nudgeSelection(-1, 0, s);
    if (k === "ArrowRight") nudgeSelection(1, 0, s);
    if (k === "ArrowUp") nudgeSelection(0, -1, s);
    if (k === "ArrowDown") nudgeSelection(0, 1, s);
    return;
  }
  if (k === "+" || k === "=") { zoomAt(VIEW_W / 2, VIEW_H / 2, CFG.ZOOM_STEP); return; }
  if (k === "-" || k === "_") { zoomAt(VIEW_W / 2, VIEW_H / 2, 1 / CFG.ZOOM_STEP); return; }
  if (k === "!" || (k === "1" && e.shiftKey)) { resetView(); return; }
  if (k === "0") { zoomAt(VIEW_W / 2, VIEW_H / 2, 1 / view.z); return; }

  const key = k.toLowerCase();
  const map = { v: "select", p: "pen", r: "rect", o: "ellipse", l: "arrow", t: "text", e: "eraser", d: "pen" };
  if (e.shiftKey && key === "l") { setTool("line"); return; }
  if (map[key]) { setTool(map[key]); return; }
});
window.addEventListener("keyup", (e) => {
  if (e.key === " ") { state.spaceDown = false; setCursor(); requestRender(); }
});
window.addEventListener("blur", () => { state.spaceDown = false; panState = null; setCursor(); });

/* ============================================================================
   14. ZOOM BAR WIRING
   ========================================================================= */
document.getElementById("zoomIn").addEventListener("click", () => zoomAt(VIEW_W / 2, VIEW_H / 2, CFG.ZOOM_STEP));
document.getElementById("zoomOut").addEventListener("click", () => zoomAt(VIEW_W / 2, VIEW_H / 2, 1 / CFG.ZOOM_STEP));
document.getElementById("zoomVal").addEventListener("click", () => { zoomAt(VIEW_W / 2, VIEW_H / 2, 1 / view.z); scheduleSave(); });
document.getElementById("resetView").addEventListener("click", resetView);

/* ============================================================================
   15. INIT
   ========================================================================= */
function seedDemo() {
  // A tiny welcome sketch so a first-time visitor sees something.
  const a = makeObject("rect", -300, -170, 250, 150, { stroke: PALETTE.strokes[2], fill: "#a5c9ff", sw: 2 });
  const b = makeObject("ellipse", 20, -180, 220, 170, { stroke: PALETTE.strokes[3], fill: "#b2f2bb", sw: 2 });
  const t = makeObject("text", -300, 20, 0, 0, { text: "Double-click to edit this text", size: 26, stroke: PALETTE.strokes[0] });
  syncTextBounds(t);
  const arrow = makeObject("arrow", 300, -80, 0, 0, { x2: 470, y2: 40, stroke: PALETTE.strokes[0], sw: 2 });
  const ink = makeObject("pen", 0, 0, 0, 0, { stroke: PALETTE.strokes[1], sw: 3, pts: [] });
  const rnd = seeded(7);
  for (let i = 0; i <= 40; i++) {
    const x = -320 + i * 4;
    const y = 130 + Math.sin(i * 0.28) * 26 + (rnd() - .5) * 3;
    ink.pts.push([round(x), round(y)]);
  }
  const bb = objBounds(ink);
  ink.x = round(bb.x); ink.y = round(bb.y); ink.w = round(bb.w); ink.h = round(bb.h);
  scene.objects.push(a, b, t, arrow, ink);
}

function init() {
  canvas.style.background = "#fbfbfa";
  buildToolbar();
  buildSwatches();
  buildWidths();
  state.selection.clear();

  const restored = loadBoard();
  if (!restored) {
    view.x = VIEW_W / 2; view.y = VIEW_H / 2; view.z = 1;
    seedDemo();
  }
  setTool("select");
  resize();
  window.addEventListener("resize", resize);
  syncStyleUI();
  updateZoomLabel();
  updateHint();
  requestAnimationFrame(frame);

  setTimeout(() => toast(restored ? "Board restored" : "Welcome — press P and draw"), 400);
}

// Canvas must be sized before building scene coordinates for the first run.
VIEW_W = window.innerWidth; VIEW_H = window.innerHeight;
init();
</script>
</body>
</html>
<!-- agent-meta {"model":"deepseek/deepseek-v4.1-flash","provider":"openrouter","persona":"full","sessionId":"64610731-98ab-4e72-ac31-51254a05b123","tokensIn":4907875,"tokensOut":91013,"tokensTotal":4998888,"cost":0.08029689,"turns":49,"toolCalls":48,"failedToolCalls":0,"timestamp":"2026-09-10T21:10:00.355Z"} -->