← Whiteboard results

Whiteboard

Ornith 1.5 35B A3B · 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 52,176 bytes · SHA-256 6f50a3161fa9
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Whiteboard</title>
<style>
/* ============================================================
   RESET & BASE
   ============================================================ */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
  width: 100%; height: 100%; overflow: hidden;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  background: #e8eaed; color: #1e1e1e; -webkit-font-smoothing: antialiased;
  user-select: none; -webkit-user-select: none;
}
#canvas { position: absolute; top: 0; left: 0; display: block; cursor: default; touch-action: none; }

/* ============================================================
   FLOATING TOOLBAR (left)
   ============================================================ */
.toolbar {
  position: absolute; left: 16px; top: 16px; z-index: 20;
  background: #ffffff; border-radius: 12px; padding: 6px;
  box-shadow: 0 2px 12px rgba(0,0,0,0.12), 0 0 0 1px rgba(0,0,0,0.04);
  display: flex; flex-direction: column; gap: 4px;
}
.tool-btn {
  width: 40px; height: 40px; border: none; background: transparent;
  border-radius: 9px; cursor: pointer; display: flex; align-items: center; justify-content: center;
  color: #4a4a4a; position: relative; transition: background 0.12s, color 0.12s;
}
.tool-btn:hover { background: #f0f1f3; color: #1e1e1e; }
.tool-btn.active { background: #e7f0ff; color: #1a71ff; }
.tool-btn svg { width: 22px; height: 22px; pointer-events: none; }
.tool-btn .shortcut {
  position: absolute; right: 4px; bottom: 2px; font-size: 9px; color: #b0b0b0; font-weight: 600;
}
.divider { height: 1px; background: #e8e8e8; margin: 4px 2px; }

/* ============================================================
   TOP BAR (title, undo/redo, reset view)
   ============================================================ */
.topbar {
  position: absolute; left: 72px; top: 16px; z-index: 20;
  display: flex; align-items: center; gap: 8px;
}
.brand { font-weight: 700; font-size: 15px; color: #1e1e1e; padding-right: 8px; letter-spacing: -0.2px; }
.icon-btn {
  width: 36px; height: 36px; border: none; background: #ffffff;
  border-radius: 9px; cursor: pointer; display: flex; align-items: center; justify-content: center;
  color: #4a4a4a; box-shadow: 0 2px 12px rgba(0,0,0,0.1), 0 0 0 1px rgba(0,0,0,0.04);
  transition: background 0.12s, color 0.12s;
}
.icon-btn:hover { background: #f0f1f3; color: #1e1e1e; }
.icon-btn:disabled { opacity: 0.35; cursor: default; background: #fff; }
.icon-btn:disabled:hover { color: #4a4a4a; }
.icon-btn svg { width: 20px; height: 20px; }

/* ============================================================
   PROPERTIES BAR (bottom)
   ============================================================ */
.propsbar {
  position: absolute; left: 50%; bottom: 16px; transform: translateX(-50%); z-index: 20;
  background: #ffffff; border-radius: 12px; padding: 8px 12px;
  box-shadow: 0 2px 12px rgba(0,0,0,0.12), 0 0 0 1px rgba(0,0,0,0.04);
  display: flex; align-items: center; gap: 16px;
}
.prop-group { display: flex; align-items: center; gap: 8px; }
.prop-label { font-size: 11px; color: #8a8a8a; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; }
.color-swatch {
  width: 24px; height: 24px; border-radius: 6px; border: 2px solid #e0e0e0; cursor: pointer;
  padding: 0; transition: transform 0.1s;
}
.color-swatch:hover { transform: scale(1.08); }
.color-swatch::-webkit-color-swatch-wrapper { padding: 0; }
.color-swatch::-webkit-color-swatch { border: none; border-radius: 4px; }
.color-swatch::-moz-color-swatch { border: none; border-radius: 4px; }
.width-slider { width: 90px; accent-color: #1a71ff; }
.width-val { font-size: 11px; color: #8a8a8a; min-width: 26px; text-align: center; }
.palette { display: flex; gap: 4px; }
.palette .swatch {
  width: 20px; height: 20px; border-radius: 5px; border: 2px solid transparent; cursor: pointer;
  transition: transform 0.1s, border-color 0.1s;
}
.palette .swatch:hover { transform: scale(1.1); }
.palette .swatch.active { border-color: #1a71ff; }

/* ============================================================
   ZOOM INDICATOR (bottom-right)
   ============================================================ */
.zoom-indicator {
  position: absolute; right: 16px; bottom: 16px; z-index: 20;
  background: #ffffff; border-radius: 9px; padding: 4px;
  box-shadow: 0 2px 12px rgba(0,0,0,0.1), 0 0 0 1px rgba(0,0,0,0.04);
  display: flex; align-items: center; gap: 2px;
}
.zoom-indicator .z-btn {
  width: 30px; height: 30px; border: none; background: transparent; cursor: pointer;
  color: #4a4a4a; font-size: 18px; line-height: 1; border-radius: 6px;
  display: flex; align-items: center; justify-content: center;
}
.zoom-indicator .z-btn:hover { background: #f0f1f3; color: #1e1e1e; }
.zoom-indicator .z-val {
  min-width: 54px; text-align: center; font-size: 12px; font-weight: 600; color: #4a4a4a;
  cursor: pointer;
}

/* ============================================================
   TEXT EDITING OVERLAY
   ============================================================ */
#text-edit {
  position: absolute; z-index: 30; display: none;
  background: rgba(255,255,255,0.0); border: 2px solid #1a71ff; border-radius: 4px;
  padding: 4px 6px; color: #1e1e1e; font: 16px -apple-system, "Segoe UI", sans-serif;
  outline: none; min-width: 40px; line-height: 1.3; overflow-wrap: break-word; white-space: pre-wrap;
  box-shadow: 0 4px 16px rgba(0,0,0,0.18);
}

/* ============================================================
   HELP HINT
   ============================================================ */
.hint {
  position: absolute; left: 50%; top: 16px; transform: translateX(-50%); z-index: 15;
  font-size: 12px; color: #9a9a9a; background: rgba(255,255,255,0.85); padding: 4px 12px;
  border-radius: 20px; pointer-events: none; transition: opacity 0.5s;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>

<!-- Inline SVG icon definitions referenced by toolbar -->
<div class="toolbar" id="toolbar">
  <button class="tool-btn active" data-tool="select" title="Select (V)">
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3l0 15 4.5-3.5 3 6 2.5-1.2-3-5.8L16 19l5-5z"/></svg>
    <span class="shortcut">V</span>
  </button>
  <button class="tool-btn" data-tool="pen" title="Freehand (P)">
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21c3-1 5-3 7-6L19 7l3 3-9 9c-3 2-5 4-6 7z"/><path d="M14 6l4 4"/></svg>
    <span class="shortcut">P</span>
  </button>
  <button class="tool-btn" data-tool="rectangle" title="Rectangle (R)">
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="4" y="5" width="16" height="14" rx="1.5"/></svg>
    <span class="shortcut">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="1.8"><ellipse cx="12" cy="12" rx="8" ry="6"/></svg>
    <span class="shortcut">O</span>
  </button>
  <button class="tool-btn" data-tool="arrow" title="Arrow (L)">
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h12M13 6l7 6-7 6"/></svg>
    <span class="shortcut">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="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 5h12M12 5v14"/><path d="M8 19h8"/></svg>
    <span class="shortcut">T</span>
  </button>
  <div class="divider"></div>
  <button class="tool-btn" data-tool="eraser" title="Eraser (E)">
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 14l5-5 6 6-5 5z"/><path d="M4 16l4-4M11 9l-7 7-1 3 3-1 8-8"/></svg>
    <span class="shortcut">E</span>
  </button>
</div>

<div class="topbar">
  <span class="brand">Whiteboard</span>
  <button class="icon-btn" id="undo-btn" title="Undo (Ctrl+Z)" disabled>
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M9 10l-5 5 5 5"/><path d="M4 15h10a6 6 0 0 1 0 12h-2"/></svg>
  </button>
  <button class="icon-btn" id="redo-btn" title="Redo (Ctrl+Shift+Z)" disabled>
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M15 10l5 5-5 5"/><path d="M20 15H10a6 6 0 0 0 0 12h2"/></svg>
  </button>
</div>

<div class="propsbar" id="propsbar">
  <div class="prop-group">
    <span class="prop-label">Stroke</span>
    <div class="palette" id="stroke-palette"></div>
    <input type="color" class="color-swatch" id="stroke-color" value="#1e1e1e" title="Custom stroke color">
  </div>
  <div class="prop-group">
    <span class="prop-label">Fill</span>
    <div class="palette" id="fill-palette"></div>
    <input type="color" class="color-swatch" id="fill-color" value="#ffffff" title="Custom fill color">
  </div>
  <div class="prop-group">
    <span class="prop-label">Size</span>
    <input type="range" class="width-slider" id="width-slider" min="1" max="24" value="3">
    <span class="width-val" id="width-val">3</span>
  </div>
</div>

<div class="zoom-indicator" id="zoom-indicator">
  <button class="z-btn" id="zoom-out" title="Zoom out">−</button>
  <span class="z-val" id="zoom-val" title="Click to reset">100%</span>
  <button class="z-btn" id="zoom-in" title="Zoom in">+</button>
</div>

<div class="hint" id="hint">Space + drag to pan · Scroll to zoom · Draw, select, move</div>

<div id="text-edit"></div>

<script>
/* ============================================================
   TUNABLE CONSTANTS
   ============================================================ */
const CONFIG = {
  // Palette (tasteful, hand-drawn-adjacent)
  colors: [
    '#1e1e1e', '#e03131', '#f0932b', '#2f9e44', '#1a71ff',
    '#6741d9', '#e8590c', '#495057', '#868e96', '#ffffff'
  ],
  // Viewport
  zoomMin: 0.25,
  zoomMax: 4.0,
  zoomStep: 0.1,
  // Interaction
  hitTolerance: 6,        // world px tolerance for clicking lines/borders
  handleSize: 8,           // handle size in screen px
  nudge: 1,                // arrow nudge px
  nudgeBig: 10,            // shift+arrow nudge px
  eraserRadius: 14,        // world px for eraser
  // Appearance
  strokeWidth: 3,
  defaultStroke: '#1e1e1e',
  defaultFill: '#ffffff',
  grid: { color: 'rgba(0,0,0,0.08)', size: 28 },
  dotColor: 'rgba(0,0,0,0.10)',
  // Persistence
  storageKey: 'whiteboard.scene.v1',
  historyLimit: 200
};

/* ============================================================
   UTILITIES
   ============================================================ */
let idCounter = 0;
function uid() { return 'o' + (++idCounter) + '_' + Date.now().toString(36); }

function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }

// Ensure a rectangle is normalized (top-left, positive w/h)
function normRect(x, y, w, h) {
  if (w < 0) { x += w; w = -w; }
  if (h < 0) { y += h; h = -h; }
  return { x, y, w, h };
}

// distance from point p to segment ab
function pointSegDist(p, a, b) {
  const dx = b.x - a.x, dy = b.y - a.y;
  const len2 = dx * dx + dy * dy;
  let t = len2 ? ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2 : 0;
  t = clamp(t, 0, 1);
  const cx = a.x + t * dx, cy = a.y + t * dy;
  return Math.hypot(p.x - cx, p.y - cy);
}

// min distance from point to a freehand stroke (list of points)
function strokeDist(pts, p) {
  let min = Infinity;
  for (let i = 1; i < pts.length; i++) {
    const d = pointSegDist(p, pts[i - 1], pts[i]);
    if (d < min) min = d;
  }
  return min;
}

// distance from point to rectangle border (for outline click)
function borderDist(rect, p) {
  const { x, y, w, h } = rect;
  const t = CONFIG.hitTolerance;
  // inside check
  if (p.x > x - t && p.x < x + w + t && p.y > y - t && p.y < y + h + t) {
    // distance to nearest edge
    const dLeft = Math.abs(p.x - x);
    const dRight = Math.abs(p.x - (x + w));
    const dTop = Math.abs(p.y - y);
    const dBottom = Math.abs(p.y - (y + h));
    return Math.min(dLeft, dRight, dTop, dBottom);
  }
  return Infinity;
}

// is point inside rectangle (filled)
function pointInRect(rect, p) {
  return p.x >= rect.x && p.x <= rect.x + rect.w && p.y >= rect.y && p.y <= rect.y + rect.h;
}

// is point inside ellipse (filled)
function pointInEllipse(ell, p) {
  const cx = ell.x + ell.w / 2, cy = ell.y + ell.h / 2;
  const rx = ell.w / 2, ry = ell.h / 2;
  if (rx <= 0 || ry <= 0) return false;
  const dx = (p.x - cx) / rx, dy = (p.y - cy) / ry;
  return dx * dx + dy * dy <= 1;
}

// distance from point to ellipse border
function ellipseBorderDist(ell, p) {
  const cx = ell.x + ell.w / 2, cy = ell.y + ell.h / 2;
  const rx = ell.w / 2, ry = ell.h / 2;
  if (rx <= 0 || ry <= 0) return Infinity;
  // transform to unit circle
  const dx = (p.x - cx) / rx, dy = (p.y - cy) / ry;
  const d = Math.hypot(dx, dy);
  if (d === 0) return Math.min(rx, ry);
  // nearest point on ellipse
  const nx = dx / d, ny = dy / d;
  const bx = cx + nx * rx, by = cy + ny * ry;
  return Math.hypot(p.x - bx, p.y - by);
}

// distance from point to arrow line
function arrowDist(arrow, p) {
  return pointSegDist(p, { x: arrow.x1, y: arrow.y1 }, { x: arrow.x2, y: arrow.y2 });
}

// bounding box of a list of objects (world coords)
function objectsBBox(objs) {
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
  for (const o of objs) {
    const b = bbox(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);
  }
  return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
}

// bounding box of a single object (world coords)
function bbox(o) {
  switch (o.type) {
    case 'rectangle': { const r = normRect(o.x, o.y, o.w, o.h); return r; }
    case 'ellipse': { const r = normRect(o.x, o.y, o.w, o.h); return r; }
    case 'arrow': {
      return { x: Math.min(o.x1, o.x2), y: Math.min(o.y1, o.y2),
               w: Math.abs(o.x2 - o.x1), h: Math.abs(o.y2 - o.y1) };
    }
    case 'text': {
      const cw = o.charW || 8, lh = o.lineH || 18;
      const lines = o.text.split('\n').length || 1;
      const maxW = Math.max(...o.text.split('\n').map(l => l.length)) * cw;
      return { x: o.x, y: o.y, w: maxW, h: lh * lines };
    }
    case 'freehand': {
      let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
      for (const p of o.points) { minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x);
        minY = Math.min(minY, p.y); maxY = Math.max(maxY, p.y); }
      return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
    }
  }
  return { x: 0, y: 0, w: 0, h: 0 };
}

/* ============================================================
   SCENE + HISTORY
   ============================================================ */
let scene = [];                 // array of object { id, type, ... }
let history = [];               // array of scenes
let histIndex = -1;             // current index into history
let redoStack = [];

function pushHistory() {
  // discard any redo branch
  redoStack = [];
  // prune history if too long
  if (history.length > CONFIG.historyLimit) history.shift();
  histIndex++;
  history.length = histIndex;
  history.push(JSON.parse(JSON.stringify(scene)));
}

function undo() {
  if (histIndex <= 0) return;
  histIndex--;
  redoStack.push(JSON.parse(JSON.stringify(scene)));
  scene = JSON.parse(JSON.stringify(history[histIndex]));
  clearSelection();
  refreshHistoryButtons();
  render(); persist();
}

function redo() {
  if (redoStack.length === 0) return;
  scene = redoStack.pop();
  histIndex++;
  history[histIndex] = JSON.parse(JSON.stringify(scene));
  history.length = histIndex + 1;
  clearSelection();
  refreshHistoryButtons();
  render(); persist();
}

function refreshHistoryButtons() {
  document.getElementById('undo-btn').disabled = histIndex <= 0;
  document.getElementById('redo-btn').disabled = redoStack.length === 0;
}

/* ============================================================
   PERSISTENCE (localStorage)
   ============================================================ */
function persist() {
  try { localStorage.setItem(CONFIG.storageKey, JSON.stringify(scene)); }
  catch (e) { /* quota or disabled */ }
}

function loadScene() {
  try {
    const raw = localStorage.getItem(CONFIG.storageKey);
    if (raw) {
      const parsed = JSON.parse(raw);
      if (Array.isArray(parsed) && parsed.length) { scene = parsed; return true; }
    }
  } catch (e) {}
  return false;
}

/* ============================================================
   VIEWPORT (pan / zoom)
   ============================================================ */
const viewport = { x: 0, y: 0, scale: 1 };   // translation + scale

// convert screen -> world
function screenToWorld(sx, sy) {
  return { x: (sx - viewport.x) / viewport.scale, y: (sy - viewport.y) / viewport.scale };
}
// convert world -> screen
function worldToScreen(wx, wy) {
  return { x: wx * viewport.scale + viewport.x, y: wy * viewport.scale + viewport.y };
}

// zoom centered on a screen point (keeps world point under cursor fixed)
function zoomAt(screenX, screenY, newScale) {
  newScale = clamp(newScale, CONFIG.zoomMin, CONFIG.zoomMax);
  const w = screenToWorld(screenX, screenY);
  viewport.scale = newScale;
  viewport.x = screenX - w.x * newScale;
  viewport.y = screenY - w.y * newScale;
  updateZoomDisplay();
  render();
}

function resetView() {
  viewport.x = 0; viewport.y = 0; viewport.scale = 1;
  updateZoomDisplay(); render();
}

function updateZoomDisplay() {
  document.getElementById('zoom-val').textContent = Math.round(viewport.scale * 100) + '%';
}

/* ============================================================
   CANVAS SETUP & DPR
   ============================================================ */
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
let dpr = window.devicePixelRatio || 1;

function resizeCanvas() {
  dpr = window.devicePixelRatio || 1;
  canvas.width = Math.floor(window.innerWidth * dpr);
  canvas.height = Math.floor(window.innerHeight * dpr);
  canvas.style.width = window.innerWidth + 'px';
  canvas.style.height = window.innerHeight + 'px';
  render();
}
window.addEventListener('resize', resizeCanvas);

/* ============================================================
   RENDERING
   ============================================================ */
function render() {
  const w = canvas.width, h = canvas.height;
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);   // work in CSS px
  // background
  ctx.fillStyle = '#f6f6f7';
  ctx.fillRect(0, 0, w, h);

  drawGrid();

  // enter world space
  ctx.save();
  ctx.translate(viewport.x, viewport.y);
  ctx.scale(viewport.scale, viewport.scale);

  // draw all objects
  for (const o of scene) drawObject(o);

  // draw live preview while creating
  if (previewObj) drawObject(previewObj);

  // rubber-band selection rectangle
  if (rubber.active) {
    const a = screenToWorld(rubber.x0, rubber.y0);
    const b = screenToWorld(rubber.x1, rubber.y1);
    const r = normRect(a.x, a.y, b.x - a.x, b.y - a.y);
    ctx.save();
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // back to screen space for the overlay
    ctx.fillStyle = 'rgba(26,113,255,0.08)';
    ctx.strokeStyle = 'rgba(26,113,255,0.6)';
    ctx.lineWidth = 1;
    const s1 = worldToScreen(r.x, r.y), s2 = worldToScreen(r.x + r.w, r.y + r.h);
    ctx.fillRect(s1.x, s1.y, s2.x - s1.x, s2.y - s1.y);
    ctx.strokeRect(s1.x, s1.y, s2.x - s1.x, s2.y - s1.y);
    ctx.restore();
    ctx.restore(); // world space
    return;
  }

  ctx.restore(); // world space

  // selection overlay (screen space)
  if (selected.size) drawSelectionOverlay();
}

// draw the optional dot grid that pans/zooms with world
function drawGrid() {
  const w = window.innerWidth, h = window.innerHeight;
  const step = CONFIG.grid.size * viewport.scale;
  if (step < 10) return; // hide when too zoomed out
  const ox = (viewport.x % step + step) % step;
  const oy = (viewport.y % step + step) % step;
  ctx.fillStyle = CONFIG.dotColor;
  for (let x = ox; x < w; x += step) {
    for (let y = oy; y < h; y += step) {
      ctx.beginPath();
      ctx.arc(x, y, 1, 0, Math.PI * 2);
      ctx.fill();
    }
  }
}

function applyStyle(o) {
  ctx.lineWidth = o.strokeWidth;
  ctx.strokeStyle = o.stroke;
  ctx.fillStyle = o.fill;
  ctx.lineCap = 'round';
  ctx.lineJoin = 'round';
}

function drawObject(o) {
  switch (o.type) {
    case 'rectangle': return drawRectangle(o);
    case 'ellipse': return drawEllipse(o);
    case 'arrow': return drawArrow(o);
    case 'text': return drawText(o);
    case 'freehand': return drawFreehand(o);
  }
}

function drawRectangle(o) {
  const r = normRect(o.x, o.y, o.w, o.h);
  applyStyle(o);
  if (o.fill && o.fill !== 'transparent' && r.w > 0 && r.h > 0) {
    ctx.beginPath();
    roundRect(r.x, r.y, r.w, r.h, 3);
    ctx.fill();
  }
  if (r.w > 0 && r.h > 0) {
    ctx.beginPath();
    roundRect(r.x, r.y, r.w, r.h, 3);
    ctx.stroke();
  }
}

function roundRect(x, y, w, h, r) {
  r = Math.min(r, w / 2, h / 2);
  ctx.moveTo(x + r, y);
  ctx.arcTo(x + w, y, x + w, y + h, r);
  ctx.arcTo(x + w, y + h, x, y + h, r);
  ctx.arcTo(x, y + h, x, y, r);
  ctx.arcTo(x, y, x + w, y, r);
  ctx.closePath();
}

function drawEllipse(o) {
  const r = normRect(o.x, o.y, o.w, o.h);
  applyStyle(o);
  if (r.w > 1 && r.h > 1) {
    const cx = r.x + r.w / 2, cy = r.y + r.h / 2;
    const rx = r.w / 2, ry = r.h / 2;
    ctx.beginPath();
    ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
    if (o.fill && o.fill !== 'transparent') ctx.fill();
    ctx.stroke();
  }
}

function drawArrow(o) {
  applyStyle(o);
  ctx.beginPath();
  ctx.moveTo(o.x1, o.y1);
  ctx.lineTo(o.x2, o.y2);
  ctx.stroke();
  // arrowhead
  const ang = Math.atan2(o.y2 - o.y1, o.x2 - o.x1);
  const len = 12 + o.strokeWidth * 2;
  ctx.fillStyle = o.stroke;
  ctx.beginPath();
  ctx.moveTo(o.x2, o.y2);
  ctx.lineTo(o.x2 - len * Math.cos(ang - Math.PI / 7), o.y2 - len * Math.sin(ang - Math.PI / 7));
  ctx.lineTo(o.x2 - len * Math.cos(ang + Math.PI / 7), o.y2 - len * Math.sin(ang + Math.PI / 7));
  ctx.closePath();
  ctx.fill();
}

function drawText(o) {
  const cw = o.charW || 8, lh = o.lineH || 18;
  ctx.font = `${lh}px -apple-system, "Segoe UI", sans-serif`;
  ctx.textBaseline = 'top';
  ctx.fillStyle = o.stroke;
  ctx.textAlign = 'left';
  const lines = o.text.split('\n');
  for (let i = 0; i < lines.length; i++) {
    ctx.fillText(lines[i], o.x, o.y + i * lh);
  }
}

function drawFreehand(o) {
  applyStyle(o);
  const pts = o.points;
  if (pts.length < 2) return;
  ctx.beginPath();
  ctx.moveTo(pts[0].x, pts[0].y);
  if (pts.length === 2) {
    ctx.lineTo(pts[1].x, pts[1].y);
  } else {
    for (let i = 1; i < pts.length - 1; i++) {
      const mx = (pts[i].x + pts[i + 1].x) / 2;
      const 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();
}

/* ============================================================
   SELECTION OVERLAY (bounding box + handles)
   ============================================================ */
function drawSelectionOverlay() {
  const selected = [...selectedArr];
  const box = selected.length === 1 ? bbox(selected[0]) : objectsBBox(selected);
  const s = worldToScreen(box.x, box.y);
  const e = worldToScreen(box.x + box.w, box.y + box.h);
  const wpx = e.x - s.x, hpx = e.y - s.y;

  // bounding box
  ctx.strokeStyle = '#1a71ff';
  ctx.lineWidth = 1.5;
  ctx.strokeRect(s.x, s.y, wpx, hpx);

  // 4 corner resize handles (screen space)
  const hs = CONFIG.handleSize;
  const corners = [
    [s.x, s.y], [s.x + wpx, s.y], [s.x, s.y + hpx], [s.x + wpx, s.y + hpx]
  ];
  for (const [cx, cy] of corners) {
    ctx.fillStyle = '#ffffff';
    ctx.strokeStyle = '#1a71ff';
    ctx.lineWidth = 1.5;
    ctx.beginPath();
    ctx.rect(cx - hs / 2, cy - hs / 2, hs, hs);
    ctx.fill();
    ctx.stroke();
  }
  // store overlay geometry for hit-testing
  overlayRect = { x: s.x, y: s.y, w: wpx, h: hpx, corners };
}
let overlayRect = null;

/* ============================================================
   HIT-TESTING (world coordinates)
   ============================================================ */
function hitTest(world, ignoreObj) {
  const t = CONFIG.hitTolerance / viewport.scale; // world-space tolerance
  // iterate top-to-bottom (reverse): return first (topmost) hit
  for (let i = scene.length - 1; i >= 0; i--) {
    const o = scene[i];
    if (o === ignoreObj) continue;
    if (hitObject(o, world, t)) return o;
  }
  return null;
}

function hitObject(o, p, t) {
  switch (o.type) {
    case 'rectangle': {
      if (pointInRect(o, p)) return true;
      if (borderDist(o, p) <= t) return true;
      return false;
    }
    case 'ellipse': {
      if (pointInEllipse(o, p)) return true;
      if (ellipseBorderDist(o, p) <= t) return true;
      return false;
    }
    case 'arrow': {
      return arrowDist(o, p) <= t;
    }
    case 'freehand': {
      const b = bbox(o);
      if (p.x < b.x - t || p.x > b.x + b.w + t || p.y < b.y - t || p.y > b.y + b.h + t) return false;
      return strokeDist(o.points, p) <= t;
    }
    case 'text': {
      const b = bbox(o);
      return p.x >= b.x - t && p.x <= b.x + b.w + t && p.y >= b.y - t && p.y <= b.y + b.h + t;
    }
  }
  return false;
}

/* ============================================================
   SELECTION STATE
   ============================================================ */
let selected = new Set();      // set of object references
let selectedArr = [];
function selectedArrCache() { return [...selected]; }
function clearSelection() {
  selected.clear();
  selectedArr = [];
  overlayRect = null;
}
function updateSelectedArr() { selectedArr = [...selected]; }

/* ============================================================
   INTERACTION STATE
   ============================================================ */
let currentTool = 'select';
let isPanning = false;
let spaceDown = false;
let mouseDown = false;
let lastMouse = { x: 0, y: 0 };

// creation preview
let previewObj = null;
let previewStart = null;

// dragging (move/resize/eraser)
let dragMode = null; // 'move' | 'resize' | 'eraser'
let dragStartWorld = null;
let dragStartObjects = null; // snapshot for undo
let resizeCorner = -1;       // 0: tl, 1: tr, 2: bl, 3: br
let resizeStartBoxes = null;
let shiftHeld = false;

// rubber band
let rubber = { active: false, x0: 0, y0: 0, x1: 0, y1: 0 };

// panning buffer
let pendingPan = false;

const el = {
  canvas: document.getElementById('canvas'),
  textEdit: document.getElementById('text-edit'),
  toolbar: document.getElementById('toolbar')
};

/* ============================================================
   POINTER EVENTS
   ============================================================ */
el.canvas.addEventListener('pointerdown', onPointerDown);
el.canvas.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
el.canvas.addEventListener('wheel', onWheel, { passive: false });

function getPointerPos(e) {
  const rect = el.canvas.getBoundingClientRect();
  return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}

function onPointerDown(e) {
  // hide hint after first interaction
  const hint = document.getElementById('hint');
  if (hint) hint.style.opacity = '0';

  // Clear any leaked preview state from a previous interaction
  previewObj = null;
  previewStart = null;

  const pos = getPointerPos(e);
  el.canvas.setPointerCapture(e.pointerId);
  lastMouse = { ...pos };
  mouseDown = true;

  // --- Panning: space held or middle button ---
  if (spaceDown || e.button === 1) {
    isPanning = true;
    return;
  }

  if (e.button !== 0) return; // only left button for tools

  const world = screenToWorld(pos.x, pos.y);

  switch (currentTool) {
    case 'select': handleSelectDown(pos, world, e); break;
    case 'pen': handlePenDown(pos, world); break;
    case 'rectangle': handleShapeDown(world); break;
    case 'ellipse': handleShapeDown(world); break;
    case 'arrow': handleShapeDown(world); break;
    case 'text': handleTextDown(world, pos); break;
    case 'eraser': handleEraserDown(world); break;
  }
}

function handleSelectDown(pos, world, e) {
  // check resize handle first (only if something selected & tool is select)
  if (selectedArr.length && overlayRect) {
    const corner = hitCorner(pos);
    if (corner >= 0) {
      resizeCorner = corner;
      dragMode = 'resize';
      // snapshot start boxes
      resizeStartBoxes = selectedArr.map(o => bbox(o));
      dragStartWorld = world;
      return;
    }
  }

  // hit-test (ignore already-selected unless shift)
  const hit = hitTest(world, null);

  if (hit) {
    if (e.shiftKey && selected.has(hit)) {
      // deselect if already selected & shift
      selected.delete(hit);
      updateSelectedArr();
      render();
      return;
    }
    if (!e.shiftKey && !selected.has(hit)) {
      selected.clear();
    }
    selected.add(hit);
    updateSelectedArr();
    dragMode = 'move';
    // snapshot objects (world positions) at drag start for single-step undo
    dragStartObjects = selectedArr.map(o => JSON.parse(JSON.stringify(o)));
    dragStartWorld = world;
    render();
  } else {
    // start rubber band
    clearSelection();
    rubber.active = true;
    rubber.x0 = pos.x; rubber.y0 = pos.y;
    rubber.x1 = pos.x; rubber.y1 = pos.y;
  }
}

function handlePenDown(pos, world) {
  previewObj = {
    type: 'freehand',
    points: [{ x: world.x, y: world.y }],
    stroke: currentStroke(), fill: 'transparent',
    strokeWidth: currentWidth()
  };
  lastPenWorld = { ...world };
}

function handleShapeDown(world) {
  const type = currentTool;
  previewObj = makeShapePreview(type, world);
  previewStart = { ...world };
}

function handleTextDown(world, pos) {
  const text = {
    id: uid(), type: 'text',
    x: world.x, y: world.y,
    text: '', stroke: currentStroke(), fill: 'transparent',
    strokeWidth: 16, charW: 8, lineH: 18
  };
  scene.push(text);
  pushHistory();
  persist();
  openTextEditor(text, pos);
}

function handleEraserDown(world) {
  dragMode = 'eraser';
  eraserTargets = new Set();
  eraseNear(world);
}
let eraserTargets = null;

/* ============================================================
   POINTER MOVE
   ============================================================ */
function onPointerMove(e) {
  const pos = getPointerPos(e);
  const world = screenToWorld(pos.x, pos.y);

  if (isPanning) {
    viewport.x += pos.x - lastMouse.x;
    viewport.y += pos.y - lastMouse.y;
    lastMouse = { ...pos };
    render();
    return;
  }

  if (mouseDown && dragMode === 'move') {
    const dx = world.x - dragStartWorld.x;
    const dy = world.y - dragStartWorld.y;
    for (let i = 0; i < selectedArr.length; i++) {
      const o = selectedArr[i];
      const start = dragStartObjects[i];
      moveObject(o, start, dx, dy);
    }
    render();
    lastMouse = { ...pos };
    return;
  }

  if (mouseDown && dragMode === 'resize') {
    performResize(world);
    render();
    lastMouse = { ...pos };
    return;
  }

  if (mouseDown && dragMode === 'eraser') {
    eraseNear(world);
    render();
    lastMouse = { ...pos };
    return;
  }

  // live previews
  if (mouseDown && previewObj) {
    updatePreview(world);
    render();
  }

  if (rubber.active) {
    rubber.x1 = pos.x; rubber.y1 = pos.y;
    render();
  }

  // update cursor for select tool
  updateCursor(pos);
  lastMouse = { ...pos };
}

function moveObject(o, start, dx, dy) {
  switch (o.type) {
    case 'rectangle': case 'ellipse':
      o.x = start.x + dx; o.y = start.y + dy; break;
    case 'arrow':
      o.x1 = start.x1 + dx; o.y1 = start.y1 + dy;
      o.x2 = start.x2 + dx; o.y2 = start.y2 + dy; break;
    case 'freehand':
      for (let i = 0; i < o.points.length; i++) {
        o.points[i].x = start.points[i].x + dx;
        o.points[i].y = start.points[i].y + dy;
      }
      break;
    case 'text':
      o.x = start.x + dx; o.y = start.y + dy; break;
  }
}

function performResize(world) {
  if (selectedArr.length !== 1) return;
  const o = selectedArr[0];
  const start = resizeStartBoxes[0];
  const corner = resizeCorner;
  const newBox = normRect(start.x, start.y, start.w, start.h);

  // The dragged corner moves to world point (world.x, world.y).
  // Other corners stay fixed. Compute new box, then remap object points.
  let nx = newBox.x, ny = newBox.y, nw = newBox.w, nh = newBox.h;

  // corner constraints: 0=tl,1=tr,2=bl,3=br
  if (corner === 0 || corner === 2) { nx = world.x; nw = start.x + start.w - world.x; }
  if (corner === 0 || corner === 1) { ny = world.y; nh = start.y + start.h - world.y; }
  if (corner === 1 || corner === 3) { nw = world.x - start.x; }
  if (corner === 2 || corner === 3) { nh = world.y - start.y; }

  if (shiftHeld) {
    const s = Math.max(nw, nh);
    nw = s; nh = s;
  }
  nw = Math.max(8, nw); nh = Math.max(8, nh);

  const target = { x: nx, y: ny, w: nw, h: nh };
  applyResize(o, start, target);
}

function applyResize(o, startBox, targetBox) {
  switch (o.type) {
    case 'rectangle': case 'ellipse': {
      o.x = targetBox.x; o.y = targetBox.y; o.w = targetBox.w; o.h = targetBox.h;
      break;
    }
    case 'arrow': {
      // scale endpoints relative to box
      const sx = targetBox.w / startBox.w, sy = targetBox.h / startBox.h;
      const rx0 = o.x1 - startBox.x, ry0 = o.y1 - startBox.y;
      const rx1 = o.x2 - startBox.x, ry1 = o.y2 - startBox.y;
      o.x1 = targetBox.x + rx0 * sx; o.y1 = targetBox.y + ry0 * sy;
      o.x2 = targetBox.x + rx1 * sx; o.y2 = targetBox.y + ry1 * sy;
      break;
    }
    case 'freehand': {
      const sx = startBox.w ? targetBox.w / startBox.w : 0;
      const sy = startBox.h ? targetBox.h / startBox.h : 0;
      for (let i = 0; i < o.points.length; i++) {
        o.points[i].x = targetBox.x + (o.points[i].x - startBox.x) * sx;
        o.points[i].y = targetBox.y + (o.points[i].y - startBox.y) * sy;
      }
      break;
    }
    case 'text': {
      const sx = startBox.w ? targetBox.w / startBox.w : 0;
      const sy = startBox.h ? targetBox.h / startBox.h : 0;
      o.x = targetBox.x + (o.x - startBox.x) * sx;
      o.y = targetBox.y + (o.y - startBox.y) * sy;
      o.charW = (o.charW || 8) * sx;
      o.lineH = (o.lineH || 18) * sy;
      break;
    }
  }
}

function updatePreview(world) {
  if (previewObj.type === 'freehand') {
    // add point if moved beyond sampling distance
    const last = previewObj.points[previewObj.points.length - 1];
    if (Math.hypot(world.x - last.x, world.y - last.y) > 3 / viewport.scale) {
      previewObj.points.push({ x: world.x, y: world.y });
    }
  } else {
    // shapes & arrow: live rect from previewStart to world
    const a = previewStart, b = world;
    if (currentTool === 'arrow') {
      previewObj.x1 = a.x; previewObj.y1 = a.y;
      previewObj.x2 = b.x; previewObj.y2 = b.y;
    } else {
      previewObj.x = a.x; previewObj.y = a.y;
      previewObj.w = b.x - a.x; previewObj.h = b.y - a.y;
      if (shiftHeld && previewObj.w !== 0 && previewObj.h !== 0) {
        const s = Math.max(Math.abs(previewObj.w), Math.abs(previewObj.h));
        previewObj.w = Math.sign(previewObj.w || 1) * s;
        previewObj.h = Math.sign(previewObj.h || 1) * s;
      }
    }
  }
}

function makeShapePreview(type, world) {
  const base = {
    type, stroke: currentStroke(), fill: currentFill(), strokeWidth: currentWidth()
  };
  if (type === 'arrow') {
    return { ...base, x1: world.x, y1: world.y, x2: world.x, y2: world.y };
  }
  return { ...base, x: world.x, y: world.y, w: 0, h: 0 };
}

/* ============================================================
   POINTER UP
   ============================================================ */
function onPointerUp(e) {
  if (isPanning) { isPanning = false; return; }

  if (dragMode === 'move') {
    // commit to history only if the objects actually moved
    let moved = false;
    for (let i = 0; i < selectedArr.length; i++) {
      const o = selectedArr[i], s = dragStartObjects[i];
      if (o !== s && JSON.stringify(o) !== JSON.stringify(s)) { moved = true; break; }
    }
    if (moved) {
      pushHistory(); persist(); refreshHistoryButtons();
    }
    updateSelectedArr();
    render();
  }
  if (dragMode === 'resize') {
    let resized = false;
    for (let i = 0; i < selectedArr.length; i++) {
      const o = selectedArr[i], b = bbox(o), sb = resizeStartBoxes[i];
      if (JSON.stringify({ x: b.x, y: b.y, w: b.w, h: b.h }) !==
          JSON.stringify({ x: sb.x, y: sb.y, w: sb.w, h: sb.h })) { resized = true; break; }
    }
    if (resized) {
      pushHistory(); persist(); refreshHistoryButtons();
    }
    updateSelectedArr();
    render();
  }
  if (dragMode === 'eraser') {
    if (eraserTargets && eraserTargets.size) {
      scene = scene.filter(o => !eraserTargets.has(o));
      clearSelection();
      pushHistory();
      persist();
      refreshHistoryButtons();
    }
    render();
  }
  if (rubber.active) {
    commitRubberBand();
  }
  if (previewObj) {
    // Ensure the preview reflects the final drag position before committing
    if (previewObj.type === 'freehand' || previewStart) {
      const up = screenToWorld(e.clientX, e.clientY);
      updatePreview(up);
    }
    commitPreview();
    previewObj = null;
    previewStart = null;
  }

  dragMode = null; resizeCorner = -1; dragStartWorld = null;
  dragStartObjects = null; resizeStartBoxes = null;
  rubber = { active: false, x0: 0, y0: 0, x1: 0, y1: 0 };
  mouseDown = false;
  updateCursor();
}

function commitPreview() {
  if (!previewObj) return;
  const valid = isValidCreation(previewObj);
  if (valid) {
    previewObj.id = uid();
    scene.push(previewObj);
    pushHistory();
    persist();
    refreshHistoryButtons();
  }
}

function isValidCreation(o) {
  if (o.type === 'freehand') return o.points.length >= 2;
  if (o.type === 'arrow') return Math.hypot(o.x2 - o.x1, o.y2 - o.y1) > 5 / viewport.scale;
  const b = bbox(o);
  return b.w > 5 / viewport.scale || b.h > 5 / viewport.scale;
}

function commitRubberBand() {
  const a = screenToWorld(rubber.x0, rubber.y0);
  const b = screenToWorld(rubber.x1, rubber.y1);
  const sel = normRect(a.x, a.y, b.x - a.x, b.y - a.y);
  const t = 4 / viewport.scale;
  const hits = scene.filter(o => {
    const bb = bbox(o);
    return bb.x >= sel.x - t && bb.y >= sel.y - t &&
           bb.x + bb.w <= sel.x + sel.w + t && bb.y + bb.h <= sel.y + sel.h + t;
  });
  if (hits.length) {
    selected = new Set(hits);
    updateSelectedArr();
    render();
  }
}

function eraseNear(world) {
  const r = CONFIG.eraserRadius / viewport.scale;
  for (const o of scene) {
    const b = bbox(o);
    if (world.x < b.x - r || world.x > b.x + b.w + r ||
        world.y < b.y - r || world.y > b.y + b.h + r) continue;
    let hit = false;
    if (o.type === 'freehand') {
      hit = strokeDist(o.points, world) <= r;
    } else {
      hit = hitObject(o, world, 0);
    }
    if (hit) eraserTargets.add(o);
  }
  // redraw eraser cursor region via a quick render handled by caller
}

/* ============================================================
   CORNER HIT (resize handles)
   ============================================================ */
function hitCorner(pos) {
  if (!overlayRect) return -1;
  const hs = CONFIG.handleSize;
  const t = hs / 2 + 4;
  const corners = overlayRect.corners;
  for (let i = 0; i < corners.length; i++) {
    const [cx, cy] = corners[i];
    if (Math.abs(pos.x - cx) <= t && Math.abs(pos.y - cy) <= t) return i;
  }
  return -1;
}

/* ============================================================
   KEYBOARD
   ============================================================ */
window.addEventListener('keydown', (e) => {
  const editing = el.textEdit.style.display !== 'none';
  if (e.key === ' ') {
    if (!spaceDown) { spaceDown = true; el.canvas.style.cursor = 'grab'; }
    e.preventDefault();
    return;
  }
  if (editing) return; // let text editor handle typing

  if (e.ctrlKey || e.metaKey) {
    if (e.key === 'z' || e.key === 'Z') {
      e.preventDefault();
      if (e.shiftKey) redo(); else undo();
    } else if (e.key === 'y' || e.key === 'Y') {
      e.preventDefault(); redo();
    }
    return;
  }

  switch (e.key) {
    case 'v': case 'V': setTool('select'); break;
    case 'p': case 'P': setTool('pen'); break;
    case 'r': case 'R': setTool('rectangle'); break;
    case 'o': case 'O': setTool('ellipse'); break;
    case 'l': case 'L': setTool('arrow'); break;
    case 't': case 'T': setTool('text'); break;
    case 'e': case 'E': setTool('eraser'); break;
    case 'Delete': case 'Backspace': deleteSelection(); break;
    case 'Escape':
      closeTextEditor();
      clearSelection(); render();
      break;
    case 'ArrowUp': nudge(0, -1, e.shiftKey); break;
    case 'ArrowDown': nudge(0, 1, e.shiftKey); break;
    case 'ArrowLeft': nudge(-1, 0, e.shiftKey); break;
    case 'ArrowRight': nudge(1, 0, e.shiftKey); break;
  }
});

window.addEventListener('keyup', (e) => {
  if (e.key === ' ') {
    spaceDown = false;
    if (!isPanning) updateCursor();
  }
  if (e.key === 'Shift') shiftHeld = false;
});
window.addEventListener('blur', () => { spaceDown = false; shiftHeld = false; });

el.canvas.addEventListener('dblclick', (e) => {
  if (currentTool !== 'select') return;
  const pos = getPointerPos(e);
  const world = screenToWorld(pos.x, pos.y);
  const hit = hitTest(world, null);
  if (hit && hit.type === 'text') {
    const rect = worldToScreen(hit.x, hit.y);
    openTextEditor(hit, { x: rect.x, y: rect.y });
  }
});

document.addEventListener('keydown', (e) => { if (e.key === 'Shift') shiftHeld = true; });
document.addEventListener('keyup', (e) => { if (e.key === 'Shift') shiftHeld = false; });

function nudge(dx, dy, big) {
  if (!selectedArr.length) return;
  const step = big ? CONFIG.nudgeBig : CONFIG.nudge;
  const sdx = dx * step, sdy = dy * step;
  pushHistory();
  for (const o of selectedArr) {
    moveObject(o, JSON.parse(JSON.stringify(o)), sdx, sdy);
  }
  render(); persist(); refreshHistoryButtons();
}

function deleteSelection() {
  if (!selectedArr.length) return;
  pushHistory();
  scene = scene.filter(o => !selected.has(o));
  clearSelection();
  render(); persist(); refreshHistoryButtons();
}

/* ============================================================
   WHEEL ZOOM (toward cursor)
   ============================================================ */
function onWheel(e) {
  e.preventDefault();
  const pos = getPointerPos(e);
  const delta = -e.deltaY;
  const factor = delta > 0 ? 1 + CONFIG.zoomStep : 1 - CONFIG.zoomStep;
  zoomAt(pos.x, pos.y, viewport.scale * factor);
}

/* ============================================================
   TEXT EDITOR OVERLAY
   ============================================================ */
let editingText = null;
function openTextEditor(obj, screenPos) {
  editingText = obj;
  const te = el.textEdit;
  te.textContent = obj.text || '';
  te.style.display = 'block';
  te.style.left = screenPos.x + 'px';
  te.style.top = screenPos.y + 'px';
  te.style.color = obj.stroke;
  te.style.fontSize = Math.max(10, 16 * viewport.scale) + 'px';
  te.focus();
  // set cursor at end
  window.getSelection().selectAllChildren(te);
}

function commitText() {
  const te = el.textEdit;
  const obj = editingText;
  if (obj) {
    const raw = te.textContent || '';
    if (raw !== obj.text) {
      obj.text = raw;
      pushHistory();
      persist();
      refreshHistoryButtons();
    }
    // measure
    measureTextDimensions(obj);
  }
  editingText = null;
  te.style.display = 'none';
  render();
}

function closeTextEditor() {
  if (editingText) commitText();
  editingText = null;
  el.textEdit.style.display = 'none';
}

function measureTextDimensions(obj) {
  // approximate char width based on font
  const tmp = document.createElement('span');
  tmp.style.cssText = 'position:absolute;left:-9999px;top:-9999px;font:18px -apple-system,"Segoe UI",sans-serif;white-space:pre;';
  tmp.textContent = 'm'.repeat(50);
  document.body.appendChild(tmp);
  const w = tmp.getBoundingClientRect().width / 50;
  tmp.remove();
  obj.charW = w;
  obj.lineH = w * 1.2;
}

el.textEdit.addEventListener('keydown', (e) => {
  if (e.key === 'Enter' && !e.shiftKey) {
    e.preventDefault();
    commitText();
  } else if (e.key === 'Escape') {
    e.preventDefault();
    editingText = null; // discard
    el.textEdit.style.display = 'none';
  } else {
    // allow default typing
    return;
  }
});
el.textEdit.addEventListener('blur', () => { commitText(); });

/* ============================================================
   TOOLS UI
   ============================================================ */
function setTool(tool) {
  currentTool = tool;
  document.querySelectorAll('.tool-btn').forEach(b => {
    b.classList.toggle('active', b.dataset.tool === tool);
  });
  el.canvas.style.cursor = tool === 'text' ? 'text' :
    (tool === 'eraser' ? 'cell' : 'default');
  if (tool !== 'select') { closeTextEditor(); clearSelection(); render(); }
  updateCursor();
}

document.querySelectorAll('.tool-btn').forEach(btn => {
  btn.addEventListener('click', () => setTool(btn.dataset.tool));
});

/* ============================================================
   PROPERTIES BAR
   ============================================================ */
function currentStroke() { return document.getElementById('stroke-color').value; }
function currentFill() {
  const v = document.getElementById('fill-color').value;
  return v;
}
function currentWidth() { return parseInt(document.getElementById('width-slider').value, 10); }

// build palettes
function buildPalettes() {
  const sp = document.getElementById('stroke-palette');
  const fp = document.getElementById('fill-palette');
  CONFIG.colors.forEach(c => {
    const s = document.createElement('button');
    s.type = 'button';
    s.className = 'swatch';
    s.style.background = c;
    s.title = c;
    s.addEventListener('click', () => {
      document.getElementById('stroke-color').value = c;
      updatePaletteActive('stroke');
      applyToSelection();
    });
    sp.appendChild(s);

    const f = document.createElement('button');
    f.type = 'button';
    f.className = 'swatch';
    f.style.background = c;
    f.title = c;
    f.addEventListener('click', () => {
      document.getElementById('fill-color').value = c;
      updatePaletteActive('fill');
      applyToSelection();
    });
    fp.appendChild(f);
  });
  updatePaletteActive('stroke');
  updatePaletteActive('fill');
}

function updatePaletteActive(kind) {
  const val = kind === 'stroke' ? currentStroke() : currentFill();
  const paletteId = kind === 'stroke' ? 'stroke-palette' : 'fill-palette';
  document.querySelectorAll('#' + paletteId + ' .swatch').forEach(s => {
    s.classList.toggle('active', s.title.toLowerCase() === val.toLowerCase());
  });
}

function applyToSelection() {
  if (!selectedArr.length) return;
  pushHistory();
  const stroke = currentStroke();
  const fill = currentFill();
  const width = currentWidth();
  for (const o of selectedArr) {
    o.stroke = stroke;
    o.fill = fill;
    if (o.type !== 'text') o.strokeWidth = width;
  }
  render(); persist(); refreshHistoryButtons();
}

// color inputs
document.getElementById('stroke-color').addEventListener('input', (e) => {
  updatePaletteActive('stroke');
  if (selectedArr.length) { applyToSelection(); }
});
document.getElementById('fill-color').addEventListener('input', (e) => {
  updatePaletteActive('fill');
  if (selectedArr.length) { applyToSelection(); }
});
const widthSlider = document.getElementById('width-slider');
const widthVal = document.getElementById('width-val');
widthSlider.addEventListener('input', () => {
  widthVal.textContent = widthSlider.value;
  if (selectedArr.length) { applyToSelection(); }
});

// set initial palette values
document.getElementById('stroke-color').value = CONFIG.defaultStroke;
document.getElementById('fill-color').value = CONFIG.defaultFill;
widthSlider.value = CONFIG.strokeWidth;
widthVal.textContent = CONFIG.strokeWidth;

// apply property changes to selection on change (commit)
document.getElementById('stroke-color').addEventListener('change', () => applyToSelection());
document.getElementById('fill-color').addEventListener('change', () => applyToSelection());

/* ============================================================
   ZOOM CONTROLS
   ============================================================ */
document.getElementById('zoom-in').addEventListener('click', () => {
  zoomAt(window.innerWidth / 2, window.innerHeight / 2, viewport.scale * (1 + CONFIG.zoomStep));
});
document.getElementById('zoom-out').addEventListener('click', () => {
  zoomAt(window.innerWidth / 2, window.innerHeight / 2, viewport.scale * (1 - CONFIG.zoomStep));
});
document.getElementById('zoom-val').addEventListener('click', resetView);
document.getElementById('undo-btn').addEventListener('click', undo);
document.getElementById('redo-btn').addEventListener('click', redo);

// prevent zoom via trackpad pinch default (we handle wheel)
canvas.addEventListener('touchstart', () => {}, { passive: true });

/* ============================================================
   CURSOR HELPERS
   ============================================================ */
function updateCursor(pos) {
  if (spaceDown) { el.canvas.style.cursor = 'grab'; return; }
  if (currentTool === 'text') { el.canvas.style.cursor = 'text'; return; }
  if (currentTool === 'eraser') { el.canvas.style.cursor = 'cell'; return; }
  if (currentTool === 'select') {
    if (!pos) return;
    if (selectedArr.length && overlayRect && hitCorner(pos) >= 0) {
      const c = hitCorner(pos);
      if (c === 0 || c === 3) el.canvas.style.cursor = 'nwse-resize';
      else if (c === 1 || c === 2) el.canvas.style.cursor = 'nesw-resize';
      else el.canvas.style.cursor = 'move';
      return;
    }
    el.canvas.style.cursor = 'default';
  }
}

/* ============================================================
   INIT
   ============================================================ */
function init() {
  buildPalettes();
  const hadScene = loadScene();
  if (!hadScene) {
    // starter content
    scene = [
      { id: uid(), type: 'rectangle', x: 120, y: 140, w: 200, h: 140,
        stroke: '#1a71ff', fill: '#e7f0ff', strokeWidth: 3 },
      { id: uid(), type: 'ellipse', x: 420, y: 160, w: 150, h: 150,
        stroke: '#2f9e44', fill: '#e7f8ec', strokeWidth: 3 },
      { id: uid(), type: 'arrow', x1: 380, y1: 340, x2: 560, y2: 340,
        stroke: '#e03131', fill: 'transparent', strokeWidth: 3 },
      { id: uid(), type: 'text', x: 120, y: 330, text: 'Welcome to the Whiteboard',
        stroke: '#1e1e1e', fill: 'transparent', strokeWidth: 16, charW: 8.6, lineH: 21 }
    ];
  }
  history = [JSON.parse(JSON.stringify(scene))];
  histIndex = 0;

  resizeCanvas();
  updateZoomDisplay();
  refreshHistoryButtons();
  render();

  // center view a bit
  viewport.x = 40; viewport.y = 40;
  render();
}

init();
</script>
</body>
</html>
<!-- agent-meta {"model":"bartowski/Ornith-1.5-35B-A3B","provider":"brigid","persona":"full","sessionId":"12235992-0b0d-4be3-b4c5-7fb426375fb4","tokensIn":3925398,"tokensOut":68944,"tokensTotal":3994342,"cost":0,"turns":73,"toolCalls":72,"failedToolCalls":0,"timestamp":"2026-08-23T21:50:41.775Z"} -->