Whiteboard
ox alpha · 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 35,072 bytes · SHA-256 abea4ec46995
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Whiteboard</title>
<style>
/* ============================ APP CHROME ============================ */
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #e8e6e1;
color: #2b2a27;
user-select: none;
-webkit-user-select: none;
}
#app { position: relative; width: 100%; height: 100%; }
#canvas {
position: absolute; inset: 0; width: 100%; height: 100%;
display: block; touch-action: none; cursor: default;
}
/* ============================ TOOLBAR ============================ */
#toolbar {
position: absolute; top: 14px; left: 50%; transform: translateX(-50%);
display: flex; align-items: center; gap: 4px;
background: #ffffff; border-radius: 12px; padding: 6px;
box-shadow: 0 2px 10px rgba(0,0,0,.12), 0 0 0 1px rgba(0,0,0,.05);
z-index: 10;
}
.tool-btn {
width: 38px; height: 38px; border: none; border-radius: 8px;
background: transparent; cursor: pointer; color: #444;
display: flex; align-items: center; justify-content: center;
font-size: 16px; transition: background .12s, color .12s;
}
.tool-btn:hover { background: #efedea; }
.tool-btn.active { background: #e0d7ff; color: #5b34c7; }
.tool-btn svg { width: 20px; height: 20px; stroke: currentColor; fill: none; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.sep { width: 1px; height: 24px; background: #ddd; margin: 0 4px; }
/* ============================ PROPERTIES BAR ============================ */
#props {
position: absolute; top: 68px; left: 50%; transform: translateX(-50%);
display: flex; align-items: center; gap: 14px;
background: #fff; border-radius: 10px; padding: 7px 14px;
box-shadow: 0 2px 8px rgba(0,0,0,.10), 0 0 0 1px rgba(0,0,0,.05);
z-index: 9; font-size: 11px; color: #888;
}
.prop-group { display: flex; align-items: center; gap: 5px; }
.swatches { display: flex; gap: 4px; }
.swatch {
width: 20px; height: 20px; border-radius: 6px; cursor: pointer;
border: 2px solid transparent; outline: 1px solid rgba(0,0,0,.12);
}
.swatch.selected { border-color: #5b34c7; outline-color: transparent; }
.swatch.fill-none { background: #fff; position: relative; }
.swatch.fill-none::after {
content: ""; position: absolute; left: -2px; top: 8px; width: 22px; height: 1.5px;
background: #d33; transform: rotate(-45deg);
}
.width-opt {
padding: 4px 8px; border-radius: 6px; cursor: pointer; background: #f2f0ec;
}
.width-opt.selected { background: #e0d7ff; color: #5b34c7; font-weight: 600; }
.prop-label { margin-right: 2px; letter-spacing: .04em; text-transform: uppercase; font-size: 10px; }
/* ============================ ZOOM HUD ============================ */
#zoomhud {
position: absolute; right: 16px; bottom: 16px; z-index: 10;
display: flex; align-items: center; gap: 6px;
background: #fff; border-radius: 10px; padding: 6px 10px;
box-shadow: 0 2px 8px rgba(0,0,0,.10), 0 0 0 1px rgba(0,0,0,.05);
font-size: 13px; color: #555;
}
#zoomhud button {
border: none; background: transparent; cursor: pointer; font-size: 15px;
color: #666; width: 24px; height: 24px; border-radius: 6px;
}
#zoomhud button:hover { background: #efedea; }
#resetview { font-size: 11px !important; width: auto !important; padding: 0 8px; }
#hint {
position: absolute; bottom: 18px; left: 18px; z-index: 5;
font-size: 11px; color: #999; pointer-events: none;
}
</style>
</head>
<body>
<div id="app">
<canvas id="canvas"></canvas>
<!-- ============================ TOOLBAR ============================ -->
<div id="toolbar">
<button class="tool-btn active" data-tool="select" title="Select (V)">
<svg viewBox="0 0 24 24"><path d="M5 3l14 8-6.5 1.5L9 19z"/></svg>
</button>
<button class="tool-btn" data-tool="pen" title="Pen (P)">
<svg viewBox="0 0 24 24"><path d="M4 20c1-4 3-6 6-6M14 5l5 5L9 20l-5 1 1-5z"/></svg>
</button>
<button class="tool-btn" data-tool="rect" title="Rectangle (R)">
<svg viewBox="0 0 24 24"><rect x="4" y="6" width="16" height="12" rx="1"/></svg>
</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"/></svg>
</button>
<button class="tool-btn" data-tool="arrow" title="Line / Arrow (L)">
<svg viewBox="0 0 24 24"><path d="M5 19L19 5M19 5h-7M19 5v7"/></svg>
</button>
<button class="tool-btn" data-tool="text" title="Text (T)">
<svg viewBox="0 0 24 24"><path d="M6 6h12M12 6v13"/></svg>
</button>
<button class="tool-btn" data-tool="eraser" title="Eraser (E)">
<svg viewBox="0 0 24 24"><path d="M8 20H21M4 15l8-8 6 6-6 6H8z"/></svg>
</button>
<div class="sep"></div>
<button class="tool-btn" id="undoBtn" title="Undo (Ctrl+Z)">
<svg viewBox="0 0 24 24"><path d="M8 5L3 10l5 5M3 10h11a6 6 0 0 1 0 12h-3"/></svg>
</button>
<button class="tool-btn" id="redoBtn" title="Redo (Ctrl+Shift+Z)">
<svg viewBox="0 0 24 24"><path d="M16 5l5 5-5 5M21 10H10a6 6 0 0 0 0 12h3"/></svg>
</button>
</div>
<!-- ============================ PROPERTIES BAR ============================ -->
<div id="props">
<div class="prop-group"><span class="prop-label">Stroke</span><div class="swatches" id="strokeSwatches"></div></div>
<div class="prop-group"><span class="prop-label">Fill</span><div class="swatches" id="fillSwatches"></div></div>
<div class="prop-group"><span class="prop-label">Width</span><div id="widthOpts"></div></div>
</div>
<!-- ============================ ZOOM HUD ============================ -->
<div id="zoomhud">
<button id="zoomOut" title="Zoom out">−</button>
<span id="zoomPct">100%</span>
<button id="zoomIn" title="Zoom in">+</button>
<button id="resetview" title="Reset view (Ctrl+0)">Reset</button>
</div>
<div id="hint">V P R O L T E · Space+drag pan · wheel zoom · Del delete · arrows nudge</div>
</div>
<script>
"use strict";
/* ================================================================
TUNABLE CONSTANTS
================================================================ */
const K = {
ZOOM_MIN: 0.25,
ZOOM_MAX: 4.0,
HIT_TOL: 8 / 1, // px (screen space) tolerance for hit tests
HANDLE_PX: 9, // resize handle half-size in screen px
GRID: 24, // world-space dot grid spacing
MIN_SHAPE: 4, // min world size when creating/resizing
NUDGE_STEP: 1,
NUDGE_BIG: 10,
SAVE_KEY: "whiteboard.scene.v1",
};
const STROKE_COLORS = ["#1e1e1e", "#e03131", "#2f9e44", "#1971c2", "#f08c00"];
const FILL_COLORS = [null, "#ffe3e3", "#d3f9d8", "#d0ebff", "#fff3bf", "#eee0ff"];
const STROKE_WIDTHS = [2, 4, 8];
const DEFAULTS = { stroke: "#1e1e1e", fill: null, width: 2 };
/* ================================================================
STATE
================================================================ */
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
let DPR = window.devicePixelRatio || 1;
const state = {
tool: "select",
shapes: [], // serializable scene array
selection: new Set(), // ids
view: { x: 0, y: 0, zoom: 1 }, // screen = world * zoom + (x,y)
spaceDown: false,
// transient gesture data
gesture: null, // {kind:'draw'|'move'|'resize'|'pan'|'marquee'|'erase', ...}
editing: null, // {id} while text editor open
};
/* ================================================================
COORDINATE HELPERS
================================================================ */
const toWorld = (sx, sy) => ({ x: (sx - state.view.x) / state.view.zoom, y: (sy - state.view.y) / state.view.zoom });
const toScreen = (wx, wy) => ({ x: wx * state.view.zoom + state.view.x, y: wy * state.view.zoom + state.view.y });
function evPos(e) { const r = canvas.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; }
/* ================================================================
SHAPE MODEL + GEOMETRY
================================================================ */
let nextId = 1;
const uid = () => "s" + (nextId++) + "_" + Math.random().toString(36).slice(2, 6);
function makeShape(type, x, y) {
const base = { id: uid(), type, stroke: DEFAULTS.stroke, width: DEFAULTS.width };
if (type === "rect" || type === "ellipse") return Object.assign(base, { x, y, w: 0, h: 0, fill: DEFAULTS.fill });
if (type === "arrow") return Object.assign(base, { x1: x, y1: y, x2: x, y2: y });
if (type === "text") return Object.assign(base, { x, y, text: "", size: 20, fill: null });
return base;
}
function bboxOf(s) {
if (s.type === "arrow") return normRect(s.x1, s.y1, s.x2, s.y2);
if (s.type === "freehand") {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const [px, py] of s.points) { if (px<minX)minX=px; if (py<minY)minY=py; if (px>maxX)maxX=px; if (py>maxY)maxY=py; }
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
}
if (s.type === "text") {
ctx.font = fontOf(s);
const m = ctx.measureText(s.text || " ");
const lines = (s.text || "").split("\n");
return { x: s.x, y: s.y - s.size, w: Math.max(...lines.map(l => ctx.measureText(l).width)), h: s.size * lines.length * 1.25 };
}
return { x: s.x, y: s.y, w: s.w, h: s.h };
}
const normRect = (x1, y1, x2, y2) => ({ x: Math.min(x1,x2), y: Math.min(y1,y2), w: Math.abs(x1-x2), h: Math.abs(y1-y2) });
const fontOf = s => `${s.size}px "Comic Sans MS", "Segoe Print", cursive, sans-serif`;
function unionBBox(ids) {
let r = null;
for (const id of ids) {
const b = bboxOf(shapeById(id));
r = r ? { x: Math.min(r.x,b.x), y: Math.min(r.y,b.y), w: Math.max(r.x+r.w,b.x+b.w)-Math.min(r.x,b.x), h: Math.max(r.y+r.h,b.y+b.h)-Math.min(r.y,b.y) } : b;
}
return r;
}
const shapeById = id => state.shapes.find(s => s.id === id);
// translate a shape by dx,dy in world coords
function translateShape(s, dx, dy) {
if (s.type === "arrow") { s.x1+=dx; s.y1+=dy; s.x2+=dx; s.y2+=dy; }
else if (s.type === "freehand") { for (const p of s.points) { p[0]+=dx; p[1]+=dy; } }
else { s.x += dx; s.y += dy; }
}
// linearly remap a shape's geometry from old bbox -> new bbox (used by resize)
function remapShape(s, ob, nb) {
const sx = ob.w ? nb.w / ob.w : 1, sy = ob.h ? nb.h / ob.h : 1;
const mapPt = (x, y) => [nb.x + (x - ob.x) * sx, nb.y + (y - ob.y) * sy];
if (s.type === "arrow") { [s.x1,s.y1]=mapPt(s.x1,s.y1); [s.x2,s.y2]=mapPt(s.x2,s.y2); }
else if (s.type === "freehand") { for (const p of s.points) { [p[0],p[1]]=mapPt(p[0],p[1]); } }
else if (s.type === "text") { const k = Math.max(sx, sy); s.size = Math.max(6, s.size * k); const [x,y] = mapPt(s.x, s.y + s.size/k); s.x=x; s.y=y; }
else { s.x = nb.x; s.y = nb.y; s.w = nb.w; s.h = nb.h; }
}
/* ================================================================
HIT TESTING (world coordinates)
================================================================ */
function distToSeg(px, py, x1, y1, x2, y2) {
const dx = x2-x1, dy = y2-y1, len2 = dx*dx + dy*dy;
const t = len2 ? Math.max(0, Math.min(1, ((px-x1)*dx + (py-y1)*dy) / len2)) : 0;
return Math.hypot(px - (x1 + t*dx), py - (y1 + t*dy));
}
function hitTest(wx, wy, s) {
const tol = K.HIT_TOL / state.view.zoom + s.width / 2;
if (s.type === "freehand") {
const pts = s.points;
for (let i = 0; i < pts.length - 1; i++)
if (distToSeg(wx, wy, pts[i][0], pts[i][1], pts[i+1][0], pts[i+1][1]) <= tol) return true;
return false;
}
if (s.type === "arrow")
return distToSeg(wx, wy, s.x1, s.y1, s.x2, s.y2) <= tol;
if (s.type === "rect") {
const inX = wx >= s.x - tol && wx <= s.x + s.w + tol;
const inY = wy >= s.y - tol && wy <= s.y + s.h + tol;
if (!inX || !inY) return false;
if (s.fill) return true;
return true;
}
if (s.type === "ellipse") {
const rx = s.w/2, ry = s.h/2;
if (rx <= 0 || ry <= 0) return false;
const nx = (wx - (s.x+rx)) / rx, ny = (wy - (s.y+ry)) / ry;
const d = nx*nx + ny*ny;
return d <= 1 + tol/Math.min(rx,ry);
}
if (s.type === "text") {
const b = bboxOf(s);
return wx >= b.x && wx <= b.x+b.w && wy >= b.y && wy <= b.y+b.h;
}
return false;
}
function pickTopmost(wx, wy) {
for (let i = state.shapes.length - 1; i >= 0; i--)
if (hitTest(wx, wy, state.shapes[i])) return state.shapes[i];
return null;
}
// corner handles of selection bbox, in world coords
function selBBox() { return state.selection.size ? unionBBox([...state.selection]) : null; }
function handlePositions(b) {
return [
{ id: 0, x: b.x, y: b.y }, { id: 1, x: b.x + b.w, y: b.y },
{ id: 2, x: b.x, y: b.y + b.h }, { id: 3, x: b.x + b.w, y: b.y + b.h },
];
}
function handleAtScreen(sx, sy) {
const b = selBBox();
if (!b) return -1;
for (const h of handlePositions(b)) {
const p = toScreen(h.x, h.y);
if (Math.abs(sx - p.x) <= K.HANDLE_PX && Math.abs(sy - p.y) <= K.HANDLE_PX) return h.id;
}
return -1;
}
/* ================================================================
RENDERING
================================================================ */
function resizeCanvas() {
DPR = window.devicePixelRatio || 1;
canvas.width = canvas.clientWidth * DPR;
canvas.height = canvas.clientHeight * DPR;
}
window.addEventListener("resize", resizeCanvas);
function draw() {
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
drawGrid();
ctx.save();
ctx.translate(state.view.x, state.view.y);
ctx.scale(state.view.zoom, state.view.zoom);
for (const s of state.shapes) drawShape(s);
ctx.restore();
drawOverlay();
document.getElementById("zoomPct").textContent = Math.round(state.view.zoom * 100) + "%";
}
function drawGrid() {
const { x, y, zoom } = state.view;
const W = canvas.clientWidth, H = canvas.clientHeight;
let step = K.GRID;
while (step * zoom < 14) step *= 4;
const startX = Math.floor((-x) / (step * zoom)) * step;
const startY = Math.floor((-y) / (step * zoom)) * step;
ctx.fillStyle = "rgba(30,30,30,.13)";
const r = Math.max(0.7, 1.1 * Math.min(zoom, 1));
for (let wx = startX; toScreen(wx, 0).x < W; wx += step)
for (let wy = startY; toScreen(0, wy).y < H; wy += step) {
const p = toScreen(wx, wy);
ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, 7); ctx.fill();
}
}
function drawShape(s) {
ctx.lineWidth = s.width;
ctx.strokeStyle = s.stroke;
ctx.lineCap = "round"; ctx.lineJoin = "round";
if (s.type === "freehand") {
const pts = s.points;
if (!pts.length) return;
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
if (pts.length < 3) { for (const p of pts) ctx.lineTo(p[0], p[1]); }
else {
for (let i = 1; i < pts.length - 1; i++) {
const mx = (pts[i][0] + pts[i+1][0]) / 2, my = (pts[i][1] + pts[i+1][1]) / 2;
ctx.quadraticCurveTo(pts[i][0], pts[i][1], mx, my);
}
ctx.lineTo(pts[pts.length-1][0], pts[pts.length-1][1]);
}
ctx.stroke();
} else if (s.type === "rect") {
if (s.fill) { ctx.fillStyle = s.fill; ctx.fillRect(s.x, s.y, s.w, s.h); }
ctx.strokeRect(s.x, s.y, s.w, s.h);
} else if (s.type === "ellipse") {
ctx.beginPath();
ctx.ellipse(s.x + s.w/2, s.y + s.h/2, Math.abs(s.w/2), Math.abs(s.h/2), 0, 0, 7);
if (s.fill) { ctx.fillStyle = s.fill; ctx.fill(); }
ctx.stroke();
} else if (s.type === "arrow") {
ctx.beginPath(); ctx.moveTo(s.x1, s.y1); ctx.lineTo(s.x2, s.y2); ctx.stroke();
const ang = Math.atan2(s.y2 - s.y1, s.x2 - s.x1);
const hl = Math.max(10, s.width * 4);
ctx.beginPath();
ctx.moveTo(s.x2, s.y2);
ctx.lineTo(s.x2 - hl*Math.cos(ang - 0.45), s.y2 - hl*Math.sin(ang - 0.45));
ctx.moveTo(s.x2, s.y2);
ctx.lineTo(s.x2 - hl*Math.cos(ang + 0.45), s.y2 - hl*Math.sin(ang + 0.45));
ctx.stroke();
} else if (s.type === "text") {
ctx.font = fontOf(s);
ctx.fillStyle = s.stroke;
ctx.textBaseline = "alphabetic";
const lines = (s.text || "").split("\n");
lines.forEach((ln, i) => ctx.fillText(ln, s.x, s.y + i * s.size * 1.25));
}
}
// selection UI, marquee, eraser cursor — drawn in SCREEN space
function drawOverlay() {
const b = selBBox();
if (b && !state.editing) {
const p1 = toScreen(b.x, b.y), p2 = toScreen(b.x + b.w, b.y + b.h);
ctx.strokeStyle = "#6b5ce7"; ctx.lineWidth = 1;
ctx.setLineDash([4, 3]);
ctx.strokeRect(p1.x - 4, p1.y - 4, p2.x - p1.x + 8, p2.y - p1.y + 8);
ctx.setLineDash([]);
ctx.fillStyle = "#fff";
for (const h of handlePositions(b)) {
const p = toScreen(h.x, h.y);
ctx.fillRect(p.x - K.HANDLE_PX/2, p.y - K.HANDLE_PX/2, K.HANDLE_PX, K.HANDLE_PX);
ctx.strokeRect(p.x - K.HANDLE_PX/2, p.y - K.HANDLE_PX/2, K.HANDLE_PX, K.HANDLE_PX);
}
}
const g = state.gesture;
if (g && g.kind === "marquee") {
const r = normRect(g.sx, g.sy, g.cx, g.cy);
ctx.fillStyle = "rgba(107,92,231,.08)";
ctx.strokeStyle = "#6b5ce7";
ctx.fillRect(r.x, r.y, r.w, r.h);
ctx.strokeRect(r.x, r.y, r.w, r.h);
}
}
(function loop() { draw(); requestAnimationFrame(loop); })();
/* ================================================================
HISTORY (undo/redo) — snapshot-based, one step per gesture
================================================================ */
const undoStack = [], redoStack = [];
let pendingSnap = null;
const serialize = () => JSON.stringify(state.shapes);
function beginAction() { pendingSnap = serialize(); }
function commitAction() {
if (pendingSnap === null) return;
const after = serialize();
if (after !== pendingSnap) {
undoStack.push(pendingSnap);
if (undoStack.length > 200) undoStack.shift();
redoStack.length = 0;
saveBoard();
}
pendingSnap = null;
}
function undo() {
if (!undoStack.length) return;
redoStack.push(serialize());
state.shapes = JSON.parse(undoStack.pop());
pruneSelection(); saveBoard();
}
function redo() {
if (!redoStack.length) return;
undoStack.push(serialize());
state.shapes = JSON.parse(redoStack.pop());
pruneSelection(); saveBoard();
}
function pruneSelection() {
for (const id of [...state.selection]) if (!shapeById(id)) state.selection.delete(id);
}
/* ================================================================
PERSISTENCE
================================================================ */
let saveTimer = null;
function saveBoard() {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
try { localStorage.setItem(K.SAVE_KEY, serialize()); } catch (e) {}
}, 250);
}
function loadBoard() {
try {
const raw = localStorage.getItem(K.SAVE_KEY);
if (raw) {
state.shapes = JSON.parse(raw);
// keep uid counter ahead of restored ids
for (const s of state.shapes) {
const m = /^s(\d+)_/.exec(s.id);
if (m) nextId = Math.max(nextId, +m[1] + 1);
}
}
} catch (e) {}
}
/* ================================================================
TEXT EDITOR OVERLAY
================================================================ */
const editor = document.createElement("textarea");
editor.id = "textEditor";
editor.style.cssText = "position:absolute;display:none;z-index:20;border:2px solid #6b5ce7;border-radius:4px;outline:none;resize:none;overflow:hidden;background:transparent;padding:0;font-family:'Comic Sans MS','Segoe Print',cursive,sans-serif;line-height:1.25;";
document.getElementById("app").appendChild(editor);
function openEditor(shape) {
closeEditor(true);
state.editing = { id: shape.id, isNew: !state.shapes.includes(shape) };
const s = shape;
editor.value = s.text || "";
editor.style.display = "block";
editor.style.color = s.stroke;
editor.style.fontSize = (s.size * state.view.zoom) + "px";
const p = toScreen(s.x, s.y - s.size);
editor.style.left = (p.x - 4) + "px";
editor.style.top = (p.y - 4) + "px";
editor.style.width = "300px";
editor.style.height = (s.size * 1.4 * state.view.zoom) + "px";
setTimeout(() => editor.focus(), 0);
if (state.editing.isNew) { beginAction(); state.shapes.push(s); } // keep in scene while editing
}
function closeEditor(cancel) {
if (!state.editing) return;
const s = shapeById(state.editing.id);
state.editing = null;
editor.style.display = "none";
if (!s) return;
if (cancel) { if (state.editing === null && !s.text) removeShape(s.id); return; }
s.text = editor.value.replace(/\s+$/, "");
if (!s.text) { removeShape(s.id); commitAction(); }
else { commitAction(); state.selection = new Set([s.id]); }
editor.value = "";
}
function removeShape(id) {
const i = state.shapes.findIndex(s => s.id === id);
if (i >= 0) state.shapes.splice(i, 1);
state.selection.delete(id);
}
editor.addEventListener("keydown", e => {
e.stopPropagation();
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); closeEditor(false); }
if (e.key === "Escape") { closeEditor(true); commitAction(); }
});
editor.addEventListener("blur", () => closeEditor(false));
/* ================================================================
POINTER INPUT
================================================================ */
let panLast = null;
canvas.addEventListener("contextmenu", e => e.preventDefault());
canvas.addEventListener("pointerdown", e => {
if (state.editing) closeEditor(false);
canvas.setPointerCapture(e.pointerId);
const sp = evPos(e);
// pan: space+drag or middle mouse
if (state.spaceDown || e.button === 1) {
state.gesture = { kind: "pan", last: sp };
canvas.style.cursor = "grabbing";
return;
}
if (e.button !== 0) return;
const w = toWorld(sp.x, sp.y);
if (state.tool === "select") {
const h = handleAtScreen(sp.x, sp.y);
if (h >= 0 && state.selection.size) {
beginAction();
const b = selBBox();
state.gesture = { kind: "resize", handle: h, orig: b, start: w };
return;
}
const hit = pickTopmost(w.x, w.y);
if (hit) {
if (e.shiftKey) {
state.selection.has(hit.id) ? state.selection.delete(hit.id) : state.selection.add(hit.id);
if (!state.selection.has(hit.id)) return; // removed from selection, no drag
} else if (!state.selection.has(hit.id)) {
state.selection = new Set([hit.id]);
}
beginAction();
const orig = new Map();
for (const id of state.selection) orig.set(id, JSON.stringify(shapeById(id)));
state.gesture = { kind: "move", start: w, orig, moved: false };
} else {
if (!e.shiftKey) state.selection.clear();
state.gesture = { kind: "marquee", sx: sp.x, sy: sp.y, cx: sp.x, cy: sp.y, base: new Set(state.selection), additive: e.shiftKey };
}
}
else if (state.tool === "pen") {
beginAction();
const s = makeShape("freehand", w.x, w.y);
s.points = [[w.x, w.y]];
state.shapes.push(s);
state.gesture = { kind: "draw", shape: s };
}
else if (state.tool === "rect" || state.tool === "ellipse" || state.tool === "arrow") {
beginAction();
const s = makeShape(state.tool, w.x, w.y);
if (s.type === "arrow") { s.x1 = w.x; s.y1 = w.y; s.x2 = w.x; s.y2 = w.y; }
state.shapes.push(s);
state.gesture = { kind: "draw", shape: s, start: w };
}
else if (state.tool === "text") {
const hit = pickTopmost(w.x, w.y);
if (hit && hit.type === "text") { openEditor(hit); return; }
const s = makeShape("text", w.x, w.y);
openEditor(s);
}
else if (state.tool === "eraser") {
beginAction();
state.gesture = { kind: "erase", removed: [] };
eraseAt(w);
}
});
canvas.addEventListener("pointermove", e => {
const sp = evPos(e);
const w = toWorld(sp.x, sp.y);
const g = state.gesture;
if (!g) { updateCursor(sp); return; }
if (g.kind === "pan") {
state.view.x += sp.x - g.last.x;
state.view.y += sp.y - g.last.y;
g.last = sp;
}
else if (g.kind === "draw") {
const s = g.shape;
if (s.type === "freehand") {
const pts = s.points, last = pts[pts.length - 1];
if (Math.hypot(w.x - last[0], w.y - last[1]) * state.view.zoom > 1.5) pts.push([w.x, w.y]);
} else if (s.type === "arrow") {
if (e.shiftKey) { // snap to 45°
const dx = w.x - g.start.x, dy = w.y - g.start.y;
const ang = Math.round(Math.atan2(dy, dx) / (Math.PI / 4)) * (Math.PI / 4);
const len = Math.hypot(dx, dy);
s.x2 = g.start.x + len * Math.cos(ang); s.y2 = g.start.y + len * Math.sin(ang);
} else { s.x2 = w.x; s.y2 = w.y; }
} else {
let x = Math.min(g.start.x, w.x), y = Math.min(g.start.y, w.y);
let ww = Math.abs(w.x - g.start.x), hh = Math.abs(w.y - g.start.y);
if (e.shiftKey) { const m = Math.max(ww, hh); ww = hh = m; if (w.x < g.start.x) x = g.start.x - m; if (w.y < g.start.y) y = g.start.y - m; }
s.x = x; s.y = y; s.w = ww; s.h = hh;
}
}
else if (g.kind === "move") {
const dx = w.x - g.start.x, dy = w.y - g.start.y;
if (!g.moved && Math.hypot(dx, dy) * state.view.zoom < 2) return;
g.moved = true;
for (const id of state.selection) {
const s = shapeById(id);
const o = JSON.parse(g.orig.get(id));
// restore original then apply offset (idempotent)
Object.assign(s, o);
translateShape(s, dx, dy);
}
}
else if (g.kind === "resize") {
const ob = g.orig;
const h = g.handle;
let ax = h === 0 || h === 2 ? ob.x + ob.w : ob.x; // anchor x (opposite corner)
let ay = h === 0 || h === 1 ? ob.y + ob.h : ob.y;
let nx = w.x, ny = w.y;
let nw = nx - ax, nh = ny - ay;
if (e.shiftKey && ob.w > 0 && ob.h > 0) {
const ratio = ob.w / ob.h;
if (Math.abs(nw / (ratio || 1)) > Math.abs(nh)) nh = nw / ratio; else nw = nh * ratio;
}
if (nw < 0) { ax = ax + nw; nw = -nw; }
if (nh < 0) { ay = ay + nh; nh = -nh; }
nw = Math.max(K.MIN_SHAPE, nw); nh = Math.max(K.MIN_SHAPE, nh);
const nb = { x: ax, y: ay, w: nw, h: nh };
// remap each selected shape from its own original bbox to the new bbox
for (const id of state.selection) {
const s = shapeById(id);
const osb = bboxOf(JSON.parse(g.orig.get(id)));
Object.assign(s, JSON.parse(g.orig.get(id)));
if (s.type === "freehand") s.points = JSON.parse(g.orig.get(id)).points.map(p => p.slice());
remapShape(s, osb, nb);
}
}
else if (g.kind === "marquee") {
g.cx = sp.x; g.cy = sp.y;
const r = normRect(g.sx, g.sy, g.cx, g.cy);
const w1 = toWorld(r.x, r.y), w2 = toWorld(r.x + r.w, r.y + r.h);
const sel = new Set(g.additive ? g.base : []);
for (const s of state.shapes) {
const b = bboxOf(s);
if (b.x + b.w >= w1.x && b.x <= w2.x && b.y + b.h >= w1.y && b.y <= w2.y) sel.add(s.id);
}
state.selection = sel;
}
else if (g.kind === "erase") eraseAt(w);
});
canvas.addEventListener("pointerup", e => {
const g = state.gesture;
if (!g) return;
state.gesture = null;
canvas.style.cursor = "";
if (g.kind === "draw") {
const s = g.shape;
if (s.type === "freehand" && s.points.length < 2) s.points.push([s.points[0][0] + 1, s.points[0][1] + 1]);
if ((s.type === "rect" || s.type === "ellipse") && (s.w < 2 || s.h < 2)) removeShape(s.id);
if (s.type === "arrow" && Math.hypot(s.x2 - s.x1, s.y2 - s.y1) < 2) removeShape(s.id);
commitAction();
if (state.shapes.includes(s)) state.selection = new Set([s.id]);
setTool("select");
}
else if (g.kind === "move" || g.kind === "resize") commitAction();
else if (g.kind === "erase") commitAction();
else if (g.kind === "marquee") { /* selection already live */ }
});
function eraseAt(w) {
const hit = pickTopmost(w.x, w.y);
if (hit) { state.gesture.removed.push(JSON.stringify(hit)); removeShape(hit.id); }
}
function updateCursor(sp) {
let c = "default";
if (state.spaceDown) c = "grab";
else if (state.tool === "pen" || state.tool === "text") c = "crosshair";
else if (state.tool === "rect" || state.tool === "ellipse" || state.tool === "arrow") c = "crosshair";
else if (state.tool === "eraser") c = "cell";
else if (state.tool === "select") {
if (handleAtScreen(sp.x, sp.y) >= 0) c = "nwse-resize";
else {
const w = toWorld(sp.x, sp.y);
c = pickTopmost(w.x, w.y) ? "move" : "default";
}
}
canvas.style.cursor = c;
}
/* ================================================================
WHEEL — zoom toward cursor
================================================================ */
canvas.addEventListener("wheel", e => {
e.preventDefault();
const sp = evPos(e);
const before = toWorld(sp.x, sp.y);
let z = state.view.zoom * Math.pow(0.999, e.deltaY);
z = Math.max(K.ZOOM_MIN, Math.min(K.ZOOM_MAX, z));
state.view.zoom = z;
// keep world point under cursor: view = screen - world*zoom
state.view.x = sp.x - before.x * z;
state.view.y = sp.y - before.y * z;
}, { passive: false });
/* ================================================================
KEYBOARD
================================================================ */
let lastNudge = 0;
window.addEventListener("keydown", e => {
if (e.target === editor) return;
const mod = e.ctrlKey || e.metaKey;
if (mod && e.key.toLowerCase() === "z") { e.preventDefault(); e.shiftKey ? redo() : undo(); return; }
if (mod && e.key.toLowerCase() === "y") { e.preventDefault(); redo(); return; }
if (mod && e.key.toLowerCase() === "0") { e.preventDefault(); resetView(); return; }
if (e.code === "Space") { state.spaceDown = true; updateCursor(evPos({ clientX: 0, clientY: 0 })); e.preventDefault(); return; }
if (e.key === "Delete" || e.key === "Backspace") {
if (state.selection.size) { beginAction(); for (const id of [...state.selection]) removeShape(id); commitAction(); }
e.preventDefault(); return;
}
if (e.key.startsWith("Arrow") && state.selection.size) {
const step = (e.shiftKey ? K.NUDGE_BIG : K.NUDGE_STEP) / state.view.zoom;
const now = Date.now();
if (now - lastNudge > 600) beginAction();
lastNudge = now;
const dx = e.key === "ArrowLeft" ? -step : e.key === "ArrowRight" ? step : 0;
const dy = e.key === "ArrowUp" ? -step : e.key === "ArrowDown" ? step : 0;
for (const id of state.selection) translateShape(shapeById(id), dx, dy);
saveBoard(); e.preventDefault(); return;
}
const keymap = { v: "select", p: "pen", r: "rect", o: "ellipse", l: "arrow", t: "text", e: "eraser" };
const t = keymap[e.key.toLowerCase()];
if (t && !mod) setTool(t);
});
window.addEventListener("keyup", e => { if (e.code === "Space") { state.spaceDown = false; updateCursor({ x: -100, y: -100 }); } });
/* ================================================================
TOOLBAR / PROPS / ZOOM UI
================================================================ */
function setTool(t) {
state.tool = t;
document.querySelectorAll("#toolbar [data-tool]").forEach(b => b.classList.toggle("active", b.dataset.tool === t));
updateCursor({ x: -100, y: -100 });
if (t !== "select") state.selection.clear();
}
document.querySelectorAll("#toolbar [data-tool]").forEach(b => b.addEventListener("click", () => setTool(b.dataset.tool)));
document.getElementById("undoBtn").addEventListener("click", undo);
document.getElementById("redoBtn").addEventListener("click", redo);
function resetView() {
// fit content, or identity if empty
if (!state.shapes.length) { state.view = { x: 0, y: 0, zoom: 1 }; return; }
let b = unionBBox(state.shapes.map(s => s.id));
const pad = 60;
const z = Math.min(K.ZOOM_MAX, Math.max(K.ZOOM_MIN,
Math.min((canvas.clientWidth - pad*2) / Math.max(b.w, 1), (canvas.clientHeight - pad*2) / Math.max(b.h, 1))));
state.view.zoom = z;
state.view.x = canvas.clientWidth / 2 - (b.x + b.w/2) * z;
state.view.y = canvas.clientHeight / 2 - (b.y + b.h/2) * z;
}
document.getElementById("resetview").addEventListener("click", resetView);
document.getElementById("zoomIn").addEventListener("click", () => zoomStep(1.25));
document.getElementById("zoomOut").addEventListener("click", () => zoomStep(0.8));
function zoomStep(f) {
const cx = canvas.clientWidth / 2, cy = canvas.clientHeight / 2;
const before = toWorld(cx, cy);
state.view.zoom = Math.max(K.ZOOM_MIN, Math.min(K.ZOOM_MAX, state.view.zoom * f));
state.view.x = cx - before.x * state.view.zoom;
state.view.y = cy - before.y * state.view.zoom;
}
// swatches
function buildSwatches(elId, colors, get, set) {
const el = document.getElementById(elId);
colors.forEach(c => {
const d = document.createElement("div");
d.className = "swatch" + (c === null ? " fill-none" : "");
if (c) d.style.background = c;
d.addEventListener("click", () => { set(c); refreshProps(); });
d.dataset.color = c === null ? "none" : c;
el.appendChild(d);
});
el._get = get;
}
buildSwatches("strokeSwatches", STROKE_COLORS,
() => currentProp("stroke"), c => applyProp("stroke", c));
buildSwatches("fillSwatches", FILL_COLORS,
() => currentProp("fill"), c => applyProp("fill", c));
// widths
const widthEl = document.getElementById("widthOpts");
STROKE_WIDTHS.forEach(w => {
const d = document.createElement("div");
d.className = "width-opt"; d.textContent = w;
d.addEventListener("click", () => { applyProp("width", w); refreshProps(); });
d.dataset.width = w;
widthEl.appendChild(d);
});
function currentProp(name) {
if (state.selection.size) {
const vals = new Set([...state.selection].map(id => shapeById(id)[name]));
return vals.size === 1 ? [...vals][0] : undefined;
}
return DEFAULTS[name];
}
function applyProp(name, val) {
beginAction();
if (state.selection.size) for (const id of state.selection) shapeById(id)[name] = val;
DEFAULTS[name] = val;
commitAction();
refreshProps();
}
function refreshProps() {
document.querySelectorAll("#strokeSwatches .swatch").forEach(d =>
d.classList.toggle("selected", d.dataset.color === (currentProp("stroke") || "")));
document.querySelectorAll("#fillSwatches .swatch").forEach(d =>
d.classList.toggle("selected", d.dataset.color === (currentProp("fill") === undefined ? "?" : (currentProp("fill") || "none"))));
document.querySelectorAll(".width-opt").forEach(d =>
d.classList.toggle("selected", +d.dataset.width === currentProp("width")));
}
/* ================================================================
INIT
================================================================ */
resizeCanvas();
loadBoard();
refreshProps();
setTool("select");
</script>
</body>
</html>
<!-- agent-meta {"model":"stealth/ox-alpha","provider":"openrouter","persona":"full","sessionId":"976cfa61-cc4c-4fe6-9166-a97407c6cf64","tokensIn":820580,"tokensOut":22709,"tokensTotal":843289,"cost":0,"turns":26,"toolCalls":25,"failedToolCalls":0,"timestamp":"2026-08-21T18:01:36.865Z"} -->