← Whiteboard results

Whiteboard

Qwen 3.6 27b MTP · 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 56,770 bytes · SHA-256 38b8b2cae7d3
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Whiteboard</title>
<style>
/* ============================================================
   CSS — App chrome, toolbar, properties bar, canvas overlay
   ============================================================ */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  overflow: hidden;
  background: #f8f9fa;
  color: #343a40;
  user-select: none;
  -webkit-user-select: none;
}

#app {
  width: 100vw;
  height: 100vh;
  position: relative;
  overflow: hidden;
}

canvas {
  display: block;
  cursor: crosshair;
}

/* ---------- Toolbar ---------- */
#toolbar {
  position: absolute;
  top: 14px;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  gap: 2px;
  background: #fff;
  border-radius: 12px;
  box-shadow: 0 2px 16px rgba(0,0,0,.1), 0 0 0 1px rgba(0,0,0,.05);
  padding: 4px;
  z-index: 100;
}

.tool-btn {
  width: 40px;
  height: 40px;
  border: none;
  background: transparent;
  border-radius: 8px;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  color: #6c757d;
  font-size: 16px;
  transition: background .12s, color .12s, box-shadow .12s;
  position: relative;
}

.tool-btn:hover { background: #f0f2f5; }
.tool-btn.active { background: #e8edff; color: #4a6cf7; font-weight: 600; }
.tool-btn.active::after {
  content: '';
  position: absolute;
  bottom: 3px;
  left: 50%;
  transform: translateX(-50%);
  width: 18px;
  height: 2.5px;
  background: #4a6cf7;
  border-radius: 2px;
}

.tool-btn svg { width: 20px; height: 20px; pointer-events: none; }

.tool-btn .shortcut {
  position: absolute;
  bottom: -1px;
  right: 4px;
  font-size: 8px;
  color: #adb5bd;
  pointer-events: none;
  line-height: 1;
}

/* ---------- Properties Bar ---------- */
#properties-bar {
  position: absolute;
  top: 62px;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  gap: 14px;
  align-items: center;
  background: #fff;
  border-radius: 12px;
  box-shadow: 0 2px 16px rgba(0,0,0,.1), 0 0 0 1px rgba(0,0,0,.05);
  padding: 8px 16px;
  z-index: 100;
  opacity: 0;
  pointer-events: none;
  transition: opacity .15s ease;
}

#properties-bar.visible {
  opacity: 1;
  pointer-events: auto;
}

.prop-group {
  display: flex;
  align-items: center;
  gap: 7px;
}

.prop-label {
  font-size: 10px;
  color: #adb5bd;
  text-transform: uppercase;
  letter-spacing: .6px;
  font-weight: 600;
}

.color-swatch-btn {
  width: 26px;
  height: 26px;
  border-radius: 7px;
  border: 2.5px solid #e9ecef;
  cursor: pointer;
  transition: border-color .1s, transform .1s;
}

.color-swatch-btn:hover { transform: scale(1.08); }
.color-swatch-btn.active { border-color: #4a6cf7; }

#stroke-width-slider {
  width: 72px;
  accent-color: #4a6cf7;
  cursor: pointer;
}

/* ---------- Zoom Indicator ---------- */
#zoom-indicator {
  position: absolute;
  bottom: 14px;
  right: 14px;
  display: flex;
  align-items: center;
  gap: 6px;
  background: #fff;
  border-radius: 10px;
  box-shadow: 0 2px 12px rgba(0,0,0,.08), 0 0 0 1px rgba(0,0,0,.05);
  padding: 6px 4px;
  z-index: 100;
  font-size: 13px;
  color: #6c757d;
}

#zoom-indicator button {
  width: 30px;
  height: 30px;
  border: none;
  background: transparent;
  border-radius: 7px;
  cursor: pointer;
  font-size: 16px;
  color: #6c757d;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: background .1s;
}

#zoom-indicator button:hover { background: #f0f2f5; }

/* ---------- Undo/Redo buttons ---------- */
#history-btns {
  position: absolute;
  top: 14px;
  right: 18px;
  display: flex;
  gap: 4px;
  z-index: 100;
}

.history-btn {
  width: 36px;
  height: 36px;
  border: none;
  background: #fff;
  border-radius: 9px;
  box-shadow: 0 2px 10px rgba(0,0,0,.08), 0 0 0 1px rgba(0,0,0,.05);
  cursor: pointer;
  font-size: 17px;
  color: #6c757d;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: background .1s;
}

.history-btn:hover { background: #f0f2f5; }
.history-btn.disabled { opacity: .3; pointer-events: none; }

/* ---------- Text input overlay ---------- */
#text-input-overlay {
  position: absolute;
  z-index: 200;
  display: none;
}

#text-input-overlay textarea {
  border: 2px solid #4a6cf7;
  border-radius: 5px;
  padding: 5px 9px;
  font-family: inherit;
  outline: none;
  resize: none;
  min-width: 120px;
  background: rgba(255,255,255,.97);
  box-shadow: 0 4px 16px rgba(0,0,0,.12);
}

/* ---------- Color picker popup ---------- */
.color-picker-popup {
  position: absolute;
  top: 38px;
  left: -10px;
  background: #fff;
  border-radius: 10px;
  box-shadow: 0 6px 24px rgba(0,0,0,.15);
  padding: 10px;
  display: none;
  z-index: 300;
}

.color-picker-popup.visible { display: block; }

.color-grid {
  display: grid;
  grid-template-columns: repeat(6, 26px);
  gap: 5px;
}

.color-grid .color-swatch { width: 26px; height: 26px; border-radius: 6px; cursor: pointer; transition: transform .1s; }
.color-grid .color-swatch:hover { transform: scale(1.15); }

/* ---------- Help hint ---------- */
#help-hint {
  position: absolute;
  bottom: 14px;
  left: 14px;
  font-size: 11px;
  color: #adb5bd;
  z-index: 100;
  pointer-events: none;
}
</style>
</head>
<body>
<div id="app">
  <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" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3l7.07 16.97 2.51-7.39 7.39-2.51L3 3z"/><path d="M13 13l6 6"/></svg>
      <span class="shortcut">V</span>
    </button>
    <button class="tool-btn" data-tool="freehand" title="Freehand (P)">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/></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="2"><rect x="3" y="3" width="18" height="18" rx="2"/></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="2"><ellipse cx="12" cy="12" rx="10" ry="8"/></svg>
      <span class="shortcut">O</span>
    </button>
    <button class="tool-btn" data-tool="line" title="Line/Arrow (L)">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="5" y1="19" x2="19" y2="5"/><polyline points="10,5 19,5 19,14"/></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="2"><polyline points="4,7 4,4 20,4 20,7"/><line x1="9.5" y1="4" x2="9.5" y2="20"/><line x1="14.5" y1="4" x2="14.5" y2="20"/><line x1="7" y1="20" x2="17" y2="20"/></svg>
      <span class="shortcut">T</span>
    </button>
    <button class="tool-btn" data-tool="eraser" title="Eraser (E)">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 20H7L3 16l9-9 8 8-4 5z"/><line x1="6" y1="20" x2="20" y2="20"/></svg>
      <span class="shortcut">E</span>
    </button>
  </div>

  <!-- Properties Bar -->
  <div id="properties-bar">
    <div class="prop-group">
      <span class="prop-label">Stroke</span>
      <div style="position:relative">
        <div class="color-swatch-btn" id="stroke-color-btn" onclick="toggleColorPicker('stroke')"></div>
        <div class="color-picker-popup" id="stroke-color-picker"></div>
      </div>
    </div>
    <div class="prop-group">
      <span class="prop-label">Fill</span>
      <div style="position:relative">
        <div class="color-swatch-btn" id="fill-color-btn" onclick="toggleColorPicker('fill')"></div>
        <div class="color-picker-popup" id="fill-color-picker"></div>
      </div>
    </div>
    <div class="prop-group">
      <span class="prop-label">Width</span>
      <input type="range" id="stroke-width-slider" min="1" max="20" value="2">
      <span id="stroke-width-val" style="font-size:12px;min-width:18px;color:#6c757d;">2</span>
    </div>
  </div>

  <!-- History buttons -->
  <div id="history-btns">
    <button class="history-btn disabled" id="undo-btn" title="Undo (Ctrl+Z)">↩</button>
    <button class="history-btn disabled" id="redo-btn" title="Redo (Ctrl+Shift+Z)">↪</button>
  </div>

  <!-- Zoom indicator -->
  <div id="zoom-indicator">
    <button onclick="zoomBy(-1)" title="Zoom out">−</button>
    <span id="zoom-level" style="min-width:48px;text-align:center;">100%</span>
    <button onclick="zoomBy(1)" title="Zoom in">+</button>
    <div style="width:1px;height:20px;background:#e9ecef;margin:0 2px;"></div>
    <button onclick="resetView()" title="Reset view" style="font-size:11px;width:auto;padding:0 10px;font-weight:600;">Fit</button>
  </div>

  <!-- Text input overlay -->
  <div id="text-input-overlay">
    <textarea id="text-area" rows="1"></textarea>
  </div>

  <!-- Help hint -->
  <div id="help-hint">Space+drag to pan · Scroll to zoom</div>
</div>

<script>
/* ============================================================
   CONSTANTS — Tunable parameters
   ============================================================ */
const C = {
  // Colors
  PALETTE: [
    '#343a40', '#e63946', '#f77f00', '#fcbf49', '#06d6a0', '#118ab2',
    '#073b4c', '#ef476f', '#ffd166', '#06d6a0', '#118ab2', '#8338ec',
  ],
  STROKE_DEFAULT: '#343a40',
  FILL_DEFAULT: 'transparent',
  STROKE_WIDTH_DEFAULT: 2,

  // Canvas
  BG_COLOR: '#f8f9fa',
  GRID_COLOR: '#dee2e6',
  GRID_SPACING: 24,

  // Zoom
  ZOOM_MIN: 0.15,
  ZOOM_MAX: 5,
  ZOOM_STEP: 1.12,

  // Selection
  HANDLE_SIZE: 8,       // screen pixels for handles
  HIT_TOLERANCE: 6,     // px for stroke hit-testing (screen)
  RUBBER_BAND_BORDER: '#4a6cf7',

  // Eraser
  ERASER_HIT_RADIUS: 12,

  // Persistence
  STORAGE_KEY: 'whiteboard_scene_v3',
};

/* ============================================================
   STATE — Scene, viewport, tools, history
   ============================================================ */
let scene = [];          // Array of element objects
let selectedIds = new Set();
let currentTool = 'select';
let nextId = 1;

// Viewport transform: world → screen via (x * zoom + panX, y * zoom + panY)
let view = { x: 0, y: 0, zoom: 1 };

// Current style defaults (for new objects) and selection overrides
let currentStyle = {
  strokeColor: C.STROKE_DEFAULT,
  fillColor: C.FILL_DEFAULT,
  strokeWidth: C.STROKE_WIDTH_DEFAULT,
};

// Interaction state — set during mouse drag operations
let interaction = null;

// History stacks
let undoStack = [];
let redoStack = [];

// Canvas refs
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

/* ============================================================
   UTILITY FUNCTIONS
   ============================================================ */
function genId() { return nextId++; }

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

// Screen → world coordinates
function screenToWorld(sx, sy) {
  return { x: (sx - view.x) / view.zoom, y: (sy - view.y) / view.zoom };
}

// World → screen coordinates
function worldToScreen(wx, wy) {
  return { x: wx * view.zoom + view.x, y: wy * view.zoom + view.y };
}

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);
}

/* ============================================================
   ELEMENT MODEL — creation helpers
   ============================================================ */
function createRect(x, y, w, h) {
  return { id: genId(), type: 'rectangle', x, y, width: Math.abs(w), height: Math.abs(h), strokeColor: currentStyle.strokeColor, fillColor: currentStyle.fillColor, strokeWidth: currentStyle.strokeWidth };
}

function createEllipse(cx, cy, rx, ry) {
  return { id: genId(), type: 'ellipse', cx, cy, rx: Math.max(0.1, Math.abs(rx)), ry: Math.max(0.1, Math.abs(ry)), strokeColor: currentStyle.strokeColor, fillColor: currentStyle.fillColor, strokeWidth: currentStyle.strokeWidth };
}

function createLine(x1, y1, x2, y2) {
  return { id: genId(), type: 'line', x1, y1, x2, y2, strokeColor: currentStyle.strokeColor, strokeWidth: currentStyle.strokeWidth, arrow: true };
}

function createFreehand(points) {
  return { id: genId(), type: 'freehand', points: points.map(p => ({...p})), strokeColor: currentStyle.strokeColor, strokeWidth: currentStyle.strokeWidth };
}

function createText(x, y, text) {
  return { id: genId(), type: 'text', x, y, text: text || '', fontSize: 18, color: currentStyle.strokeColor };
}

/* ============================================================
   HIT TESTING — all in world coordinates
   ============================================================ */
function hitTestElement(el, wx, wy) {
  const tol = C.HIT_TOLERANCE / view.zoom; // scale tolerance with zoom for pixel-accurate selection
  switch (el.type) {
    case 'rectangle': return hitRect(el.x, el.y, el.width, el.height, wx, wy, tol);
    case 'ellipse':   return hitEllipse(el.cx, el.cy, el.rx, el.ry, wx, wy, tol);
    case 'line':      return hitLine(el.x1, el.y1, el.x2, el.y2, wx, wy, tol);
    case 'freehand':  return hitFreehand(el.points, wx, wy, tol, el.strokeWidth || C.STROKE_WIDTH_DEFAULT);
    case 'text': {
      const tw = measureTextWidth(el.text || 'A', el.fontSize);
      const th = (el.fontSize || 18) * 1.3;
      return hitRect(el.x - 4/view.zoom, el.y - th - 2/view.zoom, tw + 8/view.zoom, th + 4/view.zoom, wx, wy, tol);
    }
  }
  return false;
}

function hitRect(x, y, w, h, px, py, tol) {
  const minX = Math.min(x, x + w), maxX = Math.max(x, x + w);
  const minY = Math.min(y, y + h), maxY = Math.max(y, y + h);
  // Inside test
  if (px >= minX && px <= maxX && py >= minY && py <= maxY) return true;
  // Near border test
  const nearX = px >= minX - tol && px <= maxX + tol;
  const nearY = py >= minY - tol && py <= maxY + tol;
  if (!nearX || !nearY) return false;
  // Check proximity to edges
  const dLeft = Math.abs(px - minX), dRight = Math.abs(px - maxX);
  const dTop = Math.abs(py - minY), dBottom = Math.abs(py - maxY);
  if (px >= minX && px <= maxX && (dTop <= tol || dBottom <= tol)) return true;
  if (py >= minY && py <= maxY && (dLeft <= tol || dRight <= tol)) return true;
  // Corner proximity
  const corners = [[minX,minY],[maxX,minY],[minX,maxY],[maxX,maxY]];
  for (const [cx,cy] of corners) {
    if (Math.hypot(px - cx, py - cy) <= tol * 1.5) return true;
  }
  return false;
}

function hitEllipse(cx, cy, rx, ry, px, py, tol) {
  // Proper ellipse equation: ((x-cx)/rx)^2 + ((y-cy)/ry)^2 <= 1
  if (rx < tol && ry < tol) return Math.hypot(px - cx, py - cy) <= tol;
  const dx = (px - cx) / (rx || tol);
  const dy = (py - cy) / (ry || tol);
  const v = dx * dx + dy * dy;
  if (v <= 1.0) return true; // inside ellipse
  // Near border? Check distance to ellipse boundary
  if (v > 1.0 && v < 2.5) {
    const angle = Math.atan2(dy, dx);
    const ex = cx + rx * Math.cos(angle);
    const ey = cy + ry * Math.sin(angle);
    return Math.hypot(px - ex, py - ey) <= tol;
  }
  return false;
}

function hitLine(x1, y1, x2, y2, px, py, tol) {
  return distPointToSegment(px, py, x1, y1, x2, y2) <= tol + (tol * 0.5);
}

function hitFreehand(points, px, py, tol, sw) {
  const effectiveTol = tol + (sw || C.STROKE_WIDTH_DEFAULT) / view.zoom;
  for (let i = 0; i < points.length - 1; i++) {
    if (distPointToSegment(px, py, points[i].x, points[i].y, points[i+1].x, points[i+1].y) <= effectiveTol) return true;
  }
  // Also check last point
  if (points.length > 0 && Math.hypot(px - points[points.length-1].x, py - points[points.length-1].y) <= effectiveTol) return true;
  return false;
}

function distPointToSegment(px, py, x1, y1, x2, y2) {
  const dx = x2 - x1, dy = y2 - y1;
  const lenSq = dx * dx + dy * dy;
  if (lenSq === 0) return Math.hypot(px - x1, py - y1);
  let t = ((px - x1) * dx + (py - y1) * dy) / lenSq;
  t = clamp(t, 0, 1);
  return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy));
}

function measureTextWidth(text, fontSize) {
  ctx.font = `${fontSize || 18}px -apple-system, BlinkMacSystemFont, sans-serif`;
  return ctx.measureText(text || 'A').width;
}

/* ============================================================
   SELECTION HELPERS
   ============================================================ */
function getSelection() {
  return scene.filter(el => selectedIds.has(el.id));
}

function getSelectionBounds() {
  const sel = getSelection();
  if (!sel.length) return null;
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
  for (const el of sel) {
    const b = getElementBounds(el);
    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 };
}

function getElementBounds(el) {
  switch (el.type) {
    case 'rectangle':
      return { x: Math.min(el.x, el.x + el.width), y: Math.min(el.y, el.y + el.height), w: Math.abs(el.width), h: Math.abs(el.height) };
    case 'ellipse':
      return { x: el.cx - el.rx, y: el.cy - el.ry, w: el.rx * 2, h: el.ry * 2 };
    case 'line':
      return { x: Math.min(el.x1, el.x2), y: Math.min(el.y1, el.y2), w: Math.abs(el.x2 - el.x1) || 0.01, h: Math.abs(el.y2 - el.y1) || 0.01 };
    case 'freehand': {
      if (!el.points.length) return { x: 0, y: 0, w: 0, h: 0 };
      let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
      for (const p of el.points) {
        minX = Math.min(minX, p.x); minY = Math.min(minY, p.y);
        maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y);
      }
      return { x: minX, y: minY, w: maxX - minX || 0.01, h: maxY - minY || 0.01 };
    }
    case 'text': {
      const fs = el.fontSize || 18;
      const tw = measureTextWidth(el.text || 'A', fs);
      return { x: el.x, y: el.y - fs * 1.3, w: tw, h: fs * 1.3 };
    }
  }
  return { x: 0, y: 0, w: 0, h: 0 };
}

function hitTestSelectionHandle(wx, wy) {
  const bounds = getSelectionBounds();
  if (!bounds || bounds.w < 4 && bounds.h < 4) return null;
  // Handle size in screen pixels → world coords
  const hs = C.HANDLE_SIZE / view.zoom + 2/view.zoom;
  const corners = [
    { 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 },
  ];
  for (const c of corners) {
    if (Math.abs(wx - c.x) <= hs && Math.abs(wy - c.y) <= hs) return c.id;
  }
  return null;
}

/* ============================================================
   RENDERING — draw everything
   ============================================================ */
function render() {
  const W = window.innerWidth, H = window.innerHeight;
  ctx.clearRect(0, 0, W, H);

  // Background
  ctx.fillStyle = C.BG_COLOR;
  ctx.fillRect(0, 0, W, H);

  // Grid (in world coords)
  drawGrid(W, H);

  // Elements (apply viewport transform)
  ctx.save();
  ctx.translate(view.x, view.y);
  ctx.scale(view.zoom, view.zoom);

  for (const el of scene) {
    drawElement(el, selectedIds.has(el.id));
  }

  ctx.restore();

  // Overlays: selection boxes, handles, rubber-band preview
  drawOverlays(W, H);
}

function drawGrid(W, H) {
  const sp = C.GRID_SPACING;
  const tl = screenToWorld(0, 0);
  const br = screenToWorld(W, H);

  // Skip grid at extreme zooms
  if (view.zoom < 0.25 || view.zoom > 10) return;

  const startX = Math.floor(tl.x / sp) * sp;
  const startY = Math.floor(tl.y / sp) * sp;

  ctx.save();
  ctx.translate(view.x, view.y);
  ctx.scale(view.zoom, view.zoom);

  // Dot grid — constant screen-size dots
  const dotR = Math.max(0.8, 1.2 / view.zoom);
  ctx.fillStyle = C.GRID_COLOR;

  for (let x = startX; x <= br.x + sp; x += sp) {
    for (let y = startY; y <= br.y + sp; y += sp) {
      ctx.beginPath();
      ctx.arc(x, y, dotR, 0, Math.PI * 2);
      ctx.fill();
    }
  }

  ctx.restore();
}

function drawElement(el, selected) {
  const sw = el.strokeWidth || C.STROKE_WIDTH_DEFAULT;
  ctx.lineWidth = sw;
  ctx.lineCap = 'round';
  ctx.lineJoin = 'round';

  switch (el.type) {
    case 'rectangle':
      if (el.fillColor && el.fillColor !== 'transparent') {
        ctx.fillStyle = el.fillColor;
        ctx.fillRect(el.x, el.y, el.width, el.height);
      }
      ctx.strokeStyle = selected ? '#4a6cf7' : el.strokeColor;
      ctx.strokeRect(el.x, el.y, el.width, el.height);
      break;

    case 'ellipse': {
      const rx = Math.max(0.1, el.rx), ry = Math.max(0.1, el.ry);
      ctx.beginPath();
      ctx.ellipse(el.cx, el.cy, rx, ry, 0, 0, Math.PI * 2);
      if (el.fillColor && el.fillColor !== 'transparent') {
        ctx.fillStyle = el.fillColor;
        ctx.fill();
      }
      ctx.strokeStyle = selected ? '#4a6cf7' : el.strokeColor;
      ctx.stroke();
      break;
    }

    case 'line':
      ctx.beginPath();
      ctx.moveTo(el.x1, el.y1);
      ctx.lineTo(el.x2, el.y2);
      ctx.strokeStyle = selected ? '#4a6cf7' : el.strokeColor;
      ctx.stroke();
      // Arrow head
      if (el.arrow) {
        const angle = Math.atan2(el.y2 - el.y1, el.x2 - el.x1);
        const aLen = 10 + sw * 2;
        ctx.beginPath();
        ctx.moveTo(el.x2, el.y2);
        ctx.lineTo(el.x2 - aLen * Math.cos(angle - 0.4), el.y2 - aLen * Math.sin(angle - 0.4));
        ctx.moveTo(el.x2, el.y2);
        ctx.lineTo(el.x2 - aLen * Math.cos(angle + 0.4), el.y2 - aLen * Math.sin(angle + 0.4));
        ctx.stroke();
      }
      break;

    case 'freehand': {
      const pts = el.points;
      if (pts.length < 2) return;
      ctx.beginPath();
      ctx.moveTo(pts[0].x, pts[0].y);
      // Smooth with quadratic curves through midpoints
      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);
      }
      // Last segment
      if (pts.length > 2) {
        const last = pts[pts.length - 1];
        const prev = pts[pts.length - 2];
        ctx.quadraticCurveTo(prev.x, prev.y, last.x, last.y);
      } else {
        ctx.lineTo(pts[1].x, pts[1].y);
      }
      ctx.strokeStyle = selected ? '#4a6cf7' : el.strokeColor;
      ctx.stroke();
      break;
    }

    case 'text': {
      const fs = el.fontSize || 18;
      ctx.font = `${fs}px -apple-system, BlinkMacSystemFont, sans-serif`;
      ctx.fillStyle = selected ? '#4a6cf7' : (el.color || C.STROKE_DEFAULT);
      // Draw text with word wrapping
      const lines = wrapText(el.text || '', 300 / view.zoom);
      for (let i = 0; i < lines.length; i++) {
        ctx.fillText(lines[i], el.x, el.y + fs * 1.2 * i);
      }
      break;
    }
  }

  // Selection glow
  if (selected) {
    const b = getElementBounds(el);
    ctx.save();
    ctx.strokeStyle = 'rgba(74,108,247,.25)';
    ctx.lineWidth = sw + 6;
    switch (el.type) {
      case 'rectangle':
        ctx.strokeRect(b.x - 3, b.y - 3, b.w + 6, b.h + 6);
        break;
      case 'ellipse':
        ctx.beginPath();
        ctx.ellipse(el.cx, el.cy, Math.max(0.1,b.w/2)+3, Math.max(0.1,b.h/2)+3, 0, 0, Math.PI*2);
        ctx.stroke();
        break;
      case 'line':
        ctx.beginPath();
        ctx.moveTo(el.x1, el.y1);
        ctx.lineTo(el.x2, el.y2);
        ctx.stroke();
        break;
    }
    ctx.restore();
  }
}

function wrapText(text, maxWidth) {
  if (!text) return [''];
  const lines = text.split('\n');
  const result = [];
  for (const line of lines) {
    let current = '';
    for (let i = 0; i < line.length; i++) {
      const test = current + line[i];
      if (ctx.measureText(test).width > maxWidth && current) {
        result.push(current);
        current = line[i];
      } else {
        current = test;
      }
    }
    result.push(current);
  }
  return result.length ? result : [''];
}

function drawOverlays(W, H) {
  // Selection bounding box + handles (screen coords)
  const bounds = getSelectionBounds();
  if (bounds && selectedIds.size > 0) {
    ctx.save();
    ctx.translate(view.x, view.y);
    ctx.scale(view.zoom, view.zoom);

    const pad = 4 / view.zoom;
    ctx.strokeStyle = C.RUBBER_BAND_BORDER;
    ctx.lineWidth = 1.5 / view.zoom;
    ctx.setLineDash([5 / view.zoom, 3 / view.zoom]);
    ctx.strokeRect(bounds.x - pad, bounds.y - pad, bounds.w + pad * 2, bounds.h + pad * 2);
    ctx.setLineDash([]);

    // Handles (constant screen size)
    const hs = C.HANDLE_SIZE;
    const corners = [
      { x: bounds.x, y: bounds.y },
      { x: bounds.x + bounds.w, y: bounds.y },
      { x: bounds.x, y: bounds.y + bounds.h },
      { x: bounds.x + bounds.w, y: bounds.y + bounds.h },
    ];
    for (const c of corners) {
      const sp = worldToScreen(c.x, c.y);
      ctx.fillStyle = '#fff';
      ctx.strokeStyle = C.RUBBER_BAND_BORDER;
      ctx.lineWidth = 1.5;
      ctx.fillRect(sp.x - hs/2, sp.y - hs/2, hs, hs);
      ctx.strokeRect(sp.x - hs/2, sp.y - hs/2, hs, hs);
    }

    ctx.restore();
  }

  // Rubber-band selection rectangle (screen coords)
  if (interaction && interaction.type === 'rubberBand') {
    const x = Math.min(interaction.startS.x, interaction.currentS.x);
    const y = Math.min(interaction.startS.y, interaction.currentS.y);
    const w = Math.abs(interaction.currentS.x - interaction.startS.x);
    const h = Math.abs(interaction.currentS.y - interaction.startS.y);
    ctx.strokeStyle = C.RUBBER_BAND_BORDER;
    ctx.lineWidth = 1.5;
    ctx.setLineDash([6, 4]);
    ctx.strokeRect(x, y, w, h);
    ctx.fillStyle = 'rgba(74,108,247,.06)';
    ctx.fillRect(x, y, w, h);
    ctx.setLineDash([]);
  }

  // Live preview for shape creation (world coords)
  if (interaction && interaction.type === 'creating' && interaction.previewEl) {
    ctx.save();
    ctx.translate(view.x, view.y);
    ctx.scale(view.zoom, view.zoom);
    drawElement(interaction.previewEl, false);
    ctx.restore();
  }

  // Eraser cursor indicator (world coords)
  if (currentTool === 'eraser' && interaction && interaction.type === 'erasing') {
    const r = C.ERASER_HIT_RADIUS / view.zoom;
    ctx.save();
    ctx.translate(view.x, view.y);
    ctx.scale(view.zoom, view.zoom);
    ctx.beginPath();
    ctx.arc(interaction.currentW.x, interaction.currentW.y, r + 4/view.zoom, 0, Math.PI * 2);
    ctx.strokeStyle = 'rgba(230,57,70,.4)';
    ctx.lineWidth = 1.5 / view.zoom;
    ctx.setLineDash([4/view.zoom, 4/view.zoom]);
    ctx.stroke();
    ctx.setLineDash([]);
    ctx.restore();
  }
}

/* ============================================================
   HISTORY — Undo/Redo
   ============================================================ */
function pushHistory(action) {
  undoStack.push(action);
  redoStack = []; // clear redo on new action
  updateHistoryButtons();
}

function undo() {
  if (!undoStack.length) return;
  const action = undoStack.pop();
  redoStack.push(action);
  applyUndo(action);
  updateHistoryButtons();
  render();
}

function redo() {
  if (!redoStack.length) return;
  const action = redoStack.pop();
  undoStack.push(action);
  applyRedo(action);
  updateHistoryButtons();
  render();
}

function applyUndo(action) {
  switch (action.type) {
    case 'create':
      scene = scene.filter(el => !action.ids.has(el.id));
      for (const id of action.ids) selectedIds.delete(id);
      break;
    case 'delete':
      const restored = JSON.parse(JSON.stringify(action.deletedElements));
      for (const el of restored) {
        scene.push(el);
        if (el.id >= nextId) nextId = el.id + 1;
      }
      break;
    case 'modify':
      const prevMap = new Map(action.prevData);
      for (const [id, data] of prevMap) {
        const idx = scene.findIndex(el => el.id === id);
        if (idx >= 0) Object.assign(scene[idx], data);
      }
      break;
    case 'textEdit':
      if (action.prevData && action.id) {
        const idx = scene.findIndex(el => el.id === action.id);
        if (idx >= 0) scene[idx].text = action.prevText;
      }
      break;
  }
}

function applyRedo(action) {
  switch (action.type) {
    case 'create':
      const newEls = JSON.parse(JSON.stringify(action.createdElements));
      for (const el of newEls) {
        scene.push(el);
        if (el.id >= nextId) nextId = el.id + 1;
      }
      break;
    case 'delete':
      scene = scene.filter(el => !action.ids.has(el.id));
      for (const id of action.ids) selectedIds.delete(id);
      break;
    case 'modify':
      const newMap = new Map(action.newData);
      for (const [id, data] of newMap) {
        const idx = scene.findIndex(el => el.id === id);
        if (idx >= 0) Object.assign(scene[idx], data);
      }
      break;
    case 'textEdit':
      if (action.newText !== undefined && action.id) {
        const idx = scene.findIndex(el => el.id === action.id);
        if (idx >= 0) scene[idx].text = action.newText;
      }
      break;
  }
}

function updateHistoryButtons() {
  document.getElementById('undo-btn').classList.toggle('disabled', !undoStack.length);
  document.getElementById('redo-btn').classList.toggle('disabled', !redoStack.length);
}

/* ============================================================
   PERSISTENCE — localStorage
   ============================================================ */
function saveScene() {
  try {
    const data = JSON.stringify({ scene, nextId });
    localStorage.setItem(C.STORAGE_KEY, data);
  } catch (e) {}
}

function loadScene() {
  try {
    const raw = localStorage.getItem(C.STORAGE_KEY);
    if (!raw) return false;
    const data = JSON.parse(raw);
    scene = data.scene || [];
    nextId = data.nextId || 1;
    selectedIds.clear();
    return true;
  } catch (e) { return false; }
}

/* ============================================================
   ZOOM — toward cursor position
   ============================================================ */
function zoomAt(sx, sy, factor) {
  const oldZoom = view.zoom;
  const newZoom = clamp(oldZoom * factor, C.ZOOM_MIN, C.ZOOM_MAX);
  if (newZoom === oldZoom) return;

  // Keep world point under cursor stable during zoom
  const wx = (sx - view.x) / oldZoom;
  const wy = (sy - view.y) / oldZoom;
  view.zoom = newZoom;
  view.x = sx - wx * newZoom;
  view.y = sy - wy * newZoom;

  updateZoomDisplay();
}

function zoomBy(dir) {
  const cx = window.innerWidth / 2;
  const cy = window.innerHeight / 2;
  if (dir > 0) zoomAt(cx, cy, C.ZOOM_STEP);
  else zoomAt(cx, cy, 1 / C.ZOOM_STEP);
}

function resetView() {
  view.x = 0; view.y = 0; view.zoom = 1;
  updateZoomDisplay();
  render();
}

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

/* ============================================================
   MOUSE / POINTER EVENTS — the main interaction loop
   ============================================================ */
let spaceDown = false;

canvas.addEventListener('mousedown', onMouseDown);
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
canvas.addEventListener('wheel', onWheel, { passive: false });
canvas.addEventListener('contextmenu', e => e.preventDefault());

function getMousePos(e) {
  return { x: e.clientX, y: e.clientY };
}

function onMouseDown(e) {
  const s = getMousePos(e);
  const w = screenToWorld(s.x, s.y);

  // Middle mouse or space+left → pan
  if (e.button === 1 || (e.button === 0 && spaceDown)) {
    interaction = { type: 'panning', startS: s, viewStartX: view.x, viewStartY: view.y };
    canvas.style.cursor = 'grabbing';
    return;
  }

  if (e.button !== 0) return;

  switch (currentTool) {
    case 'select': handleSelectDown(s, w, e); break;
    case 'freehand': handleFreehandDown(s, w); break;
    case 'rectangle': handleShapeDown('rectangle', s, w); break;
    case 'ellipse': handleShapeDown('ellipse', s, w); break;
    case 'line': handleShapeDown('line', s, w); break;
    case 'text': handleTextDown(s, w); break;
    case 'eraser': handleEraserDown(s, w); break;
  }
}

function onMouseMove(e) {
  const s = getMousePos(e);
  const w = screenToWorld(s.x, s.y);

  if (!interaction) return;

  switch (interaction.type) {
    case 'panning': handlePanMove(s); break;
    case 'selecting': /* no-op */ break;
    case 'moving': handleMoveMove(w); break;
    case 'resizing': handleResizeMove(w, e); break;
    case 'rubberBand': handleRubberBandMove(s); break;
    case 'drawing': handleFreehandMove(w); break;
    case 'creating': handleShapeMove(w); break;
    case 'erasing': handleEraserMove(w); break;
  }

  render();
}

function onMouseUp(e) {
  const s = getMousePos(e);
  const w = screenToWorld(s.x, s.y);

  if (!interaction) return;

  switch (interaction.type) {
    case 'panning':
      canvas.style.cursor = toolCursor(currentTool);
      interaction = null;
      break;
    case 'selecting':
      selectedIds.clear();
      updatePropertiesBar();
      interaction = null;
      render();
      break;
    case 'moving':
      finishMove();
      interaction = null;
      break;
    case 'resizing':
      finishResize();
      interaction = null;
      break;
    case 'rubberBand':
      finishRubberBand(s);
      interaction = null;
      break;
    case 'drawing':
      finishFreehand();
      interaction = null;
      break;
    case 'creating':
      finishShapeCreation(w);
      interaction = null;
      break;
    case 'erasing':
      finishEraser();
      interaction = null;
      break;
  }

  render();
}

function onWheel(e) {
  e.preventDefault();
  const factor = e.deltaY < 0 ? C.ZOOM_STEP : 1 / C.ZOOM_STEP;
  zoomAt(e.clientX, e.clientY, factor);
  render();
}

/* ---------- Tool cursors ---------- */
function toolCursor(tool) {
  switch (tool) {
    case 'select': return 'default';
    case 'freehand': return 'crosshair';
    case 'rectangle': return 'crosshair';
    case 'ellipse': return 'crosshair';
    case 'line': return 'crosshair';
    case 'text': return 'text';
    case 'eraser': return 'pointer';
  }
  return 'default';
}

/* ============================================================
   SELECT TOOL HANDLERS
   ============================================================ */
function handleSelectDown(s, w, e) {
  // Check resize handles first (only if something is selected)
  if (selectedIds.size > 0) {
    const handle = hitTestSelectionHandle(w.x, w.y);
    if (handle) {
      interaction = { type: 'resizing', handle, startW: w, boundsStart: getSelectionBounds(), elementsSnapshot: snapshotElements(getSelection()) };
      return;
    }
  }

  // Hit test elements (reverse order for top-most first)
  const hitEl = findTopElementAt(w.x, w.y);

  if (hitEl) {
    if (e.shiftKey) {
      // Toggle selection
      if (selectedIds.has(hitEl.id)) selectedIds.delete(hitEl.id);
      else selectedIds.add(hitEl.id);
    } else if (!selectedIds.has(hitEl.id)) {
      selectedIds.clear();
      selectedIds.add(hitEl.id);
    }

    interaction = { type: 'moving', startW: w, elementsSnapshot: snapshotElements(getSelection()) };
  } else {
    // Start rubber-band selection (unless shift is held — then just deselect)
    if (!e.shiftKey) {
      selectedIds.clear();
      updatePropertiesBar();
      interaction = { type: 'rubberBand', startS: s, currentS: s };
    } else {
      selectedIds.clear();
      updatePropertiesBar();
      interaction = { type: 'selecting' };
    }
  }

  updatePropertiesBar();
  render();
}

function handleMoveMove(w) {
  const dx = w.x - interaction.startW.x;
  const dy = w.y - interaction.startW.y;
  applyDeltaToSelection(dx, dy);
}

function finishMove() {
  // Push history: modify action with previous positions
  const prevData = new Map();
  for (const id of selectedIds) {
    const snap = interaction.elementsSnapshot.get(id);
    if (snap) prevData.set(id, JSON.parse(JSON.stringify(snap)));
  }
  pushHistory({ type: 'modify', ids: selectedIds, prevData });
  saveScene();
}

function handleResizeMove(w, e) {
  const dx = w.x - interaction.startW.x;
  const dy = w.y - interaction.startW.y;
  const bs = interaction.boundsStart; // bounds at start of resize
  const shiftHeld = e.shiftKey;
  const handle = interaction.handle;

  // Calculate new selection bounds based on which handle is dragged
  let newX, newY, newW, newH;
  if (shiftHeld) {
    // Preserve aspect ratio — use the dominant axis
    const aspect = bs.w / (bs.h || 1);
    if (handle === 'nw') { newW = Math.max(5, bs.w - dx); newH = newW / aspect; newX = bs.x + bs.w - newW; newY = bs.y + bs.h - newH; }
    else if (handle === 'ne') { newW = Math.max(5, bs.w + dx); newH = newW / aspect; newX = bs.x; newY = bs.y + bs.h - newH; }
    else if (handle === 'sw') { newW = Math.max(5, bs.w - dx); newH = newW / aspect; newX = bs.x + bs.w - newW; newY = bs.y; }
    else { newW = Math.max(5, bs.w + dx); newH = newW / aspect; newX = bs.x; newY = bs.y; }
  } else {
    if (handle === 'nw') { newW = Math.max(5, bs.w - dx); newH = Math.max(5, bs.h - dy); newX = bs.x + bs.w - newW; newY = bs.y + bs.h - newH; }
    else if (handle === 'ne') { newW = Math.max(5, bs.w + dx); newH = Math.max(5, bs.h - dy); newX = bs.x; newY = bs.y + bs.h - newH; }
    else if (handle === 'sw') { newW = Math.max(5, bs.w - dx); newH = Math.max(5, bs.h + dy); newX = bs.x + bs.w - newW; newY = bs.y; }
    else { newW = Math.max(5, bs.w + dx); newH = Math.max(5, bs.h + dy); newX = bs.x; newY = bs.y; }
  }

  // Apply resize to each selected element proportionally within the selection bounds
  for (const el of getSelection()) {
    const eb = getElementBounds(el);
    const ratioX = bs.w ? (eb.x - bs.x) / bs.w : 0;
    const ratioY = bs.h ? (eb.y - bs.y) / bs.h : 0;

    // New bounds for this element within the resized selection
    const elNewX = newX + ratioX * newW;
    const elNewY = newY + ratioY * newH;
    const elNewW = eb.w * (newW / (bs.w || 1));
    const elNewH = eb.h * (newH / (bs.h || 1));

    applyResizeToElement(el, elNewX, elNewY, Math.max(2, elNewW), Math.max(2, elNewH), handle);
  }
}

function finishResize() {
  const prevData = new Map();
  for (const id of selectedIds) {
    const snap = interaction.elementsSnapshot.get(id);
    if (snap) prevData.set(id, JSON.parse(JSON.stringify(snap)));
  }
  pushHistory({ type: 'modify', ids: selectedIds, prevData });
  saveScene();
}

function applyResizeToElement(el, newX, newY, newW, newH, handle) {
  switch (el.type) {
    case 'rectangle':
      el.x = newX;
      el.y = newY;
      el.width = newW * Math.sign(el.width || 1);
      el.height = newH * Math.sign(el.height || 1);
      break;
    case 'ellipse':
      el.cx = newX + newW / 2;
      el.cy = newY + newH / 2;
      el.rx = Math.max(1, newW / 2);
      el.ry = Math.max(1, newH / 2);
      break;
    case 'line': {
      const oldB = getElementBounds(el);
      const scaleX = newW / (oldB.w || 0.01);
      const scaleY = newH / (oldB.h || 0.01);
      // Anchor at the opposite corner of the handle
      const ax = handle.includes('w') ? newX + newW : newX;
      const ay = handle.includes('s') ? newY + newH : newY;
      el.x1 = ax + (el.x1 - (oldB.x + oldB.w)) * scaleX;
      el.y1 = ay + (el.y1 - (oldB.y + oldB.h)) * scaleY;
      el.x2 = ax + (el.x2 - (oldB.x + oldB.w)) * scaleX;
      el.y2 = ay + (el.y2 - (oldB.y + oldB.h)) * scaleY;
      break;
    }
    case 'freehand': {
      const oldB = getElementBounds(el);
      const scaleX = newW / (oldB.w || 0.01);
      const scaleY = newH / (oldB.h || 0.01);
      const ax = handle.includes('w') ? newX + newW : newX;
      const ay = handle.includes('s') ? newY + newH : newY;
      for (const p of el.points) {
        p.x = ax + (p.x - (oldB.x + oldB.w)) * scaleX;
        p.y = ay + (p.y - (oldB.y + oldB.h)) * scaleY;
      }
      break;
    }
    case 'text':
      el.x = newX;
      // text y is baseline, bounds.y is top of text
      const fs = el.fontSize || 18;
      el.y = newY + newH;
      break;
  }
}

function handleRubberBandMove(s) {
  interaction.currentS = s;
}

function finishRubberBand(s) {
  const x1 = Math.min(interaction.startS.x, s.x);
  const y1 = Math.min(interaction.startS.y, s.y);
  const x2 = Math.max(interaction.startS.x, s.x);
  const y2 = Math.max(interaction.startS.y, s.y);

  // Convert to world coords for hit testing
  const tl = screenToWorld(x1, y1);
  const br = screenToWorld(x2, y2);

  for (const el of scene) {
    const b = getElementBounds(el);
    if (b.x < br.x && b.x + b.w > tl.x && b.y < br.y && b.y + b.h > tl.y) {
      selectedIds.add(el.id);
    }
  }

  updatePropertiesBar();
}

/* ============================================================
   FREEHAND TOOL HANDLERS
   ============================================================ */
function handleFreehandDown(s, w) {
  interaction = { type: 'drawing', points: [{ x: w.x, y: w.y }] };
}

function handleFreehandMove(w) {
  const last = interaction.points[interaction.points.length - 1];
  // Only add point if far enough from last (thinning for performance)
  const minDist = 0.5 / view.zoom;
  if (Math.hypot(w.x - last.x, w.y - last.y) > minDist || interaction.points.length < 2) {
    interaction.points.push({ x: w.x, y: w.y });
  }
}

function finishFreehand() {
  const pts = interaction.points;
  if (pts.length < 2) return; // Too short, discard

  const el = createFreehand(pts);
  scene.push(el);
  selectedIds.clear();
  selectedIds.add(el.id);

  pushHistory({ type: 'create', ids: new Set([el.id]), createdElements: [JSON.parse(JSON.stringify(el))] });
  updatePropertiesBar();
  saveScene();
}

/* ============================================================
   SHAPE CREATION HANDLERS (Rectangle, Ellipse, Line)
   ============================================================ */
function handleShapeDown(type, s, w) {
  let previewEl;
  if (type === 'rectangle') {
    previewEl = createRect(w.x, w.y, 0, 0);
  } else if (type === 'ellipse') {
    previewEl = createEllipse(w.x, w.y, 0.1, 0.1);
  } else if (type === 'line') {
    previewEl = createLine(w.x, w.y, w.x, w.y);
  }

  interaction = { type: 'creating', shapeType: type, startW: w, previewEl };
}

function handleShapeMove(w) {
  const s = interaction.startW;
  if (interaction.shapeType === 'rectangle') {
    let x = Math.min(s.x, w.x), y = Math.min(s.y, w.y);
    let wd = Math.abs(w.x - s.x), ht = Math.abs(w.y - s.y);
    interaction.previewEl.x = x;
    interaction.previewEl.y = y;
    interaction.previewEl.width = wd;
    interaction.previewEl.height = ht;
  } else if (interaction.shapeType === 'ellipse') {
    const cx = (s.x + w.x) / 2, cy = (s.y + w.y) / 2;
    interaction.previewEl.cx = cx;
    interaction.previewEl.cy = cy;
    interaction.previewEl.rx = Math.abs(w.x - s.x) / 2;
    interaction.previewEl.ry = Math.abs(w.y - s.y) / 2;
  } else if (interaction.shapeType === 'line') {
    interaction.previewEl.x1 = s.x;
    interaction.previewEl.y1 = s.y;
    interaction.previewEl.x2 = w.x;
    interaction.previewEl.y2 = w.y;
  }
}

function finishShapeCreation(w) {
  const el = JSON.parse(JSON.stringify(interaction.previewEl));
  // Validate minimum size
  if (el.type === 'rectangle' && el.width < 3 && el.height < 3) return;
  if (el.type === 'ellipse' && el.rx < 2 && el.ry < 2) return;
  if (el.type === 'line') {
    const len = Math.hypot(el.x2 - el.x1, el.y2 - el.y1);
    if (len < 3) return;
  }

  scene.push(el);
  selectedIds.clear();
  selectedIds.add(el.id);

  pushHistory({ type: 'create', ids: new Set([el.id]), createdElements: [JSON.parse(JSON.stringify(el))] });
  updatePropertiesBar();
  saveScene();
}

/* ============================================================
   TEXT TOOL HANDLERS
   ============================================================ */
function handleTextDown(s, w) {
  const overlay = document.getElementById('text-input-overlay');
  const textarea = document.getElementById('text-area');

  // Position in screen coords
  const sp = worldToScreen(w.x, w.y);
  overlay.style.display = 'block';
  overlay.style.left = sp.x + 'px';
  overlay.style.top = (sp.y - 2) + 'px';

  textarea.value = '';
  textarea.style.fontSize = '18px';
  textarea.style.color = currentStyle.strokeColor;
  textarea.focus();

  // Store world position for when text is committed
  interaction = { type: 'textInput', worldPos: w };

  textarea.onkeydown = (e) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      commitText();
    }
    if (e.key === 'Escape') {
      cancelTextInput();
    }
  };
}

function commitText() {
  const textarea = document.getElementById('text-area');
  const overlay = document.getElementById('text-input-overlay');
  overlay.style.display = 'none';

  const text = textarea.value.trim();
  if (!text) return;

  const el = createText(interaction.worldPos.x, interaction.worldPos.y + 18, text);
  scene.push(el);
  selectedIds.clear();
  selectedIds.add(el.id);

  pushHistory({ type: 'create', ids: new Set([el.id]), createdElements: [JSON.parse(JSON.stringify(el))] });
  updatePropertiesBar();
  saveScene();
  render();
}

function cancelTextInput() {
  const overlay = document.getElementById('text-input-overlay');
  overlay.style.display = 'none';
  interaction = null;
}

/* ============================================================
   ERASER TOOL HANDLERS
   ============================================================ */
let erasedIds = new Set();

function handleEraserDown(s, w) {
  erasedIds.clear();
  // Check if any element is under cursor
  for (const el of [...scene].reverse()) {
    if (hitTestElement(el, w.x, w.y)) {
      erasedIds.add(el.id);
      break; // only erase top-most at this point on mousedown
    }
  }
  interaction = { type: 'erasing', currentW: w };
}

function handleEraserMove(w) {
  interaction.currentW = w;
  for (const el of [...scene].reverse()) {
    if (!erasedIds.has(el.id) && hitTestElement(el, w.x, w.y)) {
      erasedIds.add(el.id);
    }
  }
}

function finishEraser() {
  if (!erasedIds.size) return;
  const deletedElements = scene.filter(el => erasedIds.has(el.id));
  scene = scene.filter(el => !erasedIds.has(el.id));
  for (const id of erasedIds) selectedIds.delete(id);

  pushHistory({ type: 'delete', ids: new Set(erasedIds), deletedElements });
  updatePropertiesBar();
  saveScene();
}

/* ============================================================
   PAN HANDLER
   ============================================================ */
function handlePanMove(s) {
  view.x = interaction.viewStartX + (s.x - interaction.startS.x);
  view.y = interaction.viewStartY + (s.y - interaction.startS.y);
}

/* ============================================================
   HELPERS — find element, snapshot, apply delta
   ============================================================ */
function findTopElementAt(wx, wy) {
  for (let i = scene.length - 1; i >= 0; i--) {
    if (hitTestElement(scene[i], wx, wy)) return scene[i];
  }
  return null;
}

function snapshotElements(elements) {
  const map = new Map();
  for (const el of elements) {
    map.set(el.id, JSON.parse(JSON.stringify(el)));
  }
  return map;
}

function applyDeltaToSelection(dx, dy) {
  for (const id of selectedIds) {
    const idx = scene.findIndex(el => el.id === id);
    if (idx < 0) continue;
    const el = scene[idx];
    switch (el.type) {
      case 'rectangle':
        el.x += dx; el.y += dy; break;
      case 'ellipse':
        el.cx += dx; el.cy += dy; break;
      case 'line':
        el.x1 += dx; el.y1 += dy; el.x2 += dx; el.y2 += dy; break;
      case 'freehand':
        for (const p of el.points) { p.x += dx; p.y += dy; } break;
      case 'text':
        el.x += dx; el.y += dy; break;
    }
  }
}

/* ============================================================
   KEYBOARD HANDLERS
   ============================================================ */
window.addEventListener('keydown', (e) => {
  // Don't handle if typing in text input
  const overlay = document.getElementById('text-input-overlay');
  if (overlay.style.display === 'block') return;

  // Space for panning
  if (e.code === 'Space' && !spaceDown) {
    e.preventDefault();
    spaceDown = true;
    canvas.style.cursor = 'grab';
  }

  // Tool shortcuts
  if (!e.ctrlKey && !e.metaKey) {
    switch (e.key.toLowerCase()) {
      case 'v': setTool('select'); break;
      case 'p': setTool('freehand'); break;
      case 'r': setTool('rectangle'); 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();
    return;
  }
  if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'y') {
    e.preventDefault();
    redo();
    return;
  }

  // Delete/Backspace
  if ((e.key === 'Delete' || e.key === 'Backspace') && selectedIds.size > 0) {
    e.preventDefault();
    deleteSelection();
    return;
  }

  // Arrow keys — nudge selection
  if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key) && selectedIds.size > 0) {
    e.preventDefault();
    const step = e.shiftKey ? 10 : 1;
    let dx = 0, dy = 0;
    if (e.key === 'ArrowUp') dy = -step;
    if (e.key === 'ArrowDown') dy = step;
    if (e.key === 'ArrowLeft') dx = -step;
    if (e.key === 'ArrowRight') dx = step;

    // Snapshot before nudge for undo
    const prevData = new Map();
    for (const id of selectedIds) {
      const idx = scene.findIndex(el => el.id === id);
      if (idx >= 0) prevData.set(id, JSON.parse(JSON.stringify(scene[idx])));
    }

    applyDeltaToSelection(dx, dy);
    pushHistory({ type: 'modify', ids: selectedIds, prevData });
    saveScene();
    render();
  }

  // Escape — deselect
  if (e.key === 'Escape') {
    selectedIds.clear();
    updatePropertiesBar();
    render();
  }
});

window.addEventListener('keyup', (e) => {
  if (e.code === 'Space') {
    spaceDown = false;
    canvas.style.cursor = toolCursor(currentTool);
  }
});

function deleteSelection() {
  const ids = new Set(selectedIds);
  const deletedElements = scene.filter(el => ids.has(el.id));
  scene = scene.filter(el => !ids.has(el.id));
  selectedIds.clear();

  pushHistory({ type: 'delete', ids, deletedElements });
  updatePropertiesBar();
  saveScene();
  render();
}

/* ============================================================
   TOOL SWITCHING & UI
   ============================================================ */
function setTool(tool) {
  currentTool = tool;
  document.querySelectorAll('.tool-btn').forEach(btn => {
    btn.classList.toggle('active', btn.dataset.tool === tool);
  });
  canvas.style.cursor = toolCursor(tool);

  // Show/hide properties bar based on selection
  updatePropertiesBar();
}

function updatePropertiesBar() {
  const bar = document.getElementById('properties-bar');
  if (selectedIds.size > 0) {
    bar.classList.add('visible');
    // Update swatches to reflect first selected element's style
    const sel = getSelection()[0];
    if (sel) {
      updateSwatch('stroke-color-btn', sel.strokeColor || C.STROKE_DEFAULT);
      updateSwatch('fill-color-btn', sel.fillColor || 'transparent');
      document.getElementById('stroke-width-slider').value = sel.strokeWidth || C.STROKE_WIDTH_DEFAULT;
      document.getElementById('stroke-width-val').textContent = sel.strokeWidth || C.STROKE_WIDTH_DEFAULT;
    }
  } else {
    bar.classList.remove('visible');
  }
}

function updateSwatch(id, color) {
  const el = document.getElementById(id);
  if (color === 'transparent') {
    el.style.background = 'repeating-conic-gradient(#ddd 0% 25%, #fff 0% 50%) 50% / 10px 10px';
  } else {
    el.style.background = color;
  }
}

/* ============================================================
   PROPERTIES BAR — Style changes
   ============================================================ */
document.getElementById('stroke-width-slider').addEventListener('input', (e) => {
  const val = parseInt(e.target.value);
  document.getElementById('stroke-width-val').textContent = val;
  currentStyle.strokeWidth = val;

  if (selectedIds.size > 0) {
    applyStyleToSelection({ strokeWidth: val });
  }
});

function applyStyleToSelection(styleObj) {
  const prevData = new Map();
  for (const id of selectedIds) {
    const idx = scene.findIndex(el => el.id === id);
    if (idx >= 0) {
      prevData.set(id, JSON.parse(JSON.stringify(scene[idx])));
      Object.assign(scene[idx], styleObj);
    }
  }
  pushHistory({ type: 'modify', ids: selectedIds, prevData });
  saveScene();
  render();
}

/* ============================================================
   COLOR PICKER POPUP
   ============================================================ */
let activeColorPicker = null;

function toggleColorPicker(which) {
  const pickerId = which + '-color-picker';
  const btnId = which + '-color-btn';
  const popup = document.getElementById(pickerId);

  if (popup.classList.contains('visible')) {
    popup.classList.remove('visible');
    activeColorPicker = null;
    return;
  }

  // Close any other picker
  document.querySelectorAll('.color-picker-popup').forEach(p => p.classList.remove('visible'));

  // Build color grid
  popup.innerHTML = '<div class="color-grid">' + C.PALETTE.map(c =>
    `<div class="color-swatch" style="background:${c}" onclick="pickColor('${which}','${c}')"></div>`
  ).join('') +
  `<div class="color-swatch" style="background:repeating-conic-gradient(#ddd 0% 25%,#fff 0% 50%) 50%/10px 10px;position:relative;" onclick="pickColor('${which}','transparent')" title="No fill">
    <div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%) rotate(45deg);width:16px;height:2.5px;background:#c0392b;"></div>
  </div>` + '</div>';

  popup.classList.add('visible');
  activeColorPicker = which;
}

function pickColor(which, color) {
  const btnId = which + '-color-btn';
  updateSwatch(btnId, color);

  if (which === 'stroke') currentStyle.strokeColor = color;
  else currentStyle.fillColor = color;

  if (selectedIds.size > 0) {
    const styleObj = {};
    if (which === 'stroke') styleObj.strokeColor = color;
    else styleObj.fillColor = color;
    applyStyleToSelection(styleObj);
  }

  document.getElementById(which + '-color-picker').classList.remove('visible');
  activeColorPicker = null;
}

// Close pickers on outside click
document.addEventListener('click', (e) => {
  if (!e.target.closest('.prop-group')) {
    document.querySelectorAll('.color-picker-popup').forEach(p => p.classList.remove('visible'));
    activeColorPicker = null;
  }
});

/* ============================================================
   TOOLBAR BUTTON CLICKS
   ============================================================ */
document.getElementById('toolbar').addEventListener('click', (e) => {
  const btn = e.target.closest('.tool-btn');
  if (!btn) return;
  setTool(btn.dataset.tool);
});

// History buttons
document.getElementById('undo-btn').addEventListener('click', undo);
document.getElementById('redo-btn').addEventListener('click', redo);

/* ============================================================
   INITIALIZATION
   ============================================================ */
function init() {
  resizeCanvas();
  window.addEventListener('resize', () => { resizeCanvas(); render(); });

  // Load saved scene
  loadScene();

  // Set initial cursor
  canvas.style.cursor = toolCursor(currentTool);

  // Initialize swatches
  updateSwatch('stroke-color-btn', currentStyle.strokeColor);
  updateSwatch('fill-color-btn', currentStyle.fillColor);

  render();
}

init();
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen3.6-27b-mtp","provider":"lmstudio","persona":"full","sessionId":"439116a9-7b44-4fbe-a61e-812ae7559468","tokensIn":1393284,"tokensOut":59934,"tokensTotal":1453218,"turns":29,"toolCalls":28,"failedToolCalls":0,"timestamp":"2026-07-30T00:08:26.010Z"} -->