Whiteboard
Deepseek v4.1 Flash · 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 61,807 bytes · SHA-256 8232a17c4f51
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Slate — Whiteboard</title>
<style>
/* ============================================================
APP CHROME / GLOBAL STYLES
============================================================ */
:root{
--chrome:#e9ebef;
--chrome-2:#f6f7f9;
--canvas-bg:#fbfbfa;
--ink:#1b1d21;
--muted:#6b7280;
--line:#d6d9df;
--line-soft:#e3e6ea;
--accent:#5b5bd6;
--accent-soft:#ececfb;
--shadow-sm:0 1px 2px rgba(16,20,30,.08), 0 1px 3px rgba(16,20,30,.06);
--shadow-md:0 4px 14px rgba(16,20,30,.10), 0 1px 3px rgba(16,20,30,.07);
--shadow-lg:0 12px 40px rgba(16,20,30,.16), 0 2px 8px rgba(16,20,30,.08);
--radius:10px;
}
*{box-sizing:border-box}
html,body{height:100%;margin:0;overflow:hidden}
body{
font-family:"Inter",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
color:var(--ink);
background:var(--chrome);
-webkit-font-smoothing:antialiased;
font-size:13px;
user-select:none;
}
#stage{position:fixed;inset:0}
#board{position:absolute;inset:0;display:block;touch-action:none;cursor:crosshair}
#board.tool-select{cursor:default}
#board.panning{cursor:grabbing}
#board.pannable{cursor:grab}
/* ---------- Floating toolbar ---------- */
.toolbar{
position:fixed;top:70px;left:14px;
display:flex;align-items:center;gap:2px;flex-wrap:wrap;
background:#fff;border:1px solid var(--line);
border-radius:12px;box-shadow:var(--shadow-md);
padding:5px;z-index:20;max-width:calc(100vw - 28px);
}
.toolbar .sep{width:1px;height:22px;background:var(--line-soft);margin:0 5px}
.tbtn{
width:36px;height:36px;border:0;background:transparent;border-radius:8px;
display:flex;align-items:center;justify-content:center;cursor:pointer;color:#41454d;
position:relative;transition:background .12s ease,color .12s ease;
}
.tbtn:hover{background:#f1f2f5;color:var(--ink)}
.tbtn:active{transform:translateY(1px)}
.tbtn.active{background:var(--accent-soft);color:var(--accent)}
.tbtn.active::after{
content:"";position:absolute;bottom:-1px;left:50%;transform:translateX(-50%);
width:14px;height:2px;border-radius:2px;background:var(--accent);
}
.tbtn[disabled]{opacity:.3;pointer-events:none}
.tbtn svg{width:19px;height:19px;display:block}
.tbtn .kbd{
position:absolute;right:3px;bottom:2px;font-size:8px;color:#a7abb4;font-weight:600;
letter-spacing:.2px;
}
.tbtn.active .kbd{color:#b0b0f0}
/* ---------- Properties bar ---------- */
.props{
position:fixed;top:14px;left:14px;
display:flex;align-items:center;gap:10px;height:46px;
background:#fff;border:1px solid var(--line);border-radius:12px;
box-shadow:var(--shadow-md);padding:0 12px;z-index:20;
}
.brand{display:flex;align-items:center;gap:8px;padding-right:10px;margin-right:2px;border-right:1px solid var(--line-soft)}
.brand .dot{width:11px;height:11px;border-radius:3px;background:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
.brand span{font-weight:650;letter-spacing:-.2px;font-size:13.5px}
.swatches{display:flex;gap:3px}
.sw{
width:19px;height:19px;border-radius:50%;border:1.5px solid rgba(0,0,0,.10);
cursor:pointer;padding:0;position:relative;transition:transform .1s ease;
}
.sw:hover{transform:scale(1.14)}
.sw.sel{outline:2px solid var(--accent);outline-offset:1.5px}
.sw.none{background:#fff;overflow:hidden}
.sw.none::after{
content:"";position:absolute;left:50%;top:50%;width:150%;height:1.5px;background:#e05252;
transform:translate(-50%,-50%) rotate(-45deg);
}
.pgroup{display:flex;align-items:center;gap:6px}
.plabel{color:var(--muted);font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px}
.widths{display:flex;gap:3px;align-items:center}
.wb{
width:26px;height:26px;border:0;background:transparent;border-radius:7px;cursor:pointer;
display:flex;align-items:center;justify-content:center;
}
.wb:hover{background:#f1f2f5}
.wb.sel{background:var(--accent-soft)}
.wb i{display:block;background:#41454d;border-radius:99px}
.wb.sel i{background:var(--accent)}
/* ---------- Zoom indicator ---------- */
.zoombar{
position:fixed;bottom:14px;right:14px;display:flex;align-items:center;gap:1px;
background:#fff;border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow-md);
padding:3px;z-index:20;
}
.zbtn{
height:30px;min-width:30px;padding:0 8px;border:0;background:transparent;border-radius:7px;
cursor:pointer;color:#41454d;font-size:12px;font-weight:600;display:flex;align-items:center;gap:5px;
justify-content:center;
}
.zbtn:hover{background:#f1f2f5;color:var(--ink)}
.zval{min-width:56px;text-align:center;font-variant-numeric:tabular-nums;color:var(--ink)}
.zbtn svg{width:15px;height:15px}
/* ---------- Hint / status ---------- */
.hint{
position:fixed;bottom:14px;left:14px;color:var(--muted);z-index:20;
background:rgba(255,255,255,.82);backdrop-filter:blur(6px);
border:1px solid var(--line-soft);border-radius:9px;padding:7px 11px;
font-size:11.5px;box-shadow:var(--shadow-sm);
}
.hint b{color:#41454d;font-weight:600}
.hint kbd{
font-family:inherit;font-size:10.5px;background:#f1f2f5;border:1px solid var(--line-soft);
border-radius:4px;padding:1px 4px;color:#41454d;font-weight:600;
}
/* ---------- Text editor overlay ---------- */
#textEditor{
position:fixed;display:none;z-index:30;margin:0;padding:0;border:0;outline:0;
background:transparent;resize:none;overflow:hidden;white-space:pre;line-height:1.25;
caret-color:var(--accent);
}
#textEditor::placeholder{color:#c2c6cd}
.caret-guide{position:fixed;pointer-events:none;z-index:29;border:1.5px dashed var(--accent);border-radius:6px;opacity:.55}
.toast{
position:fixed;bottom:70px;left:50%;transform:translateX(-50%) translateY(8px);
background:#1b1d21;color:#fff;padding:8px 14px;border-radius:9px;font-size:12px;font-weight:500;
box-shadow:var(--shadow-lg);opacity:0;pointer-events:none;transition:opacity .2s ease,transform .2s ease;z-index:40;
}
.toast.on{opacity:.95;transform:translateX(-50%) translateY(0)}
</style>
</head>
<body>
<div id="stage">
<canvas id="board"></canvas>
</div>
<!-- ============ Properties bar ============ -->
<div class="props" id="propsBar">
<div class="brand"><div class="dot"></div><span>Slate</span></div>
<div class="pgroup">
<span class="plabel">Stroke</span>
<div class="swatches" id="strokeSwatches"></div>
</div>
<div class="pgroup">
<span class="plabel">Fill</span>
<div class="swatches" id="fillSwatches"></div>
</div>
<div class="pgroup">
<span class="plabel">Width</span>
<div class="widths" id="widthBtns"></div>
</div>
</div>
<!-- ============ Toolbar ============ -->
<div class="toolbar" id="toolbar"></div>
<!-- ============ Zoom bar ============ -->
<div class="zoombar">
<button class="zbtn" id="zoomOut" title="Zoom out">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M5 12h14"/></svg>
</button>
<button class="zbtn zval" id="zoomVal" title="Click to reset to 100%">100%</button>
<button class="zbtn" id="zoomIn" title="Zoom in">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
</button>
<div class="sep" style="width:1px;height:20px;background:var(--line-soft);margin:0 3px"></div>
<button class="zbtn" id="resetView" title="Reset view (Shift+1)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 4v5h5"/></svg>
Reset
</button>
</div>
<div class="hint" id="hint"></div>
<div class="toast" id="toast"></div>
<textarea id="textEditor" spellcheck="false"></textarea>
<div class="caret-guide" id="caretGuide" style="display:none"></div>
<script>
"use strict";
/* ============================================================================
0. TUNABLE CONSTANTS
========================================================================= */
const CFG = {
ZOOM_MIN: 0.25,
ZOOM_MAX: 4.0,
ZOOM_STEP: 1.15, // wheel / button zoom factor
GRID_BASE: 20, // world-space grid spacing at zoom 1
HIT_TOLERANCE: 6, // world units of slack for stroke / border hit tests
HANDLE_SIZE: 8, // screen px corner handles
HANDLE_HIT: 11, // screen px hit radius for handles
MIN_SHAPE: 2, // min drag size before a shape is created
TEXT_SIZE: 20, // default font size (world units)
TEXT_LINE: 1.25,
TEXT_MIN_W: 40,
NUDGE: 1,
NUDGE_BIG: 10,
PEN_CLOSE: 2.0, // min world distance between captured pen points
SMOOTH: 0.45, // ink smoothing (0 = raw, 1 = heavy)
DPR_CAP: 2,
STORAGE_KEY: "slate.board.v1",
ERASER_R: 12, // world radius of eraser head
};
const PALETTE = {
strokes: ["#1e1e1e", "#e03131", "#2f6fed", "#2f9e44", "#e8590c", "#9c36b5", "#868e96"],
fills: ["none", "#ffc9c9", "#a5c9ff", "#b2f2bb", "#ffd8a8", "#e5c8f5", "#e9ecef"],
};
/* ============================================================================
1. UTILITIES
========================================================================= */
const uid = () => Math.random().toString(36).slice(2, 9) + Date.now().toString(36).slice(-4);
const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
const lerp = (a, b, t) => a + (b - a) * t;
const dist2 = (x1, y1, x2, y2) => { const dx = x2 - x1, dy = y2 - y1; return dx * dx + dy * dy; };
const dist = (x1, y1, x2, y2) => Math.sqrt(dist2(x1, y1, x2, y2));
const round = (v, p = 2) => Math.round(v * 10 ** p) / 10 ** p;
function clone(o) { return JSON.parse(JSON.stringify(o)); }
/* ============================================================================
2. SCENE / STATE MODEL
---------------------------------------------------------------------------
Every drawable is one serializable object in `scene.objects`:
{ id, type:'rect'|'ellipse'|'line'|'arrow'|'pen'|'text', ... }
Bounds are stored explicitly (x,y,w,h) — for freehand we keep the raw
world-space points and derive bounds from those (recomputed on resize).
========================================================================= */
let scene = { objects: [] };
const state = {
tool: "select", // active tool
view: { x: 0, y: 0, z: 1 }, // world->screen is: s = (w - view.x) * z ? NO
selection: new Set(), // ids of selected objects
style: { stroke: PALETTE.strokes[0], fill: "none", width: 2 },
draft: null, // in-progress shape/pen being drawn
marquee: null, // rubber band rect in world coords
drag: null, // active move/resize interaction
spaceDown: false,
editing: null, // text editing session
hoverId: null,
erasing: false,
};
/* viewport helpers ---------------------------------------------------------
world -> screen: s = w * z + t (t = pan offset in screen px)
screen -> world: w = (s - t) / z
view.x / view.y ARE the translation t (screen px). */
const view = state.view;
function w2s(wx, wy) { return [wx * view.z + view.x, wy * view.z + view.y]; }
function s2w(sx, sy) { return [(sx - view.x) / view.z, (sy - view.y) / view.z]; }
/* ============================================================================
3. OBJECT CONSTRUCTION / GEOMETRY
========================================================================= */
function makeObject(type, x, y, w, h, extra) {
const o = {
id: uid(), type,
x: round(x), y: round(y), w: round(w), h: round(h),
stroke: state.style.stroke,
fill: type === "pen" || type === "line" || type === "arrow" ? "none" : state.style.fill,
sw: state.style.width,
seed: (Math.random() * 1000) | 0,
};
if (extra) Object.assign(o, extra);
return o;
}
function normRect(x0, y0, x1, y1) {
return { x: Math.min(x0, x1), y: Math.min(y0, y1), w: Math.abs(x1 - x0), h: Math.abs(y1 - y0) };
}
// bounds of an object in world coords
function objBounds(o) {
if (o.type === "pen" || o.type === "line" || o.type === "arrow") {
if (o.type === "pen" && o.pts) {
let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
for (const p of o.pts) {
if (p[0] < minx) minx = p[0]; if (p[0] > maxx) maxx = p[0];
if (p[1] < miny) miny = p[1]; if (p[1] > maxy) maxy = p[1];
}
return { x: minx, y: miny, w: maxx - minx, h: maxy - miny };
}
// line / arrow: stored as x,y and x2,y2 (absolute endpoints)
return normRect(o.x, o.y, o.x2, o.y2);
}
return { x: o.x, y: o.y, w: o.w, h: o.h };
}
function unionBounds(objs) {
if (!objs.length) return null;
let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
for (const o of objs) {
const b = objBounds(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);
}
// pad by stroke width so selection box encloses the ink
const pad = 0;
return { x: minx - pad, y: miny - pad, w: maxx - minx + pad * 2, h: maxy - miny + pad * 2 };
}
function getById(id) { return scene.objects.find(o => o.id === id); }
function selectedObjects() { return scene.objects.filter(o => state.selection.has(o.id)); }
/* ============================================================================
4. HIT TESTING (all in world coordinates)
========================================================================= */
function pointInRect(px, py, b, tol) {
tol = tol || 0;
return px >= b.x - tol && px <= b.x + b.w + tol && py >= b.y - tol && py <= b.y + b.h + tol;
}
function pointInEllipse(px, py, b, tol) {
tol = tol || 0;
const cx = b.x + b.w / 2, cy = b.y + b.h / 2;
const rx = b.w / 2 + tol, ry = b.h / 2 + tol;
if (rx <= 0 || ry <= 0) return false;
return ((px - cx) / rx) ** 2 + ((py - cy) / ry) ** 2 <= 1;
}
function distToSegment(px, py, x1, y1, x2, y2) {
const dx = x2 - x1, dy = y2 - y1;
const L2 = dx * dx + dy * dy;
if (L2 === 0) return dist(px, py, x1, y1);
let t = ((px - x1) * dx + (py - y1) * dy) / L2;
t = clamp(t, 0, 1);
return dist(px, py, x1 + t * dx, y1 + t * dy);
}
function nearPolyline(px, py, pts, tol) {
for (let i = 1; i < pts.length; i++) {
if (distToSegment(px, py, pts[i - 1][0], pts[i - 1][1], pts[i][0], pts[i][1]) <= tol) return true;
}
if (pts.length === 1) return dist(px, py, pts[0][0], pts[0][1]) <= tol;
return false;
}
// convex-ish outline test for rect/ellipse stroke borders
function hitObject(o, px, py, tol) {
tol = tol || 0;
const b = objBounds(o);
switch (o.type) {
case "rect":
// a rectangle's hit area *is* its interior — corners included
return pointInRect(px, py, b, tol);
case "ellipse":
// proper ellipse test: the bounding-box corner region is NOT a hit
return pointInEllipse(px, py, b, tol);
case "line":
case "arrow":
return distToSegment(px, py, o.x, o.y, o.x2, o.y2) <= tol + o.sw / 2;
case "pen":
return nearPolyline(px, py, o.pts, tol + o.sw / 2);
case "text": {
const pad = 4;
const bb = { x: b.x - pad, y: b.y - pad, w: b.w + pad * 2, h: b.h + pad * 2 };
if (pointInRect(px, py, bb, tol)) return true;
if (o.fill && o.fill !== "none") return false;
return false;
}
}
return false;
}
// topmost object under a world point
function objectAt(wx, wy) {
const tol = CFG.HIT_TOLERANCE / view.z; // constant screen tolerance
for (let i = scene.objects.length - 1; i >= 0; i--) {
if (hitObject(scene.objects[i], wx, wy, tol)) return scene.objects[i];
}
return null;
}
function rectsIntersect(a, b) {
return !(a.x + a.w < b.x || b.x + b.w < a.x || a.y + a.h < b.y || b.y + b.h < a.y);
}
/* ============================================================================
5. RENDERING
========================================================================= */
const canvas = document.getElementById("board");
const ctx = canvas.getContext("2d");
let VIEW_W = 0, VIEW_H = 0, DPR = 1;
function resize() {
DPR = Math.min(window.devicePixelRatio || 1, CFG.DPR_CAP);
VIEW_W = window.innerWidth;
VIEW_H = window.innerHeight;
canvas.width = Math.round(VIEW_W * DPR);
canvas.height = Math.round(VIEW_H * DPR);
canvas.style.width = VIEW_W + "px";
canvas.style.height = VIEW_H + "px";
requestRender();
}
let needsRender = true;
function requestRender() { needsRender = true; }
/* ---- grid ---------------------------------------------------------------- */
function paintGrid(c) {
const z = view.z;
let step = CFG.GRID_BASE;
while (step * z < 16) step *= 2;
while (step * z > 90) step /= 2;
const sx = step * z;
let ox = view.x % sx; if (ox > 0) ox -= sx;
let oy = view.y % sx; if (oy > 0) oy -= sx;
// dots
const r = z > 2.2 ? 1.35 : 1.1;
c.fillStyle = "rgba(120,128,145,.38)";
if (sx < 14) {
// too dense — draw faint lines instead
c.strokeStyle = "rgba(120,128,145,.13)";
c.lineWidth = 1;
c.beginPath();
for (let x = ox; x < VIEW_W; x += sx) { c.moveTo(Math.round(x) + .5, 0); c.lineTo(Math.round(x) + .5, VIEW_H); }
for (let y = oy; y < VIEW_H; y += sx) { c.moveTo(0, Math.round(y) + .5); c.lineTo(VIEW_W, Math.round(y) + .5); }
c.stroke();
return;
}
c.beginPath();
for (let x = ox; x < VIEW_W + sx; x += sx) {
for (let y = oy; y < VIEW_H + sx; y += sx) {
c.moveTo(x + r, y);
c.arc(x, y, r, 0, Math.PI * 2);
}
}
c.fill();
}
/* ---- shape drawing (screen space after transform applied externally) ----- */
// We render in *screen space* with a manual transform to keep line widths and
// handle crispness under our control: every world coordinate goes through w2s.
function pathRoundRect(c, x, y, w, h, r) {
r = Math.min(r, Math.abs(w) / 2, Math.abs(h) / 2);
c.beginPath();
c.moveTo(x + r, y);
c.arcTo(x + w, y, x + w, y + h, r);
c.arcTo(x + w, y + h, x, y + h, r);
c.arcTo(x, y + h, x, y, r);
c.arcTo(x, y, x + w, y, r);
c.closePath();
}
// Hand-drawn wobble: seeded pseudo-random offsets baked per object so the line
// is stable across frames (Excalidraw-ish), scaled down for small shapes.
function seeded(seed) {
let s = seed % 2147483647; if (s <= 0) s += 2147483646;
return () => (s = (s * 16807) % 2147483647) / 2147483647;
}
function wobbleRect(c, x, y, w, h, seed, amp) {
const rnd = seeded(seed);
const j = () => (rnd() - 0.5) * amp;
const pts = [[x, y], [x + w, y], [x + w, y + h], [x, y + h]];
c.beginPath();
for (let k = 0; k < 4; k++) {
const a = pts[k], b = pts[(k + 1) % 4];
const steps = Math.max(2, Math.round(dist(a[0], a[1], b[0], b[1]) / 26));
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const px = lerp(a[0], b[0], t) + j();
const py = lerp(a[1], b[1], t) + j();
if (k === 0 && i === 0) c.moveTo(px, py); else c.lineTo(px, py);
}
}
c.closePath();
}
function wobbleEllipse(c, cx, cy, rx, ry, seed, amp) {
const rnd = seeded(seed);
const j = () => (rnd() - 0.5) * amp;
const N = 34;
c.beginPath();
for (let i = 0; i <= N; i++) {
const t = (i / N) * Math.PI * 2;
const px = cx + Math.cos(t) * (rx + j());
const py = cy + Math.sin(t) * (ry + j());
if (i === 0) c.moveTo(px, py); else c.lineTo(px, py);
}
c.closePath();
}
function applyInk(c, o) {
c.strokeStyle = o.stroke;
c.lineWidth = Math.max(0.6, o.sw * view.z);
c.lineJoin = "round";
c.lineCap = "round";
}
function drawArrowHead(c, x1, y1, x2, y2, w, color) {
const a = Math.atan2(y2 - y1, x2 - x1);
const len = clamp(w * 4.5, 8, 22);
const spread = 0.42;
c.fillStyle = color;
c.beginPath();
c.moveTo(x2, y2);
c.lineTo(x2 - Math.cos(a - spread) * len, y2 - Math.sin(a - spread) * len);
c.lineTo(x2 - Math.cos(a + spread) * len, y2 - Math.sin(a + spread) * len);
c.closePath();
c.fill();
}
function drawObject(c, o, scaleHint) {
const b = objBounds(o);
const [sx, sy] = w2s(b.x, b.y);
const sw = b.w * view.z, sh = b.h * view.z;
c.save();
const amp = clamp(1.6 * Math.min(view.z, 1.6), 0.5, 3);
switch (o.type) {
case "rect": {
const rad = Math.min(6, sw * .06, sh * .06);
if (o.fill && o.fill !== "none") {
c.fillStyle = o.fill;
if (o.stroke === "transparent") pathRoundRect(c, sx, sy, sw, sh, rad);
else wobbleRect(c, sx, sy, sw, sh, o.seed, amp);
c.fill();
}
if (o.stroke !== "transparent") {
applyInk(c, o);
if (o.fill && o.fill !== "none" && o.stroke === "transparent") { /* skip */ }
wobbleRect(c, sx, sy, sw, sh, o.seed, amp);
c.stroke();
}
break;
}
case "ellipse": {
const cx = sx + sw / 2, cy = sy + sh / 2, rx = sw / 2, ry = sh / 2;
if (o.fill && o.fill !== "none") {
c.fillStyle = o.fill;
wobbleEllipse(c, cx, cy, rx, ry, o.seed, amp);
c.fill();
}
if (o.stroke !== "transparent") {
applyInk(c, o);
wobbleEllipse(c, cx, cy, rx, ry, o.seed + 7, amp);
c.stroke();
}
break;
}
case "line":
case "arrow": {
applyInk(c, o);
const [x1, y1] = w2s(o.x, o.y), [x2, y2] = w2s(o.x2, o.y2);
c.beginPath();
c.moveTo(x1, y1);
c.lineTo(x2, y2);
c.stroke();
if (o.type === "arrow") drawArrowHead(c, x1, y1, x2, y2, Math.max(1.4, o.sw * view.z), o.stroke);
break;
}
case "pen": {
applyInk(c, o);
drawPenPath(c, o.pts.map(p => w2s(p[0], p[1])), o.seed, o.smooth !== false);
break;
}
case "text": {
const fs = (o.size || CFG.TEXT_SIZE) * view.z;
c.fillStyle = o.stroke;
c.textBaseline = "top";
c.font = `${o.bold ? "700 " : ""}${fs}px Inter, -apple-system, "Segoe UI", sans-serif`;
const lh = fs * CFG.TEXT_LINE;
const lines = (o.text || "").split("\n");
let yy = sy + Math.max(0, (sh - lines.length * lh) / 2);
for (const ln of lines) {
c.fillText(ln, sx, yy);
yy += lh;
}
break;
}
}
c.restore();
}
// smooth polyline through screen-space points (quadratic midpoint method)
function drawPenPath(c, pts, seed, smooth) {
if (!pts.length) return;
c.beginPath();
if (pts.length < 3 || !smooth) {
c.moveTo(pts[0][0], pts[0][1]);
for (let i = 1; i < pts.length; i++) c.lineTo(pts[i][0], pts[i][1]);
} else {
c.moveTo(pts[0][0], pts[0][1]);
for (let i = 1; i < pts.length - 1; i++) {
const mx = (pts[i][0] + pts[i + 1][0]) / 2;
const my = (pts[i][1] + pts[i + 1][1]) / 2;
c.quadraticCurveTo(pts[i][0], pts[i][1], mx, my);
}
const n = pts.length - 1;
c.lineTo(pts[n][0], pts[n][1]);
}
c.stroke();
}
/* ---- selection chrome ---------------------------------------------------- */
function selectionBounds() {
const objs = selectedObjects();
if (!objs.length) return null;
return unionBounds(objs);
}
function handlePositions(b) {
return {
nw: [b.x, b.y], ne: [b.x + b.w, b.y],
se: [b.x + b.w, b.y + b.h], sw: [b.x, b.y + b.h],
};
}
function drawSelectionOverlay(c) {
const objs = selectedObjects();
if (objs.length) {
const b = selectionBounds();
const [sx, sy] = w2s(b.x, b.y);
const sw = b.w * view.z, sh = b.h * view.z;
c.save();
c.strokeStyle = "#5b5bd6";
c.lineWidth = 1.5;
c.setLineDash([]);
c.strokeRect(round(sx) + .5, round(sy) + .5, round(sw), round(sh));
// handles
const h = CFG.HANDLE_SIZE;
const hs = handlePositions(b);
for (const k in hs) {
const [hx, hy] = w2s(hs[k][0], hs[k][1]);
c.fillStyle = "#fff";
c.strokeStyle = "#5b5bd6";
c.lineWidth = 1.6;
c.beginPath();
c.rect(round(hx - h / 2) + .5, round(hy - h / 2) + .5, h, h);
c.fill(); c.stroke();
}
// individual outline for multi-select
if (objs.length > 1) {
c.strokeStyle = "rgba(91,91,214,.35)";
c.lineWidth = 1;
for (const o of objs) {
const ob = objBounds(o);
const [ox, oy] = w2s(ob.x, ob.y);
c.strokeRect(round(ox) + .5, round(oy) + .5, round(ob.w * view.z), round(ob.h * view.z));
}
}
c.restore();
}
// hover highlight
if (state.hoverId && !state.selection.has(state.hoverId) && state.tool === "select") {
const o = getById(state.hoverId);
if (o) {
const ob = objBounds(o);
const [ox, oy] = w2s(ob.x, ob.y);
c.save();
c.strokeStyle = "rgba(91,91,214,.45)";
c.lineWidth = 1;
c.setLineDash([4, 3]);
c.strokeRect(round(ox) + .5, round(oy) + .5, round(ob.w * view.z) + 4, round(ob.h * view.z) + 4);
c.restore();
}
}
// marquee
if (state.marquee) {
const m = state.marquee;
const [mx, my] = w2s(m.x, m.y);
const mw = m.w * view.z, mh = m.h * view.z;
c.save();
c.fillStyle = "rgba(91,91,214,.10)";
c.strokeStyle = "#5b5bd6";
c.lineWidth = 1;
c.fillRect(mx, my, mw, mh);
c.strokeRect(round(mx) + .5, round(my) + .5, round(mw), round(mh));
c.restore();
}
// eraser head
if (state.erasing) {
const p = state.eraserPos;
const r = CFG.ERASER_R * view.z;
c.save();
c.strokeStyle = "rgba(224,49,49,.85)";
c.fillStyle = "rgba(224,49,49,.10)";
c.lineWidth = 1.5;
c.beginPath(); c.arc(p[0], p[1], r, 0, Math.PI * 2); c.fill(); c.stroke();
c.restore();
}
}
/* ---- main render loop ---------------------------------------------------- */
function render() {
if (!needsRender) return;
needsRender = false;
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
ctx.clearRect(0, 0, VIEW_W, VIEW_H);
ctx.fillStyle = "#fbfbfa";
ctx.fillRect(0, 0, VIEW_W, VIEW_H);
paintGrid(ctx);
// cull offscreen objects
for (const o of scene.objects) {
const b = objBounds(o);
const [sx, sy] = w2s(b.x, b.y);
const w = b.w * view.z, h = b.h * view.z;
const pad = 40;
if (sx + w < -pad || sy + h < -pad || sx > VIEW_W + pad || sy > VIEW_H + pad) continue;
drawObject(ctx, o);
}
// in-progress draft
if (state.draft) drawObject(ctx, state.draft);
drawSelectionOverlay(ctx);
updateZoomLabel();
updateHint();
}
function frame() {
if (needsRender) render();
requestAnimationFrame(frame);
}
/* ============================================================================
6. HISTORY (undo / redo)
---------------------------------------------------------------------------
Snapshot based: we push a JSON snapshot of the object array *before* a
mutation. A multi-object drag pushes exactly one snapshot (at drag start).
========================================================================= */
const history = { past: [], future: [], limit: 120 };
function snapshot() { return JSON.stringify(scene.objects); }
function restoreSnap(s) {
scene.objects = JSON.parse(s);
// drop ids that no longer exist
const ids = new Set(scene.objects.map(o => o.id));
for (const id of Array.from(state.selection)) if (!ids.has(id)) state.selection.delete(id);
requestRender();
}
function pushUndo() {
history.past.push(snapshot());
if (history.past.length > history.limit) history.past.shift();
history.future.length = 0;
updateUndoButtons();
}
function undo() {
if (!history.past.length) return;
history.future.push(snapshot());
restoreSnap(history.past.pop());
cancelEdit();
scheduleSave();
updateUndoButtons();
toast("Undo");
}
function redo() {
if (!history.future.length) return;
history.past.push(snapshot());
restoreSnap(history.future.pop());
cancelEdit();
scheduleSave();
updateUndoButtons();
toast("Redo");
}
function updateUndoButtons() {
const u = document.getElementById("undoBtn"), r = document.getElementById("redoBtn");
if (u) u.disabled = history.past.length === 0;
if (r) r.disabled = history.future.length === 0;
}
/* ============================================================================
7. PERSISTENCE
========================================================================= */
let saveTimer = null;
function scheduleSave() {
clearTimeout(saveTimer);
saveTimer = setTimeout(saveNow, 220);
}
function saveNow() {
try {
localStorage.setItem(CFG.STORAGE_KEY, JSON.stringify({
v: 1, objects: scene.objects, view: { x: view.x, y: view.y, z: view.z },
}));
} catch (e) { /* storage full / disabled — prototype keeps running */ }
}
function loadBoard() {
try {
const raw = localStorage.getItem(CFG.STORAGE_KEY);
if (!raw) return false;
const data = JSON.parse(raw);
if (!data || !Array.isArray(data.objects)) return false;
scene.objects = data.objects;
if (data.view && isFinite(data.view.z)) {
view.x = data.view.x || 0; view.y = data.view.y || 0;
view.z = clamp(data.view.z, CFG.ZOOM_MIN, CFG.ZOOM_MAX);
}
return true;
} catch (e) { return false; }
}
/* ============================================================================
8. TEXT MEASUREMENT
========================================================================= */
function textFont(size, bold) {
return `${bold ? "700 " : ""}${size}px Inter, -apple-system, "Segoe UI", sans-serif`;
}
function measureTextObj(o) {
const lines = (o.text || " ").split("\n");
let w = 0;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.font = textFont(o.size || CFG.TEXT_SIZE, o.bold);
for (const l of lines) w = Math.max(w, ctx.measureText(l || " ").width);
const fs = o.size || CFG.TEXT_SIZE;
return {
w: Math.max(CFG.TEXT_MIN_W, Math.ceil(w)),
h: Math.max(fs, lines.length * fs * CFG.TEXT_LINE),
};
}
function syncTextBounds(o) {
const m = measureTextObj(o);
o.w = round(m.w); o.h = round(m.h);
}
/* ============================================================================
9. TEXT EDITING (overlay textarea)
========================================================================= */
const editor = document.getElementById("textEditor");
const caretGuide = document.getElementById("caretGuide");
function beginEdit(o, isNew) {
state.editing = { id: o.id, isNew: !!isNew, original: o.text || "" };
editor.value = o.text || "";
editor.style.display = "block";
layoutEditor(o);
caretGuide.style.display = "block";
editor.focus();
// place caret at end
editor.setSelectionRange(editor.value.length, editor.value.length);
requestRender();
}
function layoutEditor(o) {
const fs = (o.size || CFG.TEXT_SIZE) * view.z;
const [sx, sy] = w2s(o.x, o.y);
editor.style.left = sx + "px";
editor.style.top = sy + "px";
editor.style.font = textFont(fs, o.bold);
editor.style.lineHeight = CFG.TEXT_LINE;
editor.style.width = Math.max(CFG.TEXT_MIN_W * view.z, editor.scrollWidth + 4) + "px";
editor.style.height = Math.max(fs, editor.scrollHeight) + "px";
const pad = 5;
caretGuide.style.left = (sx - pad) + "px";
caretGuide.style.top = (sy - pad * .6) + "px";
caretGuide.style.width = (parseFloat(editor.style.width) + pad) + "px";
caretGuide.style.height = (parseFloat(editor.style.height) + pad) + "px";
caretGuide.style.borderRadius = (6 * view.z) + "px";
}
function commitEdit() {
if (!state.editing) return;
const { id, isNew, original } = state.editing;
const o = getById(id);
const val = editor.value;
state.editing = null;
editor.style.display = "none";
caretGuide.style.display = "none";
if (!o) { requestRender(); return; }
if (isNew) {
// the object was already pushed to the scene with an undo snapshot
if (val.trim() === "") {
scene.objects = scene.objects.filter(x => x.id !== o.id);
history.past.pop(); // nothing actually happened
updateUndoButtons();
} else {
o.text = val;
syncTextBounds(o);
}
} else {
if (val === original) { /* no-op — nothing to record */ }
else {
// one undoable step for a text edit: snapshot the pre-edit text
pushUndo();
o.text = val;
syncTextBounds(o);
}
}
scheduleSave();
requestRender();
}
function cancelEdit() {
if (!state.editing) return;
const { id, isNew, original } = state.editing;
const idx = scene.objects.findIndex(o => o.id === id);
state.editing = null;
editor.style.display = "none";
caretGuide.style.display = "none";
if (isNew && idx >= 0) { scene.objects.splice(idx, 1); }
else { const o = getById(id); if (o) { o.text = original; syncTextBounds(o); } }
requestRender();
}
editor.addEventListener("input", () => {
if (!state.editing) return;
const o = getById(state.editing.id);
if (o) { o.text = editor.value; syncTextBounds(o); layoutEditor(o); requestRender(); }
});
editor.addEventListener("keydown", (e) => {
e.stopPropagation();
if (e.key === "Escape") { e.preventDefault(); cancelEdit(); scheduleSave(); }
else if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); commitEdit(); }
});
editor.addEventListener("blur", () => { commitEdit(); });
editor.addEventListener("pointerdown", (e) => e.stopPropagation());
/* ============================================================================
10. POINTER INTERACTION
========================================================================= */
let pointer = { x: 0, y: 0, down: false, id: null };
let panState = null;
function localPoint(e) {
const r = canvas.getBoundingClientRect();
return [e.clientX - r.left, e.clientY - r.top];
}
function setCursor() {
canvas.classList.toggle("tool-select", state.tool === "select");
canvas.classList.toggle("pannable", state.spaceDown && state.tool !== "pan");
if (state.spaceDown || state.tool === "pan") canvas.style.cursor = "grab";
else if (state.tool === "text") canvas.style.cursor = "text";
else if (state.tool === "select") canvas.style.cursor = "default";
else canvas.style.cursor = "crosshair";
}
canvas.addEventListener("contextmenu", e => e.preventDefault());
canvas.addEventListener("pointerdown", (e) => {
if (state.editing) commitEdit();
const [sx, sy] = localPoint(e);
pointer.x = sx; pointer.y = sy; pointer.down = true; pointer.id = e.pointerId;
canvas.setPointerCapture && (function(){ try { canvas.setPointerCapture(e.pointerId); } catch (_) {} })();
const [wx, wy] = s2w(sx, sy);
const wantPan = e.button === 1 || state.spaceDown || state.tool === "pan";
if (wantPan) {
e.preventDefault();
panState = { sx, sy, vx: view.x, vy: view.y };
canvas.classList.add("panning");
canvas.style.cursor = "grabbing";
return;
}
if (e.button !== 0) return;
switch (state.tool) {
case "select": onSelectDown(wx, wy, sx, sy, e); break;
case "pen": onPenDown(wx, wy); break;
case "rect":
case "ellipse":
case "line":
case "arrow": onShapeDown(wx, wy); break;
case "text": onTextDown(wx, wy); break;
case "eraser": onEraseDown(wx, wy); break;
}
requestRender();
});
canvas.addEventListener("pointermove", (e) => {
const [sx, sy] = localPoint(e);
pointer.x = sx; pointer.y = sy;
const [wx, wy] = s2w(sx, sy);
if (panState) {
view.x = panState.vx + (sx - panState.sx);
view.y = panState.vy + (sy - panState.sy);
requestRender(); return;
}
if (!pointer.down) {
// hover feedback for the select tool
if (state.tool === "select") {
const o = handleAtScreen(sx, sy) ? null : objectAt(wx, wy);
const id = o ? o.id : null;
if (id !== state.hoverId) { state.hoverId = id; requestRender(); }
const hh = handleAtScreen(sx, sy);
if (!state.spaceDown) canvas.style.cursor = hh ? cursorForHandle(hh) : (o ? "move" : "default");
} else if (state.tool === "eraser") {
state.eraserPos = [sx, sy]; state.erasing = true; requestRender();
if (state.erasing && state.hoverId) state.hoverId = null;
}
return;
}
switch (state.drag && state.drag.mode) {
case "move": doMove(wx, wy, e); break;
case "resize": doResize(wx, wy, e); break;
case "marquee": doMarquee(wx, wy, e); break;
case "pen": doPenMove(wx, wy); break;
case "shape": doShapeMove(wx, wy, e); break;
case "erase": doErase(wx, wy); break;
}
requestRender();
});
function endPointer(e) {
if (!pointer.down && !panState) return;
pointer.down = false;
try { canvas.releasePointerCapture(e.pointerId); } catch (_) {}
if (panState) { panState = null; canvas.classList.remove("panning"); setCursor(); scheduleSave(); return; }
const d = state.drag;
state.drag = null;
if (!d) return;
if (d.mode === "shape") commitShape();
else if (d.mode === "pen") commitPen();
else if (d.mode === "move" || d.mode === "resize") { if (d.changed) { scheduleSave(); } else { history.past.pop(); updateUndoButtons(); } }
else if (d.mode === "marquee") { state.marquee = null; }
else if (d.mode === "erase") { state.erasing = false; scheduleSave(); }
requestRender();
}
canvas.addEventListener("pointerup", endPointer);
canvas.addEventListener("pointercancel", endPointer);
canvas.addEventListener("pointerleave", () => {
if (pointer.down) return;
state.erasing = false;
state.hoverId = null;
requestRender();
});
canvas.addEventListener("dblclick", (e) => {
const [sx, sy] = localPoint(e);
const [wx, wy] = s2w(sx, sy);
const o = objectAt(wx, wy);
if (o && o.type === "text") { selectOnly(o.id); beginEdit(o, false); }
else if (o && state.tool === "select") { /* nothing */ }
});
/* ---- SELECT tool --------------------------------------------------------- */
function handleAtScreen(sx, sy) {
const b = selectionBounds();
if (!b) return null;
const hs = handlePositions(b);
for (const k in hs) {
const [hx, hy] = w2s(hs[k][0], hs[k][1]);
if (Math.abs(sx - hx) <= CFG.HANDLE_HIT && Math.abs(sy - hy) <= CFG.HANDLE_HIT) return k;
}
return null;
}
function cursorForHandle(k) {
return { nw: "nwse-resize", se: "nwse-resize", ne: "nesw-resize", sw: "nesw-resize" }[k] || "pointer";
}
function selectOnly(id) { state.selection.clear(); if (id) state.selection.add(id); }
function snapshotGeom(o) {
return { id: o.id, x: o.x, y: o.y, w: o.w, h: o.h, size: o.size,
x2: o.x2, y2: o.y2, pts: o.pts ? o.pts.map(p => [p[0], p[1]]) : null };
}
function onSelectDown(wx, wy, sx, sy, e) {
// 1) resize handle?
const h = handleAtScreen(sx, sy);
if (h) {
const objs = selectedObjects();
pushUndo();
state.drag = {
mode: "resize", corner: h, b0: unionBounds(objs),
geoms: objs.map(snapshotGeom), changed: false,
bStart: unionBounds(objs).w * unionBounds(objs).h,
};
return;
}
// 2) object hit?
const o = objectAt(wx, wy);
if (o) {
if (e.shiftKey) {
if (state.selection.has(o.id)) state.selection.delete(o.id); else state.selection.add(o.id);
requestRender();
return;
}
if (!state.selection.has(o.id)) selectOnly(o.id);
const objs = selectedObjects();
pushUndo();
state.drag = {
mode: "move", startW: [wx, wy], geoms: objs.map(snapshotGeom),
changed: false, axis: null,
};
requestRender();
return;
}
// 3) empty space -> marquee
if (!e.shiftKey) state.selection.clear();
state.drag = { mode: "marquee", startW: [wx, wy], base: new Set(state.selection), changed: false };
}
function doMove(wx, wy, e) {
const d = state.drag;
let dx = wx - d.startW[0], dy = wy - d.startW[1];
if (e && e.shiftKey) { if (Math.abs(dx) > Math.abs(dy)) dy = 0; else dx = 0; }
if (dx === 0 && dy === 0 && !d.changed) return;
d.changed = true;
for (const g of d.geoms) {
const o = getById(g.id); if (!o) continue;
if (o.type === "pen") { o.pts = g.pts.map(p => [round(p[0] + dx), round(p[1] + dy)]); }
else if (o.type === "line" || o.type === "arrow") {
o.x = round(g.x + dx); o.y = round(g.y + dy); o.x2 = round(g.x2 + dx); o.y2 = round(g.y2 + dy);
} else { o.x = round(g.x + dx); o.y = round(g.y + dy); }
}
}
function doResize(wx, wy, e) {
const d = state.drag;
const b0 = d.b0;
const c = d.corner;
const ax = c.includes("w") ? b0.x + b0.w : b0.x; // anchor on opposite x side
const ay = c.includes("n") ? b0.y + b0.h : b0.y;
let nW = Math.abs(wx - ax), nH = Math.abs(wy - ay);
nW = Math.max(4, nW); nH = Math.max(4, nH);
if (e && e.shiftKey && b0.w > 0.5 && b0.h > 0.5) {
const s = Math.max(nW / b0.w, nH / b0.h);
nW = b0.w * s; nH = b0.h * s;
}
const ox = c.includes("w") ? ax - nW : ax;
const oy = c.includes("n") ? ay - nH : ay;
const sx = b0.w > 0.5 ? nW / b0.w : 1;
const sy = b0.h > 0.5 ? nH / b0.h : 1;
d.changed = true;
for (const g of d.geoms) {
const o = getById(g.id); if (!o) continue;
if (o.type === "pen") {
o.pts = g.pts.map(p => [round(ox + (p[0] - b0.x) * sx), round(oy + (p[1] - b0.y) * sy)]);
} else if (o.type === "line" || o.type === "arrow") {
o.x = round(ox + (g.x - b0.x) * sx); o.y = round(oy + (g.y - b0.y) * sy);
o.x2 = round(ox + (g.x2 - b0.x) * sx); o.y2 = round(oy + (g.y2 - b0.y) * sy);
} else if (o.type === "text") {
const s = clamp((sx + sy) / 2, 0.15, 6);
o.size = round(clamp((g.size || CFG.TEXT_SIZE) * s, 6, 300), 1);
syncTextBounds(o);
o.x = round(ox + (g.x - b0.x) * sx); o.y = round(oy + (g.y - b0.y) * sy);
} else {
o.x = round(ox + (g.x - b0.x) * sx);
o.y = round(oy + (g.y - b0.y) * sy);
o.w = round(Math.max(2, g.w * sx));
o.h = round(Math.max(2, g.h * sy));
}
}
}
function doMarquee(wx, wy, e) {
const d = state.drag;
const r = normRect(d.startW[0], d.startW[1], wx, wy);
state.marquee = r;
const sel = new Set(d.base);
for (const o of scene.objects) {
if (rectsIntersect(r, objBounds(o))) sel.add(o.id);
}
state.selection = sel;
}
/* ---- PEN ----------------------------------------------------------------- */
function onPenDown(wx, wy) {
const o = makeObject("pen", wx, wy, 0, 0, { pts: [[round(wx), round(wy)]], sw: state.style.width, stroke: state.style.stroke });
state.draft = o;
state.drag = { mode: "pen", changed: false };
}
function doPenMove(wx, wy) {
const o = state.draft; if (!o) return;
const last = o.pts[o.pts.length - 1];
const minD = Math.max(0.5, 1.6 / view.z);
if (dist(wx, wy, last[0], last[1]) < minD) return;
o.pts.push([round(wx), round(wy)]);
state.drag.changed = true;
}
function commitPen() {
const o = state.draft; state.draft = null;
if (!o) return;
if (!o.pts || o.pts.length < 2) { return; }
pushUndo();
scene.objects.push(o);
// derive bounds so persistence + selection box are consistent
const b = objBounds(o);
o.x = round(b.x); o.y = round(b.y); o.w = round(b.w); o.h = round(b.h);
scheduleSave();
}
/* ---- SHAPES -------------------------------------------------------------- */
function onShapeDown(wx, wy) {
const t = state.tool;
const o = makeObject(t, wx, wy, 0, 0, t === "line" || t === "arrow" ? { x2: round(wx), y2: round(wy) } : null);
state.draft = o;
state.drag = { mode: "shape", startW: [wx, wy], changed: false };
}
function doShapeMove(wx, wy, e) {
const o = state.draft; if (!o) return;
const [x0, y0] = state.drag.startW;
let x1 = wx, y1 = wy;
if (e && e.shiftKey) {
if (o.type === "line" || o.type === "arrow") {
// snap to 15° increments
const a = Math.atan2(y1 - y0, x1 - x0);
const step = Math.PI / 12;
const a2 = Math.round(a / step) * step;
const L = dist(x0, y0, x1, y1);
x1 = x0 + Math.cos(a2) * L; y1 = y0 + Math.sin(a2) * L;
} else {
const s = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0));
x1 = x0 + Math.sign(x1 - x0 || 1) * s;
y1 = y0 + Math.sign(y1 - y0 || 1) * s;
}
}
if (o.type === "line" || o.type === "arrow") {
o.x = round(x0); o.y = round(y0); o.x2 = round(x1); o.y2 = round(y1);
} else {
const r = normRect(x0, y0, x1, y1);
o.x = round(r.x); o.y = round(r.y); o.w = round(r.w); o.h = round(r.h);
}
state.drag.changed = true;
}
function commitShape() {
const o = state.draft; state.draft = null;
if (!o) return;
const big = (o.type === "line" || o.type === "arrow")
? dist(o.x, o.y, o.x2, o.y2) >= CFG.MIN_SHAPE
: (o.w >= CFG.MIN_SHAPE || o.h >= CFG.MIN_SHAPE);
if (!big) return;
pushUndo();
scene.objects.push(o);
selectOnly(o.id);
scheduleSave();
}
/* ---- TEXT ---------------------------------------------------------------- */
function onTextDown(wx, wy) {
const o = makeObject("text", wx, wy - CFG.TEXT_SIZE / 2, 0, 0, {
text: "", size: CFG.TEXT_SIZE, stroke: state.style.stroke,
});
o.w = CFG.TEXT_MIN_W; o.h = CFG.TEXT_SIZE * CFG.TEXT_LINE;
pushUndo();
scene.objects.push(o);
selectOnly(o.id);
beginEdit(o, true);
}
/* ---- ERASER -------------------------------------------------------------- */
function onEraseDown(wx, wy) {
pushUndo();
state.drag = { mode: "erase", changed: false };
state.erasing = true;
state.eraserPos = w2s(wx, wy);
doErase(wx, wy);
}
function doErase(wx, wy) {
const r = CFG.ERASER_R / view.z;
const hit = scene.objects.filter(o => {
const b = objBounds(o);
// cheap AABB reject then precise-ish test
if (wx < b.x - r || wx > b.x + b.w + r || wy < b.y - r || wy > b.y + b.h + r) return false;
return hitObject(o, wx, wy, r);
});
if (hit.length) {
scene.objects = scene.objects.filter(o => !hit.includes(o));
state.drag.changed = true;
for (const o of hit) state.selection.delete(o.id);
requestRender();
}
}
/* ---- ZOOM ---------------------------------------------------------------- */
function zoomAt(screenX, screenY, factor) {
const newZ = clamp(view.z * factor, CFG.ZOOM_MIN, CFG.ZOOM_MAX);
if (Math.abs(newZ - view.z) < 1e-6) return;
const [wx, wy] = s2w(screenX, screenY);
view.z = newZ;
view.x = screenX - wx * view.z;
view.y = screenY - wy * view.z;
if (state.editing) { const o = getById(state.editing.id); if (o) layoutEditor(o); }
requestRender();
}
canvas.addEventListener("wheel", (e) => {
e.preventDefault();
const [sx, sy] = localPoint(e);
let d = e.deltaY;
if (e.deltaMode === 1) d *= 16;
const factor = Math.pow(0.9988, d * (e.ctrlKey ? 3.2 : 1));
zoomAt(sx, sy, factor);
scheduleSave();
}, { passive: false });
function resetView() {
view.x = VIEW_W / 2; view.y = VIEW_H / 2; view.z = 1;
requestRender(); scheduleSave(); toast("View reset");
}
/* ============================================================================
11. TOOLBAR / PROPERTIES BAR UI
========================================================================= */
const ICONS = {
select: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"><path d="M5 3l6.5 16 2.2-6.3L20 10.5z"/></svg>',
pen: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20l3.2-.7L19 7.5 16.5 5 4.7 16.8z"/><path d="M14.6 6.9l2.5 2.5"/></svg>',
rect: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"><rect x="4" y="5.5" width="16" height="13" rx="2"/></svg>',
ellipse: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><ellipse cx="12" cy="12" rx="8" ry="6.6"/></svg>',
line: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M5 19L19 5"/></svg>',
arrow: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M5 19L19 5"/><path d="M11.5 5H19v7.5"/></svg>',
text: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M5 6h14M12 6v13M9 19h6"/></svg>',
eraser: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"><path d="M8.5 19.5H19"/><path d="M5.6 15.4l6-6a2 2 0 0 1 2.8 0l3.2 3.2a2 2 0 0 1 0 2.8l-3.6 3.6H9.2z"/></svg>',
undo: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M9 14L4 9l5-5"/><path d="M4 9h9a6 6 0 0 1 0 12H8"/></svg>',
redo: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M15 14l5-5-5-5"/><path d="M20 9h-9a6 6 0 0 0 0 12h5"/></svg>',
trash: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13"/></svg>',
copy: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M15 5.5A2 2 0 0 0 13 4H6a2 2 0 0 0-2 2v7a2 2 0 0 0 1.5 1.94"/></svg>',
};
const TOOLS = [
{ id: "select", key: "V", label: "Select" },
{ id: "pen", key: "P", label: "Draw" },
{ id: "rect", key: "R", label: "Rectangle" },
{ id: "ellipse", key: "O", label: "Ellipse" },
{ id: "arrow", key: "L", label: "Arrow" },
{ id: "line", key: "", label: "Line" },
{ id: "text", key: "T", label: "Text" },
{ id: "eraser", key: "E", label: "Eraser" },
];
function buildToolbar() {
const tb = document.getElementById("toolbar");
let html = "";
for (const t of TOOLS) {
html += `<button class="tbtn" data-tool="${t.id}" title="${t.label}${t.key ? " (" + t.key + ")" : ""}">
${ICONS[t.id]}${t.key ? `<span class="kbd">${t.key}</span>` : ""}</button>`;
}
html += '<div class="sep"></div>';
html += `<button class="tbtn" id="undoBtn" title="Undo (Ctrl+Z)">${ICONS.undo}</button>`;
html += `<button class="tbtn" id="redoBtn" title="Redo (Ctrl+Shift+Z)">${ICONS.redo}</button>`;
html += `<button class="tbtn" id="dupBtn" title="Duplicate (Ctrl+D)">${ICONS.copy}</button>`;
html += `<button class="tbtn" id="delBtn" title="Delete (Del)">${ICONS.trash}</button>`;
tb.innerHTML = html;
tb.querySelectorAll("[data-tool]").forEach(b => {
b.addEventListener("click", () => { setTool(b.dataset.tool); b.blur(); });
});
document.getElementById("undoBtn").addEventListener("click", undo);
document.getElementById("redoBtn").addEventListener("click", redo);
document.getElementById("dupBtn").addEventListener("click", duplicateSelection);
document.getElementById("delBtn").addEventListener("click", deleteSelection);
updateUndoButtons();
}
function setTool(t) {
if (state.editing) commitEdit();
if (state.tool === "eraser" && t !== "eraser") { state.erasing = false; }
state.tool = t;
state.hoverId = null;
document.querySelectorAll(".tbtn[data-tool]").forEach(b => {
b.classList.toggle("active", b.dataset.tool === t);
});
setCursor();
requestRender();
}
function buildSwatches() {
const mk = (host, colors, kind) => {
host.innerHTML = "";
colors.forEach(c => {
const b = document.createElement("button");
b.className = "sw" + (c === "none" ? " none" : "");
b.dataset.color = c;
b.title = c === "none" ? "No fill" : c;
if (c !== "none") b.style.background = c;
b.addEventListener("click", () => setStyle(kind, c));
host.appendChild(b);
});
};
mk(document.getElementById("strokeSwatches"), PALETTE.strokes, "stroke");
mk(document.getElementById("fillSwatches"), PALETTE.fills, "fill");
syncStyleUI();
}
function buildWidths() {
const host = document.getElementById("widthBtns");
host.innerHTML = "";
[[1, 3], [2, 5], [4, 8], [6, 11]].forEach(([w, px]) => {
const b = document.createElement("button");
b.className = "wb"; b.dataset.w = w; b.title = w + "px stroke";
b.innerHTML = `<i style="width:${px + 7}px;height:${px}px"></i>`;
b.addEventListener("click", () => setStyle("width", w));
host.appendChild(b);
});
syncStyleUI();
}
/* Styling applies to the current selection, and always becomes the default
for newly created objects. One undo step per change. */
function setStyle(kind, value) {
const objs = selectedObjects();
if (objs.length) pushUndo();
if (kind === "stroke") {
state.style.stroke = value;
objs.forEach(o => o.stroke = value);
} else if (kind === "fill") {
state.style.fill = value;
objs.forEach(o => { if (o.type !== "pen") o.fill = value; });
} else {
state.style.width = value;
objs.forEach(o => o.sw = value);
}
if (objs.length) scheduleSave();
syncStyleUI();
requestRender();
}
function syncStyleUI() {
const objs = selectedObjects();
const eff = { ...state.style };
if (objs.length) {
eff.stroke = objs[0].stroke;
eff.fill = objs[0].fill || "none";
eff.width = objs[0].sw;
}
document.querySelectorAll("#strokeSwatches .sw").forEach(b =>
b.classList.toggle("sel", b.dataset.color === eff.stroke));
document.querySelectorAll("#fillSwatches .sw").forEach(b =>
b.classList.toggle("sel", b.dataset.color === eff.fill));
document.querySelectorAll("#widthBtns .wb").forEach(b =>
b.classList.toggle("sel", +b.dataset.w === eff.width));
}
/* ---- zoom + misc UI ------------------------------------------------------ */
function updateZoomLabel() {
const el = document.getElementById("zoomVal");
if (el) el.textContent = Math.round(view.z * 100) + "%";
}
const HINTS = {
select: "<b>Drag</b> to move · <b>Shift-drag</b> rubber band · <b>Double-click</b> text to edit",
pen: "<b>Drag</b> to draw",
rect: "<b>Drag</b> to draw a rectangle · <b>Shift</b> for a square",
ellipse: "<b>Drag</b> to draw an ellipse · <b>Shift</b> for a circle",
arrow: "<b>Drag</b> for an arrow · <b>Shift</b> to snap the angle",
line: "<b>Drag</b> for a line · <b>Shift</b> to snap the angle",
text: "<b>Click</b> to place text · <b>Enter</b> or click away to commit",
eraser: "<b>Drag</b> over objects to erase them",
pan: "<b>Drag</b> to pan the canvas",
};
function updateHint() {
const el = document.getElementById("hint");
if (!el) return;
const base = HINTS[state.tool] || "";
const extras = " · <kbd>Space</kbd>+drag pan · <kbd>Ctrl</kbd>+scroll zoom";
const html = (state.spaceDown ? "<b>Drag to pan the canvas</b>" : base) + extras;
if (el.dataset.h !== html) { el.innerHTML = html; el.dataset.h = html; }
}
let toastTimer = null;
function toast(msg) {
const el = document.getElementById("toast");
el.textContent = msg;
el.classList.add("on");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove("on"), 900);
}
/* ============================================================================
12. SELECTION COMMANDS
========================================================================= */
function deleteSelection() {
if (!state.selection.size) return;
pushUndo();
scene.objects = scene.objects.filter(o => !state.selection.has(o.id));
state.selection.clear();
scheduleSave(); requestRender(); syncStyleUI();
}
function duplicateSelection() {
const objs = selectedObjects();
if (!objs.length) return;
pushUndo();
const copies = objs.map(o => {
const c = clone(o);
c.id = uid();
const off = 16;
if (c.type === "pen") c.pts = c.pts.map(p => [round(p[0] + off), round(p[1] + off)]);
else if (c.type === "line" || c.type === "arrow") { c.x += off; c.y += off; c.x2 += off; c.y2 += off; }
else { c.x += off; c.y += off; }
return c;
});
scene.objects.push(...copies);
state.selection = new Set(copies.map(o => o.id));
scheduleSave(); requestRender();
toast(copies.length + " duplicated");
}
function nudgeSelection(dx, dy, big) {
if (!state.selection.size) return;
const now = performance.now();
if (!state._lastNudge || now - state._lastNudge > 700) pushUndo();
state._lastNudge = now;
const step = big ? CFG.NUDGE_BIG : CFG.NUDGE;
for (const o of selectedObjects()) {
if (o.type === "pen") o.pts = o.pts.map(p => [round(p[0] + dx * step), round(p[1] + dy * step)]);
else if (o.type === "line" || o.type === "arrow") { o.x += dx * step; o.y += dy * step; o.x2 += dx * step; o.y2 += dy * step; }
else { o.x = round(o.x + dx * step); o.y = round(o.y + dy * step); }
}
scheduleSave(); requestRender();
}
function selectAll() {
state.selection = new Set(scene.objects.map(o => o.id));
syncStyleUI(); requestRender();
}
/* ============================================================================
13. KEYBOARD
========================================================================= */
window.addEventListener("keydown", (e) => {
const tag = (e.target && e.target.tagName) || "";
if (tag === "TEXTAREA" || tag === "INPUT" || state.editing) return;
const mod = e.ctrlKey || e.metaKey;
const k = e.key;
if (mod && (k === "z" || k === "Z")) { e.preventDefault(); e.shiftKey ? redo() : undo(); return; }
if (mod && (k === "y" || k === "Y")) { e.preventDefault(); redo(); return; }
if (mod && (k === "d" || k === "D")) { e.preventDefault(); duplicateSelection(); return; }
if (mod && (k === "a" || k === "A")) { e.preventDefault(); selectAll(); return; }
if (mod) return;
if (k === " " && !state.spaceDown) {
state.spaceDown = true; setCursor(); requestRender();
e.preventDefault();
return;
}
if (k === "Delete" || k === "Backspace") { e.preventDefault(); deleteSelection(); return; }
if (k === "Escape") {
if (state.draft) { state.draft = null; state.drag = null; }
state.selection.clear(); syncStyleUI(); requestRender(); return;
}
if (k.startsWith("Arrow")) {
e.preventDefault();
const s = e.shiftKey;
if (k === "ArrowLeft") nudgeSelection(-1, 0, s);
if (k === "ArrowRight") nudgeSelection(1, 0, s);
if (k === "ArrowUp") nudgeSelection(0, -1, s);
if (k === "ArrowDown") nudgeSelection(0, 1, s);
return;
}
if (k === "+" || k === "=") { zoomAt(VIEW_W / 2, VIEW_H / 2, CFG.ZOOM_STEP); return; }
if (k === "-" || k === "_") { zoomAt(VIEW_W / 2, VIEW_H / 2, 1 / CFG.ZOOM_STEP); return; }
if (k === "!" || (k === "1" && e.shiftKey)) { resetView(); return; }
if (k === "0") { zoomAt(VIEW_W / 2, VIEW_H / 2, 1 / view.z); return; }
const key = k.toLowerCase();
const map = { v: "select", p: "pen", r: "rect", o: "ellipse", l: "arrow", t: "text", e: "eraser", d: "pen" };
if (e.shiftKey && key === "l") { setTool("line"); return; }
if (map[key]) { setTool(map[key]); return; }
});
window.addEventListener("keyup", (e) => {
if (e.key === " ") { state.spaceDown = false; setCursor(); requestRender(); }
});
window.addEventListener("blur", () => { state.spaceDown = false; panState = null; setCursor(); });
/* ============================================================================
14. ZOOM BAR WIRING
========================================================================= */
document.getElementById("zoomIn").addEventListener("click", () => zoomAt(VIEW_W / 2, VIEW_H / 2, CFG.ZOOM_STEP));
document.getElementById("zoomOut").addEventListener("click", () => zoomAt(VIEW_W / 2, VIEW_H / 2, 1 / CFG.ZOOM_STEP));
document.getElementById("zoomVal").addEventListener("click", () => { zoomAt(VIEW_W / 2, VIEW_H / 2, 1 / view.z); scheduleSave(); });
document.getElementById("resetView").addEventListener("click", resetView);
/* ============================================================================
15. INIT
========================================================================= */
function seedDemo() {
// A tiny welcome sketch so a first-time visitor sees something.
const a = makeObject("rect", -300, -170, 250, 150, { stroke: PALETTE.strokes[2], fill: "#a5c9ff", sw: 2 });
const b = makeObject("ellipse", 20, -180, 220, 170, { stroke: PALETTE.strokes[3], fill: "#b2f2bb", sw: 2 });
const t = makeObject("text", -300, 20, 0, 0, { text: "Double-click to edit this text", size: 26, stroke: PALETTE.strokes[0] });
syncTextBounds(t);
const arrow = makeObject("arrow", 300, -80, 0, 0, { x2: 470, y2: 40, stroke: PALETTE.strokes[0], sw: 2 });
const ink = makeObject("pen", 0, 0, 0, 0, { stroke: PALETTE.strokes[1], sw: 3, pts: [] });
const rnd = seeded(7);
for (let i = 0; i <= 40; i++) {
const x = -320 + i * 4;
const y = 130 + Math.sin(i * 0.28) * 26 + (rnd() - .5) * 3;
ink.pts.push([round(x), round(y)]);
}
const bb = objBounds(ink);
ink.x = round(bb.x); ink.y = round(bb.y); ink.w = round(bb.w); ink.h = round(bb.h);
scene.objects.push(a, b, t, arrow, ink);
}
function init() {
canvas.style.background = "#fbfbfa";
buildToolbar();
buildSwatches();
buildWidths();
state.selection.clear();
const restored = loadBoard();
if (!restored) {
view.x = VIEW_W / 2; view.y = VIEW_H / 2; view.z = 1;
seedDemo();
}
setTool("select");
resize();
window.addEventListener("resize", resize);
syncStyleUI();
updateZoomLabel();
updateHint();
requestAnimationFrame(frame);
setTimeout(() => toast(restored ? "Board restored" : "Welcome — press P and draw"), 400);
}
// Canvas must be sized before building scene coordinates for the first run.
VIEW_W = window.innerWidth; VIEW_H = window.innerHeight;
init();
</script>
</body>
</html>
<!-- agent-meta {"model":"deepseek/deepseek-v4.1-flash","provider":"openrouter","persona":"full","sessionId":"64610731-98ab-4e72-ac31-51254a05b123","tokensIn":4907875,"tokensOut":91013,"tokensTotal":4998888,"cost":0.08029689,"turns":49,"toolCalls":48,"failedToolCalls":0,"timestamp":"2026-09-10T21:10:00.355Z"} -->