← Whiteboard results

Whiteboard

Qwen 3.8 27B GGUF · 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 86,354 bytes · SHA-256 e0877fbef9b8
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Whiteboard — Local Prototype</title>
<style>
/* ── css: base ── */
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; }
body {
  background: #e7e9ef;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  user-select: none;
}
#app { position: fixed; inset: 0; }
#board {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  touch-action: none;
}
#text-layer { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
#text-editor {
  position: absolute;
  pointer-events: auto;
  border: 1.5px solid #4263eb;
  border-radius: 4px;
  background: rgba(255, 255, 255, 0.8);
  box-shadow: 0 2px 10px rgba(20, 30, 60, 0.14);
  outline: none;
  resize: none;
  white-space: pre;
  overflow: hidden;
  font-weight: 400;
}
#hint {
  position: absolute;
  right: 14px;
  bottom: 12px;
  font-size: 11.5px;
  color: #98a0b0;
  pointer-events: none;
  z-index: 4;
  letter-spacing: 0.2px;
}
/* ── css: panels ── */
.panel {
  position: absolute;
  z-index: 10;
  display: flex;
  align-items: center;
  gap: 2px;
  padding: 5px;
  background: #ffffff;
  border: 1px solid #dcdfe7;
  border-radius: 11px;
  box-shadow: 0 3px 12px rgba(25, 32, 56, 0.10), 0 1px 2px rgba(25, 32, 56, 0.06);
}
#toolbar { left: 14px; top: 50%; transform: translateY(-50%); flex-direction: column; }
#props { left: 14px; bottom: 14px; flex-wrap: wrap; max-width: 430px; }
#view-controls { top: 14px; right: 14px; }

button { font-family: inherit; }
.tool-btn, .icon-btn {
  width: 34px;
  height: 34px;
  border: 0;
  border-radius: 8px;
  background: transparent;
  color: #414957;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: background 0.1s ease, color 0.1s ease;
}
.tool-btn:hover, .icon-btn:hover, .zoom-label:hover, .width-btn:hover, .swatch:hover { background: #eef1f6; }
.tool-btn.active, .icon-btn.active { background: #e4ebfd; color: #4263eb; }
.tool-btn:disabled, .icon-btn:disabled { opacity: 0.32; pointer-events: none; }
.tool-btn:focus-visible, .icon-btn:focus-visible, .zoom-label:focus-visible, .swatch:focus-visible, .width-btn:focus-visible {
  outline: 2px solid #4263eb;
  outline-offset: 1px;
}
.tool-btn svg, .icon-btn svg { width: 18px; height: 18px; display: block; }

.divider { background: #e2e5ec; flex: none; }
.divider.v { width: 1px; height: 20px; margin: 2px 3px; }

.zoom-label {
  min-width: 52px;
  border: 0;
  background: transparent;
  font-size: 12.5px;
  font-weight: 600;
  color: #414957;
  text-align: center;
  cursor: pointer;
  padding: 7px 4px;
  border-radius: 8px;
  font-variant-numeric: tabular-nums;
}
#undo-btn, #redo-btn, #zoom-out, #zoom-in, #zoom-reset { width: 30px; height: 30px; }
#undo-btn svg, #redo-btn svg, #zoom-out svg, #zoom-in svg, #zoom-reset svg { width: 16px; height: 16px; }

.swatch {
  width: 20px;
  height: 20px;
  border-radius: 6px;
  border: 1.5px solid rgba(0, 0, 0, 0.10);
  cursor: pointer;
  flex: none;
}
.swatch.active { border-color: #4263eb; box-shadow: 0 0 0 1.5px #4263eb; }
.swatch-none { background: linear-gradient(135deg, transparent 45%, #e03131 45%, #e03131 55%, transparent 55%) #ffffff; }

.width-btn {
  width: 28px;
  height: 28px;
  border: 0;
  border-radius: 7px;
  background: transparent;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  color: #414957;
  flex: none;
}
.width-btn.active { background: #e4ebfd; color: #4263eb; }
.wline { display: block; width: 16px; background: currentColor; border-radius: 2px; }
</style>
</head>
<body>
<div id="app"><canvas id="board"></canvas>
<div id="text-layer"></div>
<div class="panel" id="toolbar" role="toolbar" aria-label="Tools">
  <button class="tool-btn" data-tool="select" title="Select (V)" type="button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M5.5 3.5 19 11l-6.4 1.4-3 6.1z" fill="currentColor" stroke="none"></path></svg></button>
  <div class="divider v"></div>
  <button class="tool-btn" data-tool="pen" title="Pen (P)" type="button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20l1.1-4.3L16.4 4.4a2 2 0 0 1 2.9 0l.3.3a2 2 0 0 1 0 2.9L8.3 18.9 4 20z"></path></svg></button>
  <button class="tool-btn" data-tool="rect" title="Rectangle (R)" type="button"><svg 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"></rect></svg></button>
  <button class="tool-btn" data-tool="ellipse" title="Ellipse (O)" type="button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="7.5"></circle></svg></button>
  <button class="tool-btn" data-tool="line" title="Arrow (L)" type="button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M5.5 18.5 18.5 5.5M18.5 5.5H10.5M18.5 5.5v8"></path></svg></button>
  <div class="divider v"></div>
  <button class="tool-btn" data-tool="text" title="Text (T)" type="button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M5 6.5V5h14v1.5M12 5v14M9.5 19h5"></path></svg></button>
  <div class="divider v"></div>
  <button class="tool-btn" data-tool="eraser" title="Eraser (E)" type="button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M5 20h16"></path><path d="M13.6 4.3a1.7 1.7 0 0 1 2.4 0l4.4 4.4a1.7 1.7 0 0 1 0 2.4l-7.5 7.5H9.1L4.3 13.7a1.7 1.7 0 0 1 0-2.4z"></path><path d="M9.1 18.6 15 12.7"></path></svg></button>
</div>
<div class="panel" id="props" aria-label="Style">
  <button class="swatch" data-stroke="#1e1e1e" style="background:#1e1e1e" title="Ink" type="button"></button>
  <button class="swatch" data-stroke="#e03131" style="background:#e03131" title="Red" type="button"></button>
  <button class="swatch" data-stroke="#2f9e44" style="background:#2f9e44" title="Green" type="button"></button>
  <button class="swatch" data-stroke="#1971c2" style="background:#1971c2" title="Blue" type="button"></button>
  <button class="swatch" data-stroke="#f08c00" style="background:#f08c00" title="Orange" type="button"></button>
  <button class="swatch" data-stroke="#6741d9" style="background:#6741d9" title="Purple" type="button"></button>
  <div class="divider v"></div>
  <button class="swatch swatch-none" data-fill="none" title="No fill" type="button"></button>
  <button class="swatch" data-fill="#ffc9c9" style="background:#ffc9c9" title="Red fill" type="button"></button>
  <button class="swatch" data-fill="#b2f2bb" style="background:#b2f2bb" title="Green fill" type="button"></button>
  <button class="swatch" data-fill="#a5d8ff" style="background:#a5d8ff" title="Blue fill" type="button"></button>
  <button class="swatch" data-fill="#ffec99" style="background:#ffec99" title="Yellow fill" type="button"></button>
  <button class="swatch" data-fill="#d0bfff" style="background:#d0bfff" title="Purple fill" type="button"></button>
  <div class="divider v"></div>
  <button class="width-btn" data-width="1" title="Thin" type="button"><span class="wline" style="height:2px"></span></button>
  <button class="width-btn" data-width="2" title="Medium" type="button"><span class="wline" style="height:3px"></span></button>
  <button class="width-btn" data-width="4" title="Thick" type="button"><span class="wline" style="height:6px"></span></button>
</div>
<div class="panel" id="view-controls" aria-label="History and view">
  <button class="icon-btn" id="undo-btn" title="Undo (Ctrl+Z)" type="button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M7.5 5.5 3.5 9.5l4 4"></path><path d="M3.5 9.5H15a5 5 0 0 1 0 10h-4"></path></svg></button>
  <button class="icon-btn" id="redo-btn" title="Redo (Ctrl+Shift+Z)" type="button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M16.5 5.5l4 4-4 4"></path><path d="M20.5 9.5H9a5 5 0 0 0 0 10h4"></path></svg></button>
  <div class="divider v"></div>
  <button class="icon-btn" id="zoom-out" title="Zoom out (−)" type="button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M5 12h14"></path></svg></button>
  <button class="zoom-label" id="zoom-label" title="Reset to 100%" type="button">100%</button>
  <button class="icon-btn" id="zoom-in" title="Zoom in (+)" type="button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"></path></svg></button>
  <button class="icon-btn" id="zoom-reset" title="Reset view (0)" type="button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9V5a1 1 0 0 1 1-1h4M15 4h4a1 1 0 0 1 1 1v4M20 15v4a1 1 0 0 1-1 1h-4M9 20H5a1 1 0 0 1-1-1v-4"></path></svg></button>
</div>
<div id="hint">Space drag pan · Scroll zoom · Double-click text to edit</div></div>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {"src/main.ts":{"./types":"src/types.ts","./store":"src/store.ts","./render":"src/render.ts","./input":"src/input.ts","./ui":"src/ui.ts"},"src/store.ts":{"./types":"src/types.ts"},"src/geometry.ts":{"./types":"src/types.ts"},"src/render.ts":{"./types":"src/types.ts","./geometry":"src/geometry.ts"},"src/text.ts":{"./types":"src/types.ts","./store":"src/store.ts"},"src/input.ts":{"./types":"src/types.ts","./store":"src/store.ts","./geometry":"src/geometry.ts","./render":"src/render.ts","./text":"src/text.ts"},"src/ui.ts":{"./types":"src/types.ts","./store":"src/store.ts","./geometry":"src/geometry.ts","./input":"src/input.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, resize, save loop, startup
// ============================================================================
Object.defineProperty(exports, "__esModule", { value: true });
const store_1 = require("./store");
const render_1 = require("./render");
const input_1 = require("./input");
const ui_1 = require("./ui");
const canvas = document.getElementById('board');
const layer = document.getElementById('text-layer');
const renderer = new render_1.Renderer(canvas);
const input = new input_1.InputController(canvas, renderer, layer, (cam) => {
    store_1.events.emit('camera');
});
// ---- sizing -----------------------------------------------------------
const doResize = () => {
    const dpr = Math.min(3, window.devicePixelRatio || 1);
    renderer.resize(window.innerWidth, window.innerHeight, dpr);
    input.requestPaint();
};
window.addEventListener('resize', doResize);
doResize();
// ---- persistence loop ----------------------------------------------------
setInterval(() => {
    if (store_1.store.dirty)
        store_1.store.save();
}, 700);
window.addEventListener('beforeunload', () => {
    if (store_1.store.dirty)
        store_1.store.save();
});
// ---- startup -------------------------------------------------------------
if (!store_1.store.load()) {
    // First run: seed a small welcome board so the canvas isn't empty.
    const st = store_1.store.style;
    store_1.store.scene = [
        { id: 'wb_w1', type: 'text', x: 120, y: 120, text: 'Welcome to your whiteboard', stroke: '#1e1e1e', fontSize: 28, strokeWidth: 1, fill: 'none', fillColor: '' },
        { id: 'wb_w2', type: 'text', x: 120, y: 168, text: 'Draw, drag, undo — the board saves automatically.', stroke: '#5c6577', fontSize: 16, strokeWidth: 1, fill: 'none', fillColor: '' },
        { id: 'wb_w3', type: 'rect', x: 120, y: 220, w: 170, h: 100, stroke: '#1e1e1e', strokeWidth: 2, fill: 'solid', fillColor: '#a5d8ff' },
        { id: 'wb_w4', type: 'ellipse', x: 330, y: 230, w: 120, h: 90, stroke: '#1e1e1e', strokeWidth: 2, fill: 'solid', fillColor: '#b2f2bb' },
        { id: 'wb_w5', type: 'line', x1: 290, y1: 270, x2: 330, y2: 270, stroke: '#1e1e1e', strokeWidth: 2, fill: 'none', fillColor: '' },
        {
            id: 'wb_w6', type: 'stroke',
            points: [520, 300, 530, 280, 545, 262, 562, 250, 582, 246, 604, 252, 622, 268, 634, 290, 640, 316, 638, 342, 628, 364, 610, 378, 588, 382, 566, 372, 550, 352, 544, 328, 548, 308],
            stroke: '#e03131', strokeWidth: 3, fill: 'none', fillColor: '',
        },
    ];
    void st;
}
store_1.store.clearHistory();
input.init();
(0, ui_1.wireUI)(input, (factor) => {
    input.zoomCenter(factor);
});
input.requestPaint();
// Debug/testing hook (local prototype only)
window.__wb = { store: store_1.store, input, events: store_1.events, renderer };
};

// ── module: src/types.ts ──
__mods["src/types.ts"] = function (exports, require, module) {
"use strict";
// ============================================================================
// Model types + tunable constants block
// ============================================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.CONST = void 0;
exports.compactRecord = compactRecord;
// ---------------------------------------------------------------------------
// Tunable constants
// ---------------------------------------------------------------------------
exports.CONST = {
    // Canvas & grid
    CANVAS_BG: '#f4f5f8',
    GRID_DOT: 'rgba(69, 84, 119, 0.14)',
    GRID_DOT_SIZE: 1.5,
    GRID_STEP: 24, // world units between dots at zoom 1
    // Zoom
    MIN_ZOOM: 0.25,
    MAX_ZOOM: 4,
    ZOOM_STEP: 1.2, // per button click
    WHEEL_FACTOR: 0.0015, // natural-log zoom per wheel pixel
    // Handles & selection (screen space — constant at every zoom level)
    HANDLE_SIZE: 8,
    SELECT_COLOR: '#4263eb',
    SELECT_FILL: 'rgba(66, 99, 235, 0.06)',
    MIN_SIZE: 1,
    // Hit testing (world units derived from these per zoom)
    HIT_TOL_SCREEN: 6, // screen px shape/stroke tolerance
    STROKE_HIT_EXTRA: 2, // extra world px beyond half stroke width
    TEXT_LINE_HEIGHT: 1.28,
    // Editing
    NUDGE: 1,
    NUDGE_BIG: 10,
    MIN_TEXT_W: 24,
    MAX_HISTORY: 300,
    // Persistence
    STORAGE_KEY: 'whiteboard.board.v1',
};
/**
 * Compact an undo group: merge successive update records per id (first
 * `before`, last `after`), and drop create/delete entries whose ids were
 * already handled by an earlier record in the same group (a create that is
 * then updated lives on through the update record, whose `before` is the
 * freshly-created state).
 */
function compactRecord(rec) {
    const shapes = [];
    const handled = new Set(); // ids covered by create/delete records
    const uBefore = {};
    const uAfter = {};
    for (const s of rec.shapes) {
        if (s.kind === 'create') {
            const ids = s.ids.filter((i) => !handled.has(i));
            if (ids.length) {
                const map = new Map(s.shapes.map((sh) => [sh.id, sh]));
                for (const i of ids)
                    handled.add(i);
                shapes.push({ kind: 'create', ids, shapes: ids.map((i) => map.get(i)) });
            }
        }
        else if (s.kind === 'delete') {
            const keep = s.shapes.filter((sh) => !handled.has(sh.id));
            if (keep.length) {
                for (const sh of keep)
                    handled.add(sh.id);
                shapes.push({ kind: 'delete', shapes: keep });
            }
        }
        else {
            for (const [id, b] of Object.entries(s.before)) {
                if (handled.has(id))
                    continue; // created in this group
                if (!(id in uBefore))
                    uBefore[id] = b;
                uAfter[id] = s.after[id];
            }
        }
    }
    if (Object.keys(uBefore).length) {
        shapes.push({ kind: 'update', before: uBefore, after: uAfter });
    }
    const out = { shapes };
    if (rec.reselect)
        out.reselect = rec.reselect;
    if (rec._merge)
        out._merge = rec._merge;
    return out;
}
};

// ── module: src/store.ts ──
__mods["src/store.ts"] = function (exports, require, module) {
"use strict";
// ============================================================================
// Scene store: single scene array, undo/redo history, localStorage persistence
// ============================================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.store = exports.events = void 0;
exports.newId = newId;
const types_1 = require("./types");
let counter = 0;
function newId() {
    return `s_${Date.now().toString(36)}_${(counter++).toString(36)}`;
}
/** History snapshots must be deep copies — scene objects are mutated in place. */
function cloneShape(s) {
    const c = { ...s };
    if (s.points)
        c.points = s.points.slice();
    return c;
}
// ---------------------------------------------------------------------------
// Tiny event bus so UI (buttons, zoom label) can react without importing main
// ---------------------------------------------------------------------------
exports.events = {
    _map: {},
    on(ev, fn) {
        var _a;
        ((_a = this._map)[ev] ?? (_a[ev] = new Set())).add(fn);
    },
    emit(ev) {
        this._map[ev]?.forEach((f) => f());
    },
};
exports.store = {
    scene: [],
    selection: new Set(),
    tool: 'select',
    style: {
        stroke: '#1e1e1e',
        strokeWidth: 2,
        fill: 'solid',
        fillColor: '#a5d8ff',
    },
    dirty: false, // persistence flag
    _undo: [],
    _redo: [],
    _current: [],
    _reselect: undefined,
    // ------------------------------------------------------------------
    // History grouping — one undoable step per gesture
    // ------------------------------------------------------------------
    recordCreate(ids) {
        const shapes = [];
        for (const i of ids) {
            const s = this.get(i);
            if (s)
                shapes.push(cloneShape(s));
        }
        this._current.push({ kind: 'create', ids, shapes });
    },
    recordDelete(shapes) {
        if (shapes.length)
            this._current.push({ kind: 'delete', shapes: shapes.map(cloneShape) });
    },
    /** Record `before` snapshots for ids (first call in a gesture wins). */
    _beginTouch(ids) {
        let g = this._current[this._current.length - 1];
        for (const id of ids) {
            if (!g || g.kind !== 'update' || !(id in g.before)) {
                if (!g || g.kind !== 'update') {
                    g = { kind: 'update', before: {}, after: {} };
                    this._current.push(g);
                }
                const cur = this.get(id);
                if (cur)
                    g.before[id] = cloneShape(cur);
            }
        }
    },
    /** Refresh `after` snapshots to the current (mutated) scene state. */
    _endTouch(ids) {
        for (const g of this._current) {
            if (g.kind !== 'update')
                continue;
            for (const id of ids) {
                if (id in g.before) {
                    const cur = this.get(id);
                    if (cur)
                        g.after[id] = cloneShape(cur);
                }
            }
        }
    },
    setReselect(id) {
        this._reselect = id;
    },
    /** Discard the pending (uncommitted) group and restore scene to match. */
    discardPending() {
        this._current = [];
        this._reselect = undefined;
    },
    /** Drop a pending create (e.g. cancelled text creation) and remove the shapes. */
    cancelPending(ids) {
        const drop = new Set(ids);
        this._current = this._current.filter((r) => !(r.kind === 'create' && drop.has(r.ids[0])));
        this._reselect = undefined;
        this.scene = this.scene.filter((s) => !drop.has(s.id));
        this.selection = new Set([...this.selection].filter((i) => !drop.has(i)));
        this.markDirty();
    },
    /**
     * Commit the pending group as ONE undoable step (clears redo).
     * `mergeKey` collapses with the previous step of the same gesture when it
     * happened within `mergeWindowMs` (e.g. consecutive property-bar clicks or
     * arrow-key repeats collapse into a single step).
     */
    commit(mergeKey, mergeWindowMs = 800) {
        if (!this._current.length)
            return;
        // Refresh create snapshots to their final state (e.g. text set after open).
        for (const r of this._current) {
            if (r.kind === 'create') {
                r.shapes = r.ids.map((i) => cloneShape(this.get(i)));
            }
        }
        const rec = (0, types_1.compactRecord)({
            shapes: this._current,
            reselect: this._reselect,
        });
        this._current = [];
        this._reselect = undefined;
        if (!rec.shapes.length)
            return;
        rec._t = Date.now();
        const prev = this._undo[this._undo.length - 1];
        if (mergeKey &&
            prev &&
            prev._merge === mergeKey &&
            rec._t - (prev._t || 0) <= mergeWindowMs) {
            const merged = (0, types_1.compactRecord)({
                shapes: [...prev.shapes, ...rec.shapes],
                reselect: rec.reselect ?? prev.reselect,
                _merge: mergeKey,
            });
            merged._t = rec._t;
            this._undo[this._undo.length - 1] = merged;
        }
        else {
            rec._merge = mergeKey;
            this._undo.push(rec);
            if (this._undo.length > types_1.CONST.MAX_HISTORY)
                this._undo.shift();
        }
        this._redo = [];
        this.markDirty();
        exports.events.emit('history');
    },
    // ------------------------------------------------------------------
    // Scene access
    // ------------------------------------------------------------------
    get(id) {
        if (!id)
            return undefined;
        return this.scene.find((s) => s.id === id);
    },
    selectedShapes() {
        const out = [];
        for (const s of this.scene)
            if (this.selection.has(s.id))
                out.push(s);
        return out;
    },
    add(shape) {
        this.scene.push(shape);
        this.markDirty();
    },
    /** Remove shapes by id (callers record history first). */
    remove(ids) {
        const drop = new Set(ids);
        this.scene = this.scene.filter((s) => !drop.has(s.id));
        this.markDirty();
    },
    /** Replace scene entries with given copies (used by live edits). */
    setShapes(list) {
        const map = new Map(list.map((s) => [s.id, s]));
        for (let i = this.scene.length - 1; i >= 0; i--) {
            const rep = map.get(this.scene[i].id);
            if (rep)
                this.scene[i] = rep;
        }
        for (const s of list)
            if (!this.scene.some((x) => x.id === s.id))
                this.scene.push(s);
        this.markDirty();
    },
    // ------------------------------------------------------------------
    // Selection
    // ------------------------------------------------------------------
    setSelection(ids) {
        this.selection = new Set(ids);
    },
    clearSelection() {
        this.selection = new Set();
    },
    toggle(id) {
        if (this.selection.has(id))
            this.selection.delete(id);
        else
            this.selection.add(id);
    },
    // ------------------------------------------------------------------
    // Undo / redo
    // ------------------------------------------------------------------
    get canUndo() {
        return this._undo.length > 0;
    },
    get canRedo() {
        return this._redo.length > 0;
    },
    _apply(rec, forward) {
        const touched = new Set();
        for (const s of rec.shapes) {
            if (s.kind === 'create')
                s.ids.forEach((i) => touched.add(i));
            else if (s.kind === 'delete')
                s.shapes.forEach((sh) => touched.add(sh.id));
            else
                Object.keys(s.before).forEach((i) => touched.add(i));
        }
        const doStep = (s) => {
            if (s.kind === 'create') {
                if (forward) {
                    for (const sh of s.shapes)
                        this.scene.push(sh);
                }
                else {
                    const drop = new Set(s.ids);
                    this.scene = this.scene.filter((sh) => !drop.has(sh.id));
                }
            }
            else if (s.kind === 'delete') {
                if (forward) {
                    const drop = new Set(s.shapes.map((sh) => sh.id));
                    this.scene = this.scene.filter((sh) => !drop.has(sh.id));
                }
                else {
                    for (const sh of s.shapes)
                        this.scene.push(sh);
                }
            }
            else {
                for (const id of Object.keys(s.after)) {
                    const sh = this.scene.find((x) => x.id === id);
                    if (sh)
                        Object.assign(sh, forward ? s.after[id] : s.before[id]);
                }
            }
        };
        const list = forward ? rec.shapes : [...rec.shapes].reverse();
        for (const s of list)
            doStep(s);
        // Selection: objects touched by this step become the selection.
        this.selection = new Set([...touched].filter((i) => this.scene.some((sh) => sh.id === i)));
        if (rec.reselect && this.scene.some((sh) => sh.id === rec.reselect)) {
            this.selection.add(rec.reselect);
        }
        this.markDirty();
    },
    undo() {
        const rec = this._undo.pop();
        if (!rec)
            return;
        this._apply(rec, false);
        this._redo.push(rec);
        exports.events.emit('history');
    },
    redo() {
        const rec = this._redo.pop();
        if (!rec)
            return;
        this._apply(rec, true);
        this._undo.push(rec);
        exports.events.emit('history');
    },
    clearHistory() {
        this._undo = [];
        this._redo = [];
        this._current = [];
        exports.events.emit('history');
    },
    // ------------------------------------------------------------------
    // Persistence — board survives reload; history does not
    // ------------------------------------------------------------------
    markDirty() {
        this.dirty = true;
    },
    save() {
        this.dirty = false;
        try {
            localStorage.setItem(types_1.CONST.STORAGE_KEY, JSON.stringify({ scene: this.scene, style: this.style }));
        }
        catch {
            /* storage unavailable */
        }
    },
    load() {
        try {
            const raw = localStorage.getItem(types_1.CONST.STORAGE_KEY);
            if (!raw)
                return false;
            const data = JSON.parse(raw);
            if (Array.isArray(data.scene)) {
                this.scene = data.scene.filter((s) => s && typeof s.id === 'string' && s.type);
            }
            if (data.style && typeof data.style === 'object') {
                this.style = { ...this.style, ...data.style };
            }
            return true;
        }
        catch {
            return false;
        }
    },
};
};

// ── module: src/geometry.ts ──
__mods["src/geometry.ts"] = function (exports, require, module) {
"use strict";
// ============================================================================
// Geometry: camera math, bounding boxes, hit-testing in WORLD coordinates
// ============================================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.handlePositions = exports.clampZoom = exports.worldToScreen = exports.screenToWorld = void 0;
exports.zoomAt = zoomAt;
exports.shapeBBox = shapeBBox;
exports.normRect = normRect;
exports.unionBBox = unionBBox;
exports.hitTest = hitTest;
exports.translateShape = translateShape;
exports.segDist = segDist;
exports.pointNearPolyline = pointNearPolyline;
exports.computeResize = computeResize;
const types_1 = require("./types");
const screenToWorld = (cam, sx, sy) => [
    (sx - cam.x) / cam.zoom,
    (sy - cam.y) / cam.zoom,
];
exports.screenToWorld = screenToWorld;
const worldToScreen = (cam, wx, wy) => [
    wx * cam.zoom + cam.x,
    wy * cam.zoom + cam.y,
];
exports.worldToScreen = worldToScreen;
function zoomAt(cam, sx, sy, newZoom) {
    const z = Math.min(types_1.CONST.MAX_ZOOM, Math.max(types_1.CONST.MIN_ZOOM, newZoom));
    const [wx, wy] = (0, exports.screenToWorld)(cam, sx, sy);
    return { zoom: z, x: sx - wx * z, y: sy - wy * z };
}
const clampZoom = (z) => Math.min(types_1.CONST.MAX_ZOOM, Math.max(types_1.CONST.MIN_ZOOM, z));
exports.clampZoom = clampZoom;
// ---------------------------------------------------------------------------
// Bounding boxes (world)
// ---------------------------------------------------------------------------
function shapeBBox(s) {
    switch (s.type) {
        case 'rect':
        case 'ellipse':
            return normRect(s.x, s.y, s.w, s.h);
        case 'line': {
            const x = Math.min(s.x1, s.x2);
            const y = Math.min(s.y1, s.y2);
            return { x, y, w: Math.abs(s.x2 - s.x1), h: Math.abs(s.y2 - s.y1) };
        }
        case 'stroke': {
            let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
            const p = s.points;
            for (let i = 0; i < p.length; i += 2) {
                if (p[i] < minX)
                    minX = p[i];
                if (p[i + 1] < minY)
                    minY = p[i + 1];
                if (p[i] > maxX)
                    maxX = p[i];
                if (p[i + 1] > maxY)
                    maxY = p[i + 1];
            }
            if (!isFinite(minX))
                return { x: 0, y: 0, w: 0, h: 0 };
            return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
        }
        case 'text': {
            const fs = s.fontSize || 20;
            const lines = (s.text || '').split('\n');
            const maxLen = lines.reduce((m, l) => Math.max(m, l.length), 0);
            const w = Math.max(maxLen * fs * 0.58, types_1.CONST.MIN_TEXT_W);
            const h = Math.max(lines.length, 1) * fs * types_1.CONST.TEXT_LINE_HEIGHT;
            return { x: s.x, y: s.y, w, h };
        }
    }
}
function normRect(x, y, w, h) {
    return {
        x: w < 0 ? x + w : x,
        y: h < 0 ? y + h : y,
        w: Math.abs(w),
        h: Math.abs(h),
    };
}
/** Union bbox of multiple shapes (world). */
function unionBBox(shapes) {
    if (!shapes.length)
        return null;
    let x = Infinity, y = Infinity, r = -Infinity, b = -Infinity;
    for (const s of shapes) {
        const bb = shapeBBox(s);
        x = Math.min(x, bb.x);
        y = Math.min(y, bb.y);
        r = Math.max(r, bb.x + bb.w);
        b = Math.max(b, bb.y + bb.h);
    }
    return { x, y, w: r - x, h: b - y };
}
// ---------------------------------------------------------------------------
// Hit testing — always in WORLD coordinates, so it is zoom-accurate.
// ---------------------------------------------------------------------------
function hitTest(s, wx, wy, zoom) {
    const tol = types_1.CONST.HIT_TOL_SCREEN / zoom;
    switch (s.type) {
        case 'rect':
            return hitRect(s, wx, wy, tol);
        case 'ellipse':
            return hitEllipse(s, wx, wy, tol);
        case 'line':
            return hitLine(s, wx, wy, tol);
        case 'stroke':
            return pointNearPolyline(s.points, wx, wy, tol + s.strokeWidth / 2 + types_1.CONST.STROKE_HIT_EXTRA / zoom);
        case 'text': {
            const bb = shapeBBox(s);
            return (wx >= bb.x - tol &&
                wx <= bb.x + bb.w + tol &&
                wy >= bb.y - tol &&
                wy <= bb.y + bb.h + tol);
        }
    }
}
function hitRect(s, wx, wy, tol) {
    const bb = normRect(s.x, s.y, s.w, s.h);
    const inside = wx >= bb.x && wx <= bb.x + bb.w && wy >= bb.y && wy <= bb.y + bb.h;
    if (inside) {
        // Solid fill: whole interior selects (the interior IS the shape).
        if (s.fill === 'solid')
            return true;
        // Empty fill: only the border band selects (corner regions don't).
        // Stroke centerline is inset sw/2 from the bbox edge; band covers 0..sw.
        const dNear = Math.min(wx - bb.x, bb.x + bb.w - wx, wy - bb.y, bb.y + bb.h - wy);
        return dNear <= s.strokeWidth + tol;
    }
    // just outside: within tolerance of the border (stroke outer edge = bbox edge)
    const px = Math.max(bb.x - wx, wx - (bb.x + bb.w), 0);
    const py = Math.max(bb.y - wy, wy - (bb.y + bb.h), 0);
    return Math.hypot(px, py) <= tol;
}
function hitEllipse(s, wx, wy, tol) {
    const bb = normRect(s.x, s.y, s.w, s.h);
    const cx = bb.x + bb.w / 2;
    const cy = bb.y + bb.h / 2;
    const rx = Math.max(bb.w / 2, 0.001);
    const ry = Math.max(bb.h / 2, 0.001);
    // value < 1 means inside the ellipse (bbox radii)
    const v = ((wx - cx) / rx) ** 2 + ((wy - cy) / ry) ** 2;
    if (s.fill === 'solid')
        return v <= 1 + tol / Math.min(rx, ry);
    // Empty fill: the stroke band (centerline inset sw/2) or just outside.
    const d = Math.sqrt(v); // 1 at the border
    const band = s.strokeWidth / Math.min(rx, ry);
    if (d <= 1 && d >= 1 - band)
        return true;
    return d >= 1 && d - 1 <= tol / Math.min(rx, ry);
}
function hitLine(s, wx, wy, tol) {
    const r = segDist(s.x1, s.y1, s.x2, s.y2, wx, wy) / Math.max(s.strokeWidth / 2 + tol, 0.0001);
    return r <= 1;
}
/** Translate a shape by (dx, dy) in world units (mutates). */
function translateShape(s, dx, dy) {
    switch (s.type) {
        case 'rect':
        case 'ellipse':
        case 'text':
            s.x = (s.x ?? 0) + dx;
            s.y = (s.y ?? 0) + dy;
            break;
        case 'line':
            s.x1 = (s.x1 ?? 0) + dx;
            s.y1 = (s.y1 ?? 0) + dy;
            s.x2 = (s.x2 ?? 0) + dx;
            s.y2 = (s.y2 ?? 0) + dy;
            break;
        case 'stroke':
            if (s.points) {
                for (let i = 0; i < s.points.length; i += 2) {
                    s.points[i] += dx;
                    s.points[i + 1] += dy;
                }
            }
            break;
    }
}
/** Distance from point to segment. */
function segDist(ax, ay, bx, by, px, py) {
    const dx = bx - ax, dy = by - ay;
    const len2 = dx * dx + dy * dy;
    let t = len2 ? ((px - ax) * dx + (py - ay) * dy) / len2 : 0;
    t = Math.max(0, Math.min(1, t));
    const qx = ax + t * dx, qy = ay + t * dy;
    return Math.hypot(px - qx, py - qy);
}
/** Point near polyline (flat xy array), within `tol` world units. */
function pointNearPolyline(pts, wx, wy, tol) {
    if (pts.length < 2)
        return false;
    if (pts.length === 2)
        return Math.hypot(pts[0] - wx, pts[1] - wy) <= tol;
    for (let i = 0; i + 3 < pts.length; i += 2) {
        if (segDist(pts[i], pts[i + 1], pts[i + 2], pts[i + 3], wx, wy) <= tol)
            return true;
    }
    return false;
}
const handlePositions = (b) => ({
    nw: [b.x, b.y],
    ne: [b.x + b.w, b.y],
    sw: [b.x, b.y + b.h],
    se: [b.x + b.w, b.y + b.h],
});
exports.handlePositions = handlePositions;
const OPPOSITE = { nw: 'se', ne: 'sw', sw: 'ne', se: 'nw' };
/** Compute new positions of each shape given the cursor world point. */
function computeResize(p, wx, wy) {
    const b = p.startBBox;
    const anchor = (0, exports.handlePositions)(b)[OPPOSITE[p.handle]]; // fixed corner
    let dx = wx - anchor[0];
    let dy = wy - anchor[1];
    if (p.keepAspect && b.w > 0.01 && b.h > 0.01) {
        const ar = b.w / b.h;
        if (Math.abs(dx / b.w) > Math.abs(dy / b.h))
            dy = Math.sign(dy || 1) * Math.abs(dx) / ar;
        else
            dx = Math.sign(dx || 1) * Math.abs(dy) * ar;
    }
    const w = Math.max(Math.abs(dx), p.minSize);
    const h = Math.max(Math.abs(dy), p.minSize);
    const sx = b.w < 0.01 ? 1 : dx / b.w || 1;
    const sy = b.h < 0.01 ? 1 : dy / b.h || 1;
    const flipX = dx < 0, flipY = dy < 0;
    const nb = { x: anchor[0], y: anchor[1], w: w * (flipX ? -1 : 1), h: h * (flipY ? -1 : 1) };
    const nbn = normRect(nb.x, nb.y, nb.w, nb.h);
    const mapPoint = (x, y) => {
        let nx = x, ny = y;
        if (b.w > 0.01) {
            const t = (x - b.x) / b.w;
            nx = nbn.x + t * nbn.w;
        }
        if (b.h > 0.01) {
            const t = (y - b.y) / b.h;
            ny = nbn.y + t * nbn.h;
        }
        return [nx, ny];
    };
    return p.shapes.map((s) => {
        const c = { ...s };
        if (c.type === 'rect' || c.type === 'ellipse') {
            const tl = mapPoint(c.x, c.y);
            const br = mapPoint(c.x + c.w, c.y + c.h);
            c.x = Math.min(tl[0], br[0]);
            c.y = Math.min(tl[1], br[1]);
            c.w = Math.abs(br[0] - tl[0]);
            c.h = Math.abs(br[1] - tl[1]);
        }
        else if (c.type === 'line') {
            const a = mapPoint(c.x1, c.y1);
            const bb2 = mapPoint(c.x2, c.y2);
            c.x1 = a[0];
            c.y1 = a[1];
            c.x2 = bb2[0];
            c.y2 = bb2[1];
        }
        else if (c.type === 'stroke') {
            const pts = c.points;
            const out = new Array(pts.length);
            for (let i = 0; i < pts.length; i += 2) {
                const m = mapPoint(pts[i], pts[i + 1]);
                out[i] = m[0];
                out[i + 1] = m[1];
            }
            c.points = out;
        }
        else if (c.type === 'text') {
            const m = mapPoint(c.x, c.y);
            c.x = m[0];
            c.y = m[1];
            if (p.keepAspect) {
                const f = b.w < 0.01 ? 1 : nbn.w / b.w;
                c.fontSize = Math.max(8, (c.fontSize || 20) * f);
            }
        }
        return c;
    });
}
};

// ── module: src/render.ts ──
__mods["src/render.ts"] = function (exports, require, module) {
"use strict";
// ============================================================================
// Rendering: world shapes, grid, in-progress previews, selection chrome
// ============================================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.Renderer = void 0;
exports.drawSmoothPath = drawSmoothPath;
exports.bboxIntersects = bboxIntersects;
const types_1 = require("./types");
const geometry_1 = require("./geometry");
class Renderer {
    constructor(canvas) {
        this.canvas = canvas;
        this.dpr = 1;
        this.width = 0;
        this.height = 0;
        this.ctx = canvas.getContext('2d');
    }
    resize(w, h, dpr) {
        this.width = w;
        this.height = h;
        this.dpr = dpr;
        this.canvas.width = Math.max(1, Math.round(w * dpr));
        this.canvas.height = Math.max(1, Math.round(h * dpr));
        this.canvas.style.width = `${w}px`;
        this.canvas.style.height = `${h}px`;
    }
    // ------------------------------------------------------------------
    clear() {
        const c = this.ctx;
        c.setTransform(1, 0, 0, 1, 0, 0);
        c.clearRect(0, 0, this.canvas.width, this.canvas.height);
        c.fillStyle = types_1.CONST.CANVAS_BG;
        c.fillRect(0, 0, this.canvas.width, this.canvas.height);
    }
    // ------------------------------------------------------------------
    // Dot grid — pans/zooms with the world, density-adaptive
    // ------------------------------------------------------------------
    drawGrid(cam) {
        const c = this.ctx;
        let step = types_1.CONST.GRID_STEP;
        let px = step * cam.zoom;
        while (px < 14) {
            step *= 2;
            px *= 2;
        }
        while (px > 56) {
            step /= 2;
            px /= 2;
        }
        const left = -cam.x / cam.zoom;
        const top = -cam.y / cam.zoom;
        const right = (this.width - cam.x) / cam.zoom;
        const bottom = (this.height - cam.y) / cam.zoom;
        const x0 = Math.floor(left / step) * step;
        const y0 = Math.floor(top / step) * step;
        // One batched path for all dots — hundreds of fillRects would cost a lot.
        c.save();
        c.fillStyle = types_1.CONST.GRID_DOT;
        const r = types_1.CONST.GRID_DOT_SIZE * 0.75;
        c.beginPath();
        let any = false;
        for (let x = x0; x <= right; x += step) {
            const sx = x * cam.zoom + cam.x;
            for (let y = y0; y <= bottom; y += step) {
                const sy = y * cam.zoom + cam.y;
                c.rect(sx - r, sy - r, r * 2, r * 2);
                any = true;
            }
        }
        if (any)
            c.fill();
        c.restore();
    }
    // ------------------------------------------------------------------
    // Shapes — drawn in world space (caller sets the viewport transform).
    // No per-shape save/restore (state is set directly) for throughput.
    // ------------------------------------------------------------------
    drawShape(s) {
        const c = this.ctx;
        c.strokeStyle = s.stroke;
        c.lineWidth = s.strokeWidth;
        if (s.type === 'rect') {
            const b = (0, geometry_1.normRect)(s.x, s.y, s.w, s.h);
            const inset = s.strokeWidth / 2;
            const w = Math.max(b.w - s.strokeWidth, 0.01);
            const h = Math.max(b.h - s.strokeWidth, 0.01);
            if (s.fill === 'solid') {
                c.fillStyle = s.fillColor;
                c.fillRect(b.x + inset, b.y + inset, w, h);
            }
            c.strokeRect(b.x + inset, b.y + inset, w, h);
        }
        else if (s.type === 'ellipse') {
            const b = (0, geometry_1.normRect)(s.x, s.y, s.w, s.h);
            const inset = s.strokeWidth / 2;
            c.beginPath();
            c.ellipse(b.x + b.w / 2, b.y + b.h / 2, Math.max(b.w / 2 - inset, 0.01), Math.max(b.h / 2 - inset, 0.01), 0, 0, Math.PI * 2);
            if (s.fill === 'solid') {
                c.fillStyle = s.fillColor;
                c.fill();
            }
            c.stroke();
        }
        else if (s.type === 'line') {
            c.beginPath();
            c.moveTo(s.x1, s.y1);
            c.lineTo(s.x2, s.y2);
            c.stroke();
            drawArrowHead(c, s);
        }
        else if (s.type === 'stroke') {
            drawSmoothPath(c, s.points);
            c.stroke();
        }
        else if (s.type === 'text') {
            c.fillStyle = s.stroke;
            c.font = `400 ${s.fontSize || 20}px -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif`;
            c.textBaseline = 'top';
            const lines = (s.text || '').split('\n');
            const lh = (s.fontSize || 20) * types_1.CONST.TEXT_LINE_HEIGHT;
            lines.forEach((line, i) => {
                c.fillText(line, s.x, s.y + i * lh);
            });
        }
    }
    /**
     * Draw all shapes, culling those whose bbox is far outside the viewport
     * (keeps hundreds of objects cheap when zoomed in or panned far).
     */
    drawScene(shapes, cam) {
        const c = this.ctx;
        c.lineJoin = 'round';
        c.lineCap = 'round';
        if (cam) {
            const m = 60 / cam.zoom; // margin in world units
            const vx = -cam.x / cam.zoom - m;
            const vy = -cam.y / cam.zoom - m;
            const vw = this.width / cam.zoom + m * 2;
            const vh = this.height / cam.zoom + m * 2;
            const view = { x: vx, y: vy, w: vw, h: vh };
            for (const s of shapes) {
                if (!bboxIntersects((0, geometry_1.shapeBBox)(s), view))
                    continue;
                this.drawShape(s);
            }
            return;
        }
        for (const s of shapes)
            this.drawShape(s);
    }
    /** Live preview while creating (world space). */
    drawPreview(p) {
        if (p.kind === 'stroke') {
            const c = this.ctx;
            c.save();
            c.strokeStyle = '#1e1e1e';
            c.lineWidth = 2;
            c.lineCap = 'round';
            c.lineJoin = 'round';
            drawSmoothPath(c, p.points);
            c.stroke();
            c.restore();
        }
        else if (p.kind === 'shape') {
            this.drawShape(p.shape);
        }
    }
    // ------------------------------------------------------------------
    // Selection chrome — screen space; handles keep constant pixel size
    // ------------------------------------------------------------------
    drawMarquee(p) {
        const c = this.ctx;
        const x = Math.min(p.sx, p.ex);
        const y = Math.min(p.sy, p.ey);
        const w = Math.abs(p.ex - p.sx);
        const h = Math.abs(p.ey - p.sy);
        c.save();
        c.fillStyle = 'rgba(66, 99, 235, 0.07)';
        c.fillRect(x, y, w, h);
        c.strokeStyle = types_1.CONST.SELECT_COLOR;
        c.lineWidth = 1;
        c.strokeRect(x + 0.5, y + 0.5, w, h);
        c.restore();
    }
    drawSelection(shapes, cam, showHandles) {
        if (!shapes.length)
            return;
        const box = (0, geometry_1.unionBBox)(shapes);
        if (!box)
            return;
        const c = this.ctx;
        const x = box.x * cam.zoom + cam.x;
        const y = box.y * cam.zoom + cam.y;
        const w = box.w * cam.zoom;
        const h = box.h * cam.zoom;
        c.save();
        c.fillStyle = types_1.CONST.SELECT_FILL;
        c.fillRect(x, y, w, h);
        c.strokeStyle = types_1.CONST.SELECT_COLOR;
        c.lineWidth = 1.5;
        c.setLineDash([5, 3]);
        c.strokeRect(x, y, w, h);
        c.setLineDash([]);
        if (showHandles) {
            const hs = types_1.CONST.HANDLE_SIZE;
            const pos = (0, geometry_1.handlePositions)(box);
            c.lineWidth = 1.5;
            for (const key of Object.keys(pos)) {
                const [wx, wy] = pos[key];
                const sx = wx * cam.zoom + cam.x;
                const sy = wy * cam.zoom + cam.y;
                c.fillStyle = '#ffffff';
                c.fillRect(sx - hs / 2, sy - hs / 2, hs, hs);
                c.strokeStyle = types_1.CONST.SELECT_COLOR;
                c.strokeRect(sx - hs / 2, sy - hs / 2, hs, hs);
            }
        }
        c.restore();
    }
    /** Handle under a screen point (constant screen size at any zoom). */
    hitHandle(box, cam, sx, sy) {
        const pad = 4;
        const pos = (0, geometry_1.handlePositions)(box);
        for (const key of Object.keys(pos)) {
            const [wx, wy] = pos[key];
            const hx = wx * cam.zoom + cam.x;
            const hy = wy * cam.zoom + cam.y;
            if (Math.abs(sx - hx) <= types_1.CONST.HANDLE_SIZE / 2 + pad &&
                Math.abs(sy - hy) <= types_1.CONST.HANDLE_SIZE / 2 + pad) {
                return key;
            }
        }
        return null;
    }
}
exports.Renderer = Renderer;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function drawArrowHead(c, s) {
    const dx = s.x2 - s.x1;
    const dy = s.y2 - s.y1;
    const len = Math.hypot(dx, dy);
    if (len < 0.001)
        return;
    const size = Math.max(9, s.strokeWidth * 3.2);
    const ang = Math.atan2(dy, dx);
    c.save();
    c.translate(s.x2, s.y2);
    c.rotate(ang);
    c.fillStyle = s.stroke;
    c.beginPath();
    c.moveTo(0, 0);
    c.lineTo(-size, -size * 0.45);
    c.lineTo(-size, size * 0.45);
    c.closePath();
    c.fill();
    c.restore();
}
/** Catmull-Rom → cubic Bézier smoothing for a flat xy point list. */
function drawSmoothPath(c, pts) {
    const n = pts.length / 2;
    if (n === 0)
        return;
    c.beginPath();
    if (n === 1) {
        c.arc(pts[0], pts[1], 1.1, 0, Math.PI * 2);
        c.closePath();
        return;
    }
    if (n === 2) {
        c.moveTo(pts[0], pts[1]);
        c.lineTo(pts[2], pts[3]);
        return;
    }
    c.moveTo(pts[0], pts[1]);
    for (let i = 1; i < n - 1; i++) {
        const p0x = pts[(i - 1) * 2];
        const p0y = pts[(i - 1) * 2 + 1];
        const p1x = pts[i * 2];
        const p1y = pts[i * 2 + 1];
        const p2x = pts[(i + 1) * 2];
        const p2y = pts[(i + 1) * 2 + 1];
        const p3x = i + 2 < n ? pts[(i + 2) * 2] : p2x + (p2x - p1x);
        const p3y = i + 2 < n ? pts[(i + 2) * 2 + 1] : p2y + (p2y - p1y);
        const c1x = p1x + (p2x - p0x) / 6;
        const c1y = p1y + (p2y - p0y) / 6;
        const c2x = p2x - (p3x - p1x) / 6;
        const c2y = p2y - (p3y - p1y) / 6;
        c.bezierCurveTo(c1x, c1y, c2x, c2y, p2x, p2y);
    }
}
/** Bounds intersection (marquee culling). */
function bboxIntersects(a, b) {
    return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
}
};

// ── module: src/text.ts ──
__mods["src/text.ts"] = function (exports, require, module) {
"use strict";
// ============================================================================
// Inline text editing: positioned <textarea> overlay in world space
// ============================================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.TextEditor = void 0;
exports.textShapeAt = textShapeAt;
exports.defaultTextFontSize = defaultTextFontSize;
exports.measureTextWorld = measureTextWorld;
exports.applyTextEdit = applyTextEdit;
const types_1 = require("./types");
const store_1 = require("./store");
class TextEditor {
    constructor(layer, cam, onCommit) {
        this.el = null;
        this.shape = null;
        this.layer = layer;
        this.cam = cam;
        this.onCommit = onCommit;
    }
    /** Position updates as the camera changes (while editing). */
    updateCamera(cam) {
        this.cam = cam;
        this.position();
    }
    open(shape, initial) {
        this.shape = shape;
        this.el?.remove();
        this.el = null;
        this.layer.style.display = 'block';
        const el = document.createElement('textarea');
        el.id = 'text-editor';
        el.spellcheck = false;
        el.rows = 1;
        el.value = initial;
        el.style.fontFamily = '-apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif';
        this.el = el;
        this.layer.appendChild(el);
        this.position();
        this.resizeToFit();
        el.focus();
        el.select();
        el.addEventListener('keydown', (e) => {
            e.stopPropagation();
            if (e.key === 'Escape') {
                e.preventDefault();
                this.commit(true);
            }
            else if (e.key === 'Enter' && !e.shiftKey) {
                e.preventDefault();
                this.commit(false);
            }
        });
        el.addEventListener('blur', () => this.commit(false));
        el.addEventListener('input', () => this.resizeToFit());
        el.addEventListener('mousedown', (e) => e.stopPropagation());
    }
    /** World → screen, scale the box, fit height to content. */
    position() {
        if (!this.el || !this.shape)
            return;
        const s = this.shape;
        const z = this.cam.zoom;
        const fs = (s.fontSize || 20) * z;
        const el = this.el;
        el.style.left = `${s.x * z + this.cam.x}px`;
        el.style.top = `${s.y * z + this.cam.y}px`;
        el.style.fontSize = `${fs}px`;
        el.style.lineHeight = `${fs * types_1.CONST.TEXT_LINE_HEIGHT}px`;
        el.style.minWidth = `${types_1.CONST.MIN_TEXT_W * z}px`;
    }
    resizeToFit() {
        if (!this.el || !this.shape)
            return;
        const el = this.el;
        el.style.height = 'auto';
        const h = Math.max(el.scrollHeight + 2, el.clientHeight);
        el.style.height = `${h}px`;
    }
    commit(cancelled) {
        if (!this.el || !this.shape)
            return;
        const el = this.el;
        const text = el.value.replace(/\n+$/g, '');
        this.el = null;
        this.shape = null;
        el.remove();
        if (!this.layer.children.length)
            this.layer.style.display = 'none';
        this.onCommit({ text, cancelled });
    }
}
exports.TextEditor = TextEditor;
function textShapeAt(id, x, y, text, style) {
    return {
        id,
        type: 'text',
        x,
        y,
        text,
        stroke: style.stroke,
        fontSize: style.fontSize || 20,
        strokeWidth: 1,
        fill: 'none',
        fillColor: '',
    };
}
function defaultTextFontSize(cam) {
    // Text is 20 world px at 100% zoom; slightly larger when zoomed out so it
    // stays legible when placed, clamped to a sane range.
    const size = 20 / Math.sqrt(cam.zoom);
    return Math.round(Math.min(40, Math.max(14, size)));
}
function measureTextWorld(s) {
    const fs = s.fontSize || 20;
    const lines = (s.text || '').split('\n');
    const maxLen = lines.reduce((m, l) => Math.max(m, l.length), 0);
    return {
        w: Math.max(maxLen * fs * 0.58, types_1.CONST.MIN_TEXT_W),
        h: Math.max(lines.length, 1) * fs * types_1.CONST.TEXT_LINE_HEIGHT,
    };
}
/** Rebase a text edit onto the live scene object. */
function applyTextEdit(shape, text) {
    const target = store_1.store.get(shape.id);
    if (target)
        target.text = text;
}
};

// ── module: src/input.ts ──
__mods["src/input.ts"] = function (exports, require, module) {
"use strict";
// ============================================================================
// Input: tool state machine (pointer + keyboard). All hit-testing happens in
// WORLD coordinates so selection stays pixel-accurate at any zoom.
// ============================================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.InputController = void 0;
const types_1 = require("./types");
const store_1 = require("./store");
const geometry_1 = require("./geometry");
const render_1 = require("./render");
const text_1 = require("./text");
class InputController {
    constructor(canvas, renderer, layer, onCamera) {
        this.cam = { x: 0, y: 0, zoom: 1 };
        // gesture state
        this.mode = 'idle';
        this.spaceDown = false;
        this.lastX = 0;
        this.lastY = 0;
        this.panStartX = 0;
        this.panStartY = 0;
        // tool gesture state
        this.drawPts = [];
        this.drawId = '';
        this.createStart = [0, 0];
        this.createCur = [0, 0];
        this.createShift = false;
        this.marqueeStart = [0, 0];
        this.marqueeCur = [0, 0];
        this.marqueeBase = [];
        this.marqueeAdded = [];
        this.moved = false;
        this.moveStart = [0, 0];
        this.moveIds = [];
        this.moveApplied = [0, 0];
        this.moveTouched = false;
        this.resizeParams = null;
        this.eraseHits = new Set();
        this.editingId = '';
        this.preview = null;
        this.nudgeKey = '';
        this.lastPointer = null;
        this.canvas = canvas;
        this.renderer = renderer;
        this.onCamera = onCamera;
        this.editor = new text_1.TextEditor(layer, this.cam, (r) => this.onTextCommitted(r));
        this.requestFrame = () => requestAnimationFrame(() => this.paint());
    }
    // ------------------------------------------------------------------
    // Lifecycle
    // ------------------------------------------------------------------
    init() {
        const cv = this.canvas;
        cv.addEventListener('pointerdown', (e) => this.onPointerDown(e));
        cv.addEventListener('pointermove', (e) => this.onPointerMove(e));
        cv.addEventListener('pointerup', (e) => this.onPointerUp(e));
        cv.addEventListener('pointercancel', (e) => this.onPointerUp(e));
        cv.addEventListener('wheel', (e) => this.onWheel(e), { passive: false });
        cv.addEventListener('dblclick', (e) => this.onDblClick(e));
        cv.addEventListener('contextmenu', (e) => e.preventDefault());
        window.addEventListener('keydown', (e) => this.onKeyDown(e));
        window.addEventListener('keyup', (e) => this.onKeyUp(e));
        // Safety net: commit editing when the window loses focus.
        window.addEventListener('blur', () => this.commitTextIfEditing(false));
    }
    requestPaint() {
        if (!document.hidden)
            this.requestFrame();
    }
    paint() {
        const c = this.cam;
        const r = this.renderer;
        r.clear();
        r.drawGrid(c);
        r.ctx.setTransform(c.zoom * r.dpr, 0, 0, c.zoom * r.dpr, c.x * r.dpr, c.y * r.dpr);
        r.drawScene(store_1.store.scene, c);
        if (this.preview)
            r.drawPreview(this.preview);
        r.ctx.setTransform(r.dpr, 0, 0, r.dpr, 0, 0);
        if (this.preview?.kind === 'marquee')
            r.drawMarquee(this.preview);
        const sel = store_1.store.selectedShapes();
        r.drawSelection(sel, c, this.mode === 'idle');
    }
    setCamera(cam) {
        this.cam = cam;
        this.onCamera(cam);
        this.editor.updateCamera(cam);
        this.requestPaint();
    }
    getCamera() {
        return { ...this.cam };
    }
    // ------------------------------------------------------------------
    // Pointer helpers
    // ------------------------------------------------------------------
    pt(e) {
        const r = this.canvas.getBoundingClientRect();
        return [e.clientX - r.left, e.clientY - r.top];
    }
    // ------------------------------------------------------------------
    // Wheel — zoom toward the cursor (world point under cursor is invariant)
    // ------------------------------------------------------------------
    onWheel(e) {
        e.preventDefault();
        const [sx, sy] = this.pt(e);
        const delta = e.deltaMode === 1 ? e.deltaY * 24 : e.deltaY;
        const factor = Math.exp(-delta * types_1.CONST.WHEEL_FACTOR);
        this.setCamera((0, geometry_1.zoomAt)(this.cam, sx, sy, this.cam.zoom * factor));
    }
    // ------------------------------------------------------------------
    // Pointer down
    // ------------------------------------------------------------------
    onPointerDown(e) {
        if (this.mode === 'text-edit')
            this.commitTextIfEditing(false);
        const [sx, sy] = this.pt(e);
        const [wx, wy] = (0, geometry_1.screenToWorld)(this.cam, sx, sy);
        this.lastX = sx;
        this.lastY = sy;
        // Pan takes priority: Space held or middle button.
        if (this.spaceDown || e.button === 1) {
            this.mode = 'pan';
            this.panStartX = this.cam.x;
            this.panStartY = this.cam.y;
            this.setCapture(e);
            return;
        }
        if (e.button !== 0)
            return;
        switch (store_1.store.tool) {
            case 'select':
                this.selectDown(sx, sy, wx, wy, e.shiftKey);
                break;
            case 'pen':
                this.startDraw(wx, wy, e);
                break;
            case 'rect':
            case 'ellipse':
            case 'line':
                this.startCreate(wx, wy, e);
                break;
            case 'text':
                this.startText(wx, wy);
                break;
            case 'eraser':
                this.mode = 'erase';
                this.eraseHits = new Set();
                this.eraseAt(wx, wy);
                this.setCapture(e);
                break;
        }
    }
    setCapture(e) {
        try {
            this.canvas.setPointerCapture(e.pointerId);
        }
        catch {
            /* ignore */
        }
        this.lastPointer = e;
        this.cursorForMode();
    }
    // ---- select tool ------------------------------------------------------
    selectDown(sx, sy, wx, wy, shift) {
        const sel = store_1.store.selectedShapes();
        // 1) resize handle of the current selection
        if (sel.length) {
            const box = (0, geometry_1.unionBBox)(sel);
            if (box) {
                const h = this.renderer.hitHandle(box, this.cam, sx, sy);
                if (h) {
                    this.mode = 'resize';
                    this.resizeParams = {
                        handle: h,
                        startBBox: box,
                        shapes: sel.map((s) => ({ ...s })),
                        aspect: box,
                        keepAspect: false,
                        minSize: types_1.CONST.MIN_SIZE / this.cam.zoom,
                    };
                    this.moveIds = sel.map((s) => s.id);
                    store_1.store._beginTouch(this.moveIds);
                    this.moved = false;
                    this.setCapture(this.lastPointer);
                    return;
                }
            }
        }
        // 2) topmost shape hit
        const hit = this.pick(wx, wy);
        if (hit) {
            if (shift) {
                store_1.store.toggle(hit.id);
            }
            else if (!store_1.store.selection.has(hit.id)) {
                store_1.store.setSelection([hit.id]);
            }
            // move the selection as it stands AFTER this click resolved
            this.moveIds = [...store_1.store.selection];
            this.mode = 'move';
            this.moveStart = [wx, wy];
            this.moved = false;
            this.moveApplied = [0, 0];
            this.moveTouched = false;
            this.setCapture(this.lastPointer);
        }
        else {
            // 3) empty space → marquee (or click = deselect)
            if (!shift)
                store_1.store.clearSelection();
            this.mode = 'marquee';
            this.marqueeStart = [sx, sy];
            this.marqueeCur = [sx, sy];
            this.marqueeBase = store_1.store.selection.size ? [...store_1.store.selection] : [];
            this.marqueeAdded = [];
            this.moved = false;
            this.setCapture(this.lastPointer);
        }
    }
    pick(wx, wy) {
        for (let i = store_1.store.scene.length - 1; i >= 0; i--) {
            if ((0, geometry_1.hitTest)(store_1.store.scene[i], wx, wy, this.cam.zoom))
                return store_1.store.scene[i];
        }
        return null;
    }
    // ---- pen --------------------------------------------------------------
    startDraw(wx, wy, e) {
        this.mode = 'draw';
        this.drawPts = [wx, wy];
        this.drawId = (0, store_1.newId)();
        const st = store_1.store.style;
        store_1.store.add({
            id: this.drawId,
            type: 'stroke',
            points: this.drawPts,
            stroke: st.stroke,
            strokeWidth: st.strokeWidth,
            fill: 'none',
            fillColor: '',
        });
        store_1.store.recordCreate([this.drawId]);
        this.setCapture(e);
    }
    // ---- rect/ellipse/line ------------------------------------------------
    startCreate(wx, wy, e) {
        this.mode = 'create';
        this.createStart = [wx, wy];
        this.createCur = [wx, wy];
        this.createShift = e.shiftKey;
        this.setCapture(e);
    }
    buildCreateShape() {
        const x = this.createStart[0];
        const y = this.createStart[1];
        let x2 = this.createCur[0];
        let y2 = this.createCur[1];
        if (this.createShift) {
            const dx = x2 - x;
            const dy = y2 - y;
            const m = Math.max(Math.abs(dx), Math.abs(dy));
            x2 = x + Math.sign(dx || 1) * m;
            y2 = y + Math.sign(dy || 1) * m;
        }
        const st = store_1.store.style;
        const base = {
            stroke: st.stroke,
            strokeWidth: st.strokeWidth,
            fill: st.fill,
            fillColor: st.fillColor,
        };
        const type = store_1.store.tool;
        if (type === 'line') {
            return { id: '', type: 'line', x1: x, y1: y, x2, y2, ...base };
        }
        return {
            id: '',
            type,
            x: Math.min(x, x2),
            y: Math.min(y, y2),
            w: Math.abs(x2 - x),
            h: Math.abs(y2 - y),
            ...base,
        };
    }
    // ---- text ---------------------------------------------------------------
    startText(wx, wy) {
        const id = (0, store_1.newId)();
        const sh = {
            id,
            type: 'text',
            x: wx,
            y: wy,
            text: '',
            stroke: store_1.store.style.stroke,
            fontSize: (0, text_1.defaultTextFontSize)(this.cam),
            strokeWidth: 1,
            fill: 'none',
            fillColor: '',
        };
        store_1.store.add(sh);
        store_1.store.recordCreate([id]);
        store_1.store.setSelection([id]);
        store_1.store.setReselect(id);
        this.mode = 'text-edit';
        this.editingId = id;
        this.editor.open(sh, '');
        this.requestPaint();
    }
    // ------------------------------------------------------------------
    // Pointer move
    // ------------------------------------------------------------------
    onPointerMove(e) {
        this.lastPointer = e;
        const [sx, sy] = this.pt(e);
        const [wx, wy] = (0, geometry_1.screenToWorld)(this.cam, sx, sy);
        switch (this.mode) {
            case 'pan': {
                // incremental delta from the previous pointer position
                this.cam = {
                    ...this.cam,
                    x: this.cam.x + (sx - this.lastX),
                    y: this.cam.y + (sy - this.lastY),
                };
                break;
            }
            case 'draw': {
                const pts = this.drawPts;
                const lx = pts[pts.length - 2];
                const ly = pts[pts.length - 1];
                if (Math.hypot(wx - lx, wy - ly) >= 0.35 / this.cam.zoom) {
                    pts.push(wx, wy);
                    const sh = store_1.store.get(this.drawId);
                    if (sh)
                        sh.points = pts;
                    this.requestPaint();
                }
                break;
            }
            case 'create': {
                this.createCur = [wx, wy];
                this.createShift = e.shiftKey;
                this.preview = { kind: 'shape', shape: this.buildCreateShape() };
                this.requestPaint();
                break;
            }
            case 'marquee': {
                if (Math.abs(sx - this.marqueeStart[0]) > 2 ||
                    Math.abs(sy - this.marqueeStart[1]) > 2) {
                    this.moved = true;
                }
                this.marqueeCur = [sx, sy];
                this.preview = {
                    kind: 'marquee',
                    sx: this.marqueeStart[0],
                    sy: this.marqueeStart[1],
                    ex: sx,
                    ey: sy,
                };
                this.updateMarqueeSelection();
                this.requestPaint();
                break;
            }
            case 'move': {
                const dNow = [wx - this.moveStart[0], wy - this.moveStart[1]];
                if (!this.moved && Math.hypot(dNow[0], dNow[1]) * this.cam.zoom < 3)
                    break;
                this.moved = true;
                const dx = dNow[0] - this.moveApplied[0];
                const dy = dNow[1] - this.moveApplied[1];
                if (dx === 0 && dy === 0)
                    break;
                if (!this.moveTouched) {
                    store_1.store._beginTouch(this.moveIds);
                    this.moveTouched = true;
                }
                for (const id of this.moveIds) {
                    const sh = store_1.store.get(id);
                    if (sh)
                        (0, geometry_1.translateShape)(sh, dx, dy);
                }
                this.moveApplied = dNow;
                store_1.store._endTouch(this.moveIds);
                this.requestPaint();
                break;
            }
            case 'resize': {
                if (!this.resizeParams)
                    break;
                this.moved = true;
                this.resizeParams.keepAspect = e.shiftKey;
                const updated = (0, geometry_1.computeResize)(this.resizeParams, wx, wy);
                store_1.store.setShapes(updated);
                store_1.store._endTouch(this.moveIds);
                this.requestPaint();
                break;
            }
            case 'erase': {
                this.eraseAt(wx, wy);
                break;
            }
            default:
                this.hoverCursor(sx, sy, wx, wy);
        }
        this.lastX = sx;
        this.lastY = sy;
    }
    eraseAt(wx, wy) {
        const hit = this.pick(wx, wy);
        if (hit && !this.eraseHits.has(hit.id)) {
            this.eraseHits.add(hit.id);
            store_1.store.recordDelete([hit]);
            store_1.store.remove([hit.id]);
            store_1.store.selection.delete(hit.id);
            this.requestPaint();
        }
    }
    // ------------------------------------------------------------------
    // Pointer up
    // ------------------------------------------------------------------
    onPointerUp(e) {
        try {
            this.canvas.releasePointerCapture(e.pointerId);
        }
        catch {
            /* ignore */
        }
        switch (this.mode) {
            case 'pan':
                break;
            case 'draw':
                store_1.store.commit();
                break;
            case 'create': {
                const sh = this.buildCreateShape();
                this.preview = null;
                if (sh) {
                    const size = sh.type === 'line'
                        ? Math.hypot(sh.x2 - sh.x1, sh.y2 - sh.y1)
                        : Math.max(sh.w ?? 0, sh.h ?? 0);
                    if (size * this.cam.zoom >= 4) {
                        sh.id = (0, store_1.newId)();
                        store_1.store.add(sh);
                        store_1.store.recordCreate([sh.id]);
                        store_1.store.commit();
                        store_1.store.setSelection([sh.id]);
                    }
                }
                break;
            }
            case 'marquee':
                // selection only — nothing to record
                this.preview = null;
                break;
            case 'move':
                if (this.moved)
                    store_1.store.commit();
                break;
            case 'resize':
                if (this.moved)
                    store_1.store.commit();
                this.resizeParams = null;
                break;
            case 'erase':
                store_1.store.commit();
                break;
        }
        this.mode = 'idle';
        this.preview = null;
        this.moved = false;
        this.cursorForMode();
        this.requestPaint();
    }
    // ------------------------------------------------------------------
    // Marquee selection (live while dragging)
    // ------------------------------------------------------------------
    updateMarqueeSelection() {
        const [x0, y0] = (0, geometry_1.screenToWorld)(this.cam, this.marqueeStart[0], this.marqueeStart[1]);
        const [x1, y1] = (0, geometry_1.screenToWorld)(this.cam, this.marqueeCur[0], this.marqueeCur[1]);
        const box = (0, geometry_1.normRect)(x0, y0, x1 - x0, y1 - y0);
        // base (shift-marquee) only applies once a real drag is happening
        const sel = new Set(this.moved ? this.marqueeBase : []);
        const added = [];
        for (const s of store_1.store.scene) {
            if (sel.has(s.id))
                continue;
            if ((0, render_1.bboxIntersects)((0, geometry_1.shapeBBox)(s), box)) {
                sel.add(s.id);
                added.push(s.id);
            }
        }
        this.marqueeAdded = added;
        store_1.store.setSelection([...sel]);
    }
    // ------------------------------------------------------------------
    // Double-click: edit existing text
    // ------------------------------------------------------------------
    onDblClick(e) {
        const [wx, wy] = (0, geometry_1.screenToWorld)(this.cam, ...this.pt(e));
        const hit = this.pick(wx, wy);
        if (hit && hit.type === 'text') {
            store_1.store.setSelection([hit.id]);
            store_1.store.setReselect(hit.id);
            this.mode = 'text-edit';
            this.editingId = hit.id;
            this.editor.open(hit, hit.text ?? '');
            this.requestPaint();
        }
    }
    commitTextIfEditing(cancelled) {
        if (this.mode === 'text-edit')
            this.editor.commit(cancelled);
    }
    onTextCommitted(r) {
        this.mode = 'idle';
        const id = this.editingId;
        this.editingId = '';
        const sh = store_1.store.get(id);
        if (!sh) {
            this.afterTextCommit();
            return;
        }
        if (!r.text) {
            // nothing typed → the object is discarded, undo history untouched
            store_1.store.cancelPending([id]);
        }
        else {
            if (r.text !== sh.text) {
                sh.text = r.text;
                store_1.store._beginTouch([id]);
                store_1.store._endTouch([id]);
            }
            store_1.store.commit();
        }
        this.afterTextCommit();
    }
    /** Text placement is one-shot; return to the select tool afterwards. */
    afterTextCommit() {
        if (store_1.store.tool === 'text') {
            store_1.store.tool = 'select';
            store_1.events.emit('tool');
            this.cursorForMode();
        }
        this.requestPaint();
    }
    // ------------------------------------------------------------------
    // Keyboard
    // ------------------------------------------------------------------
    onKeyDown(e) {
        const t = e.target;
        const inEditor = !!t &&
            (t.tagName === 'TEXTAREA' ||
                t.tagName === 'INPUT' ||
                t.isContentEditable);
        if (inEditor)
            return;
        // Space = temporary pan
        if (e.code === 'Space' && !e.repeat) {
            e.preventDefault();
            this.spaceDown = true;
            this.canvas.style.cursor = 'grab';
            return;
        }
        const meta = e.ctrlKey || e.metaKey;
        // Undo / redo
        if (meta && (e.key === 'z' || e.key === 'Z')) {
            e.preventDefault();
            if (e.shiftKey)
                store_1.store.redo();
            else
                store_1.store.undo();
            this.requestPaint();
            return;
        }
        if (meta && (e.key === 'y' || e.key === 'Y')) {
            e.preventDefault();
            store_1.store.redo();
            this.requestPaint();
            return;
        }
        // Delete / Backspace
        if (e.key === 'Delete' || e.key === 'Backspace') {
            e.preventDefault();
            this.deleteSelection();
            return;
        }
        // Arrow nudge (1px, 10px with Shift — world pixels at current zoom)
        if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
            const sel = store_1.store.selectedShapes();
            if (!sel.length)
                return;
            e.preventDefault();
            const step = (e.shiftKey ? types_1.CONST.NUDGE_BIG : types_1.CONST.NUDGE) / this.cam.zoom;
            let dx = 0, dy = 0;
            if (e.key === 'ArrowLeft')
                dx = -step;
            if (e.key === 'ArrowRight')
                dx = step;
            if (e.key === 'ArrowUp')
                dy = -step;
            if (e.key === 'ArrowDown')
                dy = step;
            if (!this.nudgeKey) {
                store_1.store._beginTouch(sel.map((s) => s.id));
                this.nudgeKey = e.key;
            }
            for (const s of sel)
                (0, geometry_1.translateShape)(s, dx, dy);
            store_1.store._endTouch(sel.map((s) => s.id));
            this.requestPaint();
            return;
        }
        // Escape
        if (e.key === 'Escape') {
            if (this.mode === 'text-edit')
                this.editor.commit(true);
            else {
                store_1.store.clearSelection();
                this.requestPaint();
            }
            return;
        }
        // Zoom controls
        if (e.key === '=' || e.key === '+') {
            e.preventDefault();
            this.zoomCenter(types_1.CONST.ZOOM_STEP);
            return;
        }
        if (e.key === '-') {
            e.preventDefault();
            this.zoomCenter(1 / types_1.CONST.ZOOM_STEP);
            return;
        }
        if (e.key === '0') {
            this.resetView();
            return;
        }
        // Tool shortcuts
        const keymap = {
            v: 'select',
            V: 'select',
            p: 'pen',
            P: 'pen',
            r: 'rect',
            R: 'rect',
            o: 'ellipse',
            O: 'ellipse',
            l: 'line',
            L: 'line',
            t: 'text',
            T: 'text',
            e: 'eraser',
            E: 'eraser',
        };
        if (keymap[e.key])
            this.setTool(keymap[e.key]);
    }
    onKeyUp(e) {
        if (e.code === 'Space') {
            this.spaceDown = false;
            this.cursorForMode();
        }
        if (this.nudgeKey) {
            store_1.store.commit('nudge');
            this.nudgeKey = '';
        }
    }
    deleteSelection() {
        const sel = store_1.store.selectedShapes();
        if (!sel.length)
            return;
        store_1.store.recordDelete(sel);
        store_1.store.remove(sel.map((s) => s.id));
        store_1.store.clearSelection();
        store_1.store.commit();
        this.requestPaint();
    }
    // ------------------------------------------------------------------
    // Camera controls
    // ------------------------------------------------------------------
    zoomCenter(factor) {
        const cx = this.renderer.width / 2;
        const cy = this.renderer.height / 2;
        this.setCamera((0, geometry_1.zoomAt)(this.cam, cx, cy, (0, geometry_1.clampZoom)(this.cam.zoom * factor)));
    }
    resetView() {
        this.setCamera({ x: 0, y: 0, zoom: 1 });
    }
    // ------------------------------------------------------------------
    // Tool switching & cursor
    // ------------------------------------------------------------------
    setTool(tool) {
        if (this.mode === 'text-edit')
            this.editor.commit(false);
        store_1.store.tool = tool;
        store_1.events.emit('tool');
        if (tool !== 'select')
            store_1.store.clearSelection();
        this.cursorForMode();
        this.requestPaint();
    }
    cursorForMode() {
        if (this.spaceDown) {
            this.canvas.style.cursor = 'grab';
            return;
        }
        const cur = {
            select: 'default',
            pen: 'crosshair',
            rect: 'crosshair',
            ellipse: 'crosshair',
            line: 'crosshair',
            text: 'text',
            eraser: 'cell',
        };
        this.canvas.style.cursor = cur[store_1.store.tool];
    }
    hoverCursor(sx, sy, wx, wy) {
        if (store_1.store.tool !== 'select')
            return;
        const sel = store_1.store.selectedShapes();
        if (sel.length) {
            const box = (0, geometry_1.unionBBox)(sel);
            if (box && this.renderer.hitHandle(box, this.cam, sx, sy)) {
                this.canvas.style.cursor = 'nwse-resize';
                return;
            }
        }
        const hit = this.pick(wx, wy);
        this.canvas.style.cursor = hit ? 'move' : 'default';
    }
}
exports.InputController = InputController;
};

// ── module: src/ui.ts ──
__mods["src/ui.ts"] = function (exports, require, module) {
"use strict";
// ============================================================================
// UI wiring: toolbar, properties bar, view controls, status
// ============================================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.wireUI = wireUI;
exports.clampZoomPublic = clampZoomPublic;
const types_1 = require("./types");
const store_1 = require("./store");
const geometry_1 = require("./geometry");
function wireUI(input, onZoom) {
    // ---- toolbar (tools) -------------------------------------------------
    const toolBtns = Array.from(document.querySelectorAll('[data-tool]'));
    const syncTool = () => {
        for (const b of toolBtns) {
            b.classList.toggle('active', b.dataset.tool === store_1.store.tool);
        }
    };
    for (const b of toolBtns) {
        b.addEventListener('click', () => {
            input.setTool(b.dataset.tool);
            syncTool();
            store_1.store.save();
        });
    }
    store_1.events.on('tool', syncTool);
    syncTool();
    // ---- properties bar (style) -------------------------------------------
    const syncStyle = () => {
        for (const b of document.querySelectorAll('[data-stroke]')) {
            b.classList.toggle('active', b.dataset.stroke === store_1.store.style.stroke);
        }
        for (const b of document.querySelectorAll('[data-fill]')) {
            const active = b.dataset.fill === 'none'
                ? store_1.store.style.fill === 'none'
                : store_1.store.style.fill === 'solid' && b.dataset.fill === store_1.store.style.fillColor;
            b.classList.toggle('active', active);
        }
        for (const b of document.querySelectorAll('[data-width]')) {
            b.classList.toggle('active', Number(b.dataset.width) === store_1.store.style.strokeWidth);
        }
    };
    const applyStyleToSelection = () => {
        const sel = store_1.store.selectedShapes();
        if (!sel.length)
            return;
        store_1.store._beginTouch(sel.map((s) => s.id));
        for (const s of sel) {
            s.stroke = store_1.store.style.stroke;
            s.strokeWidth = store_1.store.style.strokeWidth;
            s.fill = store_1.store.style.fill;
            s.fillColor = store_1.store.style.fillColor;
        }
        store_1.store._endTouch(sel.map((s) => s.id));
    };
    const styleClicked = () => {
        applyStyleToSelection();
        if (store_1.store._current.length)
            store_1.store.commit('restyle');
        syncStyle();
        store_1.store.save();
        input.requestPaint();
    };
    for (const b of document.querySelectorAll('[data-stroke]')) {
        b.addEventListener('click', () => {
            store_1.store.style.stroke = b.dataset.stroke;
            styleClicked();
        });
    }
    for (const b of document.querySelectorAll('[data-fill]')) {
        b.addEventListener('click', () => {
            const f = b.dataset.fill;
            if (f === 'none') {
                store_1.store.style.fill = 'none';
                store_1.store.style.fillColor = '';
            }
            else {
                store_1.store.style.fill = 'solid';
                store_1.store.style.fillColor = f;
            }
            styleClicked();
        });
    }
    for (const b of document.querySelectorAll('[data-width]')) {
        b.addEventListener('click', () => {
            store_1.store.style.strokeWidth = Number(b.dataset.width);
            styleClicked();
        });
    }
    syncStyle();
    // ---- history buttons ----------------------------------------------------
    const undoBtn = document.getElementById('undo-btn');
    const redoBtn = document.getElementById('redo-btn');
    const syncHistory = () => {
        undoBtn.disabled = !store_1.store.canUndo;
        redoBtn.disabled = !store_1.store.canRedo;
    };
    undoBtn.addEventListener('click', () => {
        store_1.store.undo();
        input.requestPaint();
    });
    redoBtn.addEventListener('click', () => {
        store_1.store.redo();
        input.requestPaint();
    });
    store_1.events.on('history', syncHistory);
    syncHistory();
    // ---- zoom controls -------------------------------------------------------
    const label = document.getElementById('zoom-label');
    const syncZoom = (cam) => {
        label.textContent = `${Math.round(cam.zoom * 100)}%`;
    };
    document.getElementById('zoom-in')?.addEventListener('click', () => onZoom(types_1.CONST.ZOOM_STEP));
    document.getElementById('zoom-out')?.addEventListener('click', () => onZoom(1 / types_1.CONST.ZOOM_STEP));
    label.addEventListener('click', () => input.resetView());
    document.getElementById('zoom-reset')?.addEventListener('click', () => input.resetView());
    store_1.events.on('camera', () => syncZoom(input.getCamera()));
    syncZoom(input.getCamera());
}
function clampZoomPublic(z) {
    return (0, geometry_1.clampZoom)(z);
}
};

// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"unsloth/Qwen3.8-27B-GGUF","provider":"brigid","persona":"typescript","sessionId":"01d1d89d-306b-4ac9-9fbd-f0401374b9c0","tokensIn":6475034,"tokensOut":246537,"tokensTotal":6721571,"cost":0,"turns":88,"toolCalls":111,"failedToolCalls":6,"timestamp":"2026-08-15T20:59:32.174Z"} -->