Whiteboard
Qwen 3.8 27B GGUF · full

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 46,898 bytes · SHA-256 454808e60b2c
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Whiteboard — Collaborative Canvas Prototype</title>
<style>
/* ============================================================
VISUAL STYLE — minimal product chrome
============================================================ */
:root {
--chrome: #f0f1f4; /* slightly darker app chrome */
--chrome-border: #dcdee3;
--ink: #2b2f36;
--ink-soft: #6a7078;
--accent: #1971c2;
--accent-soft: #dcebfb;
--canvas: #ffffff;
--shadow: 0 2px 6px rgba(20, 24, 32, .10), 0 8px 24px rgba(20, 24, 32, .08);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: var(--canvas);
color: var(--ink);
-webkit-font-smoothing: antialiased;
user-select: none;
}
#board { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; }
/* ---------- floating panels ---------- */
.panel {
position: absolute;
background: var(--chrome);
border: 1px solid var(--chrome-border);
border-radius: 10px;
box-shadow: var(--shadow);
z-index: 10;
}
.toolbar {
top: 14px; left: 50%; transform: translateX(-50%);
display: flex; align-items: center; gap: 2px; padding: 5px;
}
.tool-btn {
width: 36px; height: 36px;
display: flex; align-items: center; justify-content: center;
border: none; border-radius: 8px;
background: transparent; color: var(--ink-soft);
cursor: pointer; position: relative;
transition: background .12s, color .12s;
}
.tool-btn:hover { background: #e4e6ea; color: var(--ink); }
.tool-btn.active { background: var(--accent-soft); color: var(--accent); }
.tool-btn:disabled { opacity: .35; cursor: default; }
.tool-btn:disabled:hover { background: transparent; color: var(--ink-soft); }
.tool-btn svg { width: 19px; height: 19px; display: block; }
.tool-btn .kbd {
position: absolute; right: 3px; bottom: 1px;
font-size: 8px; line-height: 1; color: var(--ink-soft); opacity: .7;
font-weight: 600;
}
.tool-btn.active .kbd { color: var(--accent); }
.sep { width: 1px; height: 24px; background: var(--chrome-border); margin: 0 4px; flex: none; }
/* ---------- properties bar ---------- */
.props {
top: 62px; left: 50%; transform: translateX(-50%);
display: flex; align-items: center; gap: 8px; padding: 6px 10px;
}
.props .group { display: flex; align-items: center; gap: 4px; }
.swatch {
width: 20px; height: 20px; border-radius: 6px;
border: 1px solid rgba(0,0,0,.14);
cursor: pointer; padding: 0;
transition: transform .1s;
}
.swatch:hover { transform: scale(1.12); }
.swatch.active { outline: 2px solid var(--accent); outline-offset: 1.5px; }
.swatch.none {
background:
linear-gradient(135deg, transparent 44%, #d63031 44%, #d63031 56%, transparent 56%),
#ffffff;
}
.width-btn {
width: 28px; height: 28px;
display: flex; align-items: center; justify-content: center;
border: none; border-radius: 7px; background: transparent; cursor: pointer;
}
.width-btn:hover { background: #e4e6ea; }
.width-btn.active { background: var(--accent-soft); }
.width-btn .dot { border-radius: 50%; background: var(--ink-soft); }
.width-btn.active .dot { background: var(--accent); }
/* ---------- zoom bar ---------- */
.zoombar {
right: 14px; bottom: 14px;
display: flex; align-items: center; gap: 2px; padding: 4px;
}
.zoombar button {
width: 30px; height: 30px; border: none; border-radius: 7px;
background: transparent; color: var(--ink-soft); cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.zoombar button:hover { background: #e4e6ea; color: var(--ink); }
.zoombar svg { width: 16px; height: 16px; }
#zoomLabel {
min-width: 52px; text-align: center;
font-size: 12px; font-weight: 600; color: var(--ink);
cursor: pointer; border-radius: 7px; padding: 6px 4px;
}
#zoomLabel:hover { background: #e4e6ea; }
/* ---------- hint ---------- */
.hint {
position: absolute; left: 14px; bottom: 14px; z-index: 10;
font-size: 11px; color: var(--ink-soft);
background: var(--chrome); border: 1px solid var(--chrome-border);
border-radius: 8px; padding: 6px 10px;
box-shadow: var(--shadow);
pointer-events: none;
}
.hint b { color: var(--ink); font-weight: 600; }
/* ---------- text editor overlay ---------- */
#textEditor {
position: absolute; z-index: 20;
border: 1.5px dashed var(--accent);
border-radius: 4px;
background: rgba(255,255,255,.6);
outline: none; resize: none; overflow: hidden;
padding: 2px 4px;
font-family: inherit;
color: var(--ink);
line-height: 1.35;
display: none;
}
/* ---------- cursors ---------- */
.cur-select { cursor: default; }
.cur-pen, .cur-rect, .cur-ellipse, .cur-line { cursor: crosshair; }
.cur-text { cursor: text; }
.cur-eraser { cursor: crosshair; }
.cur-pan { cursor: grab; }
.cur-panning { cursor: grabbing !important; }
</style>
</head>
<body>
<canvas id="board" class="cur-select"></canvas>
<!-- ================= TOOLBAR ================= -->
<div class="panel toolbar" id="toolbar" role="toolbar">
<button class="tool-btn active" data-tool="select" title="Select (V)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4l7.07 17 2.51-7.39L21 11.07z"/></svg>
<span class="kbd">V</span>
</button>
<button class="tool-btn" data-tool="pen" title="Freehand pen (P)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
<span class="kbd">P</span>
</button>
<button class="tool-btn" data-tool="rect" title="Rectangle (R)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><rect x="4" y="6" width="16" height="12" rx="1.5"/></svg>
<span class="kbd">R</span>
</button>
<button class="tool-btn" data-tool="ellipse" title="Ellipse (O)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="12" rx="8.5" ry="6.5"/></svg>
<span class="kbd">O</span>
</button>
<button class="tool-btn" data-tool="line" title="Line / Arrow (L)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 18L18 6"/><path d="M10 6h8v8"/></svg>
<span class="kbd">L</span>
</button>
<button class="tool-btn" data-tool="text" title="Text (T)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 6h14"/><path d="M12 6v13"/><path d="M9 19h6"/></svg>
<span class="kbd">T</span>
</button>
<button class="tool-btn" data-tool="eraser" title="Eraser (E)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 19.5L4.2 14.2a2 2 0 0 1 0-2.83l8.17-8.17a2 2 0 0 1 2.83 0l5.66 5.66a2 2 0 0 1 0 2.83L12.5 19.5"/><path d="M8.5 8.5l7 7"/><path d="M7 21h13"/></svg>
<span class="kbd">E</span>
</button>
<div class="sep"></div>
<button class="tool-btn" id="undoBtn" title="Undo (Ctrl+Z)" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 14L4 9l5-5"/><path d="M4 9h10.5A5.5 5.5 0 0 1 20 14.5V19"/></svg>
</button>
<button class="tool-btn" id="redoBtn" title="Redo (Ctrl+Shift+Z)" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 14l5-5-5-5"/><path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5V19"/></svg>
</button>
</div>
<!-- ================= PROPERTIES BAR ================= -->
<div class="panel props" id="props">
<div class="group" id="strokeGroup" title="Stroke color"></div>
<div class="sep"></div>
<div class="group" id="fillGroup" title="Fill color"></div>
<div class="sep"></div>
<div class="group" id="widthGroup" title="Stroke width"></div>
</div>
<!-- ================= ZOOM BAR ================= -->
<div class="panel zoombar" id="zoombar">
<button id="zoomOut" title="Zoom out">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M5 12h14"/></svg>
</button>
<div id="zoomLabel" title="Reset to 100%">100%</div>
<button id="zoomIn" title="Zoom in">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
</button>
<button id="zoomReset" title="Reset view (center content)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6"/><path d="M9 21H3v-6"/><path d="M21 3l-7.5 7.5"/><path d="M3 21l7.5-7.5"/></svg>
</button>
</div>
<div class="hint"><b>Space</b>+drag pan · <b>Scroll</b> zoom · <b>Shift</b>+click multi-select · <b>Arrows</b> nudge</div>
<textarea id="textEditor" spellcheck="false"></textarea>
<script>
'use strict';
/* ================================================================
1. TUNABLE CONSTANTS
================================================================ */
const CFG = {
ZOOM_MIN: 0.25,
ZOOM_MAX: 4,
ZOOM_STEP: 1.25, // multiplier for +/- buttons
WHEEL_ZOOM: 0.0016, // exp factor per wheel delta unit
GRID_BASE: 32, // base grid spacing (world px)
GRID_DOT: 1.6, // dot radius (screen px)
GRID_COLOR: '#e4e7ec',
HIT_TOL: 5, // extra hit tolerance (world px)
HANDLE: 8, // handle size (screen px)
HANDLE_HIT: 7, // handle grab radius (screen px)
MIN_RESIZE: 4, // min shape size (world px)
MIN_STROKE_LEN: 3, // discard shapes smaller than this
STROKES: ['#1e1e1e', '#e8590c', '#1971c2', '#2f9e44', '#e64980', '#7048e8'],
FILLS: ['none', '#ffe066', '#ffd8a8', '#a5d8ff', '#b2f2bb', '#fcc2d7', '#d0bfff', '#ced4da'],
WIDTHS: [2, 4, 8],
ACCENT: '#1971c2',
FONT: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
TEXT_SIZE: 20,
HISTORY_MAX: 100,
COALESCE_MS: 900, // coalesce window for nudges
STORE_KEY: 'wb.board.v1',
PEN_MIN_DIST: 1.25, // min point spacing (screen px)
};
/* ================================================================
2. STATE
================================================================ */
const canvas = document.getElementById('board');
const ctx = canvas.getContext('2d');
const measureCtx = document.createElement('canvas').getContext('2d');
const view = { x: 0, y: 0, z: 1 }; // screen = world * z + (x, y)
let shapes = []; // THE scene array (world coords)
let selection = new Set(); // selected shape ids
let tool = 'select';
let defaults = { stroke: CFG.STROKES[0], fill: 'none', strokeWidth: 2 };
let idCounter = 0;
let spaceDown = false;
let hoverId = null;
let pointer = { sx: 0, sy: 0, inside: false }; // last screen position
let interaction = null; // active gesture (see section 8)
let dirty = true;
let dpr = 1;
// history
const history = { past: [], future: [] };
let lastCommit = { key: null, time: 0 };
function uid() { return 's' + (++idCounter).toString(36) + Date.now().toString(36).slice(-4) + Math.floor(Math.random() * 1296).toString(36); }
function requestRender() { dirty = true; }
function clone(v) { return JSON.parse(JSON.stringify(v)); }
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
function byId(id) { return shapes.find(s => s.id === id) || null; }
function selShapes() { return shapes.filter(s => selection.has(s.id)); }
function isTyping() { const a = document.activeElement; return a && (a.tagName === 'TEXTAREA' || a.tagName === 'INPUT'); }
/* ================================================================
3. VIEWPORT & TRANSFORMS
================================================================ */
function s2w(sx, sy) { return { x: (sx - view.x) / view.z, y: (sy - view.y) / view.z }; }
function w2s(wx, wy) { return { x: wx * view.z + view.x, y: wy * view.z + view.y }; }
function zoomAt(sx, sy, factor) {
const z2 = clamp(view.z * factor, CFG.ZOOM_MIN, CFG.ZOOM_MAX);
if (z2 === view.z) return;
const w = s2w(sx, sy); // world point under cursor
view.z = z2;
view.x = sx - w.x * z2; // keep that point under cursor
view.y = sy - w.y * z2;
updateZoomUI(); saveSoon(); requestRender();
}
function resetView() {
const vw = window.innerWidth, vh = window.innerHeight;
let cx = 0, cy = 0;
if (shapes.length) {
const b = unionBBox(shapes);
cx = b.x + b.w / 2; cy = b.y + b.h / 2;
const z = clamp(Math.min(vw / Math.max(b.w + 160, 1), vh / Math.max(b.h + 160, 1)), CFG.ZOOM_MIN, 1);
view.z = z;
} else {
view.z = 1;
}
view.x = vw / 2 - cx * view.z;
view.y = vh / 2 - cy * view.z;
updateZoomUI(); saveSoon(); requestRender();
}
function resizeCanvas() {
dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(canvas.clientWidth * dpr);
canvas.height = Math.round(canvas.clientHeight * dpr);
requestRender();
}
/* ================================================================
4. PERSISTENCE (board survives reload; history does not)
================================================================ */
let saveTimer = null;
let viewRestored = false;
function saveSoon() { clearTimeout(saveTimer); saveTimer = setTimeout(save, 250); }
function save() {
try {
localStorage.setItem(CFG.STORE_KEY, JSON.stringify({ shapes, view, idCounter }));
} catch (e) { /* storage unavailable */ }
}
function load() {
try {
const raw = localStorage.getItem(CFG.STORE_KEY);
if (!raw) return;
const data = JSON.parse(raw);
if (Array.isArray(data.shapes)) shapes = data.shapes;
if (data.view && isFinite(data.view.x) && isFinite(data.view.y) && isFinite(data.view.z)) {
view.x = data.view.x; view.y = data.view.y;
view.z = clamp(data.view.z, CFG.ZOOM_MIN, CFG.ZOOM_MAX);
viewRestored = true;
}
idCounter = data.idCounter || 0;
} catch (e) { /* corrupt data — start fresh */ }
}
/* ================================================================
5. SHAPE MODEL — geometry, hit-testing, transforms
Every shape: { id, type, stroke, fill, strokeWidth, ...geometry }
================================================================ */
function shapeBBox(s) {
switch (s.type) {
case 'rect': case 'ellipse':
return { x: s.x, y: s.y, w: s.w, h: s.h };
case 'line':
return { x: Math.min(s.x1, s.x2), y: Math.min(s.y1, s.y2), w: Math.abs(s.x2 - s.x1), h: Math.abs(s.y2 - s.y1) };
case 'freeline': {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const p of s.points) {
if (p.x < minX) minX = p.x; if (p.x > maxX) maxX = p.x;
if (p.y < minY) minY = p.y; if (p.y > maxY) maxY = p.y;
}
const m = s.strokeWidth / 2;
return { x: minX - m, y: minY - m, w: (maxX - minX) + m * 2, h: (maxY - minY) + m * 2 };
}
case 'text': {
measureCtx.font = fontFor(s);
const lines = (s.text || '').split('\n');
let w = 0;
for (const ln of lines) w = Math.max(w, measureCtx.measureText(ln).width);
return { x: s.x, y: s.y, w: Math.max(w, 8), h: lines.length * lineH(s) };
}
}
return { x: 0, y: 0, w: 0, h: 0 };
}
function fontFor(s) { return s.fontSize + 'px ' + CFG.FONT; }
function lineH(s) { return s.fontSize * 1.35; }
function unionBBox(list) {
let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;
for (const s of list) {
const b = shapeBBox(s);
x1 = Math.min(x1, b.x); y1 = Math.min(y1, b.y);
x2 = Math.max(x2, b.x + b.w); y2 = Math.max(y2, b.y + b.h);
}
if (!isFinite(x1)) return { x: 0, y: 0, w: 0, h: 0 };
return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 };
}
function distToSeg(px, py, ax, ay, bx, by) {
const dx = bx - ax, dy = by - ay;
const l2 = dx * dx + dy * dy;
let t = l2 ? ((px - ax) * dx + (py - ay) * dy) / l2 : 0;
t = clamp(t, 0, 1);
const qx = ax + t * dx - px, qy = ay + t * dy - py;
return Math.sqrt(qx * qx + qy * qy);
}
// Hit-test in WORLD coordinates — pixel-accurate at any zoom.
function hitTest(wx, wy) {
const tol = CFG.HIT_TOL / view.z; // screen-constant tolerance
for (let i = shapes.length - 1; i >= 0; i--) {
const s = shapes[i];
const t = tol + s.strokeWidth / 2;
switch (s.type) {
case 'rect': {
if (s.fill === 'none') {
const b = shapeBBox(s);
const near = wx >= b.x - t && wx <= b.x + b.w + t && wy >= b.y - t && wy <= b.y + b.h + t;
const inside = wx >= b.x && wx <= b.x + b.w && wy >= b.y && wy <= b.y + b.h;
if (near && (!inside || t > 0)) return s; // unfilled: border band
} else {
const b = shapeBBox(s);
if (wx >= b.x - t && wx <= b.x + b.w + t && wy >= b.y - t && wy <= b.y + b.h + t) return s;
}
break;
}
case 'ellipse': {
const b = shapeBBox(s);
const rx = b.w / 2 + t, ry = b.h / 2 + t;
const cx = b.x + b.w / 2, cy = b.y + b.h / 2;
if (rx > 0 && ry > 0) {
const nx = (wx - cx) / rx, ny = (wy - cy) / ry;
if (nx * nx + ny * ny <= 1) return s; // true ellipse test
}
break;
}
case 'line':
if (distToSeg(wx, wy, s.x1, s.y1, s.x2, s.y2) <= t) return s;
break;
case 'freeline': {
const pts = s.points;
if (pts.length === 1) {
if (Math.hypot(wx - pts[0].x, wy - pts[0].y) <= t) return s;
} else {
for (let k = 0; k < pts.length - 1; k++)
if (distToSeg(wx, wy, pts[k].x, pts[k].y, pts[k + 1].x, pts[k + 1].y) <= t) return s;
}
break;
}
case 'text': {
const b = shapeBBox(s);
if (wx >= b.x - t && wx <= b.x + b.w + t && wy >= b.y - t && wy <= b.y + b.h + t) return s;
break;
}
}
}
return null;
}
// Scale a shape about an anchor point (used by resize).
function scaleShape(s, ax, ay, sx, sy) {
const tx = v => ax + (v - ax) * sx;
const ty = v => ay + (v - ay) * sy;
const avg = Math.max((sx + sy) / 2, 0.05);
switch (s.type) {
case 'rect': case 'ellipse':
s.x = tx(s.x); s.y = ty(s.y); s.w *= sx; s.h *= sy; break;
case 'line':
s.x1 = tx(s.x1); s.y1 = ty(s.y1); s.x2 = tx(s.x2); s.y2 = ty(s.y2); break;
case 'freeline':
for (const p of s.points) { p.x = tx(p.x); p.y = ty(p.y); } break;
case 'text':
s.x = tx(s.x); s.y = ty(s.y);
s.fontSize = Math.max(6, s.fontSize * avg); break;
}
s.strokeWidth = clamp(s.strokeWidth * avg, 0.5, 64);
}
function moveShape(s, dx, dy) {
switch (s.type) {
case 'rect': case 'ellipse': case 'text': s.x += dx; s.y += dy; break;
case 'line': s.x1 += dx; s.y1 += dy; s.x2 += dx; s.y2 += dy; break;
case 'freeline': for (const p of s.points) { p.x += dx; p.y += dy; } break;
}
}
/* ================================================================
6. HISTORY — snapshot based; one gesture = one step
================================================================ */
function commit(preState, key) {
// preState = scene snapshot from BEFORE the gesture
const now = performance.now();
const coalesce = key && lastCommit.key === key && (now - lastCommit.time) < CFG.COALESCE_MS;
if (!coalesce) {
history.past.push(preState);
if (history.past.length > CFG.HISTORY_MAX) history.past.shift();
history.future.length = 0;
}
lastCommit = { key: key || null, time: now };
updateHistoryUI(); saveSoon();
}
function undo() {
if (!history.past.length) return;
history.future.push(clone(shapes));
shapes = history.past.pop();
pruneSelection();
lastCommit = { key: null, time: 0 };
updateHistoryUI(); saveSoon(); requestRender();
}
function redo() {
if (!history.future.length) return;
history.past.push(clone(shapes));
shapes = history.future.pop();
pruneSelection();
lastCommit = { key: null, time: 0 };
updateHistoryUI(); saveSoon(); requestRender();
}
function pruneSelection() {
const ids = new Set(shapes.map(s => s.id));
let changed = false;
for (const id of [...selection]) if (!ids.has(id)) { selection.delete(id); changed = true; }
if (changed) requestRender();
}
function updateHistoryUI() {
document.getElementById('undoBtn').disabled = !history.past.length;
document.getElementById('redoBtn').disabled = !history.future.length;
}
/* ================================================================
7. RENDERING
================================================================ */
function render() {
const w = canvas.clientWidth, h = canvas.clientHeight;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, w, h);
drawGrid(w, h);
// ---- world pass ----
ctx.setTransform(dpr * view.z, 0, 0, dpr * view.z, dpr * view.x, dpr * view.y);
for (const s of shapes) {
if (s.id === editingId) continue; // text being edited shows in overlay
drawShape(s);
}
// ---- screen pass (crisp UI at any zoom) ----
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
drawHover();
drawSelectionUI();
drawInteractionUI();
}
function drawGrid(w, h) {
let step = CFG.GRID_BASE;
while (step * view.z < 22) step *= 2;
while (step * view.z > 88) step /= 2;
const tl = s2w(0, 0), br = s2w(w, h);
ctx.fillStyle = CFG.GRID_COLOR;
const r = CFG.GRID_DOT;
for (let gx = Math.floor(tl.x / step) * step; gx <= br.x; gx += step) {
const sx = gx * view.z + view.x;
for (let gy = Math.floor(tl.y / step) * step; gy <= br.y; gy += step) {
const sy = gy * view.z + view.y;
ctx.beginPath();
ctx.arc(sx, sy, r, 0, Math.PI * 2);
ctx.fill();
}
}
}
function drawShape(s) {
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = s.stroke;
ctx.lineWidth = s.strokeWidth;
if (s.fill && s.fill !== 'none') ctx.fillStyle = s.fill;
switch (s.type) {
case 'rect':
if (s.fill && s.fill !== 'none') ctx.fillRect(s.x, s.y, s.w, s.h);
ctx.strokeRect(s.x, s.y, s.w, s.h);
break;
case 'ellipse': {
const cx = s.x + s.w / 2, cy = s.y + s.h / 2;
ctx.beginPath();
ctx.ellipse(cx, cy, Math.abs(s.w / 2), Math.abs(s.h / 2), 0, 0, Math.PI * 2);
if (s.fill && s.fill !== 'none') ctx.fill();
ctx.stroke();
break;
}
case 'line': {
ctx.beginPath();
ctx.moveTo(s.x1, s.y1);
ctx.lineTo(s.x2, s.y2);
ctx.stroke();
// arrowhead
const ang = Math.atan2(s.y2 - s.y1, s.x2 - s.x1);
const len = Math.max(10, s.strokeWidth * 3.2);
ctx.beginPath();
ctx.moveTo(s.x2, s.y2);
ctx.lineTo(s.x2 - len * Math.cos(ang - 0.45), s.y2 - len * Math.sin(ang - 0.45));
ctx.moveTo(s.x2, s.y2);
ctx.lineTo(s.x2 - len * Math.cos(ang + 0.45), s.y2 - len * Math.sin(ang + 0.45));
ctx.stroke();
break;
}
case 'freeline': {
const pts = s.points;
if (!pts.length) break;
ctx.beginPath();
if (pts.length === 1) {
ctx.arc(pts[0].x, pts[0].y, s.strokeWidth / 2, 0, Math.PI * 2);
ctx.fillStyle = s.stroke;
ctx.fill();
break;
}
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length - 1; i++) {
const mx = (pts[i].x + pts[i + 1].x) / 2, my = (pts[i].y + pts[i + 1].y) / 2;
ctx.quadraticCurveTo(pts[i].x, pts[i].y, mx, my);
}
const last = pts[pts.length - 1];
ctx.lineTo(last.x, last.y);
ctx.stroke();
break;
}
case 'text': {
ctx.font = fontFor(s);
ctx.textBaseline = 'top';
ctx.fillStyle = s.stroke;
const lines = (s.text || '').split('\n');
const lh = lineH(s);
for (let i = 0; i < lines.length; i++)
ctx.fillText(lines[i], s.x, s.y + i * lh);
break;
}
}
}
function drawHover() {
if (tool !== 'select' || !hoverId || selection.has(hoverId) || interaction) return;
const s = byId(hoverId);
if (!s) return;
const b = shapeBBox(s);
const p = w2s(b.x, b.y), q = w2s(b.x + b.w, b.y + b.h);
ctx.strokeStyle = CFG.ACCENT;
ctx.globalAlpha = 0.45;
ctx.lineWidth = 1.25;
ctx.setLineDash([]);
ctx.strokeRect(p.x, p.y, q.x - p.x, q.y - p.y);
ctx.globalAlpha = 1;
}
function drawSelectionUI() {
if (!selection.size) return;
const list = selShapes();
if (!list.length) return;
const b = unionBBox(list);
const p = w2s(b.x, b.y), q = w2s(b.x + b.w, b.y + b.h);
ctx.strokeStyle = CFG.ACCENT;
ctx.lineWidth = 1.25;
ctx.setLineDash([]);
ctx.strokeRect(p.x, p.y, q.x - p.x, q.y - p.y);
// 4 corner handles — constant screen size at every zoom
const hs = CFG.HANDLE;
for (const [hx, hy] of [[p.x, p.y], [q.x, p.y], [p.x, q.y], [q.x, q.y]]) {
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = CFG.ACCENT;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.rect(hx - hs / 2, hy - hs / 2, hs, hs);
ctx.fill();
ctx.stroke();
}
}
function drawInteractionUI() {
if (!interaction) return;
if (interaction.type === 'rubberband') {
const a = w2s(interaction.sx0, interaction.sy0), b = w2s(interaction.sx1, interaction.sy1);
ctx.strokeStyle = CFG.ACCENT;
ctx.fillStyle = 'rgba(25,113,194,0.08)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 3]);
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.fillRect(x, y, w, h);
ctx.strokeRect(x, y, w, h);
ctx.setLineDash([]);
} else if (interaction.type === 'erase') {
ctx.strokeStyle = '#d63031';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(pointer.sx, pointer.sy, 10, 0, Math.PI * 2);
ctx.stroke();
}
}
/* ================================================================
8. INTERACTION — the state machine
================================================================ */
function setTool(t) {
tool = t;
for (const btn of document.querySelectorAll('[data-tool]'))
btn.classList.toggle('active', btn.dataset.tool === t);
canvas.className = 'cur-' + t;
if (t !== 'select') { selection.clear(); hoverId = null; }
requestRender();
}
function handleAt(sx, sy) {
// returns { id: 'nw'|'ne'|'sw'|'se', ax, ay } in world coords, or null
if (selection.size !== 1) return null;
const s = selShapes()[0];
const b = shapeBBox(s);
const corners = {
nw: [b.x, b.y], ne: [b.x + b.w, b.y],
sw: [b.x, b.y + b.h], se: [b.x + b.w, b.y + b.h],
};
for (const [id, [wx, wy]] of Object.entries(corners)) {
const p = w2s(wx, wy);
if (Math.abs(sx - p.x) <= CFG.HANDLE_HIT && Math.abs(sy - p.y) <= CFG.HANDLE_HIT) {
// anchor = opposite corner
const opp = { nw: [b.x + b.w, b.y + b.h], ne: [b.x, b.y + b.h], sw: [b.x + b.w, b.y], se: [b.x, b.y] }[id];
return { id, ax: opp[0], ay: opp[1] };
}
}
return null;
}
function resizeCursor(sx, sy) {
const h = handleAt(sx, sy);
if (!h) return null;
return { nw: 'nwse-resize', se: 'nwse-resize', ne: 'nesw-resize', sw: 'nesw-resize' }[h.id];
}
canvas.addEventListener('pointerdown', e => {
if (isTyping()) return;
try { canvas.setPointerCapture(e.pointerId); } catch (err) { /* synthetic events */ }
const sx = e.offsetX, sy = e.offsetY;
pointer.sx = sx; pointer.sy = sy; pointer.inside = true;
// ---- pan: space+drag or middle mouse ----
if (e.button === 1 || spaceDown) {
e.preventDefault();
interaction = { type: 'pan', sx0: sx, sy0: sy, vx0: view.x, vy0: view.y };
canvas.classList.add('cur-panning');
return;
}
if (e.button !== 0) return;
const wpt = s2w(sx, sy);
switch (tool) {
case 'select': {
const h = handleAt(sx, sy);
if (h) {
const s = selShapes()[0];
interaction = {
type: 'resize', id: h.id, ax: h.ax, ay: h.ay,
before: clone(shapes),
orig: shapeBBox(s),
moved: false,
};
return;
}
const hit = hitTest(wpt.x, wpt.y);
if (hit) {
if (e.shiftKey) {
selection.has(hit.id) ? selection.delete(hit.id) : selection.add(hit.id);
} else if (!selection.has(hit.id)) {
selection.clear();
selection.add(hit.id);
}
interaction = { type: 'move', before: clone(shapes), w0: wpt, moved: false };
} else {
if (!e.shiftKey) selection.clear();
interaction = {
type: 'rubberband',
sx0: wpt.x, sy0: wpt.y, sx1: wpt.x, sy1: wpt.y,
base: new Set(selection),
};
}
requestRender();
break;
}
case 'pen': {
const before = clone(shapes);
const s = { id: uid(), type: 'freeline', points: [{ x: wpt.x, y: wpt.y }],
stroke: defaults.stroke, fill: 'none', strokeWidth: defaults.strokeWidth };
shapes.push(s);
interaction = { type: 'draw', id: s.id, last: wpt, before };
requestRender();
break;
}
case 'rect': case 'ellipse': case 'line': {
const before = clone(shapes);
const s = {
id: uid(), type: tool,
stroke: defaults.stroke, fill: defaults.fill, strokeWidth: defaults.strokeWidth,
x: wpt.x, y: wpt.y, w: 0, h: 0, x1: wpt.x, y1: wpt.y, x2: wpt.x, y2: wpt.y,
};
shapes.push(s);
interaction = { type: 'create', id: s.id, w0: wpt, before };
requestRender();
break;
}
case 'text': {
const before = clone(shapes);
const s = { id: uid(), type: 'text', x: wpt.x, y: wpt.y, text: '',
stroke: defaults.stroke, fill: 'none', strokeWidth: 2, fontSize: CFG.TEXT_SIZE };
shapes.push(s);
selection.clear();
selection.add(s.id);
setTool('select');
startEditing(s, before);
break;
}
case 'eraser': {
interaction = { type: 'erase', before: clone(shapes), erased: 0 };
eraseAt(wpt.x, wpt.y);
break;
}
}
});
canvas.addEventListener('pointermove', e => {
const sx = e.offsetX, sy = e.offsetY;
pointer.sx = sx; pointer.sy = sy; pointer.inside = true;
const wpt = s2w(sx, sy);
if (!interaction) {
// hover feedback + cursor
if (tool === 'select') {
const rc = resizeCursor(sx, sy);
if (rc) { canvas.style.cursor = rc; }
else {
canvas.style.cursor = '';
const hit = hitTest(wpt.x, wpt.y);
const id = hit ? hit.id : null;
if (id !== hoverId) { hoverId = id; requestRender(); }
canvas.style.cursor = hit ? 'move' : '';
}
}
return;
}
switch (interaction.type) {
case 'pan':
view.x = interaction.vx0 + (sx - interaction.sx0);
view.y = interaction.vy0 + (sy - interaction.sy0);
requestRender();
break;
case 'move': {
// incremental delta — applying the total delta every move would compound
const dx = wpt.x - interaction.w0.x, dy = wpt.y - interaction.w0.y;
const adx = dx - (interaction.lx || 0), ady = dy - (interaction.ly || 0);
interaction.lx = dx; interaction.ly = dy;
if (Math.abs(adx) > 0.001 || Math.abs(ady) > 0.001) interaction.moved = true;
const ids = new Set([...selection]);
for (const s of shapes) if (ids.has(s.id)) moveShape(s, adx, ady);
requestRender();
break;
}
case 'resize': {
interaction.moved = true;
applyResize(wpt.x, wpt.y, e.shiftKey);
requestRender();
break;
}
case 'rubberband':
interaction.sx1 = wpt.x; interaction.sy1 = wpt.y;
requestRender();
break;
case 'draw': {
const s = byId(interaction.id);
const d = Math.hypot(wpt.x - interaction.last.x, wpt.y - interaction.last.y) * view.z;
if (d >= CFG.PEN_MIN_DIST) {
s.points.push({ x: wpt.x, y: wpt.y });
interaction.last = wpt;
requestRender();
}
break;
}
case 'create': {
const s = byId(interaction.id);
if (s.type === 'line') { s.x2 = wpt.x; s.y2 = wpt.y; }
else {
s.w = wpt.x - interaction.w0.x;
s.h = wpt.y - interaction.w0.y;
if (e.shiftKey) { const m = Math.max(Math.abs(s.w), Math.abs(s.h)); s.w = Math.sign(s.w || 1) * m; s.h = Math.sign(s.h || 1) * m; }
}
requestRender();
break;
}
case 'erase':
eraseAt(wpt.x, wpt.y);
break;
}
});
function applyResize(wx, wy, shift) {
const it = interaction;
const list = selShapes();
if (!list.length) return;
const b = it.orig;
let sx = (wx - it.ax) / Math.max(b.w, 1);
let sy = (wy - it.ay) / Math.max(b.h, 1);
if (shift) { const m = Math.max(sx, sy); sx = sy = m; }
sx = clamp(sx, 0.02, 50); sy = clamp(sy, 0.02, 50);
// rebuild each selected shape from its ORIGINAL snapshot — scaling the live
// shape repeatedly would compound the transform
const beforeById = new Map(it.before.map(o => [o.id, o]));
for (let i = 0; i < shapes.length; i++) {
const s = shapes[i];
if (!selection.has(s.id)) continue;
const o = beforeById.get(s.id);
if (!o) continue;
const t = clone(o);
scaleShape(t, it.ax, it.ay, sx, sy);
shapes[i] = t;
}
// enforce minimum size for single shapes
if (list.length === 1) {
const cur = byId(list[0].id);
if (cur && cur.type !== 'line') {
const bb = shapeBBox(cur);
if (bb.w < CFG.MIN_RESIZE) scaleShape(cur, it.ax, it.ay, CFG.MIN_RESIZE / Math.max(bb.w, 0.001), 1);
if (bb.h < CFG.MIN_RESIZE) scaleShape(cur, it.ax, it.ay, 1, CFG.MIN_RESIZE / Math.max(bb.h, 0.001));
}
}
}
function eraseAt(wx, wy) {
const hit = hitTest(wx, wy);
if (hit) {
shapes = shapes.filter(s => s.id !== hit.id);
selection.delete(hit.id);
interaction.erased++;
requestRender();
}
}
function endPointer(e) {
if (!interaction) return;
const it = interaction;
interaction = null;
canvas.classList.remove('cur-panning');
switch (it.type) {
case 'pan':
updateZoomUI(); saveSoon();
break;
case 'move':
if (it.moved) commit(it.before);
break;
case 'resize':
if (it.moved) commit(it.before);
break;
case 'rubberband': {
const x1 = Math.min(it.sx0, it.sx1), x2 = Math.max(it.sx0, it.sx1);
const y1 = Math.min(it.sy0, it.sy1), y2 = Math.max(it.sy0, it.sy1);
if (Math.abs(x2 - x1) > 2 / view.z || Math.abs(y2 - y1) > 2 / view.z) {
for (const s of shapes) {
const b = shapeBBox(s);
if (b.x < x2 && b.x + b.w > x1 && b.y < y2 && b.y + b.h > y1)
selection.add(s.id);
}
}
requestRender();
break;
}
case 'draw': {
const s = byId(it.id);
if (s && s.points.length < 2) shapes = shapes.filter(x => x.id !== s.id);
else {
selection.clear();
selection.add(s.id);
setTool('select');
commit(it.before);
}
break;
}
case 'create': {
const s = byId(it.id);
const b = s ? shapeBBox(s) : null;
const tiny = !s || (s.type === 'line'
? Math.hypot(s.x2 - s.x1, s.y2 - s.y1) < CFG.MIN_STROKE_LEN
: (b.w < CFG.MIN_STROKE_LEN && b.h < CFG.MIN_STROKE_LEN));
if (tiny) {
if (s) shapes = shapes.filter(x => x.id !== s.id);
} else {
if (s.type === 'rect' || s.type === 'ellipse') {
if (s.w < 0) { s.x += s.w; s.w = -s.w; }
if (s.h < 0) { s.y += s.h; s.h = -s.h; }
}
selection.clear();
selection.add(s.id);
setTool('select');
commit(it.before);
}
requestRender();
break;
}
case 'erase':
if (it.erased) commit(it.before);
break;
}
}
canvas.addEventListener('pointerup', endPointer);
canvas.addEventListener('pointercancel', endPointer);
canvas.addEventListener('pointerleave', () => {
pointer.inside = false;
hoverId = null;
requestRender();
});
canvas.addEventListener('dblclick', e => {
if (tool !== 'select') return;
const w = s2w(e.offsetX, e.offsetY);
const hit = hitTest(w.x, w.y);
if (hit && hit.type === 'text') {
selection.clear();
selection.add(hit.id);
startEditing(hit, clone(shapes));
}
});
canvas.addEventListener('contextmenu', e => e.preventDefault());
// ---- wheel zoom toward cursor ----
canvas.addEventListener('wheel', e => {
e.preventDefault();
const factor = Math.exp(-e.deltaY * CFG.WHEEL_ZOOM * (e.ctrlKey ? 2.2 : 1));
zoomAt(e.offsetX, e.offsetY, factor);
}, { passive: false });
/* ================================================================
9. TEXT EDITOR (overlay textarea)
================================================================ */
const editor = document.getElementById('textEditor');
let editingId = null;
let editBefore = null; // shape snapshot before editing (for cancel/commit)
function startEditing(s, sceneBefore) {
editingId = s.id;
editBefore = { shape: clone(s), scene: sceneBefore || clone(shapes) };
const p = w2s(s.x, s.y);
editor.style.display = 'block';
editor.style.left = (p.x - 3) + 'px';
editor.style.top = (p.y - 3) + 'px';
editor.style.fontSize = (s.fontSize * view.z) + 'px';
editor.style.fontFamily = CFG.FONT;
editor.style.color = s.stroke;
const lh = s.fontSize * 1.35 * view.z;
const lines = Math.max(1, (s.text || '').split('\n').length);
editor.style.height = (lines * lh + 8) + 'px';
editor.style.width = 'auto';
editor.style.minWidth = (Math.max(shapeBBox(s).w * view.z, 40) + 10) + 'px';
editor.value = s.text || '';
requestRender();
setTimeout(() => { editor.focus(); editor.select(); }, 0);
}
function syncEditorSize(s) {
const lh = s.fontSize * 1.35 * view.z;
const lines = Math.max(1, (s.text || '').split('\n').length);
editor.style.height = (lines * lh + 8) + 'px';
const b = shapeBBox(s);
editor.style.minWidth = (Math.max(b.w * view.z, 40) + 10) + 'px';
}
function commitEdit(cancel) {
if (!editingId) return;
const s = byId(editingId);
const finalText = (editor.value || '').replace(/\n+$/, '');
editor.style.display = 'none';
const id = editingId;
editingId = null;
const before = editBefore;
editBefore = null;
const sceneBefore = before ? before.scene : clone(shapes);
if (cancel) {
// restore the shape to its pre-edit state
if (s && before) {
const i = shapes.findIndex(x => x.id === id);
if (i >= 0) shapes[i] = before.shape;
}
commit(sceneBefore);
} else if (finalText === '') {
// empty text → discard the shape
shapes = shapes.filter(x => x.id !== id);
selection.delete(id);
commit(sceneBefore);
} else {
if (s) {
s.text = finalText;
commit(sceneBefore);
selection.clear();
selection.add(id);
} else {
commit(sceneBefore);
}
}
editor.blur(); // release focus so canvas shortcuts work again
requestRender();
}
editor.addEventListener('input', () => {
const s = byId(editingId);
if (s) { s.text = editor.value; syncEditorSize(s); requestRender(); }
});
editor.addEventListener('keydown', e => {
e.stopPropagation();
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); commitEdit(false); }
else if (e.key === 'Escape') { e.preventDefault(); commitEdit(true); }
});
editor.addEventListener('blur', () => commitEdit(false));
editor.addEventListener('pointerdown', e => e.stopPropagation());
/* ================================================================
10. PROPERTIES BAR
================================================================ */
function buildSwatches(containerId, colors, kind) {
const el = document.getElementById(containerId);
el.innerHTML = '';
for (const c of colors) {
const b = document.createElement('button');
b.className = 'swatch' + (c === 'none' ? ' none' : '');
if (c !== 'none') b.style.background = c;
b.dataset.value = c;
b.title = c === 'none' ? 'No fill' : c;
b.addEventListener('click', () => applyStyle(kind, c));
el.appendChild(b);
}
}
function buildWidths() {
const el = document.getElementById('widthGroup');
el.innerHTML = '';
for (const w of CFG.WIDTHS) {
const b = document.createElement('button');
b.className = 'width-btn';
b.dataset.value = w;
b.innerHTML = '<span class="dot"></span>';
const d = 4 + w * 1.6;
b.querySelector('.dot').style.width = d + 'px';
b.querySelector('.dot').style.height = d + 'px';
b.title = w + 'px';
b.addEventListener('click', () => applyStyle('strokeWidth', w));
el.appendChild(b);
}
}
function applyStyle(kind, value) {
const sel = selShapes();
if (sel.length) {
for (const s of sel) {
if (kind === 'stroke' || kind === 'fill' || kind === 'strokeWidth') s[kind] = value;
}
commit(clone(shapes), 'restyle');
} else {
defaults[kind] = value;
}
updatePropsUI();
requestRender();
}
function updatePropsUI() {
const sel = selShapes();
const stroke = sel.length ? sel[0].stroke : defaults.stroke;
const fill = sel.length ? (sel[0].fill || 'none') : defaults.fill;
const width = sel.length ? sel[0].strokeWidth : defaults.strokeWidth;
for (const btn of document.querySelectorAll('#strokeGroup .swatch'))
btn.classList.toggle('active', btn.dataset.value === stroke);
for (const btn of document.querySelectorAll('#fillGroup .swatch'))
btn.classList.toggle('active', btn.dataset.value === fill);
for (const btn of document.querySelectorAll('#widthGroup .width-btn'))
btn.classList.toggle('active', Number(btn.dataset.value) === width);
}
/* ================================================================
11. KEYBOARD
================================================================ */
window.addEventListener('keydown', e => {
if (isTyping()) return;
const k = e.key;
if (k === ' ') {
if (!spaceDown) { spaceDown = true; canvas.classList.add('cur-pan'); }
e.preventDefault();
return;
}
if (e.ctrlKey || e.metaKey) {
const lk = k.toLowerCase();
if (lk === 'z') { e.preventDefault(); e.shiftKey ? redo() : undo(); }
else if (lk === 'y') { e.preventDefault(); redo(); }
return;
}
if (e.altKey) return;
switch (k) {
case 'v': case 'V': setTool('select'); break;
case 'p': case 'P': setTool('pen'); break;
case 'r': case 'R': setTool('rect'); break;
case 'o': case 'O': setTool('ellipse'); break;
case 'l': case 'L': setTool('line'); break;
case 't': case 'T': setTool('text'); break;
case 'e': case 'E': setTool('eraser'); break;
case 'Delete': case 'Backspace':
if (selection.size) {
shapes = shapes.filter(s => !selection.has(s.id));
selection.clear();
commit(clone(shapes));
}
break;
case 'ArrowLeft': case 'ArrowRight': case 'ArrowUp': case 'ArrowDown': {
if (!selection.size) break;
e.preventDefault();
const d = e.shiftKey ? 10 : 1;
const dx = k === 'ArrowLeft' ? -d : k === 'ArrowRight' ? d : 0;
const dy = k === 'ArrowUp' ? -d : k === 'ArrowDown' ? d : 0;
for (const s of selShapes()) moveShape(s, dx, dy);
commit(clone(shapes), 'nudge');
break;
}
case 'Escape':
selection.clear();
requestRender();
break;
}
});
window.addEventListener('keyup', e => {
if (e.key === ' ') { spaceDown = false; canvas.classList.remove('cur-pan'); }
});
/* ================================================================
12. TOOLBAR & ZOOM UI
================================================================ */
document.querySelectorAll('[data-tool]').forEach(btn => {
btn.addEventListener('click', () => setTool(btn.dataset.tool));
});
document.getElementById('undoBtn').addEventListener('click', undo);
document.getElementById('redoBtn').addEventListener('click', redo);
document.getElementById('zoomIn').addEventListener('click', () =>
zoomAt(window.innerWidth / 2, window.innerHeight / 2, CFG.ZOOM_STEP));
document.getElementById('zoomOut').addEventListener('click', () =>
zoomAt(window.innerWidth / 2, window.innerHeight / 2, 1 / CFG.ZOOM_STEP));
document.getElementById('zoomReset').addEventListener('click', resetView);
document.getElementById('zoomLabel').addEventListener('click', () => {
// click % → 100% at center
const w = s2w(window.innerWidth / 2, window.innerHeight / 2);
view.z = 1;
view.x = window.innerWidth / 2 - w.x;
view.y = window.innerHeight / 2 - w.y;
updateZoomUI(); saveSoon(); requestRender();
});
function updateZoomUI() {
document.getElementById('zoomLabel').textContent = Math.round(view.z * 100) + '%';
}
/* ================================================================
13. INIT
================================================================ */
function seedIfEmpty() {
shapes = [
{ id: uid(), type: 'rect', x: -260, y: -140, w: 240, h: 150, stroke: '#1e1e1e', fill: '#ffe066', strokeWidth: 2 },
{ id: uid(), type: 'ellipse', x: 40, y: -100, w: 170, h: 170, stroke: '#1971c2', fill: 'none', strokeWidth: 2 },
{ id: uid(), type: 'freeline', points: [
{ x: -240, y: 60 }, { x: -210, y: 40 }, { x: -180, y: 70 }, { x: -150, y: 45 },
{ x: -120, y: 65 }, { x: -90, y: 50 }, { x: -60, y: 62 },
], stroke: '#2f9e44', fill: 'none', strokeWidth: 4 },
{ id: uid(), type: 'text', x: 60, y: 120, text: 'Welcome to the board.\nDraw, select, undo — it all works.',
stroke: '#2b2f36', fill: 'none', strokeWidth: 2, fontSize: 18 },
];
}
load();
if (!shapes.length) seedIfEmpty();
buildSwatches('strokeGroup', CFG.STROKES, 'stroke');
buildSwatches('fillGroup', CFG.FILLS, 'fill');
buildWidths();
updatePropsUI();
updateHistoryUI();
updateZoomUI();
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
// frame content after layout has settled (skip if a saved view exists)
requestAnimationFrame(() => { if (!viewRestored) resetView(); });
// main loop — render only when dirty (60fps budget)
(function loop() {
if (dirty) { dirty = false; render(); }
requestAnimationFrame(loop);
})();
</script>
</body>
</html>