Whiteboard
Claude haiku 5.5 · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Task text captured before this run. Effective runtime system instructions were not captured.
Generation prompt · 6c6aa5b74f
# Task: Build a Collaborative Whiteboard UI Prototype
Build a whiteboard application prototype (vanilla JS, no external assets, no CDN, no libraries) in the spirit of Excalidraw, Miro, or tldraw. The top priority is **rock-solid interaction** — drawing, selecting, moving, and undoing must all behave exactly as a user expects.
## Core Specification
**Infinite Canvas & Viewport**
- A pan/zoom canvas: Space+drag (or middle-mouse drag) to pan, mouse wheel to zoom toward the cursor position
- Zoom range roughly 25%–400%, with the current zoom shown in the corner and a "reset view" button
- All objects live in *world coordinates*; rendering applies the viewport transform. Hit-testing must be done in world coordinates — selection must stay pixel-accurate at every zoom level
- Optional subtle dot or line grid that pans/zooms with the world
**Tools** (toolbar with clear active-tool indication)
- **Select (V):** click to select, drag to move, drag a corner handle to resize; Shift-click adds to selection; drag on empty space rubber-band selects multiple
- **Freehand pen (P):** pressure-smooth-ish stroke drawing (capture points, render as a smoothed polyline/path)
- **Rectangle (R), Ellipse (O), Line/Arrow (L):** drag to create, live preview while dragging
- **Text (T):** click to place a text box, type inline (contenteditable or textarea overlay), Enter/click-away commits
- **Eraser (E):** drag over objects to delete them
**Selection & Manipulation**
- Selected objects show a bounding box with 4 corner resize handles (resize preserves aspect with Shift)
- Arrow keys nudge the selection by 1px (10px with Shift)
- Delete/Backspace deletes the selection
- Hit-testing: freehand strokes use point-near-path testing; shapes use proper inside/on-border tests — clicking a filled ellipse's corner region outside the shape must NOT select it
**Undo / Redo**
- Full history: every create, move, resize, restyle, text edit, and erase is one undoable step (a multi-object drag is a single step, not one per object)
- Ctrl+Z / Ctrl+Shift+Z (plus toolbar buttons); history is lost on reload, but the *board itself* persists via `localStorage`
**Styling**
- Small properties bar: stroke color, fill color, stroke width — applies to the current selection or sets the default for new objects
- A tasteful hand-drawn-adjacent default palette
## Visual Style
- Minimal, professional tool UI: floating toolbar (top or left), properties bar, zoom indicator — everything else is canvas
- Light canvas on a slightly darker app chrome; crisp at any zoom
- No backend, no collaboration networking — this is a local prototype, but the UI should look like a product
## Technical Requirements
- Vanilla JS with HTML5 Canvas, with clear section comments
- A clean object model: every shape is a serializable object `{ id, type, ... }` in a single scene array — rendering, hit-testing, and persistence all derive from it
- Tunable constants block at the top (colors, zoom limits, hit tolerance, handle size)
- Target: smooth drawing and panning at 60 FPS with a few hundred objects
## Quality Bar (must hit all)
- Zoom toward cursor: the world point under the cursor stays under the cursor while zooming
- Selection and resize handles are accurate at 25% and at 400% zoom
- Undo after a multi-object rubber-band move restores all objects in one step
- Board content survives a page reload
- Drawing while zoomed in places the stroke exactly under the cursor
## Self-Check Before Finishing
Verify with the browser tools:
- Draw a rectangle, zoom in to 300%+, click it — it selects; click clearly outside it — it deselects
- Create 3 shapes, rubber-band select all, drag, Ctrl+Z — all three return in one undo
- Draw a freehand stroke while panned/zoomed and confirm it lands under the cursor
- Reload the page and confirm the board is restored
Inspect original source 40,913 bytes · SHA-256 44fa5cd4ce52
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Whiteboard Prototype</title>
<style>
:root{
--chrome:#e9ebef; --panel:#ffffff; --ink:#1f2328; --muted:#6b7280;
--accent:#4f46e5; --accent-soft:#e7e9ff; --border:#d5d9e0;
--shadow:0 6px 20px rgba(20,25,40,.14);
}
*{box-sizing:border-box}
html,body{margin:0;height:100%;overflow:hidden;background:var(--chrome);
font:13px/1.3 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;color:var(--ink);
user-select:none;-webkit-user-select:none}
#board{position:fixed;left:0;top:0;width:100vw;height:100vh;display:block;touch-action:none}
.panel{position:fixed;background:var(--panel);border:1px solid var(--border);border-radius:10px;box-shadow:var(--shadow)}
#tools{left:12px;top:50%;transform:translateY(-50%);display:flex;flex-direction:column;gap:3px;padding:6px}
#props{top:12px;left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:8px;padding:5px 10px;white-space:nowrap}
#hist{left:12px;bottom:12px;display:flex;gap:3px;padding:4px}
#zoom{right:12px;bottom:12px;display:flex;align-items:center;gap:4px;padding:4px}
.tb{width:34px;height:34px;border:0;border-radius:7px;background:transparent;cursor:pointer;
display:flex;align-items:center;justify-content:center;color:var(--ink);position:relative;padding:0}
.tb:hover{background:#f1f3f7}
.tb.active{background:var(--accent);color:#fff}
.tb:disabled{opacity:.3;cursor:default;background:transparent}
.tb svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
.tb .k{position:absolute;right:3px;bottom:1px;font-size:8px;opacity:.55}
.tb.wide{width:auto;padding:0 8px;font-size:12px}
.sepv{width:1px;height:22px;background:var(--border);margin:0 4px}
.sep{height:1px;background:var(--border);margin:3px 4px}
.grp{display:flex;align-items:center;gap:5px}
.lbl{font-size:11px;color:var(--muted);margin-right:4px}
.sw{width:18px;height:18px;border-radius:50%;border:1px solid rgba(0,0,0,.18);cursor:pointer;padding:0;flex:none}
.sw.none{background:linear-gradient(135deg,#fff 42%,#e03131 45%,#e03131 55%,#fff 58%)}
.sw.sel{box-shadow:0 0 0 2px #fff,0 0 0 3.5px var(--accent)}
.wd{width:28px;height:26px;border:0;border-radius:6px;background:transparent;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0}
.wd.sel{background:var(--accent-soft)}
.wd i{display:block;background:var(--ink);border-radius:4px}
#zVal{min-width:58px;font-variant-numeric:tabular-nums;font-size:12px;font-weight:600}
#zReset{width:auto;padding:0 10px;font-size:12px}
#textEdit{position:fixed;display:none;margin:0;padding:0;border:1px dashed var(--accent);outline:none;
background:rgba(255,255,255,.6);resize:none;overflow:hidden;white-space:pre;font-family:"Segoe UI",system-ui,sans-serif;
color:#1e1e1e;z-index:20;min-width:8px;min-height:1em}
</style>
</head>
<body>
<canvas id="board"></canvas>
<nav id="tools" class="panel">
<button class="tb" data-tool="select" title="Select (V)"><svg viewBox="0 0 24 24"><path d="M5 3l13 8-6 1.5L9.5 19z"/></svg><span class="k">V</span></button>
<button class="tb" data-tool="pen" title="Pen (P)"><svg viewBox="0 0 24 24"><path d="M4 20l1-4L16 5l3 3L8 19z"/></svg><span class="k">P</span></button>
<button class="tb" data-tool="rect" title="Rectangle (R)"><svg viewBox="0 0 24 24"><rect x="4" y="5" width="16" height="14" rx="1.5"/></svg><span class="k">R</span></button>
<button class="tb" data-tool="ellipse" title="Ellipse (O)"><svg viewBox="0 0 24 24"><ellipse cx="12" cy="12" rx="8" ry="6.5"/></svg><span class="k">O</span></button>
<button class="tb" data-tool="line" title="Line / Arrow (L)"><svg viewBox="0 0 24 24"><path d="M5 19L19 5"/><path d="M10 5h9v9"/></svg><span class="k">L</span></button>
<button class="tb" data-tool="text" title="Text (T)"><svg viewBox="0 0 24 24"><path d="M6 6h12M12 6v13"/></svg><span class="k">T</span></button>
<div class="sep"></div>
<button class="tb" data-tool="eraser" title="Eraser (E)"><svg viewBox="0 0 24 24"><path d="M4 16l8-9 7 6-5 6H8z"/><path d="M9 20h11"/></svg><span class="k">E</span></button>
</nav>
<div id="props" class="panel">
<div class="grp"><span class="lbl">Stroke</span><span id="strokeSw" class="grp"></span></div>
<div class="sepv"></div>
<div class="grp"><span class="lbl">Fill</span><span id="fillSw" class="grp"></span></div>
<div class="sepv"></div>
<div class="grp"><span class="lbl">Width</span><span id="widthBtns" class="grp"></span></div>
<div class="sepv"></div>
<button id="headBtn" class="tb" title="Toggle arrowhead on selected lines"><svg viewBox="0 0 24 24"><path d="M4 12h13"/><path d="M13 6l6 6-6 6"/></svg></button>
</div>
<div id="hist" class="panel">
<button id="undoBtn" class="tb" title="Undo (Ctrl+Z)"><svg viewBox="0 0 24 24"><path d="M9 14L4 9l5-5"/><path d="M4 9h10a6 6 0 010 12h-3"/></svg></button>
<button id="redoBtn" class="tb" title="Redo (Ctrl+Shift+Z)"><svg viewBox="0 0 24 24"><path d="M15 14l5-5-5-5"/><path d="M20 9H10a6 6 0 000 12h3"/></svg></button>
</div>
<div id="zoom" class="panel">
<button id="zOut" class="tb" title="Zoom out">−</button>
<button id="zVal" class="tb" title="Zoom to 100%">100%</button>
<button id="zIn" class="tb" title="Zoom in">+</button>
<div class="sepv"></div>
<button id="zReset" class="tb wide" title="Reset view">Reset view</button>
</div>
<textarea id="textEdit" spellcheck="false"></textarea>
<script>
(() => {
'use strict';
/* =========================================================================
1. TUNABLE CONSTANTS
========================================================================= */
const CONFIG = {
zoomMin: 0.25,
zoomMax: 4,
zoomStep: 1.25,
wheelSensitivity: 0.0015,
hitTolPx: 6, // pointer tolerance for hit-testing, in SCREEN pixels
eraserPx: 10, // eraser radius, screen pixels
handlePx: 9, // drawn handle size, screen pixels
handleHitPx: 12, // handle grab radius, screen pixels
dragThresholdPx: 3, // movement before a click becomes a drag
minSize: 2, // minimum object size in world units
nudge: 1, // arrow-key nudge (world px)
nudgeShift: 10,
nudgeCoalesceMs: 500,
penMinDistPx: 1.5, // pen sampling distance in screen px
historyLimit: 300,
fontFamily: '"Segoe UI", system-ui, -apple-system, sans-serif',
fontSize: 20,
lineHeight: 1.25,
gridSize: 32,
gridDot: '#c9ced8',
canvasBg: '#fbfbfa',
selectColor: '#4f46e5',
marqueeFill: 'rgba(79,70,229,0.08)',
storageKey: 'wb-board-v1',
viewKey: 'wb-view-v1',
strokeColors: ['#1e1e1e', '#e03131', '#2f9e44', '#1971c2', '#f08c00', '#7048e8'],
fillColors: ['none', '#ffc9c9', '#b2f2bb', '#a5d8ff', '#ffec99', '#eebefa'],
strokeWidths: [1.5, 3, 5],
strokeWidthLabels: ['S', 'M', 'L'],
defaults: { stroke: '#1e1e1e', fill: 'none', sw: 3, head: true },
doubleClickMs: 350,
};
/* =========================================================================
2. STATE
========================================================================= */
const canvas = document.getElementById('board');
const ctx = canvas.getContext('2d');
const measureCtx = document.createElement('canvas').getContext('2d');
const textEdit = document.getElementById('textEdit');
let scene = []; // ALL objects, in z-order (last = top)
let selection = new Set(); // ids
let tool = 'select';
let defaults = { ...CONFIG.defaults };
let view = { x: 0, y: 0, z: 1 }; // screen = world * z + (x, y)
let past = []; // JSON snapshots (undo)
let future = []; // JSON snapshots (redo)
let gesture = null; // active pointer gesture
let editing = null; // { id, before, isNew }
let spaceDown = false;
let cw = 0, ch = 0, dpr = 1;
let dirty = true;
let nudgeBefore = null, nudgeTimer = null;
let lastClick = { t: 0, id: null };
let hoverCursor = 'default';
let rafId = 0;
/* =========================================================================
3. UTILITIES: IDs, cloning, persistence, history
========================================================================= */
const uid = () => Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4);
const clone = v => JSON.parse(JSON.stringify(v));
const snapshot = () => JSON.stringify(scene);
const byId = id => scene.find(o => o.id === id);
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
function save() {
try { localStorage.setItem(CONFIG.storageKey, JSON.stringify({ v: 1, scene })); }
catch (err) { console.warn('Board save failed', err); }
}
function saveView() {
try { localStorage.setItem(CONFIG.viewKey, JSON.stringify(view)); } catch (e) {}
}
function load() {
try {
const raw = JSON.parse(localStorage.getItem(CONFIG.storageKey) || 'null');
if (raw && Array.isArray(raw.scene)) scene = raw.scene.filter(o => o && o.id && o.type);
} catch (e) { scene = []; }
try {
const v = JSON.parse(localStorage.getItem(CONFIG.viewKey) || 'null');
if (v && isFinite(v.x) && isFinite(v.y) && isFinite(v.z)) view = { x: v.x, y: v.y, z: clamp(v.z, CONFIG.zoomMin, CONFIG.zoomMax) };
} catch (e) {}
}
/* Commit a finished user action. `before` is the snapshot taken when the action began.
If the scene actually changed, the previous state becomes one undo step. */
function commit(before) {
if (before == null) return;
const now = snapshot();
if (now === before) { updateUI(); return; }
past.push(before);
if (past.length > CONFIG.historyLimit) past.shift();
future = [];
save();
updateUI();
requestRender();
}
function undo() {
flushNudge();
if (gesture || editing) return;
if (!past.length) return;
future.push(snapshot());
scene = JSON.parse(past.pop());
pruneSelection();
save(); updateUI(); requestRender();
}
function redo() {
flushNudge();
if (gesture || editing) return;
if (!future.length) return;
past.push(snapshot());
scene = JSON.parse(future.pop());
pruneSelection();
save(); updateUI(); requestRender();
}
function pruneSelection() {
for (const id of [...selection]) if (!byId(id)) selection.delete(id);
}
/* =========================================================================
4. GEOMETRY: world<->screen, bounds, hit testing
========================================================================= */
function screenPt(e) {
const r = canvas.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function toWorld(s) { return { x: (s.x - view.x) / view.z, y: (s.y - view.y) / view.z }; }
function toScreen(p) { return { x: p.x * view.z + view.x, y: p.y * view.z + view.y }; }
/* Axis-aligned bounds of an object in world coordinates. */
function bbox(o) {
if (o.pts) {
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const p of o.pts) {
if (p.x < x0) x0 = p.x; if (p.y < y0) y0 = p.y;
if (p.x > x1) x1 = p.x; if (p.y > y1) y1 = p.y;
}
if (x0 === Infinity) return { x: 0, y: 0, w: 0, h: 0 };
return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
}
return { x: o.x, y: o.y, w: o.w, h: o.h };
}
function unionBounds(objs) {
if (!objs.length) return null;
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const o of objs) {
const b = bbox(o);
x0 = Math.min(x0, b.x); y0 = Math.min(y0, b.y);
x1 = Math.max(x1, b.x + b.w); y1 = Math.max(y1, b.y + b.h);
}
return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
}
function selectedObjects() { return scene.filter(o => selection.has(o.id)); }
function selectionBounds() { return unionBounds(selectedObjects()); }
function distToSeg(p, a, b) {
const dx = b.x - a.x, dy = b.y - a.y;
const len2 = dx * dx + dy * dy;
let t = len2 === 0 ? 0 : ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2;
t = clamp(t, 0, 1);
const ex = a.x + t * dx - p.x, ey = a.y + t * dy - p.y;
return Math.hypot(ex, ey);
}
function filled(o) { return o.fill && o.fill !== 'none'; }
/* Hit test a single object. p is WORLD point, tol is WORLD tolerance. */
function hitObject(o, p, tol) {
const half = (o.sw || 1) / 2;
const t = tol + half;
switch (o.type) {
case 'rect': {
const b = bbox(o);
const inside = p.x >= b.x && p.x <= b.x + b.w && p.y >= b.y && p.y <= b.y + b.h;
if (inside && filled(o)) return true;
const c = [
{ x: b.x, y: b.y }, { x: b.x + b.w, y: b.y },
{ x: b.x + b.w, y: b.y + b.h }, { x: b.x, y: b.y + b.h },
];
for (let i = 0; i < 4; i++) if (distToSeg(p, c[i], c[(i + 1) % 4]) <= t) return true;
return false;
}
case 'ellipse': {
const b = bbox(o);
const rx = Math.max(b.w / 2, 0.5), ry = Math.max(b.h / 2, 0.5);
const cx = b.x + b.w / 2, cy = b.y + b.h / 2;
const nx = (p.x - cx) / rx, ny = (p.y - cy) / ry;
const r = Math.hypot(nx, ny); // normalized radius: 1 = on the outline
if (r <= 1 && filled(o)) return true;
// Distance to the outline, approximated along the normalized radius.
return Math.abs(r - 1) * Math.min(rx, ry) <= t;
}
case 'line': {
const [a, b] = o.pts;
return distToSeg(p, a, b) <= t;
}
case 'pen': {
const pts = o.pts;
if (pts.length === 1) return Math.hypot(p.x - pts[0].x, p.y - pts[0].y) <= t;
for (let i = 0; i < pts.length - 1; i++) if (distToSeg(p, pts[i], pts[i + 1]) <= t) return true;
return false;
}
case 'text': {
const b = bbox(o);
return p.x >= b.x - tol && p.x <= b.x + b.w + tol && p.y >= b.y - tol && p.y <= b.y + b.h + tol;
}
}
return false;
}
/* Topmost object under a world point, or null. */
function hitTest(p) {
const tol = CONFIG.hitTolPx / view.z;
for (let i = scene.length - 1; i >= 0; i--) {
if (hitObject(scene[i], p, tol)) return scene[i];
}
return null;
}
/* Corner handles of the selection, in SCREEN space. Index: 0 tl, 1 tr, 2 br, 3 bl. */
function handleScreenPoints(b) {
const tl = toScreen({ x: b.x, y: b.y });
const br = toScreen({ x: b.x + b.w, y: b.y + b.h });
return [
{ x: tl.x, y: tl.y }, { x: br.x, y: tl.y },
{ x: br.x, y: br.y }, { x: tl.x, y: br.y },
];
}
function handleAt(s) {
const b = selectionBounds();
if (!b) return -1;
const hs = handleScreenPoints(b);
for (let i = 0; i < 4; i++) {
if (Math.abs(hs[i].x - s.x) <= CONFIG.handleHitPx && Math.abs(hs[i].y - s.y) <= CONFIG.handleHitPx) return i;
}
return -1;
}
/* Text measurement (world units). Width/height are stored on the object. */
function measureText(text, fs) {
measureCtx.font = `${fs}px ${CONFIG.fontFamily}`;
const lines = text.split('\n');
let w = 0;
for (const l of lines) w = Math.max(w, measureCtx.measureText(l).width);
return { w: Math.max(w, fs * 0.6), h: Math.max(lines.length * fs * CONFIG.lineHeight, fs * CONFIG.lineHeight) };
}
/* =========================================================================
5. RENDERING
========================================================================= */
function requestRender() {
dirty = true;
if (!rafId) rafId = requestAnimationFrame(() => { rafId = 0; if (dirty) render(); });
}
function resizeCanvas() {
dpr = window.devicePixelRatio || 1;
cw = window.innerWidth; ch = window.innerHeight;
canvas.width = Math.round(cw * dpr);
canvas.height = Math.round(ch * dpr);
requestRender();
}
function drawGrid() {
const g0 = CONFIG.gridSize;
let g = g0 * view.z;
while (g < 10) g *= 2;
const ox = ((view.x % g) + g) % g, oy = ((view.y % g) + g) % g;
ctx.fillStyle = CONFIG.gridDot;
const size = Math.max(1, dpr * 1.2);
for (let x = ox; x < cw; x += g) {
for (let y = oy; y < ch; y += g) {
ctx.fillRect(Math.round(x * dpr) - size / 2, Math.round(y * dpr) - size / 2, size, size);
}
}
}
/* Draw one object in WORLD coordinates (ctx already transformed). */
function drawObject(c, o) {
c.save();
c.lineWidth = o.sw || 1;
c.strokeStyle = o.stroke;
c.fillStyle = o.fill && o.fill !== 'none' ? o.fill : 'transparent';
c.lineJoin = 'round';
c.lineCap = 'round';
switch (o.type) {
case 'pen': {
const p = o.pts;
c.beginPath();
if (p.length === 1) {
c.arc(p[0].x, p[0].y, c.lineWidth / 2, 0, Math.PI * 2);
c.fillStyle = o.stroke; c.fill();
} else {
c.moveTo(p[0].x, p[0].y);
for (let i = 1; i < p.length - 1; i++) {
const mx = (p[i].x + p[i + 1].x) / 2, my = (p[i].y + p[i + 1].y) / 2;
c.quadraticCurveTo(p[i].x, p[i].y, mx, my); // smoothed through midpoints
}
const l = p[p.length - 1];
c.lineTo(l.x, l.y);
c.stroke();
}
break;
}
case 'rect': {
const b = bbox(o);
c.beginPath();
c.rect(b.x, b.y, b.w, b.h);
if (filled(o)) c.fill();
c.stroke();
break;
}
case 'ellipse': {
const b = bbox(o);
c.beginPath();
c.ellipse(b.x + b.w / 2, b.y + b.h / 2, Math.max(b.w / 2, 0.01), Math.max(b.h / 2, 0.01), 0, 0, Math.PI * 2);
if (filled(o)) c.fill();
c.stroke();
break;
}
case 'line': {
const [a, b] = o.pts;
c.beginPath();
c.moveTo(a.x, a.y);
c.lineTo(b.x, b.y);
c.stroke();
if (o.head !== false) {
const ang = Math.atan2(b.y - a.y, b.x - a.x);
const len = 10 + c.lineWidth * 2.5, spread = Math.PI / 7;
c.beginPath();
c.moveTo(b.x, b.y);
c.lineTo(b.x - len * Math.cos(ang - spread), b.y - len * Math.sin(ang - spread));
c.lineTo(b.x - len * Math.cos(ang + spread), b.y - len * Math.sin(ang + spread));
c.closePath();
c.fillStyle = o.stroke;
c.fill();
}
break;
}
case 'text': {
if (editing && editing.id === o.id) break; // the DOM editor shows it instead
c.fillStyle = o.stroke;
c.font = `${o.fs}px ${CONFIG.fontFamily}`;
c.textBaseline = 'top';
const lines = o.text.split('\n');
lines.forEach((l, i) => c.fillText(l, o.x, o.y + i * o.fs * CONFIG.lineHeight));
break;
}
}
c.restore();
}
function render() {
dirty = false;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = CONFIG.canvasBg;
ctx.fillRect(0, 0, canvas.width, canvas.height);
drawGrid();
// World layer
ctx.setTransform(dpr * view.z, 0, 0, dpr * view.z, dpr * view.x, dpr * view.y);
for (const o of scene) drawObject(ctx, o);
// Screen-space overlays (selection chrome, marquee) — constant pixel size
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const sel = selectionBounds();
if (sel && selection.size) {
const tl = toScreen({ x: sel.x, y: sel.y });
const br = toScreen({ x: sel.x + sel.w, y: sel.y + sel.h });
ctx.strokeStyle = CONFIG.selectColor;
ctx.lineWidth = 1;
ctx.setLineDash([5, 4]);
ctx.strokeRect(Math.round(tl.x) + .5, Math.round(tl.y) + .5, Math.round(br.x - tl.x), Math.round(br.y - tl.y));
ctx.setLineDash([]);
const hs = handleScreenPoints(sel);
const hp = CONFIG.handlePx;
for (const h of hs) {
ctx.fillStyle = '#fff';
ctx.strokeStyle = CONFIG.selectColor;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.rect(h.x - hp / 2, h.y - hp / 2, hp, hp);
ctx.fill(); ctx.stroke();
}
}
if (gesture && gesture.type === 'marquee') {
const a = toScreen(gesture.start), b = toScreen(gesture.cur);
const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y);
const w = Math.abs(a.x - b.x), h = Math.abs(a.y - b.y);
ctx.fillStyle = CONFIG.marqueeFill;
ctx.fillRect(x, y, w, h);
ctx.strokeStyle = CONFIG.selectColor;
ctx.lineWidth = 1;
ctx.strokeRect(Math.round(x) + .5, Math.round(y) + .5, Math.round(w), Math.round(h));
}
if (editing) positionEditor();
}
/* =========================================================================
6. UI SYNC (toolbar, property bar, zoom label)
========================================================================= */
function buildSwatches() {
const sEl = document.getElementById('strokeSw');
const fEl = document.getElementById('fillSw');
const wEl = document.getElementById('widthBtns');
CONFIG.strokeColors.forEach(c => {
const b = document.createElement('button');
b.className = 'sw'; b.style.background = c; b.dataset.stroke = c; b.title = 'Stroke ' + c;
b.onclick = () => applyStyle('stroke', c);
sEl.appendChild(b);
});
CONFIG.fillColors.forEach(c => {
const b = document.createElement('button');
b.className = 'sw' + (c === 'none' ? ' none' : ''); b.dataset.fill = c;
if (c !== 'none') b.style.background = c;
b.title = c === 'none' ? 'No fill' : 'Fill ' + c;
b.onclick = () => applyStyle('fill', c);
fEl.appendChild(b);
});
CONFIG.strokeWidths.forEach((w, i) => {
const b = document.createElement('button');
b.className = 'wd'; b.dataset.sw = w; b.title = 'Stroke width ' + CONFIG.strokeWidthLabels[i];
const bar = document.createElement('i');
bar.style.width = '14px'; bar.style.height = (w + 1) + 'px';
b.appendChild(bar);
b.onclick = () => applyStyle('sw', w);
wEl.appendChild(b);
});
}
function setTool(t) {
if (editing) finishEditing();
tool = t;
document.querySelectorAll('#tools [data-tool]').forEach(b => b.classList.toggle('active', b.dataset.tool === t));
canvas.style.cursor = t === 'select' ? 'default' : (t === 'text' ? 'text' : 'crosshair');
updateUI();
}
/* Reflect the selection (or defaults) in the property bar. */
function updateUI() {
const objs = selectedObjects();
const src = objs.length ? objs[objs.length - 1] : defaults;
document.querySelectorAll('[data-stroke]').forEach(b => b.classList.toggle('sel', b.dataset.stroke === src.stroke));
document.querySelectorAll('[data-fill]').forEach(b => b.classList.toggle('sel', b.dataset.fill === (src.fill || 'none')));
document.querySelectorAll('[data-sw]').forEach(b => b.classList.toggle('sel', +b.dataset.sw === src.sw));
const hasLine = objs.some(o => o.type === 'line');
const headOn = objs.length ? (objs.find(o => o.type === 'line') || {}).head !== false : defaults.head;
document.getElementById('headBtn').classList.toggle('active', !!headOn && hasLine);
document.getElementById('headBtn').disabled = objs.length > 0 && !hasLine;
document.getElementById('undoBtn').disabled = !past.length;
document.getElementById('redoBtn').disabled = !future.length;
document.getElementById('zVal').textContent = Math.round(view.z * 100) + '%';
}
/* Apply a style property to the selection (one undo step) and to defaults. */
function applyStyle(prop, value) {
flushNudge();
if (prop === 'sw' || prop === 'stroke' || prop === 'fill') defaults[prop] = value;
const objs = selectedObjects();
if (objs.length) {
const before = snapshot();
for (const o of objs) {
if (prop === 'fill' && o.type === 'line') continue;
if (prop === 'stroke' && o.type === 'pen' && false) continue;
o[prop] = value;
}
commit(before);
} else {
updateUI();
}
}
function toggleHead() {
flushNudge();
const lines = selectedObjects().filter(o => o.type === 'line');
if (!lines.length) { defaults.head = !defaults.head; updateUI(); return; }
const before = snapshot();
const on = lines.some(o => o.head === false); // if any is off, turn all on
lines.forEach(o => o.head = on);
commit(before);
}
/* Zoom around a screen point, keeping the world point under the cursor fixed. */
function zoomAt(sx, sy, factor) {
const nz = clamp(view.z * factor, CONFIG.zoomMin, CONFIG.zoomMax);
if (nz === view.z) return;
const w = toWorld({ x: sx, y: sy });
view.z = nz;
view.x = sx - w.x * nz;
view.y = sy - w.y * nz;
saveView(); updateUI(); requestRender();
}
function resetView() {
const b = unionBounds(scene);
const cx = b ? b.x + b.w / 2 : 0, cy = b ? b.y + b.h / 2 : 0;
view.z = 1;
view.x = cw / 2 - cx;
view.y = ch / 2 - cy;
saveView(); updateUI(); requestRender();
}
/* =========================================================================
7. TEXT EDITING (DOM overlay)
========================================================================= */
function positionEditor() {
if (!editing) return;
const o = byId(editing.id);
if (!o) return;
const s = toScreen({ x: o.x, y: o.y });
const fs = o.fs * view.z;
textEdit.style.left = s.x + 'px';
textEdit.style.top = s.y + 'px';
textEdit.style.fontSize = fs + 'px';
textEdit.style.lineHeight = (fs * CONFIG.lineHeight) + 'px';
textEdit.style.color = o.stroke;
textEdit.style.fontFamily = CONFIG.fontFamily;
const m = measureText(textEdit.value || ' ', fs);
textEdit.style.width = Math.max(m.w + fs * 0.4, 12) + 'px';
textEdit.style.height = Math.max(m.h, fs) + 'px';
textEdit.rows = Math.max(1, textEdit.value.split('\n').length);
}
function openEditor(obj, before, isNew) {
editing = { id: obj.id, before, isNew };
textEdit.value = obj.text;
textEdit.style.display = 'block';
positionEditor();
textEdit.focus();
textEdit.setSelectionRange(textEdit.value.length, textEdit.value.length);
requestRender();
}
function finishEditing() {
if (!editing) return;
const ed = editing;
editing = null;
const o = byId(ed.id);
textEdit.style.display = 'none';
if (o) {
o.text = textEdit.value;
if (!o.text.trim()) {
scene = scene.filter(x => x.id !== o.id);
selection.delete(o.id);
} else {
const m = measureText(o.text, o.fs);
o.w = m.w; o.h = m.h;
selection = new Set([o.id]);
}
}
textEdit.value = '';
commit(ed.before);
requestRender();
}
textEdit.addEventListener('input', () => {
if (!editing) return;
const o = byId(editing.id);
if (o) { o.text = textEdit.value; const m = measureText(o.text || ' ', o.fs); o.w = m.w; o.h = m.h; }
positionEditor();
});
textEdit.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); finishEditing(); }
else if (e.key === 'Escape') { e.preventDefault(); finishEditing(); }
e.stopPropagation();
});
textEdit.addEventListener('blur', () => finishEditing());
/* =========================================================================
8. GEOMETRY OPERATIONS ON OBJECTS (move / resize / nudge)
========================================================================= */
/* Map each original object (clone) from base bounds `ob` into new bounds `nb`.
Writes results into the live scene objects, matched by id. */
function transformObjects(origs, ob, nb) {
const fx = p => (ob.w > 0 ? (p.x - ob.x) / ob.w : 0.5);
const fy = p => (ob.h > 0 ? (p.y - ob.y) / ob.h : 0.5);
const mapPt = p => ({ x: nb.x + fx(p) * nb.w, y: nb.y + fy(p) * nb.h });
const sx = ob.w > 0 ? nb.w / ob.w : 1;
const sy = ob.h > 0 ? nb.h / ob.h : 1;
for (const o of origs) {
const cur = byId(o.id);
if (!cur) continue;
if (o.pts) {
cur.pts = o.pts.map(mapPt);
} else if (o.type === 'text') {
const p = mapPt({ x: o.x, y: o.y });
cur.x = p.x; cur.y = p.y;
if (Math.abs(sx - 1) > 1e-9 || Math.abs(sy - 1) > 1e-9) {
const s = (sx + sy) / 2;
cur.fs = clamp(Math.round(o.fs * s * 10) / 10, 4, 400);
}
const m = measureText(cur.text || ' ', cur.fs);
cur.w = m.w; cur.h = m.h;
} else {
const p = mapPt({ x: o.x, y: o.y });
cur.x = p.x; cur.y = p.y;
cur.w = o.w * sx; cur.h = o.h * sy;
}
}
}
/* Translate selected objects by (dx, dy) in world units (used by arrow nudge). */
function translateSelected(dx, dy) {
for (const o of selectedObjects()) {
if (o.pts) o.pts = o.pts.map(p => ({ x: p.x + dx, y: p.y + dy }));
else { o.x += dx; o.y += dy; }
}
}
/* Nudge with coalescing: a burst of arrow presses becomes one undo step. */
function nudge(dx, dy) {
if (!selection.size) return;
if (nudgeBefore == null) nudgeBefore = snapshot();
translateSelected(dx, dy);
clearTimeout(nudgeTimer);
nudgeTimer = setTimeout(flushNudge, CONFIG.nudgeCoalesceMs);
save(); requestRender();
}
function flushNudge() {
clearTimeout(nudgeTimer);
nudgeTimer = null;
if (nudgeBefore != null) {
const b = nudgeBefore;
nudgeBefore = null;
commit(b);
}
}
function deleteSelection() {
if (!selection.size) return;
flushNudge();
const before = snapshot();
scene = scene.filter(o => !selection.has(o.id));
selection = new Set();
commit(before);
}
function createShape(type, p) {
const o = {
id: uid(), type,
stroke: defaults.stroke,
fill: defaults.fill,
sw: defaults.sw,
};
if (type === 'rect' || type === 'ellipse') Object.assign(o, { x: p.x, y: p.y, w: 0, h: 0 });
else if (type === 'line') Object.assign(o, { pts: [{ x: p.x, y: p.y }, { x: p.x, y: p.y }], head: defaults.head });
else if (type === 'pen') Object.assign(o, { pts: [{ x: p.x, y: p.y }] });
return o;
}
/* Round for compact storage. */
const r2 = v => Math.round(v * 100) / 100;
/* =========================================================================
9. POINTER INTERACTION (state machine via `gesture`)
========================================================================= */
function rectOf(a, b) {
return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), w: Math.abs(a.x - b.x), h: Math.abs(a.y - b.y) };
}
function intersects(a, b) {
return a.x <= b.x + b.w && a.x + a.w >= b.x && a.y <= b.y + b.h && a.y + a.h >= b.y;
}
function onPointerDown(e) {
if (e.button !== 0 && e.button !== 1) return;
if (editing) finishEditing();
flushNudge();
canvas.setPointerCapture(e.pointerId);
const s = screenPt(e);
const p = toWorld(s);
// Pan: middle button, or Space + drag
if (e.button === 1 || spaceDown) {
gesture = { type: 'pan', sx: s.x, sy: s.y, vx: view.x, vy: view.y };
canvas.style.cursor = 'grabbing';
e.preventDefault();
return;
}
if (tool === 'select') {
const h = handleAt(s);
if (h >= 0) {
const b = selectionBounds();
gesture = { type: 'resize', handle: h, before: snapshot(), ob: b, origs: selectedObjects().map(clone) };
return;
}
const hit = hitTest(p);
if (hit) {
if (e.shiftKey) {
if (selection.has(hit.id)) selection.delete(hit.id); else selection.add(hit.id);
updateUI(); requestRender();
gesture = null;
return;
}
const wasSelected = selection.has(hit.id);
if (!wasSelected) selection = new Set([hit.id]);
gesture = {
type: 'move', start: p, sStart: s, moved: false,
clickId: wasSelected ? hit.id : null, // collapse multi-selection on plain click
before: snapshot(),
ob: selectionBounds(), origs: selectedObjects().map(clone),
};
updateUI(); requestRender();
return;
}
const base = e.shiftKey ? new Set(selection) : new Set();
if (!e.shiftKey) selection = new Set();
gesture = { type: 'marquee', start: p, cur: p, base };
updateUI(); requestRender();
return;
}
if (tool === 'eraser') {
gesture = { type: 'erase', before: snapshot() };
eraseAt(p);
requestRender();
return;
}
if (tool === 'text') {
const hit = hitTest(p);
if (hit && hit.type === 'text') {
selection = new Set([hit.id]);
openEditor(hit, snapshot(), false);
return;
}
const before = snapshot();
const o = { id: uid(), type: 'text', x: p.x, y: p.y, text: '', fs: CONFIG.fontSize, stroke: defaults.stroke, w: 0, h: 0 };
scene.push(o);
selection = new Set([o.id]);
openEditor(o, before, true);
return;
}
// Shapes, lines and pen: create immediately, grow while dragging
const before = snapshot();
const o = createShape(tool, p);
scene.push(o);
gesture = { type: 'draw', id: o.id, start: p, before, sStart: s };
requestRender();
}
function eraseAt(p) {
const tol = CONFIG.eraserPx / view.z;
const before = scene.length;
scene = scene.filter(o => !hitObject(o, p, tol));
if (scene.length !== before) { pruneSelection(); updateUI(); requestRender(); }
}
function onPointerMove(e) {
const s = screenPt(e);
const p = toWorld(s);
if (!gesture) {
if (tool === 'select' && !spaceDown) {
const h = handleAt(s);
if (h >= 0) canvas.style.cursor = (h === 0 || h === 2) ? 'nwse-resize' : 'nesw-resize';
else canvas.style.cursor = hitTest(p) ? 'move' : 'default';
}
return;
}
switch (gesture.type) {
case 'pan': {
view.x = gesture.vx + (s.x - gesture.sx);
view.y = gesture.vy + (s.y - gesture.sy);
requestRender();
break;
}
case 'marquee': {
gesture.cur = p;
const r = rectOf(gesture.start, p);
const next = new Set(gesture.base);
for (const o of scene) if (intersects(bbox(o), r)) next.add(o.id);
selection = next;
updateUI(); requestRender();
break;
}
case 'move': {
if (!gesture.moved) {
const d = Math.hypot(s.x - gesture.sStart.x, s.y - gesture.sStart.y);
if (d < CONFIG.dragThresholdPx) break;
gesture.moved = true;
}
const dx = p.x - gesture.start.x, dy = p.y - gesture.start.y;
const ob = gesture.ob;
transformObjects(gesture.origs, ob, { x: ob.x + dx, y: ob.y + dy, w: ob.w, h: ob.h });
canvas.style.cursor = 'move';
requestRender();
break;
}
case 'resize': {
const ob = gesture.ob;
const corners = [
{ x: ob.x, y: ob.y }, { x: ob.x + ob.w, y: ob.y },
{ x: ob.x + ob.w, y: ob.y + ob.h }, { x: ob.x, y: ob.y + ob.h },
];
const anchor = corners[(gesture.handle + 2) % 4];
const dx = p.x - anchor.x, dy = p.y - anchor.y;
let w = Math.abs(dx), h = Math.abs(dy);
if (e.shiftKey && ob.w > 0 && ob.h > 0) {
const k = ob.w / ob.h; // keep original aspect ratio
if (w > h * k) h = w / k; else w = h * k;
}
w = Math.max(w, CONFIG.minSize);
h = Math.max(h, CONFIG.minSize);
const nb = {
x: dx < 0 ? anchor.x - w : anchor.x,
y: dy < 0 ? anchor.y - h : anchor.y,
w, h,
};
transformObjects(gesture.origs, ob, nb);
requestRender();
break;
}
case 'erase': {
eraseAt(p);
break;
}
case 'draw': {
const o = byId(gesture.id);
if (!o) break;
if (o.type === 'pen') {
const last = o.pts[o.pts.length - 1];
if (Math.hypot(p.x - last.x, p.y - last.y) * view.z >= CONFIG.penMinDistPx) {
o.pts.push({ x: r2(p.x), y: r2(p.y) });
}
} else if (o.type === 'line') {
let q = { x: p.x, y: p.y };
if (e.shiftKey) { // snap to 45° increments
const a = gesture.start;
const ang = Math.round(Math.atan2(q.y - a.y, q.x - a.x) / (Math.PI / 4)) * (Math.PI / 4);
const len = Math.hypot(q.x - a.x, q.y - a.y);
q = { x: a.x + Math.cos(ang) * len, y: a.y + Math.sin(ang) * len };
}
o.pts[1] = q;
} else {
const a = gesture.start;
let dx = p.x - a.x, dy = p.y - a.y;
if (e.shiftKey) { // square / circle
const m = Math.max(Math.abs(dx), Math.abs(dy));
dx = Math.sign(dx || 1) * m; dy = Math.sign(dy || 1) * m;
}
o.x = Math.min(a.x, a.x + dx); o.y = Math.min(a.y, a.y + dy);
o.w = Math.abs(dx); o.h = Math.abs(dy);
}
requestRender();
break;
}
}
}
function onPointerUp(e) {
if (!gesture) return;
const g = gesture;
gesture = null;
try { canvas.releasePointerCapture(e.pointerId); } catch (_) {}
switch (g.type) {
case 'pan':
saveView();
canvas.style.cursor = spaceDown ? 'grab' : (tool === 'select' ? 'default' : 'crosshair');
break;
case 'marquee':
updateUI(); requestRender();
break;
case 'move':
if (!g.moved && g.clickId) selection = new Set([g.clickId]);
if (g.moved) commit(g.before);
else { updateUI(); requestRender(); }
break;
case 'resize':
case 'erase':
commit(g.before);
break;
case 'draw': {
const o = byId(g.id);
let keep = true;
if (o) {
if (o.type === 'rect' || o.type === 'ellipse') keep = o.w >= 1 || o.h >= 1;
if (o.type === 'line') keep = Math.hypot(o.pts[1].x - o.pts[0].x, o.pts[1].y - o.pts[0].y) >= 1;
}
if (!keep) scene = scene.filter(x => x.id !== g.id);
else if (o && o.type !== 'pen') selection = new Set([o.id]);
commit(g.before);
break;
}
}
canvas.style.cursor = tool === 'select' ? 'default' : (tool === 'text' ? 'text' : 'crosshair');
requestRender();
}
function onDblClick(e) {
if (tool !== 'select') return;
const p = toWorld(screenPt(e));
const hit = hitTest(p);
if (hit && hit.type === 'text') {
selection = new Set([hit.id]);
openEditor(hit, snapshot(), false);
}
}
canvas.addEventListener('pointerdown', onPointerDown);
canvas.addEventListener('pointermove', onPointerMove);
canvas.addEventListener('pointerup', onPointerUp);
canvas.addEventListener('pointercancel', onPointerUp);
canvas.addEventListener('dblclick', onDblClick);
canvas.addEventListener('contextmenu', e => e.preventDefault());
canvas.addEventListener('wheel', e => {
e.preventDefault();
if (editing) return;
const unit = e.deltaMode === 1 ? 33 : e.deltaMode === 2 ? 800 : 1;
const factor = Math.exp(-e.deltaY * unit * CONFIG.wheelSensitivity);
const s = screenPt(e);
zoomAt(s.x, s.y, factor);
}, { passive: false });
/* =========================================================================
10. KEYBOARD
========================================================================= */
window.addEventListener('keydown', e => {
if (editing) return; // textarea handles its own keys
const mod = e.ctrlKey || e.metaKey;
const k = e.key.toLowerCase();
if (e.code === 'Space') {
if (!e.repeat) { spaceDown = true; if (!gesture) canvas.style.cursor = 'grab'; }
e.preventDefault();
return;
}
if (mod) {
if (k === 'z') { e.preventDefault(); e.shiftKey ? redo() : undo(); return; }
if (k === 'y') { e.preventDefault(); redo(); return; }
if (k === 'a') { e.preventDefault(); flushNudge(); selection = new Set(scene.map(o => o.id)); updateUI(); requestRender(); return; }
return;
}
if (k === 'delete' || k === 'backspace') { e.preventDefault(); deleteSelection(); return; }
if (k === 'escape') {
flushNudge();
selection = new Set(); updateUI(); requestRender(); return;
}
if (e.key.startsWith('Arrow')) {
e.preventDefault();
const d = e.shiftKey ? CONFIG.nudgeShift : CONFIG.nudge;
const map = { ArrowLeft: [-d, 0], ArrowRight: [d, 0], ArrowUp: [0, -d], ArrowDown: [0, d] };
nudge(...map[e.key]);
return;
}
const toolKeys = { v: 'select', p: 'pen', r: 'rect', o: 'ellipse', l: 'line', t: 'text', e: 'eraser' };
if (toolKeys[k] && !e.altKey) { setTool(toolKeys[k]); }
});
window.addEventListener('keyup', e => {
if (e.code === 'Space') {
spaceDown = false;
if (!gesture) canvas.style.cursor = tool === 'select' ? 'default' : (tool === 'text' ? 'text' : 'crosshair');
}
});
window.addEventListener('blur', () => { spaceDown = false; });
/* =========================================================================
11. CHROME WIRING & INIT
========================================================================= */
document.querySelectorAll('#tools [data-tool]').forEach(b => b.addEventListener('click', () => setTool(b.dataset.tool)));
document.getElementById('undoBtn').onclick = undo;
document.getElementById('redoBtn').onclick = redo;
document.getElementById('headBtn').onclick = toggleHead;
document.getElementById('zIn').onclick = () => zoomAt(cw / 2, ch / 2, CONFIG.zoomStep);
document.getElementById('zOut').onclick = () => zoomAt(cw / 2, ch / 2, 1 / CONFIG.zoomStep);
document.getElementById('zVal').onclick = () => zoomAt(cw / 2, ch / 2, 1 / view.z);
document.getElementById('zReset').onclick = resetView;
window.addEventListener('resize', resizeCanvas);
buildSwatches();
load();
const hadView = !!localStorage.getItem(CONFIG.viewKey);
resizeCanvas();
if (!hadView) resetView();
setTool('select');
updateUI();
requestRender();
// Expose a small debugging handle (read-only snapshot helpers)
window.__wb = { get scene() { return scene; }, get view() { return view; }, toScreen, toWorld, undo, redo };
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"anthropic/claude-haiku-5.5","provider":"openrouter","persona":"full","sessionId":"812a67c0-ad5a-4841-9b61-9a82fcdecd1b","tokensIn":243608,"tokensOut":123274,"tokensTotal":366882,"cost":0.068708655,"turns":9,"toolCalls":12,"failedToolCalls":1,"timestamp":"2026-10-08T03:01:18.748Z"} -->