Whiteboard
Qwen 3.8 · 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 47,393 bytes · SHA-256 7cef44fefd28
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<meta name="color-scheme" content="light"/>
<title>Board — Whiteboard Prototype</title>
<style>
:root{
--chrome:#e8e8e1; --panel:#ffffff; --line:#dcdcd3; --ink:#2b2b28;
--accent:#4263eb; --accent-soft:#e1e7ff; --muted:#8b8b81;
}
*{ box-sizing:border-box; }
html,body{ margin:0; height:100%; overflow:hidden; background:var(--chrome);
font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
color:var(--ink); -webkit-user-select:none; user-select:none; }
#board{ position:absolute; inset:0; display:block; touch-action:none; }
.panel{ position:fixed; background:var(--panel); border:1px solid var(--line);
border-radius:12px; box-shadow:0 6px 24px rgba(30,30,25,.10), 0 1px 2px rgba(30,30,25,.06); z-index:10; }
#brand{ left:14px; top:14px; display:flex; align-items:center; gap:8px; padding:8px 14px; font-size:13px; font-weight:600; }
#brand .logo{ width:12px; height:12px; border-radius:3px; background:var(--accent); }
#toolbar{ left:14px; top:50%; transform:translateY(-50%); display:flex; flex-direction:column; gap:3px; padding:7px; }
.tbtn{ width:42px; height:42px; border:none; background:transparent; border-radius:9px;
display:flex; align-items:center; justify-content:center; color:#4a4a44; cursor:pointer; }
.tbtn:hover{ background:#f2f2ea; }
.tbtn.active{ background:var(--accent-soft); color:var(--accent); box-shadow:inset 0 0 0 1px #b9c6ff; }
.tbtn:disabled{ opacity:.32; cursor:default; }
.tbtn:disabled:hover{ background:transparent; }
.tbtn svg{ display:block; }
.sep{ height:1px; background:var(--line); margin:4px 3px; }
#stylebar{ top:14px; left:50%; transform:translateX(-50%); display:flex; align-items:center; gap:14px; padding:9px 16px; }
.sgroup{ display:flex; align-items:center; gap:7px; }
.scap{ font-size:10px; font-weight:600; letter-spacing:.08em; text-transform:uppercase; color:var(--muted); }
.srow{ display:inline-flex; gap:6px; align-items:center; }
.swatch{ width:22px; height:22px; border-radius:50%; border:1px solid rgba(0,0,0,.18); cursor:pointer; padding:0; }
.swatch.active{ box-shadow:0 0 0 2px #fff, 0 0 0 3.5px var(--accent); border-color:transparent; }
.wbtn{ width:30px; height:26px; border-radius:7px; border:1px solid transparent; background:transparent;
display:flex; align-items:center; justify-content:center; cursor:pointer; }
.wbtn:hover{ background:#f2f2ea; }
.wbtn.active{ background:var(--accent-soft); box-shadow:inset 0 0 0 1px #b9c6ff; }
.wline{ width:18px; border-radius:2px; background:#4a4a44; }
.vsep{ width:1px; height:22px; background:var(--line); }
#zoombar{ right:14px; bottom:14px; display:flex; align-items:center; gap:2px; padding:5px 8px; }
#zoombar button{ border:none; background:transparent; min-width:30px; height:30px; border-radius:7px;
cursor:pointer; color:#4a4a44; font-size:16px; display:flex; align-items:center; justify-content:center; padding:0 4px; }
#zoombar button:hover{ background:#f2f2ea; }
#zoomPct{ min-width:52px; text-align:center; font-size:12.5px; font-weight:600; color:var(--ink); }
#hints{ position:fixed; left:14px; bottom:16px; font-size:11px; color:var(--muted);
display:flex; gap:12px; align-items:center; pointer-events:none; z-index:10; }
#hints b{ color:#6f6f65; font-weight:600; }
#textEditor{ position:fixed; display:none; border:1.5px solid var(--accent); border-radius:4px;
background:rgba(255,255,255,.88); outline:none; resize:none; overflow:hidden;
margin:0; padding:2px 4px; white-space:pre; line-height:1.25; z-index:20;
font-family:"Segoe Print","Bradley Hand","Comic Sans MS","Chalkboard SE",cursive;
-webkit-user-select:text; user-select:text; }
</style>
</head>
<body>
<canvas id="board"></canvas>
<div id="brand" class="panel"><span class="logo"></span>Board</div>
<div id="stylebar" class="panel">
<div class="sgroup"><span class="scap">Stroke</span><span id="strokeSw" class="srow"></span></div>
<div class="vsep"></div>
<div class="sgroup"><span class="scap">Fill</span><span id="fillSw" class="srow"></span></div>
<div class="vsep"></div>
<div class="sgroup"><span class="scap">Width</span><span id="widths" class="srow"></span></div>
</div>
<div id="toolbar" class="panel">
<button class="tbtn active" data-tool="select" title="Select (V)">
<svg width="20" height="20" viewBox="0 0 24 24"><path d="M6 3v15l4-3.5 2.5 5.5 2.6-1.1-2.5-5.6 5.4-1z" fill="currentColor"/></svg>
</button>
<button class="tbtn" data-tool="pen" title="Pen (P)">
<svg width="20" height="20" viewBox="0 0 24 24"><path d="M4.5 19.5l.9-3.6L16.6 4.7a2 2 0 012.8 2.8L8.2 18.7l-3.7.8z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="M14.5 6.8l2.7 2.7" stroke="currentColor" stroke-width="1.7"/></svg>
</button>
<button class="tbtn" data-tool="rect" title="Rectangle (R)">
<svg width="20" height="20" viewBox="0 0 24 24"><rect x="4.5" y="6.5" width="15" height="11" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.7"/></svg>
</button>
<button class="tbtn" data-tool="ellipse" title="Ellipse (O)">
<svg width="20" height="20" viewBox="0 0 24 24"><ellipse cx="12" cy="12" rx="8" ry="6" fill="none" stroke="currentColor" stroke-width="1.7"/></svg>
</button>
<button class="tbtn" data-tool="line" title="Arrow (L)">
<svg width="20" height="20" viewBox="0 0 24 24"><path d="M5.5 18.5L18 6M18 6h-6M18 6v6" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button class="tbtn" data-tool="text" title="Text (T)">
<svg width="20" height="20" viewBox="0 0 24 24"><path d="M5.5 6V4.5h13V6M12 4.5V19M9.5 19h5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>
</button>
<button class="tbtn" data-tool="erase" title="Eraser (E)">
<svg width="20" height="20" viewBox="0 0 24 24"><path d="M6 14.5l8-8a2 2 0 012.8 0l2.7 2.7a2 2 0 010 2.8L11.5 20H8.7l-2.7-2.7a2 2 0 010-2.8z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="M11 20H20" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>
</button>
<div class="sep"></div>
<button class="tbtn" id="undoBtn" title="Undo (Ctrl+Z)" disabled>
<svg width="20" height="20" viewBox="0 0 24 24"><path d="M8.5 13.5L4 9l4.5-4.5M4 9h9.5a6 6 0 016 6 6 6 0 01-6 6H10" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button class="tbtn" id="redoBtn" title="Redo (Ctrl+Shift+Z)" disabled>
<svg width="20" height="20" viewBox="0 0 24 24"><path d="M15.5 13.5L20 9l-4.5-4.5M20 9h-9.5a6 6 0 00-6 6 6 6 0 006 6H14" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
<div id="zoombar" class="panel">
<button id="zoomOut" title="Zoom out">−</button>
<button id="zoomPct" title="Current zoom — click to reset">100%</button>
<button id="zoomIn" title="Zoom in">+</button>
<button id="zoomReset" title="Reset view">
<svg width="18" height="18" viewBox="0 0 24 24"><path d="M9 4H5.5A1.5 1.5 0 004 5.5V9M15 4h3.5A1.5 1.5 0 0120 5.5V9M9 20H5.5A1.5 1.5 0 014 18.5V15M15 20h3.5a1.5 1.5 0 001.5-1.5V15" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>
</button>
</div>
<div id="hints">
<span><b>Space</b>+drag pan</span><span><b>wheel</b> zoom</span><span><b>V</b> select</span>
<span><b>P</b> pen</span><span><b>R</b> rect</span><span><b>O</b> ellipse</span>
<span><b>L</b> arrow</span><span><b>T</b> text</span><span><b>E</b> eraser</span>
<span><b>Del</b> delete</span><span><b>Ctrl+Z</b> undo</span><span><b>dbl-click</b> edit text</span>
</div>
<textarea id="textEditor" spellcheck="false"></textarea>
<script>
'use strict';
/* ================================================================
1. TUNABLE CONSTANTS
================================================================ */
const CFG = {
zoomMin: 0.25, // 25%
zoomMax: 4, // 400%
hitTol: 5, // screen px hit tolerance
handleSize: 8, // screen px resize handle
gridSize: 24, // world units between grid dots
minShapeSize: 3, // world units; smaller drags are cancelled
nudgePx: 1, // arrow-key nudge (screen px)
nudgeShiftPx: 10, // shift+arrow nudge (screen px)
historyLimit: 200,
coalesceMs: 700, // merge window for repeated nudges
textLineHeight: 1.25,
textMinFont: 8,
textDefaultFont: 20,
storageKey: 'whiteboard.scene.v1',
viewKey: 'whiteboard.view.v1',
palette: {
strokes: ['#1e1e1e', '#e03131', '#1971c2', '#2f9e44', '#f08c00', '#6741d9'],
fills: ['#ffd8a8', '#fff3bf', '#b2f2bb', '#a5d8ff', '#d0bfff'],
widths: [2, 4, 8],
},
colors: {
canvas: '#f4f4ee',
grid: '#d7d7cd',
selection: '#4263eb',
handleFill: '#ffffff',
rubber: '#4263eb',
},
textFont: '"Segoe Print","Bradley Hand","Comic Sans MS","Chalkboard SE",cursive',
};
const TYPES = new Set(['rect', 'ellipse', 'line', 'stroke', 'text']);
const TAU = Math.PI * 2;
/* ================================================================
2. STATE
================================================================ */
let scene = []; // single serializable scene array: {id, type, ...}
let selection = new Set(); // set of shape ids
let view = { x: 0, y: 0, z: 1 }; // pan (screen px) + zoom
let tool = 'select';
let current = { stroke: CFG.palette.strokes[0], fill: 'none', strokeWidth: CFG.palette.widths[0] };
let gesture = null; // active pointer gesture
let editing = null; // active inline text edit
let spaceDown = false;
let mouse = { x: -9999, y: -9999, in: false };
let needsRender = true;
let dpr = 1, W = 0, H = 0;
const past = [], future = []; // history stacks (scene snapshots)
let lastCoalesce = null;
/* ================================================================
3. DOM / CANVAS SETUP
================================================================ */
const canvas = document.getElementById('board');
const ctx = canvas.getContext('2d');
const editor = document.getElementById('textEditor');
const undoBtn = document.getElementById('undoBtn');
const redoBtn = document.getElementById('redoBtn');
const zoomPct = document.getElementById('zoomPct');
const measureCtx = document.createElement('canvas').getContext('2d');
function resize(){
dpr = window.devicePixelRatio || 1;
W = window.innerWidth; H = window.innerHeight;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
canvas.style.width = W + 'px';
canvas.style.height = H + 'px';
needsRender = true;
}
window.addEventListener('resize', resize);
/* ================================================================
4. COORDS & VIEWPORT
================================================================ */
function toWorld(sx, sy){ return { x: (sx - view.x) / view.z, y: (sy - view.y) / view.z }; }
function toScreen(wx, wy){ return { x: wx * view.z + view.x, y: wy * view.z + view.y }; }
function bboxToScreen(b){
const p1 = toScreen(b.x, b.y), p2 = toScreen(b.x + b.w, b.y + b.h);
return { x: p1.x, y: p1.y, w: p2.x - p1.x, h: p2.y - p1.y };
}
function clamp(v, lo, hi){ return Math.max(lo, Math.min(hi, v)); }
function evtPos(e){
const r = canvas.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function zoomAt(sx, sy, factor){
const nz = clamp(view.z * factor, CFG.zoomMin, CFG.zoomMax);
if (nz === view.z) return;
const f = nz / view.z;
view.x = sx - (sx - view.x) * f; // keep world point under cursor fixed
view.y = sy - (sy - view.y) * f;
view.z = nz;
updateZoomBar();
needsRender = true;
scheduleSave();
}
function resetView(){
if (!scene.length){ view = { x: 0, y: 0, z: 1 }; }
else {
const bb = unionBBox(scene.map(getBBox));
const bw = Math.max(bb.w, 10), bh = Math.max(bb.h, 10);
const z = clamp(Math.min((W - 160) / bw, (H - 160) / bh), CFG.zoomMin, 1);
view.z = z;
view.x = (W - bw * z) / 2 - bb.x * z;
view.y = (H - bh * z) / 2 - bb.y * z;
}
updateZoomBar();
needsRender = true;
scheduleSave();
}
function updateZoomBar(){ zoomPct.textContent = Math.round(view.z * 100) + '%'; }
/* ================================================================
5. GEOMETRY: BBOX, DISTANCE, HIT-TESTING (world coords)
================================================================ */
function textWidth(s){
const key = s.text + '|' + s.fontSize;
if (s.__w !== undefined && s.__wk === key) return s.__w;
measureCtx.font = s.fontSize + 'px ' + CFG.textFont;
let w = 0;
for (const line of s.text.split('\n')) w = Math.max(w, measureCtx.measureText(line || ' ').width);
s.__w = w; s.__wk = key;
return w;
}
function getBBox(s){
switch (s.type){
case 'rect': case 'ellipse':
return { x: s.x, y: s.y, w: s.w, h: s.h };
case 'line': {
const x = Math.min(s.x1, s.x2), y = Math.min(s.y1, s.y2);
return { x, y, w: Math.abs(s.x2 - s.x1), h: Math.abs(s.y2 - s.y1) };
}
case 'stroke': {
let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;
for (const p of s.points){
if (p.x < x1) x1 = p.x; if (p.x > x2) x2 = p.x;
if (p.y < y1) y1 = p.y; if (p.y > y2) y2 = p.y;
}
return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 };
}
case 'text': {
const lines = s.text.split('\n').length;
return { x: s.x, y: s.y, w: textWidth(s), h: lines * s.fontSize * CFG.textLineHeight };
}
}
return { x: 0, y: 0, w: 0, h: 0 };
}
function unionBBox(boxes){
let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;
for (const b of boxes){
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 distToSegment(p, a, b){
const dx = b.x - a.x, dy = b.y - a.y;
const l2 = dx * dx + dy * dy;
if (l2 === 0) return Math.hypot(p.x - a.x, p.y - a.y);
const t = clamp(((p.x - a.x) * dx + (p.y - a.y) * dy) / l2, 0, 1);
return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
}
function distToPolyline(p, pts){
let d = Infinity;
for (let i = 0; i < pts.length - 1; i++) d = Math.min(d, distToSegment(p, pts[i], pts[i + 1]));
return d;
}
function distToRectBorder(p, b){
const dx = p.x < b.x ? b.x - p.x : p.x > b.x + b.w ? p.x - (b.x + b.w) : 0;
const dy = p.y < b.y ? b.y - p.y : p.y > b.y + b.h ? p.y - (b.y + b.h) : 0;
return Math.hypot(dx, dy);
}
function insideBBox(p, b){
return p.x >= b.x && p.x <= b.x + b.w && p.y >= b.y && p.y <= b.y + b.h;
}
/* Point-near-path / proper inside+border tests, in world coordinates. */
function hitTest(s, p, tol){
const bb = getBBox(s);
const pad = tol + (s.strokeWidth || 0) / 2;
if (p.x < bb.x - pad || p.y < bb.y - pad || p.x > bb.x + bb.w + pad || p.y > bb.y + bb.h + pad) return false;
switch (s.type){
case 'rect':
if (s.fill && s.fill !== 'none') return insideBBox(p, bb);
return distToRectBorder(p, bb) < tol + (s.strokeWidth || 0) / 2;
case 'ellipse': {
const cx = bb.x + bb.w / 2, cy = bb.y + bb.h / 2, rx = bb.w / 2, ry = bb.h / 2;
if (rx < 1e-6 || ry < 1e-6) return false;
const d = Math.sqrt(((p.x - cx) / rx) ** 2 + ((p.y - cy) / ry) ** 2);
if (s.fill && s.fill !== 'none') return d <= 1;
return Math.abs(d - 1) * Math.min(rx, ry) < tol + (s.strokeWidth || 0) / 2;
}
case 'line':
return distToSegment(p, { x: s.x1, y: s.y1 }, { x: s.x2, y: s.y2 }) < tol + (s.strokeWidth || 0) / 2;
case 'stroke':
return distToPolyline(p, s.points) < tol + (s.strokeWidth || 0) / 2;
case 'text': {
const pad2 = tol + 2;
return p.x >= bb.x - pad2 && p.x <= bb.x + bb.w + pad2 && p.y >= bb.y - pad2 && p.y <= bb.y + bb.h + pad2;
}
}
return false;
}
/* Topmost-first pick. */
function pick(p, tol){
for (let i = scene.length - 1; i >= 0; i--) if (hitTest(scene[i], p, tol)) return scene[i];
return null;
}
function uid(){ return 's' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8); }
/* ================================================================
6. HISTORY (snapshot-based; one step per gesture)
================================================================ */
function snapshot(){ return JSON.parse(JSON.stringify(scene)); }
function pushHistory(before, coalesceKey){
const now = performance.now();
if (coalesceKey && lastCoalesce && lastCoalesce.key === coalesceKey && now - lastCoalesce.t < CFG.coalesceMs){
lastCoalesce.t = now;
return;
}
lastCoalesce = coalesceKey ? { key: coalesceKey, t: now } : null;
past.push(before);
if (past.length > CFG.historyLimit) past.shift();
future.length = 0;
updateHistoryButtons();
scheduleSave();
}
function undo(){
if (!past.length) return;
future.push(snapshot());
scene = past.pop();
afterHistoryChange();
}
function redo(){
if (!future.length) return;
past.push(snapshot());
scene = future.pop();
afterHistoryChange();
}
function afterHistoryChange(){
const ids = new Set(scene.map(s => s.id));
selection = new Set([...selection].filter(id => ids.has(id)));
updateHistoryButtons();
syncStyleBar();
needsRender = true;
scheduleSave();
}
function updateHistoryButtons(){
undoBtn.disabled = !past.length;
redoBtn.disabled = !future.length;
}
/* ================================================================
7. PERSISTENCE (localStorage)
================================================================ */
let saveTimer = null;
function save(){
try{
scene.forEach(s => { delete s.__w; delete s.__wk; });
localStorage.setItem(CFG.storageKey, JSON.stringify(scene));
localStorage.setItem(CFG.viewKey, JSON.stringify(view));
}catch(err){ console.warn('save failed', err); }
}
function scheduleSave(){
clearTimeout(saveTimer);
saveTimer = setTimeout(save, 250);
}
function load(){
try{
const raw = localStorage.getItem(CFG.storageKey);
if (raw){
const arr = JSON.parse(raw);
if (Array.isArray(arr)) scene = arr.filter(s => s && typeof s.id === 'string' && TYPES.has(s.type));
}
const rv = localStorage.getItem(CFG.viewKey);
if (rv){
const v = JSON.parse(rv);
if (v && isFinite(v.z)) view = { x: +v.x || 0, y: +v.y || 0, z: clamp(+v.z, CFG.zoomMin, CFG.zoomMax) };
}
}catch(err){ console.warn('load failed', err); }
}
window.addEventListener('beforeunload', save);
</script>
<script>
'use strict';
/* ================================================================
8. RENDERING
================================================================ */
function setStroke(s){
ctx.strokeStyle = s.stroke;
ctx.fillStyle = s.fill && s.fill !== 'none' ? s.fill : 'transparent';
ctx.lineWidth = (s.strokeWidth || 1) * view.z;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
}
function strokeSmooth(pts){
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
if (pts.length === 2){
ctx.lineTo(pts[1].x, pts[1].y);
} else {
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();
}
function drawArrowHead(x1, y1, x2, y2, width){
const a = Math.atan2(y2 - y1, x2 - x1);
const len = Math.max(9 * view.z, width * 2.4);
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - len * Math.cos(a - 0.45), y2 - len * Math.sin(a - 0.45));
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - len * Math.cos(a + 0.45), y2 - len * Math.sin(a + 0.45));
ctx.stroke();
}
function renderShape(s){
setStroke(s);
switch (s.type){
case 'rect':
ctx.beginPath();
ctx.rect(s.x, s.y, s.w, s.h);
if (s.fill && s.fill !== 'none') ctx.fill();
ctx.stroke();
break;
case 'ellipse':
ctx.beginPath();
ctx.ellipse(s.x + s.w / 2, s.y + s.h / 2, Math.max(s.w / 2, 0.01), Math.max(s.h / 2, 0.01), 0, 0, TAU);
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();
drawArrowHead(s.x1, s.y1, s.x2, s.y2, s.strokeWidth || 1);
break;
case 'stroke':
if (s.points.length < 2){
ctx.beginPath();
ctx.arc(s.points[0].x, s.points[0].y, (s.strokeWidth || 1) / 2, 0, TAU);
ctx.fillStyle = s.stroke;
ctx.fill();
} else strokeSmooth(s.points);
break;
case 'text': {
ctx.fillStyle = s.stroke;
ctx.font = s.fontSize + 'px ' + CFG.textFont;
ctx.textBaseline = 'top';
const lh = s.fontSize * CFG.textLineHeight;
let ty = s.y;
for (const line of s.text.split('\n')){
ctx.fillText(line || ' ', s.x, ty);
ty += lh;
}
break;
}
}
}
function drawGrid(){
let step = CFG.gridSize;
while (step * view.z < 14) step *= 2;
while (step * view.z > 64) step /= 2;
const sp = step * view.z;
const ox = ((view.x % sp) + sp) % sp;
const oy = ((view.y % sp) + sp) % sp;
ctx.fillStyle = CFG.colors.grid;
const dot = Math.max(1, view.z * 1.2);
for (let x = ox; x < W; x += sp)
for (let y = oy; y < H; y += sp)
ctx.fillRect(x, y, dot, dot);
}
/* Corner resize handles, drawn in screen space so they stay crisp at any zoom. */
function drawHandles(bb){
const hw = CFG.handleSize;
const c = [
{ x: bb.x, y: bb.y }, { x: bb.x + bb.w, y: bb.y },
{ x: bb.x, y: bb.y + bb.h }, { x: bb.x + bb.w, y: bb.y + bb.h },
];
ctx.fillStyle = CFG.colors.handleFill;
ctx.strokeStyle = CFG.colors.selection;
ctx.lineWidth = 1.5;
for (const p of c){
ctx.beginPath();
ctx.rect(p.x - hw / 2, p.y - hw / 2, hw, hw);
ctx.fill();
ctx.stroke();
}
}
function render(){
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = CFG.colors.canvas;
ctx.fillRect(0, 0, W, H);
drawGrid();
ctx.setTransform(dpr * view.z, 0, 0, dpr * view.z, dpr * view.x, dpr * view.y);
for (const s of scene) if (!(editing && s.id === editing.id)) renderShape(s);
/* selection outline */
const sel = scene.filter(s => selection.has(s.id));
if (sel.length){
const bb = bboxToScreen(unionBBox(sel.map(getBBox)));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.strokeStyle = CFG.colors.selection;
ctx.lineWidth = 1.5;
ctx.strokeRect(bb.x - 1.5, bb.y - 1.5, bb.w + 3, bb.h + 3);
drawHandles(bb);
}
/* rubber band */
if (gesture && gesture.type === 'rubber'){
const a = gesture.start, b = gesture.cur;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = CFG.colors.rubber + '22';
ctx.strokeStyle = CFG.colors.rubber;
ctx.lineWidth = 1;
const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y);
const w = Math.abs(b.x - a.x), h = Math.abs(b.y - a.y);
ctx.fillRect(x, y, w, h);
ctx.strokeRect(x, y, w, h);
}
/* live creation preview */
if (gesture && gesture.type === 'create' && gesture.shape) renderShape(gesture.shape);
/* hover outline for select tool */
if (mouse.in && !gesture && !spaceDown && tool === 'select' && !editing){
const p = toWorld(mouse.x, mouse.y);
const h = pick(p, CFG.hitTol / view.z);
if (h){
const bb = bboxToScreen(getBBox(h));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.strokeStyle = CFG.colors.selection + '99';
ctx.lineWidth = 1.25;
ctx.strokeRect(bb.x - 1, bb.y - 1, bb.w + 2, bb.h + 2);
}
}
}
function frame(){
if (needsRender){
needsRender = false;
render();
}
requestAnimationFrame(frame);
}
</script>
<script>
'use strict';
/* ================================================================
9. TOOLS & CURSORS
================================================================ */
const toolButtons = Array.from(document.querySelectorAll('#toolbar [data-tool]'));
function setTool(t){
tool = t;
toolButtons.forEach(b => b.classList.toggle('active', b.dataset.tool === t));
updateCursor();
needsRender = true;
}
function updateCursor(){
if (editing){ canvas.style.cursor = 'default'; return; }
if (spaceDown){ canvas.style.cursor = gesture ? 'grabbing' : 'grab'; return; }
switch (tool){
case 'select': canvas.style.cursor = 'default'; break;
case 'pen': canvas.style.cursor = 'crosshair'; break;
case 'rect': case 'ellipse': case 'line': canvas.style.cursor = 'crosshair'; break;
case 'text': canvas.style.cursor = 'text'; break;
case 'erase': canvas.style.cursor = 'cell'; break;
}
}
toolButtons.forEach(b => b.addEventListener('click', () => setTool(b.dataset.tool)));
undoBtn.addEventListener('click', e => { undo(); e.currentTarget.blur(); });
redoBtn.addEventListener('click', e => { redo(); e.currentTarget.blur(); });
document.getElementById('zoomIn').addEventListener('click', e => { zoomAt(W / 2, H / 2, 1.25); e.currentTarget.blur(); });
document.getElementById('zoomOut').addEventListener('click', e => { zoomAt(W / 2, H / 2, 0.8); e.currentTarget.blur(); });
document.getElementById('zoomReset').addEventListener('click', e => { resetView(); e.currentTarget.blur(); });
zoomPct.addEventListener('click', e => { resetView(); e.currentTarget.blur(); });
/* ================================================================
10. STYLE BAR
================================================================ */
function makeSwatches(containerId, colors, kind){
const box = document.getElementById(containerId);
const btns = [];
for (const c of colors){
const b = document.createElement('button');
b.className = 'swatch';
b.style.background = c;
b.title = c;
b.addEventListener('click', () => applyStyle({ [kind]: c }));
box.appendChild(b);
btns.push({ el: b, val: c });
}
return btns;
}
const strokeSwatches = makeSwatches('strokeSw', CFG.palette.strokes, 'stroke');
const fillSwatches = makeSwatches('fillSw', CFG.palette.fills, 'fill');
const widthBtns = [];
{
const box = document.getElementById('widths');
for (const w of CFG.palette.widths){
const b = document.createElement('button');
b.className = 'wbtn';
b.title = 'Width ' + w;
const line = document.createElement('span');
line.className = 'wline';
line.style.height = Math.max(1.5, w * 0.9) + 'px';
b.appendChild(line);
b.addEventListener('click', () => applyStyle({ strokeWidth: w }));
box.appendChild(b);
widthBtns.push({ el: b, val: w });
}
}
const NONE = { el: null, val: 'none' };
function syncStyleBar(){
const sel = scene.filter(s => selection.has(s.id));
const showSel = sel.length > 0;
const stroke = showSel ? sel[0].stroke : current.stroke;
const fill = showSel ? sel[0].fill : current.fill;
const width = showSel ? sel[0].strokeWidth : current.strokeWidth;
strokeSwatches.forEach(x => x.el.classList.toggle('active', x.val === stroke));
fillSwatches.forEach(x => x.el.classList.toggle('active', x.val === fill));
widthBtns.forEach(x => x.el.classList.toggle('active', x.val === width));
}
function applyStyle(props){
const sel = scene.filter(s => selection.has(s.id));
if (sel.length){
const before = snapshot();
sel.forEach(s => Object.assign(s, props));
pushHistory(before);
} else {
Object.assign(current, props);
}
syncStyleBar();
needsRender = true;
}
/* ================================================================
11. INLINE TEXT EDITING
================================================================ */
function editorFontPx(){
const z = view.z;
return Math.max(11, Math.round(20 * z));
}
function layoutEditor(id, worldX, worldY, fontSizeWorld, text){
const p = toScreen(worldX, worldY);
const size = Math.max(CFG.textMinFont, fontSizeWorld) * view.z;
const pad = 2 * view.z;
const lh = size * CFG.textLineHeight;
const lines = (text || ' ').split('\n');
measureCtx.font = size + 'px ' + CFG.textFont;
let w = 0;
for (const line of lines) w = Math.max(w, measureCtx.measureText(line || ' ').width);
const wpx = Math.max(w + pad * 2 + 8, 60);
const hpx = Math.max(lines.length * lh + pad * 2 + 6, 26);
editor.style.left = (p.x - pad) + 'px';
editor.style.top = (p.y - pad) + 'px';
editor.style.width = wpx + 'px';
editor.style.height = hpx + 'px';
editor.style.fontSize = size + 'px';
}
function beginTextEdit(id, isNew, before){
const s = scene.find(x => x.id === id);
if (!s || s.type !== 'text') return;
editing = { id, isNew, before: before !== undefined ? before : (isNew ? null : snapshot()) };
editor.value = s.text;
layoutEditor(id, s.x, s.y, s.fontSize, s.text);
editor.style.display = 'block';
editor.focus();
editor.select();
selection = new Set([id]);
updateCursor();
needsRender = true;
}
function commitTextEdit(keepEmpty){
if (!editing) return;
const s = scene.find(x => x.id === editing.id);
const ed = editing;
editing = null;
editor.style.display = 'none';
let removed = false;
if (s){
const val = editor.value.replace(/\s+$/, '');
if (ed.isNew && val.trim() === '' && !keepEmpty){
// discard empty new text
const i = scene.findIndex(x => x.id === s.id);
if (i >= 0) scene.splice(i, 1);
removed = true;
} else {
s.text = val === '' ? ' ' : val;
if (ed.before) pushHistory(ed.before);
}
}
selection = new Set(removed ? [] : s ? [s.id] : []);
updateCursor();
syncStyleBar();
needsRender = true;
scheduleSave();
}
editor.addEventListener('input', () => {
if (!editing) return;
const s = scene.find(x => x.id === editing.id);
if (s) layoutEditor(s.id, s.x, s.y, s.fontSize, editor.value || ' ');
needsRender = true;
});
editor.addEventListener('keydown', e => {
e.stopPropagation();
if (e.key === 'Escape'){ e.preventDefault(); commitTextEdit(false); }
else if (e.key === 'Enter' && !e.shiftKey){ e.preventDefault(); commitTextEdit(false); }
});
editor.addEventListener('blur', () => commitTextEdit(false));
function startNewText(worldP){
const s = {
id: uid(), type: 'text',
x: worldP.x, y: worldP.y - CFG.textDefaultFont * 0.5,
text: ' ', fontSize: CFG.textDefaultFont,
stroke: current.stroke, fill: 'none', strokeWidth: 0,
};
const before = snapshot();
scene.push(s);
beginTextEdit(s.id, true, before);
}
/* ================================================================
12. GEOMETRY OPS: MOVE / RESIZE / ERASE
================================================================ */
function moveSelectedBy(dx, dy){
if (!selection.size) return;
const before = snapshot();
for (const s of scene) if (selection.has(s.id)) translateShape(s, dx, dy);
pushHistory(before, 'nudge');
needsRender = true;
}
function translateShape(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 'stroke': for (const p of s.points){ p.x += dx; p.y += dy; } break;
}
}
function handleAt(screenP){
if (!selection.size) return null;
const bb = bboxToScreen(unionBBox(scene.filter(s => selection.has(s.id)).map(getBBox)));
const h = CFG.handleSize + 3;
const c = [
{ x: bb.x, y: bb.y, ix: 0, iy: 0 },
{ x: bb.x + bb.w, y: bb.y, ix: 1, iy: 0 },
{ x: bb.x, y: bb.y + bb.h, ix: 0, iy: 1 },
{ x: bb.x + bb.w, y: bb.y + bb.h, ix: 1, iy: 1 },
];
for (const p of c)
if (Math.abs(screenP.x - p.x) <= h && Math.abs(screenP.y - p.y) <= h) return { ix: p.ix, iy: p.iy };
return null;
}
function applyResize(g, shift){
const sel = scene.filter(s => selection.has(s.id));
if (!sel.length) return;
const b = unionBBox(sel.map(getBBox));
// convert the screen-space drag into world units so resize is zoom-accurate
const dx = (g.cur.x - g.start.x) / view.z, dy = (g.cur.y - g.start.y) / view.z;
let nx = b.x, ny = b.y, nw = b.w, nh = b.h;
if (g.ix === 1) nw = b.w + dx; else nx = b.x + dx, nw = b.w - dx;
if (g.iy === 1) nh = b.h + dy; else ny = b.y + dy, nh = b.h - dy;
if (shift){
// preserve the original aspect ratio; scale by the larger relative stretch
const sxr = nw / Math.max(b.w, 0.5), syr = nh / Math.max(b.h, 0.5);
const s = Math.max(sxr, syr);
nw = b.w * s; nh = b.h * s;
if (g.ix === 0) nx = b.x + b.w - nw;
if (g.iy === 0) ny = b.y + b.h - nh;
}
if (nw < 0){ if (g.ix === 0) nx = b.x + b.w; nw = -nw; }
if (nh < 0){ if (g.iy === 0) ny = b.y + b.h; nh = -nh; }
nw = Math.max(nw, 0.5); nh = Math.max(nh, 0.5);
const sx = b.w > 0.5 ? nw / b.w : 1, sy = b.h > 0.5 ? nh / b.h : 1;
for (const s of sel){
const sb = getBBox(s);
const scx = sb.x + sb.w / 2, scy = sb.y + sb.h / 2;
const sw = Math.max(sb.w, 0.5), sh = Math.max(sb.h, 0.5);
const tx = nx + (scx - b.x) * sx - sw * sx / 2;
const ty = ny + (scy - b.y) * sy - sh * sy / 2;
const dxs = tx - sb.x, dys = ty - sb.y;
translateShape(s, dxs, dys);
if (s.type === 'rect' || s.type === 'ellipse'){
s.x = tx; s.y = ty; s.w = sw * sx; s.h = sh * sy;
} else if (s.type === 'line'){
s.x1 = (s.x1 - sb.x) * sx + nx; s.y1 = (s.y1 - sb.y) * sy + ny;
s.x2 = (s.x2 - sb.x) * sx + nx; s.y2 = (s.y2 - sb.y) * sy + ny;
} else if (s.type === 'stroke'){
for (const p of s.points){
p.x = (p.x - sb.x) * sx + nx;
p.y = (p.y - sb.y) * sy + ny;
}
} else if (s.type === 'text'){
s.x = tx; s.y = ty;
s.fontSize = Math.max(CFG.textMinFont, s.fontSize * Math.min(sx, sy));
}
}
}
function eraseAt(worldP, tol){
const hit = pick(worldP, tol);
if (hit) return hit.id;
return null;
}
/* ================================================================
13. POINTER GESTURES
================================================================ */
function startPan(p){
gesture = { type: 'pan', start: p, vx: view.x, vy: view.y, moved: false };
updateCursor();
}
function startShapeDrag(p, shift){
if (!selection.size) return; // empty click: no gesture (deselect on up)
// selection is already set by onPointerDown (new single or preserved multi);
// snapshot every selected shape so a multi-drag is one undoable step.
gesture = {
type: 'move', start: p, moved: false, shift,
before: snapshot(),
orig: new Map(scene.filter(s => selection.has(s.id)).map(s => [s.id, JSON.parse(JSON.stringify(s))])),
};
}
function startRubber(p){
gesture = { type: 'rubber', start: p, cur: p };
}
function startCreate(p){
const wp = toWorld(p.x, p.y);
gesture = { type: 'create', start: p, startW: wp, shape: null, moved: false };
}
function startStroke(p){
const wp = toWorld(p.x, p.y);
const s = {
id: uid(), type: 'stroke',
points: [{ x: wp.x, y: wp.y }],
stroke: current.stroke, fill: 'none', strokeWidth: current.strokeWidth,
};
gesture = { type: 'stroke', start: p, shape: s, before: snapshot() };
}
function startErase(p){
const wp = toWorld(p.x, p.y);
gesture = { type: 'erase', start: p, before: snapshot(), removed: [], removedIds: new Set() };
const id = eraseAt(wp, CFG.hitTol / view.z);
if (id) removeEraseHit(id);
}
function removeEraseHit(id){
const i = scene.findIndex(s => s.id === id);
if (i >= 0){
const [s] = scene.splice(i, 1);
gesture.removed.push(s);
gesture.removedIds.add(id);
selection.delete(id);
}
}
function onPointerDown(e){
if (editing) return; // editor overlay handles its own events
canvas.focus();
const p = evtPos(e);
mouse.x = p.x; mouse.y = p.y; mouse.in = true;
if (e.button === 1 || spaceDown){
e.preventDefault();
startPan(p);
return;
}
if (e.button !== 0) return;
switch (tool){
case 'select': {
const h = handleAt(p);
if (h && selection.size){
gesture = { type: 'resize', start: p, ix: h.ix, iy: h.iy, cur: p, before: snapshot() };
return;
}
const wp = toWorld(p.x, p.y);
const hit = pick(wp, CFG.hitTol / view.z);
if (hit){
if (e.shiftKey){
if (selection.has(hit.id)) selection.delete(hit.id);
else selection.add(hit.id);
needsRender = true;
return; // shift-click: toggle only
}
if (!selection.has(hit.id)) selection = new Set([hit.id]);
startShapeDrag(p, e.shiftKey);
return;
}
if (e.shiftKey){
selection = new Set();
}
startRubber(p);
return;
}
case 'pen': startStroke(p); break;
case 'rect': case 'ellipse': case 'line': startCreate(p); break;
case 'text': {
const wp = toWorld(p.x, p.y);
startNewText(wp);
return;
}
case 'erase': startErase(p); break;
}
needsRender = true;
}
function onPointerMove(e){
const p = evtPos(e);
mouse.x = p.x; mouse.y = p.y; mouse.in = true;
if (!gesture){
if (spaceDown || e.buttons === 4) updateCursor();
else {
if (tool === 'select' && !editing) updateCursorForHover(p);
needsRender = true;
}
return;
}
switch (gesture.type){
case 'pan':
view.x = gesture.vx + (p.x - gesture.start.x);
view.y = gesture.vy + (p.y - gesture.start.y);
gesture.moved = true;
needsRender = true;
break;
case 'move': {
const dx = (p.x - gesture.start.x) / view.z;
const dy = (p.y - gesture.start.y) / view.z;
if (!gesture.moved && Math.hypot(p.x - gesture.start.x, p.y - gesture.start.y) < 2) break;
gesture.moved = true;
for (const s of scene){
if (!selection.has(s.id)) continue;
const o = gesture.orig.get(s.id);
if (!o) continue;
if (s.type === 'rect' || s.type === 'ellipse' || s.type === 'text'){
s.x = o.x + dx; s.y = o.y + dy;
} else if (s.type === 'line'){
s.x1 = o.x1 + dx; s.y1 = o.y1 + dy; s.x2 = o.x2 + dx; s.y2 = o.y2 + dy;
} else if (s.type === 'stroke'){
for (let i = 0; i < s.points.length; i++){
s.points[i].x = o.points[i].x + dx;
s.points[i].y = o.points[i].y + dy;
}
}
}
needsRender = true;
break;
}
case 'rubber':
gesture.cur = p;
needsRender = true;
break;
case 'create': {
const wp = toWorld(p.x, p.y);
if (!gesture.moved && Math.hypot(p.x - gesture.start.x, p.y - gesture.start.y) < 3) break;
gesture.moved = true;
const g = gesture, a = g.startW, b = wp;
if (tool === 'rect' || tool === 'ellipse'){
g.shape = {
id: g._id || (g._id = uid()), type: tool,
x: Math.min(a.x, b.x), y: Math.min(a.y, b.y),
w: Math.abs(b.x - a.x), h: Math.abs(b.y - a.y),
stroke: current.stroke, fill: current.fill, strokeWidth: current.strokeWidth,
};
} else if (tool === 'line'){
g.shape = {
id: g._id || (g._id = uid()), type: 'line',
x1: a.x, y1: a.y, x2: b.x, y2: b.y,
stroke: current.stroke, fill: 'none', strokeWidth: current.strokeWidth,
};
}
needsRender = true;
break;
}
case 'stroke': {
const wp = toWorld(p.x, p.y);
const pts = gesture.shape.points;
const last = pts[pts.length - 1];
if (Math.hypot(wp.x - last.x, wp.y - last.y) > 0.75 / view.z) pts.push({ x: wp.x, y: wp.y });
needsRender = true;
break;
}
case 'erase': {
const wp = toWorld(p.x, p.y);
const id = eraseAt(wp, CFG.hitTol / view.z);
if (id) removeEraseHit(id);
needsRender = true;
break;
}
case 'resize':
gesture.cur = p;
applyResize(gesture, e.shiftKey);
needsRender = true;
break;
}
}
function onPointerUp(e){
const p = evtPos(e);
if (!gesture){ return; }
const g = gesture;
gesture = null;
switch (g.type){
case 'pan':
if (g.moved) scheduleSave();
updateCursor();
break;
case 'move':
if (g.moved) pushHistory(g.before);
else if (e.shiftKey) { /* shift-click toggle already handled */ }
else {
const wp = toWorld(p.x, p.y);
const hit = pick(wp, CFG.hitTol / view.z);
if (!hit) selection = new Set();
}
syncStyleBar();
needsRender = true;
break;
case 'rubber': {
const a = g.start, b = g.cur;
const x1 = Math.min(a.x, b.x), y1 = Math.min(a.y, b.y);
const x2 = Math.max(a.x, b.x), y2 = Math.max(a.y, b.y);
if (x2 - x1 > 4 || y2 - y1 > 4){
const w1 = toWorld(x1, y1), w2 = toWorld(x2, y2);
const before = snapshot();
const ids = scene.filter(s => {
const bb = getBBox(s);
return !(bb.x > w2.x || bb.x + bb.w < w1.x || bb.y > w2.y || bb.y + bb.h < w1.y);
}).map(s => s.id);
selection = new Set(ids);
pushHistory(before);
} else {
// plain click on empty space: deselect
selection = new Set();
}
syncStyleBar();
needsRender = true;
break;
}
case 'create': {
const s = g.shape;
if (s && (s.w > CFG.minShapeSize || s.h > CFG.minShapeSize ||
(s.type === 'line' && Math.hypot(s.x2 - s.x1, s.y2 - s.y1) > CFG.minShapeSize))){
const before = snapshot();
scene.push(s);
pushHistory(before);
selection = new Set([s.id]);
syncStyleBar();
}
needsRender = true;
break;
}
case 'stroke': {
const s = g.shape;
if (s.points.length > 1){
scene.push(s);
pushHistory(g.before);
selection = new Set([s.id]);
syncStyleBar();
}
needsRender = true;
break;
}
case 'erase':
if (g.removed.length){
pushHistory(g.before);
syncStyleBar();
}
needsRender = true;
break;
case 'resize':
pushHistory(g.before);
syncStyleBar();
needsRender = true;
break;
}
}
function updateCursorForHover(p){
const wp = toWorld(p.x, p.y);
const hit = pick(wp, CFG.hitTol / view.z);
if (hit){
if (selection.has(hit.id) && handleAt(p)) canvas.style.cursor = 'nwse-resize';
else canvas.style.cursor = 'move';
} else canvas.style.cursor = 'default';
}
canvas.addEventListener('pointerdown', onPointerDown);
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
canvas.addEventListener('pointerleave', () => { mouse.in = false; needsRender = true; });
canvas.addEventListener('dblclick', e => {
if (tool !== 'select') return;
const p = evtPos(e);
const wp = toWorld(p.x, p.y);
const hit = pick(wp, CFG.hitTol / view.z);
if (hit && hit.type === 'text'){
selection = new Set([hit.id]);
beginTextEdit(hit.id, false);
} else if (!hit){
startNewText(wp);
}
});
canvas.addEventListener('wheel', e => {
e.preventDefault();
const p = evtPos(e);
const factor = Math.exp(-e.deltaY * (e.ctrlKey ? 0.008 : 0.0016));
zoomAt(p.x, p.y, factor);
}, { passive: false });
/* ================================================================
14. KEYBOARD
================================================================ */
window.addEventListener('keydown', e => {
if (editing){
if (e.key === 'Escape' || (e.key === 'Enter' && !e.shiftKey)) {
/* editor keydown handler commits; stop default here */
}
return;
}
const k = e.key;
if (k === ' '){
if (!spaceDown){ spaceDown = true; updateCursor(); }
e.preventDefault();
return;
}
const meta = e.ctrlKey || e.metaKey;
if (meta && (k === 'z' || k === 'Z')){
e.preventDefault();
if (e.shiftKey) redo(); else undo();
return;
}
if (meta && (k === 'y' || k === 'Y')){ e.preventDefault(); redo(); return; }
if (meta) return;
switch (k){
case 'v': case 'V': setTool('select'); return;
case 'p': case 'P': setTool('pen'); return;
case 'r': case 'R': setTool('rect'); return;
case 'o': case 'O': setTool('ellipse'); return;
case 'l': case 'L': setTool('line'); return;
case 't': case 'T': setTool('text'); return;
case 'e': case 'E': setTool('erase'); return;
case 'Delete': case 'Backspace':
if (selection.size){
e.preventDefault();
const before = snapshot();
scene = scene.filter(s => !selection.has(s.id));
selection = new Set();
pushHistory(before);
syncStyleBar();
needsRender = true;
}
return;
case 'ArrowLeft': case 'ArrowRight': case 'ArrowUp': case 'ArrowDown':
if (selection.size){
e.preventDefault();
const d = (e.shiftKey ? CFG.nudgeShiftPx : CFG.nudgePx) / view.z;
const dx = k === 'ArrowLeft' ? -d : k === 'ArrowRight' ? d : 0;
const dy = k === 'ArrowUp' ? -d : k === 'ArrowDown' ? d : 0;
moveSelectedBy(dx, dy);
}
return;
case 'Escape':
if (gesture){
// cancel active gesture
if (gesture.type === 'create') gesture.shape = null;
gesture = null;
needsRender = true;
} else selection = new Set();
needsRender = true;
return;
}
});
window.addEventListener('keyup', e => {
if (e.key === ' '){ spaceDown = false; updateCursor(); }
});
window.addEventListener('blur', () => { spaceDown = false; });
/* ================================================================
15. WELCOME CONTENT + INIT
================================================================ */
function welcome(){
scene = [
{ id: uid(), type: 'text', x: 220, y: 70, text: 'Welcome to Board', fontSize: 34, stroke: '#1e1e1e', fill: 'none', strokeWidth: 0 },
{ id: uid(), type: 'text', x: 222, y: 122, text: 'V select · P pen · R rect · O ellipse · L arrow · T text · E eraser', fontSize: 16, stroke: '#6741d9', fill: 'none', strokeWidth: 0 },
{ id: uid(), type: 'rect', x: 220, y: 180, w: 190, h: 120, stroke: '#1971c2', fill: '#a5d8ff', strokeWidth: 2 },
{ id: uid(), type: 'ellipse', x: 460, y: 180, w: 140, h: 120, stroke: '#2f9e44', fill: '#b2f2bb', strokeWidth: 2 },
{ id: uid(), type: 'line', x1: 415, y1: 240, x2: 455, y2: 240, stroke: '#1e1e1e', fill: 'none', strokeWidth: 2 },
{ id: uid(), type: 'stroke', points: [
{ x: 660, y: 200 }, { x: 672, y: 190 }, { x: 686, y: 202 }, { x: 700, y: 188 },
{ x: 714, y: 200 }, { x: 728, y: 188 }, { x: 742, y: 198 }, { x: 756, y: 186 },
{ x: 770, y: 196 }, { x: 784, y: 184 },
], stroke: '#e03131', fill: 'none', strokeWidth: 3 },
];
}
function init(){
resize();
load();
if (!scene.length) welcome();
updateZoomBar();
syncStyleBar();
updateHistoryButtons();
setTool('select');
frame();
}
init();
</script>
</body>
</html>
<!-- agent-meta {"model":"Qwen3.8","provider":"brigid","persona":"full","sessionId":"8e579d07-ce9e-4273-b6d9-2650f9f64a60","tokensIn":4548336,"tokensOut":162887,"tokensTotal":4711223,"cost":0,"turns":53,"toolCalls":52,"failedToolCalls":1,"timestamp":"2026-09-02T00:39:11.925Z"} -->