← Whiteboard results

Whiteboard

Qwen 3.6 27b MTP · composer

Initial view of Whiteboard

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

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

Generation prompt · exact text unavailable

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

Inspect original source 61,747 bytes · SHA-256 be7018c2744d
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Whiteboard Prototype</title>
<style>
/* ── css: layout ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f8f9fa; }

#whiteboard { position: relative; width: 100%; height: 100%; overflow: hidden; }
#canvas { display: block; cursor: crosshair; touch-action: none; }

/* Toolbar */
#toolbar {
  position: absolute; top: 12px; left: 50%; transform: translateX(-50%);
  display: flex; align-items: center; gap: 2px; padding: 4px;
  background: #ffffff; border-radius: 10px; box-shadow: 0 2px 12px rgba(0,0,0,0.12), 0 0 0 1px rgba(0,0,0,0.06);
  z-index: 100; user-select: none;
}

.tool-btn {
  width: 36px; height: 36px; border: none; background: transparent; border-radius: 8px;
  cursor: pointer; display: flex; align-items: center; justify-content: center;
  color: #5f6b7a; transition: all 0.15s ease; position: relative;
}
.tool-btn svg { width: 20px; height: 20px; }
.tool-btn:hover { background: #f0f2f4; color: #1e1e1e; }
.tool-btn.active { background: #e8edf3; color: #1a73e8; }
.tool-btn.active::after {
  content: ''; position: absolute; bottom: 2px; left: 50%; transform: translateX(-50%);
  width: 16px; height: 2px; background: #1a73e8; border-radius: 1px;
}

.toolbar-sep { width: 1px; height: 24px; background: #e0e3e8; margin: 0 4px; }

/* Properties Bar */
#properties {
  position: absolute; top: 60px; left: 50%; transform: translateX(-50%);
  display: flex; align-items: center; gap: 12px; padding: 6px 14px;
  background: #ffffff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.08), 0 0 0 1px rgba(0,0,0,0.05);
  z-index: 100; user-select: none; opacity: 0; pointer-events: none; transition: opacity 0.2s ease;
}
#properties.visible { opacity: 1; pointer-events: auto; }

.prop-group { display: flex; align-items: center; gap: 6px; }
.prop-label { font-size: 11px; color: #8b95a2; text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600; }

#strokeColor, #fillColor {
  width: 24px; height: 24px; border: 2px solid #e0e3e8; border-radius: 6px; cursor: pointer;
  padding: 0; background: none; -webkit-appearance: none; appearance: none;
}
#strokeColor::-webkit-color-swatch-wrapper, #fillColor::-webkit-color-swatch-wrapper { padding: 0; }
#strokeColor::-webkit-color-swatch, #fillColor::-webkit-color-swatch { border: none; border-radius: 4px; }

#strokeWidth {
  height: 28px; padding: 0 8px; border: 1px solid #e0e3e8; border-radius: 6px;
  font-size: 12px; color: #5f6b7a; background: #fafbfc; cursor: pointer; outline: none;
}

/* Zoom Controls */
#zoomControls {
  position: absolute; bottom: 16px; right: 16px;
  display: flex; align-items: center; gap: 4px; padding: 4px;
  background: #ffffff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.08), 0 0 0 1px rgba(0,0,0,0.05);
  z-index: 100; user-select: none;
}

.zoom-btn {
  width: 32px; height: 32px; border: none; background: transparent; border-radius: 6px;
  cursor: pointer; font-size: 18px; color: #5f6b7a; display: flex; align-items: center; justify-content: center;
}
.zoom-btn:hover { background: #f0f2f4; }

#zoomLevel {
  min-width: 48px; text-align: center; font-size: 13px; color: #5f6b7a; font-weight: 500; cursor: pointer; padding: 4px;
}
#zoomLevel:hover { background: #f0f2f4; border-radius: 4px; }

/* Hint Bar */
#hintBar {
  position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%);
  font-size: 12px; color: #a0aab4; background: rgba(255,255,255,0.85); padding: 6px 14px;
  border-radius: 6px; z-index: 100; user-select: none; pointer-events: none;
}

/* Text Input */
#textInput {
  position: absolute; z-index: 200; border: 2px solid #1a73e8; border-radius: 4px;
  padding: 4px 8px; font-family: inherit; font-size: 16px; outline: none; resize: both;
  min-width: 100px; min-height: 28px; background: #fff; box-shadow: 0 2px 8px rgba(26,115,232,0.2);
}

/* Cursor styles */
#canvas.cursor-select { cursor: default; }
#canvas.cursor-grab { cursor: grab; }
#canvas.cursor-grabbing { cursor: grabbing; }
#canvas.cursor-crosshair { cursor: crosshair; }
#canvas.cursor-text { cursor: text; }
#canvas.cursor-eraser { cursor: cell; }
</style>
</head>
<body>
<div id="whiteboard">
  <canvas id="canvas"></canvas>
  
  <!-- Toolbar -->
  <div id="toolbar">
    <button class="tool-btn active" data-tool="select" title="Select (V)">
      <svg viewBox="0 0 24 24"><path d="M4 4l7 18 3-8 8-3z"></path></svg>
    </button>
    <button class="tool-btn" data-tool="pen" title="Freehand Pen (P)">
      <svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 000-1.41l-2.34-2.34a1 1 0 00-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"></path></svg>
    </button>
    <button class="tool-btn" data-tool="rect" title="Rectangle (R)">
      <svg viewBox="0 0 24 24"><path d="M3 3h18v18H3V3zm2 2v14h14V5H5z"></path></svg>
    </button>
    <button class="tool-btn" data-tool="ellipse" title="Ellipse (O)">
      <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2"></circle></svg>
    </button>
    <button class="tool-btn" data-tool="line" title="Line/Arrow (L)">
      <svg viewBox="0 0 24 24"><path d="M5 19l13-13M18 6l-3 3 3 3"></path></svg>
    </button>
    <button class="tool-btn" data-tool="text" title="Text (T)">
      <svg viewBox="0 0 24 24"><path d="M5 4v3h5.5v12h3V7H19V4z"></path></svg>
    </button>
    <button class="tool-btn" data-tool="eraser" title="Eraser (E)">
      <svg viewBox="0 0 24 24"><path d="M16.24 3.56l4.95 4.94c.78.79.78 2.05 0 2.84L12 20.53a4.01 4.01 0 01-5.66 0L2.81 17c-.78-.78-.78-2.05 0-2.83l10.6-10.6c.79-.78 2.05-.78 2.83 0zM4.22 15.58l3.54 3.53c.78.79 2.04.79 2.83 0L16 13.71 9.29 7l-5.07 5.07c-.78.78-.78 2.04 0 2.83z"></path></svg>
    </button>
    <div class="toolbar-sep"></div>
    <button class="tool-btn" id="undoBtn" title="Undo (Ctrl+Z)">
      <svg viewBox="0 0 24 24"><path d="M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z"></path></svg>
    </button>
    <button class="tool-btn" id="redoBtn" title="Redo (Ctrl+Shift+Z)">
      <svg viewBox="0 0 24 24"><path d="M18.4 10.6C16.55 8.99 14.15 8 11.5 8c-4.65 0-8.58 3.03-9.96 7.22L3.9 16c1.05-3.19 4.05-5.5 7.6-5.5 1.95 0 3.73.72 5.12 1.88L13 16h9V7l-3.6 3.6z"></path></svg>
    </button>
  </div>

  <!-- Properties Bar -->
  <div id="properties">
    <label class="prop-group">
      <span class="prop-label">Stroke</span>
      <input type="color" id="strokeColor" value="#1e1e1e" title="Stroke Color">
    </label>
    <label class="prop-group">
      <span class="prop-label">Fill</span>
      <input type="color" id="fillColor" value="#ffffff" title="Fill Color">
    </label>
    <select id="strokeWidth" title="Stroke Width">
      <option value="1">Thin</option>
      <option value="2" selected="">Normal</option>
      <option value="4">Thick</option>
      <option value="6">Bold</option>
    </select>
  </div>

  <!-- Zoom Controls -->
  <div id="zoomControls">
    <button class="zoom-btn" id="zoomOutBtn" title="Zoom Out">−</button>
    <span id="zoomLevel">100%</span>
    <button class="zoom-btn" id="zoomInBtn" title="Zoom In">+</button>
    <button class="zoom-btn" id="resetViewBtn" title="Reset View">⟲</button>
  </div>

  <!-- Text Input Overlay -->
  <textarea id="textInput" style="display:none"></textarea>
  
  <!-- Context hint -->
  <div id="hintBar">Space+drag to pan · Scroll to zoom · Delete to remove</div>
</div>
<script>
'use strict';

// ── const: CONFIG ──
// ============================================================
// CONSTANTS & CONFIGURATION
// ============================================================
const CONFIG = {
  // Zoom limits
  ZOOM_MIN: 0.25,
  ZOOM_MAX: 4.0,
  ZOOM_STEP: 1.12,

  // Hit testing tolerance (in world coords)
  HIT_TOLERANCE: 8,          // px for shape borders/lines at zoom=1
  FREEHAND_HIT_TOL: 12,      // px for freehand stroke proximity

  // Selection handles
  HANDLE_SIZE: 8,            // px in screen space (scales inversely with zoom)

  // Grid
  GRID_SPACING: 24,          // world units between dots
  GRID_DOT_RADIUS: 1.2,      // screen-space radius of grid dots

  // Default styles
  DEFAULT_STROKE_COLOR: '#1e1e1e',
  DEFAULT_FILL_COLOR: 'transparent',
  DEFAULT_STROKE_WIDTH: 2,
  DEFAULT_FONT_SIZE: 16,

  // Colors palette for quick reference
  COLORS: ['#1e1e1e', '#e03131', '#2f9e81', '#1a73e8', '#f5a623', '#8b45c6'],

  // Selection color
  SELECTION_COLOR: '#1a73e8',
  SELECTION_DASH: [5, 5],

  // Preview opacity while drawing
  PREVIEW_OPACITY: 0.5,

  // Eraser hit radius (world coords)
  ERASER_RADIUS: 16,

  // Canvas background
  CANVAS_BG: '#f8f9fa',
  GRID_COLOR: 'rgba(0,0,0,0.07)',

  // LocalStorage key
  STORAGE_KEY: 'whiteboard-prototype-data'
};

// ── function: uid ──
// ============================================================
// UTILITY FUNCTIONS
// ============================================================
function uid() {
  return Math.random().toString(36).substring(2, 11) + Date.now().toString(36);
}

// ── function: clamp ──
function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }

// ── function: lerp ──
function lerp(a, b, t) { return a + (b - a) * t; }

// ── function: dist ──
function dist(x1, y1, x2, y2) { return Math.sqrt((x2-x1)**2 + (y2-y1)**2); }

// ── function: pointInRect ──
function pointInRect(px, py, rx, ry, rw, rh) {
  return px >= rx && px <= rx+rw && py >= ry && py <= ry+rh;
}

// ── function: pointNearLine ──
function pointNearLine(px, py, x1, y1, x2, y2) {
  const dx = x2-x1, dy = y2-y1;
  const lenSq = dx*dx + dy*dy;
  if (lenSq === 0) return dist(px, py, x1, y1);
  let t = ((px-x1)*dx + (py-y1)*dy) / lenSq;
  t = clamp(t, 0, 1);
  return dist(px, py, x1+t*dx, y1+t*dy);
}

// ── function: pointInEllipse ──
function pointInEllipse(px, py, cx, cy, rx, ry) {
  const nx = (px-cx)/rx, ny = (py-cy)/ry;
  return nx*nx + ny*ny <= 1.0;
}

// ── function: pointNearEllipseBorder ──
function pointNearEllipseBorder(px, py, cx, cy, rx, ry, tol) {
  // Approximate: check if point is within tol of the ellipse border
  const angle = Math.atan2(py-cy, px-cx);
  const ex = cx + rx * Math.cos(angle);
  const ey = cy + ry * Math.sin(angle);
  return dist(px, py, ex, ey) <= tol;
}

// ── function: getRectBounds ──
function getRectBounds(obj) {
  if (obj.type === 'rect' || obj.type === 'ellipse') {
    return { x: obj.x, y: obj.y, w: obj.width, h: obj.height };
  }
  if (obj.type === 'line') {
    const minX = Math.min(obj.x1, obj.x2);
    const minY = Math.min(obj.y1, obj.y2);
    return { x: minX, y: minY, w: Math.abs(obj.x2-obj.x1), h: Math.abs(obj.y2-obj.y1) };
  }
  if (obj.type === 'freehand') {
    let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
    for (const p of obj.points) {
      if (p.x < minX) minX = p.x;
      if (p.y < minY) minY = p.y;
      if (p.x > maxX) maxX = p.x;
      if (p.y > maxY) maxY = p.y;
    }
    return { x: minX, y: minY, w: maxX-minX, h: maxY-minY };
  }
  if (obj.type === 'text') {
    return { x: obj.x, y: obj.y, w: obj.width || 100, h: obj.height || 24 };
  }
  return { x: 0, y: 0, w: 0, h: 0 };
}

// ── function: unionBounds ──
function unionBounds(boundsArr) {
  if (!boundsArr.length) return null;
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
  for (const b of boundsArr) {
    if (b.x < minX) minX = b.x;
    if (b.y < minY) minY = b.y;
    if (b.x+b.w > maxX) maxX = b.x+b.w;
    if (b.y+b.h > maxY) maxY = b.y+b.h;
  }
  return { x: minX, y: minY, w: maxX-minX, h: maxY-minY };
}

// ── function: deepClone ──
function deepClone(obj) {
  return JSON.parse(JSON.stringify(obj));
}

// ── const: Viewport ──
// ============================================================
// VIEWPORT MANAGEMENT
// ============================================================
const Viewport = {
  x: 0,       // world coordinate of top-left corner of screen
  y: 0,
  zoom: 1.0,

  reset() {
    this.x = 0; this.y = 0; this.zoom = 1.0;
  },

  // Screen to world coordinates
  s2w(sx, sy) {
    return { x: sx / this.zoom + this.x, y: sy / this.zoom + this.y };
  },

  // World to screen coordinates
  w2s(wx, wy) {
    return { x: (wx - this.x) * this.zoom, y: (wy - this.y) * this.zoom };
  },

  // Zoom toward a screen position
  zoomAt(sx, sy, newZoom) {
    const world = this.s2w(sx, sy);
    newZoom = clamp(newZoom, CONFIG.ZOOM_MIN, CONFIG.ZOOM_MAX);
    this.x = world.x - sx / newZoom;
    this.y = world.y - sy / newZoom;
    this.zoom = newZoom;
  },

  // Pan by screen delta
  pan(dx, dy) {
    this.x -= dx / this.zoom;
    this.y -= dy / this.zoom;
  }
};

// ── function: hitTestObject ──
// ============================================================
// HIT TESTING (all in world coordinates)
// ============================================================
function hitTestObject(obj, wx, wy) {
  const tol = CONFIG.HIT_TOLERANCE / Viewport.zoom;

  switch (obj.type) {
    case 'rect': {
      const b = getRectBounds(obj);
      // Check inside or near border
      if (pointInRect(wx, wy, b.x-tol, b.y-tol, b.w+tol*2, b.h+tol*2)) {
        // For filled rects, check inside; for unfilled, check near border
        if (obj.fillColor && obj.fillColor !== 'transparent') return true;
        // Near border test: within tol of any edge
        const left = Math.abs(wx - b.x) <= tol;
        const right = Math.abs(wx - (b.x+b.w)) <= tol;
        const top = Math.abs(wy - b.y) <= tol;
        const bottom = Math.abs(wy - (b.y+b.h)) <= tol;
        if (left || right || top || bottom) return true;
        // Also check inside for filled rects
        if (pointInRect(wx, wy, b.x, b.y, b.w, b.h)) return true;
      }
      return false;
    }

    case 'ellipse': {
      const cx = obj.x + obj.width/2, cy = obj.y + obj.height/2;
      const rx = Math.abs(obj.width)/2, ry = Math.abs(obj.height)/2;
      if (rx === 0 || ry === 0) return false;
      // Inside test
      if (pointInEllipse(wx, wy, cx, cy, rx + tol, ry + tol)) {
        if (obj.fillColor && obj.fillColor !== 'transparent') return true;
        // Near border: check distance to ellipse edge
        const angle = Math.atan2(wy-cy, wx-cx);
        const ex = cx + rx * Math.cos(angle);
        const ey = cy + ry * Math.sin(angle);
        if (dist(wx, wy, ex, ey) <= tol) return true;
      }
      return false;
    }

    case 'line': {
      const d = pointNearLine(wx, wy, obj.x1, obj.y1, obj.x2, obj.y2);
      if (d <= tol + obj.strokeWidth/2) return true;
      // Arrowhead hit test
      if (obj.arrowhead) {
        const angle = Math.atan2(obj.y2-obj.y1, obj.x2-obj.x1);
        const aLen = 10 / Viewport.zoom * 3;
        const ax1 = obj.x2 - aLen*Math.cos(angle-Math.PI/6);
        const ay1 = obj.y2 - aLen*Math.sin(angle-Math.PI/6);
        const ax2 = obj.x2 - aLen*Math.cos(angle+Math.PI/6);
        const ay2 = obj.y2 - aLen*Math.sin(angle+Math.PI/6);
        if (pointNearLine(wx, wy, obj.x2, obj.y2, ax1, ay1) <= tol ||
            pointNearLine(wx, wy, obj.x2, obj.y2, ax2, ay2) <= tol) return true;
      }
      return false;
    }

    case 'freehand': {
      const ftol = CONFIG.FREEHAND_HIT_TOL / Viewport.zoom;
      for (let i = 0; i < obj.points.length - 1; i++) {
        if (pointNearLine(wx, wy, obj.points[i].x, obj.points[i].y,
                          obj.points[i+1].x, obj.points[i+1].y) <= ftol + obj.strokeWidth/2) {
          return true;
        }
      }
      // Also check near endpoints
      if (obj.points.length > 0 && dist(wx, wy, obj.points[0].x, obj.points[0].y) <= ftol) return true;
      const last = obj.points[obj.points.length-1];
      if (last && dist(wx, wy, last.x, last.y) <= ftol) return true;
      return false;
    }

    case 'text': {
      const b = getRectBounds(obj);
      return pointInRect(wx, wy, b.x-tol, b.y-tol, b.w+tol*2, b.h+tol*2);
    }

    default: return false;
  }
}

// ── function: getSelectionHandles ──
// ============================================================
// SELECTION HANDLE HIT TESTING (in screen coordinates)
// ============================================================
function getSelectionHandles(bounds) {
  const hs = CONFIG.HANDLE_SIZE / Viewport.zoom; // handle size in world coords
  if (!bounds) return [];
  return [
    { id: 'nw', x: bounds.x, y: bounds.y },
    { id: 'ne', x: bounds.x + bounds.w, y: bounds.y },
    { id: 'sw', x: bounds.x, y: bounds.y + bounds.h },
    { id: 'se', x: bounds.x + bounds.w, y: bounds.y + bounds.h }
  ];
}

// ── function: hitTestHandle ──
function hitTestHandle(handles, wx, wy) {
  const tol = CONFIG.HANDLE_SIZE / Viewport.zoom;
  for (const h of handles) {
    if (Math.abs(wx - h.x) <= tol && Math.abs(wy - h.y) <= tol) return h.id;
  }
  return null;
}

// ── let: scene ──
// ============================================================
// SCENE MANAGEMENT
// ============================================================
let scene = [];

// ── let: selectedIds ──
// Array of object dicts
let selectedIds = new Set();

// ── function: addObject ──
function addObject(obj) {
  scene.push(obj);
  return obj;
}

// ── function: removeObject ──
function removeObject(id) {
  const idx = scene.findIndex(o => o.id === id);
  if (idx >= 0) scene.splice(idx, 1);
}

// ── function: getObject ──
function getObject(id) {
  return scene.find(o => o.id === id);
}

// ── function: getSelectedObjects ──
function getSelectedObjects() {
  return scene.filter(o => selectedIds.has(o.id));
}

// ── function: setSelected ──
function setSelected(ids) {
  selectedIds = new Set(ids);
}

// ── function: addSelection ──
function addSelection(id) {
  selectedIds.add(id);
}

// ── function: removeSelection ──
function removeSelection(id) {
  selectedIds.delete(id);
}

// ── function: clearSelection ──
function clearSelection() {
  selectedIds.clear();
}

// ── function: getSelectionBounds ──
function getSelectionBounds() {
  const objs = getSelectedObjects();
  if (!objs.length) return null;
  return unionBounds(objs.map(getRectBounds));
}

// ── let: history ──
// ============================================================
// HISTORY / UNDO-REDO
// ============================================================
let history = [];

// ── let: historyIndex ──
let historyIndex = -1;

// ── const: MAX_HISTORY ──
const MAX_HISTORY = 200;

// ── function: pushHistory ──
function pushHistory(action, undoFn) {
  // Remove any redo states after current index
  if (historyIndex < history.length - 1) {
    history = history.slice(0, historyIndex + 1);
  }
  history.push({ action, undo: undoFn });
  if (history.length > MAX_HISTORY) history.shift();
  historyIndex = history.length - 1;
}

// ── function: undo ──
function undo() {
  if (historyIndex < 0) return false;
  const entry = history[historyIndex];
  entry.undo();
  historyIndex--;
  return true;
}

// ── function: redo ──
function redo() {
  if (historyIndex >= history.length - 1) return false;
  historyIndex++;
  const entry = history[historyIndex];
  // Re-apply: we store the forward action too
  if (entry.redo) entry.redo();
  return true;
}

// ── function: clearHistory ──
function clearHistory() {
  history = [];
  historyIndex = -1;
}

// ── function: saveToStorage ──
// ============================================================
// PERSISTENCE
// ============================================================
function saveToStorage() {
  try {
    const data = JSON.stringify(scene);
    localStorage.setItem(CONFIG.STORAGE_KEY, data);
  } catch(e) {}
}

// ── function: loadFromStorage ──
function loadFromStorage() {
  try {
    const raw = localStorage.getItem(CONFIG.STORAGE_KEY);
    if (raw) {
      scene = JSON.parse(raw);
      return true;
    }
  } catch(e) {}
  return false;
}

// ── function: getDefaultStyle ──
// ============================================================
// DEFAULT STYLE HELPERS
// ============================================================
function getDefaultStyle() {
  return {
    strokeColor: document.getElementById('strokeColor').value || CONFIG.DEFAULT_STROKE_COLOR,
    fillColor: document.getElementById('fillColor').value === '#ffffff' ? 'transparent' : (document.getElementById('fillColor').value || CONFIG.DEFAULT_FILL_COLOR),
    strokeWidth: parseInt(document.getElementById('strokeWidth').value) || CONFIG.DEFAULT_STROKE_WIDTH
  };
}

// ── function: applyStyleToSelection ──
function applyStyleToSelection() {
  const style = getDefaultStyle();
  if (!selectedIds.size) return;
  
  // Save old state for undo
  const oldState = deepClone(getSelectedObjects());
  
  for (const id of selectedIds) {
    const obj = getObject(id);
    if (!obj) continue;
    obj.strokeColor = style.strokeColor;
    obj.fillColor = style.fillColor;
    obj.strokeWidth = style.strokeWidth;
  }
  
  pushHistory('style', () => {
    for (const old of oldState) {
      const obj = getObject(old.id);
      if (obj) Object.assign(obj, deepClone(old));
    }
  });
}

// ── let: canvas ──
// ============================================================
// RENDERING ENGINE
// ============================================================
let canvas = null;

// ── let: ctx ──
let ctx = null;

// ── let: needsRender ──
let needsRender = true;

// ── function: initCanvas ──
function initCanvas() {
  canvas = document.getElementById('canvas');
  ctx = canvas.getContext('2d');
  resizeCanvas();
  window.addEventListener('resize', resizeCanvas);
}

// ── function: resizeCanvas ──
function resizeCanvas() {
  const dpr = window.devicePixelRatio || 1;
  canvas.width = window.innerWidth * dpr;
  canvas.height = window.innerHeight * dpr;
  canvas.style.width = window.innerWidth + 'px';
  canvas.style.height = window.innerHeight + 'px';
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  needsRender = true;
}

// ── function: render ──
function render() {
  if (!needsRender) return;
  needsRender = false;

  const w = window.innerWidth;
  const h = window.innerHeight;

  // Clear
  ctx.fillStyle = CONFIG.CANVAS_BG;
  ctx.fillRect(0, 0, w, h);

  // Apply viewport transform
  ctx.save();
  ctx.translate(-Viewport.x * Viewport.zoom, -Viewport.y * Viewport.zoom);
  ctx.scale(Viewport.zoom, Viewport.zoom);

  // Draw grid
  drawGrid(w, h);

  // Draw all objects
  for (const obj of scene) {
    drawObject(obj, false);
  }

  // Draw selection UI
  if (selectedIds.size > 0) {
    const bounds = getSelectionBounds();
    if (bounds) {
      drawSelectionBox(bounds);
    }
  }

  ctx.restore();

  // Draw preview (in screen space, no transform needed for some elements)
  if (interaction.preview) {
    ctx.save();
    ctx.translate(-Viewport.x * Viewport.zoom, -Viewport.y * Viewport.zoom);
    ctx.scale(Viewport.zoom, Viewport.zoom);
    drawPreview(interaction.preview);
    ctx.restore();
  }

  // Draw rubber-band selection rectangle (screen space)
  if (interaction.rubberBand && interaction.rubberBand.active) {
    const rb = interaction.rubberBand;
    ctx.save();
    ctx.strokeStyle = CONFIG.SELECTION_COLOR;
    ctx.lineWidth = 1;
    ctx.setLineDash([4, 4]);
    ctx.globalAlpha = 0.6;
    ctx.fillStyle = 'rgba(26, 115, 232, 0.08)';
    const rx = Math.min(rb.x1, rb.x2);
    const ry = Math.min(rb.y1, rb.y2);
    const rw = Math.abs(rb.x2 - rb.x1);
    const rh = Math.abs(rb.y2 - rb.y1);
    ctx.fillRect(rx, ry, rw, rh);
    ctx.strokeRect(rx, ry, rw, rh);
    ctx.restore();
  }
}

// ── function: drawObject ──
function drawObject(obj, isPreview) {
  const alpha = isPreview ? CONFIG.PREVIEW_OPACITY : 1.0;
  ctx.globalAlpha = alpha;

  switch (obj.type) {
    case 'rect': drawRect(obj); break;
    case 'ellipse': drawEllipse(obj); break;
    case 'line': drawLine(obj); break;
    case 'freehand': drawFreehand(obj); break;
    case 'text': drawText(obj); break;
  }

  ctx.globalAlpha = 1.0;
}

// ── function: drawRect ──
function drawRect(obj) {
  const r = Math.min(4, Math.abs(obj.width)/6, Math.abs(obj.height)/6);
  if (obj.fillColor && obj.fillColor !== 'transparent') {
    ctx.fillStyle = obj.fillColor;
    roundRect(ctx, obj.x, obj.y, obj.width, obj.height, r);
    ctx.fill();
  }
  ctx.strokeStyle = obj.strokeColor || CONFIG.DEFAULT_STROKE_COLOR;
  ctx.lineWidth = obj.strokeWidth || CONFIG.DEFAULT_STROKE_WIDTH;
  ctx.lineJoin = 'round';
  roundRect(ctx, obj.x, obj.y, obj.width, obj.height, r);
  ctx.stroke();
}

// ── function: drawEllipse ──
function drawEllipse(obj) {
  const cx = obj.x + obj.width/2;
  const cy = obj.y + obj.height/2;
  const rx = Math.abs(obj.width)/2;
  const ry = Math.abs(obj.height)/2;

  if (obj.fillColor && obj.fillColor !== 'transparent') {
    ctx.fillStyle = obj.fillColor;
    ctx.beginPath();
    ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI*2);
    ctx.fill();
  }
  ctx.strokeStyle = obj.strokeColor || CONFIG.DEFAULT_STROKE_COLOR;
  ctx.lineWidth = obj.strokeWidth || CONFIG.DEFAULT_STROKE_WIDTH;
  ctx.beginPath();
  ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI*2);
  ctx.stroke();
}

// ── function: drawLine ──
function drawLine(obj) {
  ctx.strokeStyle = obj.strokeColor || CONFIG.DEFAULT_STROKE_COLOR;
  ctx.lineWidth = obj.strokeWidth || CONFIG.DEFAULT_STROKE_WIDTH;
  ctx.lineCap = 'round';
  ctx.beginPath();
  ctx.moveTo(obj.x1, obj.y1);
  ctx.lineTo(obj.x2, obj.y2);
  ctx.stroke();

  // Arrowhead
  if (obj.arrowhead) {
    const angle = Math.atan2(obj.y2-obj.y1, obj.x2-obj.x1);
    const aLen = 10;
    ctx.fillStyle = obj.strokeColor || CONFIG.DEFAULT_STROKE_COLOR;
    ctx.beginPath();
    ctx.moveTo(obj.x2, obj.y2);
    ctx.lineTo(obj.x2 - aLen*Math.cos(angle-Math.PI/6), obj.y2 - aLen*Math.sin(angle-Math.PI/6));
    ctx.lineTo(obj.x2 - aLen*Math.cos(angle+Math.PI/6), obj.y2 - aLen*Math.sin(angle+Math.PI/6));
    ctx.closePath();
    ctx.fill();
  }
}

// ── function: drawFreehand ──
function drawFreehand(obj) {
  const pts = obj.points;
  if (pts.length < 2) return;

  ctx.strokeStyle = obj.strokeColor || CONFIG.DEFAULT_STROKE_COLOR;
  ctx.lineWidth = obj.strokeWidth || CONFIG.DEFAULT_STROKE_WIDTH;
  ctx.lineCap = 'round';
  ctx.lineJoin = 'round';

  // Smooth the path using quadratic curves
  ctx.beginPath();
  if (pts.length === 2) {
    ctx.moveTo(pts[0].x, pts[0].y);
    ctx.lineTo(pts[1].x, pts[1].y);
  } else {
    let i = 0;
    ctx.moveTo((pts[0].x + pts[1].x)/2, (pts[0].y + pts[1].y)/2);
    for (; i < pts.length - 2; i++) {
      const xc = (pts[i].x + pts[i+1].x) / 2;
      const yc = (pts[i].y + pts[i+1].y) / 2;
      ctx.quadraticCurveTo(pts[i].x, pts[i].y, xc, yc);
    }
    // Last segment
    ctx.quadraticCurveTo(pts[i].x, pts[i].y, pts[i+1].x, pts[i+1].y);
  }
  ctx.stroke();
}

// ── function: drawText ──
function drawText(obj) {
  const fontSize = obj.fontSize || CONFIG.DEFAULT_FONT_SIZE;
  ctx.font = `${fontSize}px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif`;
  ctx.fillStyle = obj.strokeColor || CONFIG.DEFAULT_STROKE_COLOR;

  // Word wrap
  const maxWidth = obj.width || 200;
  const lines = wrapText(ctx, obj.text || '', maxWidth);
  const lineHeight = fontSize * 1.35;

  for (let i = 0; i < lines.length; i++) {
    ctx.fillText(lines[i], obj.x + 4, obj.y + fontSize + i * lineHeight);
  }
}

// ── function: wrapText ──
function wrapText(ctx, text, maxWidth) {
  const words = text.split(' ');
  const lines = [];
  let currentLine = '';

  for (const word of words) {
    const testLine = currentLine ? currentLine + ' ' + word : word;
    const metrics = ctx.measureText(testLine);
    if (metrics.width > maxWidth && currentLine) {
      lines.push(currentLine);
      currentLine = word;
    } else {
      currentLine = testLine;
    }
  }
  if (currentLine) lines.push(currentLine);
  return lines.length ? lines : [''];
}

// ── function: roundRect ──
function roundRect(ctx, x, y, w, h, r) {
  ctx.beginPath();
  const actualR = Math.min(r, Math.abs(w)/2, Math.abs(h)/2);
  ctx.moveTo(x + actualR, y);
  ctx.lineTo(x + w - actualR, y);
  ctx.arcTo(x + w, y, x + w, y + h, actualR);
  ctx.arcTo(x + w, y + h, x, y + h, actualR);
  ctx.arcTo(x, y + h, x, y, actualR);
  ctx.arcTo(x, y, x + w, y, actualR);
  ctx.closePath();
}

// ── function: drawSelectionBox ──
function drawSelectionBox(bounds) {
  const pad = 4;
  ctx.strokeStyle = CONFIG.SELECTION_COLOR;
  ctx.lineWidth = 1.5 / Viewport.zoom;
  ctx.setLineDash(CONFIG.SELECTION_DASH.map(d => d / Viewport.zoom));

  // Bounding box
  ctx.strokeRect(bounds.x - pad, bounds.y - pad, bounds.w + pad*2, bounds.h + pad*2);

  // Corner handles
  const hs = CONFIG.HANDLE_SIZE / Viewport.zoom;
  const corners = getSelectionHandles({ ...bounds });
  for (const c of corners) {
    ctx.fillStyle = '#ffffff';
    ctx.strokeStyle = CONFIG.SELECTION_COLOR;
    ctx.lineWidth = 1.5 / Viewport.zoom;
    ctx.setLineDash([]);
    ctx.fillRect(c.x - hs/2, c.y - hs/2, hs, hs);
    ctx.strokeRect(c.x - hs/2, c.y - hs/2, hs, hs);
  }

  ctx.setLineDash([]);
}

// ── function: drawPreview ──
function drawPreview(preview) {
  if (preview.type === 'rect') {
    const obj = { ...preview, type: 'rect' };
    drawObject(obj, true);
  } else if (preview.type === 'ellipse') {
    const obj = { ...preview, type: 'ellipse' };
    drawObject(obj, true);
  } else if (preview.type === 'line') {
    const obj = { ...preview, type: 'line' };
    drawObject(obj, true);
  } else if (preview.type === 'freehand') {
    const obj = { points: preview.points, strokeColor: preview.strokeColor, strokeWidth: preview.strokeWidth };
    drawFreehand(obj);
  }
}

// ── function: requestRender ──
function requestRender() {
  needsRender = true;
}

// ── let: currentTool ──
// ============================================================
// INTERACTION STATE MACHINE
// ============================================================
let currentTool = 'select';

// ── let: spaceDown ──
// select, pen, rect, ellipse, line, text, eraser
let spaceDown = false;

// ── const: interaction ──
const interaction = {
  active: false,       // Is any interaction in progress?
  tool: null,          // Current tool during interaction
  startX: 0, startY: 0, // Screen coords of initial mouse down
  startWX: 0, startWY: 0, // World coords of initial mouse down
  preview: null,       // Preview object for shape creation
  rubberBand: null,    // { active, x1, y1, x2, y2 } in screen coords
  movingObjects: false,// Currently dragging selected objects
  resizingHandle: null,// Which handle is being dragged (nw/ne/sw/se)
  erasing: false,      // Eraser mode
  textPlacing: false   // Waiting for text placement click
};

// ── function: setTool ──
function setTool(tool) {
  currentTool = tool;
  
  // Update toolbar UI
  document.querySelectorAll('.tool-btn[data-tool]').forEach(btn => {
    btn.classList.toggle('active', btn.dataset.tool === tool);
  });

  // Update cursor
  updateCursor();

  // Hide properties bar when not in select mode (or show if selection exists)
  updatePropertiesBar();
}

// ── function: updateCursor ──
function updateCursor() {
  const c = canvas;
  c.classList.remove('cursor-select', 'cursor-grab', 'cursor-crosshair', 'cursor-text', 'cursor-eraser');
  
  if (spaceDown || interaction.active && interaction.movingObjects) {
    c.classList.add('cursor-grabbing');
    return;
  }

  switch(currentTool) {
    case 'select': c.classList.add('cursor-select'); break;
    case 'pen': c.classList.add('cursor-crosshair'); break;
    case 'rect': c.classList.add('cursor-crosshair'); break;
    case 'ellipse': c.classList.add('cursor-crosshair'); break;
    case 'line': c.classList.add('cursor-crosshair'); break;
    case 'text': c.classList.add('cursor-text'); break;
    case 'eraser': c.classList.add('cursor-eraser'); break;
  }
}

// ── function: updatePropertiesBar ──
function updatePropertiesBar() {
  const bar = document.getElementById('properties');
  if (selectedIds.size > 0) {
    bar.classList.add('visible');
    // Update to reflect first selected object's style
    const objs = getSelectedObjects();
    if (objs.length === 1) {
      const obj = objs[0];
      document.getElementById('strokeColor').value = obj.strokeColor || CONFIG.DEFAULT_STROKE_COLOR;
      document.getElementById('fillColor').value = (obj.fillColor && obj.fillColor !== 'transparent') ? obj.fillColor : '#ffffff';
      document.getElementById('strokeWidth').value = String(obj.strokeWidth || CONFIG.DEFAULT_STROKE_WIDTH);
    }
  } else {
    bar.classList.remove('visible');
  }
}

// ── function: updateZoomDisplay ──
function updateZoomDisplay() {
  const pct = Math.round(Viewport.zoom * 100) + '%';
  document.getElementById('zoomLevel').textContent = pct;
}

// ── let: mouseDownPos ──
// ============================================================
// MOUSE EVENT HANDLERS
// ============================================================
let mouseDownPos = null;

// ── function: getMouseWorld ──
// Track for click vs drag distinction

function getMouseWorld(e) {
  return Viewport.s2w(e.clientX, e.clientY);
}

// ── function: onPointerDown ──
function onPointerDown(e) {
  const screenX = e.clientX;
  const screenY = e.clientY;
  const world = Viewport.s2w(screenX, screenY);
  
  // Middle mouse or space+left: pan mode
  if (e.button === 1 || (e.button === 0 && spaceDown)) {
    interaction.active = true;
    interaction.tool = 'pan';
    interaction.startX = screenX;
    interaction.startY = screenY;
    canvas.classList.add('cursor-grabbing');
    e.preventDefault();
    return;
  }

  if (e.button !== 0) return; // Only handle left mouse button for tools

  mouseDownPos = { x: screenX, y: screenY };
  
  switch(currentTool) {
    case 'select': onSelectDown(screenX, screenY, world.x, world.y); break;
    case 'pen': onPenDown(screenX, screenY, world.x, world.y); break;
    case 'rect': onShapeDown('rect', screenX, screenY, world.x, world.y); break;
    case 'ellipse': onShapeDown('ellipse', screenX, screenY, world.x, world.y); break;
    case 'line': onShapeDown('line', screenX, screenY, world.x, world.y); break;
    case 'text': onTextDown(screenX, screenY, world.x, world.y); break;
    case 'eraser': onEraserDown(screenX, screenY, world.x, world.y); break;
  }
}

// ── function: onPointerMove ──
function onPointerMove(e) {
  const screenX = e.clientX;
  const screenY = e.clientY;
  const world = Viewport.s2w(screenX, screenY);

  if (!interaction.active) return;

  switch(interaction.tool) {
    case 'pan': onPanMove(screenX, screenY); break;
    case 'select': onSelectMove(screenX, screenY, world.x, world.y); break;
    case 'pen': onPenMove(world.x, world.y); break;
    case 'rect': case 'ellipse': case 'line': 
      onShapeMove(interaction.tool, screenX, screenY, world.x, world.y); break;
    case 'eraser': onEraserMove(screenX, screenY, world.x, world.y); break;
  }
}

// ── function: onPointerUp ──
function onPointerUp(e) {
  const screenX = e.clientX || mouseDownPos?.x || 0;
  const screenY = e.clientY || mouseDownPos?.y || 0;
  
  if (!interaction.active) return;

  switch(interaction.tool) {
    case 'pan': onPanUp(); break;
    case 'select': onSelectUp(screenX, screenY); break;
    case 'pen': onPenUp(); break;
    case 'rect': case 'ellipse': case 'line': 
      onShapeUp(interaction.tool, screenX, screenY); break;
    case 'eraser': onEraserUp(); break;
  }

  interaction.active = false;
  interaction.preview = null;
  mouseDownPos = null;
  updateCursor();
  requestRender();
}

// ── function: onSelectMove ──
function onSelectMove(sx, sy, wx, wy) {
  if (interaction.resizingHandle) {
    // Resize mode
    const dx = (sx - interaction.startX) / Viewport.zoom;
    const dy = (sy - interaction.startY) / Viewport.zoom;
    
    for (const id of selectedIds) {
      const obj = getObject(id);
      if (!obj) continue;
      const orig = interaction.resizeOrigins[id];
      
      switch(interaction.resizingHandle) {
        case 'se':
          if (obj.type === 'rect' || obj.type === 'ellipse') {
            let newW = orig.width + dx;
            let newH = orig.height + dy;
            if (window._lastShiftState) {
              // Preserve aspect ratio
              const aspect = Math.abs(orig.width / orig.height);
              if (Math.abs(dx) > Math.abs(dy)) {
                newH = newW / aspect * Math.sign(orig.height);
              } else {
                newW = newH * aspect * Math.sign(orig.width);
              }
            }
            obj.width = newW;
            obj.height = newH;
          } else if (obj.type === 'line') {
            obj.x2 = orig.x2 + dx;
            obj.y2 = orig.y2 + dy;
          } else if (obj.type === 'text') {
            // Text doesn't resize via handles in this prototype
          }
          break;
        case 'nw':
          if (obj.type === 'rect' || obj.type === 'ellipse') {
            let newW = orig.width - dx;
            let newH = orig.height - dy;
            if (window._lastShiftState) {
              const aspect = Math.abs(orig.width / orig.height);
              if (Math.abs(dx) > Math.abs(dy)) {
                newH = newW / aspect * Math.sign(orig.height);
              } else {
                newW = newH * aspect * Math.sign(orig.width);
              }
            }
            obj.x = orig.x + orig.width - newW;
            obj.y = orig.y + orig.height - newH;
            obj.width = newW;
            obj.height = newH;
          } else if (obj.type === 'line') {
            obj.x1 = orig.x1 + dx;
            obj.y1 = orig.y1 + dy;
          }
          break;
        case 'ne':
          if (obj.type === 'rect' || obj.type === 'ellipse') {
            let newW = orig.width + dx;
            let newH = orig.height - dy;
            if (window._lastShiftState) {
              const aspect = Math.abs(orig.width / orig.height);
              if (Math.abs(dx) > Math.abs(dy)) {
                newH = newW / aspect * Math.sign(orig.height);
              } else {
                newW = newH * aspect * Math.sign(orig.width);
              }
            }
            obj.y = orig.y + orig.height - newH;
            obj.width = newW;
            obj.height = newH;
          } else if (obj.type === 'line') {
            obj.x2 = orig.x2 + dx;
            obj.y1 = orig.y1 - dy;
          }
          break;
        case 'sw':
          if (obj.type === 'rect' || obj.type === 'ellipse') {
            let newW = orig.width - dx;
            let newH = orig.height + dy;
            if (window._lastShiftState) {
              const aspect = Math.abs(orig.width / orig.height);
              if (Math.abs(dx) > Math.abs(dy)) {
                newH = newW / aspect * Math.sign(orig.height);
              } else {
                newW = newH * aspect * Math.sign(orig.width);
              }
            }
            obj.x = orig.x + orig.width - newW;
            obj.width = newW;
            obj.height = newH;
          } else if (obj.type === 'line') {
            obj.x1 = orig.x1 + dx;
            obj.y2 = orig.y2 + dy;
          }
          break;
      }
    }
  } else if (interaction.movingObjects) {
    // Move mode
    const dx = (sx - interaction.startX) / Viewport.zoom;
    const dy = (sy - interaction.startY) / Viewport.zoom;
    
    for (const id of selectedIds) {
      const obj = getObject(id);
      if (!obj) continue;
      const orig = interaction.moveOrigins[id];
      
      switch(obj.type) {
        case 'rect':
        case 'ellipse':
          obj.x = orig.x + dx;
          obj.y = orig.y + dy;
          break;
        case 'line':
          obj.x1 = orig.x1 + dx;
          obj.y1 = orig.y1 + dy;
          obj.x2 = orig.x2 + dx;
          obj.y2 = orig.y2 + dy;
          break;
        case 'freehand':
          obj.points = orig.points.map(p => ({ x: p.x + dx, y: p.y + dy }));
          break;
        case 'text':
          obj.x = orig.x + dx;
          obj.y = orig.y + dy;
          break;
      }
    }
  } else if (interaction.rubberBand) {
    // Rubber-band selection mode
    interaction.rubberBand.x2 = sx;
    interaction.rubberBand.y2 = sy;
  }
  
  requestRender();
}

// ── function: onSelectUp ──
function onSelectUp(sx, sy) {
  if (interaction.resizingHandle) {
    // Commit resize as one undo step
    const origData = deepClone(interaction.resizeOrigins);
    pushHistory('resize', () => {
      for (const [id, orig] of Object.entries(origData)) {
        const obj = getObject(id);
        if (obj) Object.assign(obj, deepClone(orig));
      }
    });
  } else if (interaction.movingObjects && selectedIds.size > 0) {
    // Check if actually moved (not just a click)
    const dx = sx - interaction.startX;
    const dy = sy - interaction.startY;
    if (Math.abs(dx) > 2 || Math.abs(dy) > 2) {
      const origData = deepClone(interaction.moveOrigins);
      pushHistory('move', () => {
        for (const [id, orig] of Object.entries(origData)) {
          const obj = getObject(id);
          if (obj) Object.assign(obj, deepClone(orig));
        }
      });
    }
  } else if (interaction.rubberBand && interaction.rubberBand.active) {
    // Commit rubber-band selection
    const rb = interaction.rubberBand;
    const rx1 = Math.min(rb.x1, rb.x2);
    const ry1 = Math.min(rb.y1, rb.y2);
    const rx2 = Math.max(rb.x1, rb.x2);
    const ry2 = Math.max(rb.y1, rb.y2);
    
    // Only select if the rubber band is big enough (not a tiny click)
    if (Math.abs(rx2-rx1) > 5 || Math.abs(ry2-ry1) > 5) {
      const newSelection = [];
      for (const obj of scene) {
        const b = getRectBounds(obj);
        // Check if object bounds intersect rubber band rect (screen coords)
        const ob = Viewport.w2s(b.x, b.y);
        const obW = b.w * Viewport.zoom;
        const obH = b.h * Viewport.zoom;
        
        if (!(ob.x + obW < rx1 || ob.x > rx2 || ob.y + obH < ry1 || ob.y > ry2)) {
          newSelection.push(obj.id);
        }
      }
      setSelected(newSelection);
    }
  }
  
  interaction.resizingHandle = null;
  interaction.movingObjects = false;
  interaction.rubberBand = null;
  updatePropertiesBar();
}

// ── function: onPenDown ──
// ============================================================
// PEN TOOL INTERACTIONS
// ============================================================
function onPenDown(sx, sy, wx, wy) {
  const style = getDefaultStyle();
  interaction.active = true;
  interaction.tool = 'pen';
  interaction.startX = sx;
  interaction.startY = sy;
  
  // Create a new freehand object
  const obj = addObject({
    id: uid(),
    type: 'freehand',
    points: [{ x: wx, y: wy }],
    strokeColor: style.strokeColor,
    strokeWidth: style.strokeWidth
  });
  
  interaction.currentObjId = obj.id;
}

// ── function: onPenMove ──
function onPenMove(wx, wy) {
  const obj = getObject(interaction.currentObjId);
  if (!obj) return;
  
  // Add point with some distance threshold to avoid too many points
  const lastPt = obj.points[obj.points.length - 1];
  const d = dist(lastPt.x, lastPt.y, wx, wy);
  if (d > 2 / Viewport.zoom || obj.points.length < 2) {
    obj.points.push({ x: wx, y: wy });
  }
  
  requestRender();
}

// ── function: onPenUp ──
function onPenUp() {
  const obj = getObject(interaction.currentObjId);
  if (obj && obj.points.length > 1) {
    pushHistory('create', () => removeObject(obj.id));
    setSelected([obj.id]);
    updatePropertiesBar();
  } else {
    removeObject(obj?.id);
  }
  interaction.currentObjId = null;
}

// ── function: onShapeMove ──
function onShapeMove(type, sx, sy, wx, wy) {
  if (!interaction.preview) return;
  
  const dx = wx - interaction.startWX;
  const dy = wy - interaction.startWY;
  
  // Allow holding Shift to constrain aspect ratio or draw from center
  let finalX = dx, finalY = dy;
  
  if (window._lastShiftState && type !== 'line') {
    // Constrain to square/circle
    const size = Math.max(Math.abs(dx), Math.abs(dy));
    finalX = size * Math.sign(dx || 1);
    finalY = size * Math.sign(dy || 1);
  }
  
  if (type === 'line') {
    interaction.preview.x2 = wx;
    interaction.preview.y2 = wy;
    interaction.preview.arrowhead = window._lastShiftState;
  } else {
    interaction.preview.width = finalX;
    interaction.preview.height = finalY;
  }
  
  requestRender();
}

// ── function: onShapeUp ──
function onShapeUp(type, sx, sy) {
  if (!interaction.preview) return;
  
  const preview = interaction.preview;
  let obj;
  
  if (type === 'line') {
    // Only create if line has meaningful length
    const len = dist(preview.x1, preview.y1, preview.x2, preview.y2);
    if (len < 5 / Viewport.zoom) {
      interaction.preview = null;
      return;
    }
    obj = addObject({
      id: uid(), type: 'line',
      x1: preview.x1, y1: preview.y1,
      x2: preview.x2, y2: preview.y2,
      arrowhead: preview.arrowhead || false,
      strokeColor: preview.strokeColor,
      strokeWidth: preview.strokeWidth
    });
  } else {
    // Only create if shape has meaningful size
    if (Math.abs(preview.width) < 5/Viewport.zoom && Math.abs(preview.height) < 5/Viewport.zoom) {
      interaction.preview = null;
      return;
    }
    obj = addObject({
      id: uid(), type: preview.type,
      x: preview.x, y: preview.y,
      width: preview.width, height: preview.height,
      strokeColor: preview.strokeColor,
      fillColor: preview.fillColor || 'transparent',
      strokeWidth: preview.strokeWidth
    });
  }
  
  pushHistory('create', () => removeObject(obj.id));
  setSelected([obj.id]);
  interaction.preview = null;
  updatePropertiesBar();
}

// ── function: onTextDown ──
// ============================================================
// TEXT TOOL INTERACTIONS
// ============================================================
function onTextDown(sx, sy, wx, wy) {
  const textInput = document.getElementById('textInput');
  
  // Position the textarea at the click location (screen coords)
  textInput.style.display = 'block';
  textInput.style.left = sx + 'px';
  textInput.style.top = sy + 'px';
  textInput.value = '';
  textInput.focus();
  
  interaction.textPlacing = true;
  interaction.textWorldX = wx;
  interaction.textWorldY = wy;
}

// ── function: commitText ──
function commitText() {
  const textInput = document.getElementById('textInput');
  const text = textInput.value.trim();
  textInput.style.display = 'none';
  
  if (text) {
    const style = getDefaultStyle();
    // Measure text width
    ctx.font = `${CONFIG.DEFAULT_FONT_SIZE}px -apple-system, BlinkMacSystemFont, sans-serif`;
    const metrics = ctx.measureText(text);
    
    const obj = addObject({
      id: uid(), type: 'text',
      x: interaction.textWorldX,
      y: interaction.textWorldY,
      text: text,
      width: Math.max(metrics.width + 8, 60),
      height: CONFIG.DEFAULT_FONT_SIZE * 1.35,
      fontSize: CONFIG.DEFAULT_FONT_SIZE,
      strokeColor: style.strokeColor
    });
    
    pushHistory('create', () => removeObject(obj.id));
    setSelected([obj.id]);
    updatePropertiesBar();
  }
  
  interaction.textPlacing = false;
}

// ── function: onEraserDown ──
// ============================================================
// ERASER TOOL INTERACTIONS
// ============================================================
function onEraserDown(sx, sy, wx, wy) {
  interaction.active = true;
  interaction.tool = 'eraser';
  eraseAt(wx, wy);
}

// ── function: onEraserMove ──
function onEraserMove(sx, sy, wx, wy) {
  eraseAt(wx, wy);
}

// ── function: onEraserUp ──
function onEraserUp() {}

// ── function: eraseAt ──
function eraseAt(wx, wy) {
  const radius = CONFIG.ERASER_RADIUS / Viewport.zoom;
  let erased = false;
  
  for (const obj of [...scene]) {
    if (hitTestObject(obj, wx, wy)) {
      removeObject(obj.id);
      selectedIds.delete(obj.id);
      erased = true;
    }
  }
  
  if (erased) {
    requestRender();
  }
}

// ── function: onPanMove ──
// ============================================================
// PAN INTERACTIONS
// ============================================================
function onPanMove(sx, sy) {
  const dx = sx - interaction.startX;
  const dy = sy - interaction.startY;
  Viewport.pan(dx, dy);
  interaction.startX = sx;
  interaction.startY = sy;
  requestRender();
}

// ── function: onPanUp ──
function onPanUp() {}

// ── function: findObjectAt ──
// ============================================================
// FIND OBJECT AT WORLD POSITION (top-most first)
// ============================================================
function findObjectAt(wx, wy) {
  // Search in reverse order (top-most objects first)
  for (let i = scene.length - 1; i >= 0; i--) {
    if (hitTestObject(scene[i], wx, wy)) return scene[i];
  }
  return null;
}

// ── function: onKeyDown ──
// ============================================================
// KEYBOARD HANDLERS
// ============================================================
function onKeyDown(e) {
  window._lastShiftState = e.shiftKey;
  
  // Don't handle keys when typing in text input
  if (document.getElementById('textInput').style.display !== 'none' && 
      document.activeElement === document.getElementById('textInput')) return;

  // Space for pan
  if (e.code === 'Space' && !spaceDown) {
    spaceDown = true;
    e.preventDefault();
    canvas.classList.add('cursor-grab');
    updateCursor();
  }

  // Tool shortcuts
  if (!e.ctrlKey && !e.metaKey) {
    switch(e.key.toLowerCase()) {
      case 'v': setTool('select'); break;
      case 'p': setTool('pen'); break;
      case 'r': setTool('rect'); break;
      case 'o': setTool('ellipse'); break;
      case 'l': setTool('line'); break;
      case 't': setTool('text'); break;
      case 'e': setTool('eraser'); break;
    }
  }

  // Undo/Redo
  if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') {
    e.preventDefault();
    if (e.shiftKey) {
      redo();
    } else {
      undo();
    }
    requestRender();
    return;
  }

  // Delete selected objects
  if ((e.key === 'Delete' || e.key === 'Backspace') && selectedIds.size > 0) {
    const removed = getSelectedObjects().map(o => deepClone(o));
    for (const id of [...selectedIds]) removeObject(id);
    pushHistory('delete', () => {
      for (const obj of removed) addObject(obj);
      setSelected(removed.map(o => o.id));
    });
    selectedIds.clear();
    updatePropertiesBar();
    requestRender();
    return;
  }

  // Arrow key nudging
  if (selectedIds.size > 0 && ['ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.key)) {
    e.preventDefault();
    const step = e.shiftKey ? 10 : 1;
    let dx = 0, dy = 0;
    switch(e.key) {
      case 'ArrowUp': dy = -step; break;
      case 'ArrowDown': dy = step; break;
      case 'ArrowLeft': dx = -step; break;
      case 'ArrowRight': dx = step; break;
    }
    
    const origData = {};
    for (const id of selectedIds) {
      const obj = getObject(id);
      if (obj) origData[id] = deepClone(obj);
    }
    
    for (const id of selectedIds) {
      const obj = getObject(id);
      if (!obj) continue;
      switch(obj.type) {
        case 'rect': case 'ellipse': case 'text':
          obj.x += dx; obj.y += dy; break;
        case 'line':
          obj.x1 += dx; obj.y1 += dy; obj.x2 += dx; obj.y2 += dy; break;
        case 'freehand':
          obj.points = obj.points.map(p => ({ x: p.x + dx, y: p.y + dy })); break;
      }
    }
    
    pushHistory('nudge', () => {
      for (const [id, orig] of Object.entries(origData)) {
        const obj = getObject(id);
        if (obj) Object.assign(obj, deepClone(orig));
      }
    });
    
    requestRender();
  }

  // Escape to deselect / cancel
  if (e.key === 'Escape') {
    clearSelection();
    interaction.active = false;
    interaction.preview = null;
    updatePropertiesBar();
    requestRender();
  }
}

// ── function: onKeyUp ──
function onKeyUp(e) {
  window._lastShiftState = e.shiftKey;
  
  if (e.code === 'Space') {
    spaceDown = false;
    updateCursor();
  }
}

// ── function: onWheel ──
// ============================================================
// WHEEL HANDLER (zoom toward cursor)
// ============================================================
function onWheel(e) {
  e.preventDefault();
  const zoomFactor = e.deltaY < 0 ? CONFIG.ZOOM_STEP : 1/CONFIG.ZOOM_STEP;
  Viewport.zoomAt(e.clientX, e.clientY, Viewport.zoom * zoomFactor);
  updateZoomDisplay();
  requestRender();
}

// ── function: renderLoop ──
// ============================================================
// RENDER LOOP
// ============================================================
function renderLoop() {
  if (needsRender) render();
  requestAnimationFrame(renderLoop);
}

// ── function: drawGrid ──
function drawGrid(w, h) {
  const spacing = CONFIG.GRID_SPACING;
  const dotR = CONFIG.GRID_DOT_RADIUS / Viewport.zoom;

  // Calculate visible world bounds
  const tl = Viewport.s2w(0, 0);
  const br = Viewport.s2w(w, h);

  // Snap to grid
  const startX = Math.floor(tl.x / spacing) * spacing;
  const startY = Math.floor(tl.y / spacing) * spacing;
  const endX = Math.ceil(br.x / spacing) * spacing;
  const endY = Math.ceil(br.y / spacing) * spacing;

  ctx.fillStyle = CONFIG.GRID_COLOR;
  for (let x = startX; x <= endX; x += spacing) {
    for (let y = startY; y <= endY; y += spacing) {
      ctx.beginPath();
      ctx.arc(x, y, dotR, 0, Math.PI * 2);
      ctx.fill();
    }
  }
}

// ── function: onShapeDown ──
function onShapeDown(type, sx, sy, wx, wy) {
  const style = getDefaultStyle();
  interaction.active = true;
  interaction.tool = type;
  interaction.startX = sx;
  interaction.startY = sy;
  interaction.startWX = wx;
  interaction.startWY = wy;
  
  if (type === 'line') {
    interaction.preview = {
      type: 'line',
      x1: wx, y1: wy, x2: wx, y2: wy,
      arrowhead: window._lastShiftState || false,
      strokeColor: style.strokeColor,
      strokeWidth: style.strokeWidth
    };
  } else {
    interaction.preview = {
      type: type,
      x: wx, y: wy, width: 0, height: 0,
      strokeColor: style.strokeColor,
      fillColor: style.fillColor || 'transparent',
      strokeWidth: style.strokeWidth
    };
  }
  
  requestRender();
}

// ── function: onSelectDown ──
function onSelectDown(sx, sy, wx, wy) {
  // Check if clicking a handle first
  const bounds = getSelectionBounds();
  if (bounds && selectedIds.size > 0) {
    const handles = getSelectionHandles(bounds);
    const hitHandle = hitTestHandle(handles, wx, wy);
    if (hitHandle) {
      interaction.active = true;
      interaction.tool = 'select';
      interaction.startX = sx;
      interaction.startY = sy;
      interaction.resizingHandle = hitHandle;
      interaction.movingObjects = false;
      interaction.rubberBand = null;
      // Store original bounds for each selected object
      interaction.resizeOrigins = {};
      for (const id of selectedIds) {
        const obj = getObject(id);
        if (obj) interaction.resizeOrigins[id] = deepClone(obj);
      }
      return;
    }
  }

  // Check if clicking on an object
  const hitObj = findObjectAt(wx, wy);
  
  if (hitObj) {
    if (window._lastShiftState) {
      // Toggle selection
      if (selectedIds.has(hitObj.id)) {
        removeSelection(hitObj.id);
      } else {
        addSelection(hitObj.id);
      }
    } else if (selectedIds.has(hitObj.id) && selectedIds.size > 1) {
      // Clicking on an already-selected object in a multi-selection: keep selection, enter move mode
    } else {
      // Clicking on unselected object or single selection: select just this one
      setSelected([hitObj.id]);
    }
    
    interaction.active = true;
    interaction.tool = 'select';
    interaction.startX = sx;
    interaction.startY = sy;
    interaction.movingObjects = true;
    interaction.rubberBand = null;
    
    // Store original positions for undo
    interaction.moveOrigins = {};
    for (const id of selectedIds) {
      const obj = getObject(id);
      if (obj) interaction.moveOrigins[id] = deepClone(obj);
    }
  } else {
    // Clicking on empty space
    if (selectedIds.size > 0 && !window._lastShiftState) {
      // Objects are selected but clicked on empty space: enter move mode for all selected objects
      interaction.active = true;
      interaction.tool = 'select';
      interaction.startX = sx;
      interaction.startY = sy;
      interaction.movingObjects = true;
      interaction.rubberBand = null;
      
      // Store original positions for undo
      interaction.moveOrigins = {};
      for (const id of selectedIds) {
        const obj = getObject(id);
        if (obj) interaction.moveOrigins[id] = deepClone(obj);
      }
    } else {
      // No selection or shift held: start rubber-band selection
      if (!window._lastShiftState) {
        clearSelection();
      }
      interaction.active = true;
      interaction.tool = 'select';
      interaction.startX = sx;
      interaction.startY = sy;
      interaction.movingObjects = false;
      interaction.rubberBand = { active: true, x1: sx, y1: sy, x2: sx, y2: sy };
    }
  }
  
  updatePropertiesBar();
  requestRender();
}

// ── main ──
// ============================================================
// MAIN INITIALIZATION
// ============================================================
(function init() {
  // Initialize canvas
  initCanvas();

  // Load saved scene from localStorage
  loadFromStorage();

  // Set initial tool
  setTool('select');
  updateZoomDisplay();

  // --- Canvas event listeners ---
  canvas.addEventListener('pointerdown', onPointerDown, { passive: false });
  window.addEventListener('pointermove', onPointerMove, { passive: false });
  window.addEventListener('pointerup', onPointerUp);
  canvas.addEventListener('wheel', onWheel, { passive: false });

  // Prevent context menu on right click
  canvas.addEventListener('contextmenu', e => e.preventDefault());

  // --- Keyboard listeners ---
  window.addEventListener('keydown', onKeyDown);
  window.addEventListener('keyup', onKeyUp);

  // --- Text input events ---
  const textInput = document.getElementById('textInput');
  textInput.addEventListener('blur', commitText);
  textInput.addEventListener('keydown', function(e) {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      commitText();
    }
    if (e.key === 'Escape') {
      textInput.style.display = 'none';
      interaction.textPlacing = false;
    }
  });

  // --- Toolbar button events ---
  document.querySelectorAll('.tool-btn[data-tool]').forEach(btn => {
    btn.addEventListener('click', () => setTool(btn.dataset.tool));
  });

  // Undo/Redo buttons
  document.getElementById('undoBtn').addEventListener('click', () => {
    undo(); requestRender();
  });
  document.getElementById('redoBtn').addEventListener('click', () => {
    redo(); requestRender();
  });

  // Zoom controls
  document.getElementById('zoomInBtn').addEventListener('click', () => {
    const cx = window.innerWidth / 2;
    const cy = window.innerHeight / 2;
    Viewport.zoomAt(cx, cy, Viewport.zoom * CONFIG.ZOOM_STEP);
    updateZoomDisplay(); requestRender();
  });
  document.getElementById('zoomOutBtn').addEventListener('click', () => {
    const cx = window.innerWidth / 2;
    const cy = window.innerHeight / 2;
    Viewport.zoomAt(cx, cy, Viewport.zoom / CONFIG.ZOOM_STEP);
    updateZoomDisplay(); requestRender();
  });
  document.getElementById('resetViewBtn').addEventListener('click', () => {
    Viewport.reset();
    updateZoomDisplay(); requestRender();
  });
  document.getElementById('zoomLevel').addEventListener('click', () => {
    Viewport.reset();
    updateZoomDisplay(); requestRender();
  });

  // --- Properties bar events ---
  document.getElementById('strokeColor').addEventListener('input', applyStyleToSelection);
  document.getElementById('fillColor').addEventListener('input', applyStyleToSelection);
  document.getElementById('strokeWidth').addEventListener('change', applyStyleToSelection);

  // --- Auto-save on changes (debounced) ---
  let saveTimer = null;
  const origRequestRender = requestRender;
  
  // Override to also trigger auto-save
  window._autoSave = function() {
    if (saveTimer) clearTimeout(saveTimer);
    saveTimer = setTimeout(() => {
      saveToStorage();
    }, 500);
  };

  // Patch pushHistory to trigger auto-save
  const origPushHistory = pushHistory;
  
  // --- Start render loop ---
  requestRender();
  renderLoop();

  // Hide hint bar after a few seconds
  setTimeout(() => {
    const hint = document.getElementById('hintBar');
    if (hint) {
      hint.style.transition = 'opacity 1s ease';
      hint.style.opacity = '0';
      setTimeout(() => hint.remove(), 1000);
    }
  }, 5000);

})();
</script>
</body>
</html>