← Whiteboard results

Whiteboard

ling 3.0 tiny:free · full

Initial view of Whiteboard

Browser check: Artifact runtime error. This is a technical check, not a quality review.

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 37,599 bytes · SHA-256 0ff70f66eb20
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Whiteboard — Collaborative Whiteboard</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  body {
    overflow: hidden;
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    background: #1e1e2e;
    color: #c8c8d0;
    height: 100vh;
    width: 100vw;
    user-select: none;
    -webkit-user-select: none;
  }
  #app { display: flex; height: 100vh; position: relative; }
  #toolbar {
    width: 64px;
    background: #1a1a2e;
    border-right: 1px solid #2a2a4e;
    display: flex;
    flex-direction: column;
    padding: 8px 0;
    z-index: 100;
  }
  #toolbar .logo {
    font-size: 12px;
    text-align: center;
    font-weight: 700;
    color: #6688cc;
    letter-spacing: 1px;
    margin-bottom: 4px;
  }
  #toolbar .tool-group { display: flex; flex-direction: column; gap: 2px; }
  #toolbar .tool-btn {
    display: flex; align-items: center; justify-content: center;
    width: 48px; height: 48px;
    border: none; background: transparent;
    color: #8899bb; border-radius: 4px;
    cursor: pointer; font-size: 18px;
    transition: all 0.1s; margin: 1px;
  }
  #toolbar .tool-btn:hover { background: #2a2a4e; color: #ccc; }
  #toolbar .tool-btn.active {
    background: #2a3a5e; color: #fff;
    box-shadow: inset 0 0 0 1px #4a6a9a;
  }
  #toolbar .tool-btn:active { background: #3a4a6e; }
  #toolbar .tool-sep { height: 1px; background: #2a2a4e; margin: 4px 0; }
  #canvas-wrap { flex: 1; position: relative; overflow: hidden; cursor: grab; }
  #canvas-wrap.grabbing { cursor: grabbing; }
  #canvas-wrap canvas { display: block; width: 100%; height: 100%; cursor: grab; }
  #properties {
    height: 72px;
    background: #1a1a2e;
    border-top: 1px solid #2a2a4e;
    display: flex; align-items: center;
    padding: 0 12px; gap: 12px; z-index: 100;
    font-size: 12px;
  }
  #properties .prop-row { display: flex; align-items: center; gap: 6px; }
  #properties label { color: #8899bb; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }
  #properties input[type="color"] {
    width: 28px; height: 24px;
    border: 1px solid #3a3a5e; border-radius: 3px;
    background: transparent; cursor: pointer; padding: 0;
  }
  #properties input[type="range"] {
    width: 80px; accent-color: #6688cc;
  }
  #properties .prop-value { color: #ccc; font-variant-numeric: tabular-nums; min-width: 36px; text-align: right; }
  #zoom-info { margin-left: auto; color: #8899bb; font-size: 12px; font-variant-numeric: tabular-nums; }
  #zoom-info span { color: #6688cc; font-weight: 600; }
  #reset-btn {
    background: none; border: 1px solid #3a3a5e;
    color: #8899bb; padding: 3px 8px; border-radius: 3px;
    cursor: pointer; font-size: 11px; transition: all 0.1s;
  }
  #reset-btn:hover { background: #2a2a4e; color: #ccc; }
  #hint {
    position: fixed; bottom: 12px; left: 50%;
    transform: translateX(-50%);
    background: rgba(26,26,46,0.9); padding: 4px 14px;
    border-radius: 4px; font-size: 11px; color: #8899bb;
    pointer-events: none; z-index: 200; white-space: nowrap;
  }
  #status {
    position: fixed; top: 8px; right: 16px;
    font-size: 11px; color: #6688cc; z-index: 200; pointer-events: none;
  }
  #text-overlay {
    position: fixed; border: 2px solid #4a6a9a;
    background: #fff; color: #1a1a2e; padding: 4px 8px;
    font-size: 14px; border-radius: 3px; outline: none;
    cursor: text; z-index: 300; display: none;
    box-shadow: 0 2px 8px rgba(0,0,0,0.3);
  }
  #text-overlay:hover { background: #fafafa; }
  #text-overlay:focus { border-color: #6688cc; }
</style>
</head>
<body>
<div id="app">
  <div id="toolbar">
    <div class="logo">WHITEBOARD</div>
    <div class="tool-group">
      <button class="tool-btn active" data-tool="select" title="Select (V)">✦</button>
      <button class="tool-btn" data-tool="freehand" title="Freehand (P)">✿</button>
      <button class="tool-btn" data-tool="rect" title="Rectangle (R)">□</button>
      <button class="tool-btn" data-tool="ellipse" title="Ellipse (O)">⬭</button>
      <button class="tool-btn" data-tool="line" title="Line/Arrow (L)">║</button>
      <button class="tool-btn" data-tool="text" title="Text (T)">T</button>
      <button class="tool-btn" data-tool="eraser" title="Eraser (E)">◌</button>
    </div>
    <div class="tool-sep"></div>
    <button class="tool-btn" id="btn-undo" title="Undo (Ctrl+Z)">↩</button>
    <button class="tool-btn" id="btn-redo" title="Redo (Ctrl+Y)">↪</button>
    <div class="tool-sep"></div>
    <button class="tool-btn" id="btn-clear" title="Clear All">✕</button>
    <button class="tool-btn" id="btn-grid" title="Toggle Grid">▦</button>
  </div>
  <div id="canvas-wrap">
    <canvas id="canvas"></canvas>
    <div id="text-overlay"></div>
  </div>
  <div id="properties">
    <div class="prop-row">
      <label>Stroke</label>
      <input type="color" id="prop-stroke" value="#333333">
    </div>
    <div class="prop-row">
      <label>Fill</label>
      <input type="color" id="prop-fill" value="#ffffff">
    </div>
    <div class="prop-row">
      <label>Width</label>
      <input type="range" id="prop-width" min="0.5" max="10" step="0.5" value="1">
      <span class="prop-value" id="prop-width-val">1</span>
    </div>
    <div id="zoom-info">
      <span id="zoom-level">100%</span>
      <button id="reset-view">↺</button>
    </div>
  </div>
</div>
<div id="hint">Space+drag to pan · Middle-drag to pan · Scroll to zoom · Del to select & delete · Ctrl+Z to undo</div>
<div id="status"></div>

<script>
(function() {
  // =============================================================================
  // Constants & Tunables
  // =============================================================================
  const C = {
    ZOOM_MIN: 0.25,
    ZOOM_MAX: 4.0,
    ZOOM_IN: 1.25,
    ZOOM_OUT: 0.8,
    ZOOM_TO: 1.2,
    HANDLE_SIZE: 6,
    HIT_TOLERANCE: 3,
    GRID_SIZE: 20,
    MIN_CANVAS_SIZE: 400,
    STROKE_COLOR: '#333333',
    FILL_COLOR: '#ffffff',
    DEFAULT_STROKE: '#333333',
    DEFAULT_FILL: '#ffffff',
    DEFAULT_WIDTH: 1,
  };

  const COLORS = [
    '#333333','#cc3333','#3366cc','#33cc33','#cc9933','#9933cc',
    '#33cccc','#cc33cc','#33cc33','#cc6633','#66cc33','#3366cc',
    '#6633cc','#cc3366','#6666cc','#cc6666','#999999','#666666',
  ];

  // =============================================================================
  // State
  // =============================================================================
  let canvas, ctx;
  let canvasW = 0, canvasH = 0;
  let zoom = 1.0, panX = 0, panY = 0;
  let mouseDown = false, mouseButton = 0;
  let mouseX = 0, mouseY = 0;
  let prevMouseX = 0, prevMouseY = 0;
  let hoveredObject = null;
  let selectedIds = new Set();
  let isMultiSelecting = false;
  let pendingStart = null;
  let currentTool = 'select';
  let freehandPoints = [];
  let freehandPath = null;
  let erasing = false;
  let erasingIds = new Set();
  let lastStrokePoint = null;
  let gridVisible = true;
  let undoStack = [];
  let redoStack = [];
  let dirty = false;
  let idCounter = 0;
  let textBoxId = null;

  // =============================================================================
  // Object Model
  // =============================================================================
  function createObject(type, x, y, opts) {
    opts = opts || {};
    const id = 'obj_' + (idCounter++);
    const obj = {
      id: id,
      type: type,
      x: x,
      y: y,
      strokeColor: opts.strokeColor || C.STROKE_COLOR,
      fillColor: opts.fillColor || C.FILL_COLOR,
      strokeWidth: opts.strokeWidth !== undefined ? opts.strokeWidth : C.DEFAULT_WIDTH,
      text: opts.text || '',
      cx: 0,
      cy: 0,
    };
    if (type === 'rect') {
      obj.width = opts.width || 100;
      obj.height = opts.height || 60;
    } else if (type === 'ellipse') {
      obj.rx = opts.rx || 60;
      obj.ry = opts.ry || 40;
    } else if (type === 'line') {
      obj.x2 = opts.x2 || x;
      obj.y2 = opts.y2 || y;
      obj.strokeWidth = opts.strokeWidth || 2;
    }
    return obj;
  }

  // =============================================================================
  // Scene
  // =============================================================================
  let scene = [];

  // =============================================================================
  // Viewport Transform
  // =============================================================================
  function screenToWorld(sx, sy) {
    return { wx: (sx - panX) / zoom, wy: (sy - panY) / zoom };
  }

  function worldToScreen(wx, wy) {
    return { sx: wx * zoom + panX, sy: wy * zoom + panY };
  }

  function getVisibleBounds() {
    const tl = screenToWorld(0, 0);
    const br = screenToWorld(canvasW, canvasH);
    return {
      minX: Math.min(tl.wx, br.wx), maxX: Math.max(tl.wx, br.wx),
      minY: Math.min(tl.wy, br.wy), maxY: Math.max(tl.wy, br.wy)
    };
  }

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

  function resizeCanvas() {
    const wrap = document.getElementById('canvas-wrap');
    const dpr = window.devicePixelRatio || 1;
    canvasW = wrap.clientWidth;
    canvasH = wrap.clientHeight;
    canvas.width = canvasW * dpr;
    canvas.height = canvasH * dpr;
    canvas.style.width = canvasW + 'px';
    canvas.style.height = canvasH + 'px';
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    dirty = true;
  }

  // =============================================================================
  // Rendering
  // =============================================================================
  function render() {
    ctx.clearRect(0, 0, canvasW, canvasH);
    if (gridVisible) drawGrid();
    for (const obj of scene) {
      drawObject(obj);
    }
    if (selectedIds.size > 0) drawSelection();
    if (hoveredObject && hoveredObject.id) {
      drawHoverHandle(hoveredObject);
    }
    dirty = false;
  }

  function drawGrid() {
    const bounds = getVisibleBounds();
    const sp = C.GRID_SIZE;
    const startX = Math.floor(bounds.minX / sp) * sp;
    const startY = Math.floor(bounds.minY / sp) * sp;
    const endX = Math.ceil(bounds.maxX / sp) * sp;
    const endY = Math.ceil(bounds.maxY / sp) * sp;

    ctx.strokeStyle = 'rgba(255,255,255,0.06)';
    ctx.lineWidth = 0.5;
    ctx.beginPath();
    for (let x = startX; x <= endX; x += sp) {
      const s = worldToScreen(x, 0);
      ctx.moveTo(s.sx, 0);
      ctx.lineTo(s.sx, canvasH);
    }
    for (let y = startY; y <= endY; y += sp) {
      const s = worldToScreen(0, y);
      ctx.moveTo(0, s.sy);
      ctx.lineTo(canvasW, s.sy);
    }
    ctx.stroke();

    ctx.fillStyle = 'rgba(255,255,255,0.12)';
    for (let x = startX; x <= endX; x += sp) {
      for (let y = startY; y <= endY; y += sp) {
        const s = worldToScreen(x, y);
        ctx.beginPath();
        ctx.arc(s.sx, s.sy, 1.5, 0, Math.PI * 2);
        ctx.fill();
      }
    }
  }

  function drawObject(obj) {
    ctx.save();
    if (obj.type === 'rect') {
      drawRectangle(obj);
    } else if (obj.type === 'ellipse') {
      drawEllipse(obj);
    } else if (obj.type === 'line') {
      drawLine(obj);
    } else if (obj.type === 'freehand') {
      drawFreehand(obj);
    } else if (obj.type === 'text') {
      drawText(obj);
    }
    ctx.restore();
  }

  function drawRectangle(obj) {
    const halfW = obj.width / 2;
    const halfH = obj.height / 2;
    const x = obj.x - halfW;
    const y = obj.y - halfH;
    ctx.strokeStyle = obj.strokeColor;
    ctx.lineWidth = obj.strokeWidth;
    ctx.lineJoin = 'round';
    ctx.lineCap = 'round';
    ctx.setLineDash([]);
    ctx.strokeRect(x, y, obj.width, obj.height);
    if (obj.fillColor && obj.fillColor !== '#ffffff') {
      ctx.fillStyle = obj.fillColor;
      ctx.fillRect(x, y, obj.width, obj.height);
    }
    const handleSize = C.HANDLE_SIZE;
    ctx.fillStyle = '#cc3333';
    ctx.strokeStyle = '#cc3333';
    ctx.lineWidth = 1.5;
    drawHandleAt(ctx, x, y, handleSize);
    drawHandleAt(ctx, x + obj.width, y, handleSize);
    drawHandleAt(ctx, x, y + obj.height, handleSize);
    drawHandleAt(ctx, x + obj.width, y + obj.height, handleSize);
  }

  function drawEllipse(obj) {
    const cx = obj.x;
    const cy = obj.y;
    const rx = obj.rx;
    const ry = obj.ry;
    ctx.strokeStyle = obj.strokeColor;
    ctx.lineWidth = obj.strokeWidth;
    ctx.lineJoin = 'round';
    ctx.lineCap = 'round';
    ctx.setLineDash([]);
    ctx.strokeRect(cx - rx, cy - ry, rx * 2, ry * 2);
    if (obj.fillColor && obj.fillColor !== '#ffffff') {
      ctx.fillStyle = obj.fillColor;
      ctx.beginPath();
      ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
      ctx.fill();
    }
    const handleSize = C.HANDLE_SIZE;
    ctx.fillStyle = '#cc3333';
    ctx.strokeStyle = '#cc3333';
    ctx.lineWidth = 1.5;
    drawHandleAt(ctx, cx - rx, cy - ry, handleSize);
    drawHandleAt(ctx, cx + rx, cy - ry, handleSize);
    drawHandleAt(ctx, cx - rx, cy + ry, handleSize);
    drawHandleAt(ctx, cx + rx, cy + ry, handleSize);
  }

  function drawLine(obj) {
    ctx.strokeStyle = obj.strokeColor;
    ctx.lineWidth = obj.strokeWidth;
    ctx.lineJoin = 'round';
    ctx.lineCap = 'round';
    ctx.setLineDash([]);
    ctx.beginPath();
    ctx.moveTo(obj.x, obj.y);
    ctx.lineTo(obj.x2, obj.y2);
    ctx.stroke();
    const dx = obj.x2 - obj.x;
    const dy = obj.y2 - obj.y;
    const len = Math.sqrt(dx * dx + dy * dy);
    if (len > 0) {
      const nx = dx / len;
      const ny = dy / len;
      const arrowLen = 10;
      const arrowX = obj.x2 - nx * (len - arrowLen * 0.3);
      const arrowY = obj.y2 - ny * (len - arrowLen * 0.3);
      ctx.beginPath();
      ctx.setLineDash([]);
      ctx.fillStyle = obj.strokeColor;
      ctx.moveTo(arrowX, arrowY);
      ctx.lineTo(arrowX - nx * arrowLen + ny * arrowLen * 0.5, arrowY - ny * arrowLen - nx * arrowLen * 0.5);
      ctx.lineTo(arrowX - nx * arrowLen - ny * arrowLen * 0.5, arrowY - ny * arrowLen + nx * arrowLen * 0.5);
      ctx.closePath();
      ctx.fill();
    }
    ctx.fillStyle = '#cc3333';
    ctx.strokeStyle = '#cc3333';
    ctx.lineWidth = 1.5;
    drawHandleAt(ctx, obj.x2, obj.y2, C.HANDLE_SIZE);
  }

  function drawFreehand(obj) {
    if (!obj.points || obj.points.length === 0) return;
    ctx.strokeStyle = obj.strokeColor;
    ctx.lineWidth = obj.strokeWidth;
    ctx.lineCap = 'round';
    ctx.lineJoin = 'round';
    ctx.setLineDash([]);
    ctx.beginPath();
    ctx.moveTo(obj.points[0].x, obj.points[0].y);
    for (let i = 1; i < obj.points.length; i++) {
      ctx.lineTo(obj.points[i].x, obj.points[i].y);
    }
    ctx.stroke();
    if (obj.fillColor && obj.fillColor !== '#ffffff') {
      ctx.fillStyle = obj.fillColor;
      ctx.beginPath();
      ctx.moveTo(obj.points[0].x, obj.points[0].y);
      for (let i = 1; i < obj.points.length; i++) {
        ctx.lineTo(obj.points[i].x, obj.points[i].y);
      }
      ctx.closePath();
      ctx.fill();
    }
  }

  function drawText(obj) {
    const cx = obj.x;
    const cy = obj.y;
    ctx.fillStyle = obj.fillColor || '#ffffff';
    ctx.strokeStyle = obj.strokeColor;
    ctx.lineWidth = obj.strokeWidth;
    ctx.lineJoin = 'round';
    ctx.lineCap = 'round';
    ctx.setLineDash([]);
    ctx.strokeRect(cx - 60, cy - 14, 120, 28);
    ctx.fillRect(cx - 60, cy - 14, 120, 28);
    ctx.fillStyle = '#1a1a2e';
    ctx.font = '12px monospace, sans-serif';
    ctx.fillText(obj.text, cx - 50, cy + 1);
    ctx.fillStyle = '#cc3333';
    ctx.strokeStyle = '#cc3333';
    ctx.lineWidth = 1.5;
    drawHandleAt(ctx, cx + 60, cy, C.HANDLE_SIZE);
  }

  function drawHandleAt(ctx, x, y, size) {
    ctx.fillStyle = '#cc3333';
    ctx.strokeStyle = '#cc3333';
    ctx.lineWidth = 1.5;
    const s = size / 2;
    ctx.beginPath();
    ctx.moveTo(x - s, y - s);
    ctx.lineTo(x + s, y - s);
    ctx.lineTo(x + s, y + s);
    ctx.lineTo(x - s, y + s);
    ctx.closePath();
    ctx.fill();
    ctx.stroke();
  }

  function drawHoverHandle(obj) {
    const s = C.HANDLE_SIZE / 2;
    ctx.fillStyle = '#cc3333';
    ctx.strokeStyle = '#cc3333';
    ctx.lineWidth = 1.5;
    if (obj.type === 'rect') {
      const halfW = obj.width / 2;
      const halfH = obj.height / 2;
      drawHandleAt(ctx, obj.x - halfW, obj.y - halfH, s);
      drawHandleAt(ctx, obj.x + halfW, obj.y - halfH, s);
      drawHandleAt(ctx, obj.x - halfW, obj.y + halfH, s);
      drawHandleAt(ctx, obj.x + halfW, obj.y + halfH, s);
    } else if (obj.type === 'ellipse') {
      drawHandleAt(ctx, obj.x - obj.rx, obj.y - obj.ry, s);
      drawHandleAt(ctx, obj.x + obj.rx, obj.y - obj.ry, s);
      drawHandleAt(ctx, obj.x - obj.rx, obj.y + obj.ry, s);
      drawHandleAt(ctx, obj.x + obj.rx, obj.y + obj.ry, s);
    } else if (obj.type === 'line') {
      drawHandleAt(ctx, obj.x2, obj.y2, s);
    }
  }

  // =============================================================================
  // Hit Testing
  // =============================================================================
  function hitTest(wx, wy, tolerance) {
    tolerance = tolerance || C.HIT_TOLERANCE;
    for (let i = scene.length - 1; i >= 0; i--) {
      const obj = scene[i];
      if (obj.type === 'rect') {
        const halfW = obj.width / 2;
        const halfH = obj.height / 2;
        const sx = obj.x - halfW;
        const sy = obj.y - halfH;
        const ex = obj.x + halfW;
        const ey = obj.y + halfH;
        if (wx >= sx - tolerance && wx <= ex + tolerance &&
            wy >= sy - tolerance && wy <= ey + tolerance) {
          return obj;
        }
      } else if (obj.type === 'ellipse') {
        const dist = pointToEllipseDist(wx, wy, obj.x, obj.y, obj.rx, obj.ry);
        if (dist <= tolerance) return obj;
      } else if (obj.type === 'line') {
        const dist = pointToLineDist(wx, wy, obj.x, obj.y, obj.x2, obj.y2);
        if (dist <= tolerance) return obj;
      } else if (obj.type === 'freehand') {
        for (const p of obj.points) {
          const dx = wx - p.x;
          const dy = wy - p.y;
          if (dx * dx + dy * dy <= tolerance * tolerance) return obj;
        }
        if (obj.points.length > 1) {
          for (let i = 0; i < obj.points.length - 1; i++) {
            const p1 = obj.points[i];
            const p2 = obj.points[i + 1];
            const d = pointToLineDist(wx, wy, p1.x, p1.y, p2.x, p2.y);
            if (d <= tolerance) return obj;
          }
        }
      } else if (obj.type === 'text') {
        const dx = wx - obj.x;
        const dy = wy - obj.y;
        if (Math.abs(dx) < 60 + tolerance && Math.abs(dy) < 14 + tolerance) return obj;
      }
    }
    return null;
  }

  function pointToEllipseDist(px, py, cx, cy, rx, ry) {
    const dx = (px - cx) / rx;
    const dy = (py - cy) / ry;
    return Math.sqrt(dx * dx + dy * dy);
  }

  function pointToLineDist(px, py, x1, y1, x2, y2) {
    const dx = x2 - x1;
    const dy = y2 - y1;
    const len = Math.sqrt(dx * dx + dy * dy);
    if (len < 0.001) return Math.sqrt((px - x1) * (px - x1) + (py - y1) * (py - y1));
    const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / (len * len)));
    const projX = x1 + t * dx;
    const projY = y1 + t * dy;
    return Math.sqrt((px - projX) * (px - projX) + (py - projY) * (py - projY));
  }

  // =============================================================================
  // Undo / Redo
  // =============================================================================
  function saveUndo() {
    const snapshot = JSON.parse(JSON.stringify(scene));
    undoStack.push(snapshot);
    redoStack = [];
    dirty = true;
  }

  function undo() {
    if (undoStack.length === 0) return;
    redoStack.push(JSON.parse(JSON.stringify(scene)));
    scene = undoStack.pop();
    selectedIds.clear();
    dirty = true;
    updateStatus('Undo');
  }

  function redo() {
    if (redoStack.length === 0) return;
    undoStack.push(JSON.parse(JSON.stringify(scene)));
    scene = redoStack.pop();
    selectedIds.clear();
    dirty = true;
    updateStatus('Redo');
  }

  // =============================================================================
  // Tool System
  // =============================================================================
  function setTool(tool) {
    currentTool = tool;
    document.querySelectorAll('.tool-btn[data-tool]').forEach(b => {
      b.classList.toggle('active', b.dataset.tool === tool);
    });
    freehandPoints = [];
    freehandPath = null;
    erasing = false;
    erasingIds.clear();
    lastStrokePoint = null;
    document.getElementById('text-overlay').style.display = 'none';
    pendingStart = null;
  }

  // =============================================================================
  // Pointer helpers
  // =============================================================================
  function getPointerPos(e) {
    const rect = canvas.getBoundingClientRect();
    const sx = e.clientX !== undefined ? e.clientX : (e.touches ? e.touches[0].clientX : 0);
    const sy = e.clientY !== undefined ? e.clientY : (e.touches ? e.touches[0].clientY : 0);
    return { x: sx, y: sy };
  }

  function getEventPos(e) {
    const rect = canvas.getBoundingClientRect();
    const sx = e.clientX !== undefined ? e.clientX : (e.touches ? e.touches[0].clientX : 0);
    const sy = e.clientY !== undefined ? e.clientY : (e.touches ? e.touches[0].clientY : 0);
    const dpr = window.devicePixelRatio || 1;
    return {
      sx: sx * dpr,
      sy: sy * dpr,
      wx: (sx - panX) / zoom,
      wy: (sy - panY) / zoom,
    };
  }

  // =============================================================================
  // Mouse Events
  // =============================================================================
  function onPointerDown(e) {
    e.preventDefault();
    const pos = getPointerPos(e);
    mouseX = pos.x;
    mouseY = pos.y;
    const wp = screenToWorld(mouseX, mouseY);

    if (currentTool === 'select') {
      handleSelectDown(wp.wx, wp.wy, e);
    } else if (currentTool === 'freehand') {
      handleFreehandDown(wp.wx, wp.wy, e);
    } else if (currentTool === 'rect') {
      handleRectDown(wp.wx, wp.wy, e);
    } else if (currentTool === 'ellipse') {
      handleEllipseDown(wp.wx, wp.wy, e);
    } else if (currentTool === 'line') {
      handleLineDown(wp.wx, wp.wy, e);
    } else if (currentTool === 'text') {
      handleTextDown(wp.wx, wp.wy, e);
    } else if (currentTool === 'eraser') {
      handleEraserDown(wp.wx, wp.wy, e);
    }
    mouseDown = true;
  }

  function onPointerMove(e) {
    const pos = getPointerPos(e);
    mouseX = pos.x;
    mouseY = pos.y;
    if (mouseDown) {
      const wp = screenToWorld(mouseX, mouseY);
      if (currentTool === 'select' || currentTool === 'eraser') {
        handleSelectMove(wp.wx, wp.wy);
      } else if (currentTool === 'freehand') {
        handleFreehandMove(wp.wx, wp.wy);
      } else if (currentTool === 'rect' || currentTool === 'ellipse' || currentTool === 'line') {
        handlePreviewMove(wp.wx, wp.wy);
      } else if (currentTool === 'text') {
        handleTextMove(wp.wx, wp.wy);
      }
    }
  }

  function onPointerUp(e) {
    e.preventDefault();
    mouseDown = false;
    const pos = getPointerPos(e);
    mouseX = pos.x;
    mouseY = pos.y;
    const wp = screenToWorld(mouseX, mouseY);

    if (currentTool === 'select') {
      handleSelectUp(wp.wx, wp.wy);
    } else if (currentTool === 'freehand') {
      handleFreehandUp();
    } else if (currentTool === 'rect' || currentTool === 'ellipse' || currentTool === 'line') {
      handleShapeUp(wp.wx, wp.wy);
    } else if (currentTool === 'text') {
      handleTextUp();
    } else if (currentTool === 'eraser') {
      handleEraserUp();
    }
  }

  // =============================================================================
  // Select Tool
  // =============================================================================
  function handleSelectDown(wx, wy, e) {
    const obj = hitTest(wx, wy);
    if (obj) {
      if (e.shiftKey) {
        isMultiSelecting = true;
        pendingStart = { wx, wy, obj };
        return;
      }
      if (selectedIds.has(obj.id)) {
        selectedIds.delete(obj.id);
      } else {
        selectedIds.clear();
        selectedIds.add(obj.id);
      }
    } else {
      selectedIds.clear();
      pendingStart = { wx, wy };
      isMultiSelecting = true;
    }
  }

  function handleSelectMove(wx, wy) {
    if (!pendingStart) return;
    if (!isMultiSelecting) return;
    if (selectedIds.size > 0) return;
    const obj = hitTest(wx, wy);
    if (obj) {
      selectedIds.add(obj.id);
    }
  }

  function handleSelectUp(wx, wy) {
    if (pendingStart) {
      const obj = hitTest(pendingStart.wx, pendingStart.wy);
      selectedIds.clear();
      if (obj) {
        selectedIds.add(obj.id);
      }
      pendingStart = null;
      isMultiSelecting = false;
    }
  }

  // =============================================================================
  // Freehand Tool
  // =============================================================================
  function handleFreehandDown(wx, wy, e) {
    freehandPoints = [{ x: wx, y: wy, pressTime: Date.now() }];
    lastStrokePoint = null;
  }

  function handleFreehandMove(wx, wy) {
    if (freehandPoints.length === 0) return;
    const lastPt = freehandPoints[freehandPoints.length - 1];
    const dx = wx - lastPt.x;
    const dy = wy - lastPt.y;
    const dist = Math.sqrt(dx * dx + dy * dy);
    if (dist > 5) {
      freehandPoints.push({ x: wx, y: wy });
      freehandPath = null;
    }
  }

  function handleFreehandUp() {
    if (freehandPoints.length > 1) {
      const smoothed = smoothPath(freehandPoints);
      const obj = createObject('freehand', smoothed[0].x, smoothed[0].y, {
        strokeColor: C.STROKE_COLOR,
        fillColor: C.FILL_COLOR,
        strokeWidth: C.DEFAULT_WIDTH,
        points: smoothed,
      });
      scene.push(obj);
      saveUndo();
    } else if (freehandPoints.length === 1) {
      const p = freehandPoints[0];
      const obj = createObject('freehand', p.x, p.y, {
        strokeColor: C.STROKE_COLOR,
        fillColor: C.FILL_COLOR,
        strokeWidth: C.DEFAULT_WIDTH,
        points: [p],
      });
      scene.push(obj);
      saveUndo();
    }
    freehandPoints = [];
    freehandPath = null;
  }

  function smoothPath(points) {
    if (points.length < 2) return points;
    const smooth = [points[0]];
    for (let i = 1; i < points.length - 1; i++) {
      smooth.push({
        x: (points[i - 1].x + points[i].x + points[i + 1].x) / 3,
        y: (points[i - 1].y + points[i].y + points[i + 1].y) / 3,
      });
    }
    smooth.push(points[points.length - 1]);
    return smooth;
  }

  // =============================================================================
  // Shape Tools
  // =============================================================================
  function handleRectDown(wx, wy, e) {
    pendingStart = { wx, wy, isRect: true, startX: wx, startY: wy };
  }

  function handleEllipseDown(wx, wy, e) {
    pendingStart = { wx, wy, isEllipse: true, startX: wx, startY: wy };
  }

  function handleLineDown(wx, wy, e) {
    pendingStart = { wx, wy, isLine: true, startX: wx, startY: wy };
  }

  function handlePreviewMove(wx, wy) {
    if (!pendingStart) return;
    const dx = wx - pendingStart.startX;
    const dy = wy - pendingStart.startY;
    const overlay = document.getElementById('text-overlay');
    overlay.style.display = 'block';
    overlay.style.left = (wx * zoom + panX - 60) + 'px';
    overlay.style.top = (wy * zoom + panY - 14) + 'px';
  }

  function handleShapeUp(wx, wy) {
    if (!pendingStart) return;
    const w = wx - pendingStart.startX;
    const h = wy - pendingStart.startY;
    if (pendingStart.isRect) {
      const obj = createObject('rect', pendingStart.startX, pendingStart.startY, {
        width: Math.abs(w), height: Math.abs(h),
      });
      scene.push(obj);
      saveUndo();
    } else if (pendingStart.isEllipse) {
      const obj = createObject('ellipse', pendingStart.startX, pendingStart.startY, {
        rx: Math.abs(w) / 2, ry: Math.abs(h) / 2,
      });
      scene.push(obj);
      saveUndo();
    } else if (pendingStart.isLine) {
      const obj = createObject('line', pendingStart.startX, pendingStart.startY, {
        x2: wx, y2: wy,
      });
      scene.push(obj);
      saveUndo();
    }
    pendingStart = null;
  }

  // =============================================================================
  // Text Tool
  // =============================================================================
  function handleTextDown(wx, wy, e) {
    const obj = createObject('text', wx, wy, { text: '' });
    scene.push(obj);
    textBoxId = obj.id;
    saveUndo();
    const overlay = document.getElementById('text-overlay');
    overlay.style.display = 'block';
    overlay.style.left = (wx * zoom + panX - 60) + 'px';
    overlay.style.top = (wy * zoom + panY - 14) + 'px';
    overlay.value = '';
    overlay.focus();
  }

  function handleTextMove(wx, wy) {
    const obj = scene.find(o => o.id === textBoxId);
    if (obj) {
      obj.x = wx;
      obj.y = wy;
    }
  }

  function handleTextUp() {
    const obj = scene.find(o => o.id === textBoxId);
    if (obj) {
      const overlay = document.getElementById('text-overlay');
      obj.text = overlay ? overlay.value : '';
      saveUndo();
    }
    document.getElementById('text-overlay').style.display = 'none';
    textBoxId = null;
  }

  // =============================================================================
  // Eraser Tool
  // =============================================================================
  function handleEraserDown(wx, wy, e) {
    erasing = true;
    erasingIds = new Set();
    const obj = hitTest(wx, wy);
    if (obj) erasingIds.add(obj.id);
  }

  function handleEraserMove(wx, wy) {
    const obj = hitTest(wx, wy);
    if (obj) erasingIds.add(obj.id);
  }

  function handleEraserUp() {
    if (erasingIds.size > 0) {
      const toRemove = new Set();
      for (const id of erasingIds) {
        const idx = scene.findIndex(o => o.id === id);
        if (idx >= 0) toRemove.add(id);
      }
      scene = scene.filter(o => !toRemove.has(o.id));
      saveUndo();
    }
    erasing = false;
    erasingIds.clear();
  }

  // =============================================================================
  // Keyboard
  // =============================================================================
  document.addEventListener('keydown', (e) => {
    if (e.key === 'Delete' || e.key === 'Backspace') {
      if (selectedIds.size > 0) {
        e.preventDefault();
        const toRemove = [...selectedIds];
        scene = scene.filter(o => !toRemove.includes(o.id));
        saveUndo();
        selectedIds.clear();
      }
    }
    if (e.key === 'Escape') {
      setTool('select');
      selectedIds.clear();
    }
    if (e.key === 'v' || e.key === 'V') setTool('select');
    if (e.key === 'p' || e.key === 'P') setTool('freehand');
    if (e.key === 'r' || e.key === 'R') setTool('rect');
    if (e.key === 'o' || e.key === 'O') setTool('ellipse');
    if (e.key === 'l' || e.key === 'L') setTool('line');
    if (e.key === 't' || e.key === 'T') setTool('text');
    if (e.key === 'e' || e.key === 'E') setTool('eraser');
    if (e.ctrlKey && e.key === 'z') { e.preventDefault(); undo(); }
    if (e.ctrlKey && e.key === 'y') { e.preventDefault(); redo(); }
  });

  // =============================================================================
  // Canvas Mouse Events (only attached after init)
  // =============================================================================
  canvas.addEventListener('mousedown', onPointerDown);
  canvas.addEventListener('mousemove', onPointerMove);
  canvas.addEventListener('mouseup', onPointerUp);
  canvas.addEventListener('mouseleave', () => {
    if (mouseDown) onPointerUp({});
  });

  canvas.addEventListener('wheel', (e) => {
    e.preventDefault();
    const pos = getPointerPos(e);
    const wx = (pos.x - panX) / zoom;
    const wy = (pos.y - panY) / zoom;
    const delta = e.deltaY > 0 ? C.ZOOM_OUT : C.ZOOM_IN;
    const newZoom = Math.max(C.ZOOM_MIN, Math.min(C.ZOOM_MAX, zoom * delta));
    panX = pos.x - wx * newZoom;
    panY = pos.y - wy * newZoom;
    zoom = newZoom;
    dirty = true;
  }, { passive: false });

  canvas.addEventListener('mousedown', (e) => {
    if (e.button === 1 || (e.button === 0 && e.shiftKey)) {
      e.preventDefault();
    }
  });

  canvas.addEventListener('mousemove', (e) => {
    if (e.button === 1 || (e.button === 0 && e.shiftKey)) {
      const dx = e.movementX;
      const dy = e.movementY;
      panX += dx;
      panY += dy;
      dirty = true;
    }
  });

  // =============================================================================
  // Touch Events
  // =============================================================================
  canvas.addEventListener('touchstart', (e) => {
    e.preventDefault();
    const touch = e.touches[0];
    const pos = getPointerPos(touch);
    mouseX = pos.x;
    mouseY = pos.y;
    onPointerDown({ clientX: pos.x, clientY: pos.y, button: 0, shiftKey: false, target: canvas });
  }, { passive: false });

  canvas.addEventListener('touchmove', (e) => {
    e.preventDefault();
    const touch = e.touches[0];
    const pos = getPointerPos(touch);
    mouseX = pos.x;
    mouseY = pos.y;
    onPointerMove({ clientX: pos.x, clientY: pos.y });
  }, { passive: false });

  canvas.addEventListener('touchend', (e) => {
    e.preventDefault();
    onPointerUp({});
  }, { passive: false });

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

  document.getElementById('btn-undo').addEventListener('click', undo);
  document.getElementById('btn-redo').addEventListener('click', redo);
  document.getElementById('btn-clear').addEventListener('click', () => {
    if (scene.length > 0) {
      saveUndo();
      scene = [];
      selectedIds.clear();
      dirty = true;
    }
  });
  document.getElementById('btn-grid').addEventListener('click', () => {
    gridVisible = !gridVisible;
    dirty = true;
  });
  document.getElementById('reset-view').addEventListener('click', () => {
    zoom = 1.0;
    panX = 0;
    panY = 0;
    dirty = true;
  });

  // =============================================================================
  // Properties Bar
  // =============================================================================
  document.getElementById('prop-stroke').addEventListener('input', (e) => {
    if (selectedIds.size > 0) {
      for (const id of selectedIds) {
        const obj = scene.find(o => o.id === id);
        if (obj) {
          obj.strokeColor = e.target.value;
          saveUndo();
        }
      }
    }
  });
  document.getElementById('prop-fill').addEventListener('input', (e) => {
    if (selectedIds.size > 0) {
      for (const id of selectedIds) {
        const obj = scene.find(o => o.id === id);
        if (obj) {
          obj.fillColor = e.target.value;
          saveUndo();
        }
      }
    }
  });
  document.getElementById('prop-width').addEventListener('input', (e) => {
    if (selectedIds.size > 0) {
      for (const id of selectedIds) {
        const obj = scene.find(o => o.id === id);
        if (obj) {
          obj.strokeWidth = parseFloat(e.target.value);
          saveUndo();
        }
      }
    }
  });

  // =============================================================================
  // Persistence
  // =============================================================================
  function saveBoard() {
    try {
      localStorage.setItem('whiteboard_scene', JSON.stringify(scene));
    } catch (e) { /* ignore */ }
  }

  function loadBoard() {
    try {
      const data = localStorage.getItem('whiteboard_scene');
      if (data) {
        scene = JSON.parse(data);
        dirty = true;
      }
    } catch (e) { /* ignore */ }
  }

  setInterval(saveBoard, 10000);

  // =============================================================================
  // Main Loop
  // =============================================================================
  function tick() {
    if (dirty) {
      render();
    }
    requestAnimationFrame(tick);
  }

  // =============================================================================
  // Initialization
  // =============================================================================
  function init() {
    initCanvas();
    loadBoard();
    setTool('select');
    requestAnimationFrame(tick);
    window.addEventListener('resize', () => {
      resizeCanvas();
      dirty = true;
    });
  }

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