Spreadsheet
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 60,829 bytes · SHA-256 42d63c38eee8
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GridSheet — Spreadsheet</title>
<style>
/* ── css: sheet ── */
/* ===== Base & layout ===== */
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 13px;
color: #202124;
background: #f8f9fa;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
}
#app {
display: flex;
flex-direction: column;
height: 100vh;
}
/* ===== Toolbar ===== */
#toolbar {
display: flex;
align-items: center;
gap: 14px;
height: 38px;
padding: 0 12px;
background: #ffffff;
border-bottom: 1px solid #e0e0e0;
flex: none;
}
#logo {
font-weight: 700;
font-size: 14px;
color: #1a73e8;
letter-spacing: .2px;
}
#tb-group { display: flex; gap: 4px; }
#tb-group button {
font: inherit;
font-size: 12px;
padding: 4px 10px;
border: 1px solid #dadce0;
border-radius: 4px;
background: #fff;
color: #3c4043;
cursor: pointer;
}
#tb-group button:hover:not(:disabled) { background: #f1f5fe; border-color: #1a73e8; color: #1a73e8; }
#tb-group button:disabled { opacity: .45; cursor: default; }
#save-ind {
margin-left: auto;
font-size: 12px;
color: #9aa0a6;
transition: opacity .5s;
}
/* ===== Formula bar ===== */
#fbar {
display: flex;
align-items: center;
gap: 0;
height: 30px;
padding: 0 8px;
background: #ffffff;
border-bottom: 1px solid #e0e0e0;
flex: none;
}
#namebox {
width: 64px;
text-align: center;
font-weight: 600;
font-size: 12px;
color: #3c4043;
border-right: 1px solid #e8eaed;
padding-right: 8px;
}
#fx {
font-style: italic;
font-family: Georgia, "Times New Roman", serif;
color: #80868b;
font-size: 13px;
padding: 0 10px 0 12px;
border-right: 1px solid #e8eaed;
}
#barinput {
flex: 1;
margin-left: 8px;
border: 1px solid transparent;
border-radius: 3px;
padding: 3px 8px;
font: inherit;
font-size: 13px;
background: transparent;
color: #202124;
outline: none;
user-select: text;
-webkit-user-select: text;
}
#barinput:hover { background: #f8f9fa; }
#barinput:focus { background: #fff; border-color: #1a73e8; }
/* ===== Grid ===== */
#scroll {
flex: 1;
overflow: auto;
background: #fff;
position: relative;
}
table.grid {
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
margin: 0;
}
table.grid th, table.grid td {
border-right: 1px solid #e6e8ea;
border-bottom: 1px solid #e6e8ea;
white-space: nowrap;
}
table.grid thead th, table.grid tbody th {
background: #f8f9fa;
color: #5f6368;
font-weight: 400;
font-size: 11px;
text-align: center;
}
table.grid thead th { height: 24px; position: sticky; top: 0; z-index: 3; box-shadow: inset 0 -1px 0 #dadce0; }
table.grid tbody th {
width: 44px;
position: sticky;
left: 0;
z-index: 2;
box-shadow: inset -1px 0 0 #dadce0;
}
table.grid thead th.corner {
left: 0;
z-index: 4;
width: 44px;
}
table.grid td {
font-size: 13px;
padding: 0 6px;
color: #202124;
cursor: default;
}
table.grid td.num { text-align: right; font-variant-numeric: tabular-nums; }
table.grid td.neg { color: #d93025; }
table.grid td.error { color: #d93025; font-weight: 600; }
/* selection */
td.sel {
outline: 2px solid #1a73e8;
outline-offset: -1px;
}
td.editing {
position: relative;
overflow: visible;
border-color: #1a73e8 !important;
}
td.editing::after {
content: "";
position: absolute;
left: -1px; right: -1px; top: -1px; bottom: -1px;
border: 2px solid #1a73e8;
pointer-events: none;
z-index: 1;
}
td.editing input {
position: absolute;
left: 0; top: 0;
width: 100%; height: 100%;
margin: 0;
border: none;
padding: 0 6px;
font: inherit;
font-size: 13px;
outline: none;
background: #fff;
color: #202124;
z-index: 2;
user-select: text;
-webkit-user-select: text;
}
td.editing input.num { text-align: right; }
/* ===== Context menu ===== */
#ctxmenu {
position: fixed;
z-index: 100;
min-width: 190px;
background: #fff;
border: 1px solid #dadce0;
border-radius: 6px;
box-shadow: 0 4px 14px rgba(60, 64, 67, .25);
padding: 6px 0;
display: none;
}
#ctxmenu.open { display: block; }
#ctxmenu .mi {
display: flex;
justify-content: space-between;
gap: 18px;
padding: 6px 16px;
font-size: 13px;
color: #3c4043;
cursor: default;
}
#ctxmenu .mi:hover { background: #f1f5fe; color: #1a73e8; }
#ctxmenu .mi kbd { color: #9aa0a6; font-family: inherit; font-size: 11px; }
#ctxmenu .sep { height: 1px; background: #ececec; margin: 5px 0; }
</style>
</head>
<body>
<div id="app">
<div id="toolbar">
<span id="logo">▦ GridSheet</span>
<div id="tb-group">
<button type="button" data-act="ins-row" title="Insert a row above the selected row">+ Row</button>
<button type="button" data-act="del-row" title="Delete the selected row">− Row</button>
<button type="button" data-act="ins-col" title="Insert a column to the left of the selected column">+ Col</button>
<button type="button" data-act="del-col" title="Delete the selected column">− Col</button>
</div>
<span id="save-ind"></span>
</div>
<div id="fbar">
<div id="namebox">A1</div>
<div id="fx">fx</div>
<input id="barinput" type="text" spellcheck="false" autocomplete="off" autocapitalize="off">
</div>
<div id="scroll" aria-label="spreadsheet grid"></div>
</div>
<div id="ctxmenu" role="menu"></div>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {"src/main.ts":{"./store":"src/store.ts","./ui":"src/ui.ts"},"src/eval.ts":{"./formula":"src/formula.ts"},"src/store.ts":{"./config":"src/config.ts","./formula":"src/formula.ts","./eval":"src/eval.ts"},"src/ui.ts":{"./config":"src/config.ts","./formula":"src/formula.ts","./store":"src/store.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";
Object.defineProperty(exports, "__esModule", { value: true });
/* ============================================================
* MAIN — wire the store and UI together.
* ============================================================ */
const store_1 = require("./store");
const ui_1 = require("./ui");
function main() {
const sheet = new store_1.Sheet();
const ui = new ui_1.UI(sheet);
sheet.onSave = () => ui.flashSave();
// expose a dev handle for probing
window.__sheet = sheet;
window.__ui = ui;
window.addEventListener('beforeunload', () => {
try {
const data = { v: 1, cols: sheet.cols, rows: sheet.rows, raw: sheet.raw };
localStorage.setItem('gridsheet.v1', JSON.stringify(data));
}
catch { /* ignore */ }
});
// initial save indicator state
const ind = document.getElementById('save-ind');
ind.style.opacity = '0.55';
}
main();
};
// ── module: src/config.ts ──
__mods["src/config.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.C = exports.CELL_H = exports.HEAD_W = exports.COL_WIDTH = exports.MAX_ROWS = exports.MAX_COLS = exports.ROWS = exports.COLS = void 0;
/* ============================================================
* TUNABLE CONSTANTS — grid size, colors, cell dimensions
* ============================================================ */
exports.COLS = 26; // A–Z
exports.ROWS = 100; // 1–100
exports.MAX_COLS = 30; // cap for insert operations
exports.MAX_ROWS = 105;
exports.COL_WIDTH = 88; // px, data columns
exports.HEAD_W = 44; // px, row header column
exports.CELL_H = 24; // px, row height
exports.C = {
sel: '#1a73e8',
line: '#e6e8ea',
lineStrong: '#dadce0',
headBg: '#f8f9fa',
headFg: '#5f6368',
error: '#d93025',
};
};
// ── module: src/formula.ts ──
__mods["src/formula.ts"] = function (exports, require, module) {
"use strict";
/* ============================================================
* FORMULA ENGINE — tokenizer + recursive-descent parser,
* then interpreter over an AST. No eval(), no Function().
* ============================================================ */
Object.defineProperty(exports, "__esModule", { value: true });
exports.RANGE_FN = exports.err = exports.txt = exports.num = exports.ERR_VALUE = exports.ERR_CYCLE = exports.ERR_PARSE = exports.ERR_DIV = exports.ERR_REF = void 0;
exports.isErr = isErr;
exports.isNum = isNum;
exports.toCoercible = toCoercible;
exports.tokenize = tokenize;
exports.parseFormula = parseFormula;
exports.ERR_REF = '#REF!';
exports.ERR_DIV = '#DIV/0!';
exports.ERR_PARSE = '#ERR!';
exports.ERR_CYCLE = '#CYCLE!';
exports.ERR_VALUE = '#VALUE!';
const num = (v) => ({ t: 'num', v });
exports.num = num;
const txt = (s) => ({ t: 'text', s });
exports.txt = txt;
const err = (e) => ({ t: 'err', e });
exports.err = err;
function isErr(v) { return v.t === 'err'; }
function isNum(v) { return v.t === 'num'; }
/** Coerce for arithmetic: text that parses as a number becomes a number;
* blank text becomes 0; other text stays text (becomes #VALUE! in ops). */
function toCoercible(v) {
if (v.t !== 'text')
return v;
if (v.s === '')
return (0, exports.num)(0);
const n = Number(v.s);
return Number.isFinite(n) ? (0, exports.num)(n) : v;
}
exports.RANGE_FN = ['SUM', 'AVG', 'AVERAGE', 'MIN', 'MAX', 'COUNT'];
function colOfLetters(s) {
let c = 0;
for (let i = 0; i < s.length; i++)
c = c * 26 + (s.charCodeAt(i) - 64);
return c - 1;
}
function tokenize(src) {
const toks = [];
const n = src.length;
let i = 0;
while (i < n) {
const ch = src[i];
if (ch === ' ' || ch === '\t') {
i++;
continue;
}
if ((ch >= '0' && ch <= '9') || (ch === '.' && i + 1 < n && src[i + 1] >= '0' && src[i + 1] <= '9')) {
let j = i + 1;
while (j < n && (src[j] >= '0' && src[j] <= '9' || src[j] === '.'))
j++;
const raw = src.slice(i, j);
if ((raw.match(/\./g) || []).length > 1)
throw new Error('bad number');
toks.push({ t: 'num', v: parseFloat(raw) });
i = j;
continue;
}
if (ch === '"' || ch === "'") {
const q = ch;
let j = i + 1, s = '', closed = false;
while (j < n) {
if (src[j] === q) {
if (src[j + 1] === q) {
s += q;
j += 2;
continue;
} // escaped quote
j++;
closed = true;
break;
}
s += src[j];
j++;
}
if (!closed)
throw new Error('unterminated string');
toks.push({ t: 'str', s });
i = j;
continue;
}
if (ch === '#') {
// error literal: #REF! #DIV/0! #N/A #VALUE! #NAME? …
let j = i + 1;
while (j < n && /[A-Za-z0-9/?!]/.test(src[j]))
j++;
if (j <= i + 1)
throw new Error('bad error literal');
toks.push({ t: 'errlit', e: src.slice(i, j) });
i = j;
continue;
}
if (ch === '$') {
i++;
continue;
} // absolute-marker: tolerated, ignored
if (ch === '+' || ch === '-' || ch === '*' || ch === '/' || ch === ':') {
toks.push({ t: 'op', v: ch });
i++;
continue;
}
if (ch === '(' || ch === ')' || ch === ',') {
toks.push({ t: 'p', v: ch });
i++;
continue;
}
if ((ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z')) {
let j = i + 1;
while (j < n && /[A-Za-z0-9._]/.test(src[j]))
j++;
const w = src.slice(i, j);
if (/^[A-Z]+[0-9]+$/i.test(w)) {
const m = w.toUpperCase().match(/^([A-Z]+)([0-9]+)$/);
if (m[1].length < 3) {
toks.push({ t: 'ref', ref: { c: colOfLetters(m[1]), r: parseInt(m[2], 10) - 1 } });
}
else {
toks.push({ t: 'name', s: w.toUpperCase() }); // e.g. AAB1 — not a function; parser will reject
}
}
else if (/^[A-Z][A-Z0-9._]*$/i.test(w)) {
toks.push({ t: 'name', s: w.toUpperCase() });
}
else {
throw new Error('bad identifier');
}
i = j;
continue;
}
throw new Error('unexpected character');
}
toks.push({ t: 'eof' });
return toks;
}
/* ---------- Parser (recursive descent) ----------
* expr := term (('+'|'-') term)*
* term := factor (('*'|'/') factor)*
* factor := ('+'|'-') factor | primary
* primary := NUM | STR | REF (':' REF)? | NAME '(' args? ')' | '(' expr ')'
* ------------------------------------------------------------ */
class Parser {
constructor(toks) {
this.toks = toks;
this.pos = 0;
}
peek() { return this.toks[this.pos]; }
next() { return this.toks[this.pos++]; }
parse() {
const e = this.expr();
if (this.peek().t !== 'eof')
throw new Error('trailing tokens');
return e;
}
expr() {
let a = this.term();
for (;;) {
const t = this.peek();
if (t.t === 'op' && (t.v === '+' || t.v === '-')) {
this.next();
a = { k: 'bin', op: t.v, a, b: this.term() };
}
else
return a;
}
}
term() {
let a = this.factor();
for (;;) {
const t = this.peek();
if (t.t === 'op' && (t.v === '*' || t.v === '/')) {
this.next();
a = { k: 'bin', op: t.v, a, b: this.factor() };
}
else
return a;
}
}
factor() {
const t = this.peek();
if (t.t === 'op' && (t.v === '+' || t.v === '-')) {
this.next();
const a = this.factor();
return t.v === '-' ? { k: 'neg', a } : a;
}
return this.primary();
}
primary() {
const t = this.next();
if (t.t === 'num')
return { k: 'lit', v: t.v };
if (t.t === 'str')
return { k: 'str', s: t.s };
if (t.t === 'errlit')
return { k: 'errlit', e: t.e };
if (t.t === 'ref') {
if (this.peek().t === 'op' && this.peek().v === ':') {
this.next();
const e = this.next();
if (e.t !== 'ref')
throw new Error('range end must be a ref');
const a = t.ref, b = e.ref;
return { k: 'range', range: {
c1: Math.min(a.c, b.c), r1: Math.min(a.r, b.r),
c2: Math.max(a.c, b.c), r2: Math.max(a.r, b.r)
} };
}
return { k: 'ref', ref: t.ref };
}
if (t.t === 'name') {
if (this.peek().t === 'p' && this.peek().v === '(') {
this.next();
const args = [];
if (this.peek().t === 'p' && this.peek().v === ')') {
this.next();
}
else {
for (;;) {
args.push(this.expr());
const p = this.peek();
if (p.t === 'p' && p.v === ',') {
this.next();
continue;
}
if (p.t === 'p' && p.v === ')') {
this.next();
break;
}
throw new Error('expected , or )');
}
}
return { k: 'call', name: t.s, args };
}
if (t.s === 'TRUE')
return { k: 'lit', v: 1 };
if (t.s === 'FALSE')
return { k: 'lit', v: 0 };
throw new Error('unknown name ' + t.s);
}
if (t.t === 'p' && t.v === '(') {
const e = this.expr();
const c = this.next();
if (c.t !== 'p' || c.v !== ')')
throw new Error('missing )');
return e;
}
throw new Error('unexpected token');
}
}
/** Parse a formula body (no leading '='). Throws on syntax error. */
function parseFormula(src) {
return new Parser(tokenize(src)).parse();
}
};
// ── module: src/eval.ts ──
__mods["src/eval.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.keyOf = void 0;
exports.evalNode = evalNode;
exports.collectDeps = collectDeps;
/* ============================================================
* EVALUATOR — interprets an AST against a value getter.
* Dependency extraction walks the same AST.
* ============================================================ */
const formula_1 = require("./formula");
function evalNode(n, get) {
switch (n.k) {
case 'lit': return (0, formula_1.num)(n.v);
case 'str': return (0, formula_1.txt)(n.s);
case 'errlit': return (0, formula_1.err)(n.e);
case 'ref': return get(n.ref);
case 'range': return (0, formula_1.err)(formula_1.ERR_VALUE); // bare range outside a function
case 'neg': {
const v = evalNode(n.a, get);
if (v.t === 'err')
return v;
const c = (0, formula_1.toCoercible)(v);
return c.t === 'num' ? (0, formula_1.num)(-c.v) : (0, formula_1.err)(formula_1.ERR_VALUE);
}
case 'bin': {
const a = evalNode(n.a, get), b = evalNode(n.b, get);
if (a.t === 'err' || b.t === 'err')
return a.t === 'err' ? a : b;
return applyBin(n.op, a, b);
}
case 'call': return evalCall(n, get);
}
}
const strOf = (v) => v.t === 'num' ? String(v.v) : v.t === 'text' ? v.s : v.e;
const numOf = (v) => v.t === 'num' ? v.v : v.t === 'text' ? Number(v.s) : NaN;
function applyBin(op, a, b) {
// '+' with any text side → string concatenation (spreadsheet behavior)
if (op === '+' && (a.t === 'text' || b.t === 'text')) {
return (0, formula_1.txt)(strOf(a) + strOf(b));
}
const x = (0, formula_1.toCoercible)(a), y = (0, formula_1.toCoercible)(b);
if (x.t === 'err' || y.t === 'err')
return x.t === 'err' ? x : y;
if (x.t !== 'num' || y.t !== 'num')
return (0, formula_1.err)(formula_1.ERR_VALUE);
let r;
switch (op) {
case '+':
r = x.v + y.v;
break;
case '-':
r = x.v - y.v;
break;
case '*':
r = x.v * y.v;
break;
case '/':
if (y.v === 0)
return (0, formula_1.err)(formula_1.ERR_DIV);
r = x.v / y.v;
break;
}
return (0, formula_1.num)(r);
}
function evalCall(n, get) {
const name = n.name;
if (!formula_1.RANGE_FN.includes(name))
return (0, formula_1.err)(formula_1.ERR_VALUE);
const args = n.args.map(a => (a.k === 'range' ? evalRange(a.range, get) : evalNode(a, get)));
const flat = [];
for (const x of args)
if (Array.isArray(x))
flat.push(...x);
else
flat.push(x);
// any error value propagates (spreadsheet behavior)
for (const v of flat)
if (v.t === 'err')
return v;
const isNumLike = (v) => (0, formula_1.isNum)(v) || (v.t === 'text' && v.s !== '' && Number.isFinite(Number(v.s)));
switch (name) {
case 'COUNT': {
let c = 0;
for (const v of flat)
if (isNumLike(v))
c++;
return (0, formula_1.num)(c);
}
case 'SUM':
case 'AVG':
case 'AVERAGE':
case 'MIN':
case 'MAX': {
const vals = [];
for (const v of flat)
if (isNumLike(v))
vals.push(numOf(v));
if (name === 'MIN' || name === 'MAX') {
if (vals.length === 0)
return (0, formula_1.num)(0);
return (0, formula_1.num)(name === 'MIN' ? Math.min(...vals) : Math.max(...vals));
}
if (vals.length === 0)
return (0, formula_1.num)(0);
if (name === 'AVG' || name === 'AVERAGE') {
let s = 0;
for (const v of vals)
s += v;
return (0, formula_1.num)(s / vals.length);
}
let s = 0;
for (const v of vals)
s += v;
return (0, formula_1.num)(s);
}
}
return (0, formula_1.err)(formula_1.ERR_VALUE);
}
/** Materialize a range (flattened row-major). */
function evalRange(range, get) {
const out = [];
for (let r = range.r1; r <= range.r2; r++)
for (let c = range.c1; c <= range.c2; c++)
out.push(get({ c, r }));
return out;
}
/* ---------- Dependency extraction ---------- */
function collectDeps(n, out) {
switch (n.k) {
case 'lit':
case 'str':
case 'errlit': return;
case 'ref':
out.add((0, exports.keyOf)(n.ref.c, n.ref.r));
break;
case 'range': {
const R = n.range;
for (let r = R.r1; r <= R.r2; r++)
for (let c = R.c1; c <= R.c2; c++)
out.add((0, exports.keyOf)(c, r));
break;
}
case 'neg':
collectDeps(n.a, out);
break;
case 'bin':
collectDeps(n.a, out);
collectDeps(n.b, out);
break;
case 'call':
n.args.forEach(a => collectDeps(a, out));
break;
}
}
const keyOf = (c, r) => r * 1000 + c;
exports.keyOf = keyOf;
};
// ── module: src/store.ts ──
__mods["src/store.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Sheet = exports.refName = void 0;
exports.colName = colName;
exports.parseRefName = parseRefName;
exports.sameVal = sameVal;
/* ============================================================
* SHEET STORE — raw cell contents, dependency graph,
* incremental topological recalculation, row/col ops with
* formula rewriting, localStorage persistence.
*
* `raw` is the single source of truth (user input: "", number,
* text, or "=..."). `vals` are computed results.
* `deps` : key(c,r) -> Set of keys this cell DEPENDS ON.
* `rdeps` : key -> Set of keys that depend on it.
*
* Recalc = BFS of dependents from changed cells, then a Kahn
* topo-sort over that subgraph, then recompute in order.
* Cells that can never be ordered (in a cycle, or depending on
* one) are set to #CYCLE!. Cycles therefore cannot hang or
* overflow — everything is iterative.
* ============================================================ */
const config_1 = require("./config");
const formula_1 = require("./formula");
const eval_1 = require("./eval");
const LS_KEY = 'gridsheet.v1';
const isA = (ch) => (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z');
const isD = (ch) => ch >= '0' && ch <= '9';
function colName(c) {
let s = '';
c += 1;
while (c > 0) {
const m = (c - 1) % 26;
s = String.fromCharCode(65 + m) + s;
c = Math.floor((c - 1) / 26);
}
return s;
}
const refName = (c, r) => colName(c) + (r + 1);
exports.refName = refName;
function parseRefName(name) {
const m = name.trim().match(/^([A-Za-z]{1,3})([0-9]{1,5})$/);
if (!m)
return null;
let c = 0;
for (let i = 0; i < m[1].length; i++)
c = c * 26 + (m[1].toUpperCase().charCodeAt(i) - 64);
return { c: c - 1, r: parseInt(m[2], 10) - 1 };
}
const emptyVal = () => (0, formula_1.num)(0);
const idx = (c, r, cols) => r * cols + c;
class Sheet {
constructor() {
this.cols = config_1.COLS;
this.rows = config_1.ROWS;
this.raw = new Array(config_1.COLS * config_1.ROWS).fill('');
this.vals = new Array(config_1.COLS * config_1.ROWS).fill(emptyVal());
this.deps = new Map();
this.rdeps = new Map();
this.listeners = [];
this.persistT = null;
/* ---------- evaluation ---------- */
this.getter = (ref) => {
if (ref.c < 0 || ref.r < 0 || ref.c >= this.cols || ref.r >= this.rows)
return (0, formula_1.err)(formula_1.ERR_REF);
return this.vals[idx(ref.c, ref.r, this.cols)];
};
this.load();
this.recalcAll();
}
/* ---------- accessors ---------- */
getRaw(c, r) {
if (c < 0 || r < 0 || c >= this.cols || r >= this.rows)
return '';
return this.raw[idx(c, r, this.cols)];
}
valOf(c, r) {
if (c < 0 || r < 0 || c >= this.cols || r >= this.rows)
return (0, formula_1.err)(formula_1.ERR_REF);
return this.vals[idx(c, r, this.cols)];
}
inBounds(c, r) {
return c >= 0 && r >= 0 && c < this.cols && r < this.rows;
}
/* ---------- dependency graph maintenance ---------- */
reparseCell(c, r) {
const raw = this.raw[idx(c, r, this.cols)];
if (!raw.startsWith('='))
return null;
try {
return (0, formula_1.parseFormula)(raw.slice(1));
}
catch {
return null;
}
}
replaceDeps(c, r, ast) {
const k = (0, eval_1.keyOf)(c, r);
const old = this.deps.get(k);
if (old) {
for (const d of old) {
const s = this.rdeps.get(d);
if (s) {
s.delete(k);
if (s.size === 0)
this.rdeps.delete(d);
}
}
this.deps.delete(k);
}
if (ast) {
const ds = new Set();
(0, eval_1.collectDeps)(ast, ds);
this.deps.set(k, ds); // self-refs stay: they make the cell a self-cycle
for (const d of ds) {
const dc = d % 1000, dr = Math.floor(d / 1000);
if (!this.inBounds(dc, dr))
continue; // out-of-grid ref: getter yields #REF!
let s = this.rdeps.get(d);
if (!s) {
s = new Set();
this.rdeps.set(d, s);
}
s.add(k);
}
}
}
computeCell(c, r) {
const raw = this.raw[idx(c, r, this.cols)];
if (!raw.startsWith('=')) {
if (raw === '')
return (0, formula_1.num)(0);
const t = raw.trim();
if (t !== '' && /^[-+]?(\d+\.?\d*|\.\d+)([eE][-+]?\d+)?$/.test(t))
return (0, formula_1.num)(Number(t));
return (0, formula_1.txt)(raw);
}
let ast;
try {
ast = (0, formula_1.parseFormula)(raw.slice(1));
}
catch {
return (0, formula_1.err)(formula_1.ERR_PARSE);
}
try {
return (0, eval_1.evalNode)(ast, this.getter);
}
catch {
return (0, formula_1.err)(formula_1.ERR_PARSE);
}
}
/** Full rebuild of graph + values (used on load and after structure ops).
* Kahn topo-order over the whole graph so references in any direction
* read fresh values; leftover cells (in cycles) become #CYCLE!. */
recalcAll() {
this.deps = new Map();
this.rdeps = new Map();
const keys = [];
for (let r = 0; r < this.rows; r++)
for (let c = 0; c < this.cols; c++) {
this.replaceDeps(c, r, this.reparseCell(c, r));
keys.push((0, eval_1.keyOf)(c, r));
}
const indeg = new Map();
for (const k of keys) {
let deg = 0;
const ds = this.deps.get(k);
if (ds)
for (const d of ds) {
const dc = d % 1000, dr = Math.floor(d / 1000);
if (this.inBounds(dc, dr))
deg++;
}
indeg.set(k, deg);
}
const q = [];
for (const k of keys)
if (indeg.get(k) === 0)
q.push(k);
const order = [];
while (q.length) {
const k = q.pop();
order.push(k);
const rs = this.rdeps.get(k);
if (rs)
for (const d of rs) {
const nd = (indeg.get(d) || 1) - 1;
indeg.set(d, nd);
if (nd === 0)
q.push(d);
}
}
const done = new Set(order);
for (const k of order) {
const r = Math.floor(k / 1000), c = k % 1000;
this.vals[idx(c, r, this.cols)] = this.computeCell(c, r);
}
for (const k of keys) {
if (done.has(k))
continue;
const r = Math.floor(k / 1000), c = k % 1000;
this.vals[idx(c, r, this.cols)] = (0, formula_1.err)(formula_1.ERR_CYCLE);
}
}
/** Incremental: recompute only the dependents of `keys`, topo order. */
recalcDirty(keys) {
const changed = [];
// 1) collect all downstream dependents
const affected = new Set(keys);
const queue = [...keys];
while (queue.length) {
const k = queue.pop();
const rs = this.rdeps.get(k);
if (rs)
for (const d of rs)
if (!affected.has(d)) {
affected.add(d);
queue.push(d);
}
}
// 2) Kahn topo sort over the induced subgraph
const indeg = new Map();
for (const k of affected) {
let deg = 0;
const ds = this.deps.get(k);
if (ds)
for (const d of ds)
if (affected.has(d))
deg++;
indeg.set(k, deg);
}
const order = [];
const q2 = [];
for (const k of affected)
if (indeg.get(k) === 0)
q2.push(k);
while (q2.length) {
const k = q2.pop();
order.push(k);
const rs = this.rdeps.get(k);
if (rs)
for (const d of rs) {
if (!affected.has(d))
continue;
const nd = (indeg.get(d) || 1) - 1;
indeg.set(d, nd);
if (nd === 0)
q2.push(d);
}
}
// 3) recompute in topo order
const done = new Set(order);
for (const k of order) {
const r = Math.floor(k / 1000), c = k % 1000;
const i = idx(c, r, this.cols);
const v = this.computeCell(c, r);
if (!sameVal(this.vals[i], v)) {
this.vals[i] = v;
changed.push({ c, r });
}
}
// 4) leftovers are in a cycle / depend on one → #CYCLE!
for (const k of affected) {
if (done.has(k))
continue;
const r = Math.floor(k / 1000), c = k % 1000;
const i = idx(c, r, this.cols);
if (!sameVal(this.vals[i], (0, formula_1.err)(formula_1.ERR_CYCLE))) {
this.vals[i] = (0, formula_1.err)(formula_1.ERR_CYCLE);
changed.push({ c, r });
}
}
return { changed, structural: null };
}
/* ---------- user edits ---------- */
setRaw(c, r, raw, opts) {
if (!this.inBounds(c, r))
return { changed: [], structural: null };
const k = (0, eval_1.keyOf)(c, r);
const i = idx(c, r, this.cols);
if (raw === this.raw[i])
return { changed: [], structural: null };
this.raw[i] = raw;
this.replaceDeps(c, r, this.reparseCell(c, r));
const v = this.computeCell(c, r);
let changed = [];
if (!sameVal(this.vals[i], v)) {
this.vals[i] = v;
changed.push({ c, r });
}
// Always propagate: a formula may now create a cycle even when its
// own value is unchanged, and dependents must re-read it.
const sub = this.recalcDirty([k]);
for (const x of sub.changed)
changed.push(x);
if (!opts?.silent)
this.persist();
this.emit({ changed, structural: null });
return { changed, structural: null };
}
/* ---------- formula rewriting for structural ops ---------- */
/** Transform one ref for an insert/delete of a row or col.
* Deleted target → (-1,-1) which renders as #REF!. */
static shiftRef(ref, op) {
let c = ref.c, r = ref.r;
if (op.kind === 'col') {
if (op.dir === 1) {
if (c >= op.index)
c++;
}
else if (c > op.index)
c--;
else if (c === op.index)
return { c: -1, r: -1 };
}
else {
if (op.dir === 1) {
if (r >= op.index)
r++;
}
else if (r > op.index)
r--;
else if (r === op.index)
return { c: -1, r: -1 };
}
return { c, r };
}
/** Read a cell ref (letters+digits) starting at pos, if any. */
static readRef(src, pos) {
const n = src.length;
let j = pos;
while (j < n && isA(src[j]))
j++;
if (j === pos || j - pos > 3)
return null;
let k = j;
while (k < n && isD(src[k]))
k++;
if (k === j)
return null;
let c = 0;
for (let x = pos; x < j; x++)
c = c * 26 + (src[x].toUpperCase().charCodeAt(0) - 64);
return { ref: { c: c - 1, r: parseInt(src.slice(j, k), 10) - 1 }, end: k };
}
/** Rewrite a raw formula string, skipping quoted string regions.
* Ranges (REF:REF) are rewritten as a unit: if either endpoint is
* deleted, the whole range becomes #REF!. */
static rewriteFormula(src, op) {
if (!src.startsWith('='))
return src;
let out = '=';
let i = 1;
const n = src.length;
while (i < n) {
const ch = src[i];
// quoted strings are copied verbatim
if (ch === '"' || ch === "'") {
const q = ch;
out += ch;
i++;
while (i < n) {
out += src[i];
i++;
if (src[i - 1] === q) {
if (i < n && src[i] === q) {
out += q;
i++;
continue;
} // escaped quote
break;
}
}
continue;
}
if (isA(ch)) {
const a = Sheet.readRef(src, i);
if (a) {
// range? ref ':' ref
let colon = a.end;
if (colon < n && src[colon] === '$')
colon++;
const b = (colon < n && src[colon] === ':' ? Sheet.readRef(src, colon + 1) : null);
if (b) {
const na = Sheet.shiftRef(a.ref, op);
const nb = Sheet.shiftRef(b.ref, op);
if (na.c < 0 || na.r < 0 || nb.c < 0 || nb.r < 0)
out += '#REF!';
else
out += (0, exports.refName)(na.c, na.r) + ':' + (0, exports.refName)(nb.c, nb.r);
i = b.end;
continue;
}
const nr = Sheet.shiftRef(a.ref, op);
out += (nr.c < 0 || nr.r < 0) ? '#REF!' : (0, exports.refName)(nr.c, nr.r);
i = a.end;
continue;
}
out += ch;
i++;
}
else {
out += ch;
i++;
}
}
return out;
}
shiftRows(index, dir) {
const oldC = this.cols, oldR = this.rows;
const newR = oldR + dir;
const next = new Array(oldC * newR).fill('');
for (let r = 0; r < oldR; r++) {
if (dir === -1 && r === index)
continue;
const nr = dir === 1 && r >= index ? r + 1 : r;
const shift = dir === -1 && r > index ? -1 : 0;
const dst = nr + shift;
for (let c = 0; c < oldC; c++)
next[dst * oldC + c] = this.raw[r * oldC + c];
}
this.raw = next;
this.rows = newR;
}
shiftCols(index, dir) {
const oldC = this.cols, oldR = this.rows;
const newC = oldC + dir;
const next = new Array(newC * oldR).fill('');
for (let r = 0; r < oldR; r++) {
for (let c = 0; c < oldC; c++) {
if (dir === -1 && c === index)
continue;
let nc = c;
if (dir === 1 && c >= index)
nc = c + 1;
else if (dir === -1 && c > index)
nc = c - 1;
next[r * newC + nc] = this.raw[r * oldC + c];
}
}
this.raw = next;
this.cols = newC;
}
structural(op) {
// 1) move content
if (op.kind === 'row')
this.shiftRows(op.index, op.op === 'ins' ? 1 : -1);
else
this.shiftCols(op.index, op.op === 'ins' ? 1 : -1);
// 2) rewrite formulas in the surviving grid
for (let r = 0; r < this.rows; r++)
for (let c = 0; c < this.cols; c++) {
const i = r * this.cols + c;
if (this.raw[i].startsWith('=')) {
const dir = op.op === 'ins' ? 1 : -1;
this.raw[i] = Sheet.rewriteFormula(this.raw[i], { kind: op.kind, dir, index: op.index });
}
}
// 3) fresh values array + full rebuild
this.vals = new Array(this.cols * this.rows).fill(emptyVal());
this.recalcAll();
this.persist();
this.emit({ changed: [], structural: op });
}
insertRow(index) {
if (index < 0 || index >= this.rows || this.rows >= config_1.MAX_ROWS)
return { changed: [], structural: null };
this.structural({ kind: 'row', op: 'ins', index });
return { changed: [], structural: { kind: 'row', op: 'ins', index } };
}
deleteRow(index) {
if (index < 0 || index >= this.rows || this.rows <= 1)
return { changed: [], structural: null };
this.structural({ kind: 'row', op: 'del', index });
return { changed: [], structural: { kind: 'row', op: 'del', index } };
}
insertCol(index) {
if (index < 0 || index >= this.cols || this.cols >= config_1.MAX_COLS)
return { changed: [], structural: null };
this.structural({ kind: 'col', op: 'ins', index });
return { changed: [], structural: { kind: 'col', op: 'ins', index } };
}
deleteCol(index) {
if (index < 0 || index >= this.cols || this.cols <= 1)
return { changed: [], structural: null };
this.structural({ kind: 'col', op: 'del', index });
return { changed: [], structural: { kind: 'col', op: 'del', index } };
}
/* ---------- persistence (raw formulas, not values) ---------- */
persist() {
if (this.persistT !== null)
window.clearTimeout(this.persistT);
this.persistT = window.setTimeout(() => {
try {
const data = { v: 1, cols: this.cols, rows: this.rows, raw: this.raw };
localStorage.setItem(LS_KEY, JSON.stringify(data));
this.onSave?.();
}
catch { /* storage unavailable: ignore */ }
}, 250);
}
load() {
try {
const s = localStorage.getItem(LS_KEY);
if (!s)
return;
const data = JSON.parse(s);
if (!data || data.v !== 1 || !Array.isArray(data.raw))
return;
this.cols = Math.min(Math.max(1, data.cols | 0), config_1.MAX_COLS);
this.rows = Math.min(Math.max(1, data.rows | 0), config_1.MAX_ROWS);
this.raw = new Array(this.cols * this.rows).fill('');
for (let i = 0; i < this.cols * this.rows; i++) {
const x = data.raw[i];
this.raw[i] = typeof x === 'string' ? x : '';
}
}
catch { /* corrupt save: start fresh */ }
}
/* ---------- events ---------- */
on(fn) { this.listeners.push(fn); }
emit(ch) { for (const fn of this.listeners)
fn(ch); }
}
exports.Sheet = Sheet;
function sameVal(a, b) {
if (a.t !== b.t)
return false;
if (a.t === 'err')
return b.t === 'err' && a.e === b.e;
if (a.t === 'num')
return b.t === 'num' && a.v === b.v;
return b.t === 'text' && a.s === b.s;
}
};
// ── module: src/ui.ts ──
__mods["src/ui.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ERR_CYCLE = exports.ERR_REF = exports.UI = void 0;
exports.fmtNum = fmtNum;
exports.isNumLike = isNumLike;
/* ============================================================
* UI — grid rendering (sticky headers), cell selection/editing,
* formula bar sync, keyboard handling, context menus, toolbar.
* ============================================================ */
const config_1 = require("./config");
const formula_1 = require("./formula");
Object.defineProperty(exports, "ERR_REF", { enumerable: true, get: function () { return formula_1.ERR_REF; } });
Object.defineProperty(exports, "ERR_CYCLE", { enumerable: true, get: function () { return formula_1.ERR_CYCLE; } });
const store_1 = require("./store");
class UI {
constructor(sheet) {
this.sheet = sheet;
this.sel = { c: 0, r: 0 };
this.editing = false;
this.editStartSel = { c: 0, r: 0 };
this.editVal = '';
this.table = null;
this.scroll = document.getElementById('scroll');
this.namebox = document.getElementById('namebox');
this.bar = document.getElementById('barinput');
this.menu = document.getElementById('ctxmenu');
this.saveInd = document.getElementById('save-ind');
this.buildTable();
this.bindGlobal();
sheet.on(ch => this.onChanged(ch));
this.refreshSelUI();
}
/* ---------- table construction ---------- */
buildTable() {
if (this.table)
this.table.remove();
const t = document.createElement('table');
t.className = 'grid';
const thead = document.createElement('thead');
const hr = document.createElement('tr');
const corner = document.createElement('th');
corner.className = 'corner';
corner.style.width = config_1.HEAD_W + 'px';
corner.style.left = '0';
hr.appendChild(corner);
for (let c = 0; c < this.sheet.cols; c++) {
const th = document.createElement('th');
th.dataset.c = String(c);
th.style.width = config_1.COL_WIDTH + 'px';
th.style.left = (config_1.HEAD_W + c * config_1.COL_WIDTH) + 'px';
th.textContent = (0, store_1.refName)(c, 0).replace(/[0-9]/g, '');
hr.appendChild(th);
}
thead.appendChild(hr);
t.appendChild(thead);
const tbody = document.createElement('tbody');
for (let r = 0; r < this.sheet.rows; r++) {
const tr = document.createElement('tr');
tr.style.height = config_1.CELL_H + 'px';
const th = document.createElement('th');
th.dataset.r = String(r);
th.textContent = String(r + 1);
tr.appendChild(th);
for (let c = 0; c < this.sheet.cols; c++) {
const td = document.createElement('td');
td.dataset.c = String(c);
td.dataset.r = String(r);
this.paintCell(td, this.sheet.valOf(c, r));
tr.appendChild(td);
}
tbody.appendChild(tr);
}
t.appendChild(tbody);
this.table = t;
this.scroll.appendChild(t);
this.bindGrid(t);
}
td(c, r) {
const tr = this.table.tBodies[0].rows[r];
return tr.cells[c + 1];
}
headC(c) {
return this.table.tHead.rows[0].cells[c + 1];
}
headR(r) {
return this.table.tBodies[0].rows[r].cells[0];
}
/* ---------- painting ---------- */
paintCell(td, v) {
td.classList.remove('num', 'neg', 'error');
if (v.t === 'num') {
// empty cells (raw "") display blank even though they compute to 0
const raw = this.sheet.getRaw(+td.dataset.c, +td.dataset.r);
if (raw === '' && v.v === 0) {
td.textContent = '';
return;
}
td.textContent = fmtNum(v.v);
td.classList.add('num');
if (v.v < 0)
td.classList.add('neg');
}
else if (v.t === 'text') {
td.textContent = v.s;
}
else {
td.textContent = v.e;
td.classList.add('error');
}
}
renderCell(c, r) {
if (!this.sheet.inBounds(c, r))
return;
this.paintCell(this.td(c, r), this.sheet.valOf(c, r));
}
renderAll() {
for (let r = 0; r < this.sheet.rows; r++)
for (let c = 0; c < this.sheet.cols; c++)
this.paintCell(this.td(c, r), this.sheet.valOf(c, r));
}
/* ---------- selection ---------- */
selectCell(c, r) {
if (this.editing)
this.commitEdit(true);
const old = this.sel;
this.sel = { c, r };
if (old.c >= 0) {
const ot = this.td(old.c, old.r);
ot.classList.remove('sel');
}
const td = this.td(c, r);
td.classList.add('sel');
this.namebox.textContent = (0, store_1.refName)(c, r);
this.bar.value = this.sheet.getRaw(c, r);
this.scrollIntoView(c, r);
}
refreshSelUI() {
const td = this.td(this.sel.c, this.sel.r);
td.classList.add('sel');
this.namebox.textContent = (0, store_1.refName)(this.sel.c, this.sel.r);
this.bar.value = this.sheet.getRaw(this.sel.c, this.sel.r);
this.scrollIntoView(this.sel.c, this.sel.r);
}
scrollIntoView(c, r) {
const el = this.td(c, r);
const s = this.scroll;
const rTop = r * config_1.CELL_H, rBot = (r + 1) * config_1.CELL_H;
if (rTop < s.scrollTop)
s.scrollTop = rTop;
else if (rBot > s.scrollTop + s.clientHeight - 24)
s.scrollTop = rBot - s.clientHeight + 24;
const lLeft = c * config_1.COL_WIDTH, lRight = (c + 1) * config_1.COL_WIDTH;
if (lLeft < s.scrollLeft)
s.scrollLeft = lLeft;
else if (lRight > s.scrollLeft + s.clientWidth - config_1.COL_WIDTH)
s.scrollLeft = lRight - s.clientWidth + config_1.COL_WIDTH;
}
/* ---------- editing ---------- */
startEdit(initial) {
const { c, r } = this.sel;
if (!this.sheet.inBounds(c, r))
return;
this.editStartSel = { ...this.sel };
const td = this.td(c, r);
if (this.editing)
return;
this.editing = true;
const v = this.sheet.valOf(c, r);
const init = initial !== undefined ? initial : (v.t === 'text' ? v.s : v.t === 'num' ? String(v.v) : '');
const input = document.createElement('input');
input.value = init;
input.className = 'num';
td.classList.add('editing');
td.appendChild(input);
input.focus();
if (v.t === 'text')
input.setSelectionRange(0, init.length);
else
input.select();
input.addEventListener('input', () => { this.bar.value = input.value; });
input.addEventListener('keydown', ev => this.onEditKey(ev, input));
input.addEventListener('blur', () => { if (this.editing)
this.commitEdit(true); });
}
onEditKey(ev, input) {
if (ev.key === 'Enter') {
ev.preventDefault();
this.commitEdit(true);
this.moveSel(1, 0);
}
else if (ev.key === 'Tab') {
ev.preventDefault();
this.commitEdit(true);
this.moveSel(0, ev.shiftKey ? -1 : 1);
}
else if (ev.key === 'Escape') {
ev.preventDefault();
this.cancelEdit();
}
else {
ev.stopPropagation(); // don't let arrows/delete hit the sheet handler
}
}
commitEdit(keepFocus) {
if (!this.editing)
return;
const { c, r } = this.editStartSel;
const td = this.td(c, r);
const input = td.querySelector('input');
const val = input ? input.value : '';
this.editing = false;
td.classList.remove('editing');
if (input)
input.remove();
const cur = this.sheet.getRaw(c, r);
if (val !== cur)
this.sheet.setRaw(c, r, val);
this.namebox.textContent = (0, store_1.refName)(c, r);
this.bar.value = this.sheet.getRaw(c, r);
if (!keepFocus)
this.bar.focus();
}
cancelEdit() {
if (!this.editing)
return;
const { c, r } = this.editStartSel;
const td = this.td(c, r);
const input = td.querySelector('input');
this.editing = false;
td.classList.remove('editing');
if (input)
input.remove();
this.bar.value = this.sheet.getRaw(c, r);
}
/* ---------- grid events ---------- */
bindGrid(t) {
t.addEventListener('click', ev => {
const target = ev.target;
const td = target.closest('td');
if (td && td.dataset.c !== undefined && td.dataset.r !== undefined) {
this.selectCell(+td.dataset.c, +td.dataset.r);
this.bar.focus();
return;
}
const th = target.closest('th');
if (th) {
if (th.dataset.c !== undefined)
this.selectCell(+th.dataset.c, Math.min(this.sel.r, this.sheet.rows - 1));
else if (th.dataset.r !== undefined)
this.selectCell(Math.min(this.sel.c, this.sheet.cols - 1), +th.dataset.r);
}
});
t.addEventListener('dblclick', ev => {
const target = ev.target;
const td = target.closest('td');
if (td && td.dataset.c !== undefined && td.dataset.r !== undefined) {
this.selectCell(+td.dataset.c, +td.dataset.r);
this.startEdit();
}
});
t.addEventListener('contextmenu', ev => {
ev.preventDefault();
const target = ev.target;
const td = target.closest('td');
const th = target.closest('th');
if (td && td.dataset.c !== undefined && td.dataset.r !== undefined) {
this.selectCell(+td.dataset.c, +td.dataset.r);
this.openMenu(ev.clientX, ev.clientY, 'cell');
}
else if (th && th.dataset.c !== undefined) {
this.openMenu(ev.clientX, ev.clientY, 'col', +th.dataset.c);
}
else if (th && th.dataset.r !== undefined) {
this.openMenu(ev.clientX, ev.clientY, 'row', +th.dataset.r);
}
});
}
/* ---------- context menu ---------- */
openMenu(x, y, kind, index = -1) {
const m = this.menu;
m.innerHTML = '';
const item = (label, fn) => {
const d = document.createElement('div');
d.className = 'mi';
d.textContent = label;
d.addEventListener('click', () => { this.closeMenu(); fn(); });
m.appendChild(d);
};
if (kind === 'cell') {
item('Clear contents', () => { this.sheet.setRaw(this.sel.c, this.sel.r, ''); });
const sep = document.createElement('div');
sep.className = 'sep';
m.appendChild(sep);
item('Insert row above', () => this.sheet.insertRow(this.sel.r));
item('Insert column left', () => this.sheet.insertCol(this.sel.c));
m.appendChild(sepClone());
item('Delete this row', () => this.sheet.deleteRow(this.sel.r));
item('Delete this column', () => this.sheet.deleteCol(this.sel.c));
}
else if (kind === 'row') {
item('Insert row above', () => this.sheet.insertRow(index));
m.appendChild(sepClone());
item('Delete this row', () => this.sheet.deleteRow(index));
}
else {
item('Insert column left', () => this.sheet.insertCol(index));
m.appendChild(sepClone());
item('Delete this column', () => this.sheet.deleteCol(index));
}
m.classList.add('open');
const mw = m.offsetWidth, mh = m.offsetHeight;
m.style.left = Math.min(x, window.innerWidth - mw - 6) + 'px';
m.style.top = Math.min(y, window.innerHeight - mh - 6) + 'px';
}
closeMenu() { this.menu.classList.remove('open'); }
/* ---------- global (sheet-level) keys ---------- */
bindGlobal() {
document.addEventListener('keydown', ev => this.onDocKey(ev));
document.addEventListener('click', ev => {
if (!this.menu.contains(ev.target))
this.closeMenu();
});
window.addEventListener('blur', () => this.closeMenu());
this.bar.addEventListener('focus', () => {
if (!this.editing)
this.bar.select();
});
this.bar.addEventListener('input', () => { });
this.bar.addEventListener('keydown', ev => {
if (ev.key === 'Enter') {
ev.preventDefault();
this.sheet.setRaw(this.sel.c, this.sel.r, this.bar.value);
this.moveSel(1, 0);
this.bar.focus();
}
else if (ev.key === 'Tab') {
ev.preventDefault();
this.sheet.setRaw(this.sel.c, this.sel.r, this.bar.value);
this.moveSel(0, ev.shiftKey ? -1 : 1);
this.bar.focus();
}
else if (ev.key === 'Escape') {
ev.preventDefault();
this.bar.value = this.sheet.getRaw(this.sel.c, this.sel.r);
}
});
// toolbar
const tb = document.getElementById('toolbar');
if (tb)
tb.addEventListener('click', ev => {
const b = ev.target.closest('button');
if (!b)
return;
const act = b.dataset.act;
if (act === 'ins-row')
this.sheet.insertRow(this.sel.r);
else if (act === 'del-row')
this.sheet.deleteRow(this.sel.r);
else if (act === 'ins-col')
this.sheet.insertCol(this.sel.c);
else if (act === 'del-col')
this.sheet.deleteCol(this.sel.c);
});
}
onDocKey(ev) {
const tag = ev.target.tagName;
if (this.editing)
return;
if (tag === 'INPUT') {
// typing in the name box or formula bar
if (ev.target.id === 'namebox') {
if (ev.key === 'Enter') {
ev.preventDefault();
const p = (0, store_1.parseRefName)(ev.target.value);
if (p && this.sheet.inBounds(p.c, p.r))
this.selectCell(p.c, p.r);
}
}
return; // formula bar handles its own keys
}
if (ev.key === 'ArrowUp') {
ev.preventDefault();
this.moveSel(-1, 0);
}
else if (ev.key === 'ArrowDown') {
ev.preventDefault();
this.moveSel(1, 0);
}
else if (ev.key === 'ArrowLeft') {
ev.preventDefault();
this.moveSel(0, -1);
}
else if (ev.key === 'ArrowRight') {
ev.preventDefault();
this.moveSel(0, 1);
}
else if (ev.key === 'Tab') {
ev.preventDefault();
this.moveSel(0, ev.shiftKey ? -1 : 1);
}
else if (ev.key === 'Enter') {
ev.preventDefault();
this.startEdit();
}
else if (ev.key === 'Delete' || ev.key === 'Backspace') {
ev.preventDefault();
this.sheet.setRaw(this.sel.c, this.sel.r, '');
}
else if (ev.key.length === 1 && !ev.ctrlKey && !ev.metaKey && !ev.altKey) {
// start typing → replace cell content
ev.preventDefault();
this.startEdit(ev.key);
}
}
moveSel(dr, dc) {
const c = Math.max(0, Math.min(this.sheet.cols - 1, this.sel.c + dc));
const r = Math.max(0, Math.min(this.sheet.rows - 1, this.sel.r + dr));
this.selectCell(c, r);
}
/* ---------- reacting to store changes ---------- */
onChanged(ch) {
if (ch.structural) {
this.buildTable();
this.renderAll();
const s = this.sel;
if (s.c >= this.sheet.cols || s.r >= this.sheet.rows)
this.sel = { c: 0, r: 0 };
this.refreshSelUI();
return;
}
for (const x of ch.changed)
this.renderCell(x.c, x.r);
// keep formula bar in sync if the selected cell changed
if (ch.changed.some(x => x.c === this.sel.c && x.r === this.sel.r) && !this.editing) {
this.bar.value = this.sheet.getRaw(this.sel.c, this.sel.r);
}
}
/* ---------- save indicator ---------- */
flashSave() {
this.saveInd.textContent = 'Saved';
this.saveInd.style.opacity = '1';
window.setTimeout(() => { this.saveInd.style.opacity = '0.55'; }, 1200);
}
}
exports.UI = UI;
function sepClone() {
const d = document.createElement('div');
d.className = 'sep';
return d;
}
/* ---------- number formatting ---------- */
function fmtNum(v) {
if (!isFinite(v))
return '#NUM!';
if (v === 0)
return '0';
const a = Math.abs(v);
if (a !== 0 && (a < 1e-9 || a >= 1e15))
return v.toExponential(8).replace(/\.?0+e/, 'e');
const s = String(v);
// JS may produce a long float for repeating binary fractions; round to 10 sig digits
if (s.length > 12) {
const r = parseFloat(v.toPrecision(10));
return String(r);
}
return s;
}
function isNumLike(v) {
return v.t === 'num' || (v.t === 'text' && v.s !== '' && Number.isFinite(Number(v.s)));
}
};
// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"unsloth/Qwen3.8-27B-GGUF","provider":"brigid","persona":"typescript","sessionId":"5a9f568a-b959-4443-890d-39c7f41f0244","tokensIn":5640692,"tokensOut":203665,"tokensTotal":5844357,"cost":0,"turns":86,"toolCalls":109,"failedToolCalls":3,"timestamp":"2026-08-15T11:07:12.993Z"} -->