Whiteboard
Tiel Coder 35B A3B GGUF MTP · 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 44,019 bytes · SHA-256 a8ef6b9e55a3
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Whiteboard</title>
<style>
/* ============================================================
APP CHROME / LAYOUT
============================================================ */
* { box-sizing: border-box; -webkit-user-select: none; user-select: none; }
html, body {
margin: 0; padding: 0; height: 100%; overflow: hidden;
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: #e5e7eb; /* slightly darker app chrome around the canvas */
}
#board {
position: fixed; inset: 0; display: block;
background: #ffffff; /* light canvas */
touch-action: none;
cursor: default;
}
/* ---------- Floating panels ---------- */
.panel {
position: fixed; z-index: 10;
background: #ffffff;
border: 1px solid #e6e8eb;
border-radius: 14px;
box-shadow: 0 6px 24px rgba(15, 23, 42, 0.12), 0 1px 2px rgba(15,23,42,.06);
display: flex; gap: 4px; align-items: center;
padding: 6px;
}
/* ---------- Toolbar (top center) ---------- */
#toolbar { top: 14px; left: 50%; transform: translateX(-50%); }
.tool-btn {
width: 40px; height: 40px; border: none; background: transparent;
border-radius: 10px; cursor: pointer; display: flex; align-items: center; justify-content: center;
color: #334155; transition: background .12s, color .12s;
}
.tool-btn:hover { background: #f1f5f9; }
.tool-btn.active { background: #2563eb; color: #fff; }
.tool-btn svg { width: 20px; height: 20px; }
.tool-btn .kbd {
position: absolute; font-size: 9px; color: #94a3b8; bottom: 2px; right: 4px;
}
.tool-btn { position: relative; }
.sep { width: 1px; height: 24px; background: #e6e8eb; margin: 0 4px; }
.icon-btn {
width: 36px; height: 36px; border: none; background: transparent;
border-radius: 9px; cursor: pointer; color: #475569;
display: flex; align-items: center; justify-content: center;
}
.icon-btn:hover { background: #f1f5f9; }
.icon-btn:disabled { opacity: .35; cursor: default; }
.icon-btn:disabled:hover { background: transparent; }
.icon-btn svg { width: 18px; height: 18px; }
/* ---------- Properties bar (bottom center) ---------- */
#props { bottom: 16px; left: 50%; transform: translateX(-50%); gap: 10px; }
.props-group { display: flex; align-items: center; gap: 5px; padding: 0 6px; border-right: 1px solid #eef0f3; }
.props-group:last-child { border-right: none; padding-right: 2px; }
.props-label { font-size: 11px; color: #94a3b8; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; }
.swatch {
width: 22px; height: 22px; border-radius: 6px; border: 2px solid transparent;
cursor: pointer; padding: 0; transition: transform .08s;
}
.swatch:hover { transform: scale(1.08); }
.swatch.active { border-color: #2563eb; }
.swatch.transparent {
background:
linear-gradient(45deg, #cbd5e1 25%, transparent 25%) 0 0/6px 6px,
linear-gradient(-45deg, #cbd5e1 25%, transparent 25%) 0 0/6px 6px,
linear-gradient(45deg, transparent 75%, #cbd5e1 75%) 0 0/6px 6px,
linear-gradient(-45deg, transparent 75%, #cbd5e1 75%) 0 0/6px 6px;
background-color: #fff;
}
.width-btn {
width: 34px; height: 34px; border: none; background: transparent; border-radius: 8px;
cursor: pointer; display: flex; align-items: center; justify-content: center;
}
.width-btn:hover { background: #f1f5f9; }
.width-btn.active { background: #eff6ff; }
.dot { border-radius: 50%; background: #334155; }
/* ---------- Zoom control (bottom right) ---------- */
#zoom { bottom: 16px; right: 16px; gap: 2px; padding: 4px; }
.zoom-val { min-width: 56px; text-align: center; font-size: 13px; font-variant-numeric: tabular-nums; color: #334155; font-weight: 600; }
.zoom-btn {
min-width: 32px; height: 32px; border: none; background: transparent; color: #334155;
border-radius: 8px; cursor: pointer; font-size: 16px; line-height: 1;
}
.zoom-btn:hover { background: #f1f5f9; }
/* ---------- Help hint ---------- */
#hint {
position: fixed; top: 14px; left: 16px; z-index: 9;
color: #94a3b8; font-size: 11.5px; line-height: 1.6;
background: rgba(255,255,255,.85); backdrop-filter: blur(4px);
padding: 10px 14px; border-radius: 10px; border: 1px solid #eef0f3;
pointer-events: none; max-width: 240px;
}
#hint b { color: #64748b; font-weight: 600; }
#hint kbd {
font-family: inherit; font-size: 10px; background: #f1f5f9; border: 1px solid #e2e8f0;
border-radius: 4px; padding: 0 4px; color: #475569;
}
/* ---------- Inline text editor ---------- */
#text-editor {
position: fixed; z-index: 20; margin: 0; padding: 2px 3px;
background: transparent; border: 1px solid #3b82f6; border-radius: 3px;
outline: none; overflow: hidden; white-space: pre;
color: #1e293b; caret-color: #2563eb;
-webkit-user-select: text; user-select: text;
box-shadow: 0 2px 12px rgba(37,99,235,.22);
}
</style>
</head>
<body>
<canvas id="board"></canvas>
<textarea id="text-editor" style="display:none;"></textarea>
<div id="hint">
<b>Whiteboard</b><br>
<kbd>Space</kbd>+drag / middle-drag — pan<br>
Wheel — zoom to cursor<br>
<kbd>V</kbd> select · <kbd>P</kbd> pen · <kbd>R</kbd> rect<br>
<kbd>O</kbd> ellipse · <kbd>L</kbd> line · <kbd>T</kbd> text · <kbd>E</kbd> eraser<br>
<kbd>Ctrl</kbd>+<kbd>Z</kbd> undo · <kbd>Del</kbd> remove
</div>
<div id="toolbar" class="panel">
<button class="tool-btn" data-tool="select" title="Select (V)">
<svg viewBox="0 0 24 24"><path d="M6 3l11 8.5-4.6 1.3-1.9 4.7L6 3z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round" stroke-linecap="round"/></svg>
<span class="kbd">V</span>
</button>
<button class="tool-btn" data-tool="pen" title="Pen (P)">
<svg viewBox="0 0 24 24"><path d="M4 20l1.5-6L16 5.5 18.5 8 12 14.5 6 20z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M14.5 7L17 4.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
<span class="kbd">P</span>
</button>
<button class="tool-btn" data-tool="rect" title="Rectangle (R)">
<svg viewBox="0 0 24 24"><rect x="4" y="5" width="16" height="14" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>
<span class="kbd">R</span>
</button>
<button class="tool-btn" data-tool="ellipse" title="Ellipse (O)">
<svg viewBox="0 0 24 24"><ellipse cx="12" cy="12" rx="8" ry="6" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>
<span class="kbd">O</span>
</button>
<button class="tool-btn" data-tool="line" title="Line / Arrow (L)">
<svg viewBox="0 0 24 24"><line x1="5" y1="19" x2="17.5" y2="6.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="M17.5 6.5l-2.6 .9 .9 2.6 1.7-3.5z" fill="currentColor"/></svg>
<span class="kbd">L</span>
</button>
<button class="tool-btn" data-tool="text" title="Text (T)">
<svg viewBox="0 0 24 24"><path d="M7 5v14M17 5v14M9 8h6M9 16h6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
<span class="kbd">T</span>
</button>
<button class="tool-btn" data-tool="eraser" title="Eraser (E)">
<svg viewBox="0 0 24 24"><path d="M6 20h13.5L21 16.5V6a1 1 0 0 0-1-1H6z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><line x1="9" y1="13" x2="15" y2="13" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
<span class="kbd">E</span>
</button>
<div class="sep"></div>
<button class="icon-btn" id="undo-btn" title="Undo (Ctrl+Z)">
<svg viewBox="0 0 24 24"><path d="M10 9H6a3 3 0 0 0-3 3v1a2 2 0 0 0 2 2h2M10 9l-3-3M10 9l-3 3" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button class="icon-btn" id="redo-btn" title="Redo (Ctrl+Shift+Z)">
<svg viewBox="0 0 24 24"><path d="M14 9h4a3 3 0 0 1 3 3v1a2 2 0 0 1-2 2h-2M14 9l3-3M14 9l3 3" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<div class="sep"></div>
<button class="icon-btn" id="reset-btn" title="Reset view">
<svg viewBox="0 0 24 24"><path d="M4 10a8 8 0 0 1 13.7-5.3L20 6M20 14a8 8 0 0 1-13.7 5.3L4 18" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><path d="M20 6v4h-4M4 18v-4h4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
<div id="props" class="panel">
<div class="props-group" id="stroke-group">
<span class="props-label">Stroke</span>
</div>
<div class="props-group" id="fill-group">
<span class="props-label">Fill</span>
</div>
<div class="props-group" id="width-group">
<span class="props-label">Size</span>
</div>
</div>
<div id="zoom" class="panel">
<button class="zoom-btn" id="zoom-out" title="Zoom out">−</button>
<span class="zoom-val" id="zoom-val">100%</span>
<button class="zoom-btn" id="zoom-in" title="Zoom in">+</button>
</div>
<script>
/* ============================================================
TUNABLE CONSTANTS
============================================================ */
const CONFIG = {
FONT: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
zoom: { min: 0.25, max: 4, step: 0.12 },
hitTolerance: 6, // screen px added around shapes for easier clicking
handleSize: 9, // selection handle size (screen px)
nudge: 1, // px per arrow press
nudgeShift: 10, // px per arrow press with shift
dragThreshold: 3, // screen px before a press counts as a drag
grid: { screenSpacing: 26, dotRadius: 1.1, color: '#d8dde5' },
maxHistory: 200,
palette: [
'#1e293b', // ink
'#2563eb', // blue
'#dc2626', // red
'#16a34a', // green
'#ea580c', // orange
'#7c3aed', // purple
'#0891b2', // cyan
'#db2777', // pink
'#64748b', // slate
'#f59e0b', // amber
],
defaultStroke: '#1e293b',
defaultFill: 'transparent',
defaultWidth: 2,
textFontSize: 18,
};
/* ============================================================
STATE
============================================================ */
const canvas = document.getElementById('board');
const ctx = canvas.getContext('2d');
const measureCtx = document.createElement('canvas').getContext('2d');
measureCtx.font = `18px ${CONFIG.FONT}`;
let scene = []; // array of serializable shape objects
let selectedIds = new Set(); // Set of object ids
let activeTool = 'select';
let viewport = { x: 0, y: 0, zoom: 1 };
let history = []; // array of scene snapshots
let historyIndex = -1;
let spaceDown = false;
let active = null; // current pointer interaction
let preview = null; // shape being created
let textState = null; // inline text editor state
let editorZ = 1; // remember zoom used to size editor (kept in sync)
/* ============================================================
UTILITY
============================================================ */
let uidCounter = Date.now();
function uid() { return 'o' + (++uidCounter); }
function clone(o) { return JSON.parse(JSON.stringify(o)); }
function findObj(id) { return scene.find(o => o.id === id); }
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
// Coordinate transforms (pan/zoom are in CSS px, world in world units)
function screenToWorld(sx, sy) { return { x: (sx - viewport.x) / viewport.zoom, y: (sy - viewport.y) / viewport.zoom }; }
function worldToScreen(wx, wy) { return { x: wx * viewport.zoom + viewport.x, y: wy * viewport.zoom + viewport.y }; }
/* ============================================================
BOUNDS
============================================================ */
function getBounds(o) {
switch (o.type) {
case 'rect':
case 'ellipse':
case 'text':
return { x: o.x, y: o.y, width: o.width, height: o.height };
case 'line':
case 'arrow': {
const x = Math.min(o.x1, o.x2), y = Math.min(o.y1, o.y2);
return { x, y, width: Math.abs(o.x2 - o.x1), height: Math.abs(o.y2 - o.y1) };
}
case 'freehand': {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const p of o.points) { if (p.x < minX) minX = p.x; if (p.y < minY) minY = p.y; if (p.x > maxX) maxX = p.x; if (p.y > maxY) maxY = p.y; }
if (minX === Infinity) return { x: 0, y: 0, width: 0, height: 0 };
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
}
}
return { x: 0, y: 0, width: 0, height: 0 };
}
function getGroupBounds() {
if (selectedIds.size === 0) return null;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const id of selectedIds) {
const o = findObj(id); if (!o) continue;
const b = getBounds(o);
minX = Math.min(minX, b.x); minY = Math.min(minY, b.y);
maxX = Math.max(maxX, b.x + b.width); maxY = Math.max(maxY, b.y + b.height);
}
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
}
function getFirstSelected() {
for (const id of selectedIds) { const o = findObj(id); if (o) return o; }
return null;
}
/* ============================================================
HIT-TESTING (world coordinates)
============================================================ */
function segDist(px, py, ax, ay, bx, by) {
const dx = bx - ax, dy = by - ay;
const len2 = dx * dx + dy * dy;
let t = len2 ? ((px - ax) * dx + (py - ay) * dy) / len2 : 0;
t = clamp(t, 0, 1);
const cx = ax + t * dx, cy = ay + t * dy;
return Math.hypot(px - cx, py - cy);
}
function hitTest(world, obj) {
const tol = CONFIG.hitTolerance / viewport.zoom; // world-space tolerance
const b = getBounds(obj);
switch (obj.type) {
case 'rect':
case 'text':
return world.x >= b.x - tol && world.x <= b.x + b.width + tol &&
world.y >= b.y - tol && world.y <= b.y + b.height + tol;
case 'ellipse': {
const cx = b.x + b.width / 2, cy = b.y + b.height / 2;
const rx = b.width / 2, ry = b.height / 2;
if (rx < 0.001 || ry < 0.001) return false;
const m = (tol / rx) + (tol / ry);
const nx = (world.x - cx) / rx, ny = (world.y - cy) / ry;
return nx * nx + ny * ny <= 1 + m;
}
case 'line':
case 'arrow':
return segDist(world.x, world.y, obj.x1, obj.y1, obj.x2, obj.y2) <= tol + (obj.strokeWidth || 2) / 2;
case 'freehand': {
const pts = obj.points;
if (pts.length === 0) return false;
if (pts.length === 1) return Math.hypot(world.x - pts[0].x, world.y - pts[0].y) <= tol + (pts[0].w || obj.strokeWidth) / 2;
const half = (obj.strokeWidth || 2) / 2;
for (let i = 0; i < pts.length - 1; i++) {
if (segDist(world.x, world.y, pts[i].x, pts[i].y, pts[i + 1].x, pts[i + 1].y) <= tol + half) return true;
}
return false;
}
}
return false;
}
// Topmost object under a world point (last in scene draws on top)
function hitTestWorld(world) {
for (let i = scene.length - 1; i >= 0; i--) {
if (hitTest(world, scene[i])) return scene[i];
}
return null;
}
/* ============================================================
SELECTION HANDLES
============================================================ */
function handleAt(world) {
if (selectedIds.size === 0) return null;
const b = getGroupBounds(); if (!b) return null;
const hs = CONFIG.handleSize / viewport.zoom;
const corners = {
tl: { x: b.x, y: b.y },
tr: { x: b.x + b.width, y: b.y },
bl: { x: b.x, y: b.y + b.height },
br: { x: b.x + b.width, y: b.y + b.height },
};
// Higher-priority (front-most drawn) first
for (const k of ['br', 'bl', 'tr', 'tl']) {
const c = corners[k];
if (Math.hypot(world.x - c.x, world.y - c.y) <= hs) return k;
}
return null;
}
/* ============================================================
RENDERING
============================================================ */
function resizeCanvas() {
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.floor(window.innerWidth * dpr);
canvas.height = Math.floor(window.innerHeight * dpr);
canvas.style.width = window.innerWidth + 'px';
canvas.style.height = window.innerHeight + 'px';
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
render();
}
function drawGrid() {
const w0 = screenToWorld(0, 0);
const w1 = screenToWorld(canvas.width / (window.devicePixelRatio || 1), canvas.height / (window.devicePixelRatio || 1));
// world spacing that keeps dots ~screenSpacing apart, clamped so we never overdraw
let spacing = CONFIG.grid.screenSpacing / viewport.zoom;
// snap to a nice power-of-ten-ish step
const mag = Math.pow(10, Math.floor(Math.log10(spacing)));
const norm = spacing / mag;
if (norm < 2) spacing = 2 * mag; else if (norm < 5) spacing = 5 * mag; else spacing = 10 * mag;
if (spacing < 4) spacing = 4;
const r = CONFIG.grid.dotRadius / viewport.zoom;
const startX = Math.floor(w0.x / spacing) * spacing;
const startY = Math.floor(w0.y / spacing) * spacing;
ctx.fillStyle = CONFIG.grid.color;
ctx.beginPath();
for (let x = startX; x <= w1.x; x += spacing) {
for (let y = startY; y <= w1.y; y += spacing) {
ctx.moveTo(x + r, y);
ctx.arc(x, y, r, 0, Math.PI * 2);
}
}
ctx.fill();
}
function strokeStyle(o) {
if (o.type === 'freehand') return o.fillColor === 'transparent' ? (o.color || CONFIG.defaultStroke) : (o.fillColor);
return o.color === 'transparent' ? (o.fillColor || CONFIG.defaultFill) : (o.color);
}
function drawObject(o) {
const sw = Math.max(0.5, (o.strokeWidth || 2) / viewport.zoom);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
const showFill = o.fillColor && o.fillColor !== 'transparent';
const showStroke = o.color && o.color !== 'transparent';
switch (o.type) {
case 'rect': {
if (showFill) { ctx.fillStyle = o.fillColor; ctx.fillRect(o.x, o.y, o.width, o.height); }
if (showStroke) { ctx.strokeStyle = o.color; ctx.lineWidth = sw; ctx.strokeRect(o.x, o.y, o.width, o.height); }
break;
}
case 'ellipse': {
ctx.beginPath();
ctx.ellipse(o.x + o.width / 2, o.y + o.height / 2, Math.abs(o.width / 2), Math.abs(o.height / 2), 0, 0, Math.PI * 2);
if (showFill) { ctx.fillStyle = o.fillColor; ctx.fill(); }
if (showStroke) { ctx.strokeStyle = o.color; ctx.lineWidth = sw; ctx.stroke(); }
break;
}
case 'line':
case 'arrow': {
ctx.strokeStyle = o.color; ctx.lineWidth = sw;
ctx.beginPath();
ctx.moveTo(o.x1, o.y1); ctx.lineTo(o.x2, o.y2); ctx.stroke();
// arrowhead
const ang = Math.atan2(o.y2 - o.y1, o.x2 - o.x1);
const len = Math.max(8, sw * 5);
ctx.fillStyle = o.color;
ctx.beginPath();
ctx.moveTo(o.x2, o.y2);
ctx.lineTo(o.x2 - len * Math.cos(ang - Math.PI / 7), o.y2 - len * Math.sin(ang - Math.PI / 7));
ctx.lineTo(o.x2 - len * Math.cos(ang + Math.PI / 7), o.y2 - len * Math.sin(ang + Math.PI / 7));
ctx.closePath(); ctx.fill();
break;
}
case 'freehand':
drawFreehand(o);
break;
case 'text':
drawText(o);
break;
}
}
function drawFreehand(o) {
const pts = o.points;
if (pts.length === 0) return;
const fillColor = (o.fillColor && o.fillColor !== 'transparent') ? o.fillColor : (o.color || CONFIG.defaultStroke);
if (pts.length === 1) {
ctx.fillStyle = fillColor;
ctx.beginPath();
ctx.arc(pts[0].x, pts[0].y, (pts[0].w || o.strokeWidth || 2) / 2 / 2, 0, Math.PI * 2);
ctx.fill();
return;
}
// Build a filled ribbon from per-point widths (pressure-smooth-ish).
// Widths are divided by zoom so the stroke keeps a constant on-screen thickness.
const invZoom = 1 / viewport.zoom;
const left = [], right = [];
for (let i = 0; i < pts.length; i++) {
const p = pts[i];
let dx, dy;
if (i === 0) { dx = pts[1].x - p.x; dy = pts[1].y - p.y; }
else if (i === pts.length - 1) { dx = p.x - pts[i - 1].x; dy = p.y - pts[i - 1].y; }
else { dx = pts[i + 1].x - pts[i - 1].x; dy = pts[i + 1].y - pts[i - 1].y; }
const len = Math.hypot(dx, dy) || 1; dx /= len; dy /= len;
const nx = -dy, ny = dx;
const hw = ((p.w || o.strokeWidth || 2) / 2) * invZoom;
left.push({ x: p.x + nx * hw, y: p.y + ny * hw });
right.push({ x: p.x - nx * hw, y: p.y - ny * hw });
}
ctx.fillStyle = fillColor;
ctx.beginPath();
ctx.moveTo(left[0].x, left[0].y);
for (let i = 1; i < left.length; i++) ctx.lineTo(left[i].x, left[i].y);
for (let i = right.length - 1; i >= 0; i--) ctx.lineTo(right[i].x, right[i].y);
ctx.closePath();
ctx.fill();
}
function drawText(o) {
const fontPx = Math.max(6, (o.fontSize || CONFIG.textFontSize));
measureCtx.font = `${fontPx}px ${CONFIG.FONT}`;
const lines = (o.text || '').split('\n');
let w = 0;
for (const ln of lines) w = Math.max(w, measureCtx.measureText(ln).width);
const lineH = fontPx * 1.35;
const padX = 3, padY = 2;
o._measuredW = w + padX * 2;
o._measuredH = lines.length * lineH + padY * 2;
o.width = Math.max(o.width || w, w + padX * 2);
o.height = Math.max(o.height || lineH, lines.length * lineH + padY * 2);
ctx.fillStyle = o.color || CONFIG.defaultStroke;
ctx.textBaseline = 'top';
ctx.font = `${fontPx}px ${CONFIG.FONT}`;
lines.forEach((ln, i) => {
ctx.fillText(ln, o.x + padX, o.y + padY + i * lineH);
});
}
function drawSelectionOverlay() {
if (selectedIds.size === 0) return;
const b = getGroupBounds(); if (!b) return;
const tl = worldToScreen(b.x, b.y);
const br = worldToScreen(b.x + b.width, b.y + b.height);
const hs = CONFIG.handleSize;
ctx.save();
ctx.strokeStyle = '#2563eb';
ctx.lineWidth = 1;
ctx.setLineDash([5, 4]);
ctx.strokeRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y);
ctx.setLineDash([]);
const corners = [
[tl.x, tl.y], [br.x, tl.y], [tl.x, br.y], [br.x, br.y],
];
for (const [cx, cy] of corners) {
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#2563eb';
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.fillRect(cx - hs / 2, cy - hs / 2, hs, hs);
ctx.strokeRect(cx - hs / 2, cy - hs / 2, hs, hs);
}
ctx.restore();
}
function render() {
const dpr = window.devicePixelRatio || 1;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.translate(viewport.x, viewport.y);
ctx.scale(viewport.zoom, viewport.zoom);
drawGrid();
for (const o of scene) drawObject(o);
if (preview) drawObject(preview);
ctx.restore();
drawSelectionOverlay();
updateZoomUI();
}
/* ============================================================
HISTORY (undo / redo)
============================================================ */
function pushHistory() {
const snap = JSON.parse(JSON.stringify(scene));
if (historyIndex >= 0 && JSON.stringify(history[historyIndex]) === JSON.stringify(snap)) return;
history = history.slice(0, historyIndex + 1);
history.push(snap);
if (history.length > CONFIG.maxHistory) { history.shift(); historyIndex--; }
historyIndex = history.length - 1;
scheduleSave();
}
function undo() {
if (historyIndex > 0) {
historyIndex--;
scene = JSON.parse(JSON.stringify(history[historyIndex]));
selectedIds.clear();
render();
scheduleSave();
}
}
function redo() {
if (historyIndex < history.length - 1) {
historyIndex++;
scene = JSON.parse(JSON.stringify(history[historyIndex]));
selectedIds.clear();
render();
scheduleSave();
}
}
/* ============================================================
PERSISTENCE (localStorage) — board survives reload
============================================================ */
let saveTimer = null;
function scheduleSave() {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
try { localStorage.setItem('whiteboard_scene', JSON.stringify(scene)); } catch (e) {}
}, 180);
}
function loadScene() {
try {
const raw = localStorage.getItem('whiteboard_scene');
if (raw) { const s = JSON.parse(raw); if (Array.isArray(s)) scene = s; }
} catch (e) {}
}
/* ============================================================
TEXT EDITOR (inline)
============================================================ */
function startTextEditor(world) {
const obj = {
id: uid(), type: 'text', x: world.x, y: world.y,
width: 160, height: CONFIG.textFontSize * 1.35 + 4,
text: '', color: CONFIG.defaultStroke, fillColor: 'transparent',
strokeWidth: 1, fontSize: CONFIG.textFontSize,
};
scene.push(obj);
selectedIds = new Set([obj.id]);
const s = worldToScreen(world.x, world.y);
editorZ = viewport.zoom;
const ta = document.getElementById('text-editor');
if (!ta) {
const el = document.createElement('textarea');
el.id = 'text-editor';
el.rows = 1;
document.body.appendChild(el);
}
const el = document.getElementById('text-editor');
const fontPx = obj.fontSize * viewport.zoom;
el.style.font = `${fontPx}px ${CONFIG.FONT}`;
el.style.left = s.x + 'px';
el.style.top = s.y + 'px';
el.style.width = Math.max(80, obj.width * viewport.zoom) + 'px';
el.style.height = Math.max(28, obj.height * viewport.zoom) + 'px';
el.value = '';
el.style.display = 'block';
el.focus();
textState = { obj, el };
el.oninput = () => onTextInput();
el.onkeydown = (e) => onTextKey(e);
el.onblur = () => commitText();
}
function onTextInput() {
if (!textState) return;
const { obj, el } = textState;
obj.text = el.value;
// recompute size from measured text
const lines = (el.value || '').split('\n');
const fontPx = obj.fontSize;
measureCtx.font = `${fontPx}px ${CONFIG.FONT}`;
let w = 0;
for (const ln of lines) w = Math.max(w, measureCtx.measureText(ln).width);
const lineH = fontPx * 1.35;
const padX = 6, padY = 4;
obj.width = Math.max(60, w + padX);
obj.height = Math.max(lineH, lines.length * lineH + padY);
// keep editor visually in sync
el.style.width = (obj.width * viewport.zoom) + 'px';
el.style.height = (obj.height * viewport.zoom) + 'px';
render();
}
function onTextKey(e) {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); commitText(); }
else if (e.key === 'Escape') { e.preventDefault(); commitText(); }
}
function commitText() {
if (!textState) return;
const { obj, el } = textState;
obj.text = el.value;
el.style.display = 'none';
textState = null;
pushHistory();
render();
}
/* ============================================================
INTERACTIONS — pointer events
============================================================ */
function pointerWorld(e) { return screenToWorld(e.clientX, e.clientY); }
canvas.addEventListener('pointerdown', (e) => {
if (textState) return; // textarea owns input while active
if (e.button === 1 || (spaceDown && e.button === 0)) { startPan(e); return; }
if (e.button !== 0) return;
const w = pointerWorld(e);
switch (activeTool) {
case 'select': onSelectDown(e, w); break;
case 'pen':
preview = { id: uid(), type: 'freehand', points: [{ x: w.x, y: w.y, w: pressureWidth(e) }],
color: CONFIG.defaultStroke, fillColor: 'transparent', strokeWidth: CONFIG.defaultWidth };
active = { type: 'draw' };
render(); break;
case 'rect':
case 'ellipse':
preview = { id: uid(), type: activeTool, x: w.x, y: w.y, width: 0, height: 0,
color: CONFIG.defaultStroke, fillColor: CONFIG.defaultFill, strokeWidth: CONFIG.defaultWidth };
active = { type: 'draw', startX: w.x, startY: w.y }; break;
case 'line':
case 'arrow':
preview = { id: uid(), type: activeTool, x1: w.x, y1: w.y, x2: w.x, y2: w.y,
color: CONFIG.defaultStroke, fillColor: 'transparent', strokeWidth: CONFIG.defaultWidth };
active = { type: 'draw' }; break;
case 'text':
startTextEditor(w); break;
case 'eraser':
active = { type: 'erase', deleted: new Set() };
eraseAt(w); break;
}
});
function pressureWidth(e) {
const base = CONFIG.defaultWidth;
const p = e.pressure;
if (p && p > 0) return clamp(base * (0.5 + p * 1.4), base * 0.4, base * 3);
return base;
}
function startPan(e) {
active = { type: 'pan', origX: viewport.x, origY: viewport.y, sx: e.clientX, sy: e.clientY };
canvas.style.cursor = 'grabbing';
}
function eraseAt(w) {
const hit = hitTestWorld(w);
if (hit && !active.deleted.has(hit.id)) {
active.deleted.add(hit.id);
scene = scene.filter(o => o.id !== hit.id);
selectedIds.delete(hit.id);
render();
}
}
canvas.addEventListener('pointermove', (e) => {
if (!active) return;
switch (active.type) {
case 'pan': {
viewport.x = active.origX + (e.clientX - active.sx);
viewport.y = active.origY + (e.clientY - active.sy);
render();
break;
}
case 'draw': drawMove(e); break;
case 'move': moveMove(e); break;
case 'resize': resizeMove(e); break;
case 'rubber': rubberMove(e); break;
case 'erase': eraseAt(pointerWorld(e)); break;
}
});
function drawMove(e) {
const w = pointerWorld(e);
if (preview.type === 'freehand') {
const last = preview.points[preview.points.length - 1];
if (Math.hypot(w.x - last.x, w.y - last.y) > 1.5 / viewport.zoom) {
preview.points.push({ x: w.x, y: w.y, w: pressureWidth(e) });
}
} else if (preview.type === 'line' || preview.type === 'arrow') {
preview.x2 = w.x; preview.y2 = w.y;
} else {
// rect/ellipse — startX/startY captured on pointerdown
preview.x = Math.min(active.startX, w.x);
preview.y = Math.min(active.startY, w.y);
preview.width = Math.abs(w.x - active.startX);
preview.height = Math.abs(w.y - active.startY);
}
render();
}
function moveMove(e) {
const w = pointerWorld(e);
const dx = w.x - active.startWorld.x;
const dy = w.y - active.startWorld.y;
for (const id of selectedIds) {
const o = findObj(id); if (o) translateObject(o, dx, dy);
}
active.moved = active.moved || (Math.hypot(dx, dy) > 0.01);
render();
}
function translateObject(o, dx, dy) {
switch (o.type) {
case 'rect': case 'ellipse': case 'text':
o.x += dx; o.y += dy; break;
case 'line': case 'arrow':
o.x1 += dx; o.y1 += dy; o.x2 += dx; o.y2 += dy; break;
case 'freehand':
for (const p of o.points) { p.x += dx; p.y += dy; }
break;
}
}
function resizeMove(e) {
const w = pointerWorld(e);
const oldB = active.oldBounds;
const pivot = active.pivot;
// signed scale factors relative to the fixed corner
const sx = (w.x - pivot.x) / oldB.width;
const sy = (w.y - pivot.y) / oldB.height;
const fs = e.shiftKey ? Math.max(Math.abs(sx), Math.abs(sy)) : sx;
for (const id of selectedIds) {
const o = findObj(id); if (o) transformAbout(o, pivot, fs, e.shiftKey ? fs : sy);
}
active.moved = true;
render();
}
// pivot (fixed corner) + drag corner for a given resize handle, relative to oldB
function dragCorner(handle, oldB) {
switch (handle) {
case 'br': return { pivot: { x: oldB.x, y: oldB.y }, corner: { x: oldB.x + oldB.width, y: oldB.y + oldB.height } };
case 'tl': return { pivot: { x: oldB.x + oldB.width, y: oldB.y + oldB.height }, corner: { x: oldB.x, y: oldB.y } };
case 'tr': return { pivot: { x: oldB.x, y: oldB.y + oldB.height }, corner: { x: oldB.x + oldB.width, y: oldB.y } };
case 'bl': return { pivot: { x: oldB.x + oldB.width, y: oldB.y }, corner: { x: oldB.x, y: oldB.y + oldB.height } };
}
}
function transformAbout(o, pivot, sx, sy) {
const tx = (v) => ({ x: pivot.x + (v.x - pivot.x) * sx, y: pivot.y + (v.y - pivot.y) * sy });
switch (o.type) {
case 'rect':
case 'ellipse': {
const c = [[o.x, o.y], [o.x + o.width, o.y], [o.x + o.width, o.y + o.height], [o.x, o.y + o.height]].map(p => tx({ x: p[0], y: p[1] }));
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (let i = 0; i < c.length; i++) { minX = Math.min(minX, c[i].x); minY = Math.min(minY, c[i].y); maxX = Math.max(maxX, c[i].x); maxY = Math.max(maxY, c[i].y); }
o.x = minX; o.y = minY; o.width = maxX - minX; o.height = maxY - minY;
break;
}
case 'text': {
const c = [[o.x, o.y], [o.x + o.width, o.y], [o.x + o.width, o.y + o.height], [o.x, o.y + o.height]].map(p => tx({ x: p[0], y: p[1] }));
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (let i = 0; i < c.length; i++) { minX = Math.min(minX, c[i].x); minY = Math.min(minY, c[i].y); maxX = Math.max(maxX, c[i].x); maxY = Math.max(maxY, c[i].y); }
o.x = minX; o.y = minY; o.width = maxX - minX; o.height = maxY - minY;
o.fontSize = Math.max(4, (o.fontSize || CONFIG.textFontSize) * Math.max(Math.abs(sx), Math.abs(sy)));
break;
}
case 'line':
case 'arrow': {
const a = tx({ x: o.x1, y: o.y1 }), b = tx({ x: o.x2, y: o.y2 });
o.x1 = a.x; o.y1 = a.y; o.x2 = b.x; o.y2 = b.y;
break;
}
case 'freehand':
for (const p of o.points) { const t = tx(p); p.x = t.x; p.y = t.y; }
break;
}
}
function rubberMove(e) {
active.currentWorld = pointerWorld(e);
render();
}
function selectInBand(b0, b1) {
const x = Math.min(b0.x, b1.x), y = Math.min(b0.y, b1.y);
const w = Math.abs(b1.x - b0.x), h = Math.abs(b1.y - b0.y);
const band = { x, y, width: w, height: h };
const newSel = new Set();
for (const o of scene) {
const b = getBounds(o);
if (b.x <= band.x + band.width && b.x + b.width >= band.x &&
b.y <= band.y + band.height && b.y + b.height >= band.y) {
newSel.add(o.id);
}
}
selectedIds = newSel;
}
canvas.addEventListener('pointerup', (e) => {
if (!active) return;
const t = active.type;
if (t === 'pan') { canvas.style.cursor = toolCursor(); active = null; return; }
if (t === 'erase') {
if (active.deleted.size > 0) pushHistory();
active = null;
render();
return;
}
if (t === 'draw') {
finalizeDraw();
active = null;
return;
}
if (t === 'rubber') {
selectInBand(active.startWorld, active.currentWorld || active.startWorld);
render();
active = null;
return;
}
// move / resize -> one undo step
if (active.moved) pushHistory();
active = null;
render();
});
function finalizeDraw() {
if (!preview) return;
// discard zero-size shapes (accidental clicks)
const b = getBounds(preview);
const tiny = (b.width < 2 && b.height < 2) && (preview.type !== 'freehand');
if (preview.type === 'freehand' && preview.points.length < 2) { preview = null; render(); return; }
if (tiny) { preview = null; render(); return; }
scene.push(preview);
selectedIds = new Set([preview.id]);
preview = null;
pushHistory();
}
/* ============================================================
INTERACTIONS — select tool detail
============================================================ */
function onSelectDown(e, w) {
// handle first
const h = handleAt(w);
if (h) {
const oldB = getGroupBounds();
const { pivot } = dragCorner(h, oldB);
active = { type: 'resize', handle: h, pivot, oldBounds: oldB, moved: false };
return;
}
const hit = hitTestWorld(w);
if (hit) {
if (e.shiftKey) {
if (selectedIds.has(hit.id)) selectedIds.delete(hit.id); else selectedIds.add(hit.id);
render();
return;
}
if (!selectedIds.has(hit.id)) { selectedIds = new Set([hit.id]); render(); }
// begin move (start scene captured implicitly by history at pointerup)
active = { type: 'move', startWorld: w, moved: false };
} else {
if (!e.shiftKey) { selectedIds = new Set(); render(); }
active = { type: 'rubber', startWorld: w, currentWorld: w };
}
}
/* ============================================================
INTERACTIONS — keyboard
============================================================ */
function toolCursor() {
if (spaceDown) return 'grab';
switch (activeTool) {
case 'select': return 'default';
case 'pen': case 'rect': case 'ellipse': case 'line': case 'arrow': case 'eraser': return 'crosshair';
case 'text': return 'text';
}
return 'default';
}
function setTool(t) {
activeTool = t;
document.querySelectorAll('.tool-btn').forEach(b => b.classList.toggle('active', b.dataset.tool === t));
canvas.style.cursor = toolCursor();
}
window.addEventListener('keydown', (e) => {
if (textState) return;
const mod = e.ctrlKey || e.metaKey;
if (mod && (e.key.toLowerCase() === 'z' || e.key.toLowerCase() === 'y')) {
e.preventDefault();
if (mod && e.shiftKey) redo();
else undo();
return;
}
if (e.key === 'Delete' || e.key === 'Backspace') {
if (selectedIds.size > 0) {
e.preventDefault();
scene = scene.filter(o => !selectedIds.has(o.id));
selectedIds.clear();
pushHistory();
}
return;
}
if (e.key === ' ') { e.preventDefault(); spaceDown = true; canvas.style.cursor = 'grab'; return; }
const k = e.key.toLowerCase();
if (k === 'v') setTool('select');
else if (k === 'p') setTool('pen');
else if (k === 'r') setTool('rect');
else if (k === 'o') setTool('ellipse');
else if (k === 'l') setTool('line');
else if (k === 't') setTool('text');
else if (k === 'e') setTool('eraser');
else if (k === 'escape') { selectedIds = new Set(); render(); }
else if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
e.preventDefault();
const step = e.shiftKey ? CONFIG.nudgeShift : CONFIG.nudge;
let dx = 0, dy = 0;
if (e.key === 'ArrowLeft') dx = -step;
else if (e.key === 'ArrowRight') dx = step;
else if (e.key === 'ArrowUp') dy = -step;
else if (e.key === 'ArrowDown') dy = step;
for (const id of selectedIds) { const o = findObj(id); if (o) translateObject(o, dx, dy); }
render();
active = { type: 'nudge-commit' };
}
});
window.addEventListener('keyup', (e) => {
if (e.key === ' ') { spaceDown = false; canvas.style.cursor = toolCursor(); }
else if (active && active.type === 'nudge-commit') { pushHistory(); active = null; }
});
/* ============================================================
ZOOM (toward cursor) + UI
============================================================ */
function updateZoomUI() {
document.getElementById('zoom-val').textContent = Math.round(viewport.zoom * 100) + '%';
}
function setZoom(newZoom, focusX, focusY) {
newZoom = clamp(newZoom, CONFIG.zoom.min, CONFIG.zoom.max);
if (newZoom === viewport.zoom) return;
// keep the world point under (focusX, focusY) fixed
const wx = (focusX - viewport.x) / viewport.zoom;
const wy = (focusY - viewport.y) / viewport.zoom;
viewport.zoom = newZoom;
viewport.x = focusX - wx * newZoom;
viewport.y = focusY - wy * newZoom;
render();
}
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const factor = e.deltaY < 0 ? 1 + CONFIG.zoom.step : 1 - CONFIG.zoom.step;
setZoom(viewport.zoom * factor, e.clientX, e.clientY);
}, { passive: false });
document.getElementById('zoom-in').addEventListener('click', () => setZoom(viewport.zoom * (1 + CONFIG.zoom.step), window.innerWidth / 2, window.innerHeight / 2));
document.getElementById('zoom-out').addEventListener('click', () => setZoom(viewport.zoom / (1 + CONFIG.zoom.step), window.innerWidth / 2, window.innerHeight / 2));
document.getElementById('reset-btn').addEventListener('click', resetView);
document.getElementById('undo-btn').addEventListener('click', undo);
document.getElementById('redo-btn').addEventListener('click', redo);
function resetView() {
if (scene.length === 0) { viewport = { x: 0, y: 0, zoom: 1 }; render(); return; }
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const o of scene) { const b = getBounds(o); minX = Math.min(minX, b.x); minY = Math.min(minY, b.y); maxX = Math.max(maxX, b.x + b.width); maxY = Math.max(maxY, b.y + b.height); }
const pad = 60;
const bw = Math.max(1, maxX - minX), bh = Math.max(1, maxY - minY);
const z = clamp(Math.min((window.innerWidth - pad * 2) / bw, (window.innerHeight - pad * 2) / bh, 3), 0.1, 4);
viewport.zoom = z;
viewport.x = window.innerWidth / 2 - (minX + bw / 2) * z;
viewport.y = window.innerHeight / 2 - (minY + bh / 2) * z;
render();
}
/* ============================================================
TOOLBAR + PROPERTIES WIRING
============================================================ */
document.querySelectorAll('.tool-btn').forEach(b => {
b.addEventListener('click', () => setTool(b.dataset.tool));
});
function buildSwatches(containerId, colors, prop, isFill) {
const c = document.getElementById(containerId);
const all = [...colors, 'transparent'];
all.forEach(col => {
const b = document.createElement('button');
b.className = 'swatch';
if (col === 'transparent') b.classList.add('transparent');
else b.style.background = col;
b.dataset.color = col;
b.title = col === 'transparent' ? 'None' : col;
b.addEventListener('click', () => applyStyle(prop, col, c));
c.appendChild(b);
});
}
function applyStyle(prop, value, groupEl) {
// set default for new objects
if (prop === 'color') CONFIG.defaultStroke = value;
if (prop === 'fillColor') CONFIG.defaultFill = value;
// apply to selection
if (selectedIds.size > 0) {
for (const id of selectedIds) { const o = findObj(id); if (o) o[prop] = value; }
pushHistory();
}
render();
// active highlight
groupEl.querySelectorAll('.swatch').forEach(s => s.classList.toggle('active', s.dataset.color === value));
}
function applyWidth(w, groupEl) {
CONFIG.defaultWidth = w;
if (selectedIds.size > 0) {
for (const id of selectedIds) { const o = findObj(id); if (o) o.strokeWidth = w; }
pushHistory();
}
render();
groupEl.querySelectorAll('.width-btn').forEach(b => b.classList.toggle('active', +b.dataset.w === w));
}
buildSwatches('stroke-group', CONFIG.palette, 'color');
buildSwatches('fill-group', CONFIG.palette, 'fillColor');
{
const g = document.getElementById('width-group');
[1, 2, 4, 8, 14].forEach(w => {
const b = document.createElement('button');
b.className = 'width-btn';
b.dataset.w = w;
const d = document.createElement('span');
d.className = 'dot';
d.style.width = w + 'px'; d.style.height = w + 'px';
b.appendChild(d);
b.addEventListener('click', () => applyWidth(w, g));
g.appendChild(b);
});
// default active
g.querySelector('.width-btn[data-w="2"]').classList.add('active');
}
/* ============================================================
INIT
============================================================ */
function init() {
loadScene();
resizeCanvas();
// seed history with current board so undo starts from a known state
history = [JSON.parse(JSON.stringify(scene))];
historyIndex = 0;
setTool('select');
// click on empty canvas (no active tool interaction) deselects
window.addEventListener('resize', resizeCanvas);
render();
}
init();
</script>
</body>
</html>
<!-- agent-meta {"model":"peculiar-ragdoll/Tiel-Coder-35B-A3B-GGUF-MTP","provider":"brigid","persona":"full","sessionId":"5e9d079f-01bd-4be4-b71d-8eb313a05349","tokensIn":9029281,"tokensOut":78172,"tokensTotal":9107453,"cost":0,"turns":116,"toolCalls":115,"failedToolCalls":0,"timestamp":"2026-09-04T22:24:37.306Z"} -->