AND/OR/NOT, Fehler-Literale und Zeilen/Spalten einfügen/löschen
- AND(...)/OR(...)/NOT(...) als gewöhnliche Funktionen (kein Kurzschluss- Sonderfall wie IF/XLOOKUP nötig, werten wie in echtem Excel alle Argumente eager aus). Dabei die bisher dreifach duplizierte Wahrheits-Prüfung zu functions.ts#isTruthy() zusammengezogen, jetzt von FUNCTIONS.IF, evalIf() und dem Block-IF-Statement gemeinsam genutzt. Schließt "kein AND/OR für zusammengesetzte Bedingungen" aus den Bekannten Grenzen, auch nutzbar in "Mit Schleife"-IF-Bedingungen. - Neue Grammatik-Ergänzung: ErrorLiteralNode. Jeder der sieben Fehlercodes (#REF, #DIV/0!, #NAME?, #ERR, #CIRC, #VALUE!, #N/A) lässt sich jetzt direkt in eine Formel tippen (=IF(A1=0;#N/A;A1)) und wertet unbedingt zu genau diesem Fehler aus. - Zeilen/Spalten einfügen/löschen mitten im Gitter (Rechtsklick auf einen Zeilen-/Spaltenkopf), nicht nur am Rand wie bisher: Zellinhalte, Formate und Zeilenhöhen/Spaltenbreiten verschieben sich innerhalb der Tabelle, und jede Formel im gesamten Dokument, die die Tabelle referenziert (auch über Tabelle!Zelle/Blatt!Tabelle::Zelle-Qualifier aus anderen Tabellen), wird automatisch mitgezogen. Ein Bereich, der die Stelle überspannt, wächst/schrumpft entsprechend; eine Referenz, die exakt die gelöschte Zeile/Spalte trifft, wird zu einem echten #REF-Literal im Formeltext (nutzt die neue ErrorLiteralNode-Grammatik). $-Sperren bleiben dabei bewusst wirkungslos (anders als beim Kopieren), unveränderte Referenzen bleiben byte-identisch. Token-Level-Splice wie shiftFormula (Kopieren/ Ausschneiden), keine AST-Neuserialisierung. 406 Tests, vue-tsc/eslint/build sauber, Playwright-live-verifiziert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1003,6 +1003,236 @@ describe('named ranges (document-level names for a fixed cell/range)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertRow/deleteRow/insertColumn/deleteColumn (mid-grid, not just edge growth)', () => {
|
||||
const raw = (s: DocumentStore, table: string, a1: string) => {
|
||||
const { col, row } = coords(a1);
|
||||
return s.cellInput(tableId(s, table), col, row);
|
||||
};
|
||||
|
||||
it('insertRow shifts cell data down from the insertion point, leaves earlier rows alone', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
setCell(s, name, 'A1', 'x');
|
||||
setCell(s, name, 'A2', 'y');
|
||||
s.insertRow(id, 1);
|
||||
expect(val(s, name, 'A1')).toBe('x'); // row 0, before the insertion point -- untouched
|
||||
expect(val(s, name, 'A2')).toBeNull(); // new empty row
|
||||
expect(val(s, name, 'A3')).toBe('y'); // shifted down
|
||||
expect(s.tableById(id)!.rows).toBe(13); // default 12 + 1
|
||||
});
|
||||
|
||||
it('deleteRow removes that row and shifts the rest up', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
setCell(s, name, 'A1', 'x');
|
||||
setCell(s, name, 'A2', 'y');
|
||||
setCell(s, name, 'A3', 'z');
|
||||
s.deleteRow(id, 1);
|
||||
expect(val(s, name, 'A1')).toBe('x');
|
||||
expect(val(s, name, 'A2')).toBe('z'); // 'y' is gone, 'z' shifted up
|
||||
expect(s.tableById(id)!.rows).toBe(11);
|
||||
});
|
||||
|
||||
it('deleteRow refuses to remove the last remaining row', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const initialRows = s.tableById(id)!.rows;
|
||||
for (let i = 0; i < initialRows - 1; i++) s.deleteRow(id, 0);
|
||||
expect(s.tableById(id)!.rows).toBe(1);
|
||||
s.deleteRow(id, 0);
|
||||
expect(s.tableById(id)!.rows).toBe(1); // no-op, refused
|
||||
});
|
||||
|
||||
it('insertColumn/deleteColumn mirror the row behaviour, incl. colWidths', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
setCell(s, name, 'A1', 'x');
|
||||
setCell(s, name, 'B1', 'y');
|
||||
s.setColWidth(id, 1, 111); // column B
|
||||
s.insertColumn(id, 1);
|
||||
expect(val(s, name, 'A1')).toBe('x');
|
||||
expect(val(s, name, 'B1')).toBeNull();
|
||||
expect(val(s, name, 'C1')).toBe('y'); // shifted right, width override moved with it
|
||||
expect(s.tableById(id)!.colWidths[2]).toBe(111);
|
||||
|
||||
s.deleteColumn(id, 1); // remove the now-empty B
|
||||
expect(val(s, name, 'A1')).toBe('x');
|
||||
expect(val(s, name, 'B1')).toBe('y'); // shifted back left
|
||||
expect(s.tableById(id)!.colWidths[1]).toBe(111);
|
||||
});
|
||||
|
||||
it('a self-referencing formula (same table) is rewritten to keep pointing at the same logical cell', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
setCell(s, name, 'B5', '20');
|
||||
setCell(s, name, 'C1', '=B5');
|
||||
expect(val(s, name, 'C1')).toBe(20);
|
||||
|
||||
s.insertRow(id, 2); // B5 (row 4) shifts to B6; C1 (row 0) stays put
|
||||
expect(raw(s, name, 'C1')).toBe('=B6');
|
||||
expect(val(s, name, 'C1')).toBe(20);
|
||||
});
|
||||
|
||||
it('a cross-table qualified reference is rewritten too, not just formulas inside the edited table', () => {
|
||||
const s = fresh();
|
||||
const src = s.addTable(s.doc.activeSheetId);
|
||||
s.renameObject(src, 'Quelle');
|
||||
const dst = s.addTable(s.doc.activeSheetId);
|
||||
s.renameObject(dst, 'Ziel');
|
||||
setCell(s, 'Quelle', 'B5', '42');
|
||||
setCell(s, 'Ziel', 'A1', '=Quelle!B5');
|
||||
expect(val(s, 'Ziel', 'A1')).toBe(42);
|
||||
|
||||
s.insertRow(src, 2);
|
||||
expect(raw(s, 'Ziel', 'A1')).toBe('=Quelle!B6');
|
||||
expect(val(s, 'Ziel', 'A1')).toBe(42);
|
||||
});
|
||||
|
||||
it('a range that straddles the insertion point grows by one row instead of just sliding', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
setCell(s, name, 'B1', '1');
|
||||
setCell(s, name, 'B2', '2');
|
||||
setCell(s, name, 'B3', '3');
|
||||
setCell(s, name, 'D1', '=SUM(B1:B3)');
|
||||
expect(val(s, name, 'D1')).toBe(6);
|
||||
|
||||
s.insertRow(id, 1); // inside the range (row 1, between B1 and what was B2)
|
||||
expect(raw(s, name, 'D1')).toBe('=SUM(B1:B4)'); // grew by one
|
||||
setCell(s, name, 'B2', '99'); // the newly inserted row is now part of the range
|
||||
expect(val(s, name, 'D1')).toBe(1 + 99 + 2 + 3);
|
||||
});
|
||||
|
||||
it('a range fully after the deletion point shifts down, keeps its size', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
setCell(s, name, 'B2', '10');
|
||||
setCell(s, name, 'B3', '20');
|
||||
setCell(s, name, 'D5', '=SUM(B2:B3)'); // away from row 0, so it survives the delete itself
|
||||
s.deleteRow(id, 0); // delete row 0, entirely before the range -- range shifts, doesn't shrink in size
|
||||
expect(raw(s, name, 'D4')).toBe('=SUM(B1:B2)');
|
||||
expect(val(s, name, 'D4')).toBe(30);
|
||||
});
|
||||
|
||||
it('deleting a row *inside* a multi-row range shrinks it by one, keeps the rest', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
setCell(s, name, 'B1', '1');
|
||||
setCell(s, name, 'B2', '2');
|
||||
setCell(s, name, 'B3', '3');
|
||||
setCell(s, name, 'D1', '=SUM(B1:B3)');
|
||||
s.deleteRow(id, 1); // remove the middle row of the range
|
||||
expect(raw(s, name, 'D1')).toBe('=SUM(B1:B2)');
|
||||
expect(val(s, name, 'D1')).toBe(1 + 3); // only 1 and (shifted) 3 remain
|
||||
});
|
||||
|
||||
it('a single-cell reference exactly on the deleted row becomes a literal #REF', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
setCell(s, name, 'B2', '10');
|
||||
setCell(s, name, 'C1', '=B2+1');
|
||||
s.deleteRow(id, 1); // row 1 is exactly B2's row
|
||||
expect(raw(s, name, 'C1')).toBe('=#REF+1');
|
||||
expect(isError(val(s, name, 'C1'))).toBe(true);
|
||||
});
|
||||
|
||||
it('a range entirely on the deleted row collapses to a literal #REF', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
setCell(s, name, 'B2', '1');
|
||||
setCell(s, name, 'D2', '2');
|
||||
setCell(s, name, 'D1', '=SUM(B2:D2)'); // a 1-row range, entirely row 1
|
||||
s.deleteRow(id, 1);
|
||||
expect(raw(s, name, 'D1')).toBe('=SUM(#REF)');
|
||||
expect(isError(val(s, name, 'D1'))).toBe(true);
|
||||
});
|
||||
|
||||
it('a $-locked reference still shifts -- unlike copy/paste, $ has no effect on a structural insert/delete', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
setCell(s, name, 'B5', '7');
|
||||
setCell(s, name, 'C1', '=$B$5');
|
||||
s.insertRow(id, 1);
|
||||
expect(raw(s, name, 'C1')).toBe('=$B$6');
|
||||
expect(val(s, name, 'C1')).toBe(7);
|
||||
});
|
||||
|
||||
it('a reference to a row before the insertion/deletion point is left untouched, byte-identical', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
setCell(s, name, 'A1', '5');
|
||||
// lower-case + extra spacing in the formula text -- proves an untouched
|
||||
// reference (row 0, nowhere near the insertion point at row 5) is left
|
||||
// completely alone, not just numerically equivalent
|
||||
setCell(s, name, 'C1', '=a1 + 1');
|
||||
s.insertRow(id, 5);
|
||||
expect(raw(s, name, 'C1')).toBe('=a1 + 1');
|
||||
s.deleteRow(id, 5);
|
||||
expect(raw(s, name, 'C1')).toBe('=a1 + 1');
|
||||
});
|
||||
|
||||
it('the sheet-qualified 3-part form ("Blatt!Tabelle::Zelle") is resolved and rewritten too', () => {
|
||||
const s = fresh();
|
||||
// fresh sheets, names that don't collide with the seed's own "Analyse"/
|
||||
// "Umsatz" (the whole point of the 3-part form is disambiguating a name
|
||||
// used on more than one sheet, but that's not what this test is about)
|
||||
const sheetA = s.addSheet();
|
||||
s.renameSheet(sheetA, 'ErsterBogen');
|
||||
const src = s.addTable(sheetA);
|
||||
s.renameObject(src, 'Quelle3');
|
||||
const sheetB = s.addSheet();
|
||||
s.renameSheet(sheetB, 'ZweiterBogen');
|
||||
const dst = s.addTable(sheetB);
|
||||
s.renameObject(dst, 'Ziel3');
|
||||
|
||||
setCell(s, 'Quelle3', 'B5', '42');
|
||||
setCell(s, 'Ziel3', 'A1', '=ErsterBogen!Quelle3::B5');
|
||||
expect(val(s, 'Ziel3', 'A1')).toBe(42);
|
||||
|
||||
s.insertRow(src, 2);
|
||||
expect(raw(s, 'Ziel3', 'A1')).toBe('=ErsterBogen!Quelle3::B6');
|
||||
expect(val(s, 'Ziel3', 'A1')).toBe(42);
|
||||
});
|
||||
|
||||
it('undo/redo covers a row insert/delete, incl. the document-wide formula rewrite', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
setCell(s, name, 'B5', '10');
|
||||
setCell(s, name, 'C1', '=B5');
|
||||
vi.advanceTimersByTime(600);
|
||||
|
||||
s.insertRow(id, 2);
|
||||
vi.advanceTimersByTime(600);
|
||||
expect(raw(s, name, 'C1')).toBe('=B6');
|
||||
|
||||
s.undo();
|
||||
expect(raw(s, name, 'C1')).toBe('=B5');
|
||||
expect(s.tableById(id)!.rows).toBe(12);
|
||||
|
||||
s.redo();
|
||||
expect(raw(s, name, 'C1')).toBe('=B6');
|
||||
expect(s.tableById(id)!.rows).toBe(13);
|
||||
} finally {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('functionSuggestions (formula autocomplete)', () => {
|
||||
it('filters built-ins by prefix, case-insensitively', () => {
|
||||
const s = fresh();
|
||||
|
||||
@@ -203,6 +203,203 @@ function shiftFormula(raw: string, dCol: number, dRow: number, restrict?: Rect):
|
||||
return '=' + out;
|
||||
}
|
||||
|
||||
// ---- row/col insert & delete: document-wide reference rewriting ---------
|
||||
// A mid-grid row/col insert/delete (unlike edge growth/shrink, or copy/paste's
|
||||
// shiftFormula above) can invalidate/relocate references from *any* formula
|
||||
// in the *whole document*, not just the edited table's own -- another table's
|
||||
// `Tabelle!Zelle` qualifier can point straight at it. `rewriteRefsForStructuralChange`
|
||||
// below is the token-level rewriter that does that (same splice technique as
|
||||
// shiftFormula: only the affected cell/range tokens' exact text is touched,
|
||||
// everything else -- including whitespace -- is byte-identical); it's the
|
||||
// engine used by `insertRow`/`deleteRow`/`insertColumn`/`deleteColumn` (in the
|
||||
// store proper, below) via `rewriteDocumentRefs`.
|
||||
|
||||
type Axis = 'row' | 'col';
|
||||
|
||||
/** Insert-side shift for one coordinate: everything at/after `at` moves by
|
||||
* +1. Applied independently to a single cell and to each range endpoint --
|
||||
* for a range straddling `at` (start < at <= end), only the end moves, so
|
||||
* the range *grows* by one to include the newly inserted row/col (the same
|
||||
* behaviour Excel has for "insert a row inside a SUM range"). */
|
||||
function insertShift(v: number, at: number): number {
|
||||
return v >= at ? v + 1 : v;
|
||||
}
|
||||
/** Delete-side shift for a range's *start* endpoint: `at` itself stays `at`
|
||||
* (the next surviving row/col slides into that slot); only something
|
||||
* strictly after `at` moves down by one. */
|
||||
function deleteShiftStart(v: number, at: number): number {
|
||||
return v > at ? v - 1 : v;
|
||||
}
|
||||
/** Delete-side shift for a range's *end* endpoint: unlike the start
|
||||
* endpoint, `at` itself also moves down by one here -- that row/col is
|
||||
* gone, so the range's last surviving row/col is one less than before. */
|
||||
function deleteShiftEnd(v: number, at: number): number {
|
||||
return v >= at ? v - 1 : v;
|
||||
}
|
||||
|
||||
function axisValue(axis: Axis, c: { col: number; row: number }): number {
|
||||
return axis === 'row' ? c.row : c.col;
|
||||
}
|
||||
function withAxisValue(
|
||||
axis: Axis,
|
||||
c: { col: number; row: number; colAbs: boolean; rowAbs: boolean },
|
||||
v: number,
|
||||
): string {
|
||||
return axis === 'row' ? toA1(c.col, v, c.colAbs, c.rowAbs) : toA1(v, c.row, c.colAbs, c.rowAbs);
|
||||
}
|
||||
|
||||
/** True iff, with `toks[i]` a known "!" (BANG), the shape ahead is
|
||||
* IDENT/QUOTED "::" -- i.e. this "!" really introduces a SHEET name
|
||||
* followed by a table qualifier (the 3-part `Blatt!Tabelle::Zelle` form),
|
||||
* not a bare (old-style) table qualifier of its own. Exact same lookahead
|
||||
* as `parser.ts#Parser.looksLikeSheetThenTable`. */
|
||||
function looksLikeSheetThenTable(toks: Token[], i: number): boolean {
|
||||
const n1 = toks[i + 1];
|
||||
const n2 = toks[i + 2];
|
||||
return !!n1 && (n1.type === 'IDENT' || n1.type === 'QUOTED') && !!n2 && n2.type === 'DBLCOLON';
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite every reference to `targetTableId` inside one formula for a
|
||||
* structural row/col insert (`delta` `1`) or delete (`delta` `-1`) at index
|
||||
* `at` on `axis`. `homeTableId` is the formula's *own* table (needed to
|
||||
* resolve a bare, unqualified reference, and -- via a transiently-set
|
||||
* `wb.homeTableId` -- to break a same-table-name-on-two-sheets ambiguity
|
||||
* the exact same way `wb.resolveTableName` already does for evaluation;
|
||||
* this function literally calls it).
|
||||
*
|
||||
* A reference landing exactly on a deleted row/col -- or a range that
|
||||
* collapses entirely (every row/col it covered was the one deleted) --
|
||||
* becomes a literal `#REF` (see engine/parser.ts's `ErrorLiteralNode`),
|
||||
* the same thing Excel shows inline for a broken reference. A range that
|
||||
* merely *shrinks* (only part of it was the deleted line) stays a valid,
|
||||
* smaller range, exactly like Excel.
|
||||
*/
|
||||
function rewriteRefsForStructuralChange(
|
||||
raw: string,
|
||||
homeTableId: string,
|
||||
targetTableId: string,
|
||||
axis: Axis,
|
||||
at: number,
|
||||
delta: 1 | -1,
|
||||
wb: Workbook,
|
||||
): string {
|
||||
if (!raw.startsWith('=')) return raw;
|
||||
const body = raw.slice(1);
|
||||
let toks: Token[];
|
||||
try {
|
||||
toks = tokenize(body);
|
||||
} catch {
|
||||
return raw; // unparsable -- leave as-is, the evaluator already surfaces #ERR
|
||||
}
|
||||
|
||||
const savedHome = wb.homeTableId;
|
||||
wb.homeTableId = homeTableId;
|
||||
const resolve = (name: string, sheetName: string | null): string | null =>
|
||||
wb.resolveTableName(name, sheetName);
|
||||
|
||||
interface Repl {
|
||||
pos: number;
|
||||
end: number;
|
||||
text: string;
|
||||
}
|
||||
const repls: Repl[] = [];
|
||||
|
||||
let i = 0;
|
||||
while (i < toks.length && toks[i].type !== 'EOF') {
|
||||
const tok = toks[i];
|
||||
let cellIdx = -1;
|
||||
let resolvedTableId: string | null = null;
|
||||
|
||||
if ((tok.type === 'IDENT' || tok.type === 'QUOTED') && toks[i + 1]) {
|
||||
if (toks[i + 1].type === 'BANG' && looksLikeSheetThenTable(toks, i + 1)) {
|
||||
resolvedTableId = resolve(toks[i + 2].value, tok.value); // sheet=tok, table=toks[i+2]
|
||||
cellIdx = i + 4; // consumed: IDENT/QUOTED "!" IDENT/QUOTED "::"
|
||||
} else if (toks[i + 1].type === 'BANG' || toks[i + 1].type === 'DBLCOLON') {
|
||||
resolvedTableId = resolve(tok.value, null);
|
||||
cellIdx = i + 2;
|
||||
}
|
||||
}
|
||||
if (cellIdx === -1 && tok.type === 'IDENT' && looksLikeCell(tok.value)) {
|
||||
resolvedTableId = homeTableId;
|
||||
cellIdx = i;
|
||||
}
|
||||
if (cellIdx === -1) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const cellTokA = toks[cellIdx];
|
||||
if (!cellTokA || cellTokA.type !== 'IDENT' || !looksLikeCell(cellTokA.value)) {
|
||||
i++; // qualifier not actually followed by a cell -- malformed, leave alone
|
||||
continue;
|
||||
}
|
||||
const isRangeStart = toks[cellIdx + 1]?.type === 'COLON';
|
||||
const cellTokB = isRangeStart ? toks[cellIdx + 2] : undefined;
|
||||
const validRange = !!cellTokB && cellTokB.type === 'IDENT' && looksLikeCell(cellTokB.value);
|
||||
|
||||
if (resolvedTableId === targetTableId) {
|
||||
const a = parseA1(cellTokA.value);
|
||||
if (validRange && cellTokB) {
|
||||
const b = parseA1(cellTokB.value);
|
||||
const va = axisValue(axis, a);
|
||||
const vb = axisValue(axis, b);
|
||||
const lo = Math.min(va, vb);
|
||||
const hi = Math.max(va, vb);
|
||||
if (delta > 0) {
|
||||
const newLo = insertShift(lo, at);
|
||||
const newHi = insertShift(hi, at);
|
||||
if (newLo !== lo) {
|
||||
repls.push({ pos: cellTokA.pos, end: cellTokA.pos + cellTokA.value.length, text: withAxisValue(axis, a, va === lo ? newLo : newHi) });
|
||||
}
|
||||
if (newHi !== hi) {
|
||||
repls.push({ pos: cellTokB.pos, end: cellTokB.pos + cellTokB.value.length, text: withAxisValue(axis, b, vb === lo ? newLo : newHi) });
|
||||
}
|
||||
} else {
|
||||
const newLo = deleteShiftStart(lo, at);
|
||||
const newHi = deleteShiftEnd(hi, at);
|
||||
if (newLo > newHi) {
|
||||
repls.push({ pos: cellTokA.pos, end: cellTokB.pos + cellTokB.value.length, text: '#REF' });
|
||||
} else {
|
||||
if (newLo !== lo) {
|
||||
repls.push({ pos: cellTokA.pos, end: cellTokA.pos + cellTokA.value.length, text: withAxisValue(axis, a, va === lo ? newLo : newHi) });
|
||||
}
|
||||
if (newHi !== hi) {
|
||||
repls.push({ pos: cellTokB.pos, end: cellTokB.pos + cellTokB.value.length, text: withAxisValue(axis, b, vb === lo ? newLo : newHi) });
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const v = axisValue(axis, a);
|
||||
if (delta > 0) {
|
||||
const nv = insertShift(v, at);
|
||||
if (nv !== v) {
|
||||
repls.push({ pos: cellTokA.pos, end: cellTokA.pos + cellTokA.value.length, text: withAxisValue(axis, a, nv) });
|
||||
}
|
||||
} else if (v === at) {
|
||||
repls.push({ pos: cellTokA.pos, end: cellTokA.pos + cellTokA.value.length, text: '#REF' });
|
||||
} else if (v > at) {
|
||||
repls.push({ pos: cellTokA.pos, end: cellTokA.pos + cellTokA.value.length, text: withAxisValue(axis, a, v - 1) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
i = validRange ? cellIdx + 3 : cellIdx + 1;
|
||||
}
|
||||
|
||||
wb.homeTableId = savedHome;
|
||||
if (repls.length === 0) return raw;
|
||||
repls.sort((x, y) => x.pos - y.pos);
|
||||
let out = '';
|
||||
let cursor = 0;
|
||||
for (const r of repls) {
|
||||
out += body.slice(cursor, r.pos) + r.text;
|
||||
cursor = r.end;
|
||||
}
|
||||
out += body.slice(cursor);
|
||||
return '=' + out;
|
||||
}
|
||||
|
||||
function loadInitial(): DocumentState {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
@@ -1176,6 +1373,142 @@ function createStore() {
|
||||
syncWorkbook();
|
||||
}
|
||||
|
||||
// ---- row/col insert & delete (mid-grid, not just edge growth) ---------
|
||||
// Unlike setTableRows/setTableCols above (pure edge resize) or
|
||||
// removeLastRow/removeLastColumn, these shift cell data/formats/row-height
|
||||
// (or column-width) overrides *within* the table around the insertion/
|
||||
// deletion point, and rewrite every formula in the *whole document* that
|
||||
// references a row/col at/after it on this table -- see
|
||||
// `rewriteRefsForStructuralChange` above for the "how".
|
||||
|
||||
/** Move `doc.selection` (if it's on `tableId`) the same way a row/col
|
||||
* insert/delete moves cell data -- so the active cell/marquee stays on
|
||||
* the "same" logical cell it was on before, not wherever that grid
|
||||
* position now happens to be. `clampSelection` (called by every caller
|
||||
* right after) catches anything this pushes out of the shrunk bounds. */
|
||||
function shiftSelectionForStructuralChange(tableId: string, axis: Axis, at: number, delta: 1 | -1) {
|
||||
const s = doc.selection;
|
||||
if (!s || s.tableId !== tableId) return;
|
||||
const shift = (c: CellCoord): CellCoord => {
|
||||
const v = axis === 'row' ? c.row : c.col;
|
||||
const nv = delta > 0 ? insertShift(v, at) : deleteShiftStart(v, at);
|
||||
return axis === 'row' ? { ...c, row: nv } : { ...c, col: nv };
|
||||
};
|
||||
doc.selection = {
|
||||
tableId,
|
||||
anchor: shift(s.anchor),
|
||||
focus: shift(s.focus),
|
||||
ranges: s.ranges?.map((r) => ({ anchor: shift(r.anchor), focus: shift(r.focus) })),
|
||||
};
|
||||
}
|
||||
|
||||
/** Rewrite every formula in the whole document referencing `targetTableId`
|
||||
* after its rows/cols and cell data have already been shifted to their
|
||||
* new shape (self-references inside `targetTableId` are included -- it's
|
||||
* just another table as far as this loop is concerned). */
|
||||
function rewriteDocumentRefs(targetTableId: string, axis: Axis, at: number, delta: 1 | -1) {
|
||||
for (const s of doc.sheets) {
|
||||
for (const o of s.objects) {
|
||||
if (o.kind !== 'table') continue;
|
||||
for (const [key, raw] of Object.entries(o.cells)) {
|
||||
const next = rewriteRefsForStructuralChange(raw, o.id, targetTableId, axis, at, delta, wb);
|
||||
if (next !== raw) o.cells[key] = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-key every entry of a "col,row" map, shifting the given axis at/after
|
||||
* `at` by `delta` (dropping entries whose axis value === `at` when
|
||||
* `delta` is -1 -- that row/col's own cells are gone). Shared by
|
||||
* insertRow/deleteRow (axis 'row') and insertColumn/deleteColumn (axis
|
||||
* 'col'). */
|
||||
function shiftCellKeyedMap<T>(
|
||||
map: Record<string, T>,
|
||||
axis: Axis,
|
||||
at: number,
|
||||
delta: 1 | -1,
|
||||
): Record<string, T> {
|
||||
const out: Record<string, T> = {};
|
||||
for (const [k, v] of Object.entries(map)) {
|
||||
const [c, r] = k.split(',').map(Number);
|
||||
const cur = axis === 'row' ? r : c;
|
||||
if (delta < 0 && cur === at) continue; // this row/col's own cell is gone
|
||||
const nv = delta > 0 ? insertShift(cur, at) : deleteShiftStart(cur, at);
|
||||
const key = axis === 'row' ? `${c},${nv}` : `${nv},${r}`;
|
||||
out[key] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
/** Same idea as shiftCellKeyedMap, for a single-axis map keyed by plain
|
||||
* row/col number (rowHeights, colWidths). */
|
||||
function shiftAxisKeyedMap(map: Record<number, number>, at: number, delta: 1 | -1): Record<number, number> {
|
||||
const out: Record<number, number> = {};
|
||||
for (const [k, v] of Object.entries(map)) {
|
||||
const cur = Number(k);
|
||||
if (delta < 0 && cur === at) continue;
|
||||
out[delta > 0 ? insertShift(cur, at) : deleteShiftStart(cur, at)] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function insertRow(tableId: string, at: number) {
|
||||
const t = tableById(tableId);
|
||||
if (!t) return;
|
||||
const clamped = Math.max(0, Math.min(Math.floor(at), t.rows));
|
||||
t.cells = shiftCellKeyedMap(t.cells, 'row', clamped, 1);
|
||||
if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'row', clamped, 1);
|
||||
if (t.rowHeights) t.rowHeights = shiftAxisKeyedMap(t.rowHeights, clamped, 1);
|
||||
t.rows += 1;
|
||||
shiftSelectionForStructuralChange(tableId, 'row', clamped, 1);
|
||||
rewriteDocumentRefs(tableId, 'row', clamped, 1);
|
||||
clampSelection(tableId);
|
||||
syncWorkbook();
|
||||
}
|
||||
function deleteRow(tableId: string, at: number) {
|
||||
const t = tableById(tableId);
|
||||
if (!t || t.rows <= 1) return;
|
||||
const clamped = Math.max(0, Math.min(Math.floor(at), t.rows - 1));
|
||||
t.cells = shiftCellKeyedMap(t.cells, 'row', clamped, -1);
|
||||
if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'row', clamped, -1);
|
||||
if (t.rowHeights) t.rowHeights = shiftAxisKeyedMap(t.rowHeights, clamped, -1);
|
||||
t.rows -= 1;
|
||||
shiftSelectionForStructuralChange(tableId, 'row', clamped, -1);
|
||||
rewriteDocumentRefs(tableId, 'row', clamped, -1);
|
||||
pruneEmptyMaps(t);
|
||||
clampTableView(t);
|
||||
clampSelection(tableId);
|
||||
syncWorkbook();
|
||||
}
|
||||
function insertColumn(tableId: string, at: number) {
|
||||
const t = tableById(tableId);
|
||||
if (!t) return;
|
||||
const clamped = Math.max(0, Math.min(Math.floor(at), t.cols));
|
||||
t.cells = shiftCellKeyedMap(t.cells, 'col', clamped, 1);
|
||||
if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'col', clamped, 1);
|
||||
t.colWidths = shiftAxisKeyedMap(t.colWidths, clamped, 1);
|
||||
t.cols += 1;
|
||||
shiftSelectionForStructuralChange(tableId, 'col', clamped, 1);
|
||||
rewriteDocumentRefs(tableId, 'col', clamped, 1);
|
||||
clampSelection(tableId);
|
||||
syncWorkbook();
|
||||
}
|
||||
function deleteColumn(tableId: string, at: number) {
|
||||
const t = tableById(tableId);
|
||||
if (!t || t.cols <= 1) return;
|
||||
const clamped = Math.max(0, Math.min(Math.floor(at), t.cols - 1));
|
||||
t.cells = shiftCellKeyedMap(t.cells, 'col', clamped, -1);
|
||||
if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'col', clamped, -1);
|
||||
t.colWidths = shiftAxisKeyedMap(t.colWidths, clamped, -1);
|
||||
t.cols -= 1;
|
||||
shiftSelectionForStructuralChange(tableId, 'col', clamped, -1);
|
||||
rewriteDocumentRefs(tableId, 'col', clamped, -1);
|
||||
pruneEmptyMaps(t);
|
||||
clampTableView(t);
|
||||
clampSelection(tableId);
|
||||
syncWorkbook();
|
||||
}
|
||||
|
||||
// ---- cell formatting (alignment, number format, colours, bold/italic) ---
|
||||
const fmtKey = (c: number, r: number) => `${c},${r}`;
|
||||
function cellFormat(tableId: string, col: number, row: number): CellFormat {
|
||||
@@ -1837,6 +2170,10 @@ function createStore() {
|
||||
rowHeightOf,
|
||||
setTableRows,
|
||||
setTableCols,
|
||||
insertRow,
|
||||
deleteRow,
|
||||
insertColumn,
|
||||
deleteColumn,
|
||||
setTableView,
|
||||
// cell formatting
|
||||
cellFormat,
|
||||
|
||||
Reference in New Issue
Block a user