Whiteboard
Qwen 3.8 27B GGUF · typescript

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 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"} -->