Whiteboard
Ornith 1.5 35B A3B · 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 52,176 bytes · SHA-256 6f50a3161fa9
<!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>
/* ============================================================
RESET & BASE
============================================================ */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%; height: 100%; overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: #e8eaed; color: #1e1e1e; -webkit-font-smoothing: antialiased;
user-select: none; -webkit-user-select: none;
}
#canvas { position: absolute; top: 0; left: 0; display: block; cursor: default; touch-action: none; }
/* ============================================================
FLOATING TOOLBAR (left)
============================================================ */
.toolbar {
position: absolute; left: 16px; top: 16px; z-index: 20;
background: #ffffff; border-radius: 12px; padding: 6px;
box-shadow: 0 2px 12px rgba(0,0,0,0.12), 0 0 0 1px rgba(0,0,0,0.04);
display: flex; flex-direction: column; gap: 4px;
}
.tool-btn {
width: 40px; height: 40px; border: none; background: transparent;
border-radius: 9px; cursor: pointer; display: flex; align-items: center; justify-content: center;
color: #4a4a4a; position: relative; transition: background 0.12s, color 0.12s;
}
.tool-btn:hover { background: #f0f1f3; color: #1e1e1e; }
.tool-btn.active { background: #e7f0ff; color: #1a71ff; }
.tool-btn svg { width: 22px; height: 22px; pointer-events: none; }
.tool-btn .shortcut {
position: absolute; right: 4px; bottom: 2px; font-size: 9px; color: #b0b0b0; font-weight: 600;
}
.divider { height: 1px; background: #e8e8e8; margin: 4px 2px; }
/* ============================================================
TOP BAR (title, undo/redo, reset view)
============================================================ */
.topbar {
position: absolute; left: 72px; top: 16px; z-index: 20;
display: flex; align-items: center; gap: 8px;
}
.brand { font-weight: 700; font-size: 15px; color: #1e1e1e; padding-right: 8px; letter-spacing: -0.2px; }
.icon-btn {
width: 36px; height: 36px; border: none; background: #ffffff;
border-radius: 9px; cursor: pointer; display: flex; align-items: center; justify-content: center;
color: #4a4a4a; box-shadow: 0 2px 12px rgba(0,0,0,0.1), 0 0 0 1px rgba(0,0,0,0.04);
transition: background 0.12s, color 0.12s;
}
.icon-btn:hover { background: #f0f1f3; color: #1e1e1e; }
.icon-btn:disabled { opacity: 0.35; cursor: default; background: #fff; }
.icon-btn:disabled:hover { color: #4a4a4a; }
.icon-btn svg { width: 20px; height: 20px; }
/* ============================================================
PROPERTIES BAR (bottom)
============================================================ */
.propsbar {
position: absolute; left: 50%; bottom: 16px; transform: translateX(-50%); z-index: 20;
background: #ffffff; border-radius: 12px; padding: 8px 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.12), 0 0 0 1px rgba(0,0,0,0.04);
display: flex; align-items: center; gap: 16px;
}
.prop-group { display: flex; align-items: center; gap: 8px; }
.prop-label { font-size: 11px; color: #8a8a8a; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; }
.color-swatch {
width: 24px; height: 24px; border-radius: 6px; border: 2px solid #e0e0e0; cursor: pointer;
padding: 0; transition: transform 0.1s;
}
.color-swatch:hover { transform: scale(1.08); }
.color-swatch::-webkit-color-swatch-wrapper { padding: 0; }
.color-swatch::-webkit-color-swatch { border: none; border-radius: 4px; }
.color-swatch::-moz-color-swatch { border: none; border-radius: 4px; }
.width-slider { width: 90px; accent-color: #1a71ff; }
.width-val { font-size: 11px; color: #8a8a8a; min-width: 26px; text-align: center; }
.palette { display: flex; gap: 4px; }
.palette .swatch {
width: 20px; height: 20px; border-radius: 5px; border: 2px solid transparent; cursor: pointer;
transition: transform 0.1s, border-color 0.1s;
}
.palette .swatch:hover { transform: scale(1.1); }
.palette .swatch.active { border-color: #1a71ff; }
/* ============================================================
ZOOM INDICATOR (bottom-right)
============================================================ */
.zoom-indicator {
position: absolute; right: 16px; bottom: 16px; z-index: 20;
background: #ffffff; border-radius: 9px; padding: 4px;
box-shadow: 0 2px 12px rgba(0,0,0,0.1), 0 0 0 1px rgba(0,0,0,0.04);
display: flex; align-items: center; gap: 2px;
}
.zoom-indicator .z-btn {
width: 30px; height: 30px; border: none; background: transparent; cursor: pointer;
color: #4a4a4a; font-size: 18px; line-height: 1; border-radius: 6px;
display: flex; align-items: center; justify-content: center;
}
.zoom-indicator .z-btn:hover { background: #f0f1f3; color: #1e1e1e; }
.zoom-indicator .z-val {
min-width: 54px; text-align: center; font-size: 12px; font-weight: 600; color: #4a4a4a;
cursor: pointer;
}
/* ============================================================
TEXT EDITING OVERLAY
============================================================ */
#text-edit {
position: absolute; z-index: 30; display: none;
background: rgba(255,255,255,0.0); border: 2px solid #1a71ff; border-radius: 4px;
padding: 4px 6px; color: #1e1e1e; font: 16px -apple-system, "Segoe UI", sans-serif;
outline: none; min-width: 40px; line-height: 1.3; overflow-wrap: break-word; white-space: pre-wrap;
box-shadow: 0 4px 16px rgba(0,0,0,0.18);
}
/* ============================================================
HELP HINT
============================================================ */
.hint {
position: absolute; left: 50%; top: 16px; transform: translateX(-50%); z-index: 15;
font-size: 12px; color: #9a9a9a; background: rgba(255,255,255,0.85); padding: 4px 12px;
border-radius: 20px; pointer-events: none; transition: opacity 0.5s;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<!-- Inline SVG icon definitions referenced by toolbar -->
<div class="toolbar" id="toolbar">
<button class="tool-btn active" data-tool="select" title="Select (V)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3l0 15 4.5-3.5 3 6 2.5-1.2-3-5.8L16 19l5-5z"/></svg>
<span class="shortcut">V</span>
</button>
<button class="tool-btn" data-tool="pen" title="Freehand (P)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21c3-1 5-3 7-6L19 7l3 3-9 9c-3 2-5 4-6 7z"/><path d="M14 6l4 4"/></svg>
<span class="shortcut">P</span>
</button>
<button class="tool-btn" data-tool="rectangle" title="Rectangle (R)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="4" y="5" width="16" height="14" rx="1.5"/></svg>
<span class="shortcut">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="1.8"><ellipse cx="12" cy="12" rx="8" ry="6"/></svg>
<span class="shortcut">O</span>
</button>
<button class="tool-btn" data-tool="arrow" title="Arrow (L)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h12M13 6l7 6-7 6"/></svg>
<span class="shortcut">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="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 5h12M12 5v14"/><path d="M8 19h8"/></svg>
<span class="shortcut">T</span>
</button>
<div class="divider"></div>
<button class="tool-btn" data-tool="eraser" title="Eraser (E)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 14l5-5 6 6-5 5z"/><path d="M4 16l4-4M11 9l-7 7-1 3 3-1 8-8"/></svg>
<span class="shortcut">E</span>
</button>
</div>
<div class="topbar">
<span class="brand">Whiteboard</span>
<button class="icon-btn" id="undo-btn" title="Undo (Ctrl+Z)" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M9 10l-5 5 5 5"/><path d="M4 15h10a6 6 0 0 1 0 12h-2"/></svg>
</button>
<button class="icon-btn" id="redo-btn" title="Redo (Ctrl+Shift+Z)" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M15 10l5 5-5 5"/><path d="M20 15H10a6 6 0 0 0 0 12h2"/></svg>
</button>
</div>
<div class="propsbar" id="propsbar">
<div class="prop-group">
<span class="prop-label">Stroke</span>
<div class="palette" id="stroke-palette"></div>
<input type="color" class="color-swatch" id="stroke-color" value="#1e1e1e" title="Custom stroke color">
</div>
<div class="prop-group">
<span class="prop-label">Fill</span>
<div class="palette" id="fill-palette"></div>
<input type="color" class="color-swatch" id="fill-color" value="#ffffff" title="Custom fill color">
</div>
<div class="prop-group">
<span class="prop-label">Size</span>
<input type="range" class="width-slider" id="width-slider" min="1" max="24" value="3">
<span class="width-val" id="width-val">3</span>
</div>
</div>
<div class="zoom-indicator" id="zoom-indicator">
<button class="z-btn" id="zoom-out" title="Zoom out">−</button>
<span class="z-val" id="zoom-val" title="Click to reset">100%</span>
<button class="z-btn" id="zoom-in" title="Zoom in">+</button>
</div>
<div class="hint" id="hint">Space + drag to pan · Scroll to zoom · Draw, select, move</div>
<div id="text-edit"></div>
<script>
/* ============================================================
TUNABLE CONSTANTS
============================================================ */
const CONFIG = {
// Palette (tasteful, hand-drawn-adjacent)
colors: [
'#1e1e1e', '#e03131', '#f0932b', '#2f9e44', '#1a71ff',
'#6741d9', '#e8590c', '#495057', '#868e96', '#ffffff'
],
// Viewport
zoomMin: 0.25,
zoomMax: 4.0,
zoomStep: 0.1,
// Interaction
hitTolerance: 6, // world px tolerance for clicking lines/borders
handleSize: 8, // handle size in screen px
nudge: 1, // arrow nudge px
nudgeBig: 10, // shift+arrow nudge px
eraserRadius: 14, // world px for eraser
// Appearance
strokeWidth: 3,
defaultStroke: '#1e1e1e',
defaultFill: '#ffffff',
grid: { color: 'rgba(0,0,0,0.08)', size: 28 },
dotColor: 'rgba(0,0,0,0.10)',
// Persistence
storageKey: 'whiteboard.scene.v1',
historyLimit: 200
};
/* ============================================================
UTILITIES
============================================================ */
let idCounter = 0;
function uid() { return 'o' + (++idCounter) + '_' + Date.now().toString(36); }
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
// Ensure a rectangle is normalized (top-left, positive w/h)
function normRect(x, y, w, h) {
if (w < 0) { x += w; w = -w; }
if (h < 0) { y += h; h = -h; }
return { x, y, w, h };
}
// distance from point p to segment ab
function pointSegDist(p, a, b) {
const dx = b.x - a.x, dy = b.y - a.y;
const len2 = dx * dx + dy * dy;
let t = len2 ? ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2 : 0;
t = clamp(t, 0, 1);
const cx = a.x + t * dx, cy = a.y + t * dy;
return Math.hypot(p.x - cx, p.y - cy);
}
// min distance from point to a freehand stroke (list of points)
function strokeDist(pts, p) {
let min = Infinity;
for (let i = 1; i < pts.length; i++) {
const d = pointSegDist(p, pts[i - 1], pts[i]);
if (d < min) min = d;
}
return min;
}
// distance from point to rectangle border (for outline click)
function borderDist(rect, p) {
const { x, y, w, h } = rect;
const t = CONFIG.hitTolerance;
// inside check
if (p.x > x - t && p.x < x + w + t && p.y > y - t && p.y < y + h + t) {
// distance to nearest edge
const dLeft = Math.abs(p.x - x);
const dRight = Math.abs(p.x - (x + w));
const dTop = Math.abs(p.y - y);
const dBottom = Math.abs(p.y - (y + h));
return Math.min(dLeft, dRight, dTop, dBottom);
}
return Infinity;
}
// is point inside rectangle (filled)
function pointInRect(rect, p) {
return p.x >= rect.x && p.x <= rect.x + rect.w && p.y >= rect.y && p.y <= rect.y + rect.h;
}
// is point inside ellipse (filled)
function pointInEllipse(ell, p) {
const cx = ell.x + ell.w / 2, cy = ell.y + ell.h / 2;
const rx = ell.w / 2, ry = ell.h / 2;
if (rx <= 0 || ry <= 0) return false;
const dx = (p.x - cx) / rx, dy = (p.y - cy) / ry;
return dx * dx + dy * dy <= 1;
}
// distance from point to ellipse border
function ellipseBorderDist(ell, p) {
const cx = ell.x + ell.w / 2, cy = ell.y + ell.h / 2;
const rx = ell.w / 2, ry = ell.h / 2;
if (rx <= 0 || ry <= 0) return Infinity;
// transform to unit circle
const dx = (p.x - cx) / rx, dy = (p.y - cy) / ry;
const d = Math.hypot(dx, dy);
if (d === 0) return Math.min(rx, ry);
// nearest point on ellipse
const nx = dx / d, ny = dy / d;
const bx = cx + nx * rx, by = cy + ny * ry;
return Math.hypot(p.x - bx, p.y - by);
}
// distance from point to arrow line
function arrowDist(arrow, p) {
return pointSegDist(p, { x: arrow.x1, y: arrow.y1 }, { x: arrow.x2, y: arrow.y2 });
}
// bounding box of a list of objects (world coords)
function objectsBBox(objs) {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const o of objs) {
const b = bbox(o);
minX = Math.min(minX, b.x); minY = Math.min(minY, b.y);
maxX = Math.max(maxX, b.x + b.w); maxY = Math.max(maxY, b.y + b.h);
}
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
}
// bounding box of a single object (world coords)
function bbox(o) {
switch (o.type) {
case 'rectangle': { const r = normRect(o.x, o.y, o.w, o.h); return r; }
case 'ellipse': { const r = normRect(o.x, o.y, o.w, o.h); return r; }
case 'arrow': {
return { x: Math.min(o.x1, o.x2), y: Math.min(o.y1, o.y2),
w: Math.abs(o.x2 - o.x1), h: Math.abs(o.y2 - o.y1) };
}
case 'text': {
const cw = o.charW || 8, lh = o.lineH || 18;
const lines = o.text.split('\n').length || 1;
const maxW = Math.max(...o.text.split('\n').map(l => l.length)) * cw;
return { x: o.x, y: o.y, w: maxW, h: lh * lines };
}
case 'freehand': {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const p of o.points) { minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x);
minY = Math.min(minY, p.y); maxY = Math.max(maxY, p.y); }
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
}
}
return { x: 0, y: 0, w: 0, h: 0 };
}
/* ============================================================
SCENE + HISTORY
============================================================ */
let scene = []; // array of object { id, type, ... }
let history = []; // array of scenes
let histIndex = -1; // current index into history
let redoStack = [];
function pushHistory() {
// discard any redo branch
redoStack = [];
// prune history if too long
if (history.length > CONFIG.historyLimit) history.shift();
histIndex++;
history.length = histIndex;
history.push(JSON.parse(JSON.stringify(scene)));
}
function undo() {
if (histIndex <= 0) return;
histIndex--;
redoStack.push(JSON.parse(JSON.stringify(scene)));
scene = JSON.parse(JSON.stringify(history[histIndex]));
clearSelection();
refreshHistoryButtons();
render(); persist();
}
function redo() {
if (redoStack.length === 0) return;
scene = redoStack.pop();
histIndex++;
history[histIndex] = JSON.parse(JSON.stringify(scene));
history.length = histIndex + 1;
clearSelection();
refreshHistoryButtons();
render(); persist();
}
function refreshHistoryButtons() {
document.getElementById('undo-btn').disabled = histIndex <= 0;
document.getElementById('redo-btn').disabled = redoStack.length === 0;
}
/* ============================================================
PERSISTENCE (localStorage)
============================================================ */
function persist() {
try { localStorage.setItem(CONFIG.storageKey, JSON.stringify(scene)); }
catch (e) { /* quota or disabled */ }
}
function loadScene() {
try {
const raw = localStorage.getItem(CONFIG.storageKey);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.length) { scene = parsed; return true; }
}
} catch (e) {}
return false;
}
/* ============================================================
VIEWPORT (pan / zoom)
============================================================ */
const viewport = { x: 0, y: 0, scale: 1 }; // translation + scale
// convert screen -> world
function screenToWorld(sx, sy) {
return { x: (sx - viewport.x) / viewport.scale, y: (sy - viewport.y) / viewport.scale };
}
// convert world -> screen
function worldToScreen(wx, wy) {
return { x: wx * viewport.scale + viewport.x, y: wy * viewport.scale + viewport.y };
}
// zoom centered on a screen point (keeps world point under cursor fixed)
function zoomAt(screenX, screenY, newScale) {
newScale = clamp(newScale, CONFIG.zoomMin, CONFIG.zoomMax);
const w = screenToWorld(screenX, screenY);
viewport.scale = newScale;
viewport.x = screenX - w.x * newScale;
viewport.y = screenY - w.y * newScale;
updateZoomDisplay();
render();
}
function resetView() {
viewport.x = 0; viewport.y = 0; viewport.scale = 1;
updateZoomDisplay(); render();
}
function updateZoomDisplay() {
document.getElementById('zoom-val').textContent = Math.round(viewport.scale * 100) + '%';
}
/* ============================================================
CANVAS SETUP & DPR
============================================================ */
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
let dpr = window.devicePixelRatio || 1;
function resizeCanvas() {
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';
render();
}
window.addEventListener('resize', resizeCanvas);
/* ============================================================
RENDERING
============================================================ */
function render() {
const w = canvas.width, h = canvas.height;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // work in CSS px
// background
ctx.fillStyle = '#f6f6f7';
ctx.fillRect(0, 0, w, h);
drawGrid();
// enter world space
ctx.save();
ctx.translate(viewport.x, viewport.y);
ctx.scale(viewport.scale, viewport.scale);
// draw all objects
for (const o of scene) drawObject(o);
// draw live preview while creating
if (previewObj) drawObject(previewObj);
// rubber-band selection rectangle
if (rubber.active) {
const a = screenToWorld(rubber.x0, rubber.y0);
const b = screenToWorld(rubber.x1, rubber.y1);
const r = normRect(a.x, a.y, b.x - a.x, b.y - a.y);
ctx.save();
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // back to screen space for the overlay
ctx.fillStyle = 'rgba(26,113,255,0.08)';
ctx.strokeStyle = 'rgba(26,113,255,0.6)';
ctx.lineWidth = 1;
const s1 = worldToScreen(r.x, r.y), s2 = worldToScreen(r.x + r.w, r.y + r.h);
ctx.fillRect(s1.x, s1.y, s2.x - s1.x, s2.y - s1.y);
ctx.strokeRect(s1.x, s1.y, s2.x - s1.x, s2.y - s1.y);
ctx.restore();
ctx.restore(); // world space
return;
}
ctx.restore(); // world space
// selection overlay (screen space)
if (selected.size) drawSelectionOverlay();
}
// draw the optional dot grid that pans/zooms with world
function drawGrid() {
const w = window.innerWidth, h = window.innerHeight;
const step = CONFIG.grid.size * viewport.scale;
if (step < 10) return; // hide when too zoomed out
const ox = (viewport.x % step + step) % step;
const oy = (viewport.y % step + step) % step;
ctx.fillStyle = CONFIG.dotColor;
for (let x = ox; x < w; x += step) {
for (let y = oy; y < h; y += step) {
ctx.beginPath();
ctx.arc(x, y, 1, 0, Math.PI * 2);
ctx.fill();
}
}
}
function applyStyle(o) {
ctx.lineWidth = o.strokeWidth;
ctx.strokeStyle = o.stroke;
ctx.fillStyle = o.fill;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
}
function drawObject(o) {
switch (o.type) {
case 'rectangle': return drawRectangle(o);
case 'ellipse': return drawEllipse(o);
case 'arrow': return drawArrow(o);
case 'text': return drawText(o);
case 'freehand': return drawFreehand(o);
}
}
function drawRectangle(o) {
const r = normRect(o.x, o.y, o.w, o.h);
applyStyle(o);
if (o.fill && o.fill !== 'transparent' && r.w > 0 && r.h > 0) {
ctx.beginPath();
roundRect(r.x, r.y, r.w, r.h, 3);
ctx.fill();
}
if (r.w > 0 && r.h > 0) {
ctx.beginPath();
roundRect(r.x, r.y, r.w, r.h, 3);
ctx.stroke();
}
}
function roundRect(x, y, w, h, r) {
r = Math.min(r, w / 2, h / 2);
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function drawEllipse(o) {
const r = normRect(o.x, o.y, o.w, o.h);
applyStyle(o);
if (r.w > 1 && r.h > 1) {
const cx = r.x + r.w / 2, cy = r.y + r.h / 2;
const rx = r.w / 2, ry = r.h / 2;
ctx.beginPath();
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
if (o.fill && o.fill !== 'transparent') ctx.fill();
ctx.stroke();
}
}
function drawArrow(o) {
applyStyle(o);
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 = 12 + o.strokeWidth * 2;
ctx.fillStyle = o.stroke;
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();
}
function drawText(o) {
const cw = o.charW || 8, lh = o.lineH || 18;
ctx.font = `${lh}px -apple-system, "Segoe UI", sans-serif`;
ctx.textBaseline = 'top';
ctx.fillStyle = o.stroke;
ctx.textAlign = 'left';
const lines = o.text.split('\n');
for (let i = 0; i < lines.length; i++) {
ctx.fillText(lines[i], o.x, o.y + i * lh);
}
}
function drawFreehand(o) {
applyStyle(o);
const pts = o.points;
if (pts.length < 2) return;
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;
const 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();
}
/* ============================================================
SELECTION OVERLAY (bounding box + handles)
============================================================ */
function drawSelectionOverlay() {
const selected = [...selectedArr];
const box = selected.length === 1 ? bbox(selected[0]) : objectsBBox(selected);
const s = worldToScreen(box.x, box.y);
const e = worldToScreen(box.x + box.w, box.y + box.h);
const wpx = e.x - s.x, hpx = e.y - s.y;
// bounding box
ctx.strokeStyle = '#1a71ff';
ctx.lineWidth = 1.5;
ctx.strokeRect(s.x, s.y, wpx, hpx);
// 4 corner resize handles (screen space)
const hs = CONFIG.handleSize;
const corners = [
[s.x, s.y], [s.x + wpx, s.y], [s.x, s.y + hpx], [s.x + wpx, s.y + hpx]
];
for (const [cx, cy] of corners) {
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#1a71ff';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.rect(cx - hs / 2, cy - hs / 2, hs, hs);
ctx.fill();
ctx.stroke();
}
// store overlay geometry for hit-testing
overlayRect = { x: s.x, y: s.y, w: wpx, h: hpx, corners };
}
let overlayRect = null;
/* ============================================================
HIT-TESTING (world coordinates)
============================================================ */
function hitTest(world, ignoreObj) {
const t = CONFIG.hitTolerance / viewport.scale; // world-space tolerance
// iterate top-to-bottom (reverse): return first (topmost) hit
for (let i = scene.length - 1; i >= 0; i--) {
const o = scene[i];
if (o === ignoreObj) continue;
if (hitObject(o, world, t)) return o;
}
return null;
}
function hitObject(o, p, t) {
switch (o.type) {
case 'rectangle': {
if (pointInRect(o, p)) return true;
if (borderDist(o, p) <= t) return true;
return false;
}
case 'ellipse': {
if (pointInEllipse(o, p)) return true;
if (ellipseBorderDist(o, p) <= t) return true;
return false;
}
case 'arrow': {
return arrowDist(o, p) <= t;
}
case 'freehand': {
const b = bbox(o);
if (p.x < b.x - t || p.x > b.x + b.w + t || p.y < b.y - t || p.y > b.y + b.h + t) return false;
return strokeDist(o.points, p) <= t;
}
case 'text': {
const b = bbox(o);
return p.x >= b.x - t && p.x <= b.x + b.w + t && p.y >= b.y - t && p.y <= b.y + b.h + t;
}
}
return false;
}
/* ============================================================
SELECTION STATE
============================================================ */
let selected = new Set(); // set of object references
let selectedArr = [];
function selectedArrCache() { return [...selected]; }
function clearSelection() {
selected.clear();
selectedArr = [];
overlayRect = null;
}
function updateSelectedArr() { selectedArr = [...selected]; }
/* ============================================================
INTERACTION STATE
============================================================ */
let currentTool = 'select';
let isPanning = false;
let spaceDown = false;
let mouseDown = false;
let lastMouse = { x: 0, y: 0 };
// creation preview
let previewObj = null;
let previewStart = null;
// dragging (move/resize/eraser)
let dragMode = null; // 'move' | 'resize' | 'eraser'
let dragStartWorld = null;
let dragStartObjects = null; // snapshot for undo
let resizeCorner = -1; // 0: tl, 1: tr, 2: bl, 3: br
let resizeStartBoxes = null;
let shiftHeld = false;
// rubber band
let rubber = { active: false, x0: 0, y0: 0, x1: 0, y1: 0 };
// panning buffer
let pendingPan = false;
const el = {
canvas: document.getElementById('canvas'),
textEdit: document.getElementById('text-edit'),
toolbar: document.getElementById('toolbar')
};
/* ============================================================
POINTER EVENTS
============================================================ */
el.canvas.addEventListener('pointerdown', onPointerDown);
el.canvas.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
el.canvas.addEventListener('wheel', onWheel, { passive: false });
function getPointerPos(e) {
const rect = el.canvas.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}
function onPointerDown(e) {
// hide hint after first interaction
const hint = document.getElementById('hint');
if (hint) hint.style.opacity = '0';
// Clear any leaked preview state from a previous interaction
previewObj = null;
previewStart = null;
const pos = getPointerPos(e);
el.canvas.setPointerCapture(e.pointerId);
lastMouse = { ...pos };
mouseDown = true;
// --- Panning: space held or middle button ---
if (spaceDown || e.button === 1) {
isPanning = true;
return;
}
if (e.button !== 0) return; // only left button for tools
const world = screenToWorld(pos.x, pos.y);
switch (currentTool) {
case 'select': handleSelectDown(pos, world, e); break;
case 'pen': handlePenDown(pos, world); break;
case 'rectangle': handleShapeDown(world); break;
case 'ellipse': handleShapeDown(world); break;
case 'arrow': handleShapeDown(world); break;
case 'text': handleTextDown(world, pos); break;
case 'eraser': handleEraserDown(world); break;
}
}
function handleSelectDown(pos, world, e) {
// check resize handle first (only if something selected & tool is select)
if (selectedArr.length && overlayRect) {
const corner = hitCorner(pos);
if (corner >= 0) {
resizeCorner = corner;
dragMode = 'resize';
// snapshot start boxes
resizeStartBoxes = selectedArr.map(o => bbox(o));
dragStartWorld = world;
return;
}
}
// hit-test (ignore already-selected unless shift)
const hit = hitTest(world, null);
if (hit) {
if (e.shiftKey && selected.has(hit)) {
// deselect if already selected & shift
selected.delete(hit);
updateSelectedArr();
render();
return;
}
if (!e.shiftKey && !selected.has(hit)) {
selected.clear();
}
selected.add(hit);
updateSelectedArr();
dragMode = 'move';
// snapshot objects (world positions) at drag start for single-step undo
dragStartObjects = selectedArr.map(o => JSON.parse(JSON.stringify(o)));
dragStartWorld = world;
render();
} else {
// start rubber band
clearSelection();
rubber.active = true;
rubber.x0 = pos.x; rubber.y0 = pos.y;
rubber.x1 = pos.x; rubber.y1 = pos.y;
}
}
function handlePenDown(pos, world) {
previewObj = {
type: 'freehand',
points: [{ x: world.x, y: world.y }],
stroke: currentStroke(), fill: 'transparent',
strokeWidth: currentWidth()
};
lastPenWorld = { ...world };
}
function handleShapeDown(world) {
const type = currentTool;
previewObj = makeShapePreview(type, world);
previewStart = { ...world };
}
function handleTextDown(world, pos) {
const text = {
id: uid(), type: 'text',
x: world.x, y: world.y,
text: '', stroke: currentStroke(), fill: 'transparent',
strokeWidth: 16, charW: 8, lineH: 18
};
scene.push(text);
pushHistory();
persist();
openTextEditor(text, pos);
}
function handleEraserDown(world) {
dragMode = 'eraser';
eraserTargets = new Set();
eraseNear(world);
}
let eraserTargets = null;
/* ============================================================
POINTER MOVE
============================================================ */
function onPointerMove(e) {
const pos = getPointerPos(e);
const world = screenToWorld(pos.x, pos.y);
if (isPanning) {
viewport.x += pos.x - lastMouse.x;
viewport.y += pos.y - lastMouse.y;
lastMouse = { ...pos };
render();
return;
}
if (mouseDown && dragMode === 'move') {
const dx = world.x - dragStartWorld.x;
const dy = world.y - dragStartWorld.y;
for (let i = 0; i < selectedArr.length; i++) {
const o = selectedArr[i];
const start = dragStartObjects[i];
moveObject(o, start, dx, dy);
}
render();
lastMouse = { ...pos };
return;
}
if (mouseDown && dragMode === 'resize') {
performResize(world);
render();
lastMouse = { ...pos };
return;
}
if (mouseDown && dragMode === 'eraser') {
eraseNear(world);
render();
lastMouse = { ...pos };
return;
}
// live previews
if (mouseDown && previewObj) {
updatePreview(world);
render();
}
if (rubber.active) {
rubber.x1 = pos.x; rubber.y1 = pos.y;
render();
}
// update cursor for select tool
updateCursor(pos);
lastMouse = { ...pos };
}
function moveObject(o, start, dx, dy) {
switch (o.type) {
case 'rectangle': case 'ellipse':
o.x = start.x + dx; o.y = start.y + dy; break;
case 'arrow':
o.x1 = start.x1 + dx; o.y1 = start.y1 + dy;
o.x2 = start.x2 + dx; o.y2 = start.y2 + dy; break;
case 'freehand':
for (let i = 0; i < o.points.length; i++) {
o.points[i].x = start.points[i].x + dx;
o.points[i].y = start.points[i].y + dy;
}
break;
case 'text':
o.x = start.x + dx; o.y = start.y + dy; break;
}
}
function performResize(world) {
if (selectedArr.length !== 1) return;
const o = selectedArr[0];
const start = resizeStartBoxes[0];
const corner = resizeCorner;
const newBox = normRect(start.x, start.y, start.w, start.h);
// The dragged corner moves to world point (world.x, world.y).
// Other corners stay fixed. Compute new box, then remap object points.
let nx = newBox.x, ny = newBox.y, nw = newBox.w, nh = newBox.h;
// corner constraints: 0=tl,1=tr,2=bl,3=br
if (corner === 0 || corner === 2) { nx = world.x; nw = start.x + start.w - world.x; }
if (corner === 0 || corner === 1) { ny = world.y; nh = start.y + start.h - world.y; }
if (corner === 1 || corner === 3) { nw = world.x - start.x; }
if (corner === 2 || corner === 3) { nh = world.y - start.y; }
if (shiftHeld) {
const s = Math.max(nw, nh);
nw = s; nh = s;
}
nw = Math.max(8, nw); nh = Math.max(8, nh);
const target = { x: nx, y: ny, w: nw, h: nh };
applyResize(o, start, target);
}
function applyResize(o, startBox, targetBox) {
switch (o.type) {
case 'rectangle': case 'ellipse': {
o.x = targetBox.x; o.y = targetBox.y; o.w = targetBox.w; o.h = targetBox.h;
break;
}
case 'arrow': {
// scale endpoints relative to box
const sx = targetBox.w / startBox.w, sy = targetBox.h / startBox.h;
const rx0 = o.x1 - startBox.x, ry0 = o.y1 - startBox.y;
const rx1 = o.x2 - startBox.x, ry1 = o.y2 - startBox.y;
o.x1 = targetBox.x + rx0 * sx; o.y1 = targetBox.y + ry0 * sy;
o.x2 = targetBox.x + rx1 * sx; o.y2 = targetBox.y + ry1 * sy;
break;
}
case 'freehand': {
const sx = startBox.w ? targetBox.w / startBox.w : 0;
const sy = startBox.h ? targetBox.h / startBox.h : 0;
for (let i = 0; i < o.points.length; i++) {
o.points[i].x = targetBox.x + (o.points[i].x - startBox.x) * sx;
o.points[i].y = targetBox.y + (o.points[i].y - startBox.y) * sy;
}
break;
}
case 'text': {
const sx = startBox.w ? targetBox.w / startBox.w : 0;
const sy = startBox.h ? targetBox.h / startBox.h : 0;
o.x = targetBox.x + (o.x - startBox.x) * sx;
o.y = targetBox.y + (o.y - startBox.y) * sy;
o.charW = (o.charW || 8) * sx;
o.lineH = (o.lineH || 18) * sy;
break;
}
}
}
function updatePreview(world) {
if (previewObj.type === 'freehand') {
// add point if moved beyond sampling distance
const last = previewObj.points[previewObj.points.length - 1];
if (Math.hypot(world.x - last.x, world.y - last.y) > 3 / viewport.scale) {
previewObj.points.push({ x: world.x, y: world.y });
}
} else {
// shapes & arrow: live rect from previewStart to world
const a = previewStart, b = world;
if (currentTool === 'arrow') {
previewObj.x1 = a.x; previewObj.y1 = a.y;
previewObj.x2 = b.x; previewObj.y2 = b.y;
} else {
previewObj.x = a.x; previewObj.y = a.y;
previewObj.w = b.x - a.x; previewObj.h = b.y - a.y;
if (shiftHeld && previewObj.w !== 0 && previewObj.h !== 0) {
const s = Math.max(Math.abs(previewObj.w), Math.abs(previewObj.h));
previewObj.w = Math.sign(previewObj.w || 1) * s;
previewObj.h = Math.sign(previewObj.h || 1) * s;
}
}
}
}
function makeShapePreview(type, world) {
const base = {
type, stroke: currentStroke(), fill: currentFill(), strokeWidth: currentWidth()
};
if (type === 'arrow') {
return { ...base, x1: world.x, y1: world.y, x2: world.x, y2: world.y };
}
return { ...base, x: world.x, y: world.y, w: 0, h: 0 };
}
/* ============================================================
POINTER UP
============================================================ */
function onPointerUp(e) {
if (isPanning) { isPanning = false; return; }
if (dragMode === 'move') {
// commit to history only if the objects actually moved
let moved = false;
for (let i = 0; i < selectedArr.length; i++) {
const o = selectedArr[i], s = dragStartObjects[i];
if (o !== s && JSON.stringify(o) !== JSON.stringify(s)) { moved = true; break; }
}
if (moved) {
pushHistory(); persist(); refreshHistoryButtons();
}
updateSelectedArr();
render();
}
if (dragMode === 'resize') {
let resized = false;
for (let i = 0; i < selectedArr.length; i++) {
const o = selectedArr[i], b = bbox(o), sb = resizeStartBoxes[i];
if (JSON.stringify({ x: b.x, y: b.y, w: b.w, h: b.h }) !==
JSON.stringify({ x: sb.x, y: sb.y, w: sb.w, h: sb.h })) { resized = true; break; }
}
if (resized) {
pushHistory(); persist(); refreshHistoryButtons();
}
updateSelectedArr();
render();
}
if (dragMode === 'eraser') {
if (eraserTargets && eraserTargets.size) {
scene = scene.filter(o => !eraserTargets.has(o));
clearSelection();
pushHistory();
persist();
refreshHistoryButtons();
}
render();
}
if (rubber.active) {
commitRubberBand();
}
if (previewObj) {
// Ensure the preview reflects the final drag position before committing
if (previewObj.type === 'freehand' || previewStart) {
const up = screenToWorld(e.clientX, e.clientY);
updatePreview(up);
}
commitPreview();
previewObj = null;
previewStart = null;
}
dragMode = null; resizeCorner = -1; dragStartWorld = null;
dragStartObjects = null; resizeStartBoxes = null;
rubber = { active: false, x0: 0, y0: 0, x1: 0, y1: 0 };
mouseDown = false;
updateCursor();
}
function commitPreview() {
if (!previewObj) return;
const valid = isValidCreation(previewObj);
if (valid) {
previewObj.id = uid();
scene.push(previewObj);
pushHistory();
persist();
refreshHistoryButtons();
}
}
function isValidCreation(o) {
if (o.type === 'freehand') return o.points.length >= 2;
if (o.type === 'arrow') return Math.hypot(o.x2 - o.x1, o.y2 - o.y1) > 5 / viewport.scale;
const b = bbox(o);
return b.w > 5 / viewport.scale || b.h > 5 / viewport.scale;
}
function commitRubberBand() {
const a = screenToWorld(rubber.x0, rubber.y0);
const b = screenToWorld(rubber.x1, rubber.y1);
const sel = normRect(a.x, a.y, b.x - a.x, b.y - a.y);
const t = 4 / viewport.scale;
const hits = scene.filter(o => {
const bb = bbox(o);
return bb.x >= sel.x - t && bb.y >= sel.y - t &&
bb.x + bb.w <= sel.x + sel.w + t && bb.y + bb.h <= sel.y + sel.h + t;
});
if (hits.length) {
selected = new Set(hits);
updateSelectedArr();
render();
}
}
function eraseNear(world) {
const r = CONFIG.eraserRadius / viewport.scale;
for (const o of scene) {
const b = bbox(o);
if (world.x < b.x - r || world.x > b.x + b.w + r ||
world.y < b.y - r || world.y > b.y + b.h + r) continue;
let hit = false;
if (o.type === 'freehand') {
hit = strokeDist(o.points, world) <= r;
} else {
hit = hitObject(o, world, 0);
}
if (hit) eraserTargets.add(o);
}
// redraw eraser cursor region via a quick render handled by caller
}
/* ============================================================
CORNER HIT (resize handles)
============================================================ */
function hitCorner(pos) {
if (!overlayRect) return -1;
const hs = CONFIG.handleSize;
const t = hs / 2 + 4;
const corners = overlayRect.corners;
for (let i = 0; i < corners.length; i++) {
const [cx, cy] = corners[i];
if (Math.abs(pos.x - cx) <= t && Math.abs(pos.y - cy) <= t) return i;
}
return -1;
}
/* ============================================================
KEYBOARD
============================================================ */
window.addEventListener('keydown', (e) => {
const editing = el.textEdit.style.display !== 'none';
if (e.key === ' ') {
if (!spaceDown) { spaceDown = true; el.canvas.style.cursor = 'grab'; }
e.preventDefault();
return;
}
if (editing) return; // let text editor handle typing
if (e.ctrlKey || e.metaKey) {
if (e.key === 'z' || e.key === 'Z') {
e.preventDefault();
if (e.shiftKey) redo(); else undo();
} else if (e.key === 'y' || e.key === 'Y') {
e.preventDefault(); redo();
}
return;
}
switch (e.key) {
case 'v': case 'V': setTool('select'); break;
case 'p': case 'P': setTool('pen'); break;
case 'r': case 'R': setTool('rectangle'); break;
case 'o': case 'O': setTool('ellipse'); break;
case 'l': case 'L': setTool('arrow'); break;
case 't': case 'T': setTool('text'); break;
case 'e': case 'E': setTool('eraser'); break;
case 'Delete': case 'Backspace': deleteSelection(); break;
case 'Escape':
closeTextEditor();
clearSelection(); render();
break;
case 'ArrowUp': nudge(0, -1, e.shiftKey); break;
case 'ArrowDown': nudge(0, 1, e.shiftKey); break;
case 'ArrowLeft': nudge(-1, 0, e.shiftKey); break;
case 'ArrowRight': nudge(1, 0, e.shiftKey); break;
}
});
window.addEventListener('keyup', (e) => {
if (e.key === ' ') {
spaceDown = false;
if (!isPanning) updateCursor();
}
if (e.key === 'Shift') shiftHeld = false;
});
window.addEventListener('blur', () => { spaceDown = false; shiftHeld = false; });
el.canvas.addEventListener('dblclick', (e) => {
if (currentTool !== 'select') return;
const pos = getPointerPos(e);
const world = screenToWorld(pos.x, pos.y);
const hit = hitTest(world, null);
if (hit && hit.type === 'text') {
const rect = worldToScreen(hit.x, hit.y);
openTextEditor(hit, { x: rect.x, y: rect.y });
}
});
document.addEventListener('keydown', (e) => { if (e.key === 'Shift') shiftHeld = true; });
document.addEventListener('keyup', (e) => { if (e.key === 'Shift') shiftHeld = false; });
function nudge(dx, dy, big) {
if (!selectedArr.length) return;
const step = big ? CONFIG.nudgeBig : CONFIG.nudge;
const sdx = dx * step, sdy = dy * step;
pushHistory();
for (const o of selectedArr) {
moveObject(o, JSON.parse(JSON.stringify(o)), sdx, sdy);
}
render(); persist(); refreshHistoryButtons();
}
function deleteSelection() {
if (!selectedArr.length) return;
pushHistory();
scene = scene.filter(o => !selected.has(o));
clearSelection();
render(); persist(); refreshHistoryButtons();
}
/* ============================================================
WHEEL ZOOM (toward cursor)
============================================================ */
function onWheel(e) {
e.preventDefault();
const pos = getPointerPos(e);
const delta = -e.deltaY;
const factor = delta > 0 ? 1 + CONFIG.zoomStep : 1 - CONFIG.zoomStep;
zoomAt(pos.x, pos.y, viewport.scale * factor);
}
/* ============================================================
TEXT EDITOR OVERLAY
============================================================ */
let editingText = null;
function openTextEditor(obj, screenPos) {
editingText = obj;
const te = el.textEdit;
te.textContent = obj.text || '';
te.style.display = 'block';
te.style.left = screenPos.x + 'px';
te.style.top = screenPos.y + 'px';
te.style.color = obj.stroke;
te.style.fontSize = Math.max(10, 16 * viewport.scale) + 'px';
te.focus();
// set cursor at end
window.getSelection().selectAllChildren(te);
}
function commitText() {
const te = el.textEdit;
const obj = editingText;
if (obj) {
const raw = te.textContent || '';
if (raw !== obj.text) {
obj.text = raw;
pushHistory();
persist();
refreshHistoryButtons();
}
// measure
measureTextDimensions(obj);
}
editingText = null;
te.style.display = 'none';
render();
}
function closeTextEditor() {
if (editingText) commitText();
editingText = null;
el.textEdit.style.display = 'none';
}
function measureTextDimensions(obj) {
// approximate char width based on font
const tmp = document.createElement('span');
tmp.style.cssText = 'position:absolute;left:-9999px;top:-9999px;font:18px -apple-system,"Segoe UI",sans-serif;white-space:pre;';
tmp.textContent = 'm'.repeat(50);
document.body.appendChild(tmp);
const w = tmp.getBoundingClientRect().width / 50;
tmp.remove();
obj.charW = w;
obj.lineH = w * 1.2;
}
el.textEdit.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
commitText();
} else if (e.key === 'Escape') {
e.preventDefault();
editingText = null; // discard
el.textEdit.style.display = 'none';
} else {
// allow default typing
return;
}
});
el.textEdit.addEventListener('blur', () => { commitText(); });
/* ============================================================
TOOLS UI
============================================================ */
function setTool(tool) {
currentTool = tool;
document.querySelectorAll('.tool-btn').forEach(b => {
b.classList.toggle('active', b.dataset.tool === tool);
});
el.canvas.style.cursor = tool === 'text' ? 'text' :
(tool === 'eraser' ? 'cell' : 'default');
if (tool !== 'select') { closeTextEditor(); clearSelection(); render(); }
updateCursor();
}
document.querySelectorAll('.tool-btn').forEach(btn => {
btn.addEventListener('click', () => setTool(btn.dataset.tool));
});
/* ============================================================
PROPERTIES BAR
============================================================ */
function currentStroke() { return document.getElementById('stroke-color').value; }
function currentFill() {
const v = document.getElementById('fill-color').value;
return v;
}
function currentWidth() { return parseInt(document.getElementById('width-slider').value, 10); }
// build palettes
function buildPalettes() {
const sp = document.getElementById('stroke-palette');
const fp = document.getElementById('fill-palette');
CONFIG.colors.forEach(c => {
const s = document.createElement('button');
s.type = 'button';
s.className = 'swatch';
s.style.background = c;
s.title = c;
s.addEventListener('click', () => {
document.getElementById('stroke-color').value = c;
updatePaletteActive('stroke');
applyToSelection();
});
sp.appendChild(s);
const f = document.createElement('button');
f.type = 'button';
f.className = 'swatch';
f.style.background = c;
f.title = c;
f.addEventListener('click', () => {
document.getElementById('fill-color').value = c;
updatePaletteActive('fill');
applyToSelection();
});
fp.appendChild(f);
});
updatePaletteActive('stroke');
updatePaletteActive('fill');
}
function updatePaletteActive(kind) {
const val = kind === 'stroke' ? currentStroke() : currentFill();
const paletteId = kind === 'stroke' ? 'stroke-palette' : 'fill-palette';
document.querySelectorAll('#' + paletteId + ' .swatch').forEach(s => {
s.classList.toggle('active', s.title.toLowerCase() === val.toLowerCase());
});
}
function applyToSelection() {
if (!selectedArr.length) return;
pushHistory();
const stroke = currentStroke();
const fill = currentFill();
const width = currentWidth();
for (const o of selectedArr) {
o.stroke = stroke;
o.fill = fill;
if (o.type !== 'text') o.strokeWidth = width;
}
render(); persist(); refreshHistoryButtons();
}
// color inputs
document.getElementById('stroke-color').addEventListener('input', (e) => {
updatePaletteActive('stroke');
if (selectedArr.length) { applyToSelection(); }
});
document.getElementById('fill-color').addEventListener('input', (e) => {
updatePaletteActive('fill');
if (selectedArr.length) { applyToSelection(); }
});
const widthSlider = document.getElementById('width-slider');
const widthVal = document.getElementById('width-val');
widthSlider.addEventListener('input', () => {
widthVal.textContent = widthSlider.value;
if (selectedArr.length) { applyToSelection(); }
});
// set initial palette values
document.getElementById('stroke-color').value = CONFIG.defaultStroke;
document.getElementById('fill-color').value = CONFIG.defaultFill;
widthSlider.value = CONFIG.strokeWidth;
widthVal.textContent = CONFIG.strokeWidth;
// apply property changes to selection on change (commit)
document.getElementById('stroke-color').addEventListener('change', () => applyToSelection());
document.getElementById('fill-color').addEventListener('change', () => applyToSelection());
/* ============================================================
ZOOM CONTROLS
============================================================ */
document.getElementById('zoom-in').addEventListener('click', () => {
zoomAt(window.innerWidth / 2, window.innerHeight / 2, viewport.scale * (1 + CONFIG.zoomStep));
});
document.getElementById('zoom-out').addEventListener('click', () => {
zoomAt(window.innerWidth / 2, window.innerHeight / 2, viewport.scale * (1 - CONFIG.zoomStep));
});
document.getElementById('zoom-val').addEventListener('click', resetView);
document.getElementById('undo-btn').addEventListener('click', undo);
document.getElementById('redo-btn').addEventListener('click', redo);
// prevent zoom via trackpad pinch default (we handle wheel)
canvas.addEventListener('touchstart', () => {}, { passive: true });
/* ============================================================
CURSOR HELPERS
============================================================ */
function updateCursor(pos) {
if (spaceDown) { el.canvas.style.cursor = 'grab'; return; }
if (currentTool === 'text') { el.canvas.style.cursor = 'text'; return; }
if (currentTool === 'eraser') { el.canvas.style.cursor = 'cell'; return; }
if (currentTool === 'select') {
if (!pos) return;
if (selectedArr.length && overlayRect && hitCorner(pos) >= 0) {
const c = hitCorner(pos);
if (c === 0 || c === 3) el.canvas.style.cursor = 'nwse-resize';
else if (c === 1 || c === 2) el.canvas.style.cursor = 'nesw-resize';
else el.canvas.style.cursor = 'move';
return;
}
el.canvas.style.cursor = 'default';
}
}
/* ============================================================
INIT
============================================================ */
function init() {
buildPalettes();
const hadScene = loadScene();
if (!hadScene) {
// starter content
scene = [
{ id: uid(), type: 'rectangle', x: 120, y: 140, w: 200, h: 140,
stroke: '#1a71ff', fill: '#e7f0ff', strokeWidth: 3 },
{ id: uid(), type: 'ellipse', x: 420, y: 160, w: 150, h: 150,
stroke: '#2f9e44', fill: '#e7f8ec', strokeWidth: 3 },
{ id: uid(), type: 'arrow', x1: 380, y1: 340, x2: 560, y2: 340,
stroke: '#e03131', fill: 'transparent', strokeWidth: 3 },
{ id: uid(), type: 'text', x: 120, y: 330, text: 'Welcome to the Whiteboard',
stroke: '#1e1e1e', fill: 'transparent', strokeWidth: 16, charW: 8.6, lineH: 21 }
];
}
history = [JSON.parse(JSON.stringify(scene))];
histIndex = 0;
resizeCanvas();
updateZoomDisplay();
refreshHistoryButtons();
render();
// center view a bit
viewport.x = 40; viewport.y = 40;
render();
}
init();
</script>
</body>
</html>
<!-- agent-meta {"model":"bartowski/Ornith-1.5-35B-A3B","provider":"brigid","persona":"full","sessionId":"12235992-0b0d-4be3-b4c5-7fb426375fb4","tokensIn":3925398,"tokensOut":68944,"tokensTotal":3994342,"cost":0,"turns":73,"toolCalls":72,"failedToolCalls":0,"timestamp":"2026-08-23T21:50:41.775Z"} -->