← Whiteboard results

Whiteboard

Qwen 3.8 27B GGUF · 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 46,898 bytes · SHA-256 454808e60b2c
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Whiteboard — Collaborative Canvas Prototype</title>
<style>
  /* ============================================================
     VISUAL STYLE — minimal product chrome
     ============================================================ */
  :root {
    --chrome: #f0f1f4;          /* slightly darker app chrome */
    --chrome-border: #dcdee3;
    --ink: #2b2f36;
    --ink-soft: #6a7078;
    --accent: #1971c2;
    --accent-soft: #dcebfb;
    --canvas: #ffffff;
    --shadow: 0 2px 6px rgba(20, 24, 32, .10), 0 8px 24px rgba(20, 24, 32, .08);
  }
  * { box-sizing: border-box; margin: 0; padding: 0; }
  html, body { height: 100%; overflow: hidden; }
  body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
    background: var(--canvas);
    color: var(--ink);
    -webkit-font-smoothing: antialiased;
    user-select: none;
  }
  #board { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; }

  /* ---------- floating panels ---------- */
  .panel {
    position: absolute;
    background: var(--chrome);
    border: 1px solid var(--chrome-border);
    border-radius: 10px;
    box-shadow: var(--shadow);
    z-index: 10;
  }
  .toolbar {
    top: 14px; left: 50%; transform: translateX(-50%);
    display: flex; align-items: center; gap: 2px; padding: 5px;
  }
  .tool-btn {
    width: 36px; height: 36px;
    display: flex; align-items: center; justify-content: center;
    border: none; border-radius: 8px;
    background: transparent; color: var(--ink-soft);
    cursor: pointer; position: relative;
    transition: background .12s, color .12s;
  }
  .tool-btn:hover { background: #e4e6ea; color: var(--ink); }
  .tool-btn.active { background: var(--accent-soft); color: var(--accent); }
  .tool-btn:disabled { opacity: .35; cursor: default; }
  .tool-btn:disabled:hover { background: transparent; color: var(--ink-soft); }
  .tool-btn svg { width: 19px; height: 19px; display: block; }
  .tool-btn .kbd {
    position: absolute; right: 3px; bottom: 1px;
    font-size: 8px; line-height: 1; color: var(--ink-soft); opacity: .7;
    font-weight: 600;
  }
  .tool-btn.active .kbd { color: var(--accent); }
  .sep { width: 1px; height: 24px; background: var(--chrome-border); margin: 0 4px; flex: none; }

  /* ---------- properties bar ---------- */
  .props {
    top: 62px; left: 50%; transform: translateX(-50%);
    display: flex; align-items: center; gap: 8px; padding: 6px 10px;
  }
  .props .group { display: flex; align-items: center; gap: 4px; }
  .swatch {
    width: 20px; height: 20px; border-radius: 6px;
    border: 1px solid rgba(0,0,0,.14);
    cursor: pointer; padding: 0;
    transition: transform .1s;
  }
  .swatch:hover { transform: scale(1.12); }
  .swatch.active { outline: 2px solid var(--accent); outline-offset: 1.5px; }
  .swatch.none {
    background:
      linear-gradient(135deg, transparent 44%, #d63031 44%, #d63031 56%, transparent 56%),
      #ffffff;
  }
  .width-btn {
    width: 28px; height: 28px;
    display: flex; align-items: center; justify-content: center;
    border: none; border-radius: 7px; background: transparent; cursor: pointer;
  }
  .width-btn:hover { background: #e4e6ea; }
  .width-btn.active { background: var(--accent-soft); }
  .width-btn .dot { border-radius: 50%; background: var(--ink-soft); }
  .width-btn.active .dot { background: var(--accent); }

  /* ---------- zoom bar ---------- */
  .zoombar {
    right: 14px; bottom: 14px;
    display: flex; align-items: center; gap: 2px; padding: 4px;
  }
  .zoombar button {
    width: 30px; height: 30px; border: none; border-radius: 7px;
    background: transparent; color: var(--ink-soft); cursor: pointer;
    display: flex; align-items: center; justify-content: center;
  }
  .zoombar button:hover { background: #e4e6ea; color: var(--ink); }
  .zoombar svg { width: 16px; height: 16px; }
  #zoomLabel {
    min-width: 52px; text-align: center;
    font-size: 12px; font-weight: 600; color: var(--ink);
    cursor: pointer; border-radius: 7px; padding: 6px 4px;
  }
  #zoomLabel:hover { background: #e4e6ea; }

  /* ---------- hint ---------- */
  .hint {
    position: absolute; left: 14px; bottom: 14px; z-index: 10;
    font-size: 11px; color: var(--ink-soft);
    background: var(--chrome); border: 1px solid var(--chrome-border);
    border-radius: 8px; padding: 6px 10px;
    box-shadow: var(--shadow);
    pointer-events: none;
  }
  .hint b { color: var(--ink); font-weight: 600; }

  /* ---------- text editor overlay ---------- */
  #textEditor {
    position: absolute; z-index: 20;
    border: 1.5px dashed var(--accent);
    border-radius: 4px;
    background: rgba(255,255,255,.6);
    outline: none; resize: none; overflow: hidden;
    padding: 2px 4px;
    font-family: inherit;
    color: var(--ink);
    line-height: 1.35;
    display: none;
  }

  /* ---------- cursors ---------- */
  .cur-select { cursor: default; }
  .cur-pen, .cur-rect, .cur-ellipse, .cur-line { cursor: crosshair; }
  .cur-text { cursor: text; }
  .cur-eraser { cursor: crosshair; }
  .cur-pan { cursor: grab; }
  .cur-panning { cursor: grabbing !important; }
</style>
</head>
<body>
<canvas id="board" class="cur-select"></canvas>

<!-- ================= TOOLBAR ================= -->
<div class="panel toolbar" id="toolbar" role="toolbar">
  <button class="tool-btn active" data-tool="select" title="Select (V)">
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4l7.07 17 2.51-7.39L21 11.07z"/></svg>
    <span class="kbd">V</span>
  </button>
  <button class="tool-btn" data-tool="pen" title="Freehand pen (P)">
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
    <span class="kbd">P</span>
  </button>
  <button class="tool-btn" data-tool="rect" title="Rectangle (R)">
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><rect x="4" y="6" width="16" height="12" rx="1.5"/></svg>
    <span class="kbd">R</span>
  </button>
  <button class="tool-btn" data-tool="ellipse" title="Ellipse (O)">
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="12" rx="8.5" ry="6.5"/></svg>
    <span class="kbd">O</span>
  </button>
  <button class="tool-btn" data-tool="line" title="Line / Arrow (L)">
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 18L18 6"/><path d="M10 6h8v8"/></svg>
    <span class="kbd">L</span>
  </button>
  <button class="tool-btn" data-tool="text" title="Text (T)">
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 6h14"/><path d="M12 6v13"/><path d="M9 19h6"/></svg>
    <span class="kbd">T</span>
  </button>
  <button class="tool-btn" data-tool="eraser" title="Eraser (E)">
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 19.5L4.2 14.2a2 2 0 0 1 0-2.83l8.17-8.17a2 2 0 0 1 2.83 0l5.66 5.66a2 2 0 0 1 0 2.83L12.5 19.5"/><path d="M8.5 8.5l7 7"/><path d="M7 21h13"/></svg>
    <span class="kbd">E</span>
  </button>
  <div class="sep"></div>
  <button class="tool-btn" id="undoBtn" title="Undo (Ctrl+Z)" disabled>
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 14L4 9l5-5"/><path d="M4 9h10.5A5.5 5.5 0 0 1 20 14.5V19"/></svg>
  </button>
  <button class="tool-btn" id="redoBtn" title="Redo (Ctrl+Shift+Z)" disabled>
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 14l5-5-5-5"/><path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5V19"/></svg>
  </button>
</div>

<!-- ================= PROPERTIES BAR ================= -->
<div class="panel props" id="props">
  <div class="group" id="strokeGroup" title="Stroke color"></div>
  <div class="sep"></div>
  <div class="group" id="fillGroup" title="Fill color"></div>
  <div class="sep"></div>
  <div class="group" id="widthGroup" title="Stroke width"></div>
</div>

<!-- ================= ZOOM BAR ================= -->
<div class="panel zoombar" id="zoombar">
  <button id="zoomOut" title="Zoom out">
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M5 12h14"/></svg>
  </button>
  <div id="zoomLabel" title="Reset to 100%">100%</div>
  <button id="zoomIn" title="Zoom in">
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
  </button>
  <button id="zoomReset" title="Reset view (center content)">
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6"/><path d="M9 21H3v-6"/><path d="M21 3l-7.5 7.5"/><path d="M3 21l7.5-7.5"/></svg>
  </button>
</div>

<div class="hint"><b>Space</b>+drag pan &nbsp;·&nbsp; <b>Scroll</b> zoom &nbsp;·&nbsp; <b>Shift</b>+click multi-select &nbsp;·&nbsp; <b>Arrows</b> nudge</div>

<textarea id="textEditor" spellcheck="false"></textarea>

<script>
'use strict';
/* ================================================================
   1. TUNABLE CONSTANTS
   ================================================================ */
const CFG = {
  ZOOM_MIN: 0.25,
  ZOOM_MAX: 4,
  ZOOM_STEP: 1.25,                 // multiplier for +/- buttons
  WHEEL_ZOOM: 0.0016,              // exp factor per wheel delta unit
  GRID_BASE: 32,                   // base grid spacing (world px)
  GRID_DOT: 1.6,                   // dot radius (screen px)
  GRID_COLOR: '#e4e7ec',
  HIT_TOL: 5,                      // extra hit tolerance (world px)
  HANDLE: 8,                       // handle size (screen px)
  HANDLE_HIT: 7,                   // handle grab radius (screen px)
  MIN_RESIZE: 4,                   // min shape size (world px)
  MIN_STROKE_LEN: 3,               // discard shapes smaller than this
  STROKES: ['#1e1e1e', '#e8590c', '#1971c2', '#2f9e44', '#e64980', '#7048e8'],
  FILLS:   ['none', '#ffe066', '#ffd8a8', '#a5d8ff', '#b2f2bb', '#fcc2d7', '#d0bfff', '#ced4da'],
  WIDTHS:  [2, 4, 8],
  ACCENT: '#1971c2',
  FONT: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
  TEXT_SIZE: 20,
  HISTORY_MAX: 100,
  COALESCE_MS: 900,                // coalesce window for nudges
  STORE_KEY: 'wb.board.v1',
  PEN_MIN_DIST: 1.25,              // min point spacing (screen px)
};

/* ================================================================
   2. STATE
   ================================================================ */
const canvas = document.getElementById('board');
const ctx = canvas.getContext('2d');
const measureCtx = document.createElement('canvas').getContext('2d');

const view = { x: 0, y: 0, z: 1 };           // screen = world * z + (x, y)
let shapes = [];                              // THE scene array (world coords)
let selection = new Set();                    // selected shape ids
let tool = 'select';
let defaults = { stroke: CFG.STROKES[0], fill: 'none', strokeWidth: 2 };
let idCounter = 0;
let spaceDown = false;
let hoverId = null;
let pointer = { sx: 0, sy: 0, inside: false }; // last screen position
let interaction = null;                       // active gesture (see section 8)
let dirty = true;
let dpr = 1;

// history
const history = { past: [], future: [] };
let lastCommit = { key: null, time: 0 };

function uid() { return 's' + (++idCounter).toString(36) + Date.now().toString(36).slice(-4) + Math.floor(Math.random() * 1296).toString(36); }
function requestRender() { dirty = true; }
function clone(v) { return JSON.parse(JSON.stringify(v)); }
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
function byId(id) { return shapes.find(s => s.id === id) || null; }
function selShapes() { return shapes.filter(s => selection.has(s.id)); }
function isTyping() { const a = document.activeElement; return a && (a.tagName === 'TEXTAREA' || a.tagName === 'INPUT'); }

/* ================================================================
   3. VIEWPORT & TRANSFORMS
   ================================================================ */
function s2w(sx, sy) { return { x: (sx - view.x) / view.z, y: (sy - view.y) / view.z }; }
function w2s(wx, wy) { return { x: wx * view.z + view.x, y: wy * view.z + view.y }; }

function zoomAt(sx, sy, factor) {
  const z2 = clamp(view.z * factor, CFG.ZOOM_MIN, CFG.ZOOM_MAX);
  if (z2 === view.z) return;
  const w = s2w(sx, sy);                     // world point under cursor
  view.z = z2;
  view.x = sx - w.x * z2;                    // keep that point under cursor
  view.y = sy - w.y * z2;
  updateZoomUI(); saveSoon(); requestRender();
}

function resetView() {
  const vw = window.innerWidth, vh = window.innerHeight;
  let cx = 0, cy = 0;
  if (shapes.length) {
    const b = unionBBox(shapes);
    cx = b.x + b.w / 2; cy = b.y + b.h / 2;
    const z = clamp(Math.min(vw / Math.max(b.w + 160, 1), vh / Math.max(b.h + 160, 1)), CFG.ZOOM_MIN, 1);
    view.z = z;
  } else {
    view.z = 1;
  }
  view.x = vw / 2 - cx * view.z;
  view.y = vh / 2 - cy * view.z;
  updateZoomUI(); saveSoon(); requestRender();
}

function resizeCanvas() {
  dpr = window.devicePixelRatio || 1;
  canvas.width = Math.round(canvas.clientWidth * dpr);
  canvas.height = Math.round(canvas.clientHeight * dpr);
  requestRender();
}

/* ================================================================
   4. PERSISTENCE (board survives reload; history does not)
   ================================================================ */
let saveTimer = null;
let viewRestored = false;
function saveSoon() { clearTimeout(saveTimer); saveTimer = setTimeout(save, 250); }
function save() {
  try {
    localStorage.setItem(CFG.STORE_KEY, JSON.stringify({ shapes, view, idCounter }));
  } catch (e) { /* storage unavailable */ }
}
function load() {
  try {
    const raw = localStorage.getItem(CFG.STORE_KEY);
    if (!raw) return;
    const data = JSON.parse(raw);
    if (Array.isArray(data.shapes)) shapes = data.shapes;
    if (data.view && isFinite(data.view.x) && isFinite(data.view.y) && isFinite(data.view.z)) {
      view.x = data.view.x; view.y = data.view.y;
      view.z = clamp(data.view.z, CFG.ZOOM_MIN, CFG.ZOOM_MAX);
      viewRestored = true;
    }
    idCounter = data.idCounter || 0;
  } catch (e) { /* corrupt data — start fresh */ }
}

/* ================================================================
   5. SHAPE MODEL — geometry, hit-testing, transforms
   Every shape: { id, type, stroke, fill, strokeWidth, ...geometry }
   ================================================================ */
function shapeBBox(s) {
  switch (s.type) {
    case 'rect': case 'ellipse':
      return { x: s.x, y: s.y, w: s.w, h: s.h };
    case 'line':
      return { x: Math.min(s.x1, s.x2), y: Math.min(s.y1, s.y2), w: Math.abs(s.x2 - s.x1), h: Math.abs(s.y2 - s.y1) };
    case 'freeline': {
      let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
      for (const p of s.points) {
        if (p.x < minX) minX = p.x; if (p.x > maxX) maxX = p.x;
        if (p.y < minY) minY = p.y; if (p.y > maxY) maxY = p.y;
      }
      const m = s.strokeWidth / 2;
      return { x: minX - m, y: minY - m, w: (maxX - minX) + m * 2, h: (maxY - minY) + m * 2 };
    }
    case 'text': {
      measureCtx.font = fontFor(s);
      const lines = (s.text || '').split('\n');
      let w = 0;
      for (const ln of lines) w = Math.max(w, measureCtx.measureText(ln).width);
      return { x: s.x, y: s.y, w: Math.max(w, 8), h: lines.length * lineH(s) };
    }
  }
  return { x: 0, y: 0, w: 0, h: 0 };
}
function fontFor(s) { return s.fontSize + 'px ' + CFG.FONT; }
function lineH(s) { return s.fontSize * 1.35; }

function unionBBox(list) {
  let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;
  for (const s of list) {
    const b = shapeBBox(s);
    x1 = Math.min(x1, b.x); y1 = Math.min(y1, b.y);
    x2 = Math.max(x2, b.x + b.w); y2 = Math.max(y2, b.y + b.h);
  }
  if (!isFinite(x1)) return { x: 0, y: 0, w: 0, h: 0 };
  return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 };
}

function distToSeg(px, py, ax, ay, bx, by) {
  const dx = bx - ax, dy = by - ay;
  const l2 = dx * dx + dy * dy;
  let t = l2 ? ((px - ax) * dx + (py - ay) * dy) / l2 : 0;
  t = clamp(t, 0, 1);
  const qx = ax + t * dx - px, qy = ay + t * dy - py;
  return Math.sqrt(qx * qx + qy * qy);
}

// Hit-test in WORLD coordinates — pixel-accurate at any zoom.
function hitTest(wx, wy) {
  const tol = CFG.HIT_TOL / view.z;          // screen-constant tolerance
  for (let i = shapes.length - 1; i >= 0; i--) {
    const s = shapes[i];
    const t = tol + s.strokeWidth / 2;
    switch (s.type) {
      case 'rect': {
        if (s.fill === 'none') {
          const b = shapeBBox(s);
          const near = wx >= b.x - t && wx <= b.x + b.w + t && wy >= b.y - t && wy <= b.y + b.h + t;
          const inside = wx >= b.x && wx <= b.x + b.w && wy >= b.y && wy <= b.y + b.h;
          if (near && (!inside || t > 0)) return s;   // unfilled: border band
        } else {
          const b = shapeBBox(s);
          if (wx >= b.x - t && wx <= b.x + b.w + t && wy >= b.y - t && wy <= b.y + b.h + t) return s;
        }
        break;
      }
      case 'ellipse': {
        const b = shapeBBox(s);
        const rx = b.w / 2 + t, ry = b.h / 2 + t;
        const cx = b.x + b.w / 2, cy = b.y + b.h / 2;
        if (rx > 0 && ry > 0) {
          const nx = (wx - cx) / rx, ny = (wy - cy) / ry;
          if (nx * nx + ny * ny <= 1) return s;       // true ellipse test
        }
        break;
      }
      case 'line':
        if (distToSeg(wx, wy, s.x1, s.y1, s.x2, s.y2) <= t) return s;
        break;
      case 'freeline': {
        const pts = s.points;
        if (pts.length === 1) {
          if (Math.hypot(wx - pts[0].x, wy - pts[0].y) <= t) return s;
        } else {
          for (let k = 0; k < pts.length - 1; k++)
            if (distToSeg(wx, wy, pts[k].x, pts[k].y, pts[k + 1].x, pts[k + 1].y) <= t) return s;
        }
        break;
      }
      case 'text': {
        const b = shapeBBox(s);
        if (wx >= b.x - t && wx <= b.x + b.w + t && wy >= b.y - t && wy <= b.y + b.h + t) return s;
        break;
      }
    }
  }
  return null;
}

// Scale a shape about an anchor point (used by resize).
function scaleShape(s, ax, ay, sx, sy) {
  const tx = v => ax + (v - ax) * sx;
  const ty = v => ay + (v - ay) * sy;
  const avg = Math.max((sx + sy) / 2, 0.05);
  switch (s.type) {
    case 'rect': case 'ellipse':
      s.x = tx(s.x); s.y = ty(s.y); s.w *= sx; s.h *= sy; break;
    case 'line':
      s.x1 = tx(s.x1); s.y1 = ty(s.y1); s.x2 = tx(s.x2); s.y2 = ty(s.y2); break;
    case 'freeline':
      for (const p of s.points) { p.x = tx(p.x); p.y = ty(p.y); } break;
    case 'text':
      s.x = tx(s.x); s.y = ty(s.y);
      s.fontSize = Math.max(6, s.fontSize * avg); break;
  }
  s.strokeWidth = clamp(s.strokeWidth * avg, 0.5, 64);
}

function moveShape(s, dx, dy) {
  switch (s.type) {
    case 'rect': case 'ellipse': case 'text': s.x += dx; s.y += dy; break;
    case 'line': s.x1 += dx; s.y1 += dy; s.x2 += dx; s.y2 += dy; break;
    case 'freeline': for (const p of s.points) { p.x += dx; p.y += dy; } break;
  }
}

/* ================================================================
   6. HISTORY — snapshot based; one gesture = one step
   ================================================================ */
function commit(preState, key) {
  // preState = scene snapshot from BEFORE the gesture
  const now = performance.now();
  const coalesce = key && lastCommit.key === key && (now - lastCommit.time) < CFG.COALESCE_MS;
  if (!coalesce) {
    history.past.push(preState);
    if (history.past.length > CFG.HISTORY_MAX) history.past.shift();
    history.future.length = 0;
  }
  lastCommit = { key: key || null, time: now };
  updateHistoryUI(); saveSoon();
}
function undo() {
  if (!history.past.length) return;
  history.future.push(clone(shapes));
  shapes = history.past.pop();
  pruneSelection();
  lastCommit = { key: null, time: 0 };
  updateHistoryUI(); saveSoon(); requestRender();
}
function redo() {
  if (!history.future.length) return;
  history.past.push(clone(shapes));
  shapes = history.future.pop();
  pruneSelection();
  lastCommit = { key: null, time: 0 };
  updateHistoryUI(); saveSoon(); requestRender();
}
function pruneSelection() {
  const ids = new Set(shapes.map(s => s.id));
  let changed = false;
  for (const id of [...selection]) if (!ids.has(id)) { selection.delete(id); changed = true; }
  if (changed) requestRender();
}
function updateHistoryUI() {
  document.getElementById('undoBtn').disabled = !history.past.length;
  document.getElementById('redoBtn').disabled = !history.future.length;
}

/* ================================================================
   7. RENDERING
   ================================================================ */
function render() {
  const w = canvas.clientWidth, h = canvas.clientHeight;
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  ctx.fillStyle = '#ffffff';
  ctx.fillRect(0, 0, w, h);

  drawGrid(w, h);

  // ---- world pass ----
  ctx.setTransform(dpr * view.z, 0, 0, dpr * view.z, dpr * view.x, dpr * view.y);
  for (const s of shapes) {
    if (s.id === editingId) continue;        // text being edited shows in overlay
    drawShape(s);
  }

  // ---- screen pass (crisp UI at any zoom) ----
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  drawHover();
  drawSelectionUI();
  drawInteractionUI();
}

function drawGrid(w, h) {
  let step = CFG.GRID_BASE;
  while (step * view.z < 22) step *= 2;
  while (step * view.z > 88) step /= 2;
  const tl = s2w(0, 0), br = s2w(w, h);
  ctx.fillStyle = CFG.GRID_COLOR;
  const r = CFG.GRID_DOT;
  for (let gx = Math.floor(tl.x / step) * step; gx <= br.x; gx += step) {
    const sx = gx * view.z + view.x;
    for (let gy = Math.floor(tl.y / step) * step; gy <= br.y; gy += step) {
      const sy = gy * view.z + view.y;
      ctx.beginPath();
      ctx.arc(sx, sy, r, 0, Math.PI * 2);
      ctx.fill();
    }
  }
}

function drawShape(s) {
  ctx.lineCap = 'round';
  ctx.lineJoin = 'round';
  ctx.strokeStyle = s.stroke;
  ctx.lineWidth = s.strokeWidth;
  if (s.fill && s.fill !== 'none') ctx.fillStyle = s.fill;

  switch (s.type) {
    case 'rect':
      if (s.fill && s.fill !== 'none') ctx.fillRect(s.x, s.y, s.w, s.h);
      ctx.strokeRect(s.x, s.y, s.w, s.h);
      break;
    case 'ellipse': {
      const cx = s.x + s.w / 2, cy = s.y + s.h / 2;
      ctx.beginPath();
      ctx.ellipse(cx, cy, Math.abs(s.w / 2), Math.abs(s.h / 2), 0, 0, Math.PI * 2);
      if (s.fill && s.fill !== 'none') ctx.fill();
      ctx.stroke();
      break;
    }
    case 'line': {
      ctx.beginPath();
      ctx.moveTo(s.x1, s.y1);
      ctx.lineTo(s.x2, s.y2);
      ctx.stroke();
      // arrowhead
      const ang = Math.atan2(s.y2 - s.y1, s.x2 - s.x1);
      const len = Math.max(10, s.strokeWidth * 3.2);
      ctx.beginPath();
      ctx.moveTo(s.x2, s.y2);
      ctx.lineTo(s.x2 - len * Math.cos(ang - 0.45), s.y2 - len * Math.sin(ang - 0.45));
      ctx.moveTo(s.x2, s.y2);
      ctx.lineTo(s.x2 - len * Math.cos(ang + 0.45), s.y2 - len * Math.sin(ang + 0.45));
      ctx.stroke();
      break;
    }
    case 'freeline': {
      const pts = s.points;
      if (!pts.length) break;
      ctx.beginPath();
      if (pts.length === 1) {
        ctx.arc(pts[0].x, pts[0].y, s.strokeWidth / 2, 0, Math.PI * 2);
        ctx.fillStyle = s.stroke;
        ctx.fill();
        break;
      }
      ctx.moveTo(pts[0].x, pts[0].y);
      for (let i = 1; i < pts.length - 1; i++) {
        const mx = (pts[i].x + pts[i + 1].x) / 2, my = (pts[i].y + pts[i + 1].y) / 2;
        ctx.quadraticCurveTo(pts[i].x, pts[i].y, mx, my);
      }
      const last = pts[pts.length - 1];
      ctx.lineTo(last.x, last.y);
      ctx.stroke();
      break;
    }
    case 'text': {
      ctx.font = fontFor(s);
      ctx.textBaseline = 'top';
      ctx.fillStyle = s.stroke;
      const lines = (s.text || '').split('\n');
      const lh = lineH(s);
      for (let i = 0; i < lines.length; i++)
        ctx.fillText(lines[i], s.x, s.y + i * lh);
      break;
    }
  }
}

function drawHover() {
  if (tool !== 'select' || !hoverId || selection.has(hoverId) || interaction) return;
  const s = byId(hoverId);
  if (!s) return;
  const b = shapeBBox(s);
  const p = w2s(b.x, b.y), q = w2s(b.x + b.w, b.y + b.h);
  ctx.strokeStyle = CFG.ACCENT;
  ctx.globalAlpha = 0.45;
  ctx.lineWidth = 1.25;
  ctx.setLineDash([]);
  ctx.strokeRect(p.x, p.y, q.x - p.x, q.y - p.y);
  ctx.globalAlpha = 1;
}

function drawSelectionUI() {
  if (!selection.size) return;
  const list = selShapes();
  if (!list.length) return;
  const b = unionBBox(list);
  const p = w2s(b.x, b.y), q = w2s(b.x + b.w, b.y + b.h);
  ctx.strokeStyle = CFG.ACCENT;
  ctx.lineWidth = 1.25;
  ctx.setLineDash([]);
  ctx.strokeRect(p.x, p.y, q.x - p.x, q.y - p.y);
  // 4 corner handles — constant screen size at every zoom
  const hs = CFG.HANDLE;
  for (const [hx, hy] of [[p.x, p.y], [q.x, p.y], [p.x, q.y], [q.x, q.y]]) {
    ctx.fillStyle = '#ffffff';
    ctx.strokeStyle = CFG.ACCENT;
    ctx.lineWidth = 1.5;
    ctx.beginPath();
    ctx.rect(hx - hs / 2, hy - hs / 2, hs, hs);
    ctx.fill();
    ctx.stroke();
  }
}

function drawInteractionUI() {
  if (!interaction) return;
  if (interaction.type === 'rubberband') {
    const a = w2s(interaction.sx0, interaction.sy0), b = w2s(interaction.sx1, interaction.sy1);
    ctx.strokeStyle = CFG.ACCENT;
    ctx.fillStyle = 'rgba(25,113,194,0.08)';
    ctx.lineWidth = 1;
    ctx.setLineDash([4, 3]);
    const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y);
    const w = Math.abs(a.x - b.x), h = Math.abs(a.y - b.y);
    ctx.fillRect(x, y, w, h);
    ctx.strokeRect(x, y, w, h);
    ctx.setLineDash([]);
  } else if (interaction.type === 'erase') {
    ctx.strokeStyle = '#d63031';
    ctx.lineWidth = 1.5;
    ctx.beginPath();
    ctx.arc(pointer.sx, pointer.sy, 10, 0, Math.PI * 2);
    ctx.stroke();
  }
}

/* ================================================================
   8. INTERACTION — the state machine
   ================================================================ */
function setTool(t) {
  tool = t;
  for (const btn of document.querySelectorAll('[data-tool]'))
    btn.classList.toggle('active', btn.dataset.tool === t);
  canvas.className = 'cur-' + t;
  if (t !== 'select') { selection.clear(); hoverId = null; }
  requestRender();
}

function handleAt(sx, sy) {
  // returns { id: 'nw'|'ne'|'sw'|'se', ax, ay } in world coords, or null
  if (selection.size !== 1) return null;
  const s = selShapes()[0];
  const b = shapeBBox(s);
  const corners = {
    nw: [b.x, b.y], ne: [b.x + b.w, b.y],
    sw: [b.x, b.y + b.h], se: [b.x + b.w, b.y + b.h],
  };
  for (const [id, [wx, wy]] of Object.entries(corners)) {
    const p = w2s(wx, wy);
    if (Math.abs(sx - p.x) <= CFG.HANDLE_HIT && Math.abs(sy - p.y) <= CFG.HANDLE_HIT) {
      // anchor = opposite corner
      const opp = { nw: [b.x + b.w, b.y + b.h], ne: [b.x, b.y + b.h], sw: [b.x + b.w, b.y], se: [b.x, b.y] }[id];
      return { id, ax: opp[0], ay: opp[1] };
    }
  }
  return null;
}
function resizeCursor(sx, sy) {
  const h = handleAt(sx, sy);
  if (!h) return null;
  return { nw: 'nwse-resize', se: 'nwse-resize', ne: 'nesw-resize', sw: 'nesw-resize' }[h.id];
}

canvas.addEventListener('pointerdown', e => {
  if (isTyping()) return;
  try { canvas.setPointerCapture(e.pointerId); } catch (err) { /* synthetic events */ }
  const sx = e.offsetX, sy = e.offsetY;
  pointer.sx = sx; pointer.sy = sy; pointer.inside = true;

  // ---- pan: space+drag or middle mouse ----
  if (e.button === 1 || spaceDown) {
    e.preventDefault();
    interaction = { type: 'pan', sx0: sx, sy0: sy, vx0: view.x, vy0: view.y };
    canvas.classList.add('cur-panning');
    return;
  }
  if (e.button !== 0) return;

  const wpt = s2w(sx, sy);

  switch (tool) {
    case 'select': {
      const h = handleAt(sx, sy);
      if (h) {
        const s = selShapes()[0];
        interaction = {
          type: 'resize', id: h.id, ax: h.ax, ay: h.ay,
          before: clone(shapes),
          orig: shapeBBox(s),
          moved: false,
        };
        return;
      }
      const hit = hitTest(wpt.x, wpt.y);
      if (hit) {
        if (e.shiftKey) {
          selection.has(hit.id) ? selection.delete(hit.id) : selection.add(hit.id);
        } else if (!selection.has(hit.id)) {
          selection.clear();
          selection.add(hit.id);
        }
        interaction = { type: 'move', before: clone(shapes), w0: wpt, moved: false };
      } else {
        if (!e.shiftKey) selection.clear();
        interaction = {
          type: 'rubberband',
          sx0: wpt.x, sy0: wpt.y, sx1: wpt.x, sy1: wpt.y,
          base: new Set(selection),
        };
      }
      requestRender();
      break;
    }
    case 'pen': {
      const before = clone(shapes);
      const s = { id: uid(), type: 'freeline', points: [{ x: wpt.x, y: wpt.y }],
                  stroke: defaults.stroke, fill: 'none', strokeWidth: defaults.strokeWidth };
      shapes.push(s);
      interaction = { type: 'draw', id: s.id, last: wpt, before };
      requestRender();
      break;
    }
    case 'rect': case 'ellipse': case 'line': {
      const before = clone(shapes);
      const s = {
        id: uid(), type: tool,
        stroke: defaults.stroke, fill: defaults.fill, strokeWidth: defaults.strokeWidth,
        x: wpt.x, y: wpt.y, w: 0, h: 0, x1: wpt.x, y1: wpt.y, x2: wpt.x, y2: wpt.y,
      };
      shapes.push(s);
      interaction = { type: 'create', id: s.id, w0: wpt, before };
      requestRender();
      break;
    }
    case 'text': {
      const before = clone(shapes);
      const s = { id: uid(), type: 'text', x: wpt.x, y: wpt.y, text: '',
                  stroke: defaults.stroke, fill: 'none', strokeWidth: 2, fontSize: CFG.TEXT_SIZE };
      shapes.push(s);
      selection.clear();
      selection.add(s.id);
      setTool('select');
      startEditing(s, before);
      break;
    }
    case 'eraser': {
      interaction = { type: 'erase', before: clone(shapes), erased: 0 };
      eraseAt(wpt.x, wpt.y);
      break;
    }
  }
});

canvas.addEventListener('pointermove', e => {
  const sx = e.offsetX, sy = e.offsetY;
  pointer.sx = sx; pointer.sy = sy; pointer.inside = true;
  const wpt = s2w(sx, sy);

  if (!interaction) {
    // hover feedback + cursor
    if (tool === 'select') {
      const rc = resizeCursor(sx, sy);
      if (rc) { canvas.style.cursor = rc; }
      else {
        canvas.style.cursor = '';
        const hit = hitTest(wpt.x, wpt.y);
        const id = hit ? hit.id : null;
        if (id !== hoverId) { hoverId = id; requestRender(); }
        canvas.style.cursor = hit ? 'move' : '';
      }
    }
    return;
  }

  switch (interaction.type) {
    case 'pan':
      view.x = interaction.vx0 + (sx - interaction.sx0);
      view.y = interaction.vy0 + (sy - interaction.sy0);
      requestRender();
      break;
    case 'move': {
      // incremental delta — applying the total delta every move would compound
      const dx = wpt.x - interaction.w0.x, dy = wpt.y - interaction.w0.y;
      const adx = dx - (interaction.lx || 0), ady = dy - (interaction.ly || 0);
      interaction.lx = dx; interaction.ly = dy;
      if (Math.abs(adx) > 0.001 || Math.abs(ady) > 0.001) interaction.moved = true;
      const ids = new Set([...selection]);
      for (const s of shapes) if (ids.has(s.id)) moveShape(s, adx, ady);
      requestRender();
      break;
    }
    case 'resize': {
      interaction.moved = true;
      applyResize(wpt.x, wpt.y, e.shiftKey);
      requestRender();
      break;
    }
    case 'rubberband':
      interaction.sx1 = wpt.x; interaction.sy1 = wpt.y;
      requestRender();
      break;
    case 'draw': {
      const s = byId(interaction.id);
      const d = Math.hypot(wpt.x - interaction.last.x, wpt.y - interaction.last.y) * view.z;
      if (d >= CFG.PEN_MIN_DIST) {
        s.points.push({ x: wpt.x, y: wpt.y });
        interaction.last = wpt;
        requestRender();
      }
      break;
    }
    case 'create': {
      const s = byId(interaction.id);
      if (s.type === 'line') { s.x2 = wpt.x; s.y2 = wpt.y; }
      else {
        s.w = wpt.x - interaction.w0.x;
        s.h = wpt.y - interaction.w0.y;
        if (e.shiftKey) { const m = Math.max(Math.abs(s.w), Math.abs(s.h)); s.w = Math.sign(s.w || 1) * m; s.h = Math.sign(s.h || 1) * m; }
      }
      requestRender();
      break;
    }
    case 'erase':
      eraseAt(wpt.x, wpt.y);
      break;
  }
});

function applyResize(wx, wy, shift) {
  const it = interaction;
  const list = selShapes();
  if (!list.length) return;
  const b = it.orig;
  let sx = (wx - it.ax) / Math.max(b.w, 1);
  let sy = (wy - it.ay) / Math.max(b.h, 1);
  if (shift) { const m = Math.max(sx, sy); sx = sy = m; }
  sx = clamp(sx, 0.02, 50); sy = clamp(sy, 0.02, 50);
  // rebuild each selected shape from its ORIGINAL snapshot — scaling the live
  // shape repeatedly would compound the transform
  const beforeById = new Map(it.before.map(o => [o.id, o]));
  for (let i = 0; i < shapes.length; i++) {
    const s = shapes[i];
    if (!selection.has(s.id)) continue;
    const o = beforeById.get(s.id);
    if (!o) continue;
    const t = clone(o);
    scaleShape(t, it.ax, it.ay, sx, sy);
    shapes[i] = t;
  }
  // enforce minimum size for single shapes
  if (list.length === 1) {
    const cur = byId(list[0].id);
    if (cur && cur.type !== 'line') {
      const bb = shapeBBox(cur);
      if (bb.w < CFG.MIN_RESIZE) scaleShape(cur, it.ax, it.ay, CFG.MIN_RESIZE / Math.max(bb.w, 0.001), 1);
      if (bb.h < CFG.MIN_RESIZE) scaleShape(cur, it.ax, it.ay, 1, CFG.MIN_RESIZE / Math.max(bb.h, 0.001));
    }
  }
}

function eraseAt(wx, wy) {
  const hit = hitTest(wx, wy);
  if (hit) {
    shapes = shapes.filter(s => s.id !== hit.id);
    selection.delete(hit.id);
    interaction.erased++;
    requestRender();
  }
}

function endPointer(e) {
  if (!interaction) return;
  const it = interaction;
  interaction = null;
  canvas.classList.remove('cur-panning');

  switch (it.type) {
    case 'pan':
      updateZoomUI(); saveSoon();
      break;
    case 'move':
      if (it.moved) commit(it.before);
      break;
    case 'resize':
      if (it.moved) commit(it.before);
      break;
    case 'rubberband': {
      const x1 = Math.min(it.sx0, it.sx1), x2 = Math.max(it.sx0, it.sx1);
      const y1 = Math.min(it.sy0, it.sy1), y2 = Math.max(it.sy0, it.sy1);
      if (Math.abs(x2 - x1) > 2 / view.z || Math.abs(y2 - y1) > 2 / view.z) {
        for (const s of shapes) {
          const b = shapeBBox(s);
          if (b.x < x2 && b.x + b.w > x1 && b.y < y2 && b.y + b.h > y1)
            selection.add(s.id);
        }
      }
      requestRender();
      break;
    }
    case 'draw': {
      const s = byId(it.id);
      if (s && s.points.length < 2) shapes = shapes.filter(x => x.id !== s.id);
      else {
        selection.clear();
        selection.add(s.id);
        setTool('select');
        commit(it.before);
      }
      break;
    }
    case 'create': {
      const s = byId(it.id);
      const b = s ? shapeBBox(s) : null;
      const tiny = !s || (s.type === 'line'
        ? Math.hypot(s.x2 - s.x1, s.y2 - s.y1) < CFG.MIN_STROKE_LEN
        : (b.w < CFG.MIN_STROKE_LEN && b.h < CFG.MIN_STROKE_LEN));
      if (tiny) {
        if (s) shapes = shapes.filter(x => x.id !== s.id);
      } else {
        if (s.type === 'rect' || s.type === 'ellipse') {
          if (s.w < 0) { s.x += s.w; s.w = -s.w; }
          if (s.h < 0) { s.y += s.h; s.h = -s.h; }
        }
        selection.clear();
        selection.add(s.id);
        setTool('select');
        commit(it.before);
      }
      requestRender();
      break;
    }
    case 'erase':
      if (it.erased) commit(it.before);
      break;
  }
}
canvas.addEventListener('pointerup', endPointer);
canvas.addEventListener('pointercancel', endPointer);

canvas.addEventListener('pointerleave', () => {
  pointer.inside = false;
  hoverId = null;
  requestRender();
});
canvas.addEventListener('dblclick', e => {
  if (tool !== 'select') return;
  const w = s2w(e.offsetX, e.offsetY);
  const hit = hitTest(w.x, w.y);
  if (hit && hit.type === 'text') {
    selection.clear();
    selection.add(hit.id);
    startEditing(hit, clone(shapes));
  }
});
canvas.addEventListener('contextmenu', e => e.preventDefault());

// ---- wheel zoom toward cursor ----
canvas.addEventListener('wheel', e => {
  e.preventDefault();
  const factor = Math.exp(-e.deltaY * CFG.WHEEL_ZOOM * (e.ctrlKey ? 2.2 : 1));
  zoomAt(e.offsetX, e.offsetY, factor);
}, { passive: false });

/* ================================================================
   9. TEXT EDITOR (overlay textarea)
   ================================================================ */
const editor = document.getElementById('textEditor');
let editingId = null;
let editBefore = null;   // shape snapshot before editing (for cancel/commit)

function startEditing(s, sceneBefore) {
  editingId = s.id;
  editBefore = { shape: clone(s), scene: sceneBefore || clone(shapes) };
  const p = w2s(s.x, s.y);
  editor.style.display = 'block';
  editor.style.left = (p.x - 3) + 'px';
  editor.style.top = (p.y - 3) + 'px';
  editor.style.fontSize = (s.fontSize * view.z) + 'px';
  editor.style.fontFamily = CFG.FONT;
  editor.style.color = s.stroke;
  const lh = s.fontSize * 1.35 * view.z;
  const lines = Math.max(1, (s.text || '').split('\n').length);
  editor.style.height = (lines * lh + 8) + 'px';
  editor.style.width = 'auto';
  editor.style.minWidth = (Math.max(shapeBBox(s).w * view.z, 40) + 10) + 'px';
  editor.value = s.text || '';
  requestRender();
  setTimeout(() => { editor.focus(); editor.select(); }, 0);
}

function syncEditorSize(s) {
  const lh = s.fontSize * 1.35 * view.z;
  const lines = Math.max(1, (s.text || '').split('\n').length);
  editor.style.height = (lines * lh + 8) + 'px';
  const b = shapeBBox(s);
  editor.style.minWidth = (Math.max(b.w * view.z, 40) + 10) + 'px';
}

function commitEdit(cancel) {
  if (!editingId) return;
  const s = byId(editingId);
  const finalText = (editor.value || '').replace(/\n+$/, '');
  editor.style.display = 'none';
  const id = editingId;
  editingId = null;
  const before = editBefore;
  editBefore = null;
  const sceneBefore = before ? before.scene : clone(shapes);

  if (cancel) {
    // restore the shape to its pre-edit state
    if (s && before) {
      const i = shapes.findIndex(x => x.id === id);
      if (i >= 0) shapes[i] = before.shape;
    }
    commit(sceneBefore);
  } else if (finalText === '') {
    // empty text → discard the shape
    shapes = shapes.filter(x => x.id !== id);
    selection.delete(id);
    commit(sceneBefore);
  } else {
    if (s) {
      s.text = finalText;
      commit(sceneBefore);
      selection.clear();
      selection.add(id);
    } else {
      commit(sceneBefore);
    }
  }
  editor.blur();   // release focus so canvas shortcuts work again
  requestRender();
}

editor.addEventListener('input', () => {
  const s = byId(editingId);
  if (s) { s.text = editor.value; syncEditorSize(s); requestRender(); }
});
editor.addEventListener('keydown', e => {
  e.stopPropagation();
  if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); commitEdit(false); }
  else if (e.key === 'Escape') { e.preventDefault(); commitEdit(true); }
});
editor.addEventListener('blur', () => commitEdit(false));
editor.addEventListener('pointerdown', e => e.stopPropagation());

/* ================================================================
   10. PROPERTIES BAR
   ================================================================ */
function buildSwatches(containerId, colors, kind) {
  const el = document.getElementById(containerId);
  el.innerHTML = '';
  for (const c of colors) {
    const b = document.createElement('button');
    b.className = 'swatch' + (c === 'none' ? ' none' : '');
    if (c !== 'none') b.style.background = c;
    b.dataset.value = c;
    b.title = c === 'none' ? 'No fill' : c;
    b.addEventListener('click', () => applyStyle(kind, c));
    el.appendChild(b);
  }
}
function buildWidths() {
  const el = document.getElementById('widthGroup');
  el.innerHTML = '';
  for (const w of CFG.WIDTHS) {
    const b = document.createElement('button');
    b.className = 'width-btn';
    b.dataset.value = w;
    b.innerHTML = '<span class="dot"></span>';
    const d = 4 + w * 1.6;
    b.querySelector('.dot').style.width = d + 'px';
    b.querySelector('.dot').style.height = d + 'px';
    b.title = w + 'px';
    b.addEventListener('click', () => applyStyle('strokeWidth', w));
    el.appendChild(b);
  }
}
function applyStyle(kind, value) {
  const sel = selShapes();
  if (sel.length) {
    for (const s of sel) {
      if (kind === 'stroke' || kind === 'fill' || kind === 'strokeWidth') s[kind] = value;
    }
    commit(clone(shapes), 'restyle');
  } else {
    defaults[kind] = value;
  }
  updatePropsUI();
  requestRender();
}
function updatePropsUI() {
  const sel = selShapes();
  const stroke = sel.length ? sel[0].stroke : defaults.stroke;
  const fill = sel.length ? (sel[0].fill || 'none') : defaults.fill;
  const width = sel.length ? sel[0].strokeWidth : defaults.strokeWidth;
  for (const btn of document.querySelectorAll('#strokeGroup .swatch'))
    btn.classList.toggle('active', btn.dataset.value === stroke);
  for (const btn of document.querySelectorAll('#fillGroup .swatch'))
    btn.classList.toggle('active', btn.dataset.value === fill);
  for (const btn of document.querySelectorAll('#widthGroup .width-btn'))
    btn.classList.toggle('active', Number(btn.dataset.value) === width);
}

/* ================================================================
   11. KEYBOARD
   ================================================================ */
window.addEventListener('keydown', e => {
  if (isTyping()) return;
  const k = e.key;

  if (k === ' ') {
    if (!spaceDown) { spaceDown = true; canvas.classList.add('cur-pan'); }
    e.preventDefault();
    return;
  }
  if (e.ctrlKey || e.metaKey) {
    const lk = k.toLowerCase();
    if (lk === 'z') { e.preventDefault(); e.shiftKey ? redo() : undo(); }
    else if (lk === 'y') { e.preventDefault(); redo(); }
    return;
  }
  if (e.altKey) return;

  switch (k) {
    case 'v': case 'V': setTool('select'); break;
    case 'p': case 'P': setTool('pen'); break;
    case 'r': case 'R': setTool('rect'); break;
    case 'o': case 'O': setTool('ellipse'); break;
    case 'l': case 'L': setTool('line'); break;
    case 't': case 'T': setTool('text'); break;
    case 'e': case 'E': setTool('eraser'); break;
    case 'Delete': case 'Backspace':
      if (selection.size) {
        shapes = shapes.filter(s => !selection.has(s.id));
        selection.clear();
        commit(clone(shapes));
      }
      break;
    case 'ArrowLeft': case 'ArrowRight': case 'ArrowUp': case 'ArrowDown': {
      if (!selection.size) break;
      e.preventDefault();
      const d = e.shiftKey ? 10 : 1;
      const dx = k === 'ArrowLeft' ? -d : k === 'ArrowRight' ? d : 0;
      const dy = k === 'ArrowUp' ? -d : k === 'ArrowDown' ? d : 0;
      for (const s of selShapes()) moveShape(s, dx, dy);
      commit(clone(shapes), 'nudge');
      break;
    }
    case 'Escape':
      selection.clear();
      requestRender();
      break;
  }
});
window.addEventListener('keyup', e => {
  if (e.key === ' ') { spaceDown = false; canvas.classList.remove('cur-pan'); }
});

/* ================================================================
   12. TOOLBAR & ZOOM UI
   ================================================================ */
document.querySelectorAll('[data-tool]').forEach(btn => {
  btn.addEventListener('click', () => setTool(btn.dataset.tool));
});
document.getElementById('undoBtn').addEventListener('click', undo);
document.getElementById('redoBtn').addEventListener('click', redo);
document.getElementById('zoomIn').addEventListener('click', () =>
  zoomAt(window.innerWidth / 2, window.innerHeight / 2, CFG.ZOOM_STEP));
document.getElementById('zoomOut').addEventListener('click', () =>
  zoomAt(window.innerWidth / 2, window.innerHeight / 2, 1 / CFG.ZOOM_STEP));
document.getElementById('zoomReset').addEventListener('click', resetView);
document.getElementById('zoomLabel').addEventListener('click', () => {
  // click % → 100% at center
  const w = s2w(window.innerWidth / 2, window.innerHeight / 2);
  view.z = 1;
  view.x = window.innerWidth / 2 - w.x;
  view.y = window.innerHeight / 2 - w.y;
  updateZoomUI(); saveSoon(); requestRender();
});
function updateZoomUI() {
  document.getElementById('zoomLabel').textContent = Math.round(view.z * 100) + '%';
}

/* ================================================================
   13. INIT
   ================================================================ */
function seedIfEmpty() {
  shapes = [
    { id: uid(), type: 'rect', x: -260, y: -140, w: 240, h: 150, stroke: '#1e1e1e', fill: '#ffe066', strokeWidth: 2 },
    { id: uid(), type: 'ellipse', x: 40, y: -100, w: 170, h: 170, stroke: '#1971c2', fill: 'none', strokeWidth: 2 },
    { id: uid(), type: 'freeline', points: [
      { x: -240, y: 60 }, { x: -210, y: 40 }, { x: -180, y: 70 }, { x: -150, y: 45 },
      { x: -120, y: 65 }, { x: -90, y: 50 }, { x: -60, y: 62 },
    ], stroke: '#2f9e44', fill: 'none', strokeWidth: 4 },
    { id: uid(), type: 'text', x: 60, y: 120, text: 'Welcome to the board.\nDraw, select, undo — it all works.',
      stroke: '#2b2f36', fill: 'none', strokeWidth: 2, fontSize: 18 },
  ];
}

load();
if (!shapes.length) seedIfEmpty();
buildSwatches('strokeGroup', CFG.STROKES, 'stroke');
buildSwatches('fillGroup', CFG.FILLS, 'fill');
buildWidths();
updatePropsUI();
updateHistoryUI();
updateZoomUI();
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
// frame content after layout has settled (skip if a saved view exists)
requestAnimationFrame(() => { if (!viewRestored) resetView(); });

// main loop — render only when dirty (60fps budget)
(function loop() {
  if (dirty) { dirty = false; render(); }
  requestAnimationFrame(loop);
})();
</script>
</body>
</html>