Auto-sizing cell editor, multi-ref F4, and Alt+Enter line breaks

1. In-cell editor width/height ("nur in der Breite der Zelle angezeigt"):
   the editor now lives as an overlay (like the clip/fill markers) instead
   of inside its <td>, so it can grow past the cell -- width first, up to
   an approximated display-width cap, then wrapping and growing height.
   Sized via a hidden mirror element (same font/padding, real DOM
   measurement -- a <textarea> can't self-size to content with CSS alone).

2. F4 now cycles every reference a real text selection overlaps, each from
   its own lock state, not just the one at the caret -- select a stretch of
   formula spanning several references and F4 adjusts all of them at once,
   same as Excel. cycleReferenceLock(formula, caret) -> cycleReferenceLocks
   (formula, start, end); a collapsed selection (start === end) keeps the
   old single-reference caret behaviour. Wired into both the in-cell editor
   and the formula bar.

3. Alt+Enter inserts a literal newline in the editor instead of committing
   (explicit splice at the caret, not relying on the textarea's native
   Enter handling, which isn't consistently reachable for Alt+Enter). A
   cell holding a newline displays it wrapped (.cell.multiline) instead of
   collapsing to one ellipsised line.

Along the way: a <td>'s own max-height doesn't cap table-row growth in any
browser (a table-layout quirk) -- a wrapped multi-line cell was silently
growing its row past rowH(r), which would have desynced every pixel
position computed from rowH()/rowTop() (the overlays, sticky frozen panes,
the row-resize handle). Fixed by moving cell text into its own block-level
.cell-content child, which *does* respect max-height + overflow:hidden.

Verified interactively against the dev server with Playwright: a long
formula gets a ~300px editor instead of the 104px cell; Alt+Enter produces
a real "\n" that survives commit and displays wrapped, with the row height
unchanged (26px, not the ~40px it grew to before the .cell-content fix);
F4 on a full-formula selection turns every reference absolute in one press
and keeps cycling all of them on repeated presses; existing selection,
copy/paste and fill-handle behaviour all regression-checked clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-02 11:04:45 +02:00
co-authored by Claude Sonnet 5
parent 830dcb8eeb
commit cc76ea547f
7 changed files with 297 additions and 76 deletions
+5 -3
View File
@@ -15,7 +15,7 @@ löst `Umsatz!B2` (bzw. `Umsatz::B2`) von jedem Blatt aus identisch auf.
| `parser.ts` | Recursive-Descent → AST. Grammatik als EBNF-Kommentarblock im Kopf der Datei. |
| `evaluator.ts` | AST + `EngineHost` → `CellValue`. Numerisches Auswertungsmodell, Fehler-Propagierung. |
| `graph.ts` | Dokumentweiter Abhängigkeitsgraph: `affected()`, `topoOrder()`, Zirkel-Erkennung (iterative DFS, white/grey/black). |
| `refs.ts` | A1 ↔ `(col,row)` inkl. `$`-Sperrachsen, Spaltenbuchstaben, Bereichs-Expansion, `F4`-Zyklus (`cycleReferenceLock`). |
| `refs.ts` | A1 ↔ `(col,row)` inkl. `$`-Sperrachsen, Spaltenbuchstaben, Bereichs-Expansion, `F4`-Zyklus (`cycleReferenceLocks`). |
| `functions.ts` | `SUM/AVG/MIN/MAX/COUNT` (+ Alias `AVERAGE`) als Tabelle `{ NAME → fn(values) }`. |
| `workbook.ts` | Bindeglied: flache Liste von **Tabellen** + rohe Eingaben + ASTs + Werte + Graph. Inkrementelle Neuberechnung. Implementiert `EngineHost`. |
| `types.ts` | Gemeinsame Typen, `FormulaError`, `keyOf()`. |
@@ -66,8 +66,10 @@ ausschließlich beim **Kopieren/Ausschneiden** aus (`shiftFormula` in
beim *Kopieren* exakt wie eingegeben stehen, folgt aber beim *Ausschneiden*
trotzdem der verschobenen Zelle (dort verschiebt sich die referenzierte Zelle
selbst – `$` ändert nur, ob eine *Kopie* mitzieht, nicht wohin ein *Verschieben*
zeigt). `refs.ts#cycleReferenceLock` implementiert den `F4`-Zyklus
(relativ → `$A$1` → `A$1` → `$A1` → relativ) für Formelleiste und Zell-Editor.
zeigt). `refs.ts#cycleReferenceLocks` implementiert den `F4`-Zyklus
(relativ → `$A$1` → `A$1` → `$A1` → relativ) für Formelleiste und Zell-Editor
— mit Cursor nur den einen Bezug dort, mit echter Textauswahl **jeden**
darin gefundenen Bezug auf einmal, jeden ausgehend vom eigenen Zustand.
Tabellennamen werden **case-insensitiv** und whitespace-tolerant aufgelöst:
`Kosten!A2`, `'Kosten'!A2`, `kosten!a2`, `Kosten::A2` sind identisch.
+49 -11
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { parseA1, toA1, cycleReferenceLock } from '../refs';
import { parseA1, toA1, cycleReferenceLocks } from '../refs';
describe('parseA1 / toA1: $-locked (absolute/mixed) references', () => {
it('parseA1 reports which axes are $-locked', () => {
@@ -17,34 +17,72 @@ describe('parseA1 / toA1: $-locked (absolute/mixed) references', () => {
});
});
describe('cycleReferenceLock (F4)', () => {
describe('cycleReferenceLocks (F4) -- collapsed caret', () => {
it('cycles relative -> absolute -> row-locked -> col-locked -> relative', () => {
let f = '=A1';
let caret = 3;
let pos = 3;
for (const expected of ['=$A$1', '=A$1', '=$A1', '=A1']) {
const res = cycleReferenceLock(f, caret)!;
const res = cycleReferenceLocks(f, pos, pos)!;
expect(res.text).toBe(expected);
expect(res.start).toBe(res.end); // a collapsed caret in -> a collapsed caret out
f = res.text;
caret = res.caret;
pos = res.end;
}
});
it('toggles only the reference the caret sits in/against', () => {
const res = cycleReferenceLock('=A1+B2', 2)!; // caret inside "A1"
const res = cycleReferenceLocks('=A1+B2', 2, 2)!; // caret inside "A1"
expect(res.text).toBe('=$A$1+B2');
const res2 = cycleReferenceLock('=A1+B2', 6)!; // caret inside "B2"
const res2 = cycleReferenceLocks('=A1+B2', 6, 6)!; // caret inside "B2"
expect(res2.text).toBe('=A1+$B$2');
});
it('picks the right side of a range depending on the caret', () => {
const left = cycleReferenceLock('=A1:B2', 2)!;
const left = cycleReferenceLocks('=A1:B2', 2, 2)!;
expect(left.text).toBe('=$A$1:B2');
const right = cycleReferenceLock('=A1:B2', 6)!;
const right = cycleReferenceLocks('=A1:B2', 6, 6)!;
expect(right.text).toBe('=A1:$B$2');
});
it('returns null when the caret is not on a reference, or the text is not a formula', () => {
expect(cycleReferenceLock('=SUM(1;2)', 5)).toBeNull();
expect(cycleReferenceLock('42', 1)).toBeNull();
expect(cycleReferenceLocks('=SUM(1;2)', 5, 5)).toBeNull();
expect(cycleReferenceLocks('42', 1, 1)).toBeNull();
});
it('never toggles a table-name qualifier, even one shaped like a cell', () => {
// "AB12" reads as a cell reference on its own, but here it's a table name
const formula = '=AB12!A1';
expect(cycleReferenceLocks(formula, 2, 2)).toBeNull(); // caret inside "AB12"
expect(cycleReferenceLocks(formula, 7, 7)?.text).toBe('=AB12!$A$1'); // caret inside the actual cell
});
});
describe('cycleReferenceLocks (F4) -- a real text selection spanning several refs', () => {
it('toggles every reference the selection overlaps, each from its own state', () => {
// select "A1+$B2" inside "=A1+$B2+C3" -- C3 sits outside the selection.
// A1 (relative) -> $A$1; $B2 (already col-locked-only, the cycle's last
// stop before relative) -> B2, its next step back to fully relative.
const formula = '=A1+$B2+C3';
const start = formula.indexOf('A1');
const end = formula.indexOf('$B2') + '$B2'.length;
const res = cycleReferenceLocks(formula, start, end)!;
expect(res.text).toBe('=$A$1+B2+C3');
});
it('a selection covering the whole formula adjusts all of it in one press', () => {
const res = cycleReferenceLocks('=A1+B2', 1, 7)!;
expect(res.text).toBe('=$A$1+$B$2');
});
it('re-cycling the same (now resized) selection keeps advancing every reference', () => {
let f = '=A1+B2';
let sel = cycleReferenceLocks(f, 1, 7)!; // -> $A$1+$B$2
f = sel.text;
sel = cycleReferenceLocks(f, sel.start, sel.end)!; // -> A$1+B$2
expect(sel.text).toBe('=A$1+B$2');
});
it('a selection touching no reference returns null', () => {
expect(cycleReferenceLocks('=SUM(1;2)', 4, 8)).toBeNull();
});
});
+1 -1
View File
@@ -24,7 +24,7 @@ export {
toA1,
looksLikeCell,
expandRange,
cycleReferenceLock,
cycleReferenceLocks,
} from './refs';
export {
Workbook,
+54 -18
View File
@@ -57,34 +57,70 @@ export function toA1(col: number, row: number, colAbs = false, rowAbs = false):
}
/**
* Excel/Numbers-style F4: cycle the cell reference at/adjacent to `caret` (a
* 0-based offset into `formula`, which must start with "=") through
* relative -> absolute -> row-locked -> column-locked -> relative. Returns
* the rewritten formula and a caret placed after the toggled reference, or
* `null` when there's no reference at the caret to toggle.
* Excel/Numbers-style F4: cycle every cell reference through
* relative -> absolute -> row-locked -> column-locked -> relative, each
* from its own current state.
*
* With a collapsed selection (`selStart === selEnd`, the common case -- just
* a caret), this toggles the single reference at/adjacent to it. With a real
* text selection, it toggles *every* reference the selection overlaps at
* once -- select a stretch of formula text spanning several references and
* F4 adjusts them all in one press, same as Excel.
*
* `selStart`/`selEnd` are 0-based offsets into `formula` (which must start
* with "="), i.e. straight from an `<input>`/`<textarea>`'s
* `selectionStart`/`selectionEnd`. Returns the rewritten formula and a
* selection re-spanning the (possibly resized) references, so a repeated F4
* keeps cycling the same ones; `null` when none were found.
*/
export function cycleReferenceLock(
export function cycleReferenceLocks(
formula: string,
caret: number,
): { text: string; caret: number } | null {
selStart: number,
selEnd: number,
): { text: string; start: number; end: number } | null {
if (!formula.startsWith('=')) return null;
const body = formula.slice(1);
const at = caret - 1;
const lo = Math.min(selStart, selEnd) - 1;
const hi = Math.max(selStart, selEnd) - 1;
const collapsed = lo === hi;
let toks: Token[];
try {
toks = tokenize(body);
} catch {
return null;
}
const tok = toks.find(
(t) => t.type === 'IDENT' && looksLikeCell(t.value) && at >= t.pos && at <= t.pos + t.value.length,
);
if (!tok) return null;
const { col, row, colAbs, rowAbs } = parseA1(tok.value);
const [nColAbs, nRowAbs] = nextLock(colAbs, rowAbs);
const next = toA1(col, row, nColAbs, nRowAbs);
const text = '=' + body.slice(0, tok.pos) + next + body.slice(tok.pos + tok.value.length);
return { text, caret: 1 + tok.pos + next.length };
const targets: Token[] = [];
for (let i = 0; i < toks.length; i++) {
const t = toks[i];
if (t.type !== 'IDENT' || !looksLikeCell(t.value)) continue;
const next = toks[i + 1];
if (next && (next.type === 'BANG' || next.type === 'DBLCOLON')) continue; // a table-name qualifier, not a cell
const tEnd = t.pos + t.value.length;
const hit = collapsed ? lo >= t.pos && lo <= tEnd : t.pos < hi && tEnd > lo;
if (hit) targets.push(t);
}
if (!targets.length) return null;
let out = '';
let last = 0;
let start = -1;
let end = -1;
for (const tok of targets) {
const { col, row, colAbs, rowAbs } = parseA1(tok.value);
const [nColAbs, nRowAbs] = nextLock(colAbs, rowAbs);
const rewritten = toA1(col, row, nColAbs, nRowAbs);
out += body.slice(last, tok.pos) + rewritten;
if (start === -1) start = out.length - rewritten.length;
end = out.length;
last = tok.pos + tok.value.length;
}
out += body.slice(last);
// a collapsed caret in -> a collapsed caret out (after the one toggled
// reference), same as before; a real selection in keeps spanning every
// reference it touched, so a repeated F4 keeps cycling all of them.
return collapsed
? { text: '=' + out, start: 1 + end, end: 1 + end }
: { text: '=' + out, start: 1 + start, end: 1 + end };
}
/** A1 -> $A$1 -> A$1 -> $A1 -> A1 -> ... (the classic F4 cycle). */