Whiteboard
Qwen 3.8 · typescript

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 81,354 bytes · SHA-256 21ff5463d596
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Slate — Whiteboard Prototype</title>
<style>
/* ── css: base ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body {
overflow: hidden;
background: #e8eaed;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
color: #1e1e1e;
-webkit-font-smoothing: antialiased;
}
#app { position: fixed; inset: 0; }
#board {
position: absolute; inset: 0;
width: 100%; height: 100%;
display: block;
touch-action: none;
}
#text-edit {
position: absolute;
display: none;
z-index: 30;
background: rgba(47, 111, 237, 0.05);
outline: 2px solid #2f6fed;
border: none;
border-radius: 3px;
resize: none;
overflow: hidden;
white-space: pre;
font-family: inherit;
line-height: 1.25;
padding: 0 2px;
min-width: 30px;
}
/* ── css: chrome ── */
.panel {
position: absolute;
z-index: 20;
background: #fff;
border: 1px solid #dcdfe3;
border-radius: 10px;
box-shadow: 0 6px 18px rgba(15, 25, 40, 0.10), 0 1px 2px rgba(15, 25, 40, 0.06);
user-select: none;
}
#toolbar {
top: 12px; left: 50%;
transform: translateX(-50%);
display: flex; align-items: center; gap: 2px;
padding: 5px;
}
.tb-btn {
width: 34px; height: 34px;
display: flex; align-items: center; justify-content: center;
border: none; background: transparent;
border-radius: 8px;
color: #525a63;
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.tb-btn:hover:not(:disabled) { background: #eef1f4; color: #111827; }
.tb-btn.active { background: #e2ecfd; color: #2563eb; }
.tb-btn:disabled { opacity: 0.35; cursor: default; }
.tb-sep { width: 1px; height: 22px; background: #e4e7ea; margin: 0 5px; flex: none; }
#props { top: 12px; left: 12px; width: 200px; padding: 10px 12px 12px; }
.props-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 4px; }
.props-title { font-size: 12px; font-weight: 700; letter-spacing: 0.02em; }
.props-count {
font-size: 11px; font-weight: 600; color: #2563eb;
background: #e2ecfd; border-radius: 999px;
padding: 1px 7px; min-width: 20px; text-align: center;
}
.props-label {
font-size: 10px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.06em;
color: #98a1ab; margin: 9px 0 5px;
}
.swatches { display: flex; flex-wrap: wrap; gap: 5px; }
.swatch {
width: 19px; height: 19px;
border-radius: 5px;
border: 1px solid rgba(0, 0, 0, 0.14);
padding: 0; cursor: pointer;
}
.swatch:hover { transform: scale(1.12); }
.swatch.active { box-shadow: 0 0 0 2px #fff, 0 0 0 3.5px #2563eb; border-color: transparent; }
.swatch.none {
background: linear-gradient(135deg, #fff 43%, #e03131 43%, #e03131 57%, #fff 57%);
border-color: rgba(0, 0, 0, 0.2);
}
.widths { display: flex; gap: 5px; }
.width-btn {
flex: 1; height: 28px;
display: flex; align-items: center; justify-content: center;
border: 1px solid #e2e5e9; background: #fff;
border-radius: 7px; cursor: pointer;
}
.width-btn:hover { background: #f4f6f8; }
.width-btn.active { border-color: #2563eb; background: #e2ecfd; }
.wline { background: #525a63; border-radius: 2px; width: 18px; display: block; }
.props-hint { font-size: 11px; color: #9aa3ad; margin-top: 10px; }
#zoombar { bottom: 12px; right: 12px; display: flex; align-items: center; gap: 2px; padding: 4px; }
#zoom-label {
min-width: 54px; text-align: center;
font-size: 12px; font-weight: 600; color: #525a63;
cursor: pointer; border-radius: 7px; padding: 6px 5px;
}
#zoom-label:hover { background: #eef1f4; }
#brand {
bottom: 12px; left: 12px;
display: flex; align-items: center; gap: 7px;
padding: 7px 11px;
font-size: 11px; color: #6a727b;
border-radius: 999px;
}
#brand .dot { width: 7px; height: 7px; border-radius: 50%; background: #2f9e44; flex: none; }
#brand b { color: #1e1e1e; font-weight: 700; }
.tb-btn:focus-visible, .swatch:focus-visible, .width-btn:focus-visible, #zoom-label:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 1px;
}
</style>
</head>
<body>
<div id="app"><canvas id="board"></canvas><textarea id="text-edit" spellcheck="false"></textarea><div id="brand" class="panel"><span class="dot"></span><span><b>Slate</b> · autosaves to this browser</span></div></div>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {"src/main.ts":{"./constants":"src/constants.ts","./interaction":"src/interaction.ts","./renderer":"src/renderer.ts","./scene":"src/scene.ts","./texteditor":"src/texteditor.ts","./types":"src/types.ts","./ui":"src/ui.ts"},"src/geometry.ts":{"./types":"src/types.ts","./constants":"src/constants.ts"},"src/scene.ts":{"./types":"src/types.ts","./constants":"src/constants.ts","./geometry":"src/geometry.ts"},"src/renderer.ts":{"./scene":"src/scene.ts","./types":"src/types.ts","./constants":"src/constants.ts","./geometry":"src/geometry.ts"},"src/texteditor.ts":{"./types":"src/types.ts","./renderer":"src/renderer.ts"},"src/interaction.ts":{"./scene":"src/scene.ts","./types":"src/types.ts","./constants":"src/constants.ts","./geometry":"src/geometry.ts","./renderer":"src/renderer.ts","./texteditor":"src/texteditor.ts"},"src/ui.ts":{"./types":"src/types.ts","./constants":"src/constants.ts","./scene":"src/scene.ts"}};
function __require(id) {
if (__cache[id]) return __cache[id].exports;
var module = __cache[id] = { exports: {} };
var factory = __mods[id];
if (!factory) throw new Error("Module not found: " + id);
factory(module.exports, function (spec) {
var target = (__map[id] && __map[id][spec]) || spec;
return __require(target);
}, module);
return module.exports;
}
// ── module: src/main.ts ──
__mods["src/main.ts"] = function (exports, require, module) {
"use strict";
// ---------------------------------------------------------------------------
// Entry — canvas setup, event wiring, render loop
// ---------------------------------------------------------------------------
Object.defineProperty(exports, "__esModule", { value: true });
const constants_1 = require("./constants");
const interaction_1 = require("./interaction");
const renderer_1 = require("./renderer");
const scene_1 = require("./scene");
const texteditor_1 = require("./texteditor");
const ui_1 = require("./ui");
const canvas = document.getElementById('board');
const ctx = canvas.getContext('2d');
const textEl = document.getElementById('text-edit');
let dpr = window.devicePixelRatio || 1;
function resizeCanvas() {
dpr = window.devicePixelRatio || 1;
canvas.width = Math.max(1, Math.round(canvas.clientWidth * dpr));
canvas.height = Math.max(1, Math.round(canvas.clientHeight * dpr));
(0, renderer_1.markDirty)();
}
window.addEventListener('resize', resizeCanvas);
// ---- instances -------------------------------------------------------------
const editor = (0, texteditor_1.createTextEditor)(textEl);
const inter = new interaction_1.Interaction(canvas, editor);
const center = () => [canvas.clientWidth / 2, canvas.clientHeight / 2];
const ui = new ui_1.UI({
onTool: t => setTool(t),
onUndo: () => {
scene_1.scene.undo();
ui.update();
},
onRedo: () => {
scene_1.scene.redo();
ui.update();
},
onStroke: c => {
scene_1.scene.applyStyleToSelected({ stroke: c });
ui.update();
},
onFill: c => {
scene_1.scene.applyStyleToSelected({ fill: c });
ui.update();
},
onWidth: w => {
scene_1.scene.applyStyleToSelected({ strokeWidth: w });
ui.update();
},
onZoomIn: () => {
const [sx, sy] = center();
inter.zoomAt(scene_1.scene, sx, sy, 1.25);
ui.update();
},
onZoomOut: () => {
const [sx, sy] = center();
inter.zoomAt(scene_1.scene, sx, sy, 0.8);
ui.update();
},
onResetView: () => {
inter.resetView(scene_1.scene, canvas.clientWidth, canvas.clientHeight);
ui.update();
},
});
ui.build();
(0, ui_1.setUIToolGetter)(() => inter.tool);
let uiQueued = false;
scene_1.scene.onDirty = () => {
if (uiQueued)
return;
uiQueued = true;
requestAnimationFrame(() => {
uiQueued = false;
ui.update();
(0, renderer_1.markDirty)(); // any scene mutation guarantees a redraw
});
};
function setTool(t) {
inter.setTool(t);
ui.update();
}
// Debug/test hook (harmless in normal use).
window.__whiteboard = { scene: scene_1.scene, inter, ui };
// ---- boot: restore the board from localStorage, else center the view --------
resizeCanvas();
if (!scene_1.scene.load()) {
scene_1.scene.view = { x: canvas.clientWidth / 2, y: canvas.clientHeight / 2, zoom: 1 };
}
ui.update();
// ---- canvas events -----------------------------------------------------------
canvas.addEventListener('contextmenu', e => e.preventDefault());
canvas.addEventListener('wheel', e => {
e.preventDefault();
const r = canvas.getBoundingClientRect();
const sx = e.clientX - r.left;
const sy = e.clientY - r.top;
const factor = e.deltaY > 0 ? 1 / constants_1.ZOOM_STEP : constants_1.ZOOM_STEP;
inter.zoomAt(scene_1.scene, sx, sy, factor);
ui.update();
}, { passive: false });
canvas.addEventListener('pointerdown', e => {
if (e.button === 1)
e.preventDefault(); // stop middle-click autoscroll
inter.onPointerDown(scene_1.scene, e);
ui.update();
});
canvas.addEventListener('pointermove', e => {
inter.onPointerMove(scene_1.scene, e);
});
canvas.addEventListener('pointerup', e => {
inter.onPointerUp(scene_1.scene, e);
ui.update();
});
canvas.addEventListener('pointercancel', e => {
inter.onPointerUp(scene_1.scene, e);
ui.update();
});
canvas.addEventListener('dblclick', e => {
if (editor.editing || inter.tool !== 'select')
return;
const r = canvas.getBoundingClientRect();
const [sx, sy] = [e.clientX - r.left, e.clientY - r.top];
const [wx, wy] = (0, renderer_1.screenToWorld)(scene_1.scene.view, sx, sy);
const hit = inter.topmostAtWorld(scene_1.scene, wx, wy);
if (hit && hit.type === 'text') {
scene_1.scene.selected = new Set([hit.id]);
}
inter.enterText(scene_1.scene, wx, wy);
ui.update();
});
// ---- text overlay events ------------------------------------------------------
textEl.addEventListener('input', () => {
editor.fit();
(0, renderer_1.markDirty)();
});
textEl.addEventListener('keydown', e => {
e.stopPropagation(); // never let shortcuts fire while typing
if (e.key === 'Escape') {
e.preventDefault();
inter.cancelTextEdit();
ui.update();
}
else if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
inter.commitTextEdit();
ui.update();
}
});
textEl.addEventListener('blur', () => {
if (inter.isTextEditing()) {
inter.commitTextEdit();
ui.update();
}
});
// ---- keyboard ---------------------------------------------------------------
document.addEventListener('keydown', e => {
if (editor.editing)
return; // overlay handles its own keys
const k = e.key;
if (k === ' ') {
e.preventDefault(); // stop page scroll
inter.spacePressed(true);
return;
}
const ctrl = e.ctrlKey || e.metaKey;
if (ctrl) {
if (k === 'z' || k === 'Z') {
e.preventDefault();
if (e.shiftKey)
scene_1.scene.redo();
else
scene_1.scene.undo();
ui.update();
}
else if (k === 'y' || k === 'Y') {
e.preventDefault();
scene_1.scene.redo();
ui.update();
}
else if (k === 's' || k === 'S') {
e.preventDefault(); // we're a local app; just swallow browser save
}
return;
}
const shift = e.shiftKey;
switch (k) {
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':
e.preventDefault();
inter.deleteSelection(scene_1.scene);
ui.update();
break;
case 'Escape':
scene_1.scene.selected.clear();
ui.update();
break;
case 'ArrowLeft':
e.preventDefault();
inter.nudge(scene_1.scene, shift ? -constants_1.NUDGE_SHIFT : -constants_1.NUDGE, 0);
ui.update();
break;
case 'ArrowRight':
e.preventDefault();
inter.nudge(scene_1.scene, shift ? constants_1.NUDGE_SHIFT : constants_1.NUDGE, 0);
ui.update();
break;
case 'ArrowUp':
e.preventDefault();
inter.nudge(scene_1.scene, 0, shift ? -constants_1.NUDGE_SHIFT : -constants_1.NUDGE);
ui.update();
break;
case 'ArrowDown':
e.preventDefault();
inter.nudge(scene_1.scene, 0, shift ? constants_1.NUDGE_SHIFT : constants_1.NUDGE);
ui.update();
break;
case '=':
case '+': {
const [sx, sy] = center();
inter.zoomTo(scene_1.scene, scene_1.scene.view.zoom * 1.2, sx, sy);
ui.update();
break;
}
case '-':
case '_': {
const [sx, sy] = center();
inter.zoomTo(scene_1.scene, scene_1.scene.view.zoom / 1.2, sx, sy);
ui.update();
break;
}
case '0': {
const [sx, sy] = center();
inter.zoomTo(scene_1.scene, 1, sx, sy);
ui.update();
break;
}
}
});
document.addEventListener('keyup', e => {
if (e.key === ' ')
inter.spacePressed(false);
});
window.addEventListener('blur', () => inter.spacePressed(false));
// ---- render loop --------------------------------------------------------------
function frame() {
(0, texteditor_1.setTextEditorViewport)(scene_1.scene.view);
// skip the full redraw when nothing changed (draft/rubber mark dirty anyway)
if ((0, renderer_1.consumeDirty)() || inter.getDraft() || inter.getRubber()) {
(0, renderer_1.render)(ctx, scene_1.scene, canvas.clientWidth, canvas.clientHeight, dpr, inter.getDraft());
}
// rubber-band selection rectangle (screen space, drawn on top)
const rb = inter.getRubber();
if (rb) {
ctx.save();
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const x = rb.x0 * scene_1.scene.view.zoom + scene_1.scene.view.x;
const y = rb.y0 * scene_1.scene.view.zoom + scene_1.scene.view.y;
const w = (rb.x1 - rb.x0) * scene_1.scene.view.zoom;
const h = (rb.y1 - rb.y0) * scene_1.scene.view.zoom;
ctx.fillStyle = 'rgba(47, 111, 237, 0.08)';
ctx.fillRect(x, y, w, h);
ctx.strokeStyle = '#2f6fed';
ctx.lineWidth = 1;
ctx.setLineDash([4, 3]);
ctx.strokeRect(x, y, w, h);
ctx.restore();
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
};
// ── module: src/constants.ts ──
__mods["src/constants.ts"] = function (exports, require, module) {
"use strict";
// ---------------------------------------------------------------------------
// Tunable constants — colors, zoom, hit tolerance, handle size
// ---------------------------------------------------------------------------
Object.defineProperty(exports, "__esModule", { value: true });
exports.JITTER = exports.LS_KEY = exports.GRID_STEP = exports.NUDGE_SHIFT = exports.NUDGE = exports.TEXT_MIN_W = exports.MIN_SIZE = exports.HANDLE = exports.HIT_TOL = exports.ZOOM_STEP = exports.ZOOM_MAX = exports.ZOOM_MIN = exports.WIDTH_OPTIONS = exports.FILL_PALETTE = exports.STROKE_PALETTE = exports.COLORS = void 0;
exports.COLORS = {
// app chrome
appBg: '#e8eaed',
canvasBg: '#f4f5f7',
dot: '#d3d7dc',
dotActive: '#aab1ba',
// selection / UI
selection: '#2f6fed',
handleFill: '#ffffff',
// shape defaults + hand-drawn-ish palette
defaultStroke: '#1e1e1e',
defaultFill: 'transparent',
defaultWidth: 2,
};
exports.STROKE_PALETTE = [
'#1e1e1e',
'#e03131',
'#f08c00',
'#2f9e44',
'#1c7ed6',
'#6741d9',
'#0c8599',
];
exports.FILL_PALETTE = [
'transparent',
'#ffc9c9',
'#ffd8a8',
'#b2f2bb',
'#a5d8ff',
'#d0bfff',
'#99e9f2',
];
exports.WIDTH_OPTIONS = [1, 2, 4, 6];
exports.ZOOM_MIN = 0.25;
exports.ZOOM_MAX = 4;
exports.ZOOM_STEP = 1.1; // per wheel "notch"
exports.HIT_TOL = 3; // screen px: hit tolerance (divided by zoom for world)
exports.HANDLE = 8; // screen px: resize handle size
exports.MIN_SIZE = 6; // world px
exports.TEXT_MIN_W = 60;
exports.NUDGE = 1; // px per arrow press (world units)
exports.NUDGE_SHIFT = 10;
exports.GRID_STEP = 26; // world px
exports.LS_KEY = 'slate.board.v1';
// Small jitter (world px) for the hand-drawn feel of stroke corners.
exports.JITTER = 1.1;
};
// ── module: src/types.ts ──
__mods["src/types.ts"] = function (exports, require, module) {
"use strict";
// ---------------------------------------------------------------------------
// Object model — every shape is a serializable record in one scene array
// ---------------------------------------------------------------------------
Object.defineProperty(exports, "__esModule", { value: true });
};
// ── module: src/geometry.ts ──
__mods["src/geometry.ts"] = function (exports, require, module) {
"use strict";
// ---------------------------------------------------------------------------
// Geometry — bboxes, hit-testing, projections (all in world coordinates)
// ---------------------------------------------------------------------------
Object.defineProperty(exports, "__esModule", { value: true });
exports.uid = uid;
exports.clamp = clamp;
exports.dist = dist;
exports.jitterOf = jitterOf;
exports.unionRects = unionRects;
exports.bboxOfShapes = bboxOfShapes;
exports.snapToGrid = snapToGrid;
exports.makeTextShape = makeTextShape;
exports.hitShape = hitShape;
exports.strokeBBox = strokeBBox;
exports.traceSmoothPath = traceSmoothPath;
const constants_1 = require("./constants");
function uid() {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 9);
}
function clamp(v, a, b) {
return v < a ? a : v > b ? b : v;
}
function dist(ax, ay, bx, by) {
const dx = bx - ax;
const dy = by - ay;
return Math.sqrt(dx * dx + dy * dy);
}
// deterministic per-shape jitter (hand-drawn feel, stable across frames)
function jitterOf(s, i) {
let h = 0;
const src = s.id + i;
for (let k = 0; k < src.length; k++) {
h = (h * 31 + src.charCodeAt(k)) | 0;
}
return ((h % 1000) / 1000 - 0.5) * 2 * constants_1.JITTER;
}
// ---- bboxes ----------------------------------------------------------------
function unionRects(r1, r2) {
const x = Math.min(r1.x, r2.x);
const y = Math.min(r1.y, r2.y);
return { x, y, w: Math.max(r1.x + r1.w, r2.x + r2.w) - x, h: Math.max(r1.y + r1.h, r2.y + r2.h) - y };
}
function bboxOfShapes(shapes) {
let out = null;
for (const s of shapes) {
const r = { x: s.x, y: s.y, w: Math.max(0, s.w), h: Math.max(0, s.h) };
out = out ? unionRects(out, r) : r;
}
return out || { x: 0, y: 0, w: 0, h: 0 };
}
function snapToGrid(v) {
return Math.round(v / constants_1.GRID_STEP) * constants_1.GRID_STEP;
}
function makeTextShape(x, y, style) {
return {
id: uid(),
type: 'text',
x: snapToGrid(x),
y: snapToGrid(y),
w: constants_1.TEXT_MIN_W,
h: 26,
stroke: style.stroke,
fill: 'transparent',
strokeWidth: style.strokeWidth,
text: '',
fontSize: 20,
};
}
// ---- per-shape hit tests ----------------------------------------------------
function pointInPoly(px, py, pts) {
let inside = false;
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
const [xi, yi] = pts[i];
const [xj, yj] = pts[j];
if (yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi) {
inside = !inside;
}
}
return inside;
}
function distToSeg(px, py, ax, ay, bx, by) {
const dx = bx - ax;
const dy = by - ay;
const l2 = dx * dx + dy * dy;
let t = l2 === 0 ? 0 : ((px - ax) * dx + (py - ay) * dy) / l2;
t = clamp(t, 0, 1);
return dist(px, py, ax + t * dx, ay + t * dy);
}
function nearStroke(px, py, pts, tol) {
for (let i = 1; i < pts.length; i++) {
if (distToSeg(px, py, pts[i - 1][0], pts[i - 1][1], pts[i][0], pts[i][1]) <= tol) {
return true;
}
}
return false;
}
function hitShape(s, px, py, tol) {
const left = s.x - tol;
const right = s.x + s.w + tol;
const top = s.y - tol;
const bottom = s.y + s.h + tol;
if (px < left || px > right || py < top || py > bottom) {
return false; // broad phase — a corner of the bbox outside the shape misses
}
switch (s.type) {
case 'rect': {
// area test (fills and outlines alike) — bbox corner region outside a
// shape can never match because each case uses the true shape bounds
return px >= s.x - tol && px <= s.x + s.w + tol && py >= s.y - tol && py <= s.y + s.h + tol;
}
case 'ellipse': {
const cx = s.x + s.w / 2;
const cy = s.y + s.h / 2;
const rx = Math.max(0.01, s.w / 2);
const ry = Math.max(0.01, s.h / 2);
const ex = (px - cx) / rx;
const ey = (py - cy) / ry;
const e = Math.hypot(ex, ey);
// inside → always a hit; outside → only within a world-pixel band of the curve
// (bbox corner regions never match)
const outDist = Math.max(0, e - 1) * Math.min(rx, ry);
return outDist <= tol + s.strokeWidth / 2;
}
case 'line':
case 'arrow': {
const bx = s.flipX ? s.x : s.x + s.w;
const by = s.flipY ? s.y : s.y + s.h;
const d = distToSeg(px, py, s.x, s.y, bx, by);
const headR = Math.max(7, s.strokeWidth * 3);
if (s.arrow && d <= headR) {
if (dist(px, py, s.x, s.y) <= headR + tol || dist(px, py, bx, by) <= headR + tol)
return true;
}
return d <= tol + s.strokeWidth / 2;
}
case 'stroke': {
if (!s.points || s.points.length === 0)
return false;
if (s.points.length > 1 && s.fill !== 'transparent') {
// treat dense strokes as a closed region fallback
if (pointInPoly(px, py, s.points))
return true;
}
return nearStroke(px, py, s.points, tol + s.strokeWidth / 2);
}
case 'text': {
return px >= s.x - 3 && px <= s.x + s.w + 3 && py >= s.y - 3 && py <= s.y + s.h + 3;
}
}
return false;
}
// ---- stroke helpers ----------------------------------------------------------
function strokeBBox(pts) {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const [x, y] of pts) {
if (x < minX)
minX = x;
if (y < minY)
minY = y;
if (x > maxX)
maxX = x;
if (y > maxY)
maxY = y;
}
return { x: minX, y: minY, w: Math.max(1, maxX - minX), h: Math.max(1, maxY - minY) };
}
// Catmull-Rom style smoothing: quadratic beziers through midpoints.
function traceSmoothPath(ctx, pts, close = false) {
if (pts.length === 0)
return;
if (pts.length === 1) {
const [x, y] = pts[0];
ctx.moveTo(x, y);
ctx.lineTo(x + 0.01, y);
return;
}
ctx.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;
ctx.quadraticCurveTo(pts[i][0], pts[i][1], mx, my);
}
const last = pts[pts.length - 1];
if (close) {
ctx.quadraticCurveTo(last[0], last[1], (last[0] + pts[0][0]) / 2, (last[1] + pts[0][1]) / 2);
}
else {
ctx.lineTo(last[0], last[1]);
}
}
};
// ── module: src/scene.ts ──
__mods["src/scene.ts"] = function (exports, require, module) {
"use strict";
// ---------------------------------------------------------------------------
// Scene store — single source of truth, history, localStorage persistence
// ---------------------------------------------------------------------------
Object.defineProperty(exports, "__esModule", { value: true });
exports.scene = exports.Scene = void 0;
const constants_1 = require("./constants");
const geometry_1 = require("./geometry");
function snapshot(s) {
const copy = { ...s };
if (s.points)
copy.points = s.points.map(p => [p[0], p[1]]);
return copy;
}
function restore(target, from) {
const copy = { ...from };
if (from.points)
copy.points = from.points.map(p => [p[0], p[1]]);
Object.assign(target, copy);
}
class Scene {
constructor() {
this.shapes = [];
this.selected = new Set();
this.defaults = {
stroke: constants_1.COLORS.defaultStroke,
fill: constants_1.COLORS.defaultFill,
strokeWidth: constants_1.COLORS.defaultWidth,
};
this.view = { x: 0, y: 0, zoom: 1 };
// ---- history (in-memory only; board content itself persists) -----------
this.undoStack = [];
this.redoStack = [];
/** Pre-states captured by an in-flight gesture (one per beginChange). */
this.pending = [];
this.saveTimer = null;
this.onDirty = null;
// ---- eraser gesture (one undo step per drag) ----------------------------
this.erasing = null;
}
get(id) {
return this.shapes.find(s => s.id === id);
}
get selectedShapes() {
return this.shapes.filter(s => this.selected.has(s.id));
}
/** Snapshot the pre-state of a set of shapes. Call once per gesture. */
beginChange(ids) {
this.pending = this.shapes.filter(s => ids.includes(s.id)).map(snapshot);
}
/** Push the pending pre-state + current post-state as one undoable step. */
commitChange() {
const pre = this.pending;
this.pending = [];
if (!pre.length)
return;
const entries = pre.map(p => ({ pre: p, post: snapshot(this.get(p.id)) }));
this.push({ kind: 'change', entries });
}
add(shapes) {
this.shapes.push(...shapes);
this.push({ kind: 'add', shapes: shapes.map(snapshot) });
}
/** Drop a shape without touching history (used while text is being created). */
drop(s) {
this.shapes.push(s);
}
/** Remove shapes already filtered out of `shapes` as one history step. */
removeBatch(removed) {
if (!removed.length)
return;
const ids = new Set(removed.map(r => r.id));
for (const id of ids)
this.selected.delete(id);
this.push({ kind: 'remove', shapes: removed.map(snapshot) });
}
/** Discard an in-flight gesture snapshot (e.g. cancelled text creation). */
rollbackChange() {
this.pending = [];
}
/** Revert shapes to their pending pre-state, leaving no history step. */
restoreChange() {
const pre = this.pending;
this.pending = [];
for (const p of pre) {
const cur = this.get(p.id);
if (cur)
restore(cur, p);
}
}
beginErase() {
this.erasing = [];
}
/** Remove one shape into the in-flight erase gesture (no history yet). */
eraseShape(s) {
if (!this.erasing)
return;
const i = this.shapes.indexOf(s);
if (i < 0)
return;
this.shapes.splice(i, 1);
this.erasing.push(s);
this.selected.delete(s.id);
}
commitErase() {
const list = this.erasing;
this.erasing = null;
if (!list || !list.length)
return;
this.push({ kind: 'remove', shapes: list.map(snapshot) });
}
remove(ids) {
const removed = this.shapes.filter(s => ids.includes(s.id));
if (!removed.length)
return;
this.shapes = this.shapes.filter(s => !ids.includes(s.id));
for (const id of ids)
this.selected.delete(id);
this.push({ kind: 'remove', shapes: removed.map(snapshot) });
}
push(a) {
this.undoStack.push(a);
this.redoStack.length = 0;
this.markDirty();
}
canUndo() {
return this.undoStack.length > 0;
}
canRedo() {
return this.redoStack.length > 0;
}
clearHistory() {
this.undoStack.length = 0;
this.redoStack.length = 0;
}
undo() {
const a = this.undoStack.pop();
if (!a)
return;
switch (a.kind) {
case 'add':
this.shapes = this.shapes.filter(s => !a.shapes.some(r => r.id === s.id));
break;
case 'remove':
this.shapes.push(...a.shapes.map(snapshot));
break;
case 'change':
for (const e of a.entries) {
const cur = this.get(e.pre.id);
if (cur)
restore(cur, e.pre);
}
break;
}
for (const id of [...this.selected])
if (!this.get(id))
this.selected.delete(id);
this.redoStack.push(a);
this.markDirty();
}
redo() {
const a = this.redoStack.pop();
if (!a)
return;
switch (a.kind) {
case 'add':
this.shapes.push(...a.shapes.map(snapshot));
break;
case 'remove':
this.shapes = this.shapes.filter(s => !a.shapes.some(r => r.id === s.id));
break;
case 'change':
for (const e of a.entries) {
const cur = this.get(e.pre.id);
if (cur)
restore(cur, e.post);
}
break;
}
this.undoStack.push(a);
this.markDirty();
}
// ---- styling -----------------------------------------------------------
/** Apply a style patch to the selection, or set the default if nothing selected. */
applyStyleToSelected(patch) {
const sel = this.selectedShapes;
if (!sel.length) {
Object.assign(this.defaults, patch);
this.markDirty();
return;
}
this.beginChange(sel.map(s => s.id));
for (const s of sel)
Object.assign(s, patch);
this.commitChange();
}
// ---- persistence ----------------------------------------------------------
markDirty() {
if (this.onDirty)
this.onDirty();
if (this.saveTimer !== null)
window.clearTimeout(this.saveTimer);
this.saveTimer = window.setTimeout(() => {
this.saveTimer = null;
this.save();
}, 350);
}
save() {
try {
const data = {
v: 1,
shapes: this.shapes,
defaults: this.defaults,
viewport: this.view,
selected: [...this.selected],
savedAt: Date.now(),
};
localStorage.setItem(constants_1.LS_KEY, JSON.stringify(data));
}
catch {
/* storage unavailable — ignore */
}
}
load() {
try {
const raw = localStorage.getItem(constants_1.LS_KEY);
if (!raw)
return false;
const data = JSON.parse(raw);
if (!data || !Array.isArray(data.shapes))
return false;
const shapes = [];
for (const s of data.shapes) {
if (!s || typeof s.id !== 'string' || typeof s.type !== 'string')
continue;
if (typeof s.x !== 'number' || typeof s.y !== 'number')
continue;
shapes.push({
id: s.id,
type: s.type,
x: s.x,
y: s.y,
w: typeof s.w === 'number' ? s.w : 0,
h: typeof s.h === 'number' ? s.h : 0,
stroke: typeof s.stroke === 'string' ? s.stroke : constants_1.COLORS.defaultStroke,
fill: typeof s.fill === 'string' ? s.fill : constants_1.COLORS.defaultFill,
strokeWidth: typeof s.strokeWidth === 'number' ? s.strokeWidth : constants_1.COLORS.defaultWidth,
points: Array.isArray(s.points) ? s.points : undefined,
arrow: !!s.arrow,
text: typeof s.text === 'string' ? s.text : undefined,
fontSize: typeof s.fontSize === 'number' ? s.fontSize : undefined,
flipX: !!s.flipX,
flipY: !!s.flipY,
});
}
this.shapes = shapes;
this.selected = new Set(Array.isArray(data.selected)
? data.selected.filter((id) => typeof id === 'string' && shapes.some(s => s.id === id))
: []);
if (data.defaults)
Object.assign(this.defaults, data.defaults);
if (data.viewport && typeof data.viewport.zoom === 'number') {
this.view = {
x: Number(data.viewport.x) || 0,
y: Number(data.viewport.y) || 0,
zoom: (0, geometry_1.clamp)(data.viewport.zoom, 0.25, 4),
};
}
return true;
}
catch {
return false;
}
}
}
exports.Scene = Scene;
exports.scene = new Scene();
};
// ── module: src/renderer.ts ──
__mods["src/renderer.ts"] = function (exports, require, module) {
"use strict";
// ---------------------------------------------------------------------------
// Renderer — viewport transform, grid, shapes, selection (screen-space)
// ---------------------------------------------------------------------------
Object.defineProperty(exports, "__esModule", { value: true });
exports.markDirty = markDirty;
exports.consumeDirty = consumeDirty;
exports.screenToWorld = screenToWorld;
exports.worldToScreen = worldToScreen;
exports.resetViewToCenter = resetViewToCenter;
exports.drawSelection = drawSelection;
exports.render = render;
const constants_1 = require("./constants");
const geometry_1 = require("./geometry");
let dirty = true;
function markDirty() {
dirty = true;
}
function consumeDirty() {
const d = dirty;
dirty = false;
return d;
}
function screenToWorld(vp, sx, sy) {
return [(sx - vp.x) / vp.zoom, (sy - vp.y) / vp.zoom];
}
function worldToScreen(vp, wx, wy) {
return [wx * vp.zoom + vp.x, wy * vp.zoom + vp.y];
}
function resetViewToCenter(scene, cw, ch) {
scene.view.zoom = 1;
scene.view.x = cw / 2;
scene.view.y = ch / 2;
markDirty();
}
// ---- grid --------------------------------------------------------------------
// Dots are stamped from a pre-rendered pattern tile (device px), so the whole
// grid costs a single pattern fill per frame regardless of dot count.
let gridTile = null;
let gridTileSize = 0; // tile width in device px
function getGridTile(tsCss, dpr) {
const W = Math.max(2, Math.round(tsCss * dpr));
if (gridTile && gridTileSize === W)
return gridTile;
const c = document.createElement('canvas');
c.width = W;
c.height = W;
const g = c.getContext('2d');
g.fillStyle = constants_1.COLORS.dot;
const d = Math.max(2, Math.round(2 * dpr)); // ~2 css px dot
for (const [x, y] of [[0, 0], [W, 0], [0, W], [W, W]]) {
g.fillRect(x - d / 2, y - d / 2, d, d);
}
gridTile = c;
gridTileSize = W;
return c;
}
function drawGrid(ctx, vp, cw, ch, dpr) {
// pick a grid spacing in world units so dots are ~24–60 screen px apart
let step = constants_1.GRID_STEP;
while (step * vp.zoom < 24)
step *= 4;
while (step * vp.zoom > 60)
step /= 2;
const alpha = clamp01((vp.zoom - 0.35) * 1.4);
if (alpha <= 0.01)
return;
const ts = step * vp.zoom; // tile size in css px
const W = Math.max(2, Math.round(ts * dpr));
const tile = getGridTile(ts, dpr);
const pat = ctx.createPattern(tile, 'repeat');
if (!pat)
return;
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0); // device px
const offX = ((((vp.x * dpr) % W) + W) % W);
const offY = ((((vp.y * dpr) % W) + W) % W);
ctx.fillStyle = pat;
ctx.globalAlpha = 0.85 * alpha;
ctx.fillRect(offX - W, offY - W, cw * dpr + 2 * W, ch * dpr + 2 * W);
ctx.globalAlpha = 1;
ctx.restore();
}
function clamp01(v) {
return v < 0 ? 0 : v > 1 ? 1 : v;
}
// ---- shapes --------------------------------------------------------------------
function drawRect(ctx, s) {
const j = (0, geometry_1.jitterOf)(s, 0);
const x = s.x + j;
const y = s.y - j;
const w = s.w;
const h = s.h;
if (s.fill !== 'transparent') {
ctx.fillStyle = s.fill;
ctx.beginPath();
ctx.rect(x, y, w, h);
ctx.fill();
}
ctx.strokeStyle = s.stroke;
ctx.lineWidth = s.strokeWidth;
ctx.beginPath();
ctx.rect(x, y, w, h);
ctx.stroke();
}
function drawEllipse(ctx, s) {
const cx = s.x + s.w / 2;
const cy = s.y + s.h / 2;
const rx = Math.max(0.01, s.w / 2);
const ry = Math.max(0.01, s.h / 2);
if (s.fill !== 'transparent') {
ctx.fillStyle = s.fill;
ctx.beginPath();
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
ctx.fill();
}
ctx.strokeStyle = s.stroke;
ctx.lineWidth = s.strokeWidth;
ctx.beginPath();
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
ctx.stroke();
}
function drawLine(ctx, s) {
const x2 = s.flipX ? s.x : s.x + s.w;
const y2 = s.flipY ? s.y : s.y + s.h;
ctx.strokeStyle = s.stroke;
ctx.lineWidth = s.strokeWidth;
ctx.beginPath();
ctx.moveTo(s.x, s.y);
ctx.lineTo(x2, y2);
ctx.stroke();
if (s.arrow) {
const ang = Math.atan2(y2 - s.y, x2 - s.x);
const head = Math.max(7, s.strokeWidth * 3.2);
ctx.fillStyle = s.stroke;
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - head * Math.cos(ang - 0.45), y2 - head * Math.sin(ang - 0.45));
ctx.lineTo(x2 - head * Math.cos(ang + 0.45), y2 - head * Math.sin(ang + 0.45));
ctx.closePath();
ctx.fill();
}
}
function drawStroke(ctx, s) {
if (!s.points || s.points.length < 2)
return;
ctx.strokeStyle = s.stroke;
ctx.lineWidth = s.strokeWidth;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.beginPath();
(0, geometry_1.traceSmoothPath)(ctx, s.points, false);
ctx.stroke();
}
function drawText(ctx, s) {
if (!s.text)
return;
ctx.fillStyle = s.stroke;
ctx.font = `${s.fontSize || 20}px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif`;
ctx.textBaseline = 'top';
const lines = s.text.split('\n');
const lh = (s.fontSize || 20) * 1.25;
for (let i = 0; i < lines.length; i++) {
if (lines[i])
ctx.fillText(lines[i], s.x, s.y + i * lh);
}
ctx.textBaseline = 'alphabetic';
}
function drawShape(ctx, s) {
switch (s.type) {
case 'rect':
drawRect(ctx, s);
break;
case 'ellipse':
drawEllipse(ctx, s);
break;
case 'line':
case 'arrow':
drawLine(ctx, s);
break;
case 'stroke':
drawStroke(ctx, s);
break;
case 'text':
drawText(ctx, s);
break;
}
}
// ---- selection overlay (screen space, crisp at any zoom) ----------------------
function drawSelection(ctx, scene, vp) {
const sel = scene.selectedShapes;
if (!sel.length)
return;
const box = (0, geometry_1.bboxOfShapes)(sel);
const [x, y] = worldToScreen(vp, box.x, box.y);
const w = box.w * vp.zoom;
const h = box.h * vp.zoom;
ctx.save();
ctx.strokeStyle = constants_1.COLORS.selection;
ctx.lineWidth = 1.5;
ctx.setLineDash([5, 3]);
ctx.strokeRect(x - 2, y - 2, w + 4, h + 4);
ctx.setLineDash([]);
const hs = constants_1.HANDLE / 2 + 1; // half handle size (screen px)
const corners = [
[x, y],
[x + w, y],
[x, y + h],
[x + w, y + h],
];
for (const [cx, cy] of corners) {
ctx.beginPath();
ctx.rect(cx - hs, cy - hs, hs * 2, hs * 2);
ctx.fillStyle = constants_1.COLORS.handleFill;
ctx.fill();
ctx.strokeStyle = constants_1.COLORS.selection;
ctx.lineWidth = 1.5;
ctx.stroke();
}
ctx.restore();
}
// ---- main frame ---------------------------------------------------------
function render(ctx, scene, cw, ch, dpr, draft) {
const vp = scene.view;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = constants_1.COLORS.canvasBg;
ctx.fillRect(0, 0, cw, ch);
drawGrid(ctx, vp, cw, ch, dpr);
ctx.setTransform(dpr * vp.zoom, 0, 0, dpr * vp.zoom, dpr * vp.x, dpr * vp.y);
for (const s of scene.shapes)
drawShape(ctx, s);
if (draft)
drawShape(ctx, draft);
// screen-space overlay
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
drawSelection(ctx, scene, vp);
}
};
// ── module: src/texteditor.ts ──
__mods["src/texteditor.ts"] = function (exports, require, module) {
"use strict";
// ---------------------------------------------------------------------------
// Text editing — inline <textarea> overlay (screen px) around a world-anchored
// text shape; metrics computed from the text itself so the box is exact.
// ---------------------------------------------------------------------------
Object.defineProperty(exports, "__esModule", { value: true });
exports.TextEditor = exports.TEXT_PADDING = exports.TEXT_LINE_H = exports.TEXT_FONT_SIZE = void 0;
exports.measureText = measureText;
exports.createTextEditor = createTextEditor;
exports.setTextEditorViewport = setTextEditorViewport;
const renderer_1 = require("./renderer");
exports.TEXT_FONT_SIZE = 20;
exports.TEXT_LINE_H = 1.25;
exports.TEXT_PADDING = 3; // world px around text
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif';
const meas = document.createElement('canvas').getContext('2d');
/** Measure a text block in world units. */
function measureText(text, fontSize) {
const lines = (text || '').split('\n');
meas.font = `${fontSize}px ${FONT_STACK}`;
let maxW = 0;
for (const line of lines) {
maxW = Math.max(maxW, meas.measureText(line || ' ').width);
}
return [maxW + exports.TEXT_PADDING * 2, lines.length * fontSize * exports.TEXT_LINE_H];
}
class TextEditor {
constructor(el) {
this.active = null;
this.el = el;
}
get editing() {
return this.active !== null;
}
get shape() {
return this.active;
}
/** Open editing on an existing shape (focus goes to the overlay). */
open(shape) {
this.active = shape;
shape.fontSize = shape.fontSize || exports.TEXT_FONT_SIZE;
if (shape.text === undefined)
shape.text = '';
this.apply();
this.el.focus();
const len = this.el.value.length;
this.el.setSelectionRange(len, len);
}
/** Read the current overlay text without closing. */
takeValue() {
return this.el.value;
}
/** Hide the overlay; leaves the shape untouched (caller owns history). */
close() {
this.active = null;
this.el.style.display = 'none';
}
/** Keep the overlay fitted to the current content (call on input + view change). */
fit() {
const shape = this.active;
if (!shape)
return;
const z = scene_view().zoom;
const fs = shape.fontSize || exports.TEXT_FONT_SIZE;
const [w, h] = measureText(this.el.value, fs);
this.el.style.width = w * z + 'px';
this.el.style.height = h * z + 'px';
shape.w = w;
shape.h = h;
}
/** Re-anchor the overlay after a pan/zoom while editing. */
reposition(vp) {
if (this.active)
this.apply();
}
apply() {
const shape = this.active;
const vp = scene_view();
const z = vp.zoom;
const fs = shape.fontSize || exports.TEXT_FONT_SIZE;
const el = this.el;
el.style.display = 'block';
el.style.fontSize = fs * z + 'px';
el.style.lineHeight = (fs * z * exports.TEXT_LINE_H) + 'px';
el.style.fontFamily = FONT_STACK;
el.style.color = shape.stroke;
el.value = shape.text || '';
const [sx, sy] = (0, renderer_1.worldToScreen)(vp, shape.x, shape.y);
el.style.left = (sx - exports.TEXT_PADDING * z) + 'px';
el.style.top = (sy - exports.TEXT_PADDING * z) + 'px';
this.fit();
}
}
exports.TextEditor = TextEditor;
function createTextEditor(el) {
return new TextEditor(el);
}
// The editor needs the live viewport; main wires this in.
let currentVp = { x: 0, y: 0, zoom: 1 };
function setTextEditorViewport(vp) {
currentVp = vp;
}
function scene_view() {
return currentVp;
}
};
// ── module: src/interaction.ts ──
__mods["src/interaction.ts"] = function (exports, require, module) {
"use strict";
// ---------------------------------------------------------------------------
// Interaction — the pointer/keyboard state machine behind every tool.
// World coordinates throughout; screen px only for cursors & handle hits.
// ---------------------------------------------------------------------------
Object.defineProperty(exports, "__esModule", { value: true });
exports.Interaction = void 0;
const scene_1 = require("./scene");
const constants_1 = require("./constants");
const geometry_1 = require("./geometry");
const renderer_1 = require("./renderer");
const texteditor_1 = require("./texteditor");
class Interaction {
constructor(canvas, editor) {
this.tool = 'select';
this.mode = 'idle';
// gesture state
this.startS = [0, 0]; // screen
this.startW = [0, 0]; // world
this.moved = false;
this.panOrigin = null;
this.moveStart = new Map();
this.resizeAnchor = [0, 0];
this.resizePre = null;
this.rubberStart = [0, 0];
this.rubber = null;
this.penPts = [];
this.penShape = null;
this.draft = null;
this.spaceDown = false;
this.textEdit = null;
this.canvas = canvas;
this.editor = editor;
}
// ---- public queries ------------------------------------------------------
/** Topmost shape at a world point (for double-click text editing). */
topmostAtWorld(scene, wx, wy) {
return this.topmostAt(scene, wx, wy);
}
getDraft() {
return this.draft;
}
getRubber() {
return this.rubber;
}
isTextEditing() {
return this.textEdit !== null;
}
// ---- tools & viewport ----------------------------------------------------
setTool(t) {
this.tool = t;
if (this.editor.editing)
this.commitTextEdit();
this.updateCursor();
}
spacePressed(d) {
this.spaceDown = d;
this.updateCursor();
}
/** Double-click on text: edit it (opens the inline editor). */
enterText(scene, wx, wy) {
const sel = scene.selectedShapes;
if (sel.length === 1 && sel[0].type === 'text') {
this.startTextEdit(scene, sel[0], 'edit');
return;
}
const shape = (0, geometry_1.makeTextShape)(wx, wy, scene.defaults);
scene.drop(shape);
this.startTextEdit(scene, shape, 'create');
}
startTextEdit(scene, shape, mode) {
scene.beginChange([shape.id]);
scene.selected = new Set([shape.id]);
this.textEdit = { mode, shape, cancelled: false };
this.mode = 'idle';
this.editor.open(shape);
(0, renderer_1.markDirty)();
}
/** Zoom about a screen point; clamped to [ZOOM_MIN, ZOOM_MAX]. */
zoomAt(scene, sx, sy, factor) {
const vp = scene.view;
const [wx, wy] = (0, renderer_1.screenToWorld)(vp, sx, sy);
const z2 = (0, geometry_1.clamp)(vp.zoom * factor, constants_1.ZOOM_MIN, constants_1.ZOOM_MAX);
if (z2 === vp.zoom)
return;
vp.zoom = z2;
vp.x = sx - wx * z2;
vp.y = sy - wy * z2;
(0, renderer_1.markDirty)();
scene.markDirty();
if (this.editor.editing)
this.editor.reposition(vp);
}
zoomTo(scene, target, sx, sy) {
const vp = scene.view;
const [wx, wy] = (0, renderer_1.screenToWorld)(vp, sx, sy);
const z2 = (0, geometry_1.clamp)(target, constants_1.ZOOM_MIN, constants_1.ZOOM_MAX);
vp.zoom = z2;
vp.x = sx - wx * z2;
vp.y = sy - wy * z2;
(0, renderer_1.markDirty)();
scene.markDirty();
if (this.editor.editing)
this.editor.reposition(vp);
}
resetView(scene, cw, ch) {
scene.view.zoom = 1;
scene.view.x = cw / 2;
scene.view.y = ch / 2;
(0, renderer_1.markDirty)();
scene.markDirty();
}
// ---- nudging / deleting ---------------------------------------------------
nudge(scene, dx, dy) {
if (this.mode !== 'idle' || this.editor.editing)
return;
const sel = scene.selectedShapes;
if (!sel.length)
return;
scene.beginChange(sel.map(s => s.id));
for (const s of sel) {
s.x += dx;
s.y += dy;
}
scene.commitChange();
(0, renderer_1.markDirty)();
}
deleteSelection(scene) {
if (this.editor.editing)
return;
const sel = scene.selectedShapes;
if (!sel.length)
return;
scene.remove(sel.map(s => s.id));
(0, renderer_1.markDirty)();
}
// ---- pointer input ----------------------------------------------------------
onPointerDown(scene, e) {
const [sx, sy] = this.toScreen(e);
const vp = scene.view;
const [wx, wy] = (0, renderer_1.screenToWorld)(vp, sx, sy);
if (e.pointerType === 'mouse') {
try {
this.canvas.setPointerCapture(e.pointerId);
}
catch {
/* ignore */
}
}
// A click that lands on the canvas while editing text: commit first.
if (this.editor.editing) {
this.commitTextEdit();
return;
}
// pan: space+drag, middle mouse, or left-drag while space held
if (this.spaceDown || e.button === 1) {
this.mode = 'pan';
this.panOrigin = { x: vp.x, y: vp.y, sx, sy };
return;
}
if (e.button !== 0)
return;
this.startS = [sx, sy];
this.startW = [wx, wy];
this.moved = false;
switch (this.tool) {
case 'pen': {
this.mode = 'pen';
this.penPts = [[wx, wy]];
this.penShape = this.makeStroke(scene);
break;
}
case 'rect':
case 'ellipse':
case 'line':
this.mode = 'draw';
this.draft = null;
break;
case 'text': {
const shape = (0, geometry_1.makeTextShape)(wx, wy, scene.defaults);
scene.drop(shape);
this.startTextEdit(scene, shape, 'create');
break;
}
case 'eraser': {
this.mode = 'erase';
scene.beginErase();
this.eraseAt(scene, wx, wy);
break;
}
case 'select': {
this.startSelect(scene, sx, sy, wx, wy, e.shiftKey);
break;
}
}
this.updateCursor();
}
onPointerMove(scene, e) {
const [sx, sy] = this.toScreen(e);
const vp = scene.view;
const [wx, wy] = (0, renderer_1.screenToWorld)(vp, sx, sy);
if (this.mode === 'pan' && this.panOrigin) {
vp.x = this.panOrigin.x + (sx - this.panOrigin.sx);
vp.y = this.panOrigin.y + (sy - this.panOrigin.sy);
(0, renderer_1.markDirty)();
if (this.editor.editing)
this.editor.reposition(vp);
return;
}
if (!this.moved && (0, geometry_1.dist)(sx, sy, this.startS[0], this.startS[1]) > 1) {
this.moved = true;
this.updateCursor();
}
switch (this.mode) {
case 'pen': {
const last = this.penPts[this.penPts.length - 1];
if ((0, geometry_1.dist)(wx, wy, last[0], last[1]) > 1.1 / vp.zoom) {
this.penPts.push([wx, wy]);
this.penShape = this.makeStroke(scene);
}
break;
}
case 'draw': {
if (this.moved)
this.draft = this.makeDraft(scene, wx, wy, e.shiftKey);
break;
}
case 'move': {
if (this.moved) {
const dx = wx - this.startW[0];
const dy = wy - this.startW[1];
for (const s of scene.shapes) {
const st = this.moveStart.get(s.id);
if (st) {
s.x = st.x + dx;
s.y = st.y + dy;
}
}
}
break;
}
case 'resize': {
if (this.moved && this.resizePre)
this.applyResize(scene, wx, wy, e.shiftKey);
break;
}
case 'rubber': {
this.rubber = {
x0: Math.min(this.rubberStart[0], wx),
y0: Math.min(this.rubberStart[1], wy),
x1: Math.max(this.rubberStart[0], wx),
y1: Math.max(this.rubberStart[1], wy),
};
break;
}
case 'erase': {
if (this.moved)
this.eraseAt(scene, wx, wy);
break;
}
default:
this.hoverCursor(scene, sx, sy, wx, wy);
}
if (this.mode !== 'pan')
(0, renderer_1.markDirty)();
}
onPointerUp(scene, e) {
const [sx, sy] = this.toScreen(e);
const vp = scene.view;
const [wx, wy] = (0, renderer_1.screenToWorld)(vp, sx, sy);
switch (this.mode) {
case 'pan':
scene.markDirty();
break;
case 'pen': {
if (this.penPts.length >= 2) {
this.addScene(scene, [this.makeStroke(scene)], true);
}
else {
this.addScene(scene, [this.dotStroke(scene, wx, wy)], true);
}
this.penPts = [];
this.penShape = null;
break;
}
case 'draw': {
let shape = this.draft;
if (!shape || (shape.w < constants_1.MIN_SIZE / vp.zoom && shape.h < constants_1.MIN_SIZE / vp.zoom)) {
const [cx, cy] = this.startW;
shape = this.defaultShapeAt(scene, cx, cy);
}
if (shape)
this.addScene(scene, [shape], true);
this.draft = null;
break;
}
case 'move': {
if (this.moved)
scene.commitChange();
break;
}
case 'resize': {
scene.commitChange();
this.resizePre = null;
break;
}
case 'rubber': {
if (this.rubber) {
const sel = scene.selected;
const r = this.rubber;
for (const s of scene.shapes) {
if (s.type === 'text' && !s.text)
continue;
const ox = s.x - 2;
const oy = s.y - 2;
if (ox < r.x1 &&
ox + s.w + 4 > r.x0 &&
oy < r.y1 &&
oy + s.h + 4 > r.y0) {
sel.add(s.id);
}
}
}
this.rubber = null;
break;
}
case 'erase':
scene.commitErase();
break;
}
this.mode = 'idle';
this.moved = false;
this.updateCursor();
(0, renderer_1.markDirty)();
}
// ---- select tool details ------------------------------------------------------
startSelect(scene, sx, sy, wx, wy, shift) {
const vp = scene.view;
// 1) resize handle when exactly one shape is selected
if (scene.selected.size === 1) {
const s = scene.selectedShapes[0];
const corner = this.cornerAt({ x: s.x, y: s.y, w: s.w, h: s.h }, sx, sy, vp);
if (corner) {
this.mode = 'resize';
const [col, row] = corner;
const ax = col === 0 ? s.x + s.w : s.x;
const ay = row === 0 ? s.y + s.h : s.y;
this.resizeAnchor = [ax, ay];
this.resizePre = { ...s, points: s.points ? s.points.map(p => [p[0], p[1]]) : undefined };
scene.beginChange([s.id]);
return;
}
}
// 2) shape under the cursor (topmost)
const hit = this.topmostAt(scene, wx, wy);
if (hit) {
let sel;
if (scene.selected.has(hit.id)) {
sel = new Set(scene.selected);
if (shift)
sel.delete(hit.id);
}
else if (shift) {
sel = new Set(scene.selected);
sel.add(hit.id);
}
else {
sel = new Set([hit.id]);
}
scene.selected = sel;
this.mode = 'move';
this.moveStart = new Map(scene.selectedShapes.map(s => [s.id, { x: s.x, y: s.y }]));
scene.beginChange([...sel]);
return;
}
// 3) empty space → rubber band
if (!shift)
scene.selected.clear();
this.mode = 'rubber';
this.rubberStart = [wx, wy];
this.rubber = null;
}
/** Corner handle hit (screen px). Returns [col, row], 0 = left/top. */
cornerAt(box, sx, sy, vp) {
const bx = box.x * vp.zoom + vp.x;
const by = box.y * vp.zoom + vp.y;
const bw = box.w * vp.zoom;
const bh = box.h * vp.zoom;
const corners = [
[bx, by, 0, 0],
[bx + bw, by, 1, 0],
[bx, by + bh, 0, 1],
[bx + bw, by + bh, 1, 1],
];
const r = 7; // screen px grab radius
for (const [cx, cy, col, row] of corners) {
if (Math.abs(sx - cx) <= r && Math.abs(sy - cy) <= r)
return [col, row];
}
return null;
}
topmostAt(scene, wx, wy) {
const tol = constants_1.HIT_TOL / scene.view.zoom;
for (let i = scene.shapes.length - 1; i >= 0; i--) {
if ((0, geometry_1.hitShape)(scene.shapes[i], wx, wy, tol))
return scene.shapes[i];
}
return null;
}
hoverCursor(scene, sx, sy, wx, wy) {
if (this.tool !== 'select') {
this.updateCursor();
return;
}
if (scene.selected.size === 1) {
const s = scene.selectedShapes[0];
if (this.cornerAt({ x: s.x, y: s.y, w: s.w, h: s.h }, sx, sy, scene.view)) {
this.canvas.style.cursor = 'nwse-resize';
return;
}
}
this.canvas.style.cursor = this.topmostAt(scene, wx, wy) ? 'move' : 'default';
}
// ---- resize ------------------------------------------------------------------
applyResize(scene, wx, wy, shift) {
const pre = this.resizePre;
if (!pre)
return;
const s = scene.get(pre.id);
if (!s)
return;
const [ax, ay] = this.resizeAnchor;
let x2 = wx;
let y2 = wy;
if (shift && (pre.type === 'rect' || pre.type === 'ellipse' || pre.type === 'text')) {
const pw = Math.max(1, Math.abs(pre.w));
const ph = Math.max(1, Math.abs(pre.h));
const ratio = pw / ph;
const dx = x2 - ax;
const dy = y2 - ay;
if (Math.abs(dx) * ph > Math.abs(dy) * pw) {
y2 = ay + (Math.sign(dy) || 1) * (Math.abs(dx) / ratio);
}
else {
x2 = ax + (Math.sign(dx) || 1) * Math.abs(dy) * ratio;
}
}
const min = constants_1.MIN_SIZE / scene.view.zoom;
if (pre.type === 'stroke' && pre.points && pre.points.length) {
const sw = Math.max(min, pre.w);
const sh = Math.max(min, pre.h);
let nw = (0, geometry_1.clamp)(Math.abs(x2 - ax), min, Infinity);
let nh = (0, geometry_1.clamp)(Math.abs(y2 - ay), min, Infinity);
if (shift) {
const ratio = sw / sh;
if (nw / ratio < nh)
nw = nh * ratio;
else
nh = nw / ratio;
}
const fxl = x2 < ax;
const fyl = y2 < ay;
s.x = fxl ? ax - nw : ax;
s.y = fyl ? ay - nh : ay;
s.w = nw;
s.h = nh;
s.points = pre.points.map(p => [
ax + ((p[0] - ax) - (sw / 2)) * (nw / sw) * (fxl ? -1 : 1) + (nw / 2),
ay + ((p[1] - ay) - (sh / 2)) * (nh / sh) * (fyl ? -1 : 1) + (nh / 2),
]);
return;
}
let x = Math.min(ax, x2);
let y = Math.min(ay, y2);
let w = Math.abs(x2 - ax);
let h = Math.abs(y2 - ay);
s.flipX = x2 < ax;
s.flipY = y2 < ay;
if (w < min) {
w = min;
x = x2 < ax ? ax - min : ax;
}
if (h < min) {
h = min;
y = y2 < ay ? ay - min : ay;
}
s.x = x;
s.y = y;
s.w = w;
s.h = h;
}
// ---- shape factories ---------------------------------------------------------
makeStroke(scene) {
const d = this.dotStroke(scene, -Infinity, -Infinity);
d.points = this.penPts.map(p => [p[0], p[1]]);
const b = (0, geometry_1.strokeBBox)(d.points);
d.x = b.x;
d.y = b.y;
d.w = b.w;
d.h = b.h;
return d;
}
dotStroke(scene, wx, wy) {
const r = scene.defaults.strokeWidth * 0.6;
return {
id: (0, geometry_1.uid)(),
type: 'stroke',
x: wx - r,
y: wy - r,
w: r * 2,
h: r * 2,
stroke: scene.defaults.stroke,
fill: scene.defaults.fill,
strokeWidth: scene.defaults.strokeWidth,
points: [
[wx, wy],
[wx + 0.01, wy + 0.01],
],
};
}
makeDraft(scene, wx, wy, shift) {
const d = scene.defaults;
const [x0, y0] = this.startW;
const min = constants_1.MIN_SIZE / scene.view.zoom;
if (this.tool === 'line') {
let dx = wx - x0;
let dy = wy - y0;
if (shift) {
const ang = Math.atan2(dy, dx);
const snapped = Math.round(ang / (Math.PI / 4)) * (Math.PI / 4);
const len = Math.max(min, Math.hypot(dx, dy));
dx = Math.cos(snapped) * len;
dy = Math.sin(snapped) * len;
}
return {
id: (0, geometry_1.uid)(),
type: 'line',
arrow: true,
x: Math.min(x0, x0 + dx),
y: Math.min(y0, y0 + dy),
w: Math.max(min, Math.abs(dx)),
h: Math.max(0.01, Math.abs(dy)),
stroke: d.stroke,
fill: d.fill,
strokeWidth: d.strokeWidth,
flipX: dx < 0,
flipY: dy < 0,
};
}
let w = wx - x0;
let h = wy - y0;
if (shift) {
const m = Math.max(Math.abs(w), Math.abs(h));
w = (Math.sign(w) || 1) * m;
h = (Math.sign(h) || 1) * m;
}
return {
id: (0, geometry_1.uid)(),
type: this.tool === 'rect' ? 'rect' : 'ellipse',
x: Math.min(x0, x0 + w),
y: Math.min(y0, y0 + h),
w: Math.max(min, Math.abs(w)),
h: Math.max(min, Math.abs(h)),
stroke: d.stroke,
fill: d.fill,
strokeWidth: d.strokeWidth,
};
}
defaultShapeAt(scene, cx, cy) {
const d = scene.defaults;
if (this.tool === 'line') {
return {
id: (0, geometry_1.uid)(),
type: 'line',
arrow: true,
x: cx,
y: cy,
w: 120,
h: 0.01,
stroke: d.stroke,
fill: d.fill,
strokeWidth: d.strokeWidth,
};
}
return {
id: (0, geometry_1.uid)(),
type: this.tool === 'rect' ? 'rect' : 'ellipse',
x: cx - 50,
y: cy - 35,
w: 100,
h: 70,
stroke: d.stroke,
fill: d.fill,
strokeWidth: d.strokeWidth,
};
}
addScene(scene, shapes, select) {
scene.add(shapes);
if (select)
scene.selected = new Set(shapes.map(s => s.id));
(0, renderer_1.markDirty)();
}
// ---- eraser -------------------------------------------------------------
eraseAt(scene, wx, wy) {
const baseTol = constants_1.HIT_TOL / scene.view.zoom;
let any = false;
for (const s of scene.shapes) {
if (s.type === 'text' && !s.text)
continue;
if ((0, geometry_1.hitShape)(s, wx, wy, baseTol + s.strokeWidth / 2)) {
scene.eraseShape(s);
any = true;
}
}
if (any)
(0, renderer_1.markDirty)();
}
// ---- text editing -----------------------------------------------------------
commitTextEdit() {
const t = this.textEdit;
this.textEdit = null;
if (!t)
return;
const shape = t.shape;
if (t.cancelled) {
if (t.mode === 'create') {
// never typed anything real: drop the placeholder shape entirely
scene_1.scene.shapes = scene_1.scene.shapes.filter(x => x.id !== shape.id);
}
else {
// edit cancelled: put the shape back exactly as found, no history step
scene_1.scene.restoreChange();
}
scene_1.scene.rollbackChange();
this.editor.close();
}
else {
const text = this.editor.takeValue();
shape.text = text;
const [w, h] = (0, texteditor_1.measureText)(text, shape.fontSize || 20);
shape.w = w;
shape.h = h;
this.editor.close();
if (t.mode === 'create' && !text.trim()) {
// abandoned empty text: drop the shape, leave no history step
scene_1.scene.shapes = scene_1.scene.shapes.filter(x => x.id !== shape.id);
scene_1.scene.rollbackChange();
}
else if (t.mode === 'create') {
// register as a true creation step so undo removes it entirely
scene_1.scene.shapes = scene_1.scene.shapes.filter(x => x.id !== shape.id);
scene_1.scene.rollbackChange();
scene_1.scene.add([shape]);
scene_1.scene.selected = new Set([shape.id]);
}
else {
scene_1.scene.commitChange();
}
}
// T is a one-shot tool: after placing a text box, go back to Select
if (this.tool === 'text') {
this.tool = 'select';
this.updateCursor();
}
scene_1.scene.markDirty();
(0, renderer_1.markDirty)();
}
cancelTextEdit() {
const t = this.textEdit;
if (!t)
return;
t.cancelled = true;
this.commitTextEdit();
}
// ---- cursor -------------------------------------------------------------
updateCursor() {
let c = 'default';
if (this.spaceDown || this.mode === 'pan')
c = this.mode === 'pan' ? 'grabbing' : 'grab';
else if (this.mode === 'move')
c = 'move';
else if (this.mode === 'resize')
c = 'nwse-resize';
else if (this.mode === 'rubber')
c = 'crosshair';
else if (this.tool === 'select')
c = 'default';
else if (this.tool === 'eraser')
c = 'cell';
else
c = 'crosshair';
this.canvas.style.cursor = c;
}
toScreen(e) {
const r = this.canvas.getBoundingClientRect();
return [e.clientX - r.left, e.clientY - r.top];
}
}
exports.Interaction = Interaction;
};
// ── module: src/ui.ts ──
__mods["src/ui.ts"] = function (exports, require, module) {
"use strict";
// ---------------------------------------------------------------------------
// UI chrome — floating toolbar, properties bar, zoom indicator
// ---------------------------------------------------------------------------
Object.defineProperty(exports, "__esModule", { value: true });
exports.UI = void 0;
exports.setUIToolGetter = setUIToolGetter;
const constants_1 = require("./constants");
const scene_1 = require("./scene");
const ICONS = {
select: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"><path d="M5 3l6.5 15 2-6.5L20 9.5 5 3z"/></svg>',
pen: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round"><path d="M16.5 3.5l4 4L7 21H3v-4L16.5 3.5z"/></svg>',
rect: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="4" y="6" width="16" height="12" rx="1.5"/></svg>',
ellipse: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><ellipse cx="12" cy="12" rx="8" ry="6"/></svg>',
line: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M5 19L19 5M19 5h-6M19 5v6"/></svg>',
text: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M5 6h14M12 6v13M9 19h6"/></svg>',
eraser: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round"><path d="M15 4l5 5-9 9H6l-3-3 9-9 3-2z"/><path d="M4 21h16"/></svg>',
undo: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M8 5L3 10l5 5"/><path d="M3 10h11a6 6 0 016 6v1"/></svg>',
redo: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M16 5l5 5-5 5"/><path d="M21 10H10a6 6 0 00-6 6v1"/></svg>',
frame: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg>',
};
const TOOLS = [
['select', 'Select', 'V'],
['pen', 'Pen', 'P'],
['rect', 'Rectangle', 'R'],
['ellipse', 'Ellipse', 'O'],
['line', 'Line / Arrow', 'L'],
['text', 'Text', 'T'],
['eraser', 'Eraser', 'E'],
];
class UI {
constructor(cb) {
this.toolBtns = new Map();
this.strokeEls = new Map();
this.fillEls = new Map();
this.widthEls = new Map();
this.lastZoom = -1;
this.cb = cb;
}
build() {
const app = document.getElementById('app');
// ---- toolbar ---------------------------------------------------------
const tb = document.createElement('div');
tb.id = 'toolbar';
tb.className = 'panel';
for (const [tool, label, key] of TOOLS) {
const b = document.createElement('button');
b.className = 'tb-btn';
b.title = `${label} (${key})`;
b.innerHTML = ICONS[tool];
b.addEventListener('click', () => {
this.cb.onTool(tool);
b.blur();
});
tb.appendChild(b);
this.toolBtns.set(tool, b);
}
tb.appendChild(this.sep());
this.undoBtn = this.iconBtn('undo', 'Undo (Ctrl+Z)', this.cb.onUndo);
this.redoBtn = this.iconBtn('redo', 'Redo (Ctrl+Shift+Z)', this.cb.onRedo);
tb.appendChild(this.undoBtn);
tb.appendChild(this.redoBtn);
app.appendChild(tb);
// ---- properties bar ----------------------------------------------------
const props = document.createElement('div');
props.id = 'props';
props.className = 'panel';
props.innerHTML =
'<div class="props-head"><span class="props-title">Style</span>' +
'<span id="sel-count" class="props-count">0</span></div>' +
'<div class="props-label">Stroke</div><div id="stroke-swatches" class="swatches"></div>' +
'<div class="props-label">Fill</div><div id="fill-swatches" class="swatches"></div>' +
'<div class="props-label">Width</div><div id="widths" class="widths"></div>' +
'<div class="props-hint">With a selection, styles apply to it. Otherwise they set the default for new shapes.</div>';
app.appendChild(props);
this.countEl = props.querySelector('#sel-count');
const sw = props.querySelector('#stroke-swatches');
for (const c of constants_1.STROKE_PALETTE) {
const b = this.swatch(c, c === 'transparent');
b.addEventListener('click', () => {
this.cb.onStroke(c);
b.blur();
});
sw.appendChild(b);
this.strokeEls.set(c, b);
}
const fw = props.querySelector('#fill-swatches');
for (const c of constants_1.FILL_PALETTE) {
const b = this.swatch(c, c === 'transparent');
b.addEventListener('click', () => {
this.cb.onFill(c);
b.blur();
});
fw.appendChild(b);
this.fillEls.set(c, b);
}
const ww = props.querySelector('#widths');
for (const w of constants_1.WIDTH_OPTIONS) {
const b = document.createElement('button');
b.className = 'width-btn';
b.title = `${w}px`;
b.innerHTML = `<span class="wline" style="height:${Math.min(w, 6)}px"></span>`;
b.addEventListener('click', () => {
this.cb.onWidth(w);
b.blur();
});
ww.appendChild(b);
this.widthEls.set(w, b);
}
// ---- zoom bar ---------------------------------------------------------
const zb = document.createElement('div');
zb.id = 'zoombar';
zb.className = 'panel';
const out = this.iconBtn('undo', 'Zoom out', this.cb.onZoomOut);
out.innerHTML = '<span style="font-size:16px;font-weight:600">−</span>';
this.zoomLabel = document.createElement('div');
this.zoomLabel.id = 'zoom-label';
this.zoomLabel.title = 'Click to reset to 100%';
this.zoomLabel.addEventListener('click', () => {
this.cb.onResetView();
this.zoomLabel.blur?.();
});
const inn = this.iconBtn('redo', 'Zoom in', this.cb.onZoomIn);
inn.innerHTML = '<span style="font-size:16px;font-weight:600">+</span>';
const rst = this.iconBtn('frame', 'Reset view (100%, centered)', this.cb.onResetView);
zb.appendChild(out);
zb.appendChild(this.zoomLabel);
zb.appendChild(inn);
zb.appendChild(this.sep());
zb.appendChild(rst);
app.appendChild(zb);
}
sep() {
const s = document.createElement('div');
s.className = 'tb-sep';
return s;
}
iconBtn(icon, title, onClick) {
const b = document.createElement('button');
b.className = 'tb-btn';
b.title = title;
b.innerHTML = ICONS[icon];
b.addEventListener('click', () => {
onClick();
b.blur(); // keep Space usable for panning, not button re-activation
});
return b;
}
swatch(color, isNone) {
const b = document.createElement('button');
b.className = 'swatch' + (isNone ? ' none' : '');
if (!isNone)
b.style.background = color;
b.title = color;
return b;
}
/** Reflect current state (tool, selection style, zoom, counts). */
update() {
const sel = scene_1.scene.selectedShapes;
for (const [tool, btn] of this.toolBtns) {
btn.classList.toggle('active', interaction_tool() === tool);
}
this.undoBtn.disabled = !scene_1.scene.canUndo();
this.redoBtn.disabled = !scene_1.scene.canRedo();
const ref = sel.length ? sel : null;
this.countEl.textContent = ref ? String(sel.length) : '—';
this.countEl.title = ref ? `${sel.length} selected` : 'no selection';
const strokeVal = sel.length
? sel.every(s => s.stroke === sel[0].stroke)
? sel[0].stroke
: null
: scene_1.scene.defaults.stroke;
for (const [c, el] of this.strokeEls)
el.classList.toggle('active', c === strokeVal);
const fillVal = sel.length
? sel.every(s => s.fill === sel[0].fill)
? sel[0].fill
: null
: scene_1.scene.defaults.fill;
for (const [c, el] of this.fillEls)
el.classList.toggle('active', c === fillVal);
const widthVal = sel.length
? sel.every(s => s.strokeWidth === sel[0].strokeWidth)
? sel[0].strokeWidth
: null
: scene_1.scene.defaults.strokeWidth;
for (const [w, el] of this.widthEls)
el.classList.toggle('active', w === widthVal);
const z = Math.round(scene_1.scene.view.zoom * 100);
if (z !== this.lastZoom) {
this.lastZoom = z;
this.zoomLabel.textContent = z + '%';
}
}
}
exports.UI = UI;
// the active tool lives in the Interaction; main wires this accessor in.
let toolGetter = () => 'select';
function setUIToolGetter(g) {
toolGetter = g;
}
function interaction_tool() {
return toolGetter();
}
};
// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"Qwen3.8","provider":"brigid","persona":"typescript","sessionId":"4eb358b0-6cfe-4e77-babe-fd042ad41acc","tokensIn":5493168,"tokensOut":216071,"tokensTotal":5709239,"cost":0,"turns":77,"toolCalls":112,"failedToolCalls":3,"timestamp":"2026-09-09T12:21:37.014Z"} -->