← Whiteboard results

Whiteboard

Qwen 3.8 · typescript

Initial view of Whiteboard

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"} -->