Spreadsheet
Qwen 3.8 · 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 79,224 bytes · SHA-256 81890936b2e2
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sheet — Vanilla Spreadsheet</title>
<style>
/* ── css: base ── */
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
font-size: 13px;
color: #202124;
background: #fff;
overflow: hidden;
}
#app { display: flex; flex-direction: column; height: 100vh; }
/* ── css: chrome ── */
/* ---------- Toolbar ---------- */
.toolbar {
display: flex; align-items: center; gap: 14px;
padding: 6px 12px;
background: #f8f9fa;
border-bottom: 1px solid #e0e2e5;
flex: none;
}
.brand {
font-size: 15px; font-weight: 600; color: #444;
letter-spacing: .3px; margin-right: 6px;
display: flex; align-items: center; gap: 7px;
}
.brand::before {
content: ""; width: 16px; height: 16px; display: inline-block;
border: 2px solid #1a73e8; border-radius: 3px;
background:
linear-gradient(#1a73e8, #1a73e8) 50% 3px / 10px 2px no-repeat,
#e8f0fe;
}
.tb-group { display: flex; gap: 4px; }
.tb-group + .tb-group { margin-left: 8px; padding-left: 8px; border-left: 1px solid #dadce0; }
button {
font: inherit; font-size: 12.5px;
padding: 4px 10px;
border: 1px solid #dadce0; border-radius: 4px;
background: #fff; color: #3c4043; cursor: pointer;
}
button:hover { background: #f1f3f4; border-color: #bdc1c6; }
button:active { background: #e8eaed; }
button:disabled { opacity: .45; cursor: default; }
.tb-spacer { flex: 1; }
.save-note { font-size: 11.5px; color: #9aa0a6; transition: opacity .4s; }
/* ---------- Formula bar ---------- */
.formulabar {
display: flex; align-items: stretch; flex: none;
border-bottom: 1px solid #e0e2e5;
background: #fff;
height: 34px;
}
.namebox {
width: 64px; flex: none;
display: flex; align-items: center; justify-content: center;
font-weight: 600; font-size: 12.5px;
border-right: 1px solid #e0e2e5;
background: #f8f9fa; color: #1a73e8;
user-select: none;
}
.fx { display: flex; flex: 1; align-items: center; padding: 0 10px; gap: 10px; }
.fx-label {
font-family: Georgia, "Times New Roman", serif; font-style: italic;
font-size: 13px; color: #5f6368; user-select: none;
}
#fx-input {
flex: 1; height: 26px;
font: inherit; font-size: 13px;
border: none; outline: none; background: transparent;
border-bottom: 1px solid transparent;
color: #202124;
}
#fx-input:focus { border-bottom-color: #1a73e8; }
#fx-input::placeholder { color: #bdc1c6; }
/* ── css: grid ── */
/* ---------- Grid ---------- */
.gridwrap {
position: relative; flex: 1; min-height: 0;
overflow: auto; background: #fff;
}
table.sheet {
border-collapse: separate; border-spacing: 0;
table-layout: fixed;
/* HEAD_WIDTH + COLS*COL_WIDTH = 46 + 26*88 — keep in sync with constants */
width: 2334px;
min-width: 100%;
background: #fff;
}
table.sheet th, table.sheet td {
border-right: 1px solid #e8eaed;
border-bottom: 1px solid #e8eaed;
padding: 0;
height: 28px;
font-size: 13px;
line-height: 27px;
}
table.sheet thead th {
position: sticky; top: 0; z-index: 3;
background: #f8f9fa;
color: #5f6368;
font-weight: 500;
text-align: center;
user-select: none;
}
table.sheet thead th.rh {
left: 0; z-index: 5;
width: 46px; min-width: 46px; max-width: 46px;
}
table.sheet thead th.ch { width: 88px; }
table.sheet thead th.ch.hl { background: #e8f0fe; color: #1a73e8; }
table.sheet tbody th {
position: sticky; left: 0; z-index: 2;
background: #f8f9fa;
color: #5f6368;
font-weight: 500;
text-align: center;
width: 46px; min-width: 46px; max-width: 46px;
user-select: none;
}
table.sheet tbody th.rh.hl { background: #e8f0fe; color: #1a73e8; }
table.sheet tbody th:hover { background: #eef1f4; }
table.sheet thead th.ch:hover { background: #eef1f4; }
table.sheet td {
position: relative;
width: 88px;
padding: 0 6px;
white-space: pre-wrap;
word-break: break-word;
overflow: hidden;
cursor: default;
}
table.sheet td.align-right { text-align: right; }
table.sheet td.align-left { text-align: left; }
table.sheet td.align-err { text-align: center; color: #d93025; font-weight: 600; }
/* Selection: bold blue ring on the active cell */
table.sheet td.sel {
outline: 2px solid #1a73e8;
outline-offset: -2px;
z-index: 1;
}
/* In-cell editor */
.editing {
position: absolute; z-index: 10;
display: none;
}
.editing.on { display: block; }
#cell-input {
width: 100%; height: 100%;
border: 2px solid #1a73e8;
border-radius: 0;
padding: 0 5px;
font: inherit; font-size: 13px;
line-height: 24px;
outline: none;
background: #fff; color: #202124;
box-shadow: 0 1px 4px rgba(60,64,67,.3);
}
#cell-input.align-right { text-align: right; }
/* ---------- Right-click menu ---------- */
.menu {
position: fixed; z-index: 100;
min-width: 190px;
background: #fff;
border: 1px solid #dadce0;
border-radius: 6px;
box-shadow: 0 3px 10px rgba(0,0,0,.18);
padding: 5px 0;
display: none;
}
.menu.on { display: block; }
.menu button {
display: block; width: 100%;
border: none; border-radius: 0;
text-align: left;
padding: 7px 16px;
font-size: 13px; color: #3c4043;
background: transparent;
}
.menu button:hover { background: #f1f3f4; }
.menu .sep { height: 1px; background: #e8eaed; margin: 4px 0; }
/* ---------- Hint bar ---------- */
.hint {
flex: none;
padding: 4px 12px;
font-size: 11.5px; color: #9aa0a6;
background: #f8f9fa;
border-top: 1px solid #e0e2e5;
user-select: none;
}
</style>
</head>
<body>
<div id="app">
<div class="toolbar">
<div class="brand">Sheet</div>
<div class="tb-group">
<button id="btn-insert-row" title="Insert row below selection">+ Row</button>
<button id="btn-delete-row" title="Delete selected row">− Row</button>
</div>
<div class="tb-group">
<button id="btn-insert-col" title="Insert column right of selection">+ Col</button>
<button id="btn-delete-col" title="Delete selected column">− Col</button>
</div>
<div class="tb-spacer"></div>
<div class="save-note" id="save-note"></div>
</div>
<div class="formulabar">
<div class="namebox" id="namebox">A1</div>
<div class="fx"><span class="fx-label">fx</span><input id="fx-input" type="text" autocomplete="off" spellcheck="false" placeholder="Enter a value or a formula like =SUM(A1:A3)"></div>
</div>
<div class="gridwrap" id="gridwrap">
<table class="sheet" id="sheet"></table>
<div class="editing" id="editing"><input id="cell-input" type="text" autocomplete="off" spellcheck="false"></div>
</div>
<div class="menu" id="menu"></div>
<div class="hint">Click a cell to select · type to edit · Enter ↓ / Tab → · arrows move · double-click to edit · right-click headers for row/column ops · Delete clears</div>
</div>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {"src/main.ts":{"./controller":"src/controller.ts"},"src/tokenizer.ts":{"./constants":"src/constants.ts"},"src/parser.ts":{"./tokenizer":"src/tokenizer.ts"},"src/evaluator.ts":{"./constants":"src/constants.ts","./tokenizer":"src/tokenizer.ts","./parser":"src/parser.ts"},"src/sheet.ts":{"./constants":"src/constants.ts","./parser":"src/parser.ts","./evaluator":"src/evaluator.ts"},"src/controller.ts":{"./constants":"src/constants.ts","./sheet":"src/sheet.ts","./evaluator":"src/evaluator.ts"}};
function __require(id) {
if (__cache[id]) return __cache[id].exports;
var module = __cache[id] = { exports: {} };
var factory = __mods[id];
if (!factory) throw new Error("Module not found: " + id);
factory(module.exports, function (spec) {
var target = (__map[id] && __map[id][spec]) || spec;
return __require(target);
}, module);
return module.exports;
}
// ── module: src/main.ts ──
__mods["src/main.ts"] = function (exports, require, module) {
"use strict";
// =====================================================================
// ENTRY — boots the spreadsheet.
// =====================================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.start = start;
const controller_1 = require("./controller");
function start() {
(0, controller_1.init)();
// Test/debug hook (harmless in production): exposes the live model so
// automated checks can drive it without simulating full UI interaction.
window.__sheet = controller_1.state;
}
start();
};
// ── module: src/constants.ts ──
__mods["src/constants.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.STORAGE_KEY = exports.C = exports.HEAD_WIDTH = exports.ROW_HEIGHT = exports.COL_WIDTH = exports.COL_LETTERS = exports.ROWS = exports.COLS = void 0;
// =====================================================================
// TUNABLE CONSTANTS — grid size, colors, cell dimensions
// =====================================================================
exports.COLS = 26; // A–Z
exports.ROWS = 100;
exports.COL_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
exports.COL_WIDTH = 88; // px (must match CSS `td { width }`)
exports.ROW_HEIGHT = 28; // px (must match CSS row height)
exports.HEAD_WIDTH = 46; // px row-header width (must match CSS)
exports.C = {
select: "#1a73e8",
grid: "#e8eaed",
headerBg: "#f8f9fa",
headerFg: "#5f6368",
hl: "#e8f0fe",
errFg: "#d93025",
text: "#202124",
};
exports.STORAGE_KEY = "vanilla-sheet-v1";
};
// ── module: src/tokenizer.ts ──
__mods["src/tokenizer.ts"] = function (exports, require, module) {
"use strict";
// =====================================================================
// TOKENIZER — converts a formula string into a token stream.
// No eval(), no regex-on-whole-string hacks: a single-pass scanner.
// =====================================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.ParseError = void 0;
exports.validCol = validCol;
exports.validRow = validRow;
exports.parseCell = parseCell;
exports.tokenize = tokenize;
const constants_1 = require("./constants");
class ParseError extends Error {
constructor(msg) { super(msg); }
}
exports.ParseError = ParseError;
// Is `s` a syntactically valid column reference (1..COLS)?
function validCol(s) {
if (!s || s.length > 2)
return false;
let c = 0;
for (const ch of s) {
if (ch < "A" || ch > "Z")
return false;
c = c * 26 + (ch.charCodeAt(0) - 64);
}
return c <= constants_1.COLS;
}
// Is `s` a syntactically valid row number (1..ROWS)?
function validRow(s) {
if (!s || s[0] === "0")
return false;
if (!/^[1-9][0-9]*$/.test(s))
return false;
const n = parseInt(s, 10);
return n >= 1 && n <= constants_1.ROWS;
}
// Parse one cell reference "B7" -> {c:1, r:6} (0-based), or null.
function parseCell(s) {
let i = 0;
let c = 0;
while (i < s.length && s[i] >= "A" && s[i] <= "Z") {
c = c * 26 + (s.charCodeAt(i) - 64);
i++;
}
const rest = s.slice(i);
if (!rest || !validRow(rest) || c < 1 || c > constants_1.COLS)
return null;
return { c: c - 1, r: parseInt(rest, 10) - 1 };
}
function tokenize(src) {
const toks = [];
let i = 0;
const n = src.length;
while (i < n) {
const ch = src[i];
// ---- whitespace: skip --------------------------------------------
if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") {
i++;
continue;
}
// ---- number (with optional fractional part) ----------------------
if (ch >= "0" && ch <= "9" || (ch === "." && i + 1 < n && src[i + 1] >= "0" && src[i + 1] <= "9")) {
const start = i;
while (i < n && ((src[i] >= "0" && src[i] <= "9") || src[i] === "."))
i++;
const raw = src.slice(start, i);
const num = Number(raw);
if (!isFinite(num))
throw new ParseError("Number too large at position " + (start + 1));
toks.push({ type: "num", value: raw, n: num, p: start + 1 });
continue;
}
// ---- cell ref / range ---------------------------------------------
if (ch >= "A" && ch <= "Z") {
// gather maximal "letters digits" sequence: e.g. A1, AB2, A1A2 (invalid), A1:A10
const start = i;
while (i < n && src[i] >= "A" && src[i] <= "Z")
i++;
while (i < n && src[i] >= "0" && src[i] <= "9")
i++;
const chunk = src.slice(start, i);
const isRef = parseCell(chunk) !== null;
// reference-shaped but out of grid (e.g. "AA1", "A101") -> badref
const refShaped = /^[A-Z]{1,2}[0-9]+$/.test(chunk);
// range? "A1:A10" — the separator ":" must be directly adjacent
if ((isRef || refShaped) && i < n && src[i] === ":") {
i++;
const rStart = i;
while (i < n && src[i] >= "A" && src[i] <= "Z")
i++;
while (i < n && src[i] >= "0" && src[i] <= "9")
i++;
const rEnd = src.slice(rStart, i);
const a = parseCell(chunk);
const b = parseCell(rEnd);
if (!a || !b) {
// reference-shaped but broken (out of grid) -> #REF! token
toks.push({ type: "badref", value: chunk + ":" + rEnd, p: start + 1 });
continue;
}
// normalize so a <= b
const from = a.r <= b.r && a.c <= b.c ? a : b;
const to = from === a ? b : a;
toks.push({ type: "range", value: chunk + ":" + rEnd, r: { a: from, b: to }, p: start + 1 });
continue;
}
if (isRef) {
const cell = parseCell(chunk);
toks.push({ type: "ref", value: chunk, a: cell, p: start + 1 });
continue;
}
if (refShaped) {
toks.push({ type: "badref", value: chunk, p: start + 1 });
continue;
}
// letters but not a valid reference -> identifier (function name)
toks.push({ type: "ident", value: chunk, p: start + 1 });
continue;
}
// ---- string literal ("...", with "" for an escaped quote) -----------
if (ch === '"') {
const start = i;
i++;
let s = "";
let closed = false;
while (i < n) {
if (src[i] === '"') {
if (src[i + 1] === '"') {
s += '"';
i += 2;
}
else {
i++;
closed = true;
break;
}
}
else {
s += src[i++];
}
}
if (!closed)
throw new ParseError("Unterminated string literal at position " + (start + 1));
toks.push({ type: "str", value: s, p: start + 1 });
continue;
}
// ---- comma (arg separator) -----------------------------------------
if (ch === ",") {
toks.push({ type: "op", value: ",", p: i + 1 });
i++;
continue;
}
// ---- colon handled inside ref logic; bare colon is an error --------
if (ch === ":")
throw new ParseError("Unexpected ':' at position " + (i + 1));
// ---- error literal ("#REF!", "#DIV/0!", ...) -------------------------
if (ch === "#") {
const end = src.indexOf("!", i);
if (end === -1 || end - i > 12)
throw new ParseError("Bad error literal at position " + (i + 1));
toks.push({ type: "err", value: src.slice(i, end + 1), p: i + 1 });
i = end + 1;
continue;
}
// ---- operators -------------------------------------------------------
// two-character comparison operators first
if ((ch === ">" || ch === "<" || ch === "!" || ch === "=") && i + 1 < n) {
const two = src.slice(i, i + 2);
if (two === ">=" || two === "<=" || two === "==" || two === "!=" || two === "<>") {
toks.push({ type: "op", value: two, p: i + 1 });
i += 2;
continue;
}
}
if (ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "(" || ch === ")" || ch === "&" ||
ch === ">" || ch === "<" || ch === "=" || ch === "!") {
toks.push({ type: "op", value: ch, p: i + 1 });
i++;
continue;
}
// ---- percentage (suffix, e.g. 10%) — accepted, stored as 0.1 --------
if (ch === "%") {
const last = toks[toks.length - 1];
if (last && last.type === "num") {
last.n = (last.n ?? 0) / 100;
last.value += "%";
}
i++;
continue;
}
throw new ParseError("Unexpected character '" + ch + "' at position " + (i + 1));
}
return toks;
}
};
// ── module: src/parser.ts ──
__mods["src/parser.ts"] = function (exports, require, module) {
"use strict";
// =====================================================================
// PARSER — recursive descent over the token stream.
//
// expr := cond ( "?" expr ":" expr )?
// cond := add ( ("=="|"="|"!="|"<>"|"<"|">"|"<="|">=") add )*
// add := term ( ("+"|"-") term )* // + also does string concat
// term := factor ( ("*"|"/") factor )*
// factor := ("+"|"-") factor | primary
// primary := NUMBER | TRUE | FALSE | NULL | REF | RANGE
// | IDENT "(" args ")" | "(" expr ")"
// args := expr ( "," expr )* | %empty
//
// Produces an AST (plain objects). Also extracts every cell/range
// reference into a flat list for the dependency graph. No eval().
// =====================================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.RefError = void 0;
exports.parseFormula = parseFormula;
const tokenizer_1 = require("./tokenizer");
// Thrown at eval time for out-of-bounds / deleted references.
class RefError extends Error {
constructor() { super("#REF!"); }
}
exports.RefError = RefError;
// ---------------- the parser ------------------------------------------
class Parser {
constructor(src) {
this.pos = 0;
this.toks = (0, tokenizer_1.tokenize)(src);
}
static parse(src) {
const p = new Parser(src);
if (p.toks.length === 0)
throw new tokenizer_1.ParseError("Empty formula");
const node = p.expr();
if (p.pos < p.toks.length) {
const t = p.toks[p.pos];
throw new tokenizer_1.ParseError("Unexpected '" + t.value + "' at position " + t.p);
}
return node;
}
static collectRefs(node, out) {
const walk = (n) => {
switch (n.k) {
case "ref":
out.push({ c: n.c, r: n.r });
break;
case "range":
for (let c = n.a.c; c <= n.b.c; c++)
for (let r = n.a.r; r <= n.b.r; r++)
out.push({ c, r });
break;
case "un":
walk(n.x);
break;
case "bin":
walk(n.x);
walk(n.y);
break;
case "tern":
walk(n.x);
walk(n.y);
walk(n.z);
break;
case "fn":
n.args.forEach(walk);
break;
case "err": break;
default: break;
}
};
walk(node);
}
// ---------- token helpers -------------------------------------------
peek() { return this.toks[this.pos]; }
next() {
const t = this.toks[this.pos];
if (!t)
throw new tokenizer_1.ParseError("Unexpected end of formula");
this.pos++;
return t;
}
isOp(v) {
const t = this.peek();
return !!t && t.type === "op" && t.value === v;
}
eatOp(v) {
if (this.isOp(v)) {
this.pos++;
return true;
}
return false;
}
// ---------- grammar ----------------------------------------------------
expr() {
const cond = this.cond();
if (this.eatOp("?")) {
const a = this.expr();
if (!this.eatOp(":"))
throw new tokenizer_1.ParseError("Missing ':' in conditional");
const b = this.expr();
return { k: "tern", x: cond, y: a, z: b };
}
return cond;
}
cond() {
let l = this.additive();
for (;;) {
let op = "";
if (this.eatOp("==") || this.eatOp("="))
op = "==";
else if (this.eatOp("<>") || this.eatOp("!="))
op = "!=";
else if (this.eatOp("<="))
op = "<=";
else if (this.eatOp(">="))
op = ">=";
else if (this.eatOp("<"))
op = "<";
else if (this.eatOp(">"))
op = ">";
if (!op)
break;
const r = this.additive();
l = { k: "bin", op, x: l, y: r };
}
return l;
}
additive() {
let l = this.term();
for (;;) {
const t = this.peek();
if (t && t.type === "op" && (t.value === "+" || t.value === "-" || t.value === "&")) {
this.pos++;
const r = this.term();
l = { k: "bin", op: t.value, x: l, y: r };
}
else
break;
}
return l;
}
term() {
let l = this.factor();
for (;;) {
const t = this.peek();
if (t && t.type === "op" && (t.value === "*" || t.value === "/")) {
this.pos++;
const r = this.factor();
l = { k: "bin", op: t.value, x: l, y: r };
}
else
break;
}
return l;
}
factor() {
const t = this.peek();
if (t && t.type === "op" && (t.value === "+" || t.value === "-")) {
this.pos++;
const x = this.factor();
return t.value === "-" ? { k: "un", op: "neg", x } : x;
}
return this.primary();
}
primary() {
const t = this.peek();
if (!t)
throw new tokenizer_1.ParseError("Unexpected end of formula");
if (t.type === "num") {
this.pos++;
return { k: "num", v: t.n };
}
if (t.type === "str") {
this.pos++;
return { k: "str", v: t.value };
}
if (t.type === "ref") {
this.pos++;
return { k: "ref", c: t.a.c, r: t.a.r };
}
if (t.type === "range") {
this.pos++;
return { k: "range", a: t.r.a, b: t.r.b };
}
if (t.type === "ident") {
this.pos++;
const up = t.value.toUpperCase();
if (up === "TRUE")
return { k: "bool", v: true };
if (up === "FALSE")
return { k: "bool", v: false };
if (up === "NULL" || up === "NIL")
return { k: "null" };
// function call?
if (this.eatOp("(")) {
const args = [];
if (!this.isOp(")")) {
args.push(this.expr());
while (this.eatOp(","))
args.push(this.expr());
}
if (!this.eatOp(")"))
throw new tokenizer_1.ParseError("Missing ')' for " + up + "()");
return { k: "fn", name: up, args };
}
// bare identifier -> treat as text
return { k: "str", v: t.value };
}
if (t.type === "op" && t.value === "(") {
this.pos++;
const x = this.expr();
if (!this.eatOp(")"))
throw new tokenizer_1.ParseError("Missing ')'");
return x;
}
if (t.type === "err") {
this.pos++;
return { k: "err", e: t.value };
}
if (t.type === "badref") {
this.pos++;
return { k: "err", e: "#REF!" };
}
throw new tokenizer_1.ParseError("Unexpected '" + t.value + "' at position " + t.p);
}
}
// Convenience: parse a formula body and list all the references it uses.
function parseFormula(src) {
const ast = Parser.parse(src);
const refs = [];
Parser.collectRefs(ast, refs);
return { ast, refs };
}
};
// ── module: src/evaluator.ts ──
__mods["src/evaluator.ts"] = function (exports, require, module) {
"use strict";
// =====================================================================
// EVALUATOR — walks the AST and computes a typed value.
//
// Values: number | string | boolean | null.
// Errors are first-class: { e: "#DIV/0!" | "#REF!" | "#VALUE!" | "#NAME?" }
// so that errors propagate through arithmetic (like real spreadsheets)
// without ever throwing past a cell boundary.
// =====================================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.E_NAME = exports.E_VALUE = exports.E_REF = exports.E_DIV0 = void 0;
exports.isErr = isErr;
exports.evalNode = evalNode;
exports.formatVal = formatVal;
exports.computeRaw = computeRaw;
const constants_1 = require("./constants");
const tokenizer_1 = require("./tokenizer");
const parser_1 = require("./parser");
exports.E_DIV0 = "#DIV/0!";
exports.E_REF = "#REF!";
exports.E_VALUE = "#VALUE!";
exports.E_NAME = "#NAME?";
function isErr(v) {
return typeof v === "object" && v !== null && v.e !== undefined;
}
// -----------------------------------------------------------------------
// Argument flattening: ranges expand to their (computed) values; other
// nodes are evaluated directly.
// -----------------------------------------------------------------------
function flattenArgs(args, getVal) {
const out = [];
const take = (n) => {
if (n.k === "range") {
for (let c = n.a.c; c <= n.b.c; c++)
for (let r = n.a.r; r <= n.b.r; r++)
out.push(getVal(c, r));
}
else {
out.push(evalNode(n, getVal));
}
};
args.forEach(take);
return out;
}
// Numeric coercion: numbers pass, booleans become 1/0, null/"" is 0,
// numeric text is parsed; everything else is #VALUE!.
function toNum(v, forArith) {
if (typeof v === "number")
return v;
if (typeof v === "boolean")
return v ? 1 : 0;
if (v === null)
return 0;
if (typeof v === "string") {
if (v.trim() === "")
return 0;
const n = Number(v);
if (isFinite(n))
return n;
return NaN;
}
// error: propagate in arithmetic; comparisons handled by caller
if (forArith)
throw new Error("ERR:" + v.e);
return NaN;
}
function evalNode(node, getVal) {
switch (node.k) {
case "num": return node.v;
case "str": return node.v;
case "bool": return node.v;
case "null": return null;
case "err": return { e: node.e };
case "ref": {
if (node.c < 0 || node.c >= constants_1.COLS || node.r < 0 || node.r >= constants_1.ROWS)
return { e: exports.E_REF };
return getVal(node.c, node.r);
}
case "range": {
// A bare range is not a scalar: in argument position it flattens;
// if evaluated directly (e.g. =A1:A3) it's a type error.
throw new Error("ERR:" + exports.E_VALUE);
}
case "un": {
if (node.op === "neg") {
const v = evalNode(node.x, getVal);
if (isErr(v))
return v;
const n = toNum(v, true);
return -n;
}
break;
}
case "tern": {
const c = evalNode(node.x, getVal);
if (isErr(c))
return c;
return truthy(c) ? evalNode(node.y, getVal) : evalNode(node.z, getVal);
}
case "bin": {
const x = evalNode(node.x, getVal);
const y = evalNode(node.y, getVal);
if (isErr(x) || isErr(y))
return x; // propagate the error
// string concatenation: & always concatenates; + concatenates only when
// a string operand is not numeric text (numeric text coerces like Excel)
const isNumStr = (v) => typeof v === "string" && v.trim() !== "" && isFinite(Number(v));
if (node.op === "&")
return disp(x) + disp(y);
if (node.op === "+" &&
((typeof x === "string" && !isNumStr(x)) || (typeof y === "string" && !isNumStr(y)))) {
return disp(x) + disp(y);
}
let nx, ny;
try {
nx = toNum(x, true);
ny = toNum(y, true);
}
catch (err) {
const m = err.message;
return { e: m.slice(4) };
}
switch (node.op) {
case "+": return nx + ny;
case "-": return nx - ny;
case "*": return nx * ny;
case "/": {
if (ny === 0)
return { e: exports.E_DIV0 };
return nx / ny;
}
case "==": return cmp(x, y) === 0;
case "!=": return cmp(x, y) !== 0;
case "<": {
const c = cmp(x, y);
if (c === null)
return { e: exports.E_VALUE };
return c < 0;
}
case ">": {
const c = cmp(x, y);
if (c === null)
return { e: exports.E_VALUE };
return c > 0;
}
case "<=": {
const c = cmp(x, y);
if (c === null)
return { e: exports.E_VALUE };
return c <= 0;
}
case ">=": {
const c = cmp(x, y);
if (c === null)
return { e: exports.E_VALUE };
return c >= 0;
}
default: break;
}
break;
}
case "fn": {
const vals = flattenArgs(node.args, getVal);
return callFn(node.name, vals);
}
default: break;
}
return { e: exports.E_VALUE };
}
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
function disp(v) {
if (v === null)
return "";
if (typeof v === "boolean")
return v ? "TRUE" : "FALSE";
return String(v);
}
function truthy(v) {
if (isErr(v))
return false;
if (typeof v === "number")
return v !== 0;
if (typeof v === "boolean")
return v;
if (typeof v === "string")
return v.length > 0;
return false;
}
// Cross-type comparison: number vs number, string vs string (case-
// insensitive, natural), booleans as 1/0, null as 0.
function cmp(a, b) {
if (typeof a === "number" && typeof b === "number")
return a - b;
if (typeof a === "boolean" && typeof b === "boolean")
return (a ? 1 : 0) - (b ? 1 : 0);
const as = disp(a);
const bs = disp(b);
// both parseable as numbers?
const an = Number(as), bn = Number(bs);
if (as.trim() !== "" && bs.trim() !== "" && isFinite(an) && isFinite(bn))
return an - bn;
return as.localeCompare(bs, undefined, { numeric: true, sensitivity: "base" });
}
// -----------------------------------------------------------------------
// Functions
// -----------------------------------------------------------------------
function numOr(v) {
return typeof v === "boolean" ? (v ? 1 : 0) : v;
}
function callFn(name, args) {
// numbers and booleans (booleans count as 1/0, like Excel arguments)
const nums = args.filter((v) => typeof v === "number" || typeof v === "boolean");
// error propagation: the first error argument wins (Excel behavior)
for (const a of args)
if (isErr(a))
return a;
switch (name) {
case "SUM": {
let t = 0;
for (const v of nums)
t += numOr(v);
return t;
}
case "AVG":
case "AVERAGE": {
if (nums.length === 0)
return { e: exports.E_DIV0 };
let t = 0;
for (const v of nums)
t += numOr(v);
return t / nums.length;
}
case "MIN": {
if (nums.length === 0)
return 0;
let t = Infinity;
for (const v of nums)
t = Math.min(t, numOr(v));
return t;
}
case "MAX": {
if (nums.length === 0)
return 0;
let t = -Infinity;
for (const v of nums)
t = Math.max(t, numOr(v));
return t;
}
case "COUNT":
return nums.length;
case "PRODUCT": {
let t = 1;
for (const v of nums)
t *= numOr(v);
return t;
}
case "ABS":
return Math.abs(toNum(args[0], true));
case "ROUND": {
const d = args.length > 1 ? toNum(args[1], true) : 0;
const f = Math.pow(10, d);
return Math.round(toNum(args[0], true) * f) / f;
}
case "IF":
return truthy(args[0]) ? args[1] : (args.length > 2 ? args[2] : false);
case "CONCATENATE":
case "CONCAT":
return args.map(disp).join("");
case "TEXT": {
const d = args.length > 1 ? disp(args[1]).replace(/"/g, "") : "0";
const n = toNum(args[0], true);
const dec = (d.match(/0\.\d*/g) || [])[0];
const digits = dec ? dec.length - 1 : 0;
return n.toFixed(digits);
}
default:
return { e: exports.E_NAME };
}
}
// -----------------------------------------------------------------------
// Format a computed value for display.
// -----------------------------------------------------------------------
function formatVal(v) {
if (v === null)
return "";
if (isErr(v))
return v.e;
if (typeof v === "boolean")
return v ? "TRUE" : "FALSE";
if (typeof v === "string")
return v;
if (!isFinite(v))
return "#NUM!";
// trim float noise: 0.30000000000000004 -> 0.3
let s = String(Math.round(v * 1e10) / 1e10);
if (s.length > 15)
s = v.toPrecision(12).replace(/(\.\d*?)0+/, "$1").replace(/\.$/, "");
return s;
}
// -----------------------------------------------------------------------
// Top-level entry: raw cell content -> computed value.
// "" -> null
// "5", "hi" -> literal
// "=..." -> parse + evaluate (parse failures become #ERR!)
// -----------------------------------------------------------------------
function computeRaw(raw, getVal) {
if (raw === null || raw === "")
return null;
if (raw[0] !== "=") {
// plain text; if it's a plain number the cell stays text? No —
// spreadsheet convention: "5" entered without '=' is a number.
const t = raw.trim();
if (t !== "" && isFinite(Number(t)))
return Number(t);
return raw;
}
try {
const { ast } = (0, parser_1.parseFormula)(raw.slice(1));
return evalNode(ast, getVal);
}
catch (err) {
if (err instanceof tokenizer_1.ParseError)
return { e: "#ERR!" };
if (err instanceof parser_1.RefError)
return { e: exports.E_REF };
const m = err.message;
if (m.startsWith("ERR:"))
return { e: m.slice(4) };
return { e: "#ERR!" };
}
}
};
// ── module: src/sheet.ts ──
__mods["src/sheet.ts"] = function (exports, require, module) {
"use strict";
// =====================================================================
// SHEET MODEL — raw cell contents, the dependency graph, and the
// incremental (topological) recalculation engine.
//
// Model:
// raw : number[][] — the user's exact input (formulas keep "=")
// ast : (Node|null)[][] — cached parse tree per formula cell
// deps : Set<number>[] — for each cell, the flat index set of cells it
// reads (from A/B-refs and ranges)
// dependents : Set<number>[] — reverse edges (who reads me)
// value: Val[][] — last computed value (cached, drives the UI)
// dirty: boolean[][] — cells whose value may be stale
//
// Recalculation:
// setCell() marks the cell and BFS its transitive dependents dirty.
// recalc() does an iterative DFS with explicit colors (white/gray/black)
// over only the dirty subgraph, computing in topological order. A gray
// node reached again is a cycle: every node in the back-edge's path
// becomes #CYCLE!. No recursion anywhere, so arbitrarily deep chains
// and cycles cannot overflow the stack or hang.
// =====================================================================
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.Sheet = exports.rowOf = exports.colOf = exports.idx = void 0;
const constants_1 = require("./constants");
const parser_1 = require("./parser");
const evaluator_1 = require("./evaluator");
const idx = (c, r) => r * constants_1.COLS + c;
exports.idx = idx;
const colOf = (i) => i % constants_1.COLS;
exports.colOf = colOf;
const rowOf = (i) => Math.floor(i / constants_1.COLS);
exports.rowOf = rowOf;
const WHITE = 0, GRAY = 1, BLACK = 2;
class Sheet {
constructor() {
this.raw = [];
this.ast = [];
this.deps = [];
this.dependents = [];
this.value = [];
this.dirty = [];
this.hooks = {};
for (let r = 0; r < constants_1.ROWS; r++) {
this.raw.push(new Array(constants_1.COLS).fill(null));
this.ast.push(new Array(constants_1.COLS).fill(null));
this.value.push(new Array(constants_1.COLS).fill(null));
this.dirty.push(new Array(constants_1.COLS).fill(false));
}
for (let i = 0; i < constants_1.COLS * constants_1.ROWS; i++) {
this.deps.push(new Set());
this.dependents.push(new Set());
}
}
// ---------------- raw access ------------------------------------------
getRaw(c, r) {
if (c < 0 || c >= constants_1.COLS || r < 0 || r >= constants_1.ROWS)
return null;
return this.raw[r][c];
}
getVal(c, r) {
if (c < 0 || c >= constants_1.COLS || r < 0 || r >= constants_1.ROWS)
return { e: evaluator_1.E_REF };
return this.value[r][c];
}
// Re-parse a formula's dependencies and rebuild its graph edges.
// `c,r` must be the cell's current position (after any shift).
rebuildDeps(c, r) {
const i = (0, exports.idx)(c, r);
// drop old outgoing + incoming edges
for (const d of this.deps[i])
this.dependents[d].delete(i);
this.deps[i].clear();
const raw = this.raw[r][c];
this.ast[r][c] = null;
if (!raw || raw[0] !== "=")
return;
try {
const { ast, refs } = (0, parser_1.parseFormula)(raw.slice(1));
this.ast[r][c] = ast;
for (const { c: rc, r: rr } of refs) {
if (rc < 0 || rc >= constants_1.COLS || rr < 0 || rr >= constants_1.ROWS) {
// reference outside the grid: the cell will show #REF!
continue;
}
const d = (0, exports.idx)(rc, rr);
if (d === i)
continue; // self-ref -> handled as cycle by recalc
this.deps[i].add(d);
this.dependents[d].add(i);
}
}
catch {
// parse failure: no deps, value will be #ERR!
this.ast[r][c] = null;
}
}
// ---------------- writes ---------------------------------------------
setCell(c, r, raw) {
if (c < 0 || c >= constants_1.COLS || r < 0 || r >= constants_1.ROWS)
return;
this.raw[r][c] = raw;
this.dirty[r][c] = true;
this.rebuildDeps(c, r);
// mark transitive dependents dirty (BFS over reverse edges)
const stack = [(0, exports.idx)(c, r)];
while (stack.length) {
const i = stack.pop();
for (const d of this.dependents[i]) {
if (!this.dirty[(0, exports.rowOf)(d)][(0, exports.colOf)(d)]) {
this.dirty[(0, exports.rowOf)(d)][(0, exports.colOf)(d)] = true;
stack.push(d);
}
}
}
this.recalc();
}
// ---------------- recalculation ---------------------------------------
// Iterative DFS with explicit colors over the dirty subgraph.
// Computes values in dependency order; reports #CYCLE! on cycles.
recalc() {
const n = constants_1.COLS * constants_1.ROWS;
// Dirty cells are unvisited (WHITE); clean cells are already done (BLACK).
const color = new Array(n);
for (let i = 0; i < n; i++)
color[i] = this.dirty[(0, exports.rowOf)(i)][(0, exports.colOf)(i)] ? WHITE : BLACK;
const changed = [];
// worklist: dirty cells, processed as DFS roots
const roots = [];
for (let r = 0; r < constants_1.ROWS; r++)
for (let c = 0; c < constants_1.COLS; c++)
if (this.dirty[r][c])
roots.push((0, exports.idx)(c, r));
for (const root of roots) {
if (!this.dirty[(0, exports.rowOf)(root)][(0, exports.colOf)(root)])
continue;
const stack = [{ i: root, it: null }];
color[root] = GRAY;
while (stack.length) {
const frame = stack[stack.length - 1];
const i = frame.i;
if (frame.it === null)
frame.it = this.deps[i].values();
let depDone = false;
let d = frame.it.next();
while (!d.done) {
const di = d.value;
const dcol = color[di];
if (dcol === WHITE) {
// not yet visited in this pass; must be dirty (it's a dep of
// a dirty cell, so it was marked) — push it
this.dirty[(0, exports.rowOf)(di)][(0, exports.colOf)(di)] = true;
color[di] = GRAY;
stack.push({ i: di, it: null });
depDone = true; // resume this frame after child returns
break;
}
else if (dcol === GRAY) {
// back edge -> cycle: i ... di. Mark everything on the path
// from di (inclusive) to i (inclusive) as #CYCLE!.
this.markCycle(stack, di, i);
depDone = true;
break;
}
// BLACK: already computed, skip
d = frame.it.next();
}
if (depDone)
continue;
// all deps processed (or none) -> compute this node
stack.pop();
color[i] = BLACK;
const c = (0, exports.colOf)(i), r = (0, exports.rowOf)(i);
const old = this.value[r][c];
const val = this.computeCell(c, r);
this.value[r][c] = val;
this.dirty[r][c] = false;
if (!sameVal(old, val))
changed.push(i);
}
}
// surface changes to the UI (batched)
for (const i of changed)
this.hooks.onCellChanged?.((0, exports.colOf)(i), (0, exports.rowOf)(i));
}
// Every cell on the gray stack path from `from` to `to` (both ends
// inclusive) is part of the cycle: force #CYCLE! on them.
markCycle(stack, from, to) {
let start = -1;
for (let s = 0; s < stack.length; s++) {
if (stack[s].i === from) {
start = s;
break;
}
}
const members = new Set();
if (start >= 0) {
for (let s = start; s < stack.length; s++)
members.add(stack[s].i);
}
else {
members.add(from);
members.add(to);
}
for (const m of members) {
const c = (0, exports.colOf)(m), r = (0, exports.rowOf)(m);
this.value[r][c] = { e: "#CYCLE!" };
this.dirty[r][c] = false;
// the value may be "unchanged" per sameVal, but the UI has never
// rendered it — force a UI update for every cycle member
this.hooks.onCellChanged?.(c, r);
}
}
// Compute one cell's value from raw content + current dep values.
computeCell(c, r) {
const raw = this.raw[r][c];
if (!raw)
return null;
if (raw[0] !== "=")
return (0, evaluator_1.computeRaw)(raw, (cc, rr) => this.getVal(cc, rr));
const ast = this.ast[r][c];
if (!ast)
return { e: "#ERR!" }; // unparseable formula
// fast path: cached AST; recompute by walking it with live getVal
return this.evalAst(c, r, ast);
}
// Walk the cached AST for cell (c,r) with live values; cycle guard:
// a dependency reading back into an ancestor would be #CYCLE! already
// (markCycle set it), and getVal returns it — but for the cell's own
// self-reference we must also guard here.
evalAst(c, r, ast) {
const self = (0, exports.idx)(c, r);
const getVal = (cc, rr) => {
const i = (0, exports.idx)(cc, rr);
if (i === self)
return { e: "#CYCLE!" }; // direct self-reference
if (cc < 0 || cc >= constants_1.COLS || rr < 0 || rr >= constants_1.ROWS)
return { e: evaluator_1.E_REF };
return this.value[rr][cc];
};
const { evalNode } = evalLazy();
try {
return evalNode(ast, getVal);
}
catch (err) {
const m = err.message;
if (m.startsWith("ERR:"))
return { e: m.slice(4) };
return { e: "#ERR!" };
}
}
// ---------------- structural operations ---------------------------------
// Insert/delete rows & columns. Formulas are rewritten so references
// follow the logical cell they pointed at; references that fall outside
// the grid (or point into the deleted band) become #REF! naturally.
forEachFormulaCell(fn) {
for (let r = 0; r < constants_1.ROWS; r++)
for (let c = 0; c < constants_1.COLS; c++) {
const raw = this.raw[r][c];
if (raw && raw[0] === "=") {
const out = fn(c, r, raw);
if (out !== raw)
this.raw[r][c] = out;
}
}
}
// Rewrite one formula string under a row/col transform.
// `rowAt(oldRow)` -> new row index, `colAt(oldCol)` -> new col index.
// Out-of-range results are flagged so the caller can emit #REF!.
static rewriteFormula(raw, rowAt, colAt) {
let out = "";
let i = 0;
const n = raw.length;
const isDigit = (s, k) => s[k] >= "0" && s[k] <= "9";
const isUpper = (s, k) => s[k] >= "A" && s[k] <= "Z";
while (i < n) {
const ch = raw[i];
// string literals pass through untouched
if (ch === '"') {
out += ch;
i++;
while (i < n) {
if (raw[i] === '"') {
out += raw[i];
if (raw[i + 1] === '"') {
out += raw[i + 1];
i += 2;
}
else {
i++;
break;
}
}
else {
out += raw[i++];
}
}
continue;
}
if (isUpper(raw, i)) {
// collect letters+digits chunk
const start = i;
while (i < n && isUpper(raw, i))
i++;
while (i < n && isDigit(raw, i))
i++;
const chunk = raw.slice(start, i);
// validate as cell ref
let cl = "", rl = "";
for (const cc of chunk)
(cc <= "9" ? (rl += cc) : (cl += cc));
const cc0 = cl.length ? (cl.length === 1 ? cl.charCodeAt(0) - 64 : (cl.charCodeAt(0) - 64) * 26 + (cl.charCodeAt(1) - 64)) : 0;
const rr0 = rl ? parseInt(rl, 10) : 0;
const validRef = cc0 >= 1 && cc0 <= constants_1.COLS && rr0 >= 1 && rr0 <= constants_1.ROWS;
if (validRef) {
// range?
if (i < n && raw[i] === ":") {
i++;
const s2 = i;
while (i < n && isUpper(raw, i))
i++;
while (i < n && isDigit(raw, i))
i++;
const chunk2 = raw.slice(s2, i);
let cl2 = "", rl2 = "";
for (const cc of chunk2)
(cc <= "9" ? (rl2 += cc) : (cl2 += cc));
const cc1 = cl2.length ? (cl2.length === 1 ? cl2.charCodeAt(0) - 64 : (cl2.charCodeAt(0) - 64) * 26 + (cl2.charCodeAt(1) - 64)) : 0;
const rr1 = rl2 ? parseInt(rl2, 10) : 0;
const validRef2 = cc1 >= 1 && cc1 <= constants_1.COLS && rr1 >= 1 && rr1 <= constants_1.ROWS;
const aC = colAt(cc0 - 1);
const aR = rowAt(rr0 - 1);
const bC = validRef2 ? colAt(cc1 - 1) : null;
const bR = validRef2 ? rowAt(rr1 - 1) : null;
if (aC === null || aR === null || bC === null || bR === null) {
out += "#REF!"; // the range is broken; whole range becomes #REF!
}
else {
const c1 = Math.min(aC, bC), c2 = Math.max(aC, bC);
const r1 = Math.min(aR, bR), r2 = Math.max(aR, bR);
out += Sheet.colLetter(c1 + 1) + (r1 + 1) + ":" + Sheet.colLetter(c2 + 1) + (r2 + 1);
}
}
else {
const aC = colAt(cc0 - 1);
const aR = rowAt(rr0 - 1);
if (aC === null || aR === null)
out += "#REF!";
else
out += Sheet.colLetter(aC + 1) + (aR + 1);
}
continue;
}
// not a valid ref: identifier (function name) — copy verbatim
out += chunk;
continue;
}
out += ch;
i++;
}
return out;
}
static colLetter(c1) {
let s = "";
let c = c1;
while (c > 0) {
const m = (c - 1) % 26;
s = String.fromCharCode(65 + m) + s;
c = Math.floor((c - 1) / 26);
}
return s;
}
// ---- row ops -----------------------------------------------------------
// Insert a new EMPTY row at index after+1; old rows after+1 and below
// shift down by one. No-op at the last row (nothing below to displace).
insertRow(after) {
if (after < 0 || after >= constants_1.ROWS - 1)
return;
for (let r = constants_1.ROWS - 1; r > after + 1; r--) {
for (let c = 0; c < constants_1.COLS; c++)
this.raw[r][c] = this.raw[r - 1][c];
}
for (let c = 0; c < constants_1.COLS; c++)
this.raw[after + 1][c] = null;
// rewrite formulas: old row R -> R+1 if R > after
this.forEachFormulaCell((_c, r, raw) => Sheet.rewriteFormula(raw, (oldR) => (oldR > after ? oldR + 1 : oldR), (oldC) => oldC));
this.markAllDirty();
this.hooks.onCellChanged?.(0, after + 1); // UI re-render signal
}
deleteRow(at) {
if (at === 0) {
for (let r = 0; r < constants_1.ROWS - 1; r++)
for (let c = 0; c < constants_1.COLS; c++)
this.raw[r][c] = this.raw[r + 1][c];
for (let c = 0; c < constants_1.COLS; c++)
this.raw[constants_1.ROWS - 1][c] = null;
}
else {
for (let r = at; r < constants_1.ROWS - 1; r++)
for (let c = 0; c < constants_1.COLS; c++)
this.raw[r][c] = this.raw[r + 1][c];
for (let c = 0; c < constants_1.COLS; c++)
this.raw[constants_1.ROWS - 1][c] = null;
}
// old row R -> R-1 if R > at ; old R == at -> gone
this.forEachFormulaCell((_c, r, raw) => Sheet.rewriteFormula(raw, (oldR) => (oldR === at ? null : oldR > at ? oldR - 1 : oldR), (oldC) => oldC));
this.markAllDirty();
this.hooks.onCellChanged?.(0, at);
}
// ---- column ops ----------------------------------------------------------
// Insert a new EMPTY column at index after+1; old columns after+1 and to
// the right shift right by one. No-op at the last column.
insertCol(after) {
if (after < 0 || after >= constants_1.COLS - 1)
return;
for (let c = constants_1.COLS - 1; c > after + 1; c--)
for (let r = 0; r < constants_1.ROWS; r++)
this.raw[r][c] = this.raw[r][c - 1];
for (let r = 0; r < constants_1.ROWS; r++)
this.raw[r][after + 1] = null;
this.forEachFormulaCell((_c, _r, raw) => Sheet.rewriteFormula(raw, (oldR) => oldR, (oldC) => (oldC > after ? oldC + 1 : oldC)));
this.markAllDirty();
this.hooks.onCellChanged?.(after + 1, 0);
}
deleteCol(at) {
if (at === 0) {
for (let c = 0; c < constants_1.COLS - 1; c++)
for (let r = 0; r < constants_1.ROWS; r++)
this.raw[r][c] = this.raw[r][c + 1];
for (let r = 0; r < constants_1.ROWS; r++)
this.raw[r][constants_1.COLS - 1] = null;
}
else {
for (let c = at; c < constants_1.COLS - 1; c++)
for (let r = 0; r < constants_1.ROWS; r++)
this.raw[r][c] = this.raw[r][c + 1];
for (let r = 0; r < constants_1.ROWS; r++)
this.raw[r][constants_1.COLS - 1] = null;
}
this.forEachFormulaCell((_c, _r, raw) => Sheet.rewriteFormula(raw, (oldR) => oldR, (oldC) => (oldC === at ? null : oldC > at ? oldC - 1 : oldC)));
this.markAllDirty();
this.hooks.onCellChanged?.(at, 0);
}
markAllDirty() {
for (let r = 0; r < constants_1.ROWS; r++)
for (let c = 0; c < constants_1.COLS; c++)
this.dirty[r][c] = true;
}
// Full re-parse + recalc (used after structural ops and load).
refreshAll() {
for (let r = 0; r < constants_1.ROWS; r++)
for (let c = 0; c < constants_1.COLS; c++) {
this.dirty[r][c] = true;
this.rebuildDeps(c, r);
}
this.recalc();
}
// ---------------- serialization -------------------------------------------
// Serializes RAW content (formulas, not values) to a compact string:
// "A1=5\nB1==A1*2\n..." — only non-empty cells.
serialize() {
const parts = [];
for (let r = 0; r < constants_1.ROWS; r++)
for (let c = 0; c < constants_1.COLS; c++) {
const v = this.raw[r][c];
if (v !== null && v !== "")
parts.push(Sheet.colLetter(c + 1) + (r + 1) + "\u0001" + v);
}
return parts.join("\u0002");
}
load(text) {
if (!text)
return;
for (let r = 0; r < constants_1.ROWS; r++)
for (let c = 0; c < constants_1.COLS; c++)
this.raw[r][c] = null;
const cells = text.split("\u0002");
for (const cell of cells) {
if (!cell)
continue;
const sep = cell.indexOf("\u0001");
const addr = cell.slice(0, sep);
const val = cell.slice(sep + 1);
const m = addr.match(/^([A-Z]+)([1-9][0-9]*)$/);
if (!m)
continue;
const c = colLetterToNum(m[1]) - 1;
const r = parseInt(m[2], 10) - 1;
if (c < 0 || c >= constants_1.COLS || r < 0 || r >= constants_1.ROWS)
continue;
this.raw[r][c] = val;
}
}
}
exports.Sheet = Sheet;
// ---------------- small helpers ------------------------------------------
function sameVal(a, b) {
if (a === b)
return true;
if ((0, evaluator_1.isErr)(a) && (0, evaluator_1.isErr)(b))
return a.e === b.e;
if ((0, evaluator_1.isErr)(a) || (0, evaluator_1.isErr)(b))
return false;
return a === b;
}
function colLetterToNum(s) {
let n = 0;
for (const ch of s)
n = n * 26 + (ch.charCodeAt(0) - 64);
return n;
}
// Lazy accessor avoids a circular import at module init time.
const evalMod = __importStar(require("./evaluator"));
function evalLazy() {
return evalMod;
}
};
// ── module: src/controller.ts ──
__mods["src/controller.ts"] = function (exports, require, module) {
"use strict";
// =====================================================================
// CONTROLLER — DOM construction, selection, editing, keyboard,
// right-click menus, structural row/col ops, and localStorage persistence.
// =====================================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.state = void 0;
exports.init = init;
const constants_1 = require("./constants");
const sheet_1 = require("./sheet");
const evaluator_1 = require("./evaluator");
// ---------------------------------------------------------------------
// Elements
// ---------------------------------------------------------------------
const $ = (id) => document.getElementById(id);
const wrap = $("gridwrap");
const table = $("sheet");
const namebox = $("namebox");
const fxInput = $("fx-input");
const cellInput = $("cell-input");
const editingDiv = $("editing");
const menu = $("menu");
const saveNote = $("save-note");
let ui;
let saveTimer = null;
// ---------------------------------------------------------------------
// DOM construction
// ---------------------------------------------------------------------
function buildDOM() {
const thead = document.createElement("thead");
const hr = document.createElement("tr");
const corner = document.createElement("th");
corner.className = "rh";
hr.appendChild(corner);
const colHeads = [];
for (let c = 0; c < constants_1.COLS; c++) {
const th = document.createElement("th");
th.className = "ch";
th.dataset.c = String(c);
th.textContent = constants_1.COL_LETTERS[c];
th.title = "Column " + constants_1.COL_LETTERS[c];
colHeads.push(th);
hr.appendChild(th);
}
thead.appendChild(hr);
table.appendChild(thead);
const tbody = document.createElement("tbody");
const cells = [];
const rowHeads = [];
for (let r = 0; r < constants_1.ROWS; r++) {
const tr = document.createElement("tr");
const th = document.createElement("th");
th.className = "rh";
th.dataset.r = String(r);
th.textContent = String(r + 1);
th.title = "Row " + (r + 1);
rowHeads.push(th);
tr.appendChild(th);
const rowCells = [];
for (let c = 0; c < constants_1.COLS; c++) {
const td = document.createElement("td");
td.dataset.c = String(c);
td.dataset.r = String(r);
rowCells.push(td);
tr.appendChild(td);
}
cells.push(rowCells);
tbody.appendChild(tr);
}
table.appendChild(tbody);
ui = {
sheet,
sel: { c: 0, r: 0 },
editing: null,
cells,
rowHeads,
colHeads,
};
}
const sheet = new sheet_1.Sheet();
// ---------------------------------------------------------------------
// Cell rendering
// ---------------------------------------------------------------------
function cellText(c, r) {
const v = sheet.getVal(c, r);
const td = ui.cells[r][c];
if ((0, evaluator_1.isErr)(v)) {
td.textContent = v.e;
td.className = "align-err" + selCls(c, r);
}
else if (v === null) {
td.textContent = "";
td.className = selCls(c, r);
}
else if (typeof v === "number" || typeof v === "boolean") {
td.textContent = (0, evaluator_1.formatVal)(v);
td.className = "align-right" + selCls(c, r);
}
else {
td.textContent = v;
td.className = "align-left" + selCls(c, r);
}
}
function selCls(c, r) {
return ui.sel.c === c && ui.sel.r === r ? " sel" : "";
}
function fullRedraw() {
for (let r = 0; r < constants_1.ROWS; r++)
for (let c = 0; c < constants_1.COLS; c++)
cellText(c, r);
syncSelectionUI();
syncFormulaBar();
}
function syncSelectionUI() {
const { c, r } = ui.sel;
namebox.textContent = constants_1.COL_LETTERS[c] + (r + 1);
for (let i = 0; i < constants_1.COLS; i++)
ui.colHeads[i].classList.toggle("hl", i === c);
for (let i = 0; i < constants_1.ROWS; i++)
ui.rowHeads[i].classList.toggle("hl", i === r);
const td = ui.cells[r][c];
td.scrollIntoView({ block: "nearest", inline: "nearest" });
}
function syncFormulaBar() {
const raw = sheet.getRaw(ui.sel.c, ui.sel.r);
if (ui.editing?.mode === "cell") {
// while editing in-cell, show the raw text (live-ish) in the bar
fxInput.value = raw ?? "";
fxInput.disabled = true;
}
else {
fxInput.value = raw ?? "";
fxInput.disabled = false;
}
}
// ---------------------------------------------------------------------
// Selection
// ---------------------------------------------------------------------
function setSelection(c, r) {
if (c < 0 || c >= constants_1.COLS || r < 0 || r >= constants_1.ROWS)
return;
const oldC = ui.sel.c, oldR = ui.sel.r;
const moved = oldC !== c || oldR !== r;
ui.sel = { c, r };
if (moved) {
// rewrite old cell (drops the "sel" class) and the new one (gains it)
cellText(oldC, oldR);
cellText(c, r);
}
syncSelectionUI();
syncFormulaBar();
}
function moveSelection(dc, dr) {
setSelection(Math.max(0, Math.min(constants_1.COLS - 1, ui.sel.c + dc)), Math.max(0, Math.min(constants_1.ROWS - 1, ui.sel.r + dr)));
}
// ---------------------------------------------------------------------
// Editing
// ---------------------------------------------------------------------
function startEdit(mode, initial) {
if (ui.editing)
commitEdit("blur", null, false);
const { c, r } = ui.sel;
const init = initial !== undefined ? initial : (sheet.getRaw(c, r) ?? "");
ui.editing = { c, r, mode };
if (mode === "cell") {
const td = ui.cells[r][c];
const wr = wrap.getBoundingClientRect();
const tr = td.getBoundingClientRect();
editingDiv.style.left = tr.left - wr.left + wrap.scrollLeft + "px";
editingDiv.style.top = tr.top - wr.top + wrap.scrollTop + "px";
editingDiv.style.width = tr.width + "px";
editingDiv.style.height = tr.height + "px";
editingDiv.classList.add("on");
cellInput.value = init;
cellInput.className = typeof Number(init) === "number" && init.trim() !== "" ? "align-right" : "";
cellInput.focus();
cellInput.select();
fxInput.disabled = true;
fxInput.value = init;
}
else {
fxInput.disabled = false;
fxInput.value = init;
fxInput.focus();
fxInput.select();
cellInput.disabled = true;
}
syncSelectionUI();
}
function commitEdit(source, moveDir, moveAfter) {
if (!ui.editing)
return;
const { c, r, mode } = ui.editing;
const input = mode === "cell" ? cellInput : fxInput;
const text = input.value;
ui.editing = null;
cellInput.disabled = false;
editingDiv.classList.remove("on");
const wasFormulaCell = ui.sel.c === c && ui.sel.r === r;
if (wasFormulaCell && text !== (sheet.getRaw(c, r) ?? "")) {
sheet.setCell(c, r, text);
}
else if (text !== (sheet.getRaw(c, r) ?? "")) {
// committed from fx editing a different cell — shouldn't happen, guard
sheet.setCell(c, r, text);
}
syncFormulaBar();
if (moveAfter && moveDir) {
setSelection(c + moveDir.dc, r + moveDir.dr);
}
void source;
}
function cancelEdit() {
if (!ui.editing)
return;
const { c, r, mode } = ui.editing;
ui.editing = null;
cellInput.disabled = false;
editingDiv.classList.remove("on");
syncFormulaBar();
if (mode === "fx")
fxInput.value = sheet.getRaw(c, r) ?? "";
}
// ---------------------------------------------------------------------
// Menus (right-click on headers)
// ---------------------------------------------------------------------
function closeMenu() {
menu.classList.remove("on");
menu.innerHTML = "";
}
function showMenu(x, y, items) {
closeMenu();
for (const item of items) {
const b = document.createElement("button");
b.textContent = item.label;
if (item.disabled)
b.disabled = true;
else
b.onclick = () => { closeMenu(); item.run(); };
menu.appendChild(b);
}
menu.classList.add("on");
menu.style.left = Math.min(x, window.innerWidth - 210) + "px";
menu.style.top = Math.min(y, window.innerHeight - menu.offsetHeight - 8) + "px";
}
// ---------------------------------------------------------------------
// Structural operations
// ---------------------------------------------------------------------
function afterStructuralOp() {
sheet.refreshAll();
fullRedraw();
saveSoon();
}
function insertRowAt(after) {
const a = Math.max(0, Math.min(constants_1.ROWS - 2, after));
if (ui.sel.r > a)
ui.sel.r++;
sheet.insertRow(a);
afterStructuralOp();
}
function deleteRowAt(at) {
if (at < 0 || at >= constants_1.ROWS)
return;
if (ui.sel.r > at)
ui.sel.r--;
sheet.deleteRow(at);
afterStructuralOp();
}
function insertColAt(after) {
const a = Math.max(0, Math.min(constants_1.COLS - 2, after));
if (ui.sel.c > a)
ui.sel.c++;
sheet.insertCol(a);
afterStructuralOp();
}
function deleteColAt(at) {
if (at < 0 || at >= constants_1.COLS)
return;
if (ui.sel.c > at)
ui.sel.c--;
sheet.deleteCol(at);
afterStructuralOp();
}
// ---------------------------------------------------------------------
// Persistence
// ---------------------------------------------------------------------
function saveNow() {
try {
localStorage.setItem(constants_1.STORAGE_KEY, JSON.stringify({ v: 1, data: sheet.serialize() }));
saveNote.textContent = "Saved";
saveNote.style.opacity = "1";
window.setTimeout(() => { saveNote.style.opacity = "0"; }, 1200);
}
catch {
saveNote.textContent = "Save failed";
}
}
function saveSoon() {
if (saveTimer !== null)
window.clearTimeout(saveTimer);
saveTimer = window.setTimeout(saveNow, 500);
}
function loadState() {
try {
const text = localStorage.getItem(constants_1.STORAGE_KEY);
if (!text)
return false;
const obj = JSON.parse(text);
if (obj.v !== 1 || typeof obj.data !== "string")
return false;
sheet.load(obj.data);
return true;
}
catch {
return false;
}
}
function seedSample() {
sheet.setCell(0, 0, "5");
sheet.setCell(1, 0, "=A1*2");
sheet.setCell(2, 0, "=SUM(A1:B1)");
sheet.setCell(0, 1, "hello");
sheet.setCell(1, 1, "=COUNT(A1:B1)&\" numbers above\"");
saveNow();
}
// ---------------------------------------------------------------------
// Wiring
// ---------------------------------------------------------------------
function onGridClick(e) {
const t = e.target;
if (t.tagName === "TD") {
const c = parseInt(t.dataset.c, 10);
const r = parseInt(t.dataset.r, 10);
if (ui.editing)
commitEdit("click", null, false);
setSelection(c, r);
return;
}
const th = t;
if (th.tagName === "TH") {
if (th.dataset.c !== undefined && th.dataset.r === undefined)
setSelection(parseInt(th.dataset.c, 10), ui.sel.r);
else if (th.dataset.r !== undefined)
setSelection(ui.sel.c, parseInt(th.dataset.r, 10));
}
}
function onDblClick(e) {
const t = e.target;
if (t.tagName === "TD") {
const c = parseInt(t.dataset.c, 10);
const r = parseInt(t.dataset.r, 10);
setSelection(c, r);
startEdit("cell");
}
}
function onContextMenu(e) {
const t = e.target;
if (t.tagName !== "TH")
return;
e.preventDefault();
if (t.dataset.c !== undefined && t.dataset.r === undefined) {
const c = parseInt(t.dataset.c, 10);
showMenu(e.clientX, e.clientY, [
{ label: "Insert column to the left", disabled: c === 0, run: () => insertColAt(c - 1) },
{ label: "Insert column to the right", disabled: c === constants_1.COLS - 1, run: () => insertColAt(c) },
{ label: "Delete column " + constants_1.COL_LETTERS[c], run: () => deleteColAt(c) },
]);
}
else if (t.dataset.r !== undefined) {
const r = parseInt(t.dataset.r, 10);
showMenu(e.clientX, e.clientY, [
{ label: "Insert row above", disabled: r === 0, run: () => insertRowAt(r - 1) },
{ label: "Insert row below", disabled: r === constants_1.ROWS - 1, run: () => insertRowAt(r) },
{ label: "Delete row " + (r + 1), run: () => deleteRowAt(r) },
]);
}
}
function onKeyDown(e) {
const target = e.target;
// ---- while editing in the cell input --------------------------------
if (ui.editing?.mode === "cell" && target === cellInput) {
if (e.key === "Enter") {
e.preventDefault();
commitEdit("enter", { dc: 0, dr: 1 }, true);
}
else if (e.key === "Tab") {
e.preventDefault();
commitEdit("tab", { dc: 1, dr: 0 }, true);
}
else if (e.key === "Escape") {
e.preventDefault();
cancelEdit();
}
return;
}
// ---- while editing in the formula bar --------------------------------
if (ui.editing && ui.editing.mode === "fx") {
if (e.key === "Enter") {
e.preventDefault();
commitEdit("enter", null, false);
}
else if (e.key === "Escape") {
e.preventDefault();
cancelEdit();
}
return;
}
// ---- not editing -------------------------------------------------------
if (target === fxInput || target === cellInput)
return;
switch (e.key) {
case "ArrowLeft":
e.preventDefault();
moveSelection(-1, 0);
return;
case "ArrowRight":
e.preventDefault();
moveSelection(1, 0);
return;
case "ArrowUp":
e.preventDefault();
moveSelection(0, -1);
return;
case "ArrowDown":
e.preventDefault();
moveSelection(0, 1);
return;
case "Enter":
e.preventDefault();
startEdit("cell");
return;
case "F2":
e.preventDefault();
startEdit("cell");
return;
case "Tab":
e.preventDefault();
moveSelection(e.shiftKey ? -1 : 1, 0);
return;
case "Delete":
case "Backspace":
e.preventDefault();
sheet.setCell(ui.sel.c, ui.sel.r, null);
saveSoon();
return;
default:
if (e.ctrlKey || e.metaKey || e.altKey)
return;
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
// start a fresh edit with the typed character
e.preventDefault();
startEdit("cell", e.key);
cellInput.setSelectionRange(cellInput.value.length, cellInput.value.length);
}
}
}
function wireEvents() {
table.addEventListener("click", onGridClick);
table.addEventListener("dblclick", onDblClick);
table.addEventListener("contextmenu", onContextMenu);
document.addEventListener("keydown", onKeyDown);
document.addEventListener("click", (e) => {
if (!menu.contains(e.target) && e.target.tagName !== "TH")
closeMenu();
});
window.addEventListener("scroll", () => {
if (ui.editing?.mode === "cell")
cancelEdit();
}, true);
cellInput.addEventListener("blur", () => {
// defer so a click on the grid (which commits first) wins
window.setTimeout(() => {
if (ui.editing?.mode === "cell")
commitEdit("blur", null, false);
}, 0);
});
fxInput.addEventListener("focus", () => {
// focusing the bar puts us in fx-edit mode for the selected cell
if (!ui.editing && document.activeElement === fxInput) {
// don't trap: editing in bar without explicit edit only on Enter/typing
}
});
fxInput.addEventListener("blur", () => {
window.setTimeout(() => {
if (ui.editing?.mode === "fx")
commitEdit("blur", null, false);
}, 0);
});
// typing directly in the formula bar starts an fx edit
fxInput.addEventListener("input", () => {
if (!ui.editing)
ui.editing = { c: ui.sel.c, r: ui.sel.r, mode: "fx" };
});
// while editing a cell, mirror every keystroke into the formula bar
cellInput.addEventListener("input", () => {
if (ui.editing?.mode === "cell")
fxInput.value = cellInput.value;
});
$("btn-insert-row").addEventListener("click", () => insertRowAt(ui.sel.r));
$("btn-delete-row").addEventListener("click", () => deleteRowAt(ui.sel.r));
$("btn-insert-col").addEventListener("click", () => insertColAt(ui.sel.c));
$("btn-delete-col").addEventListener("click", () => deleteColAt(ui.sel.c));
window.addEventListener("beforeunload", () => saveNow());
}
// ---------------------------------------------------------------------
// Start
// ---------------------------------------------------------------------
function init() {
sheet.hooks.onCellChanged = (c, r) => {
cellText(c, r);
};
buildDOM();
wireEvents();
const loaded = loadState();
if (!loaded)
seedSample();
sheet.refreshAll();
fullRedraw();
setSelection(0, 0);
}
exports.state = {
get sheet() { return sheet; },
get sel() { return ui ? ui.sel : { c: 0, r: 0 }; },
is: (c, r) => !!ui && ui.cells[r][c].textContent === (0, evaluator_1.formatVal)(sheet.getVal(c, r)) && (0, sheet_1.idx)(c, r) >= 0,
// force the debounced localStorage write (also used by tests)
flush: () => saveNow(),
};
};
// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"Qwen3.8","provider":"brigid","persona":"typescript","sessionId":"3f9cf177-505c-459d-b715-0851632397a4","tokensIn":5961237,"tokensOut":213253,"tokensTotal":6174490,"cost":0,"turns":93,"toolCalls":122,"failedToolCalls":3,"timestamp":"2026-09-09T05:15:26.329Z"} -->