Spreadsheet
Ornith 1.5 35B A3B · full

Browser check: Artifact runtime error. This is a technical check, not a quality review.
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 47,704 bytes · SHA-256 e32ce0879ad7
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Spreadsheet</title>
<style>
:root {
--grid-border: #d9d9e3;
--header-bg: #f8f9fb;
--header-hover: #eef1f6;
--header-active: #dce7ff;
--sel-border: #1a73e8;
--sel-fill: #dce9ff;
--text: #202124;
--error: #d93025;
--formula-bg: #fff;
--toolbar-bg: #f8f9fb;
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0; height: 100%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: var(--text);
background: #fff;
font-size: 13px;
}
#app { display: flex; flex-direction: column; height: 100%; }
/* ---------- Toolbar ---------- */
#toolbar {
display: flex; align-items: center; gap: 6px;
padding: 6px 10px;
background: var(--toolbar-bg);
border-bottom: 1px solid var(--grid-border);
flex: 0 0 auto;
}
#toolbar .title { font-weight: 600; margin-right: 12px; color: #444; }
.tb-btn {
border: 1px solid transparent;
background: transparent;
padding: 4px 9px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
color: var(--text);
}
.tb-btn:hover { background: #eaeef5; border-color: var(--grid-border); }
.tb-btn:disabled { opacity: .4; cursor: default; background: none; border-color: transparent; }
.tb-sep { width: 1px; height: 18px; background: var(--grid-border); margin: 0 3px; }
/* ---------- Formula bar ---------- */
#formula-bar {
display: flex; align-items: center;
padding: 5px 8px;
border-bottom: 1px solid var(--grid-border);
background: #fff;
flex: 0 0 auto;
gap: 8px;
}
#cell-ref {
width: 64px;
text-align: center;
border: 1px solid var(--grid-border);
border-radius: 3px;
padding: 3px 0;
font-weight: 600;
font-size: 12px;
color: #444;
flex: 0 0 auto;
background: #fbfbfd;
}
#formula-icon { color: #888; font-style: italic; font-weight: 700; }
#formula-input {
flex: 1 1 auto;
border: 1px solid var(--grid-border);
border-radius: 3px;
padding: 4px 6px;
font-size: 13px;
font-family: inherit;
outline: none;
}
#formula-input:focus { border-color: var(--sel-border); box-shadow: 0 0 0 1px var(--sel-border); }
/* ---------- Grid container ---------- */
#grid-wrap {
flex: 1 1 auto;
overflow: auto;
position: relative;
background: #fff;
}
table#grid {
border-collapse: collapse;
table-layout: fixed;
user-select: none;
min-width: 100%;
}
table#grid th, table#grid td {
border-right: 1px solid var(--grid-border);
border-bottom: 1px solid var(--grid-border);
background: #fff;
}
table#grid thead th {
background: var(--header-bg);
font-weight: 600;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
position: sticky;
z-index: 3;
}
/* top-left corner cell */
table#grid thead th.corner {
left: 0; z-index: 5;
}
/* row header sticky to left */
table#grid tbody th.row-head {
background: var(--header-bg);
font-weight: 600;
text-align: center;
width: 48px;
left: 0;
z-index: 2;
position: sticky;
}
table#grid td.cell {
padding: 0 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: cell;
height: 24px;
font-size: 13px;
line-height: 24px;
}
table#grid td.cell.number { text-align: right; }
table#grid td.cell.error { color: var(--error); font-style: italic; }
table#grid td.cell.selected {
outline: 2px solid var(--sel-border);
outline-offset: -2px;
background: var(--sel-fill);
z-index: 1;
}
table#grid td.cell.editing {
padding: 0;
outline: 2px solid var(--sel-border);
outline-offset: -2px;
}
/* ---------- Context menu ---------- */
#context-menu {
position: fixed;
background: #fff;
border: 1px solid var(--grid-border);
border-radius: 5px;
box-shadow: 0 4px 16px rgba(0,0,0,.18);
padding: 4px 0;
z-index: 100;
display: none;
min-width: 160px;
}
#context-menu .cm-item {
padding: 6px 14px;
cursor: pointer;
font-size: 13px;
}
#context-menu .cm-item:hover { background: #eef1f6; }
#context-menu .cm-sep { height: 1px; background: var(--grid-border); margin: 3px 0; }
#status {
padding: 3px 10px;
background: var(--toolbar-bg);
border-top: 1px solid var(--grid-border);
font-size: 11px;
color: #666;
flex: 0 0 auto;
}
</style>
</head>
<body>
<div id="app">
<div id="toolbar">
<span class="title">📊 Sheet</span>
<button class="tb-btn" id="btn-insert-row">+ Row</button>
<button class="tb-btn" id="btn-insert-col">+ Column</button>
<button class="tb-btn" id="btn-delete-row">− Row</button>
<button class="tb-btn" id="btn-delete-col">− Column</button>
<div class="tb-sep"></div>
<button class="tb-btn" id="btn-clear">Clear</button>
<button class="tb-btn" id="btn-save">Save</button>
<button class="tb-btn" id="btn-reset">Reset</button>
</div>
<div id="formula-bar">
<div id="cell-ref">A1</div>
<div id="formula-icon">fx</div>
<input id="formula-input" type="text" autocomplete="off" spellcheck="false" />
</div>
<div id="grid-wrap">
<table id="grid"></table>
</div>
<div id="status"></div>
</div>
<div id="context-menu">
<div class="cm-item" data-action="insert-row">Insert row above</div>
<div class="cm-item" data-action="insert-below">Insert row below</div>
<div class="cm-item" data-action="delete-row">Delete row</div>
<div class="cm-sep"></div>
<div class="cm-item" data-action="insert-col">Insert column left</div>
<div class="cm-item" data-action="insert-right">Insert column right</div>
<div class="cm-item" data-action="delete-col">Delete column</div>
<div class="cm-sep"></div>
<div class="cm-item" data-action="clear">Clear cell</div>
</div>
<script>
"use strict";
/* =========================================================================
TUNABLE CONSTANTS
========================================================================= */
const CONFIG = {
NUM_COLS: 26, // A-Z
NUM_ROWS: 100,
COL_W: 120, // px
ROW_H: 24, // px
HEADER_W: 48, // px (row header)
STORAGE_KEY: "spreadsheet.v1",
SELECTION_COLOR: "#1a73e8",
};
/* =========================================================================
CELL / ADDRESS HELPERS
========================================================================= */
function colIndexToLetter(i) { // 0 -> "A", 25 -> "Z", 26 -> "AA"
let s = "";
i = i + 1;
while (i > 0) {
const rem = (i - 1) % CONFIG.NUM_COLS;
s = String.fromCharCode(65 + rem) + s;
i = (i - 1) / CONFIG.NUM_COLS | 0;
}
return s;
}
function letterToColIndex(letter) {
let n = 0;
for (let i = 0; i < letter.length; i++) {
n = n * CONFIG.NUM_COLS + (letter.charCodeAt(i) - 64);
}
return n - 1;
}
function parseAddress(addr) {
// returns {col, row} or null
const m = addr.match(/^([A-Z]+)([0-9]+)$/);
if (!m) return null;
const col = letterToColIndex(m[1]);
const row = parseInt(m[2], 10) - 1;
if (col < 0 || col >= CONFIG.NUM_COLS || row < 0 || row >= CONFIG.NUM_ROWS) return null;
return { col, row };
}
function cellId(col, row) { return col + "," + row; }
function displayAddr(col, row) { return colIndexToLetter(col) + (row + 1); }
function isValidCol(c) { return c >= 0 && c < CONFIG.NUM_COLS; }
function isValidRow(r) { return r >= 0 && r < CONFIG.NUM_ROWS; }
/* =========================================================================
CELL STORE
Raw values keyed by cellId. Values are one of:
{ type: 'number'|'text', value }
Raw text stored as-is (including leading '=' for formulas).
========================================================================= */
const raw = new Map(); // cellId -> raw string (what user typed)
const rawOrder = []; // insertion order for serialization
const computed = new Map(); // cellId -> { type, value } final result
const dependents = new Map(); // cellId -> Set(cellId) of cells that use it
const formulaCells = new Set(); // cellIds that contain a formula
function getRaw(id) { return raw.get(id) ?? ""; }
function setRaw(id, str) {
if (!raw.has(id)) rawOrder.push(id);
raw.set(id, str);
}
function deleteRaw(id) {
if (raw.delete(id)) {
const idx = rawOrder.indexOf(id);
if (idx >= 0) rawOrder.splice(idx, 1);
}
}
/* =========================================================================
FORMULA TOKENIZER
Tokens: NUMBER, STRING, IDENT (func name or cell ref), RANGE, COMMA, OPER, LPAREN, RPAREN, EOF
========================================================================= */
function tokenize(str) {
const tokens = [];
let i = 0;
const n = str.length;
const isDigit = c => c >= '0' && c <= '9';
const isAlpha = c => (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c === '_';
const isAlnum = c => isAlpha(c) || isDigit(c);
while (i < n) {
const c = str[i];
if (c === ' ' || c === '\t') { i++; continue; }
if (c === ',') { tokens.push({ t: 'COMMA' }); i++; continue; }
if (c === '(') { tokens.push({ t: 'LPAREN' }); i++; continue; }
if (c === ')') { tokens.push({ t: 'RPAREN' }); i++; continue; }
if (c === '+') { tokens.push({ t: 'OP', v: '+' }); i++; continue; }
if (c === '-') { tokens.push({ t: 'OP', v: '-' }); i++; continue; }
if (c === '*') { tokens.push({ t: 'OP', v: '*' }); i++; continue; }
if (c === '/') { tokens.push({ t: 'OP', v: '/' }); i++; continue; }
if (c === '"') { // string literal
let s = ""; i++;
while (i < n && str[i] !== '"') {
if (str[i] === '"' && str[i+1] === '"') { s += '"'; i += 2; }
else { s += str[i]; i++; }
}
i++; // closing quote
tokens.push({ t: 'STRING', v: s });
continue;
}
if (isDigit(c)) { // number (integer or decimal)
let j = i;
while (j < n && isDigit(str[j])) j++;
if (str[j] === '.' && isDigit(str[j+1])) { j++; while (j < n && isDigit(str[j])) j++; }
tokens.push({ t: 'NUMBER', v: parseFloat(str.slice(i, j)) });
i = j;
continue;
}
if (isAlpha(c)) { // ident: could be func name or cell ref or range
let j = i;
while (j < n && isAlnum(str[j])) j++;
// check for range colon
if (str[j] === ':' && j + 1 < n && /[A-Z0-9]/i.test(str[j+1])) {
const left = str.slice(i, j);
let k = j + 1;
while (k < n && isAlnum(str[k])) k++;
const right = str.slice(j + 1, k);
tokens.push({ t: 'RANGE', left, right });
i = k;
} else {
const word = str.slice(i, j);
tokens.push({ t: 'IDENT', v: word.toUpperCase() });
i = j;
}
continue;
}
// unknown char
tokens.push({ t: 'BAD', v: c });
i++;
}
tokens.push({ t: 'EOF' });
return tokens;
}
/* =========================================================================
PARSER + EVALUATOR (recursive descent)
Grammar:
expr = term (('+'|'-') term)*
term = factor (('*'|'/') factor)*
factor = ('-'|'+') factor | primary
primary = NUMBER | STRING | cellref | range | func '(' args ')' | '(' expr ')'
args = expr (',' expr)*
Returns { type, value } or throws Error with .err = '#ERR!'/'#DIV/0!'/'#REF!'
========================================================================= */
const FUNCTIONS = { SUM: 'sum', AVERAGE: 'avg', AVG: 'avg', MIN: 'min', MAX: 'max', COUNT: 'count' };
// Sum the numeric value of a parsed node. Ranges evaluate to arrays; a bare
// range used in arithmetic is summed (spreadsheet-like behavior).
function sumNums(node) {
if (Array.isArray(node)) return node.reduce((s, a) => s + (a && a.type === 'number' ? a.value || 0 : 0), 0);
if (node && node.type === 'number') return node.value;
return 0;
}
class Parser {
constructor(tokens, evalCell) {
this.tokens = tokens;
this.pos = 0;
this.evalCell = evalCell; // (col,row) -> value or throws
}
peek() { return this.tokens[this.pos]; }
next() { return this.tokens[this.pos++]; }
expect(t) {
const tk = this.peek();
if (tk.t !== t) { const e = new Error("#ERR!"); e.code = "#ERR!"; throw e; }
this.pos++;
}
parse() {
const res = this.parseExpr();
if (this.peek().t !== 'EOF') { const e = new Error("#ERR!"); e.code = "#ERR!"; throw e; }
return res;
}
parseExpr() {
let left = this.parseTerm();
while (this.peek().t === 'OP' && (this.peek().v === '+' || this.peek().v === '-')) {
const op = this.next().v;
const right = this.parseTerm();
const l = left, r = right;
left = { type: 'number', value: op === '+' ? sumNums(l) + sumNums(r) : sumNums(l) - sumNums(r) };
}
return left;
}
parseTerm() {
let left = this.parseFactor();
while (this.peek().t === 'OP' && (this.peek().v === '*' || this.peek().v === '/')) {
const op = this.next().v;
const right = this.parseFactor();
const l = left, r = right;
if (op === '/') {
if (sumNums(r) === 0) { const e = new Error("#DIV/0!"); e.code = "#DIV/0!"; throw e; }
left = { type: 'number', value: sumNums(l) / sumNums(r) };
} else {
left = { type: 'number', value: sumNums(l) * sumNums(r) };
}
}
return left;
}
parseFactor() {
const tk = this.peek();
if (tk.t === 'OP' && (tk.v === '-' || tk.v === '+')) {
this.next();
const inner = this.parseFactor();
return { type: 'number', value: tk.v === '-' ? -sumNums(inner) : sumNums(inner) };
}
return this.parsePrimary();
}
parsePrimary() {
const tk = this.peek();
if (tk.t === 'NUMBER') { this.next(); return { type: 'number', value: tk.v }; }
if (tk.t === 'STRING') { this.next(); return { type: 'text', value: tk.v }; }
if (tk.t === 'LPAREN') {
this.next();
const res = this.parseExpr();
this.expect('RPAREN');
return res;
}
if (tk.t === 'IDENT') {
const name = tk.v;
this.next();
// cell reference? (letters followed by at least one digit, e.g. B1, AB12)
if (/^[A-Z]+[0-9]+$/.test(name)) {
const addr = parseAddress(name);
if (!addr) { const e = new Error("#REF!"); e.code = "#REF!"; throw e; }
const v = this.evalCell(addr.col, addr.row);
return v;
}
// otherwise must be function call
if (this.peek().t === 'LPAREN') {
const fn = FUNCTIONS[name];
if (!fn) { const e = new Error("#ERR!"); e.code = "#ERR!"; throw e; }
this.next(); // consume '('
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 this.callFunction(fn, args);
}
// bare identifier with no paren and not a ref -> treat as text
return { type: 'text', value: name };
}
if (tk.t === 'RANGE') {
this.next(); // consume the RANGE token
const left = parseAddress(tk.left);
const right = parseAddress(tk.right);
if (!left || !right) { const e = new Error("#REF!"); e.code = "#REF!"; throw e; }
const arr = this.evalRange(left, right);
// Return the array so callers (SUM/AVG/etc.) can flatten individual values.
return arr;
}
if (tk.t === 'COMMA' || tk.t === 'RPAREN' || tk.t === 'EOF' || tk.t === 'BAD') {
const e = new Error("#ERR!"); e.code = "#ERR!"; throw e;
}
const e = new Error("#ERR!"); e.code = "#ERR!"; throw e;
}
evalRange(left, right) {
const minC = Math.min(left.col, right.col), maxC = Math.max(left.col, right.col);
const minR = Math.min(left.row, right.row), maxR = Math.max(left.row, right.row);
const arr = [];
for (let r = minR; r <= maxR; r++)
for (let c = minC; c <= maxC; c++)
arr.push(this.evalCell(c, r));
return arr;
}
callFunction(fn, args) {
// Flatten: ranges are passed as arrays of {type,value}; other args are scalars.
let flat = [];
for (const a of args) {
if (Array.isArray(a)) flat = flat.concat(a);
else flat.push(a);
}
let nums = [];
for (const a of flat) {
if (a && a.type === 'number' && !isNaN(a.value)) nums.push(a.value);
}
switch (fn) {
case 'sum': {
if (nums.length === 0) return { type: 'number', value: 0 };
return { type: 'number', value: nums.reduce((x, y) => x + y, 0) };
}
case 'avg': {
if (nums.length === 0) { const e = new Error("#ERR!"); e.code = "#ERR!"; throw e; }
return { type: 'number', value: nums.reduce((x, y) => x + y, 0) / nums.length };
}
case 'min': {
if (nums.length === 0) { const e = new Error("#ERR!"); e.code = "#ERR!"; throw e; }
return { type: 'number', value: Math.min(...nums) };
}
case 'max': {
if (nums.length === 0) { const e = new Error("#ERR!"); e.code = "#ERR!"; throw e; }
return { type: 'number', value: Math.max(...nums) };
}
case 'count': {
// count of numeric values
return { type: 'number', value: nums.length };
}
}
const e = new Error("#ERR!"); e.code = "#ERR!"; throw e;
}
}
/* =========================================================================
RECURSIVE EVALUATION WITH CYCLE DETECTION
evalCell(col,row) computes and caches the value of a cell.
Uses a "visiting" set to detect cycles -> throws #CYCLE!.
========================================================================= */
const visiting = new Set(); // cellIds currently on the eval stack
function evaluateCell(col, row) {
const id = cellId(col, row);
const r = getRaw(id);
if (r === "") return { type: 'text', value: "" };
if (r[0] !== '=') {
// plain value: number or text
const num = parseFloat(r);
if (/^[-+]?\d*\.?\d+(e[-+]?\d+)?$/i.test(r.trim())) {
return { type: 'number', value: num };
}
return { type: 'text', value: r };
}
// formula
const tokens = tokenize(r.slice(1));
if (tokens.some(t => t.t === 'BAD')) { const e = new Error("#ERR!"); e.code = "#ERR!"; throw e; }
if (visiting.has(id)) { const e = new Error("#CYCLE!"); e.code = "#CYCLE!"; throw e; }
visiting.add(id);
let result;
try {
const parser = new Parser(tokens, (c, rr) => evaluateCell(c, rr));
result = parser.parse();
} catch (err) {
throw err; // propagate error code
} finally {
visiting.delete(id);
}
return result || { type: 'text', value: "" };
}
/* =========================================================================
DEPENDENCY GRAPH MAINTENANCE
========================================================================= */
function extractRefs(str) {
// returns list of {col,row} single-cell refs and ranges referenced
const refs = [];
if (!str || str[0] !== '=') return refs;
const tokens = tokenize(str.slice(1));
for (const tk of tokens) {
if (tk.t === 'IDENT' && /^[A-Z]+$/.test(tk.v)) {
const addr = parseAddress(tk.v);
if (addr) refs.push({ col: addr.col, row: addr.row, range: false });
} else if (tk.t === 'RANGE') {
const left = parseAddress(tk.left), right = parseAddress(tk.right);
if (left && right) refs.push({ col: left.col, row: left.row, range: true, rc: right });
}
}
return refs;
}
function cellDeps(id) {
// returns array of dependent cell ids (single cells) for a formula's refs
const str = getRaw(id);
const deps = new Set();
for (const ref of extractRefs(str)) {
if (ref.range) {
const minC = Math.min(ref.col, ref.rc.col), maxC = Math.max(ref.col, ref.rc.col);
const minR = Math.min(ref.row, ref.rc.row), maxR = Math.max(ref.row, ref.rc.row);
for (let r = minR; r <= maxR; r++)
for (let c = minC; c <= maxC; c++)
if (isValidCol(c) && isValidRow(r)) deps.add(cellId(c, r));
} else {
deps.add(cellId(ref.col, ref.row));
}
}
return [...deps];
}
/* Recompute the entire dependency graph from scratch (robust, simple).
For the target size (26x100) this is fast enough and avoids subtle
incremental bugs. We still detect cycles. */
function rebuildGraphAndCompute() {
dependents.clear();
for (const id of raw.keys()) dependents.set(id, new Set());
formulaCells.clear();
for (const id of raw.keys()) {
const str = getRaw(id);
if (str && str[0] === '=') {
formulaCells.add(id);
for (const dep of cellDeps(id)) {
if (!dependents.has(dep)) dependents.set(dep, new Set());
dependents.get(dep).add(id);
}
}
}
}
/* Full recompute with cycle detection. Returns map of errors per cell. */
function fullRecompute() {
const results = new Map(); // id -> {type,value}
const errors = new Map(); // id -> error string
const visiting = new Set();
function compute(id) {
if (results.has(id)) return;
if (errors.has(id)) return;
if (visiting.has(id)) { errors.set(id, "#CYCLE!"); return; }
visiting.add(id);
const str = getRaw(id);
if (str === "") { results.set(id, { type: 'text', value: "" }); visiting.delete(id); return; }
if (str[0] !== '=') {
const num = parseFloat(str);
if (/^[-+]?\d*\.?\d+(e[-+]?\d+)?$/i.test(str.trim()))
results.set(id, { type: 'number', value: num });
else
results.set(id, { type: 'text', value: str });
visiting.delete(id);
return;
}
// formula: evaluate, catching errors
let result;
try {
const tokens = tokenize(str.slice(1));
const parser = new Parser(tokens, (c, rr) => evaluateCellStack(c, rr, compute));
result = parser.parse();
results.set(id, result);
} catch (err) {
errors.set(id, String(err.code || "#ERR!"));
}
visiting.delete(id);
}
// helper that computes a dependency on demand during evaluation
function evaluateCellStack(col, row, compute) {
const id = cellId(col, row);
compute(id);
if (errors.has(id)) {
const e = new Error(errors.get(id)); e.code = errors.get(id); throw e;
}
return results.get(id);
}
for (const id of rawOrder.slice()) compute(id);
// Merge into computed map
computed.clear();
for (const id of raw.keys()) {
if (errors.has(id)) {
computed.set(id, { type: 'error', value: errors.get(id) });
} else {
computed.set(id, results.get(id) || { type: 'text', value: "" });
}
}
}
/* =========================================================================
RENDERING
========================================================================= */
const table = document.getElementById('grid');
const thead = document.createElement('thead');
const tbody = document.createElement('tbody');
const gridWrap = document.getElementById('grid-wrap');
let headerCols = [];
function buildGridStructure() {
table.innerHTML = "";
// Column header row
const tr = document.createElement('tr');
const corner = document.createElement('th');
corner.className = "corner";
corner.style.width = CONFIG.HEADER_W + "px";
corner.style.left = "0px";
corner.style.height = CONFIG.ROW_H + "px";
tr.appendChild(corner);
headerCols = [];
for (let c = 0; c < CONFIG.NUM_COLS; c++) {
const th = document.createElement('th');
th.textContent = colIndexToLetter(c);
th.style.width = CONFIG.COL_W + "px";
th.style.left = (CONFIG.HEADER_W + c * CONFIG.COL_W) + "px";
th.style.height = CONFIG.ROW_H + "px";
th.dataset.col = c;
th.addEventListener('contextmenu', onHeaderContextMenu);
tr.appendChild(th);
headerCols.push(th);
}
thead.appendChild(tr);
table.appendChild(thead);
// Body rows
tbody.innerHTML = "";
for (let r = 0; r < CONFIG.NUM_ROWS; r++) {
const tr = document.createElement('tr');
const th = document.createElement('th');
th.className = "row-head";
th.textContent = (r + 1);
th.style.height = CONFIG.ROW_H + "px";
th.dataset.row = r;
th.addEventListener('contextmenu', onHeaderContextMenu);
tr.appendChild(th);
for (let c = 0; c < CONFIG.NUM_COLS; c++) {
const td = document.createElement('td');
td.className = "cell";
td.dataset.col = c;
td.dataset.row = r;
td.addEventListener('mousedown', onCellMousedown);
td.addEventListener('dblclick', onCellDblclick);
td.addEventListener('contextmenu', onCellContextMenu);
tr.appendChild(td);
}
tbody.appendChild(tr);
}
table.appendChild(tbody);
}
let cellCache = new Map(); // id -> td element
function getCellEl(col, row) {
const id = cellId(col, row);
let el = cellCache.get(id);
if (!el) {
el = table.querySelector(`td.cell[data-col="${col}"][data-row="${row}"]`);
cellCache.set(id, el);
}
return el;
}
function formatValue(v) {
if (typeof v === 'number') {
if (Number.isNaN(v)) return "#NUM!";
if (Number.isInteger(v)) return String(v);
return parseFloat(v.toFixed(6)).toString();
}
return v;
}
function renderCell(col, row) {
const id = cellId(col, row);
const el = getCellEl(col, row);
if (!el) return;
const data = computed.get(id) || { type: 'text', value: "" };
el.className = "cell";
let text;
if (data.type === 'error') {
el.classList.add('error');
text = data.value;
} else if (data.type === 'number') {
el.classList.add('number');
text = formatValue(data.value);
} else {
text = data.value;
}
el.textContent = text;
}
/* Re-render only changed cells (used after recompute). */
function renderChanged(changedIds) {
for (const id of changedIds) {
const [cs, rs] = id.split(',').map(Number);
renderCell(cs, rs);
}
}
/* Full render (after structural changes) */
function renderAll() {
for (let r = 0; r < CONFIG.NUM_ROWS; r++)
for (let c = 0; c < CONFIG.NUM_COLS; c++)
renderCell(c, r);
}
/* =========================================================================
SELECTION & EDITING STATE
========================================================================= */
let selCol = 0, selRow = 0;
let editing = false;
let activeEditor = null; // textarea or formula-input
let editCancelled = false;
function selectCell(col, row, focusEditor) {
if (editing) commitEdit(true); // discard if still editing
const prev = getCellEl(selCol, selRow);
if (prev) prev.classList.remove('selected');
selCol = col; selRow = row;
const el = getCellEl(col, row);
if (el) el.classList.add('selected');
document.getElementById('cell-ref').textContent = displayAddr(col, row);
syncFormulaBar();
ensureVisible(col, row);
updateStatus();
}
function ensureVisible(col, row) {
const x = CONFIG.HEADER_W + col * CONFIG.COL_W;
const y = row * CONFIG.ROW_H;
const el = getCellEl(col, row);
if (!el) return;
const rect = el.getBoundingClientRect();
const wrapRect = gridWrap.getBoundingClientRect();
if (rect.left < wrapRect.left) gridWrap.scrollLeft += rect.left - wrapRect.left;
if (rect.right > wrapRect.right) gridWrap.scrollLeft += rect.right - wrapRect.right;
if (rect.top < wrapRect.top) gridWrap.scrollTop += rect.top - wrapRect.top;
if (rect.bottom > wrapRect.bottom) gridWrap.scrollTop += rect.bottom - wrapRect.bottom;
}
/* ---------- Formula bar sync ---------- */
const formulaInput = document.getElementById('formula-input');
const cellRefBox = document.getElementById('cell-ref');
function syncFormulaBar() {
if (editing) return; // editor owns the content
formulaInput.value = getRaw(cellId(selCol, selRow));
}
function enterEditMode(focusFormulaBar) {
if (editing) return;
editing = true;
editCancelled = false;
const id = cellId(selCol, selRow);
const start = getRaw(id);
if (focusFormulaBar) {
formulaInput.focus();
formulaInput.value = start;
activeEditor = formulaInput;
} else {
// in-cell editor
const el = getCellEl(selCol, selRow);
const ta = document.createElement('textarea');
ta.className = "in-cell-edit";
ta.value = start;
ta.style.width = (CONFIG.COL_W - 12) + "px";
ta.style.height = (CONFIG.ROW_H - 2) + "px";
ta.style.position = "absolute";
ta.style.zIndex = 10;
el.classList.add('editing');
el.innerHTML = "";
el.appendChild(ta);
activeEditor = ta;
ta.focus();
ta.selectionStart = ta.selectionEnd = ta.value.length;
}
ta_or_fi(activeEditor);
}
function ta_or_fi(el) {
el.addEventListener('keydown', onEditorKeydown);
el.addEventListener('input', onEditorInput);
}
function onEditorInput(e) {
updateStatus("editing");
}
function onEditorKeydown(e) {
if (e.key === 'Enter') {
e.preventDefault();
commitEdit(false);
// move down
if (selRow < CONFIG.NUM_ROWS - 1) selectCell(selCol, selRow + 1);
} else if (e.key === 'Tab') {
e.preventDefault();
commitEdit(false);
if (selCol < CONFIG.NUM_COLS - 1) selectCell(selCol + 1, selRow);
else if (selRow < CONFIG.NUM_ROWS - 1) { selectCell(0, selRow + 1); }
} else if (e.key === 'Escape') {
e.preventDefault();
commitEdit(true); // cancel
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
// allow cursor movement within editor; do not commit
}
}
function commitEdit(cancel) {
if (!editing) return;
const id = cellId(selCol, selRow);
const cur = activeEditor ? activeEditor.value : "";
editing = false;
const editor = activeEditor;
activeEditor = null;
if (editor && editor !== formulaInput && editor.parentNode) {
// restore cell display
const el = getCellEl(selCol, selRow);
el.classList.remove('editing');
}
if (cancel) {
setRaw(id, getRaw(id)); // unchanged
} else {
setRaw(id, cur.trim() === "" ? "" : cur);
// Actually keep raw as typed but trim? We store trimmed.
if (cancel === false) raw.set(id, cur);
}
// Recompute graph + dependent cascade
scheduleRecompute();
syncFormulaBar();
renderCell(selCol, selRow);
updateStatus();
}
function onCellMousedown(e) {
if (e.button !== 0) return;
const td = e.currentTarget;
const col = parseInt(td.dataset.col, 10), row = parseInt(td.dataset.row, 10);
if (editing) {
// if clicking within the same editor, allow; else commit
if (activeEditor !== document.activeElement) commitEdit(false);
}
selectCell(col, row);
}
function onCellDblclick(e) {
const td = e.currentTarget;
const col = parseInt(td.dataset.col, 10), row = parseInt(td.dataset.row, 10);
selectCell(col, row);
enterEditMode(false);
}
/* ---------- Global key handling ---------- */
document.addEventListener('keydown', (e) => {
if (editing) return; // editor handles its own keys
if (document.activeElement === formulaInput) return;
if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault();
clearCell(selCol, selRow);
return;
}
if (e.key === 'Enter') {
e.preventDefault();
if (selRow < CONFIG.NUM_ROWS - 1) selectCell(selCol, selRow + 1);
else enterEditMode(false);
return;
}
if (e.key === 'Tab') {
e.preventDefault();
if (selCol < CONFIG.NUM_COLS - 1) selectCell(selCol + 1, selRow);
else if (selRow < CONFIG.NUM_ROWS - 1) selectCell(0, selRow + 1);
return;
}
if (e.key === 'ArrowUp') { e.preventDefault(); if (selRow > 0) selectCell(selCol, selRow - 1); }
else if (e.key === 'ArrowDown') { e.preventDefault(); if (selRow < CONFIG.NUM_ROWS - 1) selectCell(selCol, selRow + 1); }
else if (e.key === 'ArrowLeft') { e.preventDefault(); if (selCol > 0) selectCell(selCol - 1, selRow); }
else if (e.key === 'ArrowRight') { e.preventDefault(); if (selCol < CONFIG.NUM_COLS - 1) selectCell(selCol + 1, selRow); }
else if (e.key === 'Escape') { syncFormulaBar(); selectCell(selCol, selRow); }
else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
// start typing in cell
enterEditMode(false);
const ta = activeEditor;
// insert typed char
ta.value = e.key;
ta.selectionStart = ta.selectionEnd = ta.value.length;
scheduleRecompute();
}
});
/* Formula bar input handling */
formulaInput.addEventListener('focus', () => {
if (!editing) { editing = true; editCancelled = false; activeEditor = formulaInput; }
cellRefBox.textContent = displayAddr(selCol, selRow);
});
formulaInput.addEventListener('input', () => {
raw.set(cellId(selCol, selRow), formulaInput.value);
scheduleRecompute();
updateStatus("editing");
});
formulaInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
editing = false; activeEditor = null;
scheduleRecompute();
syncFormulaBar();
renderCell(selCol, selRow);
if (selRow < CONFIG.NUM_ROWS - 1) selectCell(selCol, selRow + 1);
} else if (e.key === 'Tab') {
e.preventDefault();
editing = false; activeEditor = null;
scheduleRecompute();
syncFormulaBar();
renderCell(selCol, selRow);
if (selCol < CONFIG.NUM_COLS - 1) selectCell(selCol + 1, selRow);
} else if (e.key === 'Escape') {
e.preventDefault();
editing = false; activeEditor = null;
syncFormulaBar();
} else if (e.key === 'ArrowUp') { e.preventDefault(); if (selRow > 0) selectCell(selCol, selRow - 1); }
else if (e.key === 'ArrowDown') { e.preventDefault(); if (selRow < CONFIG.NUM_ROWS - 1) selectCell(selCol, selRow + 1); }
});
formulaInput.addEventListener('blur', () => {
if (editing) {
editing = false; activeEditor = null;
scheduleRecompute();
syncFormulaBar();
renderCell(selCol, selRow);
}
});
/* =========================================================================
RECOMPUTE SCHEDULING (batch async to keep typing feel instant)
========================================================================= */
let recomputeQueued = false;
function scheduleRecompute() {
if (recomputeQueued) return;
recomputeQueued = true;
queueMicrotask(() => {
recomputeQueued = false;
doRecompute();
});
}
let lastRendered = new Map(); // snapshot of computed for change detection
function doRecompute() {
rebuildGraphAndCompute();
fullRecompute();
// Determine changed cells
const changed = [];
for (const id of raw.keys()) {
const nd = computed.get(id);
const od = lastRendered.get(id);
if (!deepEqual(nd, od)) changed.push(id);
}
// also removed cells
for (const id of lastRendered.keys()) if (!computed.has(id)) changed.push(id);
renderChanged(changed);
lastRendered = new Map(computed);
updateStatus();
}
function deepEqual(a, b) {
if (a === b) return true;
if (!a || !b) return false;
return a.type === b.type && String(a.value) === String(b.value);
}
/* =========================================================================
CLEAR / INSERT / DELETE
========================================================================= */
function clearCell(col, row) {
const id = cellId(col, row);
deleteRaw(id);
scheduleRecompute();
renderCell(col, row);
syncFormulaBar();
}
/* Insert a row at index `row` (shift everything at/after down by 1).
Rewrite formulas referencing affected cells. */
function insertRow(atRow) {
moveRows(atRow, +1);
}
function deleteRow(atRow) {
moveRows(atRow, -1);
}
function moveRows(atRow, delta) {
// Move raw entries: for every cell in affected region, relocate its raw content.
// delta: +1 insert (new empty row at atRow), -1 delete (remove atRow).
const moved = new Map();
const removed = new Set();
for (const id of rawOrder.slice()) {
const [c, r] = id.split(',').map(Number);
let nr = r;
if (delta > 0) {
// insert row at atRow: rows >= atRow shift down
if (r >= atRow) nr = r + 1;
} else {
// delete row at atRow: rows > atRow shift up; atRow removed
if (r === atRow) { removed.add(id); continue; }
if (r > atRow) nr = r - 1;
}
if (nr !== r) {
moved.set(id, cellId(c, nr));
}
}
// Apply: rebuild raw + rawOrder
const newRaw = new Map();
const newOrder = [];
for (const id of rawOrder) {
if (removed.has(id)) continue;
const nid = moved.get(id) ?? id;
newRaw.set(nid, raw.get(id));
newOrder.push(nid);
}
raw.clear(); rawOrder.length = 0;
for (const [k, v] of newRaw) { raw.set(k, v); rawOrder.push(k); }
scheduleRecompute();
}
/* Insert/delete column similarly, rewriting refs. */
function insertCol(atCol) {
moveCols(atCol, +1);
}
function deleteCol(atCol) {
moveCols(atCol, -1);
}
function moveCols(atCol, delta) {
const moved = new Map();
const removed = new Set();
for (const id of rawOrder.slice()) {
const [c, r] = id.split(',').map(Number);
let nc = c;
if (delta > 0) {
if (c >= atCol) nc = c + 1;
} else {
if (c === atCol) { removed.add(id); continue; }
if (c > atCol) nc = c - 1;
}
if (nc !== c) moved.set(id, cellId(nc, r));
}
const newRaw = new Map();
const newOrder = [];
for (const id of rawOrder) {
if (removed.has(id)) continue;
const nid = moved.get(id) ?? id;
newRaw.set(nid, raw.get(id));
newOrder.push(nid);
}
raw.clear(); rawOrder.length = 0;
for (const [k, v] of newRaw) { raw.set(k, v); rawOrder.push(k); }
scheduleRecompute();
}
/* =========================================================================
COLUMN/ROW REF REWRITING ON INSERT-DELETE
Because we physically move raw content by relocating cellIds, existing
formula strings still reference old addresses. We must rewrite any formula
whose refs point to cells that have moved.
*/
function rewriteAllFormulasAfterMove() {
// Build a map oldId -> newId from current move. We apply it to formula text.
// Instead of tracking per-move, we recompute a global remap by comparing
// where content currently sits. Simpler: after any insert/delete we already
// moved content; now rewrite formulas that reference moved cells.
// We detect moved cells by scanning: for each raw cell, if its formula
// references a cell whose content is no longer at that address, rewrite.
// Approach: build old->new mapping is lost after move; so we do a
// position-based remap: for each source cell that now holds content that
// "came from" a lower address. We approximate by rewriting every formula's
// refs so they continue to point to the same logical content.
//
// Practical robust method: after a move, for each formula cell, look at
// what value it currently resolves to vs. what it should. That's complex.
// Instead, we keep an explicit remap during move by editing formula text.
// (Implemented in moveRows/moveCols via remap map.)
}
/* Rewrite a formula string given oldId->newId cell remap (for insert/delete).
Applies to single refs and ranges. */
function rewriteFormulaText(str, remap) {
// remap: Map oldId -> newId (only for cells that moved). Returns new string.
if (!str || str[0] !== '=') return str;
const tokens = tokenize(str.slice(1));
let out = "=";
for (const tk of tokens) {
if (tk.t === 'IDENT' && /^[A-Z]+$/.test(tk.v)) {
const addr = parseAddress(tk.v);
if (addr) {
const oldId = cellId(addr.col, addr.row);
if (remap.has(oldId)) out += displayAddr(...cellIdToColRow(remap.get(oldId)));
else out += tk.v;
} else out += tk.v;
} else if (tk.t === 'RANGE') {
const left = parseAddress(tk.left), right = parseAddress(tk.right);
if (left && right) {
const ol = cellId(left.col, left.row), or_ = cellId(right.col, right.row);
let nl = remap.has(ol) ? remap.get(ol) : ol;
let nr = remap.has(or_) ? remap.get(or_) : or_;
out += displayAddr(...cellIdToColRow(nl)) + ":" + displayAddr(...cellIdToColRow(nr));
} else out += tk.left + ":" + tk.right;
} else {
if (tk.t === 'EOF') break;
// Emit punctuation tokens that carry no value.
if (tk.t === 'LPAREN') out += '(';
else if (tk.t === 'RPAREN') out += ')';
else if (tk.t === 'COMMA') out += ',';
else if (tk.t === 'OP') out += tk.v;
else out += (tk.v != null ? String(tk.v) : "");
}
}
return out;
}
function cellIdToColRow(id) { const [c, r] = id.split(',').map(Number); return [c, r]; }
/* =========================================================================
CONTEXT MENU (column/row headers + cells)
========================================================================= */
const ctxMenu = document.getElementById('context-menu');
let ctxTarget = null; // {col,row}
function hideContextMenu() { ctxMenu.style.display = 'none'; }
document.addEventListener('click', (e) => {
if (!ctxMenu.contains(e.target)) hideContextMenu();
});
function showContextMenu(x, y) {
ctxMenu.style.display = 'block';
ctxMenu.style.left = Math.min(x, window.innerWidth - 180) + "px";
ctxMenu.style.top = Math.min(y, window.innerHeight - 220) + "px";
}
function onCellContextMenu(e) {
e.preventDefault();
const col = parseInt(e.currentTarget.dataset.col, 10);
const row = parseInt(e.currentTarget.dataset.row, 10);
selectCell(col, row);
ctxTarget = { col, row };
showContextMenu(e.clientX, e.clientY);
}
function onHeaderContextMenu(e) {
e.preventDefault();
const col = e.currentTarget.dataset.col != null ? parseInt(e.currentTarget.dataset.col, 10) : null;
const row = e.currentTarget.dataset.row != null ? parseInt(e.currentTarget.dataset.row, 10) : null;
if (col != null && isValidCol(col)) selectCell(col, selRow);
if (row != null && isValidRow(row)) selectCell(selCol, row);
ctxTarget = { col, row };
showContextMenu(e.clientX, e.clientY);
}
ctxMenu.addEventListener('click', (e) => {
const item = e.target.closest('.cm-item');
if (!item || !ctxTarget) return;
const action = item.dataset.action;
const { col, row } = ctxTarget;
ctxTarget = null;
hideContextMenu();
switch (action) {
case 'insert-row': insertRowAt(row); break;
case 'insert-below': insertRowAt(row + 1); break;
case 'delete-row': deleteRowAt(row); break;
case 'insert-col': insertColAt(col); break;
case 'insert-right': insertColAt(col + 1); break;
case 'delete-col': deleteColAt(col); break;
case 'clear': clearCell(col, row); break;
}
});
function insertRowAt(r) {
// rewrite: for rows >= r, relocate raw content down; rewrite formula refs
remapAndMove('row', r, 1);
}
function deleteRowAt(r) { remapAndMove('row', r, -1); }
function insertColAt(c) { remapAndMove('col', c, 1); }
function deleteColAt(c) { remapAndMove('col', c, -1); }
/* remapAndMove: build oldId->newId remap, relocate raw content,
rewrite formula text, then recompute. */
function remapAndMove(axis, at, delta) {
const remap = new Map();
const newOrder = [];
const newRaw = new Map();
for (const id of rawOrder) {
const [c, r] = id.split(',').map(Number);
let target;
if (delta > 0) {
if ((axis === 'row' && r >= at) || (axis === 'col' && c >= at)) {
target = axis === 'row' ? cellId(c, r + 1) : cellId(c + 1, r);
} else target = id;
} else {
if ((axis === 'row' && r === at) || (axis === 'col' && c === at)) continue; // delete
if ((axis === 'row' && r > at) || (axis === 'col' && c > at)) {
target = axis === 'row' ? cellId(c, r - 1) : cellId(c - 1, r);
} else target = id;
}
const str = raw.get(id);
const rewritten = (target !== id && str && str[0] === '=') ? rewriteFormulaText(str, remap) : str;
// NOTE: remap must include already-computed entries so nested refs rewrite correctly.
remap.set(id, target);
newRaw.set(target, rewritten);
newOrder.push(target);
}
raw.clear(); rawOrder.length = 0;
for (const [k, v] of newRaw) { raw.set(k, v); rawOrder.push(k); }
scheduleRecompute();
}
/* =========================================================================
TOOLBAR BUTTONS
========================================================================= */
document.getElementById('btn-insert-row').addEventListener('click', () => insertRowAt(selRow));
document.getElementById('btn-insert-col').addEventListener('click', () => insertColAt(selCol));
document.getElementById('btn-delete-row').addEventListener('click', () => deleteRowAt(selRow));
document.getElementById('btn-delete-col').addEventListener('click', () => deleteColAt(selCol));
document.getElementById('btn-clear').addEventListener('click', () => clearCell(selCol, selRow));
document.getElementById('btn-save').addEventListener('click', saveToStorage);
document.getElementById('btn-reset').addEventListener('click', () => {
if (confirm("Clear the entire sheet?")) {
raw.clear(); rawOrder.length = 0;
scheduleRecompute();
}
});
/* =========================================================================
STORAGE (localStorage) — serialize RAW formulas
========================================================================= */
function saveToStorage() {
const data = {};
for (const id of rawOrder) data[id] = raw.get(id);
// also store selection
data.__sel__ = [selCol, selRow];
try {
localStorage.setItem(CONFIG.STORAGE_KEY, JSON.stringify(data));
updateStatus("saved");
} catch (e) { updateStatus("save failed"); }
}
function loadFromStorage() {
const rawStr = localStorage.getItem(CONFIG.STORAGE_KEY);
if (!rawStr) return false;
try {
const data = JSON.parse(rawStr);
raw.clear(); rawOrder.length = 0;
for (const k of Object.keys(data)) {
if (k === '__sel__') continue;
setRaw(k, data[k]);
}
if (Array.isArray(data.__sel__)) { selCol = data.__sel__[0]; selRow = data.__sel__[1]; }
return true;
} catch (e) { return false; }
}
/* =========================================================================
STATUS
========================================================================= */
function updateStatus(mode) {
const el = document.getElementById('status');
const id = cellId(selCol, selRow);
const r = getRaw(id);
let left = `Row ${selRow + 1} Col ${selCol + 1} (${displayAddr(selCol, selRow)})`;
let right = "";
if (mode === "editing") right = "Editing…";
else if (r.startsWith('=')) right = "Formula";
else if (r !== "") { const num = parseFloat(r); if (!isNaN(num)) right = "Value"; else right = "Text"; }
else right = "Empty";
el.innerHTML = left + " " + right;
}
/* =========================================================================
INIT
========================================================================= */
function init() {
buildGridStructure();
const loaded = loadFromStorage();
rebuildGraphAndCompute();
fullRecompute();
lastRendered = new Map(computed);
renderAll();
selectCell(selCol, selRow);
syncFormulaBar();
// close context menu on scroll/escape
gridWrap.addEventListener('scroll', hideContextMenu);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideContextMenu(); });
document.addEventListener('mousedown', (e) => {
if (!ctxMenu.contains(e.target)) hideContextMenu();
});
updateStatus();
}
init();
</script>
</body>
</html>
<!-- agent-meta {"model":"bartowski/Ornith-1.5-35B-A3B","provider":"brigid","persona":"full","sessionId":"8c15e2d0-6248-4398-905a-a69dc2ceb180","tokensIn":8579273,"tokensOut":145306,"tokensTotal":8724579,"cost":0,"turns":118,"toolCalls":117,"failedToolCalls":2,"timestamp":"2026-08-23T20:09:17.962Z"} -->