← Whiteboard results

Whiteboard

muse spark 1.2 contributor · 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 55,894 bytes · SHA-256 7e4bd6cf5c1b
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Board — Whiteboard Prototype</title>
<style>
  :root{
    --bg:#e8e9eb;
    --canvas:#ffffff;
    --toolbar-bg:#1a1a1e;
    --toolbar-border:#2a2a30;
    --accent:#7c5cff;
    --accent2:#ff6b4a;
    --text:#0f0f12;
    --muted:#8a8a93;
    --panel:#ffffff;
    --shadow:0 8px 30px rgba(0,0,0,.12), 0 1px 3px rgba(0,0,0,.08);
    --radius:14px;
  }
  *{box-sizing:border-box;margin:0;padding:0}
  html,body{height:100%;overflow:hidden;font-family: ui-sans-system, -apple-system, BlinkMacSystemFont, "Inter", Segoe UI, Roboto, Helvetica, Arial, sans-serif;background:var(--bg);color:var(--text)}
  #app{position:relative;width:100%;height:100%;overflow:hidden}
  canvas{display:block;position:absolute;inset:0;width:100%;height:100%;cursor:crosshair}
  /* Top bar */
  .topbar{
    position:absolute;top:14px;left:50%;transform:translateX(-50%);
    display:flex;align-items:center;gap:6px;
    background:var(--toolbar-bg);border:1px solid var(--toolbar-border);
    padding:6px;border-radius:999px;box-shadow:var(--shadow);z-index:20;
  }
  .tool-btn{
    width:38px;height:38px;border-radius:999px;border:none;
    background:transparent;color:#c9c9d1;display:grid;place-items:center;
    cursor:pointer;transition:.15s;font-size:15px;position:relative;
  }
  .tool-btn:hover{background:#2a2a30;color:#fff}
  .tool-btn.active{background:#fff;color:#111;box-shadow:0 2px 8px rgba(0,0,0,.2)}
  .tool-btn kbd{
    position:absolute;bottom:-18px;left:50%;transform:translateX(-50%);
    font-size:9px;letter-spacing:.06em;background:#1a1a1e;color:#8a8a93;
    padding:1px 4px;border-radius:4px;opacity:0;transition:.15s;pointer-events:none;
  }
  .tool-btn:hover kbd{opacity:1}
  .sep{width:1px;height:22px;background:#2e2e36;margin:0 4px}
  .icon{width:18px;height:18px;display:block}
  /* Properties bar */
  .props{
    position:absolute;top:68px;left:14px;
    background:var(--panel);border:1px solid #e6e6ea;border-radius:var(--radius);
    padding:10px 12px;box-shadow:var(--shadow);z-index:19;
    display:flex;align-items:center;gap:12px;min-width:260px;
  }
  .props.hidden{opacity:.45;pointer-events:none}
  .props label{font-size:11px;font-weight:600;letter-spacing:.06em;color:var(--muted);text-transform:uppercase}
  .swatches{display:flex;gap:6px;align-items:center}
  .swatch{width:22px;height:22px;border-radius:50%;border:2px solid #fff;box-shadow:0 0 0 1px #ddd,c0 1px 3px rgba(0,0,0,.1);cursor:pointer;position:relative}
  .swatch.active{box-shadow:0 0 0 2px var(--accent), 0 1px 4px rgba(0,0,0,.15)}
  .swatch.transparent{background:repeating-conic-gradient(#eee 0% 25%, #fff 0% 50%) 0 0 / 8px 8px}
  .swatch.transparent::after{content:"";position:absolute;inset:0;border-radius:50%;background:linear-gradient(45deg, transparent 45%, #ff4d4d 45%, #ff4d4d 55%, transparent 55%)}
  .width-row{display:flex;align-items:center;gap:8px}
  .width-row input[type=range]{width:84px;accent-color:var(--accent)}
  .width-val{font-size:12px;font-weight:600;min-width:28px;text-align:center;background:#f2f2f5;border-radius:6px;padding:2px 6px}
  /* Zoom indicator */
  .zoombar{
    position:absolute;bottom:16px;right:16px;
    background:var(--panel);border:1px solid #e6e6ea;border-radius:999px;
    display:flex;align-items:center;gap:2px;padding:4px;box-shadow:var(--shadow);z-index:20;
  }
  .zoombar button{border:none;background:transparent;width:32px;height:32px;border-radius:999px;cursor:pointer;display:grid;place-items:center;color:#333}
  .zoombar button:hover{background:#f2f2f5}
  .zoom-label{font-size:12px;font-weight:600;min-width:52px;text-align:center;padding:0 6px}
  /* Bottom hint */
  .hint{
    position:absolute;bottom:16px;left:16px;
    background:rgba(26,26,30,.92);color:#c9c9d1;font-size:11px;
    padding:8px 12px;border-radius:999px;z-index:20;display:flex;gap:14px;align-items:center;
    backdrop-filter:blur(6px)
  }
  .hint b{color:#fff}
  .hint span{opacity:.9}
  /* Text overlay */
  #textOverlay{
    position:absolute;display:none;z-index:30;
    background:#fff;border:2px solid var(--accent);border-radius:8px;
    box-shadow:var(--shadow);padding:8px;min-width:160px;
  }
  #textOverlay textarea{
    width:220px;min-height:60px;border:none;outline:none;resize:none;
    font:16px/1.4 ui-sans-system, Inter, sans-serif;color:#111;
  }
  #textOverlay .tip{font-size:10px;color:#8a8a93;margin-top:4px}
  /* Undo toast */
  .toast{
    position:absolute;top:76px;left:50%;transform:translateX(-50%);
    background:#1a1a1e;color:#fff;font-size:12px;padding:8px 14px;border-radius:999px;
    opacity:0;pointer-events:none;transition:.2s;z-index:25
  }
  .toast.show{opacity:1}
</style>
</head>
<body>
<div id="app">
  <canvas id="c"></canvas>
  <!-- grid is drawn on canvas -->
  <div class="props" id="propsBar">
    <div>
      <label>Stroke</label>
      <div class="swatches" id="strokeSwatches"></div>
    </div>
    <div>
      <label>Fill</label>
      <div class="swatches" id="fillSwatches"></div>
    </div>
    <div>
      <label>Width</label>
      <div class="width-row">
        <input id="widthRange" type="range" min="1" max="12" step="1" value="2">
        <span class="width-val" id="widthVal">2</span>
      </div>
    </div>
  </div>

  <div class="topbar" id="toolbar">
    <button class="tool-btn active" data-tool="select" title="Select (V)"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M3 3l7.5 13L13 13l3-2.5L3 3z"/><circle cx="13.5" cy="13.5" r="1" fill="currentColor" stroke="none"/></svg><kbd>V</kbd></button>
    <button class="tool-btn" data-tool="pen" title="Pen (P)"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 19l7-7a2 2 0 0 0-2.8-2.8l-7 7v2.8H12z"/><path d="M5 19h4"/></svg><kbd>P</kbd></button>
    <button class="tool-btn" data-tool="rect" title="Rectangle (R)"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="4" y="6" width="16" height="12" rx="1.5"/></svg><kbd>R</kbd></button>
    <button class="tool-btn" data-tool="ellipse" title="Ellipse (O)"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><ellipse cx="12" cy="12" rx="8" ry="6"/></svg><kbd>O</kbd></button>
    <button class="tool-btn" data-tool="arrow" title="Arrow (L)"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 12h14"/><path d="M13 6l6 6-6 6"/></svg><kbd>L</kbd></button>
    <button class="tool-btn" data-tool="text" title="Text (T)"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M6 7h12M12 7v10M8 17h8"/></svg><kbd>T</kbd></button>
    <button class="tool-btn" data-tool="eraser" title="Eraser (E)"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M18 13l-6 6H6l6-6 6-6h0l-0 0z"/><path d="M6 19h12"/></svg><kbd>E</kbd></button>
    <div class="sep"></div>
    <button class="tool-btn" id="undoBtn" title="Undo (Ctrl+Z)"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M9 8H5V4"/><path d="M5 8a7 7 0 0 1 12.2-4.2"/><path d="M15 16h4v4"/><path d="M19 16a7 7 0 0 1-12.2 4.2"/></svg></button>
    <button class="tool-btn" id="redoBtn" title="Redo (Ctrl+Shift+Z)"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M15 8h4V4"/><path d="M19 8A7 7 0 0 0 6.8 3.8"/><path d="M9 16H5v4"/><path d="M5 16a7 7 0 0 0 12.2 4.2"/></svg></button>
    <div class="sep"></div>
    <button class="tool-btn" id="clearBtn" title="Clear board"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 7h16"/><path d="M9 7V5h6v2"/><path d="M7 7l1 12h8l1-12"/><path d="M10 11v6M14 11v6"/></svg></button>
  </div>

  <div class="zoombar">
    <button id="zoomOut" title="Zoom out">−</button>
    <span class="zoom-label" id="zoomLabel">100%</span>
    <button id="zoomIn" title="Zoom in">+</button>
    <div style="width:1px;height:18px;background:#e6e6ea;margin:0 2px"></div>
    <button id="resetView" title="Reset view">⌖</button>
  </div>

  <div class="hint">
    <span><b>Space</b> + drag to pan</span>
    <span><b>Wheel</b> to zoom</span>
    <span><b>Del</b> to delete</span>
    <span style="opacity:.5">|</span>
    <span id="objCount">0 objects</span>
  </div>

  <div id="textOverlay">
    <textarea id="textInput" placeholder="Type something…"></textarea>
    <div class="tip">Enter to save • Esc to cancel</div>
  </div>
  <div class="toast" id="toast"></div>
</div>

<script>
/* ============================================================
   TUNABLE CONSTANTS
   ============================================================ */
const CONFIG = {
  ZOOM_MIN: 0.25,
  ZOOM_MAX: 4,
  ZOOM_STEP: 0.12,
  GRID_SIZE: 24,
  GRID_DOT_RADIUS: 1.1,
  HIT_TOLERANCE: 8,        // world px tolerance (divided by zoom for screen)
  HANDLE_SIZE: 8,          // screen px
  HANDLE_HIT: 10,
  PEN_SMOOTH: 0.35,
  PEN_MIN_DIST: 2,
  STROKE_DEFAULT: '#1a1a1e',
  FILL_DEFAULT: 'transparent',
  FILL_PALETTE: ['transparent','#ffffff','#fef3c7','#fecaca','#bfdbfe','#bbf7d0','#e9d5ff','#fed7aa'],
  STROKE_PALETTE: ['#1a1a1e','#ef4444','#f59e0b','#10b981','#3b82f6','#8b5cf6','#ec4899','#6b7280'],
  STROKE_WIDTH_DEFAULT: 2,
  STORAGE_KEY: 'whiteboard-proto-v2',
  BG: '#ffffff',
  APP_BG: '#e8e9eb',
};

/* ============================================================
   STATE
   ============================================================ */
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
let DPR = Math.max(1, window.devicePixelRatio || 1);

let cam = { x: 0, y: 0, zoom: 1 };
let scene = []; // array of objects {id,type,...}
let selectedIds = new Set();
let tool = 'select';
let clipboard = null;

// style defaults for new objects
let defaults = {
  stroke: CONFIG.STROKE_DEFAULT,
  fill: CONFIG.FILL_DEFAULT,
  strokeWidth: CONFIG.STROKE_WIDTH_DEFAULT,
  opacity: 1
};

// interaction state
let isPanning = false;
let isDragging = false;
let isResizing = false;
let isDrawing = false;
let isRubber = false;
let dragStart = null; // {sx,sy, wx,wy}
let dragOffset = {x:0,y:0};
let rubberRect = null; // {x,y,w,h} in world
let currentShape = null; // shape being created
let penPoints = [];
let resizeHandle = null; // 'nw','ne','sw','se'
let resizeStart = null; // {box, mouseWorld}
let moveStartPositions = new Map(); // id -> {x,y,x1,y1,x2,y2, points}
let eraserPath = [];
let spaceHeld = false;
let mousePos = {x:0,y:0}; // screen

// history
let history = [];
let historyIndex = -1;
let historyBatch = null; // for grouping

/* ============================================================
   UTILS
   ============================================================ */
const uid = () => Math.random().toString(36).slice(2,9);
const clamp = (v,a,b)=>Math.max(a,Math.min(b,v));
function deepClone(o){ return JSON.parse(JSON.stringify(o)); }

function screenToWorld(sx,sy){
  return { x: cam.x + sx / cam.zoom, y: cam.y + sy / cam.zoom };
}
function worldToScreen(wx,wy){
  return { x: (wx - cam.x)*cam.zoom, y: (wy - cam.y)*cam.zoom };
}
function getCanvasRect(){ return canvas.getBoundingClientRect(); }
function eventToScreen(e){
  const r=getCanvasRect();
  return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function worldDist(a,b){ const dx=a.x-b.x, dy=a.y-b.y; return Math.hypot(dx,dy); }

function showToast(msg){
  const t=document.getElementById('toast');
  t.textContent=msg; t.classList.add('show');
  clearTimeout(t._tm); t._tm=setTimeout(()=>t.classList.remove('show'),1400);
}

/* ============================================================
   OBJECT MODEL
   ============================================================ */
function makeRect(x,y,w,h, opts={}){
  return { id:uid(), type:'rect', x, y, w, h,
    stroke: opts.stroke||defaults.stroke, fill: opts.fill||defaults.fill,
    strokeWidth: opts.strokeWidth||defaults.strokeWidth, rotation:0, opacity:1 };
}
function makeEllipse(x,y,w,h, opts={}){
  return { id:uid(), type:'ellipse', x, y, w, h,
    stroke: opts.stroke||defaults.stroke, fill: opts.fill||defaults.fill,
    strokeWidth: opts.strokeWidth||defaults.strokeWidth, opacity:1 };
}
function makeLine(x1,y1,x2,y2, opts={}){
  return { id:uid(), type:'line', x1,y1,x2,y2,
    stroke: opts.stroke||defaults.stroke, strokeWidth: opts.strokeWidth||defaults.strokeWidth, opacity:1 };
}
function makeArrow(x1,y1,x2,y2, opts={}){
  return { id:uid(), type:'arrow', x1,y1,x2,y2,
    stroke: opts.stroke||defaults.stroke, strokeWidth: opts.strokeWidth||defaults.strokeWidth, opacity:1 };
}
function makePen(points, opts={}){
  return { id:uid(), type:'pen', points: points.map(p=>({...p})),
    stroke: opts.stroke||defaults.stroke, strokeWidth: opts.strokeWidth||defaults.strokeWidth, fill:'transparent', opacity:1 };
}
function makeText(x,y,text, opts={}){
  return { id:uid(), type:'text', x, y, text: text||'Text',
    stroke: 'transparent', fill:'transparent', strokeWidth:1,
    fontSize: opts.fontSize||20, color: opts.stroke||defaults.stroke, opacity:1 };
}

function getBounds(obj){
  if(obj.type==='rect' || obj.type==='ellipse'){
    return { x: Math.min(obj.x, obj.x+obj.w), y: Math.min(obj.y, obj.y+obj.h),
             w: Math.abs(obj.w), h: Math.abs(obj.h) };
  }
  if(obj.type==='line' || obj.type==='arrow'){
    return { x: Math.min(obj.x1,obj.x2), y: Math.min(obj.y1,obj.y2),
             w: Math.abs(obj.x2-obj.x1)||1, h: Math.abs(obj.y2-obj.y1)||1 };
  }
  if(obj.type==='pen'){
    let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity;
    obj.points.forEach(p=>{ minX=Math.min(minX,p.x); minY=Math.min(minY,p.y); maxX=Math.max(maxX,p.x); maxY=Math.max(maxY,p.y); });
    if(!isFinite(minX)) return {x:0,y:0,w:0,h:0};
    const pad = (obj.strokeWidth||2)/2 + 2;
    return { x:minX-pad, y:minY-pad, w:(maxX-minX)+pad*2, h:(maxY-minY)+pad*2 };
  }
  if(obj.type==='text'){
    // estimate
    const w = obj.text.length * obj.fontSize * 0.6 + 12;
    const h = obj.fontSize * 1.4 + 8;
    return { x: obj.x, y: obj.y - h + 8, w, h };
  }
  return {x:0,y:0,w:0,h:0};
}
function getSelectionBounds(){
  if(selectedIds.size===0) return null;
  let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity;
  scene.forEach(o=>{
    if(!selectedIds.has(o.id)) return;
    const b=getBounds(o);
    minX=Math.min(minX,b.x); minY=Math.min(minY,b.y);
    maxX=Math.max(maxX,b.x+b.w); maxY=Math.max(maxY,b.y+b.h);
  });
  return { x:minX, y:minY, w:maxX-minX, h:maxY-minY };
}

/* ============================================================
   HIT TESTING (world coordinates)
   ============================================================ */
function hitTest(worldPt){
  // return topmost object id or null; tolerance in world units
  const tol = CONFIG.HIT_TOLERANCE / cam.zoom;
  for(let i=scene.length-1;i>=0;i--){
    const o=scene[i];
    if(hitTestObj(o, worldPt, tol)) return o.id;
  }
  return null;
}
function hitTestObj(o, p, tol){
  if(o.type==='rect'){
    const b=getBounds(o);
    // if has fill, inside counts
    const hasFill = o.fill && o.fill!=='transparent';
    if(hasFill){
      if(p.x>=b.x && p.x<=b.x+b.w && p.y>=b.y && p.y<=b.y+b.h) return true;
    }
    // stroke hit: near border
    const onLeft = Math.abs(p.x - b.x) <= tol && p.y>=b.y-tol && p.y<=b.y+b.h+tol;
    const onRight= Math.abs(p.x - (b.x+b.w)) <= tol && p.y>=b.y-tol && p.y<=b.y+b.h+tol;
    const onTop  = Math.abs(p.y - b.y) <= tol && p.x>=b.x-tol && p.x<=b.x+b.w+tol;
    const onBot  = Math.abs(p.y - (b.y+b.h)) <= tol && p.x>=b.x-tol && p.x<=b.x+b.w+tol;
    if(!hasFill) return onLeft||onRight||onTop||onBot;
    // if filled, border already inside, but we also want border tolerance outside
    // check expanded bounds
    if(p.x>=b.x-tol && p.x<=b.x+b.w+tol && p.y>=b.y-tol && p.y<=b.y+b.h+tol){
      // if inside but not near border and hasFill, already true. If outside by tol but near border, true.
      // For filled, any point within expanded border counts? No, should be inside expanded rect.
      // But to keep corner outside ellipse logic separate, rect expanded is okay.
      // Actually for rect, corner outside but within tol of border should still select.
      // So if we are in expanded rect, check if near border or inside.
      if(p.x>=b.x && p.x<=b.x+b.w && p.y>=b.y && p.y<=b.y+b.h) return true;
      return onLeft||onRight||onTop||onBot;
    }
    return false;
  }
  if(o.type==='ellipse'){
    const b=getBounds(o);
    const cx=b.x+b.w/2, cy=b.y+b.h/2, rx=b.w/2, ry=b.h/2;
    if(rx<=0||ry<=0) return false;
    const dx=(p.x-cx)/rx, dy=(p.y-cy)/ry;
    const d2=dx*dx+dy*dy;
    const hasFill = o.fill && o.fill!=='transparent';
    if(hasFill && d2<=1) return true;
    // stroke: distance to ellipse perimeter
    // approximate: |1 - d| * avg radius < tol
    // more accurate: compute normalized distance
    const d=Math.sqrt(d2);
    if(d===0) return false;
    // point on ellipse along ray
    // distance in world units approx |d-1| * (rx*ry / sqrt((ry*cos)^2+(rx*sin)^2)) ??? simplify
    // use tolerance in normalized space: |d-1| < tol / min(rx,ry) ??? but need world tol
    // compute closest point approx by scaling
    // simple: compute world distance: |d-1| * (rx*ry)/hypot(rx*sin, ry*cos) ??? fallback to approx
    // Instead compute distance to ellipse by iterative? Use simple radial distance * avg radius
    const avgR = (rx+ry)/2;
    const worldDistApprox = Math.abs(d-1)*avgR;
    // refine: for hit we allow tol + strokeWidth/2
    const effTol = tol + (o.strokeWidth||2)/2;
    if(worldDistApprox <= effTol) return true;
    return false;
  }
  if(o.type==='line' || o.type==='arrow'){
    const d = distToSegment(p, {x:o.x1,y:o.y1}, {x:o.x2,y:o.y2});
    return d <= tol + (o.strokeWidth||2)/2;
  }
  if(o.type==='pen'){
    const eff = tol + (o.strokeWidth||2)/2 + 2;
    for(let i=0;i<o.points.length-1;i++){
      if(distToSegment(p, o.points[i], o.points[i+1]) <= eff) return true;
    }
    // also check points
    for(const pt of o.points) if(Math.hypot(p.x-pt.x,p.y-pt.y) <= eff) return true;
    return false;
  }
  if(o.type==='text'){
    const b=getBounds(o);
    return p.x>=b.x && p.x<=b.x+b.w && p.y>=b.y && p.y<=b.y+b.h;
  }
  return false;
}
function distToSegment(p,a,b){
  const l2=(b.x-a.x)**2+(b.y-a.y)**2;
  if(l2===0) return Math.hypot(p.x-a.x,p.y-a.y);
  let t=((p.x-a.x)*(b.x-a.x)+(p.y-a.y)*(b.y-a.y))/l2;
  t=Math.max(0,Math.min(1,t));
  const proj={x:a.x+t*(b.x-a.x), y:a.y+t*(b.y-a.y)};
  return Math.hypot(p.x-proj.x,p.y-proj.y);
}

/* ============================================================
   RENDERING
   ============================================================ */
function resizeCanvas(){
  const rect=getCanvasRect();
  DPR = Math.max(1, window.devicePixelRatio||1);
  canvas.width = Math.round(rect.width * DPR);
  canvas.height = Math.round(rect.height * DPR);
  canvas.style.width = rect.width+'px';
  canvas.style.height = rect.height+'px';
  ctx.setTransform(DPR,0,0,DPR,0,0);
  render();
}
function clearCanvas(){
  ctx.save();
  ctx.setTransform(DPR,0,0,DPR,0,0);
  ctx.fillStyle = CONFIG.APP_BG;
  ctx.fillRect(0,0,canvas.width/DPR, canvas.height/DPR);
  // canvas world area
  const w=canvas.width/DPR, h=canvas.height/DPR;
  // draw white canvas infinite? We'll just fill viewport with white where world is, but simpler: fill viewport white then grid
  // Actually draw white rect covering world viewport
  ctx.fillStyle = CONFIG.BG;
  // world viewport rect in screen
  // Instead just fill whole with white and then overlay app bg outside? Keep simple: white background
  ctx.fillStyle = '#ffffff';
  ctx.fillRect(0,0,w,h);
  ctx.restore();
}
function drawGrid(){
  const size = CONFIG.GRID_SIZE;
  const dot = CONFIG.GRID_DOT_RADIUS;
  // find world bounds visible
  const tl = screenToWorld(0,0);
  const br = screenToWorld(canvas.width/DPR, canvas.height/DPR);
  const startX = Math.floor(tl.x/size)*size;
  const endX = Math.ceil(br.x/size)*size;
  const startY = Math.floor(tl.y/size)*size;
  const endY = Math.ceil(br.y/size)*size;
  ctx.save();
  // transform to world
  ctx.scale(cam.zoom, cam.zoom);
  ctx.translate(-cam.x, -cam.y);
  ctx.fillStyle = 'rgba(0,0,0,.10)';
  const r = dot / cam.zoom; // keep dot size consistent? Actually want dot scales with zoom but keep visible
  // adjust radius to be screen 1px
  const sr = 1.1 / cam.zoom;
  for(let x=startX; x<=endX; x+=size){
    for(let y=startY; y<=endY; y+=size){
      ctx.beginPath();
      ctx.arc(x,y, sr, 0, Math.PI*2);
      ctx.fill();
    }
  }
  ctx.restore();
}
function renderShape(o){
  ctx.save();
  ctx.globalAlpha = o.opacity ?? 1;
  ctx.strokeStyle = o.stroke || '#111';
  ctx.fillStyle = o.fill || 'transparent';
  ctx.lineWidth = (o.strokeWidth||2);
  ctx.lineCap='round'; ctx.lineJoin='round';
  if(o.type==='rect'){
    const b=getBounds(o);
    if(o.fill && o.fill!=='transparent'){ ctx.fillStyle=o.fill; ctx.fillRect(b.x,b.y,b.w,b.h); }
    if(o.stroke && o.stroke!=='transparent'){ ctx.strokeStyle=o.stroke; ctx.strokeRect(b.x,b.y,b.w,b.h); }
  } else if(o.type==='ellipse'){
    const b=getBounds(o);
    ctx.beginPath(); ctx.ellipse(b.x+b.w/2, b.y+b.h/2, b.w/2, b.h/2, 0,0,Math.PI*2);
    if(o.fill && o.fill!=='transparent'){ ctx.fillStyle=o.fill; ctx.fill(); }
    if(o.stroke && o.stroke!=='transparent'){ ctx.strokeStyle=o.stroke; ctx.stroke(); }
  } else if(o.type==='line'){
    ctx.beginPath(); ctx.moveTo(o.x1,o.y1); ctx.lineTo(o.x2,o.y2); ctx.stroke();
  } else if(o.type==='arrow'){
    ctx.beginPath(); ctx.moveTo(o.x1,o.y1); ctx.lineTo(o.x2,o.y2); ctx.stroke();
    // arrow head
    const ang=Math.atan2(o.y2-o.y1, o.x2-o.x1);
    const len=12 + (o.strokeWidth||2)*1.2;
    const w= Math.PI/7;
    ctx.beginPath();
    ctx.moveTo(o.x2, o.y2);
    ctx.lineTo(o.x2 - len*Math.cos(ang - w), o.y2 - len*Math.sin(ang - w));
    ctx.moveTo(o.x2, o.y2);
    ctx.lineTo(o.x2 - len*Math.cos(ang + w), o.y2 - len*Math.sin(ang + w));
    ctx.stroke();
  } else if(o.type==='pen'){
    if(o.points.length<2) { ctx.restore(); return; }
    ctx.beginPath();
    // smoothed polyline using quadratic
    ctx.moveTo(o.points[0].x, o.points[0].y);
    for(let i=1;i<o.points.length-1;i++){
      const p0=o.points[i], p1=o.points[i+1];
      const mx=(p0.x+p1.x)/2, my=(p0.y+p1.y)/2;
      ctx.quadraticCurveTo(p0.x,p0.y,mx,my);
    }
    const last=o.points[o.points.length-1];
    if(o.points.length>2) ctx.lineTo(last.x,last.y);
    else ctx.lineTo(last.x,last.y);
    ctx.strokeStyle=o.stroke; ctx.lineWidth=o.strokeWidth||2;
    ctx.stroke();
  } else if(o.type==='text'){
    ctx.font = `${o.fontSize||20}px ui-sans-system, Inter, sans-serif`;
    ctx.fillStyle = o.color || '#111';
    ctx.textBaseline='top';
    // background if selected? no
    ctx.fillText(o.text, o.x, o.y);
  }
  ctx.restore();
}
function renderSelection(){
  if(selectedIds.size===0) return;
  const b=getSelectionBounds();
  if(!b) return;
  ctx.save();
  ctx.scale(cam.zoom, cam.zoom);
  ctx.translate(-cam.x, -cam.y);
  // dashed bounds
  ctx.strokeStyle='#7c5cff';
  ctx.lineWidth=1 / cam.zoom;
  ctx.setLineDash([6 / cam.zoom, 4 / cam.zoom]);
  ctx.strokeRect(b.x, b.y, b.w, b.h);
  ctx.setLineDash([]);
  // handles
  const hs = CONFIG.HANDLE_SIZE / cam.zoom;
  const half = hs/2;
  const corners=[
    {x:b.x, y:b.y, id:'nw'}, {x:b.x+b.w, y:b.y, id:'ne'},
    {x:b.x, y:b.y+b.h, id:'sw'}, {x:b.x+b.w, y:b.y+b.h, id:'se'}
  ];
  corners.forEach(c=>{
    ctx.fillStyle='#ffffff';
    ctx.strokeStyle='#7c5cff';
    ctx.lineWidth=1.2 / cam.zoom;
    ctx.fillRect(c.x-half, c.y-half, hs, hs);
    ctx.strokeRect(c.x-half, c.y-half, hs, hs);
  });
  ctx.restore();
}
function renderRubber(){
  if(!rubberRect) return;
  ctx.save();
  ctx.scale(cam.zoom, cam.zoom);
  ctx.translate(-cam.x, -cam.y);
  ctx.fillStyle='rgba(124,92,255,.08)';
  ctx.strokeStyle='rgba(124,92,255,.9)';
  ctx.lineWidth=1 / cam.zoom;
  ctx.fillRect(rubberRect.x, rubberRect.y, rubberRect.w, rubberRect.h);
  ctx.strokeRect(rubberRect.x, rubberRect.y, rubberRect.w, rubberRect.h);
  ctx.restore();
}
function renderPreview(){
  if(!currentShape) return;
  ctx.save();
  ctx.scale(cam.zoom, cam.zoom);
  ctx.translate(-cam.x, -cam.y);
  ctx.globalAlpha=0.9;
  // dashed preview
  ctx.setLineDash([6,4]);
  renderShape(currentShape);
  ctx.restore();
}
function renderPenPreview(){
  if(tool!=='pen' || penPoints.length<2) return;
  ctx.save();
  ctx.scale(cam.zoom, cam.zoom);
  ctx.translate(-cam.x, -cam.y);
  ctx.strokeStyle=defaults.stroke;
  ctx.lineWidth=defaults.strokeWidth;
  ctx.lineCap='round'; ctx.lineJoin='round';
  ctx.beginPath();
  ctx.moveTo(penPoints[0].x, penPoints[0].y);
  for(let i=1;i<penPoints.length-1;i++){
    const p0=penPoints[i], p1=penPoints[i+1];
    const mx=(p0.x+p1.x)/2, my=(p0.y+p1.y)/2;
    ctx.quadraticCurveTo(p0.x,p0.y,mx,my);
  }
  if(penPoints.length>1){
    const last=penPoints[penPoints.length-1];
    ctx.lineTo(last.x,last.y);
  }
  ctx.stroke();
  ctx.restore();
}
function render(){
  clearCanvas();
  drawGrid();
  ctx.save();
  ctx.scale(cam.zoom, cam.zoom);
  ctx.translate(-cam.x, -cam.y);
  scene.forEach(o=> renderShape(o));
  ctx.restore();
  renderPenPreview();
  renderPreview();
  renderRubber();
  renderSelection();
  // update UI
  document.getElementById('zoomLabel').textContent = Math.round(cam.zoom*100)+'%';
  document.getElementById('objCount').textContent = scene.length + ' objects';
  // props bar active state
  const bar=document.getElementById('propsBar');
  if(selectedIds.size>0) bar.classList.remove('hidden'); else bar.classList.remove('hidden'); // always show but reflect selection
  updateSwatchUI();
  // toolbar active
  document.querySelectorAll('.tool-btn[data-tool]').forEach(b=>{
    b.classList.toggle('active', b.dataset.tool===tool);
  });
}

/* ============================================================
   HISTORY & PERSISTENCE
   ============================================================ */
function pushHistory(){
  // truncate redo
  history = history.slice(0, historyIndex+1);
  history.push(deepClone(scene));
  historyIndex = history.length-1;
  if(history.length>120){ history.shift(); historyIndex--; }
  saveToStorage();
  updateUndoButtons();
}
function undo(){
  if(historyIndex<=0) { showToast('Nothing to undo'); return; }
  historyIndex--;
  scene = deepClone(history[historyIndex]);
  selectedIds.clear();
  saveToStorage();
  render();
  updateUndoButtons();
  showToast('Undone');
}
function redo(){
  if(historyIndex>=history.length-1){ showToast('Nothing to redo'); return; }
  historyIndex++;
  scene = deepClone(history[historyIndex]);
  selectedIds.clear();
  saveToStorage();
  render();
  updateUndoButtons();
  showToast('Redone');
}
function updateUndoButtons(){
  document.getElementById('undoBtn').style.opacity = historyIndex>0? '1':'.4';
  document.getElementById('redoBtn').style.opacity = historyIndex<history.length-1? '1':'.4';
}
function saveToStorage(){
  try{
    localStorage.setItem(CONFIG.STORAGE_KEY, JSON.stringify({scene, cam}));
  }catch(e){}
}
function loadFromStorage(){
  try{
    const raw=localStorage.getItem(CONFIG.STORAGE_KEY);
    if(!raw) return false;
    const data=JSON.parse(raw);
    if(Array.isArray(data.scene)){ scene=data.scene; }
    else if(Array.isArray(data)){ scene=data; }
    if(data.cam){ cam = {...cam, ...data.cam}; cam.zoom=clamp(cam.zoom, CONFIG.ZOOM_MIN, CONFIG.ZOOM_MAX); }
    // init history
    history=[deepClone(scene)];
    historyIndex=0;
    return true;
  }catch(e){ return false; }
}

/* ============================================================
   PROPERTIES BAR
   ============================================================ */
function buildSwatches(){
  const sCont=document.getElementById('strokeSwatches');
  const fCont=document.getElementById('fillSwatches');
  sCont.innerHTML=''; fCont.innerHTML='';
  CONFIG.STROKE_PALETTE.forEach(c=>{
    const d=document.createElement('div');
    d.className='swatch'; d.style.background=c; d.dataset.color=c; d.dataset.kind='stroke';
    d.title=c; sCont.appendChild(d);
  });
  CONFIG.FILL_PALETTE.forEach(c=>{
    const d=document.createElement('div');
    d.className='swatch'; if(c==='transparent') d.classList.add('transparent'); else d.style.background=c;
    d.dataset.color=c; d.dataset.kind='fill'; d.title=c;
    fCont.appendChild(d);
  });
  sCont.addEventListener('click', e=>{
    const sw=e.target.closest('.swatch'); if(!sw) return;
    const col=sw.dataset.color;
    defaults.stroke=col;
    if(selectedIds.size>0){
      // apply to selection as one history step
      const before=deepClone(scene);
      scene.forEach(o=>{ if(selectedIds.has(o.id)){
        if(o.type==='text') o.color=col; else o.stroke=col;
      }});
      if(JSON.stringify(before)!==JSON.stringify(scene)) pushHistory();
      render();
    } else updateSwatchUI();
  });
  fCont.addEventListener('click', e=>{
    const sw=e.target.closest('.swatch'); if(!sw) return;
    const col=sw.dataset.color;
    defaults.fill=col;
    if(selectedIds.size>0){
      const before=deepClone(scene);
      scene.forEach(o=>{ if(selectedIds.has(o.id) && (o.type==='rect'||o.type==='ellipse')){
        o.fill=col;
      }});
      if(JSON.stringify(before)!==JSON.stringify(scene)) pushHistory();
      render();
    } else updateSwatchUI();
  });
  document.getElementById('widthRange').addEventListener('input', e=>{
    const v=parseInt(e.target.value,10);
    defaults.strokeWidth=v;
    document.getElementById('widthVal').textContent=v;
    if(selectedIds.size>0){
      scene.forEach(o=>{ if(selectedIds.has(o.id) && o.type!=='text') o.strokeWidth=v; });
      render();
    }
  });
  document.getElementById('widthRange').addEventListener('change', ()=>{
    if(selectedIds.size>0) pushHistory();
  });
}
function updateSwatchUI(){
  const strokeSw=document.querySelectorAll('#strokeSwatches .swatch');
  const fillSw=document.querySelectorAll('#fillSwatches .swatch');
  // determine current stroke/fill from selection or defaults
  let curStroke=defaults.stroke, curFill=defaults.fill, curW=defaults.strokeWidth;
  if(selectedIds.size>0){
    const sel=[...scene].filter(o=>selectedIds.has(o.id));
    if(sel.length===1){ curStroke = sel[0].type==='text'? sel[0].color : sel[0].stroke; curFill = sel[0].fill||'transparent'; curW=sel[0].strokeWidth||defaults.strokeWidth; }
    else {
      // mixed: keep defaults display but highlight if all same
      const allStroke = sel.every(o=> (o.type==='text'?o.color:o.stroke)=== (sel[0].type==='text'?sel[0].color:sel[0].stroke));
      if(allStroke) curStroke = sel[0].type==='text'?sel[0].color:sel[0].stroke;
    }
  }
  strokeSw.forEach(s=> s.classList.toggle('active', s.dataset.color===curStroke));
  fillSw.forEach(s=> s.classList.toggle('active', s.dataset.color===curFill));
  document.getElementById('widthRange').value=curW;
  document.getElementById('widthVal').textContent=curW;
}

/* ============================================================
   VIEWPORT CONTROLS
   ============================================================ */
function setZoom(newZoom, centerScreen){
  const oldZoom=cam.zoom;
  newZoom=clamp(newZoom, CONFIG.ZOOM_MIN, CONFIG.ZOOM_MAX);
  if(newZoom===oldZoom) return;
  // keep world point under cursor stable
  const worldBefore = screenToWorld(centerScreen.x, centerScreen.y);
  cam.zoom=newZoom;
  const worldAfter = screenToWorld(centerScreen.x, centerScreen.y);
  // Actually we want worldBefore to stay under same screen point: cam.x = worldBefore.x - centerScreen.x/newZoom
  cam.x = worldBefore.x - centerScreen.x / newZoom;
  cam.y = worldBefore.y - centerScreen.y / newZoom;
  render();
  saveToStorage();
}
function resetView(){
  cam.x = - (canvas.width/DPR)/2 / cam.zoom + 0; // center around 0,0
  // Better: fit content or center
  if(scene.length>0){
    let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity;
    scene.forEach(o=>{ const b=getBounds(o); minX=Math.min(minX,b.x); minY=Math.min(minY,b.y); maxX=Math.max(maxX,b.x+b.w); maxY=Math.max(maxY,b.y+b.h); });
    const cx=(minX+maxX)/2, cy=(minY+maxY)/2;
    const vw=canvas.width/DPR, vh=canvas.height/DPR;
    cam.x = cx - vw/(2*cam.zoom);
    cam.y = cy - vh/(2*cam.zoom);
  } else {
    cam.x= -200; cam.y=-200;
  }
  cam.zoom=1;
  render(); saveToStorage();
}

/* ============================================================
   SELECTION HELPERS
   ============================================================ */
function getHandleAt(screenPt){
  if(selectedIds.size===0) return null;
  const b=getSelectionBounds(); if(!b) return null;
  const corners={
    nw: worldToScreen(b.x, b.y),
    ne: worldToScreen(b.x+b.w, b.y),
    sw: worldToScreen(b.x, b.y+b.h),
    se: worldToScreen(b.x+b.w, b.y+b.h)
  };
  const hit=CONFIG.HANDLE_HIT;
  for(const [k,pt] of Object.entries(corners)){
    if(Math.abs(pt.x - screenPt.x) <= hit && Math.abs(pt.y - screenPt.y) <= hit) return k;
  }
  return null;
}
function nudgeSelection(dx,dy){
  if(selectedIds.size===0) return;
  // capture before for undo
  const before=deepClone(scene);
  scene.forEach(o=>{
    if(!selectedIds.has(o.id)) return;
    if(o.type==='rect'||o.type==='ellipse'||o.type==='text'){ o.x+=dx; o.y+=dy; }
    else if(o.type==='line'||o.type==='arrow'){ o.x1+=dx; o.y1+=dy; o.x2+=dx; o.y2+=dy; }
    else if(o.type==='pen'){ o.points.forEach(p=>{p.x+=dx; p.y+=dy;}); }
  });
  pushHistory();
  render(); saveToStorage();
}
function deleteSelection(){
  if(selectedIds.size===0) return;
  scene = scene.filter(o=> !selectedIds.has(o.id));
  selectedIds.clear();
  pushHistory();
  render();
  showToast('Deleted');
}
function selectAll(){
  selectedIds = new Set(scene.map(o=>o.id));
  render();
}

/* ============================================================
   TEXT TOOL
   ============================================================ */
const textOverlay=document.getElementById('textOverlay');
const textInput=document.getElementById('textInput');
let editingTextId=null;
let textWorldPos=null;

function openTextEditor(worldPos, existingObj=null){
  textWorldPos=worldPos;
  editingTextId = existingObj? existingObj.id : null;
  const sp = worldToScreen(worldPos.x, worldPos.y);
  textOverlay.style.left = (sp.x+8)+'px';
  textOverlay.style.top = (sp.y+8)+'px';
  textOverlay.style.display='block';
  textInput.value = existingObj? existingObj.text : '';
  textInput.focus();
  textInput.select();
}
function commitText(){
  const txt=textInput.value.trim();
  textOverlay.style.display='none';
  if(!txt){ editingTextId=null; textWorldPos=null; return; }
  if(editingTextId){
    const obj=scene.find(o=>o.id===editingTextId);
    if(obj){ obj.text=txt; pushHistory(); render(); saveToStorage(); }
  } else {
    const obj=makeText(textWorldPos.x, textWorldPos.y, txt, {stroke: defaults.stroke});
    scene.push(obj);
    selectedIds.clear(); selectedIds.add(obj.id);
    pushHistory(); render(); saveToStorage();
  }
  editingTextId=null; textWorldPos=null;
}
function cancelText(){
  textOverlay.style.display='none';
  editingTextId=null; textWorldPos=null;
}
textInput.addEventListener('keydown', e=>{
  if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); commitText(); }
  if(e.key==='Escape'){ e.preventDefault(); cancelText(); }
});
textInput.addEventListener('blur', ()=>{ if(textOverlay.style.display==='block') commitText(); });

/* ============================================================
   MOUSE / POINTER HANDLERS
   ============================================================ */
function onPointerDown(e){
  const screen = eventToScreen(e);
  mousePos={...screen};
  const world = screenToWorld(screen.x, screen.y);
  const isMiddle = e.button===1;
  const isSpacePan = spaceHeld;

  // panning
  if(isMiddle || isSpacePan){
    isPanning=true;
    dragStart={sx:screen.x, sy:screen.y, camX:cam.x, camY:cam.y};
    canvas.setPointerCapture(e.pointerId);
    e.preventDefault();
    return;
  }

  if(tool==='select'){
    const handle=getHandleAt(screen);
    if(handle && selectedIds.size>0){
      isResizing=true;
      resizeHandle=handle;
      const b=getSelectionBounds();
      resizeStart={ box:{...b}, mouseWorld:{...world} };
      // snapshot positions of all selected objects relative to bounds
      moveStartPositions.clear();
      scene.forEach(o=>{
        if(selectedIds.has(o.id)){
          if(o.type==='rect'||o.type==='ellipse'||o.type==='text') moveStartPositions.set(o.id, {x:o.x,y:o.y,w:o.w,h:o.h, fontSize:o.fontSize});
          else if(o.type==='line'||o.type==='arrow') moveStartPositions.set(o.id, {x1:o.x1,y1:o.y1,x2:o.x2,y2:o.y2});
          else if(o.type==='pen') moveStartPositions.set(o.id, {points: deepClone(o.points)});
        }
      });
      canvas.setPointerCapture(e.pointerId);
      return;
    }
    const hitId=hitTest(world);
    if(hitId){
      const isShift=e.shiftKey;
      if(isShift){
        if(selectedIds.has(hitId)) selectedIds.delete(hitId); else selectedIds.add(hitId);
      } else {
        if(!selectedIds.has(hitId)){ selectedIds.clear(); selectedIds.add(hitId); }
      }
      // start moving
      isDragging=true;
      dragStart={sx:screen.x, sy:screen.y, wx:world.x, wy:world.y};
      moveStartPositions.clear();
      scene.forEach(o=>{
        if(selectedIds.has(o.id)){
          if(o.type==='rect'||o.type==='ellipse'||o.type==='text') moveStartPositions.set(o.id, {x:o.x,y:o.y});
          else if(o.type==='line'||o.type==='arrow') moveStartPositions.set(o.id, {x1:o.x1,y1:o.y1,x2:o.x2,y2:o.y2});
          else if(o.type==='pen') moveStartPositions.set(o.id, {points: deepClone(o.points)});
        }
      });
      render();
      canvas.setPointerCapture(e.pointerId);
      return;
    } else {
      // empty space: rubber band or deselect
      if(!e.shiftKey) selectedIds.clear();
      isRubber=true;
      rubberRect={x:world.x, y:world.y, w:0, h:0};
      dragStart={sx:screen.x, sy:screen.y, wx:world.x, wy:world.y};
      render();
      canvas.setPointerCapture(e.pointerId);
      return;
    }
  }

  if(tool==='pen'){
    isDrawing=true;
    penPoints=[world];
    canvas.setPointerCapture(e.pointerId);
    return;
  }
  if(tool==='rect' || tool==='ellipse' || tool==='arrow'){
    isDrawing=true;
    dragStart={sx:screen.x, sy:screen.y, wx:world.x, wy:world.y};
    if(tool==='rect') currentShape=makeRect(world.x, world.y, 0,0);
    else if(tool==='ellipse') currentShape=makeEllipse(world.x, world.y, 0,0);
    else if(tool==='arrow') currentShape=makeArrow(world.x, world.y, world.x, world.y);
    canvas.setPointerCapture(e.pointerId);
    return;
  }
  if(tool==='text'){
    // check if clicking existing text to edit
    const hitId=hitTest(world);
    if(hitId){
      const obj=scene.find(o=>o.id===hitId);
      if(obj && obj.type==='text'){
        openTextEditor({x:obj.x, y:obj.y}, obj);
        return;
      }
    }
    openTextEditor(world, null);
    return;
  }
  if(tool==='eraser'){
    isDrawing=true;
    eraserPath=[world];
    // immediate erase at point
    eraseAt(world);
    canvas.setPointerCapture(e.pointerId);
    return;
  }
}
function onPointerMove(e){
  const screen=eventToScreen(e);
  mousePos={...screen};
  const world=screenToWorld(screen.x, screen.y);

  if(isPanning && dragStart){
    const dx=screen.x - dragStart.sx;
    const dy=screen.y - dragStart.sy;
    cam.x = dragStart.camX - dx / cam.zoom;
    cam.y = dragStart.camY - dy / cam.zoom;
    render();
    return;
  }
  if(isResizing && resizeStart){
    const b0=resizeStart.box;
    const shift=e.shiftKey;
    // compute new bounds based on handle
    let nx=b0.x, ny=b0.y, nw=b0.w, nh=b0.h;
    const mx=world.x, my=world.y;
    if(resizeHandle==='nw'){ nx=mx; ny=my; nw=(b0.x+b0.w)-mx; nh=(b0.y+b0.h)-my; }
    if(resizeHandle==='ne'){ ny=my; nw=mx - b0.x; nh=(b0.y+b0.h)-my; }
    if(resizeHandle==='sw'){ nx=mx; nw=(b0.x+b0.w)-mx; nh=my - b0.y; }
    if(resizeHandle==='se'){ nw=mx - b0.x; nh=my - b0.y; }
    // handle negative flips? allow flipping by normalizing
    // preserve aspect with shift
    if(shift){
      const aspect = b0.w / (b0.h||1);
      // adjust to keep aspect: use larger delta
      if(Math.abs(nw) / Math.abs(nh) > aspect){
        // width dominates
        const signH = nh<0?-1:1;
        nh = Math.abs(nw)/aspect * signH;
        if(resizeHandle==='nw' || resizeHandle==='sw') ny = (b0.y+b0.h) - nh;
      } else {
        const signW = nw<0?-1:1;
        nw = Math.abs(nh)*aspect * signW;
        if(resizeHandle==='nw' || resizeHandle==='ne') nx = (b0.x+b0.w) - nw;
      }
    }
    // minimum size
    if(Math.abs(nw)<6) nw = 6*Math.sign(nw||1);
    if(Math.abs(nh)<6) nh = 6*Math.sign(nh||1);
    // normalize to positive w/h for storage but keep position
    let finalX=nx, finalY=ny, finalW=nw, finalH=nh;
    if(finalW<0){ finalX+=finalW; finalW=Math.abs(finalW); }
    if(finalH<0){ finalY+=finalH; finalH=Math.abs(finalH); }
    // apply to each selected object proportionally
    const sx = finalW / b0.w;
    const sy = finalH / b0.h;
    scene.forEach(o=>{
      if(!selectedIds.has(o.id)) return;
      const start=moveStartPositions.get(o.id);
      if(!start) return;
      if(o.type==='rect' || o.type==='ellipse' || o.type==='text'){
        // map object's bounds relative to selection
        // compute object's original bounds
        const ob = (()=> {
          if(o.type==='text'){
            // use start x,y
            return {x:start.x, y:start.y - (start.fontSize*1.4+8)+8, w: (o.text.length*start.fontSize*0.6+12), h:start.fontSize*1.4+8 };
          }
          return {x:start.x, y:start.y, w:start.w, h:start.h};
        })();
        const relX = (ob.x - b0.x)/b0.w;
        const relY = (ob.y - b0.y)/b0.h;
        const relW = ob.w / b0.w;
        const relH = ob.h / b0.h;
        const newX = finalX + relX*finalW;
        const newY = finalY + relY*finalH;
        const newW = relW*finalW;
        const newH = relH*finalH;
        o.x=newX; o.y=newY;
        if(o.type!=='text'){ o.w=newW; o.h=newH; }
        else {
          // scale font size proportionally (average)
          const sc=(sx+sy)/2;
          o.fontSize = Math.max(8, Math.round(start.fontSize*sc));
          // keep x,y as newX, newY adjusted for text baseline
          o.x=newX; o.y=newY + o.fontSize*1.4 -2;
        }
      } else if(o.type==='line' || o.type==='arrow'){
        const mapPt=(px,py)=>{
          const rx=(px - b0.x)/b0.w, ry=(py - b0.y)/b0.h;
          return {x: finalX + rx*finalW, y: finalY + ry*finalH};
        };
        const p1=mapPt(start.x1,start.y1), p2=mapPt(start.x2,start.y2);
        o.x1=p1.x; o.y1=p1.y; o.x2=p2.x; o.y2=p2.y;
      } else if(o.type==='pen'){
        o.points = start.points.map(pt=>{
          const rx=(pt.x - b0.x)/b0.w, ry=(pt.y - b0.y)/b0.h;
          return {x: finalX + rx*finalW, y: finalY + ry*finalH};
        });
      }
    });
    render();
    return;
  }
  if(isDragging && dragStart){
    const dx=world.x - dragStart.wx;
    const dy=world.y - dragStart.wy;
    scene.forEach(o=>{
      if(!selectedIds.has(o.id)) return;
      const s=moveStartPositions.get(o.id);
      if(!s) return;
      if(o.type==='rect'||o.type==='ellipse'||o.type==='text'){ o.x=s.x+dx; o.y=s.y+dy; }
      else if(o.type==='line'||o.type==='arrow'){ o.x1=s.x1+dx; o.y1=s.y1+dy; o.x2=s.x2+dx; o.y2=s.y2+dy; }
      else if(o.type==='pen'){ o.points = s.points.map(p=>({x:p.x+dx, y:p.y+dy})); }
    });
    render();
    return;
  }
  if(isRubber && dragStart){
    const x0=dragStart.wx, y0=dragStart.wy;
    rubberRect={ x: Math.min(x0, world.x), y: Math.min(y0, world.y), w: Math.abs(world.x - x0), h: Math.abs(world.y - y0) };
    // live selection: objects intersecting rubber
    const rr=rubberRect;
    const newSel=new Set(selectedIds); // keep existing if shift? Actually we cleared earlier unless shift
    // For rubber, select those intersecting
    scene.forEach(o=>{
      const b=getBounds(o);
      const intersect = !(b.x+b.w < rr.x || b.x > rr.x+rr.w || b.y+b.h < rr.y || b.y > rr.y+rr.h);
      if(intersect) newSel.add(o.id);
    });
    // If not shift, we want only those in rubber plus previously shift-selected? Simpler: if shift, add; else replace with rubber hits
    // To make undo-friendly, we just update visual selection during drag
    // We'll compute from scratch: if shift was held at start, keep original plus rubber; else only rubber
    // For simplicity, track initial selected before rubber
    // We'll just set to rubber hits (plus shift)
    // Let's store initial selection at dragStart
    if(!dragStart.initialSel) dragStart.initialSel=new Set(selectedIds);
    // Actually we cleared at mousedown if not shift, so initial is empty
    // Recompute:
    const base = dragStart.shift? dragStart.initialSel : new Set();
    const combined=new Set(base);
    scene.forEach(o=>{
      const b=getBounds(o);
      const inter = !(b.x+b.w < rr.x || b.x > rr.x+rr.w || b.y+b.h < rr.y || b.y > rr.y+rr.h);
      if(inter) combined.add(o.id);
    });
    selectedIds=combined;
    render();
    return;
  }
  if(isDrawing){
    if(tool==='pen'){
      const last=penPoints[penPoints.length-1];
      if(Math.hypot(world.x-last.x, world.y-last.y) >= CONFIG.PEN_MIN_DIST){
        penPoints.push(world);
        render();
      }
      return;
    }
    if(tool==='rect' || tool==='ellipse'){
      const x0=dragStart.wx, y0=dragStart.wy;
      const w=world.x - x0, h=world.y - y0;
      // allow shift to force square
      let fw=w, fh=h;
      if(e.shiftKey){
        const s=Math.max(Math.abs(w), Math.abs(h));
        fw = Math.sign(w)*s; fh=Math.sign(h)*s;
      }
      if(tool==='rect'){
        currentShape.x = fw<0? x0+fw : x0;
        currentShape.y = fh<0? y0+fh : y0;
        currentShape.w = Math.abs(fw); currentShape.h=Math.abs(fh);
      } else {
        currentShape.x = fw<0? x0+fw : x0;
        currentShape.y = fh<0? y0+fh : y0;
        currentShape.w = Math.abs(fw); currentShape.h=Math.abs(fh);
      }
      render();
      return;
    }
    if(tool==='arrow'){
      currentShape.x2=world.x; currentShape.y2=world.y;
      render();
      return;
    }
    if(tool==='eraser'){
      eraserPath.push(world);
      eraseAt(world);
      render();
      return;
    }
  }
  // hover cursor
  updateCursor(screen);
}
function onPointerUp(e){
  const screen=eventToScreen(e);
  const world=screenToWorld(screen.x, screen.y);

  if(isPanning){ isPanning=false; dragStart=null; saveToStorage(); return; }
  if(isResizing){
    isResizing=false; resizeHandle=null; resizeStart=null;
    moveStartPositions.clear();
    pushHistory();
    render();
    return;
  }
  if(isDragging){
    isDragging=false;
    // if actually moved, push history
    let moved=false;
    scene.forEach(o=>{
      if(!selectedIds.has(o.id)) return;
      const s=moveStartPositions.get(o.id);
      if(!s) return;
      if(o.type==='rect'||o.type==='ellipse'||o.type==='text'){ if(o.x!==s.x||o.y!==s.y) moved=true; }
      else if(o.type==='line'||o.type==='arrow'){ if(o.x1!==s.x1||o.y1!==s.y1) moved=true; }
      else if(o.type==='pen'){ if(o.points[0].x!==s.points[0].x || o.points[0].y!==s.points[0].y) moved=true; }
    });
    moveStartPositions.clear();
    dragStart=null;
    if(moved) pushHistory();
    render();
    return;
  }
  if(isRubber){
    isRubber=false;
    rubberRect=null;
    dragStart=null;
    render();
    return;
  }
  if(isDrawing){
    isDrawing=false;
    if(tool==='pen'){
      if(penPoints.length>1){
        const obj=makePen(penPoints);
        scene.push(obj);
        pushHistory();
      }
      penPoints=[];
      render();
    } else if(tool==='rect' || tool==='ellipse' || tool==='arrow'){
      if(currentShape){
        // ignore tiny shapes
        let valid=true;
        if(tool==='rect' || tool==='ellipse'){
          if(currentShape.w<4 || currentShape.h<4) valid=false;
        } else {
          if(Math.hypot(currentShape.x2-currentShape.x1, currentShape.y2-currentShape.y1)<6) valid=false;
        }
        if(valid){
          scene.push(currentShape);
          selectedIds.clear(); selectedIds.add(currentShape.id);
          pushHistory();
        }
      }
      currentShape=null;
      dragStart=null;
      render();
    } else if(tool==='eraser'){
      // eraser already deleted objects live; push history if changed
      // we pushed incrementally? Instead we should have pushed once at end if any deleted
      // To make undo single step, we need to track before
      // Our eraseAt deletes immediately without history; we need to push once
      // We'll handle by checking if scene length changed vs history top
      const lastHist = history[historyIndex];
      if(lastHist && lastHist.length !== scene.length){
        pushHistory();
      } else if(scene.length>0){
        // if no history yet
        if(history.length===0) pushHistory();
      }
      eraserPath=[];
      dragStart=null;
      render();
    }
    return;
  }
}
function eraseAt(worldPt){
  const tol = 10 / cam.zoom;
  // find objects hit
  const toDelete=[];
  scene.forEach(o=>{
    if(hitTestObj(o, worldPt, tol)) toDelete.push(o.id);
  });
  if(toDelete.length>0){
    // delete them
    scene = scene.filter(o=> !toDelete.includes(o.id));
    toDelete.forEach(id=> selectedIds.delete(id));
  }
}
function updateCursor(screen){
  if(tool==='select'){
    const h=getHandleAt(screen);
    if(h){
      const cursors={nw:'nwse-resize', ne:'nesw-resize', sw:'nesw-resize', se:'nwse-resize'};
      canvas.style.cursor=cursors[h];
      return;
    }
    const world=screenToWorld(screen.x, screen.y);
    const hit=hitTest(world);
    canvas.style.cursor = hit? 'move' : 'default';
  } else if(tool==='pen') canvas.style.cursor='crosshair';
  else if(tool==='eraser') canvas.style.cursor='cell';
  else canvas.style.cursor='crosshair';
}

/* ============================================================
   KEYBOARD
   ============================================================ */
window.addEventListener('keydown', e=>{
  const isMod = e.ctrlKey || e.metaKey;
  if(e.code==='Space' && !spaceHeld && !e.repeat){
    spaceHeld=true;
    canvas.style.cursor='grab';
    e.preventDefault();
  }
  if(isMod && e.key.toLowerCase()==='z'){
    e.preventDefault();
    if(e.shiftKey) redo(); else undo();
    return;
  }
  if(isMod && e.key.toLowerCase()==='y'){ e.preventDefault(); redo(); return; }
  if(e.key==='Delete' || e.key==='Backspace'){
    // don't delete if editing text
    if(textOverlay.style.display==='block') return;
    if(selectedIds.size>0){ e.preventDefault(); deleteSelection(); }
    return;
  }
  if(e.key==='Escape'){
    if(textOverlay.style.display==='block'){ cancelText(); return; }
    selectedIds.clear(); currentShape=null; penPoints=[]; isDrawing=false; isDragging=false; isRubber=false; rubberRect=null;
    render();
    return;
  }
  // tool shortcuts
  if(!isMod && textOverlay.style.display!=='block'){
    const k=e.key.toLowerCase();
    if(k==='v') { tool='select'; render(); }
    else if(k==='p') { tool='pen'; render(); }
    else if(k==='r') { tool='rect'; render(); }
    else if(k==='o') { tool='ellipse'; render(); }
    else if(k==='l') { tool='arrow'; render(); }
    else if(k==='t') { tool='text'; render(); }
    else if(k==='e') { tool='eraser'; render(); }
    else if(k==='a' && isMod){ /* select all handled below */ }
  }
  if(isMod && e.key.toLowerCase()==='a'){
    e.preventDefault(); selectAll();
  }
  // arrow nudge
  if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.key) && selectedIds.size>0 && textOverlay.style.display!=='block'){
    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;
    nudgeSelection(dx,dy);
  }
});
window.addEventListener('keyup', e=>{
  if(e.code==='Space'){ spaceHeld=false; canvas.style.cursor='default'; }
});

/* ============================================================
   WHEEL ZOOM
   ============================================================ */
canvas.addEventListener('wheel', e=>{
  e.preventDefault();
  const screen=eventToScreen(e);
  const delta = -e.deltaY;
  const factor = delta>0 ? (1+CONFIG.ZOOM_STEP) : (1/(1+CONFIG.ZOOM_STEP));
  const newZoom = clamp(cam.zoom * factor, CONFIG.ZOOM_MIN, CONFIG.ZOOM_MAX);
  setZoom(newZoom, screen);
},{passive:false});

/* ============================================================
   TOOLBAR EVENTS
   ============================================================ */
document.querySelectorAll('.tool-btn[data-tool]').forEach(btn=>{
  btn.addEventListener('click', ()=>{
    tool=btn.dataset.tool;
    // cancel any in-progress
    isDrawing=false; penPoints=[]; currentShape=null; rubberRect=null;
    render();
  });
});
document.getElementById('undoBtn').addEventListener('click', undo);
document.getElementById('redoBtn').addEventListener('click', redo);
document.getElementById('clearBtn').addEventListener('click', ()=>{
  if(scene.length===0) return;
  if(confirm('Clear entire board?')){
    scene=[]; selectedIds.clear(); pushHistory(); render();
  }
});
document.getElementById('zoomIn').addEventListener('click', ()=>{
  const rect=getCanvasRect();
  setZoom(cam.zoom*(1+CONFIG.ZOOM_STEP), {x:rect.width/2, y:rect.height/2});
});
document.getElementById('zoomOut').addEventListener('click', ()=>{
  const rect=getCanvasRect();
  setZoom(cam.zoom/(1+CONFIG.ZOOM_STEP), {x:rect.width/2, y:rect.height/2});
});
document.getElementById('resetView').addEventListener('click', resetView);

/* ============================================================
   CANVAS POINTER EVENTS
   ============================================================ */
canvas.addEventListener('pointerdown', onPointerDown);
canvas.addEventListener('pointermove', onPointerMove);
canvas.addEventListener('pointerup', onPointerUp);
canvas.addEventListener('pointercancel', onPointerUp);
// prevent context menu
canvas.addEventListener('contextmenu', e=> e.preventDefault());

/* ============================================================
   INIT
   ============================================================ */
buildSwatches();
if(!loadFromStorage()){
  // demo content
  scene.push(makeRect(120,140,220,140, {stroke:'#1a1a1e', fill:'#fef3c7', strokeWidth:2}));
  scene.push(makeEllipse(420,160,180,120, {stroke:'#7c5cff', fill:'#e9d5ff', strokeWidth:2}));
  scene.push(makeArrow(200,340,520,340, {stroke:'#1a1a1e', strokeWidth:2}));
  scene.push(makeText(140, 120, 'Welcome to Board', {stroke:'#1a1a1e'}));
  scene.push(makePen([{x:520,y:120},{x:560,y:140},{x:600,y:120},{x:640,y:160}], {stroke:'#ef4444', strokeWidth:3}));
  history=[deepClone(scene)]; historyIndex=0;
  cam.x=-80; cam.y=-60; cam.zoom=1;
  saveToStorage();
}
resizeCanvas();
render();
updateUndoButtons();
window.addEventListener('resize', resizeCanvas);

// expose for probe tests
window.__board = {
  get scene(){ return scene; },
  get cam(){ return cam; },
  get selected(){ return [...selectedIds]; },
  get tool(){ return tool; },
  set tool(v){ tool=v; render(); },
  screenToWorld, worldToScreen, hitTest, getBounds, CONFIG
};
</script>
</body>
</html>
<!-- agent-meta {"model":"meta/muse-spark-1.2-contributor","provider":"openrouter","persona":"full","sessionId":"5adb59a4-bb78-4ae1-88af-3b06bc79a125","tokensIn":142327,"tokensOut":19105,"tokensTotal":161432,"cost":0.009769466,"turns":6,"toolCalls":5,"failedToolCalls":0,"timestamp":"2026-08-23T01:11:23.869Z"} -->