/** * T-146 — table row/column operations. * * A table block's content is `{ rows: string[][], width? }` — a jagged 2-D * array of cell strings (the FIRST row is the header row by convention, * matching the existing web table renderer). These are PURE functions: * they take a content object and return a NEW one (the codec + batch save * path already treat content as immutable). * * Row/column operations are the unit of meaning for a table (Ponytail: no * per-cell cursor model, no selection state — the toolbar targets whole * rows/columns by index). */ export interface TableContent { rows: string[][]; width?: number; } export class TableOpError extends Error {} function asTable(content: unknown): TableContent { if (typeof content !== "object" || content === null) throw new TableOpError("table content must be an object"); const c = content as Record; if (!Array.isArray(c.rows)) throw new TableOpError("table content.rows must be an array"); for (const r of c.rows) { if (!Array.isArray(r) || !r.every((x) => typeof x === "string")) throw new TableOpError("table content.rows must be string[][]"); } const t: TableContent = { rows: (c.rows as string[][]).map((r) => [...r]) }; if (typeof c.width === "number") t.width = c.width; return t; } function normalizeWidth(rows: string[][]): string[][] { // every row padded/truncated to the widest row (keeps the grid rectangular // after a column op — the renderer indexes [row][col] without bounds). const w = rows.reduce((m, r) => Math.max(m, r.length), 0); return rows.map((r) => { const out = r.slice(0, w); while (out.length < w) out.push(""); return out; }); } /** Number of columns in the (rectangularized) table. */ export function tableColumns(content: unknown): number { const t = asTable(content); return t.rows.reduce((m, r) => Math.max(m, r.length), 0); } /** Number of rows (header row included). */ export function tableRows(content: unknown): number { return asTable(content).rows.length; } function withRows(content: unknown, rows: string[][]): TableContent { const t = asTable(content); const out: TableContent = { rows: normalizeWidth(rows) }; if (t.width !== undefined) out.width = t.width; return out; } /** Insert a new (empty) row after `afterRowIndex` (-1 = at the top). */ export function insertRow(content: unknown, afterRowIndex: number): TableContent { const t = asTable(content); const w = tableColumns(t); const idx = afterRowIndex === -1 ? 0 : afterRowIndex + 1; if (idx < 0 || idx > t.rows.length) throw new TableOpError(`row index out of range: ${afterRowIndex}`); const rows = [...t.rows]; rows.splice(idx, 0, new Array(w).fill("")); return withRows(t, rows); } /** Remove the row at `rowIndex`. Keeps at least one row (the header). */ export function removeRow(content: unknown, rowIndex: number): TableContent { const t = asTable(content); if (rowIndex < 0 || rowIndex >= t.rows.length) throw new TableOpError(`row index out of range: ${rowIndex}`); if (t.rows.length <= 1) throw new TableOpError("cannot remove the only row"); const rows = t.rows.filter((_, i) => i !== rowIndex); return withRows(t, rows); } /** Move the row at `from` to sit after `toIndex` (swap positions in order). */ export function moveRow(content: unknown, from: number, toIndex: number): TableContent { const t = asTable(content); if (from < 0 || from >= t.rows.length) throw new TableOpError(`from row out of range: ${from}`); if (toIndex < 0 || toIndex >= t.rows.length) throw new TableOpError(`to row out of range: ${toIndex}`); const rows = [...t.rows]; const moved = rows.splice(from, 1)[0]; if (moved === undefined) throw new TableOpError(`from row out of range: ${from}`); rows.splice(toIndex, 0, moved); return withRows(t, rows); } /** Insert a new (empty) column after `afterColIndex` (-1 = at the left). */ export function insertColumn(content: unknown, afterColIndex: number): TableContent { const t = asTable(content); const idx = afterColIndex === -1 ? 0 : afterColIndex + 1; const rows = normalizeWidth(t.rows).map((r) => { const out = [...r]; out.splice(idx, 0, ""); return out; }); return withRows(t, rows); } /** Remove the column at `colIndex`. Keeps at least one column. */ export function removeColumn(content: unknown, colIndex: number): TableContent { const t = asTable(content); const w = tableColumns(t); if (colIndex < 0 || colIndex >= w) throw new TableOpError(`column index out of range: ${colIndex}`); if (w <= 1) throw new TableOpError("cannot remove the only column"); const rows = normalizeWidth(t.rows).map((r) => r.filter((_, i) => i !== colIndex)); return withRows(t, rows); } /** Move the column at `from` to position `toIndex`. */ export function moveColumn(content: unknown, from: number, toIndex: number): TableContent { const t = asTable(content); const w = tableColumns(t); if (from < 0 || from >= w) throw new TableOpError(`from column out of range: ${from}`); if (toIndex < 0 || toIndex >= w) throw new TableOpError(`to column out of range: ${toIndex}`); const rows = normalizeWidth(t.rows).map((r) => { const out = [...r]; const c = out.splice(from, 1)[0] ?? ""; out.splice(toIndex, 0, c); return out; }); return withRows(t, rows); } /** Set a single cell (row, col) — clamped, no grid resize. */ export function setCell(content: unknown, row: number, col: number, value: string): TableContent { const t = asTable(content); const w = tableColumns(t); if (row < 0 || row >= t.rows.length) throw new TableOpError(`row out of range: ${row}`); if (col < 0 || col >= w) throw new TableOpError(`column out of range: ${col}`); const rows = normalizeWidth(t.rows).map((r, i) => (i === row ? r.map((c, j) => (j === col ? value : c)) : r)); return withRows(t, rows); }