Whiteboard
Qwen 3.8 27B GGUF XL · 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 49,006 bytes · SHA-256 64063dbad1e1
<!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 ============================ */
:root{
--font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-text: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--canvas-bg: #f7f6f3;
--panel: #f1f0ed;
--panel-border: rgba(0,0,0,.07);
--ink: #37352f;
--ink-soft: #8a867d;
--accent: #1971c2;
--accent-soft: #dbeafc;
--shadow: 0 6px 20px rgba(30,28,24,.14), 0 1px 3px rgba(30,28,24,.10);
}
*{ box-sizing: border-box; }
html, body{ height: 100%; margin: 0; overflow: hidden; }
body{
font-family: var(--font-ui);
background: var(--canvas-bg);
color: var(--ink);
-webkit-user-select: none; user-select: none;
}
button:focus-visible{ outline: 2px solid var(--accent); outline-offset: 1px; }
#board{ position: fixed; inset: 0; display: block; touch-action: none; }
.panel{
position: fixed;
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 12px;
box-shadow: var(--shadow);
z-index: 10;
}
/* ---------------- logo ---------------- */
#logo{
top: 14px; left: 14px;
display: flex; align-items: center; gap: 9px;
padding: 9px 13px;
font-size: 14px; font-weight: 650; letter-spacing: .2px;
}
#logo .mark{
width: 12px; height: 12px; border-radius: 4px;
background: var(--accent);
box-shadow: inset 0 0 0 2.5px rgba(255,255,255,.35);
}
#logo .tag{
font-size: 10px; font-weight: 500; color: var(--ink-soft);
background: rgba(0,0,0,.05); padding: 2px 7px; border-radius: 20px; letter-spacing: .5px;
}
/* ---------------- toolbar ---------------- */
#toolbar{
left: 14px; top: 50%; transform: translateY(-50%);
display: flex; flex-direction: column; gap: 2px; padding: 6px;
}
#toolbar button{
width: 38px; height: 38px;
border: 0; background: transparent; border-radius: 9px;
display: grid; place-items: center; cursor: pointer;
color: #57544c; padding: 0;
}
#toolbar button:hover{ background: rgba(0,0,0,.06); }
#toolbar button.active{ background: var(--accent-soft); color: var(--accent); }
#toolbar button:disabled{ opacity: .35; cursor: default; }
#toolbar button:disabled:hover{ background: transparent; }
#toolbar .sep{ height: 1px; background: var(--panel-border); margin: 4px 5px; }
#toolbar svg{ display: block; }
/* ---------------- properties bar ---------------- */
#props{
top: 14px; left: 50%; transform: translateX(-50%);
display: flex; align-items: center; gap: 10px;
padding: 8px 12px;
}
.pgroup{ display: flex; align-items: center; gap: 6px; }
.psep{ width: 1px; height: 22px; background: var(--panel-border); }
.swatch{
width: 20px; height: 20px; border-radius: 50%;
border: 1px solid rgba(0,0,0,.15);
cursor: pointer; padding: 0; position: relative;
transition: transform .08s ease;
}
.swatch:hover{ transform: scale(1.12); }
.swatch.active{ outline: 2px solid var(--accent); outline-offset: 2px; }
.swatch.none{ background: #fff; }
.swatch.none::after{
content: ""; position: absolute; left: -2px; top: 9px;
width: 24px; height: 2px; background: #d9480f;
transform: rotate(-45deg); border-radius: 2px;
}
.wbtn{
width: 26px; height: 26px; border: 0; background: transparent;
border-radius: 7px; display: grid; place-items: center; cursor: pointer;
}
.wbtn:hover{ background: rgba(0,0,0,.06); }
.wbtn.active{ background: var(--accent-soft); }
.wbtn i{ display: block; border-radius: 50%; background: #57544c; }
.wbtn.active i{ background: var(--accent); }
/* ---------------- zoom bar ---------------- */
#zoombar{
right: 14px; bottom: 14px;
display: flex; align-items: center; gap: 2px; padding: 4px;
}
#zoombar button{
border: 0; background: transparent; border-radius: 8px; cursor: pointer;
color: #57544c; height: 30px; display: grid; place-items: center;
padding: 0 8px; font-family: inherit; font-size: 12.5px; font-weight: 600;
}
#zoombar button:hover{ background: rgba(0,0,0,.06); }
#zoombar button.icon{ width: 30px; padding: 0; }
#zoombar .zsep{ width: 1px; height: 18px; background: var(--panel-border); margin: 0 3px; }
/* ---------------- hint ---------------- */
#hint{
position: fixed; left: 14px; bottom: 16px; z-index: 10;
font-size: 12px; color: var(--ink-soft); pointer-events: none;
}
kbd{
font-family: inherit; font-size: 10.5px;
background: rgba(0,0,0,.06); border: 1px solid rgba(0,0,0,.08);
border-bottom-width: 2px; border-radius: 5px; padding: 1px 5px; margin: 0 1px;
}
/* ---------------- inline text editor ---------------- */
#text-editor{
position: fixed; display: none;
white-space: pre; overflow: hidden; resize: none;
border: 1.5px solid var(--accent); border-radius: 6px;
padding: 4px 8px;
background: rgba(255,255,255,.96);
z-index: 20; outline: none;
font-family: var(--font-text); line-height: 1.25;
box-shadow: 0 4px 14px rgba(25,113,194,.18);
-webkit-user-select: text; user-select: text;
}
/* ---------------- eraser cursor ---------------- */
#eraser-cursor{
position: fixed; width: 22px; height: 22px;
border: 1.5px solid var(--accent); border-radius: 50%;
transform: translate(-50%,-50%);
pointer-events: none; z-index: 15; display: none;
background: rgba(255,255,255,.35);
}
</style>
</head>
<body>
<canvas id="board"></canvas>
<div id="logo" class="panel"><span class="mark"></span>Slate<span class="tag">LOCAL PROTOTYPE</span></div>
<div id="toolbar" class="panel">
<button data-tool="select" title="Select (V)" aria-label="Select">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M5.2 2.5 19.8 11.4 12.9 12.9 9.5 19.8 Z"/></svg>
</button>
<button data-tool="pen" title="Pen (P)" aria-label="Pen">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M17.2 3.3a2.6 2.6 0 0 1 3.6 3.6L8 19.7 3 21l1.3-5z"/><path d="M14.5 6 18 9.5"/></svg>
</button>
<button data-tool="rect" title="Rectangle (R)" aria-label="Rectangle">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"><rect x="4" y="6.5" width="16" height="11" rx="1.5"/></svg>
</button>
<button data-tool="ellipse" title="Ellipse (O)" aria-label="Ellipse">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><ellipse cx="12" cy="12" rx="8.5" ry="6.5"/></svg>
</button>
<button data-tool="line" title="Arrow (L)" aria-label="Arrow">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M5 19 18.5 5.5M18.5 5.5H12M18.5 5.5V12"/></svg>
</button>
<button data-tool="text" title="Text (T)" aria-label="Text">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M6 6h12M12 6v13M9.5 19h5"/></svg>
</button>
<button data-tool="eraser" title="Eraser (E)" aria-label="Eraser">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M15.5 4.5l4 4L9 19H5v-4z"/><path d="M11 9l4 4"/></svg>
</button>
<div class="sep"></div>
<button id="undo-btn" title="Undo (Ctrl+Z)" aria-label="Undo">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 4.5 3.5 9.5l5 5"/><path d="M4 9.5h9.5a6.5 6.5 0 0 1 6.5 6.5v1.5"/></svg>
</button>
<button id="redo-btn" title="Redo (Ctrl+Shift+Z)" aria-label="Redo">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M15.5 4.5l5 5-5 5"/><path d="M20 9.5h-9.5A6.5 6.5 0 0 0 4 16v1.5"/></svg>
</button>
</div>
<div id="props" class="panel">
<div class="pgroup" id="stroke-colors"></div>
<div class="psep"></div>
<div class="pgroup" id="fill-colors"></div>
<div class="psep"></div>
<div class="pgroup" id="stroke-widths"></div>
</div>
<div id="zoombar" class="panel">
<button id="zoom-out" class="icon" title="Zoom out" aria-label="Zoom out">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 12h12"/></svg>
</button>
<button id="zoom-label" title="Reset to 100%">100%</button>
<button id="zoom-in" class="icon" title="Zoom in" aria-label="Zoom in">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 12h12M12 6v12"/></svg>
</button>
<div class="zsep"></div>
<button id="zoom-fit" class="icon" title="Fit to content (0)" aria-label="Fit to content">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 9V6a2 2 0 0 1 2-2h3M15 4h3a2 2 0 0 1 2 2v3M20 15v3a2 2 0 0 1-2 2h-3M9 20H6a2 2 0 0 1-2-2v-3"/></svg>
</button>
</div>
<div id="hint"><kbd>Space</kbd>+drag pan · scroll zoom · <kbd>V</kbd><kbd>P</kbd><kbd>R</kbd><kbd>O</kbd><kbd>L</kbd><kbd>T</kbd><kbd>E</kbd> tools · <kbd>Del</kbd> delete</div>
<textarea id="text-editor" spellcheck="false"></textarea>
<div id="eraser-cursor"></div>
<script>
'use strict';
/* ============================================================
1. CONSTANTS — tunable knobs
============================================================ */
const MIN_ZOOM = 0.25, MAX_ZOOM = 4; // zoom limits (25% – 400%)
const HIT_TOL_PX = 5; // selection hit tolerance (screen px)
const ERASE_TOL_PX = 11; // eraser tolerance (screen px)
const HANDLE_SIZE = 8, HANDLE_HIT_PX = 8; // resize handle size / hit radius (screen px)
const MIN_OBJ_SIZE = 3; // min size after resize (world units)
const GRID_SPACING = 24, GRID_MIN_PX = 14, GRID_DOT = 1.5;
const MAX_HISTORY = 60;
const STORAGE_KEY = 'slate.board.v1';
const TEXT_LINE_HEIGHT = 1.25;
const FONT_STACK = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif';
const ACCENT = '#1971c2';
const CANVAS_BG = '#f7f6f3';
const GRID_COLOR = 'rgba(55,53,47,0.11)';
const PALETTE = {
strokes: ['#37352f', '#e03131', '#f08c00', '#1971c2', '#2f9e44', '#6741d9'],
fills: ['transparent', '#ffe3e3', '#fff3bf', '#d3f9d8', '#d0ebff', '#e5dbff', '#e9ecef'],
widths: [2, 4, 8],
};
const DEFAULT_STYLE = { stroke: '#37352f', fill: 'transparent', strokeWidth: 2 };
/* ============================================================
2. STATE
============================================================ */
let objects = []; // scene: single array of serializable shape objects
let cam = { x: 0, y: 0, zoom: 1 }; // viewport: screen = (world - cam) * zoom
let tool = 'select';
let selected = new Set(); // selected object ids
let defaults = { ...DEFAULT_STYLE };
let op = null; // active pointer operation
let spaceDown = false;
let editing = null; // { obj, origText, isNew } while editing text
let needsRender = true;
const history = { stack: [], index: -1 };
let nudgeLast = 0;
/* ============================================================
3. DOM & CANVAS SETUP
============================================================ */
const canvas = document.getElementById('board');
const ctx = canvas.getContext('2d');
const measureCtx = document.createElement('canvas').getContext('2d');
const editor = document.getElementById('text-editor');
const eraserEl = document.getElementById('eraser-cursor');
const undoBtn = document.getElementById('undo-btn');
const redoBtn = document.getElementById('redo-btn');
const zoomLabel = document.getElementById('zoom-label');
let dpr = 1, viewW = 0, viewH = 0;
function resize(){
dpr = window.devicePixelRatio || 1;
viewW = window.innerWidth; viewH = window.innerHeight;
canvas.width = Math.round(viewW * dpr);
canvas.height = Math.round(viewH * dpr);
canvas.style.width = viewW + 'px';
canvas.style.height = viewH + 'px';
if (editing) positionEditor();
needsRender = true;
}
/* ---------- ids & serialization (caches use "_" prefix, stripped) ---------- */
let idCounter = 0;
function newId(){
return 'o' + Date.now().toString(36) + (idCounter++).toString(36) + Math.floor(Math.random() * 1e4).toString(36);
}
const stripCaches = (k, v) => (k.startsWith('_') ? undefined : v);
const serialize = arr => JSON.stringify(arr, stripCaches);
const byId = id => objects.find(o => o.id === id);
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
/* ============================================================
4. PERSISTENCE & HISTORY
============================================================ */
let saveTimer = null;
function saveSoon(){ clearTimeout(saveTimer); saveTimer = setTimeout(save, 350); }
function save(){
try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ v: 1, cam, objects: serialize(objects) })); }
catch(e){ /* storage unavailable — ignore */ }
}
function loadBoard(){
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const d = JSON.parse(raw);
if (d && Array.isArray(d.objects)) return d;
} catch(e){}
return null;
}
function pushHistory(){
const s = serialize(objects);
if (s === history.stack[history.index]) return; // no-op guard
history.stack.splice(history.index + 1); // drop redo tail
history.stack.push(s);
if (history.stack.length > MAX_HISTORY) history.stack.shift();
history.index = history.stack.length - 1;
uiSync(); saveSoon();
}
function restoreState(i){
history.index = i;
objects = JSON.parse(history.stack[i]);
pruneSelection(); uiSync(); saveSoon();
}
function undo(){ if (history.index > 0) restoreState(history.index - 1); }
function redo(){ if (history.index < history.stack.length - 1) restoreState(history.index + 1); }
// coalesce rapid nudges into a single undo step
function pushHistoryNudge(){
const now = performance.now();
if (now - nudgeLast < 600){
const s = serialize(objects);
if (s !== history.stack[history.index]){ history.stack[history.index] = s; saveSoon(); }
} else {
pushHistory();
}
nudgeLast = now;
}
function pruneSelection(){
const ids = new Set(objects.map(o => o.id));
for (const id of [...selected]) if (!ids.has(id)) selected.delete(id);
}
function uiSync(){ refreshPropsUI(); updateHistoryButtons(); needsRender = true; }
/* ============================================================
5. GEOMETRY — bbox, translate, resize transforms
============================================================ */
function fontFor(size){ return size + 'px ' + FONT_STACK; }
function textMetrics(o){
measureCtx.font = fontFor(o.fontSize);
const lines = (o.text || '').split('\n');
let w = 0;
for (const l of lines) w = Math.max(w, measureCtx.measureText(l).width);
return { w: Math.max(w, 10), h: lines.length * o.fontSize * TEXT_LINE_HEIGHT };
}
function getBBox(o){
if (o._bbox) return o._bbox;
let b;
switch (o.type){
case 'rect': case 'ellipse':
b = { x: o.x, y: o.y, w: o.w, h: o.h }; break;
case 'line':
b = { 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) }; break;
case 'text': {
const m = textMetrics(o);
b = { x: o.x, y: o.y, w: m.w, h: m.h }; break;
}
case 'freedraw': {
let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;
for (const p of o.points){
if (p.x < x1) x1 = p.x; if (p.y < y1) y1 = p.y;
if (p.x > x2) x2 = p.x; if (p.y > y2) y2 = p.y;
}
b = { x: x1, y: y1, w: x2 - x1, h: y2 - y1 }; break;
}
}
o._bbox = b;
return b;
}
function invalidate(o){ o._paths = null; o._bbox = null; }
function translateObject(o, dx, dy){
switch (o.type){
case 'rect': case 'ellipse': case 'text': o.x += dx; o.y += dy; break;
case 'line': o.x1 += dx; o.y1 += dy; o.x2 += dx; o.y2 += dy; break;
case 'freedraw': for (const p of o.points){ p.x += dx; p.y += dy; } break;
}
invalidate(o);
}
// restore a live object from its operation-start clone (deep for points)
function restoreFromClone(o, cl){
Object.assign(o, cl);
if (o.points) o.points = o.points.map(p => ({ x: p.x, y: p.y }));
invalidate(o);
}
// map every geometry point: p -> anchor + (p - anchor) * (sx, sy)
function applyResize(o, anchor, sx, sy){
const map = (x, y) => ({ x: anchor.x + (x - anchor.x) * sx, y: anchor.y + (y - anchor.y) * sy });
if (o.type === 'rect' || o.type === 'ellipse'){
const a = map(o.x, o.y), b = map(o.x + o.w, o.y + o.h);
o.x = Math.min(a.x, b.x); o.y = Math.min(a.y, b.y);
o.w = Math.abs(b.x - a.x); o.h = Math.abs(b.y - a.y);
} else if (o.type === 'line'){
const a = map(o.x1, o.y1), b = map(o.x2, o.y2);
o.x1 = a.x; o.y1 = a.y; o.x2 = b.x; o.y2 = b.y;
} else if (o.type === 'freedraw'){
for (const p of o.points){ const m = map(p.x, p.y); p.x = m.x; p.y = m.y; }
} else if (o.type === 'text'){
const m = map(o.x, o.y); o.x = m.x; o.y = m.y;
o.fontSize = clamp(o.fontSize * (sx + sy) / 2, 6, 400);
}
invalidate(o);
}
function makeObject(type, props){
const o = {
id: newId(), type,
stroke: defaults.stroke, fill: defaults.fill, strokeWidth: defaults.strokeWidth,
seed: (Math.random() * 1e9) | 0,
...props,
};
if (type !== 'rect' && type !== 'ellipse') o.fill = 'transparent';
return o;
}
/* ============================================================
6. HIT TESTING — world coordinates, screen-px tolerance
============================================================ */
function segDist(px, py, x1, y1, x2, y2){
const dx = x2 - x1, dy = y2 - y1;
const l2 = dx * dx + dy * dy;
if (l2 === 0) return Math.hypot(px - x1, py - y1);
let t = ((px - x1) * dx + (py - y1) * dy) / l2;
t = clamp(t, 0, 1);
return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy));
}
function hitObject(o, wx, wy, tol){
switch (o.type){
case 'rect': {
// whole drawn region: interior plus the stroke band
return wx >= o.x - tol && wx <= o.x + o.w + tol && wy >= o.y - tol && wy <= o.y + o.h + tol;
}
case 'ellipse': {
const cx = o.x + o.w / 2, cy = o.y + o.h / 2;
const rx = Math.max(0.001, o.w / 2 + tol), ry = Math.max(0.001, o.h / 2 + tol);
return ((wx - cx) / rx) ** 2 + ((wy - cy) / ry) ** 2 <= 1;
}
case 'line':
return segDist(wx, wy, o.x1, o.y1, o.x2, o.y2) <= tol + o.strokeWidth / 2;
case 'freedraw': {
const pts = o.points;
const t = tol + o.strokeWidth / 2;
for (let i = 0; i < pts.length - 1; i++)
if (segDist(wx, wy, pts[i].x, pts[i].y, pts[i + 1].x, pts[i + 1].y) <= t) return true;
if (pts.length === 1) return Math.hypot(wx - pts[0].x, wy - pts[0].y) <= t;
return false;
}
case 'text': {
const b = getBBox(o);
return wx >= b.x && wx <= b.x + b.w && wy >= b.y && wy <= b.y + b.h;
}
}
return false;
}
function hitTest(wx, wy, tol){
for (let i = objects.length - 1; i >= 0; i--){
const o = objects[i];
const b = getBBox(o);
if (wx < b.x - tol || wx > b.x + b.w + tol || wy < b.y - tol || wy > b.y + b.h + tol) continue;
if (hitObject(o, wx, wy, tol)) return o;
}
return null;
}
function bboxesIntersect(a, b){
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
}
/* ============================================================
7. ROUGH PATHS — seeded hand-drawn rendering (cached Path2D)
============================================================ */
function mulberry32(a){
return function(){
a |= 0; a = a + 0x6D2B79F5 | 0;
let t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
function smoothPoints(p, pts, close){
const n = pts.length;
if (n === 0) return;
p.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < n - 1; i++){
const mx = (pts[i].x + pts[i + 1].x) / 2, my = (pts[i].y + pts[i + 1].y) / 2;
p.quadraticCurveTo(pts[i].x, pts[i].y, mx, my);
}
p.lineTo(pts[n - 1].x, pts[n - 1].y);
if (close) p.closePath();
}
function roughRect(p, o, rnd, amp){
if (o.w < 1 && o.h < 1){ p.moveTo(o.x, o.y); p.arc(o.x, o.y, 0.5, 0, Math.PI * 2); return; }
const c = [[o.x, o.y], [o.x + o.w, o.y], [o.x + o.w, o.y + o.h], [o.x, o.y + o.h]];
const j = () => (rnd() * 2 - 1) * amp * 0.4;
p.moveTo(c[0][0] + j(), c[0][1] + j());
for (let i = 0; i < 4; i++){
const A = c[i], B = c[(i + 1) % 4];
const dx = B[0] - A[0], dy = B[1] - A[1];
const len = Math.hypot(dx, dy) || 1;
const off = (rnd() * 2 - 1) * amp;
p.quadraticCurveTo((A[0] + B[0]) / 2 - dy / len * off, (A[1] + B[1]) / 2 + dx / len * off, B[0] + j(), B[1] + j());
}
p.closePath();
}
function roughEllipse(p, o, rnd, amp){
const cx = o.x + o.w / 2, cy = o.y + o.h / 2;
const rx = o.w / 2, ry = o.h / 2;
if (rx < 1.5 || ry < 1.5){ p.moveTo(cx - 0.5, cy); p.arc(cx, cy, 0.5, 0, Math.PI * 2); return; }
const N = 26, start = rnd() * Math.PI * 2;
const pts = [];
for (let i = 0; i <= N; i++){
const a = start + (i / N) * Math.PI * 2;
const j = 1 + (rnd() * 2 - 1) * (amp / Math.max(rx, ry)) * 1.1;
pts.push({ x: cx + Math.cos(a) * rx * j, y: cy + Math.sin(a) * ry * j });
}
smoothPoints(p, pts, true);
}
function roughLine(p, o, rnd, amp){
const { x1, y1, x2, y2 } = o;
const dx = x2 - x1, dy = y2 - y1;
const len = Math.hypot(dx, dy);
p.moveTo(x1, y1);
if (len < 0.5) p.lineTo(x2, y2);
else {
const off = (rnd() * 2 - 1) * amp;
p.quadraticCurveTo((x1 + x2) / 2 - dy / len * off, (y1 + y2) / 2 + dx / len * off, x2, y2);
}
const ang = Math.atan2(dy, dx);
const ah = Math.max(9, o.strokeWidth * 3.5);
for (const s of [-1, 1]){
const a = ang + Math.PI + s * 0.48;
p.moveTo(x2, y2);
p.lineTo(x2 + Math.cos(a) * ah, y2 + Math.sin(a) * ah);
}
}
function getPaths(o){
if (o._paths) return o._paths;
const rnd = mulberry32(o.seed || 1);
const amp = Math.min(1.5, Math.max(0.7, o.strokeWidth * 0.3));
const path = new Path2D();
if (o.type === 'rect') roughRect(path, o, rnd, amp);
else if (o.type === 'ellipse') roughEllipse(path, o, rnd, amp);
else if (o.type === 'line') roughLine(path, o, rnd, amp);
else if (o.type === 'freedraw') smoothPoints(path, o.points, false);
o._paths = { path };
return o._paths;
}
/* ============================================================
8. RENDERING
============================================================ */
function toWorld(sx, sy){ return { x: cam.x + sx / cam.zoom, y: cam.y + sy / cam.zoom }; }
function toScreen(wx, wy){ return { x: (wx - cam.x) * cam.zoom, y: (wy - cam.y) * cam.zoom }; }
function drawGrid(){
let s = GRID_SPACING;
while (s * cam.zoom < GRID_MIN_PX) s *= 2;
const step = s * cam.zoom;
const ox = ((-cam.x * cam.zoom) % step + step) % step;
const oy = ((-cam.y * cam.zoom) % step + step) % step;
ctx.fillStyle = GRID_COLOR;
for (let x = ox; x < viewW; x += step)
for (let y = oy; y < viewH; y += step)
ctx.fillRect(x - GRID_DOT, y - GRID_DOT, GRID_DOT * 2, GRID_DOT * 2);
}
function drawTextObject(o, alpha){
ctx.globalAlpha = alpha;
ctx.fillStyle = o.stroke || '#37352f';
ctx.font = fontFor(o.fontSize);
ctx.textBaseline = 'top';
const lines = (o.text || '').split('\n');
const lh = o.fontSize * TEXT_LINE_HEIGHT;
for (let i = 0; i < lines.length; i++) ctx.fillText(lines[i], o.x, o.y + i * lh);
ctx.globalAlpha = 1;
}
function drawObject(o, alpha = 1){
if (o.type === 'text'){ drawTextObject(o, alpha); return; }
if (o.type === 'freedraw' && o.points.length === 1){
const p = o.points[0];
ctx.globalAlpha = alpha;
ctx.fillStyle = o.stroke;
ctx.beginPath();
ctx.arc(p.x, p.y, Math.max(o.strokeWidth / 2, 0.8), 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
return;
}
const { path } = getPaths(o);
ctx.globalAlpha = alpha;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
if (o.fill && o.fill !== 'transparent'){ ctx.fillStyle = o.fill; ctx.fill(path); }
if (o.stroke && o.stroke !== 'transparent'){
ctx.strokeStyle = o.stroke;
ctx.lineWidth = o.strokeWidth;
ctx.stroke(path);
}
ctx.globalAlpha = 1;
}
function shapeDraftCoords(o){
let { x1, y1, x2, y2 } = o;
if (o.shift){
if (o.type === 'rect' || o.type === 'ellipse'){
const s = Math.max(Math.abs(x2 - x1), Math.abs(y2 - y1));
x2 = x1 + (x2 >= x1 ? s : -s);
y2 = y1 + (y2 >= y1 ? s : -s);
} else if (o.type === 'line'){
const dx = x2 - x1, dy = y2 - y1, len = Math.hypot(dx, dy);
if (len > 1){
const a = Math.round(Math.atan2(dy, dx) / (Math.PI / 4)) * (Math.PI / 4);
x2 = x1 + Math.cos(a) * len; y2 = y1 + Math.sin(a) * len;
}
}
}
return { x1, y1, x2, y2 };
}
function drawShapeDraft(){
const c = shapeDraftCoords(op);
let o;
if (op.type === 'line')
o = { type: 'line', x1: c.x1, y1: c.y1, x2: c.x2, y2: c.y2, stroke: defaults.stroke, fill: 'transparent', strokeWidth: defaults.strokeWidth, seed: 1 };
else {
const x = Math.min(c.x1, c.x2), y = Math.min(c.y1, c.y2);
o = { type: op.type, x, y, w: Math.abs(c.x2 - c.x1), h: Math.abs(c.y2 - c.y1), stroke: defaults.stroke, fill: 'transparent', strokeWidth: defaults.strokeWidth, seed: 1 };
}
drawObject(o, 0.7);
}
function selectionBBox(){
let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;
for (const id of selected){
const o = byId(id); if (!o) continue;
const b = getBBox(o);
x1 = Math.min(x1, b.x); y1 = Math.min(y1, b.y);
x2 = Math.max(x2, b.x + b.w); y2 = Math.max(y2, b.y + b.h);
}
if (x1 === Infinity) return null;
return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 };
}
function drawSelectionUI(){
const box = selectionBBox();
if (!box) return;
const s = toScreen(box.x, box.y);
const w = box.w * cam.zoom, h = box.h * cam.zoom;
ctx.strokeStyle = ACCENT;
ctx.lineWidth = 1.5;
ctx.strokeRect(s.x - 1, s.y - 1, w + 2, h + 2);
const hs = HANDLE_SIZE / 2;
for (const [hx, hy] of [[s.x, s.y], [s.x + w, s.y], [s.x, s.y + h], [s.x + w, s.y + h]]){
ctx.fillStyle = '#fff';
ctx.fillRect(hx - hs, hy - hs, HANDLE_SIZE, HANDLE_SIZE);
ctx.strokeRect(hx - hs + 0.75, hy - hs + 0.75, HANDLE_SIZE - 1.5, HANDLE_SIZE - 1.5);
}
}
function drawRubberband(){
if (!op || op.kind !== 'rubberband') return;
const x = Math.min(op.sx, op.sx2), y = Math.min(op.sy, op.sy2);
const w = Math.abs(op.sx2 - op.sx), h = Math.abs(op.sy2 - op.sy);
ctx.fillStyle = 'rgba(25,113,194,0.08)';
ctx.fillRect(x, y, w, h);
ctx.strokeStyle = ACCENT;
ctx.lineWidth = 1;
ctx.strokeRect(x + 0.5, y + 0.5, w, h);
}
function render(){
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = CANVAS_BG;
ctx.fillRect(0, 0, viewW, viewH);
drawGrid();
// world space
ctx.setTransform(dpr * cam.zoom, 0, 0, dpr * cam.zoom, -cam.x * cam.zoom * dpr, -cam.y * cam.zoom * dpr);
for (const o of objects){
if (editing && o.id === editing.obj.id) continue; // shown in the overlay editor
drawObject(o);
}
if (op && op.kind === 'pen') drawObject(op.obj, 0.9);
if (op && op.kind === 'shape') drawShapeDraft();
// screen space
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
drawSelectionUI();
drawRubberband();
}
function frame(){
if (needsRender){
needsRender = false;
render();
updateZoomUI();
}
requestAnimationFrame(frame);
}
/* ============================================================
9. VIEWPORT — pan & zoom (world coords stay under cursor)
============================================================ */
function updateZoomUI(){
zoomLabel.textContent = Math.round(cam.zoom * 100) + '%';
}
function zoomAt(sx, sy, factor){
const w = toWorld(sx, sy);
cam.zoom = clamp(cam.zoom * factor, MIN_ZOOM, MAX_ZOOM);
cam.x = w.x - sx / cam.zoom;
cam.y = w.y - sy / cam.zoom;
if (editing) positionEditor();
needsRender = true;
}
function fitView(){
if (objects.length === 0){ resetView(); return; }
let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;
for (const o of objects){
const b = getBBox(o);
x1 = Math.min(x1, b.x); y1 = Math.min(y1, b.y);
x2 = Math.max(x2, b.x + b.w); y2 = Math.max(y2, b.y + b.h);
}
const pad = 60;
cam.zoom = clamp(Math.min((viewW - pad * 2) / Math.max(1, x2 - x1), (viewH - pad * 2) / Math.max(1, y2 - y1)), MIN_ZOOM, MAX_ZOOM);
cam.x = (x1 + x2) / 2 - viewW / 2 / cam.zoom;
cam.y = (y1 + y2) / 2 - viewH / 2 / cam.zoom;
needsRender = true;
}
function resetView(){
cam = { x: 0, y: 0, zoom: 1 };
needsRender = true;
}
/* ============================================================
10. POINTER INTERACTIONS — every operation starts with a
deep copy of the affected objects so undo is one step
============================================================ */
const cloneObj = o => JSON.parse(JSON.stringify(o, stripCaches));
function handleAt(wx, wy){
if (selected.size === 0) return null;
const box = selectionBBox();
if (!box) return null;
const tol = (HANDLE_HIT_PX + 3) / cam.zoom;
const cs = [[box.x, box.y], [box.x + box.w, box.y], [box.x, box.y + box.h], [box.x + box.w, box.y + box.h]];
for (let i = 0; i < 4; i++)
if (Math.abs(wx - cs[i][0]) <= tol && Math.abs(wy - cs[i][1]) <= tol) return { index: i, ax: cs[i][0], ay: cs[i][1] };
return null;
}
function startMove(sx, sy, shift){
op = { kind: 'move', sx, sy, sx2: sx, sy2: sy, shift: !!shift,
before: selected.size ? new Map([...selected].map(id => [id, cloneObj(byId(id))])) : null,
moved: false };
}
function startResize(h){
const box = selectionBBox();
op = { kind: 'resize', h, ax: h.ax, ay: h.ay, bw: box.w, bh: box.h,
before: new Map([...selected].map(id => [id, cloneObj(byId(id))])) };
}
function startPen(wx, wy){
const o = makeObject('freedraw', { points: [{ x: wx, y: wy }] });
objects.push(o);
op = { kind: 'pen', obj: o, before: null, last: { x: wx, y: wy }, dist: 0 };
}
function startShape(sx, sy, wx, wy){
op = { kind: 'shape', x1: wx, y1: wy, x2: wx, y2: wy, shift: false, type: tool };
}
function startErase(wx, wy){
op = { kind: 'eraser', erased: [] };
eraseAt(wx, wy);
}
function eraseAt(wx, wy){
const tol = ERASE_TOL_PX / cam.zoom;
for (let i = objects.length - 1; i >= 0; i--){
const o = objects[i];
if (hitObject(o, wx, wy, tol)){
objects.splice(i, 1);
selected.delete(o.id);
op.erased.push(o.id);
}
}
needsRender = true;
}
function setCursor(){
let c;
if (spaceDown) c = op && op.kind === 'pan' ? 'grabbing' : 'grab';
else if (tool === 'select') c = 'default';
else if (tool === 'text') c = 'text';
else c = 'crosshair';
canvas.style.cursor = c;
}
function eraseStroke(a, b){
const tol = ERASE_TOL_PX / cam.zoom;
for (let i = objects.length - 1; i >= 0; i--){
const o = objects[i];
let hit = false;
if (o.type === 'freedraw'){
const t = tol + o.strokeWidth / 2;
for (let k = 0; k < o.points.length - 1 && !hit; k++)
hit = segDist(a.x, a.y, o.points[k].x, o.points[k].y, o.points[k + 1].x, o.points[k + 1].y) <= t
|| segDist(b.x, b.y, o.points[k].x, o.points[k].y, o.points[k + 1].x, o.points[k + 1].y) <= t;
} else hit = hitObject(o, a.x, a.y, tol) || hitObject(o, b.x, b.y, tol);
if (hit){ objects.splice(i, 1); selected.delete(o.id); }
}
}
function onPointerDown(e){
if (e.target !== canvas) return;
if (editing) commitText();
if (e.button === 1 || (e.button === 0 && spaceDown)){
e.preventDefault();
try { canvas.setPointerCapture(e.pointerId); } catch(_){}
op = { kind: 'pan', sx: e.clientX, sy: e.clientY, camX: cam.x, camY: cam.y };
setCursor(); needsRender = true;
return;
}
if (e.button !== 0) return;
try { canvas.setPointerCapture(e.pointerId); } catch(_){}
const { x: wx, y: wy } = toWorld(e.clientX, e.clientY);
const sx = e.clientX, sy = e.clientY;
if (tool === 'select'){
const h = handleAt(wx, wy);
if (h){ startResize(h); return; }
const hit = hitTest(wx, wy, HIT_TOL_PX / cam.zoom);
if (hit){
if (e.shiftKey){
if (selected.has(hit.id)) selected.delete(hit.id); else selected.add(hit.id);
uiSync();
return; // simple toggle click — no drag started
}
if (!selected.has(hit.id)){ selected.clear(); selected.add(hit.id); uiSync(); }
startMove(sx, sy, false);
} else {
if (!e.shiftKey){ selected.clear(); uiSync(); }
op = { kind: 'rubberband', sx, sy, sx2: sx, sy2: sy, shift: e.shiftKey };
}
}
else if (tool === 'pen') startPen(wx, wy);
else if (tool === 'rect' || tool === 'ellipse' || tool === 'line') startShape(sx, sy, wx, wy);
else if (tool === 'text') startTextAt(wx, wy);
else if (tool === 'eraser'){ eraserEl.style.display = 'block'; startErase(wx, wy); }
}
function onPointerMove(e){
if (tool === 'eraser' && !op){
eraserEl.style.left = e.clientX + 'px';
eraserEl.style.top = e.clientY + 'px';
}
if (!op) return;
const sx = e.clientX, sy = e.clientY;
const { x: wx, y: wy } = toWorld(sx, sy);
switch (op.kind){
case 'pan':
cam.x = op.camX - (sx - op.sx) / cam.zoom;
cam.y = op.camY - (sy - op.sy) / cam.zoom;
if (editing) positionEditor();
needsRender = true;
break;
case 'rubberband':
op.sx2 = sx; op.sy2 = sy;
needsRender = true;
break;
case 'move': {
if (!op.moved && Math.hypot(sx - op.sx, sy - op.sy) < 2) return;
op.moved = true;
if (!op.before) return;
const dx = (sx - op.sx) / cam.zoom, dy = (sy - op.sy) / cam.zoom;
for (const [id, cl] of op.before){
const o = byId(id);
if (o){ restoreFromClone(o, cl); translateObject(o, dx, dy); }
}
needsRender = true;
break;
}
case 'resize': {
const dx = wx - op.ax, dy = op.ay - wy; // anchor corner pulled toward cursor
if (Math.abs(dx) < 0.001 && Math.abs(dy) < 0.001) return;
let sx = 1, sy = 1;
if (op.h.index === 0 || op.h.index === 3) sx = (op.bw + dx) / op.bw;
if (op.h.index === 1 || op.h.index === 3) sy = (op.bh + dy) / op.bh;
if (e.shiftKey) sy = sx; // preserve aspect
sx = clamp(sx, MIN_OBJ_SIZE / (op.bw || 1), 100);
sy = clamp(sy, MIN_OBJ_SIZE / (op.bh || 1), 100);
for (const [id, cl] of op.before){
const o = byId(id);
if (!o) continue;
restoreFromClone(o, cl);
if (op.h.index === 0) applyResize(o, { x: o.x + o.w, y: o.y + o.h }, sx, sy);
else if (op.h.index === 1) applyResize(o, { x: o.x, y: o.y + o.h }, sx, sy);
else if (op.h.index === 2) applyResize(o, { x: o.x + o.w, y: o.y }, sx, sy);
else applyResize(o, { x: o.x, y: o.y }, sx, sy);
}
needsRender = true;
break;
}
case 'pen': {
const d = Math.hypot(wx - op.last.x, wy - op.last.y);
if (d >= 0.75 / cam.zoom){
op.obj.points.push({ x: wx, y: wy });
op.last = { x: wx, y: wy };
needsRender = true;
}
break;
}
case 'shape':
op.x2 = wx; op.y2 = wy; op.shift = e.shiftKey;
needsRender = true;
break;
case 'eraser': {
const prev = { x: op.lwx ?? wx, y: op.lwy ?? wy };
eraseStroke(prev, { x: wx, y: wy });
op.lwx = wx; op.lwy = wy;
break;
}
}
}
function finishMove(){
if (op.kind === 'move' && op.moved) pushHistory();
}
function onPointerUp(e){
if (!op) return;
const o = op;
const { x: wx, y: wy } = toWorld(e.clientX, e.clientY);
switch (o.kind){
case 'pan':
if (editing) positionEditor();
break;
case 'rubberband': {
const a = toWorld(Math.min(o.sx, o.sx2), Math.min(o.sy, o.sy2));
const b = toWorld(Math.max(o.sx, o.sx2), Math.max(o.sy, o.sy2));
console.log('DBG RB-up', o.sx, o.sx2, o.sy, o.sy2, 'objs=', objects.length, 'sel=', selected.size);
if (Math.abs(o.sx2 - o.sx) > 4 || Math.abs(o.sy2 - o.sy) > 4){
const rect = { x: a.x, y: a.y, w: b.x - a.x, h: b.y - a.y };
console.log('DBG RB-if rect=', JSON.stringify(rect), 'shift=', o.shift);
if (o.shift) for (const obj of objects) if (bboxesIntersect(getBBox(obj), rect)) selected.add(obj.id);
else { selected.clear(); for (const obj of objects) if (bboxesIntersect(getBBox(obj), rect)) selected.add(obj.id); }
console.log('DBG RB-after sel=', selected.size);
pushHistory(); // selection change is undoable
} else {
selected.clear();
}
uiSync();
break;
}
case 'move':
finishMove();
break;
case 'resize': {
// enforce minimum size on the group bbox
const box = selectionBBox();
if (box && (box.w < MIN_OBJ_SIZE || box.h < MIN_OBJ_SIZE)){
for (const [id, cl] of o.before) restoreFromClone(byId(id), cl);
} else pushHistory();
break;
}
case 'pen':
if (o.obj.points.length === 1) o.obj.points.push({ x: o.obj.points[0].x + 0.01, y: o.obj.points[0].y });
pushHistory();
break;
case 'shape': {
const c = shapeDraftCoords(o);
const size = Math.max(Math.abs(c.x2 - c.x1), Math.abs(c.y2 - c.y1));
if (size > 2){
let obj;
if (o.type === 'line') obj = makeObject('line', { x1: c.x1, y1: c.y1, x2: c.x2, y2: c.y2 });
else {
const x = Math.min(c.x1, c.x2), y = Math.min(c.y1, c.y2);
obj = makeObject(o.type, { x, y, w: Math.abs(c.x2 - c.x1), h: Math.abs(c.y2 - c.y1) });
}
objects.push(obj);
selected.clear(); selected.add(obj.id);
pushHistory();
uiSync();
}
break;
}
case 'eraser':
if (o.erased.length) pushHistory();
break;
}
op = null;
eraserEl.style.display = 'none';
setCursor();
needsRender = true;
}
/* ============================================================
11. TEXT TOOL — inline editor overlay
============================================================ */
function startTextAt(wx, wy){
const obj = makeObject('text', { x: wx, y: wy, text: '', fontSize: 20 });
objects.push(obj);
selected.clear(); selected.add(obj.id);
uiSync();
openEditor(obj, true);
}
function openEditor(obj, isNew){
editing = { obj, origText: obj.text || '', isNew };
editor.value = obj.text || '';
editor.style.fontSize = (obj.fontSize * cam.zoom) + 'px';
editor.style.fontFamily = FONT_STACK;
positionEditor();
editor.style.display = 'block';
needsRender = true;
requestAnimationFrame(() => { editor.focus(); editor.select(); });
}
function positionEditor(){
if (!editing) return;
const o = editing.obj;
const s = toScreen(o.x, o.y);
const m = textMetrics(o);
editor.style.left = (s.x - 8) + 'px';
editor.style.top = (s.y - 4) + 'px';
editor.style.minWidth = Math.max(40, m.w * cam.zoom + 16) + 'px';
editor.style.minHeight = Math.max(24, m.h * cam.zoom + 8) + 'px';
}
function commitText(){
if (!editing) return;
const { obj, origText, isNew } = editing;
editing = null;
editor.style.display = 'none';
const text = editor.value.replace(/\n+$/, '');
if (text.length === 0){
if (isNew){
objects.splice(objects.indexOf(obj), 1);
selected.delete(obj.id);
} else {
obj.text = origText;
invalidate(obj);
}
} else if (text !== origText){
obj.text = text;
invalidate(obj);
pushHistory();
}
uiSync();
needsRender = true;
}
function cancelText(){
if (!editing) return;
const { obj, isNew } = editing;
editing = null;
editor.style.display = 'none';
if (isNew) objects.splice(objects.indexOf(obj), 1);
selected.delete(obj.id);
uiSync(); needsRender = true;
}
/* ============================================================
12. KEYBOARD
============================================================ */
function nudgeSelection(dx, dy, big){
if (selected.size === 0 || editing) return;
const step = big ? 10 : 1;
for (const id of selected){
const o = byId(id); if (o) translateObject(o, dx * step, dy * step);
}
if (editing) positionEditor();
pushHistoryNudge();
needsRender = true;
}
function onKey(e){
const meta = e.ctrlKey || e.metaKey;
if (e.key === ' ' && !e.repeat){ spaceDown = true; setCursor(); e.preventDefault(); return; }
if (editing){
if (e.key === 'Escape'){ e.preventDefault(); cancelText(); }
return; // let typing flow into the editor
}
if (meta && (e.key === 'z' || e.key === 'Z')){ e.preventDefault(); e.shiftKey ? redo() : undo(); return; }
if (meta && (e.key === 'y' || e.key === 'Y')){ e.preventDefault(); redo(); return; }
if (meta && (e.key === 'a' || e.key === 'A')){
e.preventDefault();
selected = new Set(objects.map(o => o.id));
uiSync(); return;
}
if (meta) return;
switch (e.key){
case 'v': case 'V': setTool('select'); break;
case 'p': case 'P': setTool('pen'); break;
case 'r': case 'R': setTool('rect'); break;
case 'o': case 'O': setTool('ellipse'); break;
case 'l': case 'L': setTool('line'); break;
case 't': case 'T': setTool('text'); break;
case 'e': case 'E': setTool('eraser'); break;
case 'Delete': case 'Backspace':
if (selected.size){
for (const id of [...selected]){
const i = objects.findIndex(o => o.id === id);
if (i >= 0) objects.splice(i, 1);
}
selected.clear();
pushHistory(); uiSync(); needsRender = true;
}
break;
case 'ArrowLeft': e.preventDefault(); nudgeSelection(-1, 0, e.shiftKey); break;
case 'ArrowRight': e.preventDefault(); nudgeSelection(1, 0, e.shiftKey); break;
case 'ArrowUp': e.preventDefault(); nudgeSelection(0, -1, e.shiftKey); break;
case 'ArrowDown': e.preventDefault(); nudgeSelection(0, 1, e.shiftKey); break;
case 'Escape': selected.clear(); uiSync(); break;
case '0': fitView(); break;
}
}
function onKeyUp(e){
if (e.key === ' '){ spaceDown = false; setCursor(); }
}
function onWheel(e){
e.preventDefault();
let dy = e.deltaY;
if (e.deltaMode === 1) dy *= 16;
zoomAt(e.clientX, e.clientY, Math.exp(-dy * 0.0015));
}
function onCanvasDblClick(e){
const { x: wx, y: wy } = toWorld(e.clientX, e.clientY);
const hit = hitTest(wx, wy, HIT_TOL_PX / cam.zoom);
if (hit && hit.type === 'text'){
selected.clear(); selected.add(hit.id); uiSync();
openEditor(hit, false);
} else if (!hit){
startTextAt(wx, wy);
}
}
/* ============================================================
13. TOOLBAR / PROPERTIES / ZOOM UI
============================================================ */
function setTool(t){
if (editing) commitText();
tool = t;
for (const b of document.querySelectorAll('#toolbar button[data-tool]'))
b.classList.toggle('active', b.dataset.tool === t);
if (t !== 'select') { selected.clear(); uiSync(); }
setCursor();
needsRender = true;
}
function applySelectionStyle(patch){
if (selected.size === 0){ Object.assign(defaults, patch); uiSync(); return; }
for (const id of selected){
const o = byId(id); if (!o) continue;
Object.assign(o, patch);
invalidate(o);
}
pushHistory();
uiSync();
needsRender = true;
}
function currentStyle(){
if (selected.size === 0) return defaults;
const o = byId([...selected][0]);
return o || defaults;
}
function refreshPropsUI(){
const st = currentStyle();
document.querySelectorAll('#stroke-colors .swatch').forEach(el =>
el.classList.toggle('active', el.dataset.c === st.stroke));
document.querySelectorAll('#fill-colors .swatch').forEach(el =>
el.classList.toggle('active', el.dataset.c === st.fill));
document.querySelectorAll('#stroke-widths .wbtn').forEach(el =>
el.classList.toggle('active', +el.dataset.w === st.strokeWidth));
}
function updateHistoryButtons(){
undoBtn.disabled = history.index <= 0;
redoBtn.disabled = history.index >= history.stack.length - 1;
}
function buildUI(){
const sc = document.getElementById('stroke-colors');
for (const c of PALETTE.strokes){
const b = document.createElement('button');
b.className = 'swatch'; b.dataset.c = c;
b.style.background = c; b.title = 'Stroke ' + c;
b.onclick = () => applySelectionStyle({ stroke: c });
sc.appendChild(b);
}
const fc = document.getElementById('fill-colors');
for (const c of PALETTE.fills){
const b = document.createElement('button');
b.className = 'swatch' + (c === 'transparent' ? ' none' : '');
b.dataset.c = c;
if (c !== 'transparent') b.style.background = c;
b.title = c === 'transparent' ? 'No fill' : 'Fill ' + c;
b.onclick = () => applySelectionStyle({ fill: c });
fc.appendChild(b);
}
const wc = document.getElementById('stroke-widths');
for (const w of PALETTE.widths){
const b = document.createElement('button');
b.className = 'wbtn'; b.dataset.w = w; b.title = 'Stroke width ' + w;
const d = Math.max(3, w);
b.innerHTML = '<i style="width:' + d + 'px;height:' + d + 'px"></i>';
b.onclick = () => applySelectionStyle({ strokeWidth: w });
wc.appendChild(b);
}
for (const b of document.querySelectorAll('#toolbar button[data-tool]'))
b.onclick = () => setTool(b.dataset.tool);
undoBtn.onclick = undo;
redoBtn.onclick = redo;
document.getElementById('zoom-in').onclick = () => zoomAt(viewW / 2, viewH / 2, 1.25);
document.getElementById('zoom-out').onclick = () => zoomAt(viewW / 2, viewH / 2, 0.8);
zoomLabel.onclick = resetView;
document.getElementById('zoom-fit').onclick = fitView;
}
/* ============================================================
14. BOOT
============================================================ */
function seedBoard(){
const mk = (type, props) => {
const o = makeObject(type, props);
o.seed = (Math.random() * 1e9) | 0;
return o;
};
const r1 = mk('rect', { x: 140, y: 150, w: 220, h: 130, fill: '#d0ebff' });
const e1 = mk('ellipse', { x: 430, y: 190, w: 150, h: 150, fill: '#fff3bf' });
const a1 = mk('line', { x1: 365, y1: 215, x2: 425, y2: 250 });
const t1 = mk('text', { x: 150, y: 90, text: 'Welcome to Slate 👋\nDraw, select, zoom — it all works.', fontSize: 22, stroke: '#37352f' });
const pen = mk('freedraw', { points: [] });
for (let i = 0; i <= 40; i++){
const t = i / 40;
pen.points.push({ x: 140 + t * 440, y: 360 + Math.sin(t * Math.PI * 3) * 22 });
}
objects.push(r1, e1, a1, t1, pen);
cam = { x: 40, y: 20, zoom: 1 };
history.stack = [serialize(objects)];
history.index = 0;
save();
}
function boot(){
buildUI();
resize();
const saved = loadBoard();
if (saved && saved.objects.length){
objects = saved.objects;
if (saved.cam && isFinite(saved.cam.x)) cam = saved.cam;
history.stack = [serialize(objects)];
history.index = 0;
} else {
seedBoard();
}
pruneSelection();
setTool('select');
refreshPropsUI();
updateHistoryButtons();
setCursor();
window.addEventListener('resize', resize);
canvas.addEventListener('pointerdown', onPointerDown);
canvas.addEventListener('pointermove', onPointerMove);
canvas.addEventListener('pointerup', onPointerUp);
canvas.addEventListener('pointercancel', onPointerUp);
canvas.addEventListener('wheel', onWheel, { passive: false });
canvas.addEventListener('dblclick', onCanvasDblClick);
canvas.addEventListener('contextmenu', e => e.preventDefault());
window.addEventListener('keydown', onKey);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('blur', () => { if (spaceDown){ spaceDown = false; setCursor(); } });
editor.addEventListener('keydown', e => {
e.stopPropagation();
if (e.key === 'Escape'){ e.preventDefault(); cancelText(); }
if (e.key === 'Enter' && !e.shiftKey){ e.preventDefault(); commitText(); }
});
editor.addEventListener('input', positionEditor);
editor.addEventListener('blur', () => { if (editing) commitText(); });
document.addEventListener('visibilitychange', () => { if (document.hidden) save(); });
window.addEventListener('beforeunload', save);
requestAnimationFrame(frame);
}
boot();
</script>
</body>
</html>
<!-- agent-meta {"model":"unsloth/Qwen3.8-27B-GGUF-XL","provider":"brigid","persona":"full","sessionId":"c7265193-c5ed-449a-a70d-c237717cc393","tokensIn":5273397,"tokensOut":160778,"tokensTotal":5434175,"cost":0,"turns":54,"toolCalls":61,"failedToolCalls":1,"timestamp":"2026-08-19T02:07:41.533Z"} -->