Spreadsheet
Claude Opus 5 · 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 74,942 bytes · SHA-256 e0c9c1235d7c
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sheets — Spreadsheet</title>
<style>
/* ── css: base ── */
:root {
--gridline: #e1e3e6;
--gridline-strong: #c8ccd0;
--header-bg: #f8f9fa;
--header-fg: #5f6368;
--header-active-bg: #d3e3fd;
--sel: #1a73e8;
--err: #d93025;
--fg: #202124;
--chrome: #ffffff;
--chrome-alt: #f1f3f4;
--cell-w: 96px;
--cell-h: 25px;
--rowhead-w: 46px;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
height: 100%;
overflow: hidden;
background: var(--chrome);
color: var(--fg);
font: 13px/1.3 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
#app {
display: flex;
flex-direction: column;
height: 100%;
}
button { font: inherit; color: inherit; }
/* ── css: chrome ── */
#toolbar {
display: flex;
align-items: center;
gap: 16px;
padding: 8px 12px;
border-bottom: 1px solid var(--gridline);
background: var(--chrome);
flex: 0 0 auto;
}
.brand {
font-size: 15px;
font-weight: 500;
color: #3c4043;
letter-spacing: .2px;
display: flex;
align-items: center;
gap: 7px;
padding-right: 6px;
}
.brand .logo { color: #0f9d58; font-size: 17px; }
.tbgroup { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
.tbtn {
border: 1px solid transparent;
background: var(--chrome-alt);
border-radius: 6px;
padding: 5px 11px;
cursor: pointer;
color: #3c4043;
transition: background .12s, box-shadow .12s;
white-space: nowrap;
}
.tbtn:hover { background: #e4e7ea; }
.tbtn:active { background: #dadce0; }
.tbtn:focus-visible { outline: 2px solid var(--sel); outline-offset: 1px; }
.tbtn.danger { color: #b3261e; }
.tbtn.danger:hover { background: #fce8e6; }
.tsep { width: 1px; height: 20px; background: var(--gridline); margin: 0 5px; }
.status {
margin-left: auto;
font-size: 12px;
color: #80868b;
min-width: 130px;
text-align: right;
white-space: nowrap;
}
/* ---- formula bar ---- */
#fbar {
display: flex;
align-items: stretch;
gap: 0;
background: var(--chrome);
border-bottom: 1px solid var(--gridline-strong);
padding: 5px 12px 6px;
flex: 0 0 auto;
}
.cellref {
min-width: 62px;
padding: 0 8px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 12.5px;
color: #3c4043;
background: var(--chrome-alt);
border: 1px solid var(--gridline);
border-radius: 4px;
}
.fx {
display: flex;
align-items: center;
padding: 0 12px;
color: #80868b;
font-style: italic;
font-family: Georgia, serif;
font-size: 14px;
border-right: 1px solid var(--gridline);
margin-right: 8px;
}
.finput {
flex: 1;
height: 26px;
border: 1px solid transparent;
border-radius: 4px;
padding: 0 8px;
font: 13px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
background: #fff;
color: var(--fg);
}
.finput:focus { outline: none; border-color: var(--sel); box-shadow: 0 0 0 1px var(--sel) inset; }
/* ── css: grid ── */
#gridwrap {
flex: 1 1 auto;
overflow: auto;
background: #fff;
position: relative;
outline: none;
}
#grid {
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
width: max-content;
font-variant-numeric: tabular-nums;
}
#grid th, #grid td {
padding: 0;
margin: 0;
height: var(--cell-h);
border-right: 1px solid var(--gridline);
border-bottom: 1px solid var(--gridline);
overflow: hidden;
white-space: nowrap;
}
/* column headers */
#grid thead th {
position: sticky;
top: 0;
z-index: 3;
width: var(--cell-w);
background: var(--header-bg);
color: var(--header-fg);
font-weight: 500;
font-size: 11.5px;
text-align: center;
cursor: pointer;
user-select: none;
border-bottom: 1px solid var(--gridline-strong);
}
#grid thead th.corner {
left: 0;
z-index: 5;
width: var(--rowhead-w);
min-width: var(--rowhead-w);
border-right: 1px solid var(--gridline-strong);
cursor: default;
}
#grid thead th.hl { background: var(--header-active-bg); color: #174ea6; }
/* row headers */
#grid tbody th {
position: sticky;
left: 0;
z-index: 2;
width: var(--rowhead-w);
min-width: var(--rowhead-w);
background: var(--header-bg);
color: var(--header-fg);
font-weight: 400;
font-size: 11.5px;
text-align: center;
cursor: pointer;
user-select: none;
border-right: 1px solid var(--gridline-strong);
}
#grid tbody th.hl { background: var(--header-active-bg); color: #174ea6; }
/* data cells */
#grid td {
width: var(--cell-w);
min-width: var(--cell-w);
max-width: var(--cell-w);
padding: 0 4px;
text-align: left;
color: var(--fg);
cursor: cell;
position: relative;
text-overflow: clip;
line-height: calc(var(--cell-h) - 1px);
}
#grid td.num { text-align: right; }
#grid td.err { color: var(--err); text-align: center; font-weight: 500; }
/* selection */
#grid td.sel {
outline: 2px solid var(--sel);
outline-offset: -2px;
z-index: 1;
box-shadow: 0 0 0 1px rgba(26,115,232,.18);
}
#grid td.sel::after {
content: "";
position: absolute;
right: -3px;
bottom: -3px;
width: 6px;
height: 6px;
background: var(--sel);
border: 1px solid #fff;
z-index: 4;
}
/* in-cell editor */
#grid td.editing { outline: 2px solid var(--sel); outline-offset: -2px; padding: 0; overflow: visible; z-index: 6; }
#grid td.editing::after { display: none; }
.celledit {
position: absolute;
left: 0;
top: 0;
min-width: 100%;
width: 100%;
height: 100%;
border: none;
padding: 0 3px;
margin: 0;
font: 13px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
background: #fff;
color: var(--fg);
outline: none;
box-shadow: 0 1px 6px rgba(60,64,67,.28);
z-index: 7;
}
.ctxmenu {
position: fixed;
z-index: 50;
background: #fff;
border: 1px solid var(--gridline);
border-radius: 6px;
box-shadow: 0 4px 14px rgba(60,64,67,.24);
padding: 5px 0;
min-width: 190px;
}
.ctxmenu button {
display: block;
width: 100%;
text-align: left;
background: none;
border: none;
padding: 7px 16px;
cursor: pointer;
font-size: 13px;
white-space: nowrap;
}
.ctxmenu button:hover { background: var(--chrome-alt); }
</style>
</head>
<body>
<div id="app">
<header id="toolbar">
<div class="brand"><span class="logo">▦</span> Sheets</div>
<div class="tbgroup">
<button id="btn-ins-row" class="tbtn" title="Insert a row above the selection">Insert row</button>
<button id="btn-del-row" class="tbtn" title="Delete the selected row">Delete row</button>
<span class="tsep"></span>
<button id="btn-ins-col" class="tbtn" title="Insert a column left of the selection">Insert col</button>
<button id="btn-del-col" class="tbtn" title="Delete the selected column">Delete col</button>
<span class="tsep"></span>
<button id="btn-clear" class="tbtn" title="Clear the selected cell (Delete)">Clear</button>
<button id="btn-reset" class="tbtn danger" title="Erase the whole sheet">Reset sheet</button>
</div>
<div id="status" class="status"></div>
</header>
<div id="fbar">
<div id="cellref" class="cellref">A1</div>
<div class="fx">fx</div>
<input id="finput" class="finput" type="text" spellcheck="false" autocomplete="off" aria-label="Formula bar">
</div>
<div id="gridwrap">
<table id="grid"><thead></thead><tbody></tbody></table>
</div>
<div id="ctxmenu" class="ctxmenu" hidden=""></div>
</div>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {"src/main.ts":{"./app":"src/app.ts"},"src/refs.ts":{"./config":"src/config.ts"},"src/lexer.ts":{"./ast":"src/ast.ts"},"src/parser.ts":{"./ast":"src/ast.ts","./lexer":"src/lexer.ts","./refs":"src/refs.ts"},"src/printer.ts":{"./ast":"src/ast.ts","./refs":"src/refs.ts"},"src/value.ts":{"./ast":"src/ast.ts","./config":"src/config.ts"},"src/eval.ts":{"./ast":"src/ast.ts","./value":"src/value.ts","./refs":"src/refs.ts"},"src/rewrite.ts":{"./ast":"src/ast.ts"},"src/sheet.ts":{"./config":"src/config.ts","./ast":"src/ast.ts","./parser":"src/parser.ts","./printer":"src/printer.ts","./rewrite":"src/rewrite.ts","./eval":"src/eval.ts","./value":"src/value.ts","./refs":"src/refs.ts"},"src/view.ts":{"./config":"src/config.ts","./refs":"src/refs.ts","./value":"src/value.ts","./sheet":"src/sheet.ts"},"src/app.ts":{"./config":"src/config.ts","./refs":"src/refs.ts","./sheet":"src/sheet.ts","./view":"src/view.ts","./rewrite":"src/rewrite.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";
/* ============================================================
* MAIN — bootstrap
* ============================================================ */
Object.defineProperty(exports, "__esModule", { value: true });
exports.start = start;
const app_1 = require("./app");
const DEMO = {
A1: 'Item', B1: 'Qty', C1: 'Price', D1: 'Total',
A2: 'Widget', B2: '4', C2: '2.5', D2: '=B2*C2',
A3: 'Gadget', B3: '10', C3: '1.75', D3: '=B3*C3',
A4: 'Doohickey', B4: '3', C4: '9.99', D4: '=B4*C4',
A5: 'Sprocket', B5: '7', C5: '4', D5: '=B5*C5',
A7: 'Subtotal', D7: '=SUM(D2:D5)',
A8: 'Tax (8%)', D8: '=D7*0.08',
A9: 'Grand total', D9: '=D7+D8',
F1: 'Stats', F2: 'Count', G2: '=COUNT(D2:D5)',
F3: 'Average', G3: '=AVG(D2:D5)',
F4: 'Min', G4: '=MIN(D2:D5)',
F5: 'Max', G5: '=MAX(D2:D5)',
F7: 'Chain', G7: '5', H7: '=G7*2', I7: '=H7+1',
};
function start() {
const app = new app_1.App();
if (app.sheet.allKeys().length === 0) {
app.sheet.loadJSON(DEMO);
app.view.paintAll();
app.select(0, 0);
app.sheet.save();
app.setStatus('Loaded sample sheet');
}
window.app = app;
}
start();
};
// ── module: src/config.ts ──
__mods["src/config.ts"] = function (exports, require, module) {
"use strict";
/* ============================================================
* CONFIG — tunable constants
* ============================================================ */
Object.defineProperty(exports, "__esModule", { value: true });
exports.COLORS = exports.CONFIG = void 0;
exports.CONFIG = {
COLS: 26, // number of columns (A..Z)
ROWS: 100, // number of rows
CELL_W: 96, // px, data column width
CELL_H: 25, // px, row height
ROW_HEAD_W: 46, // px, width of the row-number gutter
STORAGE_KEY: 'vanilla-sheets:v1',
SAVE_DEBOUNCE_MS: 200,
MAX_DIGITS: 12, // significant digits used when formatting numbers
};
exports.COLORS = {
gridline: '#e1e3e6',
headerBg: '#f8f9fa',
headerFg: '#5f6368',
headerActiveBg: '#d3e3fd',
selection: '#1a73e8',
error: '#d93025',
text: '#202124',
};
};
// ── module: src/refs.ts ──
__mods["src/refs.ts"] = function (exports, require, module) {
"use strict";
/* ============================================================
* REFS — cell coordinate <-> A1 name helpers
* col and row are ZERO-BASED internally.
* ============================================================ */
Object.defineProperty(exports, "__esModule", { value: true });
exports.colName = colName;
exports.colIndex = colIndex;
exports.keyOf = keyOf;
exports.parseKey = parseKey;
exports.inBounds = inBounds;
exports.clampCol = clampCol;
exports.clampRow = clampRow;
const config_1 = require("./config");
function colName(col) {
let n = col, s = '';
do {
s = String.fromCharCode(65 + (n % 26)) + s;
n = Math.floor(n / 26) - 1;
} while (n >= 0);
return s;
}
function colIndex(name) {
let n = 0;
const up = name.toUpperCase();
for (let i = 0; i < up.length; i++)
n = n * 26 + (up.charCodeAt(i) - 64);
return n - 1;
}
/** "A1" for col 0, row 0 */
function keyOf(col, row) {
return colName(col) + (row + 1);
}
function parseKey(key) {
const m = /^([A-Za-z]+)([0-9]+)$/.exec(key.trim());
if (!m)
return null;
const col = colIndex(m[1]);
const row = parseInt(m[2], 10) - 1;
if (row < 0)
return null;
return { col, row };
}
function inBounds(col, row) {
return col >= 0 && row >= 0 && col < config_1.CONFIG.COLS && row < config_1.CONFIG.ROWS;
}
function clampCol(c) {
return Math.max(0, Math.min(config_1.CONFIG.COLS - 1, c));
}
function clampRow(r) {
return Math.max(0, Math.min(config_1.CONFIG.ROWS - 1, r));
}
};
// ── module: src/ast.ts ──
__mods["src/ast.ts"] = function (exports, require, module) {
"use strict";
/* ============================================================
* AST — node types for parsed formulas
* ============================================================ */
Object.defineProperty(exports, "__esModule", { value: true });
};
// ── module: src/lexer.ts ──
__mods["src/lexer.ts"] = function (exports, require, module) {
"use strict";
/* ============================================================
* LEXER — tokenizer for the formula language
* ============================================================ */
Object.defineProperty(exports, "__esModule", { value: true });
exports.LexError = void 0;
exports.tokenize = tokenize;
const ERRORS = ['#REF!', '#DIV/0!', '#CYCLE!', '#VALUE!', '#NAME?', '#ERR!'];
class LexError extends Error {
}
exports.LexError = LexError;
function tokenize(src) {
const out = [];
let i = 0;
const n = src.length;
while (i < n) {
const c = src[i];
// whitespace
if (c === ' ' || c === '\t' || c === '\n' || c === '\r') {
i++;
continue;
}
// number: 12, 12.5, .5, 1e-3
if (/[0-9]/.test(c) || (c === '.' && /[0-9]/.test(src[i + 1] ?? ''))) {
const start = i;
while (i < n && /[0-9]/.test(src[i]))
i++;
if (src[i] === '.') {
i++;
while (i < n && /[0-9]/.test(src[i]))
i++;
}
if (src[i] === 'e' || src[i] === 'E') {
const save = i;
i++;
if (src[i] === '+' || src[i] === '-')
i++;
if (/[0-9]/.test(src[i] ?? '')) {
while (i < n && /[0-9]/.test(src[i]))
i++;
}
else
i = save;
}
const s = src.slice(start, i);
out.push({ t: 'num', s, n: parseFloat(s), pos: start });
continue;
}
// quoted string
if (c === '"') {
const start = i;
i++;
let val = '';
let closed = false;
while (i < n) {
if (src[i] === '"') {
if (src[i + 1] === '"') {
val += '"';
i += 2;
continue;
}
i++;
closed = true;
break;
}
val += src[i++];
}
if (!closed)
throw new LexError('unterminated string');
out.push({ t: 'str', s: val, pos: start });
continue;
}
// error literal
if (c === '#') {
const rest = src.slice(i).toUpperCase();
const hit = ERRORS.find((e) => rest.startsWith(e));
if (!hit)
throw new LexError('bad error literal');
out.push({ t: 'err', s: hit, pos: i });
i += hit.length;
continue;
}
// reference or identifier
if (/[A-Za-z_$]/.test(c)) {
const start = i;
// try absolute/relative reference: $?A$?1
const m = /^(\$?)([A-Za-z]{1,3})(\$?)([0-9]{1,7})(?![A-Za-z0-9_.])/.exec(src.slice(i));
if (m) {
out.push({ t: 'ref', s: m[0], pos: start });
i += m[0].length;
continue;
}
while (i < n && /[A-Za-z0-9_.]/.test(src[i]))
i++;
out.push({ t: 'ident', s: src.slice(start, i), pos: start });
continue;
}
if (c === '$') {
const m = /^(\$)([A-Za-z]{1,3})(\$?)([0-9]{1,7})(?![A-Za-z0-9_.])/.exec(src.slice(i));
if (m) {
out.push({ t: 'ref', s: m[0], pos: i });
i += m[0].length;
continue;
}
throw new LexError('unexpected $');
}
if (c === '(') {
out.push({ t: 'lparen', s: c, pos: i++ });
continue;
}
if (c === ')') {
out.push({ t: 'rparen', s: c, pos: i++ });
continue;
}
if (c === ',' || c === ';') {
out.push({ t: 'comma', s: ',', pos: i++ });
continue;
}
if (c === ':') {
out.push({ t: 'colon', s: c, pos: i++ });
continue;
}
// operators
const two = src.substr(i, 2);
if (two === '<=' || two === '>=' || two === '<>') {
out.push({ t: 'op', s: two, pos: i });
i += 2;
continue;
}
if ('+-*/^=<>&'.includes(c)) {
out.push({ t: 'op', s: c, pos: i++ });
continue;
}
throw new LexError('unexpected character "' + c + '"');
}
out.push({ t: 'eof', s: '', pos: n });
return out;
}
};
// ── module: src/parser.ts ──
__mods["src/parser.ts"] = function (exports, require, module) {
"use strict";
/* ============================================================
* PARSER — recursive descent, precedence climbing
*
* expr := compare
* compare := concat (( = | <> | < | > | <= | >= ) concat)*
* concat := additive ( & additive )*
* additive:= term (( + | - ) term)*
* term := power (( * | / ) power)*
* power := unary ( ^ unary )*
* unary := (+|-) unary | postfix
* primary := num | str | err | ref [ : ref ] | ident '(' args ')' | '(' expr ')'
* ============================================================ */
Object.defineProperty(exports, "__esModule", { value: true });
exports.ParseError = void 0;
exports.parseFormula = parseFormula;
exports.collectRefs = collectRefs;
const lexer_1 = require("./lexer");
const refs_1 = require("./refs");
class ParseError extends Error {
}
exports.ParseError = ParseError;
class Parser {
constructor(toks) {
this.i = 0;
this.toks = toks;
}
peek() { return this.toks[this.i]; }
next() { return this.toks[this.i++]; }
isOp(...ops) {
const t = this.peek();
return t.t === 'op' && ops.includes(t.s);
}
expect(t, what) {
const tok = this.peek();
if (tok.t !== t)
throw new ParseError('expected ' + what);
return this.next();
}
parseProgram() {
const n = this.parseExpr();
if (this.peek().t !== 'eof')
throw new ParseError('unexpected trailing input');
return n;
}
parseExpr() { return this.parseCompare(); }
parseCompare() {
let a = this.parseConcat();
while (this.isOp('=', '<>', '<', '>', '<=', '>=')) {
const op = this.next().s;
a = { k: 'binary', op, a, b: this.parseConcat() };
}
return a;
}
parseConcat() {
let a = this.parseAdditive();
while (this.isOp('&')) {
this.next();
a = { k: 'binary', op: '&', a, b: this.parseAdditive() };
}
return a;
}
parseAdditive() {
let a = this.parseTerm();
while (this.isOp('+', '-')) {
const op = this.next().s;
a = { k: 'binary', op, a, b: this.parseTerm() };
}
return a;
}
parseTerm() {
let a = this.parsePower();
while (this.isOp('*', '/')) {
const op = this.next().s;
a = { k: 'binary', op, a, b: this.parsePower() };
}
return a;
}
parsePower() {
// left-associative, matching spreadsheet convention (2^3^2 = 64)
let a = this.parseUnary();
while (this.isOp('^')) {
this.next();
a = { k: 'binary', op: '^', a, b: this.parseUnary() };
}
return a;
}
parseUnary() {
if (this.isOp('-', '+')) {
const op = this.next().s;
return { k: 'unary', op, a: this.parseUnary() };
}
return this.parsePrimary();
}
parsePrimary() {
const t = this.peek();
switch (t.t) {
case 'num':
this.next();
return { k: 'num', v: t.n };
case 'str':
this.next();
return { k: 'str', v: t.s };
case 'err':
this.next();
return { k: 'err', v: t.s };
case 'ref': {
this.next();
const a = refFromToken(t.s);
if (this.peek().t === 'colon') {
this.next();
const bt = this.peek();
if (bt.t !== 'ref')
throw new ParseError('expected reference after ":"');
this.next();
return { k: 'range', a, b: refFromToken(bt.s) };
}
return a;
}
case 'ident': {
this.next();
const name = t.s.toUpperCase();
if (this.peek().t === 'lparen') {
this.next();
const args = [];
if (this.peek().t !== 'rparen') {
args.push(this.parseExpr());
while (this.peek().t === 'comma') {
this.next();
args.push(this.parseExpr());
}
}
this.expect('rparen', '")"');
return { k: 'call', name, args };
}
if (name === 'TRUE')
return { k: 'num', v: 1 };
if (name === 'FALSE')
return { k: 'num', v: 0 };
throw new ParseError('unknown name ' + name);
}
case 'lparen': {
this.next();
const inner = this.parseExpr();
this.expect('rparen', '")"');
return { k: 'paren', a: inner };
}
default:
throw new ParseError('unexpected ' + (t.t === 'eof' ? 'end of formula' : '"' + t.s + '"'));
}
}
}
function refFromToken(s) {
const m = /^(\$?)([A-Za-z]{1,3})(\$?)([0-9]{1,7})$/.exec(s);
if (!m)
throw new ParseError('bad reference ' + s);
return {
k: 'ref',
col: (0, refs_1.colIndex)(m[2]),
row: parseInt(m[4], 10) - 1,
absCol: m[1] === '$',
absRow: m[3] === '$',
};
}
/** Parse the body of a formula (text AFTER the leading "="). Throws ParseError. */
function parseFormula(body) {
let toks;
try {
toks = (0, lexer_1.tokenize)(body);
}
catch (e) {
if (e instanceof lexer_1.LexError)
throw new ParseError(e.message);
throw e;
}
if (toks.length === 1)
throw new ParseError('empty formula');
return new Parser(toks).parseProgram();
}
/** Collect every ref/range in an AST. */
function collectRefs(n, out = []) {
switch (n.k) {
case 'ref':
case 'range':
out.push(n);
break;
case 'unary':
collectRefs(n.a, out);
break;
case 'paren':
collectRefs(n.a, out);
break;
case 'binary':
collectRefs(n.a, out);
collectRefs(n.b, out);
break;
case 'call':
for (const a of n.args)
collectRefs(a, out);
break;
default: break;
}
return out;
}
};
// ── module: src/printer.ts ──
__mods["src/printer.ts"] = function (exports, require, module) {
"use strict";
/* ============================================================
* PRINTER — AST back to source text (used when formulas are
* rewritten after row/column insert & delete)
* ============================================================ */
Object.defineProperty(exports, "__esModule", { value: true });
exports.printRef = printRef;
exports.printNode = printNode;
const refs_1 = require("./refs");
function printRef(r) {
if (r.col < 0 || r.row < 0)
return '#REF!';
return (r.absCol ? '$' : '') + (0, refs_1.colName)(r.col) + (r.absRow ? '$' : '') + String(r.row + 1);
}
function printNode(n) {
switch (n.k) {
case 'num': return String(n.v);
case 'str': return '"' + n.v.replace(/"/g, '""') + '"';
case 'err': return n.v;
case 'ref': return printRef(n);
case 'range': {
const a = printRef(n.a), b = printRef(n.b);
if (a === '#REF!' || b === '#REF!')
return '#REF!';
return a + ':' + b;
}
case 'unary': return n.op + printNode(n.a);
case 'paren': return '(' + printNode(n.a) + ')';
case 'binary': return printNode(n.a) + n.op + printNode(n.b);
case 'call': return n.name + '(' + n.args.map(printNode).join(', ') + ')';
}
}
};
// ── module: src/value.ts ──
__mods["src/value.ts"] = function (exports, require, module) {
"use strict";
/* ============================================================
* VALUE — the runtime value model + coercion + formatting
* ============================================================ */
Object.defineProperty(exports, "__esModule", { value: true });
exports.err = exports.str = exports.num = exports.BLANK = void 0;
exports.isErr = isErr;
exports.toNum = toNum;
exports.toStr = toStr;
exports.formatNumber = formatNumber;
exports.display = display;
exports.literalOf = literalOf;
const config_1 = require("./config");
exports.BLANK = { t: 'blank' };
const num = (v) => Number.isFinite(v) ? { t: 'num', v } : { t: 'err', v: '#VALUE!' };
exports.num = num;
const str = (v) => ({ t: 'str', v });
exports.str = str;
const err = (v) => ({ t: 'err', v });
exports.err = err;
function isErr(v) { return v.t === 'err'; }
/** Coerce to number for arithmetic. Returns an error Value on failure. */
function toNum(v) {
switch (v.t) {
case 'num': return { ok: true, n: v.v };
case 'bool': return { ok: true, n: v.v ? 1 : 0 };
case 'blank': return { ok: true, n: 0 };
case 'err': return { ok: false, e: v };
case 'str': {
const s = v.v.trim();
if (s === '')
return { ok: true, n: 0 };
const n = Number(s);
if (Number.isFinite(n))
return { ok: true, n };
return { ok: false, e: (0, exports.err)('#VALUE!') };
}
}
}
function toStr(v) {
switch (v.t) {
case 'num': return formatNumber(v.v);
case 'str': return v.v;
case 'bool': return v.v ? 'TRUE' : 'FALSE';
case 'err': return v.v;
case 'blank': return '';
}
}
/** Compact, spreadsheet-ish number formatting. */
function formatNumber(n) {
if (!Number.isFinite(n))
return n > 0 ? '#DIV/0!' : '#DIV/0!';
if (Number.isInteger(n) && Math.abs(n) < 1e15)
return String(n);
const abs = Math.abs(n);
if (abs !== 0 && (abs < 1e-9 || abs >= 1e12))
return n.toExponential(6).replace(/e([+-])(\d)$/, 'e$10$2');
let s = n.toPrecision(config_1.CONFIG.MAX_DIGITS);
if (s.includes('.'))
s = s.replace(/0+$/, '').replace(/\.$/, '');
return s;
}
/** How a value renders in a cell. */
function display(v) {
switch (v.t) {
case 'num': return { text: formatNumber(v.v), cls: 'num' };
case 'bool': return { text: v.v ? 'TRUE' : 'FALSE', cls: 'text' };
case 'err': return { text: v.v, cls: 'err' };
case 'str': return { text: v.v, cls: 'text' };
case 'blank': return { text: '', cls: 'blank' };
}
}
/** Parse raw user input that is NOT a formula. */
function literalOf(raw) {
if (raw === '')
return exports.BLANK;
const s = raw.trim();
if (s !== '' && /^[-+]?(\d+\.?\d*|\.\d+)([eE][-+]?\d+)?$/.test(s)) {
const n = Number(s);
if (Number.isFinite(n))
return { t: 'num', v: n };
}
if (/^-?\d+(\.\d+)?%$/.test(s)) {
return { t: 'num', v: Number(s.slice(0, -1)) / 100 };
}
return { t: 'str', v: raw };
}
};
// ── module: src/eval.ts ──
__mods["src/eval.ts"] = function (exports, require, module) {
"use strict";
/* ============================================================
* EVAL — AST evaluation against a value provider
* ============================================================ */
Object.defineProperty(exports, "__esModule", { value: true });
exports.evalNode = evalNode;
exports.refKeys = refKeys;
const value_1 = require("./value");
const refs_1 = require("./refs");
/** Flatten one argument node into a list of values (ranges expand). */
function argValues(n, src) {
if (n.k === 'range')
return rangeValues(n.a, n.b, src);
return [evalNode(n, src)];
}
function rangeValues(a, b, src) {
if (a.col < 0 || a.row < 0 || b.col < 0 || b.row < 0)
return [(0, value_1.err)('#REF!')];
if (!(0, refs_1.inBounds)(a.col, a.row) || !(0, refs_1.inBounds)(b.col, b.row))
return [(0, value_1.err)('#REF!')];
const c0 = Math.min(a.col, b.col), c1 = Math.max(a.col, b.col);
const r0 = Math.min(a.row, b.row), r1 = Math.max(a.row, b.row);
const out = [];
for (let r = r0; r <= r1; r++)
for (let c = c0; c <= c1; c++)
out.push(src.valueAt(c, r));
return out;
}
/** Numbers only, skipping blanks/text — the SUM/AVG convention. */
function numericArgs(args, src) {
const ns = [];
for (const a of args) {
const vals = argValues(a, src);
const fromRange = a.k === 'range';
for (const v of vals) {
if (v.t === 'err')
return { ok: false, e: v };
if (v.t === 'blank')
continue;
if (v.t === 'str') {
if (fromRange)
continue; // text in a range is ignored
const c = (0, value_1.toNum)(v);
if (!c.ok)
return { ok: false, e: c.e };
ns.push(c.n);
continue;
}
const c = (0, value_1.toNum)(v);
if (!c.ok)
return { ok: false, e: c.e };
ns.push(c.n);
}
}
return { ok: true, ns };
}
/* ---------- functions ---------- */
function callFn(name, args, src) {
switch (name) {
case 'SUM': {
const r = numericArgs(args, src);
if (!r.ok)
return r.e;
return (0, value_1.num)(r.ns.reduce((a, b) => a + b, 0));
}
case 'AVG':
case 'AVERAGE': {
const r = numericArgs(args, src);
if (!r.ok)
return r.e;
if (r.ns.length === 0)
return (0, value_1.err)('#DIV/0!');
return (0, value_1.num)(r.ns.reduce((a, b) => a + b, 0) / r.ns.length);
}
case 'MIN': {
const r = numericArgs(args, src);
if (!r.ok)
return r.e;
if (r.ns.length === 0)
return (0, value_1.num)(0);
return (0, value_1.num)(Math.min(...r.ns));
}
case 'MAX': {
const r = numericArgs(args, src);
if (!r.ok)
return r.e;
if (r.ns.length === 0)
return (0, value_1.num)(0);
return (0, value_1.num)(Math.max(...r.ns));
}
case 'COUNT': {
const r = numericArgs(args, src);
if (!r.ok)
return r.e;
return (0, value_1.num)(r.ns.length);
}
case 'COUNTA': {
let n = 0;
for (const a of args)
for (const v of argValues(a, src))
if (v.t !== 'blank')
n++;
return (0, value_1.num)(n);
}
case 'ABS':
case 'ROUND':
case 'SQRT':
case 'INT':
case 'POWER':
case 'MOD': {
const r = numericArgs(args, src);
if (!r.ok)
return r.e;
const [x, y] = r.ns;
if (x === undefined)
return (0, value_1.err)('#ERR!');
if (name === 'ABS')
return (0, value_1.num)(Math.abs(x));
if (name === 'SQRT')
return x < 0 ? (0, value_1.err)('#VALUE!') : (0, value_1.num)(Math.sqrt(x));
if (name === 'INT')
return (0, value_1.num)(Math.floor(x));
if (name === 'ROUND') {
const d = y === undefined ? 0 : Math.trunc(y);
const f = Math.pow(10, d);
return (0, value_1.num)(Math.round(x * f) / f);
}
if (name === 'POWER')
return y === undefined ? (0, value_1.err)('#ERR!') : (0, value_1.num)(Math.pow(x, y));
/* MOD */
if (y === undefined)
return (0, value_1.err)('#ERR!');
if (y === 0)
return (0, value_1.err)('#DIV/0!');
return (0, value_1.num)(x - y * Math.floor(x / y));
}
case 'IF': {
if (args.length < 2)
return (0, value_1.err)('#ERR!');
const c = evalNode(args[0], src);
if (c.t === 'err')
return c;
const cn = (0, value_1.toNum)(c);
const truthy = c.t === 'str' ? c.v !== '' : (cn.ok ? cn.n !== 0 : true);
if (truthy)
return evalNode(args[1], src);
return args.length > 2 ? evalNode(args[2], src) : { t: 'bool', v: false };
}
case 'CONCAT':
case 'CONCATENATE': {
let s = '';
for (const a of args)
for (const v of argValues(a, src)) {
if (v.t === 'err')
return v;
s += (0, value_1.toStr)(v);
}
return (0, value_1.str)(s);
}
case 'LEN': {
if (args.length !== 1)
return (0, value_1.err)('#ERR!');
const v = evalNode(args[0], src);
if (v.t === 'err')
return v;
return (0, value_1.num)((0, value_1.toStr)(v).length);
}
default:
return (0, value_1.err)('#NAME?');
}
}
/* ---------- core ---------- */
function evalNode(n, src) {
switch (n.k) {
case 'num': return { t: 'num', v: n.v };
case 'str': return { t: 'str', v: n.v };
case 'err': return { t: 'err', v: n.v };
case 'paren': return evalNode(n.a, src);
case 'ref': {
if (n.col < 0 || n.row < 0 || !(0, refs_1.inBounds)(n.col, n.row))
return (0, value_1.err)('#REF!');
return src.valueAt(n.col, n.row);
}
case 'range': {
// A bare range outside a function collapses to its first cell's value.
const vs = rangeValues(n.a, n.b, src);
return vs.length ? vs[0] : value_1.BLANK;
}
case 'unary': {
const v = evalNode(n.a, src);
if (v.t === 'err')
return v;
const c = (0, value_1.toNum)(v);
if (!c.ok)
return c.e;
return (0, value_1.num)(n.op === '-' ? -c.n : c.n);
}
case 'call': return callFn(n.name, n.args, src);
case 'binary': {
const a = evalNode(n.a, src);
if (a.t === 'err')
return a;
const b = evalNode(n.b, src);
if (b.t === 'err')
return b;
if (n.op === '&')
return (0, value_1.str)((0, value_1.toStr)(a) + (0, value_1.toStr)(b));
if (n.op === '=' || n.op === '<>' || n.op === '<' || n.op === '>' || n.op === '<=' || n.op === '>=') {
return compare(n.op, a, b);
}
const ca = (0, value_1.toNum)(a);
if (!ca.ok)
return ca.e;
const cb = (0, value_1.toNum)(b);
if (!cb.ok)
return cb.e;
switch (n.op) {
case '+': return (0, value_1.num)(ca.n + cb.n);
case '-': return (0, value_1.num)(ca.n - cb.n);
case '*': return (0, value_1.num)(ca.n * cb.n);
case '/': return cb.n === 0 ? (0, value_1.err)('#DIV/0!') : (0, value_1.num)(ca.n / cb.n);
case '^': return (0, value_1.num)(Math.pow(ca.n, cb.n));
}
return (0, value_1.err)('#ERR!');
}
}
}
function compare(op, a, b) {
let r;
if (a.t === 'str' || b.t === 'str') {
const sa = (0, value_1.toStr)(a).toLowerCase(), sb = (0, value_1.toStr)(b).toLowerCase();
r = sa < sb ? -1 : sa > sb ? 1 : 0;
}
else {
const ca = (0, value_1.toNum)(a);
if (!ca.ok)
return ca.e;
const cb = (0, value_1.toNum)(b);
if (!cb.ok)
return cb.e;
r = ca.n < cb.n ? -1 : ca.n > cb.n ? 1 : 0;
}
const res = op === '=' ? r === 0 :
op === '<>' ? r !== 0 :
op === '<' ? r < 0 :
op === '>' ? r > 0 :
op === '<=' ? r <= 0 : r >= 0;
return { t: 'bool', v: res };
}
/** Used by the dependency graph: every single cell key an AST reads. */
function refKeys(n, out) {
switch (n.k) {
case 'ref':
if (n.col >= 0 && n.row >= 0 && (0, refs_1.inBounds)(n.col, n.row))
out.add((0, refs_1.keyOf)(n.col, n.row));
break;
case 'range': {
const { a, b } = n;
if (a.col < 0 || a.row < 0 || b.col < 0 || b.row < 0)
break;
const c0 = Math.max(0, Math.min(a.col, b.col)), c1 = Math.min(999, Math.max(a.col, b.col));
const r0 = Math.max(0, Math.min(a.row, b.row)), r1 = Math.max(a.row, b.row);
for (let r = r0; r <= r1; r++)
for (let c = c0; c <= c1; c++) {
if ((0, refs_1.inBounds)(c, r))
out.add((0, refs_1.keyOf)(c, r));
}
break;
}
case 'unary':
case 'paren':
refKeys(n.a, out);
break;
case 'binary':
refKeys(n.a, out);
refKeys(n.b, out);
break;
case 'call':
for (const a of n.args)
refKeys(a, out);
break;
default: break;
}
}
};
// ── module: src/rewrite.ts ──
__mods["src/rewrite.ts"] = function (exports, require, module) {
"use strict";
/* ============================================================
* REWRITE — structural edits (insert/delete row & column) applied
* to a formula AST so it keeps pointing at the same logical cells.
* A destroyed target becomes col/row = -1, which prints as #REF!.
* ============================================================ */
Object.defineProperty(exports, "__esModule", { value: true });
exports.rewriteNode = rewriteNode;
const DEAD = -1;
function shiftInsert(v, at) { return v >= at ? v + 1 : v; }
/** single reference under a deletion of index `at` */
function shiftDeleteSingle(v, at) {
if (v === at)
return DEAD;
return v > at ? v - 1 : v;
}
function isDead(r) { return r.col === DEAD || r.row === DEAD; }
/**
* Returns a rewritten copy, or null if the whole expression became #REF!
* (callers still get a node — refs are marked dead and print as #REF!).
*/
function rewriteNode(n, kind, at) {
switch (n.k) {
case 'num':
case 'str':
case 'err': return n;
case 'paren': return { k: 'paren', a: rewriteNode(n.a, kind, at) };
case 'unary': return { k: 'unary', op: n.op, a: rewriteNode(n.a, kind, at) };
case 'binary': return { k: 'binary', op: n.op, a: rewriteNode(n.a, kind, at), b: rewriteNode(n.b, kind, at) };
case 'call': return { k: 'call', name: n.name, args: n.args.map((a) => rewriteNode(a, kind, at)) };
case 'ref': return rewriteRef(n, kind, at);
case 'range': return rewriteRange(n, kind, at);
}
}
function rewriteRef(r, kind, at) {
if (isDead(r))
return r;
const out = { ...r };
switch (kind) {
case 'insertRow':
out.row = shiftInsert(r.row, at);
break;
case 'insertCol':
out.col = shiftInsert(r.col, at);
break;
case 'deleteRow':
out.row = shiftDeleteSingle(r.row, at);
break;
case 'deleteCol':
out.col = shiftDeleteSingle(r.col, at);
break;
}
return out;
}
function rewriteRange(n, kind, at) {
const a = n.a, b = n.b;
if (isDead(a) || isDead(b))
return n;
const axis = (kind === 'insertRow' || kind === 'deleteRow') ? 'row' : 'col';
const lo = Math.min(a[axis], b[axis]);
const hi = Math.max(a[axis], b[axis]);
let nlo = lo, nhi = hi;
if (kind === 'insertRow' || kind === 'insertCol') {
nlo = lo >= at ? lo + 1 : lo;
nhi = hi >= at ? hi + 1 : hi;
}
else {
if (lo === at && hi === at) {
return { k: 'range', a: { ...a, col: DEAD, row: DEAD }, b: { ...b, col: DEAD, row: DEAD } };
}
nlo = lo > at ? lo - 1 : lo; // lo === at: start slides onto the next survivor
nhi = hi >= at ? hi - 1 : hi;
if (nlo > nhi) {
return { k: 'range', a: { ...a, col: DEAD, row: DEAD }, b: { ...b, col: DEAD, row: DEAD } };
}
}
// preserve the original endpoint ordering
const aFirst = a[axis] <= b[axis];
const na = { ...a }, nb = { ...b };
if (axis === 'row') {
na.row = aFirst ? nlo : nhi;
nb.row = aFirst ? nhi : nlo;
}
else {
na.col = aFirst ? nlo : nhi;
nb.col = aFirst ? nhi : nlo;
}
return { k: 'range', a: na, b: nb };
}
};
// ── module: src/sheet.ts ──
__mods["src/sheet.ts"] = function (exports, require, module) {
"use strict";
/* ============================================================
* SHEET — cell store, dependency graph, topological recalc,
* cycle detection, structural edits, serialization
* ============================================================ */
Object.defineProperty(exports, "__esModule", { value: true });
exports.Sheet = void 0;
const config_1 = require("./config");
const parser_1 = require("./parser");
const printer_1 = require("./printer");
const rewrite_1 = require("./rewrite");
const eval_1 = require("./eval");
const value_1 = require("./value");
const refs_1 = require("./refs");
class Sheet {
constructor() {
this.cells = new Map();
/** key -> cells it reads (precedents) */
this.precedents = new Map();
/** key -> cells that read it (dependents) */
this.dependents = new Map();
/** bumped whenever any value changes; the view uses it to know what to repaint */
this.dirty = new Set();
}
/* ---------------- basic access ---------------- */
getCell(key) { return this.cells.get(key); }
raw(key) { return this.cells.get(key)?.raw ?? ''; }
value(key) { return this.cells.get(key)?.value ?? value_1.BLANK; }
valueAt(col, row) {
if (!(0, refs_1.inBounds)(col, row))
return (0, value_1.err)('#REF!');
return this.cells.get((0, refs_1.keyOf)(col, row))?.value ?? value_1.BLANK;
}
allKeys() { return [...this.cells.keys()]; }
/* ---------------- dependency graph ---------------- */
unlink(key) {
const pre = this.precedents.get(key);
if (!pre)
return;
for (const p of pre) {
const set = this.dependents.get(p);
if (set) {
set.delete(key);
if (set.size === 0)
this.dependents.delete(p);
}
}
this.precedents.delete(key);
}
link(key, ast) {
if (!ast)
return;
const refs = new Set();
(0, eval_1.refKeys)(ast, refs);
// NB: a self-reference is kept, so Kahn's algorithm reports it as #CYCLE!
if (refs.size === 0)
return;
this.precedents.set(key, refs);
for (const p of refs) {
let set = this.dependents.get(p);
if (!set) {
set = new Set();
this.dependents.set(p, set);
}
set.add(key);
}
}
/* ---------------- editing ---------------- */
/** Write raw text into a cell and recalculate everything it affects. */
setRaw(key, rawIn) {
const raw = rawIn;
if (raw === '') {
this.unlink(key);
this.cells.delete(key);
this.dirty.add(key);
this.recalcFrom([key]);
return;
}
const cell = this.compile(raw);
this.unlink(key);
this.cells.set(key, cell);
this.link(key, cell.ast);
this.dirty.add(key);
this.recalcFrom([key]);
}
compile(raw) {
if (raw.startsWith('=')) {
try {
const ast = (0, parser_1.parseFormula)(raw.slice(1));
return { raw, ast, parseFailed: false, value: value_1.BLANK };
}
catch (e) {
if (e instanceof parser_1.ParseError)
return { raw, ast: null, parseFailed: true, value: (0, value_1.err)('#ERR!') };
return { raw, ast: null, parseFailed: true, value: (0, value_1.err)('#ERR!') };
}
}
return { raw, ast: null, parseFailed: false, value: (0, value_1.literalOf)(raw) };
}
clear(key) { this.setRaw(key, ''); }
/* ---------------- recalculation ---------------- */
/** Recompute the seed cells and everything downstream, in topological order. */
recalcFrom(seeds) {
// 1. transitive closure over dependents
const affected = new Set();
const stack = [...seeds];
while (stack.length) {
const k = stack.pop();
if (affected.has(k))
continue;
affected.add(k);
const ds = this.dependents.get(k);
if (ds)
for (const d of ds)
if (!affected.has(d))
stack.push(d);
}
// 2. in-degrees inside the affected subgraph
const indeg = new Map();
for (const k of affected) {
let d = 0;
const pre = this.precedents.get(k);
if (pre)
for (const p of pre)
if (affected.has(p))
d++;
indeg.set(k, d);
}
// 3. Kahn
const order = [];
const queue = [];
for (const [k, d] of indeg)
if (d === 0)
queue.push(k);
while (queue.length) {
const k = queue.shift();
order.push(k);
const ds = this.dependents.get(k);
if (!ds)
continue;
for (const d of ds) {
if (!affected.has(d))
continue;
const nd = indeg.get(d) - 1;
indeg.set(d, nd);
if (nd === 0)
queue.push(d);
}
}
// 4. leftovers are in (or downstream of) a cycle
const cyclic = [];
for (const [k, d] of indeg)
if (d > 0)
cyclic.push(k);
for (const k of cyclic) {
const c = this.cells.get(k);
if (c) {
c.value = (0, value_1.err)('#CYCLE!');
this.dirty.add(k);
}
}
// 5. evaluate in dependency order
for (const k of order)
this.evaluateCell(k);
}
evaluateCell(key) {
const c = this.cells.get(key);
if (!c) {
this.dirty.add(key);
return;
}
let next;
if (c.parseFailed)
next = (0, value_1.err)('#ERR!');
else if (c.ast)
next = (0, eval_1.evalNode)(c.ast, this);
else
next = (0, value_1.literalOf)(c.raw);
const prev = c.value;
c.value = next;
if (prev.t !== next.t || prev.v !== next.v) {
this.dirty.add(key);
}
}
/** Full recompute of every cell (used after load and structural edits). */
recalcAll() {
this.precedents.clear();
this.dependents.clear();
for (const [key, c] of this.cells)
this.link(key, c.ast);
for (const key of this.cells.keys())
this.dirty.add(key);
this.recalcFrom([...this.cells.keys()]);
}
/* ---------------- structural edits ---------------- */
structuralEdit(kind, at) {
const old = [...this.cells.entries()];
const next = new Map();
for (const [key, cell] of old) {
const pos = (0, refs_1.parseKey)(key);
if (!pos)
continue;
let { col, row } = pos;
if (kind === 'insertRow') {
if (row >= at)
row++;
}
else if (kind === 'deleteRow') {
if (row === at)
continue;
if (row > at)
row--;
}
else if (kind === 'insertCol') {
if (col >= at)
col++;
}
else if (kind === 'deleteCol') {
if (col === at)
continue;
if (col > at)
col--;
}
if (!(0, refs_1.inBounds)(col, row))
continue; // pushed off the edge of the sheet
let raw = cell.raw;
let ast = cell.ast;
if (ast) {
ast = (0, rewrite_1.rewriteNode)(ast, kind, at);
raw = '=' + (0, printer_1.printNode)(ast);
}
const recompiled = this.compile(raw);
next.set((0, refs_1.keyOf)(col, row), recompiled);
}
this.cells = next;
this.dirty = new Set();
for (let c = 0; c < config_1.CONFIG.COLS; c++)
for (let r = 0; r < config_1.CONFIG.ROWS; r++)
this.dirty.add((0, refs_1.keyOf)(c, r));
this.recalcAll();
}
/* ---------------- serialization ---------------- */
toJSON() {
const out = {};
for (const [k, c] of this.cells)
if (c.raw !== '')
out[k] = c.raw;
return out;
}
loadJSON(data) {
this.cells.clear();
this.precedents.clear();
this.dependents.clear();
for (const k of Object.keys(data)) {
const pos = (0, refs_1.parseKey)(k);
if (!pos || !(0, refs_1.inBounds)(pos.col, pos.row))
continue;
const raw = data[k];
if (typeof raw !== 'string' || raw === '')
continue;
this.cells.set((0, refs_1.keyOf)(pos.col, pos.row), this.compile(raw));
}
this.recalcAll();
}
save() {
try {
localStorage.setItem(config_1.CONFIG.STORAGE_KEY, JSON.stringify(this.toJSON()));
}
catch { /* storage unavailable — keep working in memory */ }
}
load() {
try {
const s = localStorage.getItem(config_1.CONFIG.STORAGE_KEY);
if (!s)
return false;
const data = JSON.parse(s);
if (!data || typeof data !== 'object')
return false;
this.loadJSON(data);
return true;
}
catch {
return false;
}
}
}
exports.Sheet = Sheet;
};
// ── module: src/view.ts ──
__mods["src/view.ts"] = function (exports, require, module) {
"use strict";
/* ============================================================
* VIEW — builds the table once, then paints cells on demand
* ============================================================ */
Object.defineProperty(exports, "__esModule", { value: true });
exports.View = void 0;
const config_1 = require("./config");
const refs_1 = require("./refs");
const value_1 = require("./value");
class View {
constructor(table, sheet) {
this.tds = []; // [row][col]
this.colHeads = [];
this.rowHeads = [];
this.selCol = 0;
this.selRow = 0;
this.table = table;
this.sheet = sheet;
this.thead = table.tHead;
this.tbody = table.tBodies[0];
this.build();
}
/* ---------- construction ---------- */
build() {
const hrow = document.createElement('tr');
const corner = document.createElement('th');
corner.className = 'corner';
hrow.appendChild(corner);
for (let c = 0; c < config_1.CONFIG.COLS; c++) {
const th = document.createElement('th');
th.textContent = (0, refs_1.colName)(c);
th.dataset.col = String(c);
hrow.appendChild(th);
this.colHeads.push(th);
}
this.thead.appendChild(hrow);
const frag = document.createDocumentFragment();
for (let r = 0; r < config_1.CONFIG.ROWS; r++) {
const tr = document.createElement('tr');
const th = document.createElement('th');
th.textContent = String(r + 1);
th.dataset.row = String(r);
tr.appendChild(th);
this.rowHeads.push(th);
const rowCells = [];
for (let c = 0; c < config_1.CONFIG.COLS; c++) {
const td = document.createElement('td');
td.dataset.col = String(c);
td.dataset.row = String(r);
tr.appendChild(td);
rowCells.push(td);
}
this.tds.push(rowCells);
frag.appendChild(tr);
}
this.tbody.appendChild(frag);
}
/* ---------- painting ---------- */
tdAt(col, row) {
const row2 = this.tds[row];
return row2 ? (row2[col] ?? null) : null;
}
paintCell(col, row) {
const td = this.tdAt(col, row);
if (!td)
return;
const v = this.sheet.value((0, refs_1.keyOf)(col, row));
const d = (0, value_1.display)(v);
if (td.firstChild && td.firstChild.nodeType === 3) {
if (td.firstChild.nodeValue !== d.text)
td.firstChild.nodeValue = d.text;
}
else if (!td.querySelector('input')) {
td.textContent = d.text;
}
const sel = td.classList.contains('sel');
const editing = td.classList.contains('editing');
let cls = '';
if (d.cls === 'num')
cls = 'num';
else if (d.cls === 'err')
cls = 'err';
td.className = [cls, sel ? 'sel' : '', editing ? 'editing' : ''].filter(Boolean).join(' ');
const raw = this.sheet.raw((0, refs_1.keyOf)(col, row));
if (raw)
td.title = raw.startsWith('=') ? raw + ' → ' + d.text : '';
else if (td.title)
td.title = '';
}
/** Repaint every cell the sheet flagged as changed. */
flush() {
for (const key of this.sheet.dirty) {
const m = /^([A-Z]+)(\d+)$/.exec(key);
if (!m)
continue;
let col = 0;
for (let i = 0; i < m[1].length; i++)
col = col * 26 + (m[1].charCodeAt(i) - 64);
this.paintCell(col - 1, parseInt(m[2], 10) - 1);
}
this.sheet.dirty.clear();
}
paintAll() {
for (let r = 0; r < config_1.CONFIG.ROWS; r++)
for (let c = 0; c < config_1.CONFIG.COLS; c++)
this.paintCell(c, r);
this.sheet.dirty.clear();
}
/* ---------- selection ---------- */
setSelection(col, row) {
const prev = this.tdAt(this.selCol, this.selRow);
if (prev)
prev.classList.remove('sel');
this.colHeads[this.selCol]?.classList.remove('hl');
this.rowHeads[this.selRow]?.classList.remove('hl');
this.selCol = col;
this.selRow = row;
const td = this.tdAt(col, row);
if (td)
td.classList.add('sel');
this.colHeads[col]?.classList.add('hl');
this.rowHeads[row]?.classList.add('hl');
}
scrollIntoView(col, row) {
const td = this.tdAt(col, row);
const wrap = this.table.parentElement;
if (!td || !wrap)
return;
const cw = config_1.CONFIG.CELL_W, ch = config_1.CONFIG.CELL_H;
const x = config_1.CONFIG.ROW_HEAD_W + col * (cw + 1);
const y = row * (ch + 1);
const padL = config_1.CONFIG.ROW_HEAD_W, padT = ch + 1;
if (x - padL < wrap.scrollLeft)
wrap.scrollLeft = Math.max(0, x - padL);
else if (x + cw > wrap.scrollLeft + wrap.clientWidth)
wrap.scrollLeft = x + cw - wrap.clientWidth;
if (y - padT < wrap.scrollTop)
wrap.scrollTop = Math.max(0, y - padT);
else if (y + ch > wrap.scrollTop + wrap.clientHeight)
wrap.scrollTop = y + ch - wrap.clientHeight;
}
}
exports.View = View;
};
// ── module: src/app.ts ──
__mods["src/app.ts"] = function (exports, require, module) {
"use strict";
/* ============================================================
* APP — controller: selection, editing, formula bar, keyboard,
* toolbar, context menu, autosave
* ============================================================ */
Object.defineProperty(exports, "__esModule", { value: true });
exports.App = void 0;
const config_1 = require("./config");
const refs_1 = require("./refs");
const sheet_1 = require("./sheet");
const view_1 = require("./view");
function el(id) {
const e = document.getElementById(id);
if (!e)
throw new Error('missing element #' + id);
return e;
}
class App {
constructor() {
this.sheet = new sheet_1.Sheet();
this.wrap = el('gridwrap');
this.fInput = el('finput');
this.cellRef = el('cellref');
this.statusEl = el('status');
this.ctx = el('ctxmenu');
this.col = 0;
this.row = 0;
this.editor = null;
this.editKey = '';
this.saveTimer = 0;
this.fbarDirty = false;
this.view = new view_1.View(el('grid'), this.sheet);
const restored = this.sheet.load();
this.view.paintAll();
this.select(0, 0);
this.setStatus(restored ? 'Restored from local storage' : 'Ready');
this.bindGrid();
this.bindFormulaBar();
this.bindKeys();
this.bindToolbar();
this.bindContextMenu();
}
/* ---------------- selection ---------------- */
get key() { return (0, refs_1.keyOf)(this.col, this.row); }
select(col, row, scroll = false) {
this.commitEditor(false);
this.col = (0, refs_1.clampCol)(col);
this.row = (0, refs_1.clampRow)(row);
this.view.setSelection(this.col, this.row);
this.cellRef.textContent = this.key;
this.fInput.value = this.sheet.raw(this.key);
this.fbarDirty = false;
if (scroll)
this.view.scrollIntoView(this.col, this.row);
}
move(dc, dr) {
this.select(this.col + dc, this.row + dr, true);
}
/* ---------------- committing values ---------------- */
setCell(key, raw) {
this.sheet.setRaw(key, raw);
this.view.flush();
// selection styling can be wiped by a repaint of the selected cell
this.view.setSelection(this.col, this.row);
if (key === this.key) {
this.fInput.value = this.sheet.raw(key);
this.fbarDirty = false;
}
this.queueSave();
}
/* ---------------- in-cell editing ---------------- */
beginEdit(seed) {
if (this.editor)
return;
const td = this.view.tdAt(this.col, this.row);
if (!td)
return;
this.editKey = this.key;
const input = document.createElement('input');
input.className = 'celledit';
input.type = 'text';
input.spellcheck = false;
input.value = seed === null ? this.sheet.raw(this.editKey) : seed;
td.textContent = '';
td.classList.add('editing');
td.appendChild(input);
this.editor = input;
input.focus();
if (seed === null)
input.select();
else {
input.setSelectionRange(input.value.length, input.value.length);
}
input.addEventListener('input', () => { this.fInput.value = input.value; });
// blur (e.g. clicking the formula bar or a toolbar button) commits, but must
// not steal focus back to the grid
input.addEventListener('blur', () => {
if (this.editor === input)
this.commitEditor(false);
});
input.addEventListener('keydown', (e) => this.onEditorKey(e, input));
}
onEditorKey(e, input) {
if (e.key === 'Enter') {
e.preventDefault();
const raw = input.value;
this.discardEditor();
this.setCell(this.editKey, raw);
this.move(0, e.shiftKey ? -1 : 1);
}
else if (e.key === 'Tab') {
e.preventDefault();
const raw = input.value;
this.discardEditor();
this.setCell(this.editKey, raw);
this.move(e.shiftKey ? -1 : 1, 0);
}
else if (e.key === 'Escape') {
e.preventDefault();
this.discardEditor();
const c = this.col, r = this.row;
this.view.paintCell(c, r);
this.view.setSelection(c, r);
this.fInput.value = this.sheet.raw(this.key);
this.focusGrid();
}
else {
e.stopPropagation();
}
}
/** Remove the editor DOM without writing anything. */
discardEditor() {
const input = this.editor;
if (!input)
return;
const td = input.parentElement;
this.editor = null; // clear first: removal fires blur re-entrantly
if (input.parentNode)
input.parentNode.removeChild(input);
if (td) {
td.classList.remove('editing');
const c = Number(td.dataset.col), r = Number(td.dataset.row);
this.view.paintCell(c, r);
}
}
/** Commit whatever is in the in-cell editor (if any). */
commitEditor(refocus = true) {
if (!this.editor)
return;
const raw = this.editor.value;
const key = this.editKey;
this.discardEditor();
this.sheet.setRaw(key, raw);
this.view.flush();
this.view.setSelection(this.col, this.row);
this.queueSave();
if (refocus)
this.focusGrid();
}
focusGrid() {
this.wrap.focus({ preventScroll: true });
}
/* ---------------- grid events ---------------- */
cellFromEvent(e) {
const t = e.target;
if (!t)
return null;
const td = t.closest('td');
if (!td || td.dataset.col === undefined)
return null;
return { col: Number(td.dataset.col), row: Number(td.dataset.row) };
}
bindGrid() {
this.wrap.tabIndex = 0;
this.wrap.addEventListener('mousedown', (e) => {
this.hideCtx();
this.commitFormulaBarIfPending();
const hit = this.cellFromEvent(e);
if (hit) {
if (this.editor && hit.col === this.col && hit.row === this.row)
return;
e.preventDefault();
this.select(hit.col, hit.row);
this.focusGrid();
return;
}
const t = e.target;
const th = t.closest('th');
if (th && th.dataset.col !== undefined) {
e.preventDefault();
this.select(Number(th.dataset.col), 0, true);
this.focusGrid();
}
else if (th && th.dataset.row !== undefined) {
e.preventDefault();
this.select(0, Number(th.dataset.row), true);
this.focusGrid();
}
});
this.wrap.addEventListener('dblclick', (e) => {
const hit = this.cellFromEvent(e);
if (!hit)
return;
this.select(hit.col, hit.row);
this.beginEdit(null);
});
}
/* ---------------- formula bar ---------------- */
bindFormulaBar() {
this.fInput.addEventListener('input', () => {
this.fbarDirty = true;
if (this.editor)
this.editor.value = this.fInput.value;
});
this.fInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.discardEditor();
this.setCell(this.key, this.fInput.value);
this.move(0, 1);
this.focusGrid();
}
else if (e.key === 'Tab') {
e.preventDefault();
this.discardEditor();
this.setCell(this.key, this.fInput.value);
this.move(1, 0);
this.focusGrid();
}
else if (e.key === 'Escape') {
e.preventDefault();
this.fInput.value = this.sheet.raw(this.key);
this.focusGrid();
}
e.stopPropagation();
});
this.fInput.addEventListener('blur', () => this.commitFormulaBarIfPending());
}
/** If the formula bar holds uncommitted text for the selected cell, write it. */
commitFormulaBarIfPending() {
if (!this.fbarDirty)
return;
this.fbarDirty = false;
if (this.editor)
return;
if (this.fInput.value === this.sheet.raw(this.key))
return;
this.setCell(this.key, this.fInput.value);
}
/* ---------------- keyboard on the grid ---------------- */
bindKeys() {
document.addEventListener('keydown', (e) => {
if (this.editor)
return;
const target = e.target;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA'))
return;
switch (e.key) {
case 'ArrowUp':
e.preventDefault();
this.move(0, -1);
return;
case 'ArrowDown':
e.preventDefault();
this.move(0, 1);
return;
case 'ArrowLeft':
e.preventDefault();
this.move(-1, 0);
return;
case 'ArrowRight':
e.preventDefault();
this.move(1, 0);
return;
case 'Tab':
e.preventDefault();
this.move(e.shiftKey ? -1 : 1, 0);
return;
case 'Enter':
e.preventDefault();
this.beginEdit(null);
return;
case 'F2':
e.preventDefault();
this.beginEdit(null);
return;
case 'Escape':
this.hideCtx();
return;
case 'Home':
e.preventDefault();
if (e.ctrlKey || e.metaKey)
this.select(0, 0, true);
else
this.select(0, this.row, true);
return;
case 'End':
e.preventDefault();
this.select(config_1.CONFIG.COLS - 1, e.ctrlKey || e.metaKey ? config_1.CONFIG.ROWS - 1 : this.row, true);
return;
case 'PageDown':
e.preventDefault();
this.move(0, 20);
return;
case 'PageUp':
e.preventDefault();
this.move(0, -20);
return;
case 'Delete':
case 'Backspace':
e.preventDefault();
this.setCell(this.key, '');
return;
}
if (e.ctrlKey || e.metaKey || e.altKey)
return;
if (e.key.length === 1) {
e.preventDefault();
this.beginEdit(e.key);
}
});
}
/* ---------------- structural edits + toolbar ---------------- */
structural(kind, at) {
this.discardEditor();
this.sheet.structuralEdit(kind, at);
this.view.paintAll();
this.view.setSelection(this.col, this.row);
this.fInput.value = this.sheet.raw(this.key);
this.queueSave();
const label = {
insertRow: 'Inserted row ' + (at + 1),
deleteRow: 'Deleted row ' + (at + 1),
insertCol: 'Inserted column ' + (0, refs_1.colName)(at),
deleteCol: 'Deleted column ' + (0, refs_1.colName)(at),
};
this.setStatus(label[kind]);
}
bindToolbar() {
el('btn-ins-row').addEventListener('click', () => this.structural('insertRow', this.row));
el('btn-del-row').addEventListener('click', () => this.structural('deleteRow', this.row));
el('btn-ins-col').addEventListener('click', () => this.structural('insertCol', this.col));
el('btn-del-col').addEventListener('click', () => this.structural('deleteCol', this.col));
el('btn-clear').addEventListener('click', () => { this.setCell(this.key, ''); this.focusGrid(); });
el('btn-reset').addEventListener('click', () => {
this.discardEditor();
this.sheet.loadJSON({});
this.view.paintAll();
this.select(0, 0);
this.queueSave();
this.setStatus('Sheet cleared');
});
}
/* ---------------- header context menu ---------------- */
bindContextMenu() {
this.wrap.addEventListener('contextmenu', (e) => {
const t = e.target;
const th = t.closest('th');
const td = t.closest('td');
let items = [];
if (th && th.dataset.col !== undefined) {
const c = Number(th.dataset.col);
this.select(c, this.row);
items = [
['Insert 1 column left', () => this.structural('insertCol', c)],
['Insert 1 column right', () => this.structural('insertCol', c + 1)],
['Delete column ' + (0, refs_1.colName)(c), () => this.structural('deleteCol', c)],
];
}
else if (th && th.dataset.row !== undefined) {
const r = Number(th.dataset.row);
this.select(this.col, r);
items = [
['Insert 1 row above', () => this.structural('insertRow', r)],
['Insert 1 row below', () => this.structural('insertRow', r + 1)],
['Delete row ' + (r + 1), () => this.structural('deleteRow', r)],
];
}
else if (td) {
const c = Number(td.dataset.col), r = Number(td.dataset.row);
this.select(c, r);
items = [
['Clear ' + (0, refs_1.keyOf)(c, r), () => this.setCell((0, refs_1.keyOf)(c, r), '')],
['Insert 1 row above', () => this.structural('insertRow', r)],
['Insert 1 column left', () => this.structural('insertCol', c)],
['Delete row ' + (r + 1), () => this.structural('deleteRow', r)],
['Delete column ' + (0, refs_1.colName)(c), () => this.structural('deleteCol', c)],
];
}
else
return;
e.preventDefault();
this.showCtx(e.clientX, e.clientY, items);
});
document.addEventListener('mousedown', (e) => {
if (!this.ctx.hidden && !this.ctx.contains(e.target))
this.hideCtx();
});
}
showCtx(x, y, items) {
this.ctx.textContent = '';
for (const [label, fn] of items) {
const b = document.createElement('button');
b.textContent = label;
b.addEventListener('click', () => { this.hideCtx(); fn(); });
this.ctx.appendChild(b);
}
this.ctx.hidden = false;
const w = this.ctx.offsetWidth, h = this.ctx.offsetHeight;
this.ctx.style.left = Math.min(x, window.innerWidth - w - 6) + 'px';
this.ctx.style.top = Math.min(y, window.innerHeight - h - 6) + 'px';
}
hideCtx() { this.ctx.hidden = true; }
/* ---------------- persistence & status ---------------- */
queueSave() {
if (this.saveTimer)
clearTimeout(this.saveTimer);
this.saveTimer = window.setTimeout(() => {
this.saveTimer = 0;
this.sheet.save();
this.setStatus('Saved to local storage');
}, config_1.CONFIG.SAVE_DEBOUNCE_MS);
}
setStatus(msg) { this.statusEl.textContent = msg; }
}
exports.App = App;
};
// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"anthropic/claude-opus-5","provider":"openrouter","persona":"typescript","sessionId":"4dc5bbb5-038b-45a5-928a-deb8fbe9284d","tokensIn":2395144,"tokensOut":65904,"tokensTotal":2461048,"cost":3.2487059999999994,"turns":51,"toolCalls":58,"failedToolCalls":0,"timestamp":"2026-07-31T21:40:36.361Z"} -->