diff --git a/README.md b/README.md
index 30f2903..713f42f 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@ Vorbild von **Apple Numbers**. Zwei Dinge stehen im Mittelpunkt:
```bash
npm install
npm run dev # Vite-Devserver
-npm run test # Vitest-Suite (Engine + Store + IO), 127 Tests
+npm run test # Vitest-Suite (Engine + Store + IO), 132 Tests
npm run build # Typecheck + Produktionsbuild
npm run lint # ESLint (flat config)
```
@@ -97,12 +97,21 @@ umbenennen, löschen, per Drag sortieren). Zoom 40–200 % pro Blatt
| `Pos1` / `Strg`+`Pos1` / `Ende` / `Strg`+`Ende` | Zeilenanfang / A1 / Zeilenende / letzte Zelle |
| `Bild↑` / `Bild↓` | seitenweise (bei festen Tabellen nach Viewport-Höhe) |
| **`Strg`/`Cmd`+`C`/`X`/`V`** | Kopieren / Ausschneiden / Einfügen (Zellen + Formatierung, bezugsangepasst) |
-| **`F4`** (im Editor/in der Formelleiste) | Bezug unter dem Cursor sperren/zyklisch umschalten: `A1` → `$A$1` → `A$1` → `$A1` → `A1` |
+| **`F4`** (im Editor/in der Formelleiste) | Bezug am Cursor sperren/zyklisch umschalten: `A1` → `$A$1` → `A$1` → `$A1` → `A1`; bei markiertem Text werden **alle** darin gefundenen Bezüge auf einmal umgeschaltet, jeder ausgehend vom eigenen Zustand |
+| **`Alt`+`Enter`** (im Editor) | Zeilenumbruch in der Zelle einfügen, statt zu committen |
Bei **festen (scrollbaren) Tabellen** wird die aktive Zelle bei jeder Navigation
automatisch in den sichtbaren Bereich gescrollt (unter Beachtung fixierter
Zeilen/Spalten).
+**In-Zellen-Editor**: das Eingabefeld liegt als Overlay über der Zelle (nicht
+in ihr) und wächst mit dem Inhalt — in der Breite bis zu einer angenäherten
+„sichtbaren Anzeigebreite", danach umbrechend; in der Höhe mit `Alt`+`Enter`
+eingefügten Zeilenumbrüchen, bis zu einem Maximum, danach intern scrollbar.
+Ein Zeilenumbruch bleibt auch nach dem Committen erhalten und wird in der
+Zelle als solcher dargestellt (umbrechend statt einzeiliger Ellipse), auf die
+Zeilenhöhe der Tabelle geklemmt.
+
### Zell-Formatierung
Auswahl (Zelle, Bereich, Mehrfachauswahl, ganze Zeile/Spalte über den Kopf) → im
@@ -315,14 +324,17 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
## Tests
-`npm run test` — Vitest, 127 Tests:
+`npm run test` — Vitest, 132 Tests:
- **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^`
rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`,
Bereiche, `;`-Argumente, Parse-Fehler.
- **refs (`$`-Sperrachsen, `F4`)** — `parseA1`/`toA1` runden relativ/absolut/
- gemischt; `cycleReferenceLock` durchläuft die vier Kombinationen, trifft
- bei Cursor auf einem Bereich die richtige Seite, liefert `null` ohne Treffer.
+ gemischt; `cycleReferenceLocks` durchläuft die vier Kombinationen, trifft
+ bei Cursor auf einem Bereich die richtige Seite, liefert `null` ohne
+ Treffer; bei echter Textauswahl schaltet es **alle** überlappten Bezüge auf
+ einmal um (jeden vom eigenen Zustand aus) und lässt Tabellen-Qualifier, die
+ wie eine Zelle aussehen (`AB12!A1`), unangetastet.
- **evaluator** — Arithmetik, leere Zelle = 0, alle Funktionen inkl. Alias,
Cross-Table, Case-/`::`-Toleranz, jeder Fehlerfall, Fehler-Propagierung.
- **graph** — `affected`, topologische Ordnung, Selbst-/2er-/längerer Zyklus.
diff --git a/src/components/FormulaBar.vue b/src/components/FormulaBar.vue
index 101092e..7b09b97 100644
--- a/src/components/FormulaBar.vue
+++ b/src/components/FormulaBar.vue
@@ -1,7 +1,7 @@
diff --git a/src/components/TableGrid.vue b/src/components/TableGrid.vue
index f1ef62a..e7254d0 100644
--- a/src/components/TableGrid.vue
+++ b/src/components/TableGrid.vue
@@ -11,7 +11,7 @@
* in-cell editing and per-column resize.
*/
import { computed, nextTick, onBeforeUnmount, ref } from 'vue';
-import { numberToCol, cycleReferenceLock } from '../engine';
+import { numberToCol, cycleReferenceLocks } from '../engine';
import { useDocument } from '../store/document';
import { DEFAULT_ROW_H } from '../store/types';
import ContextMenu from './ContextMenu.vue';
@@ -23,6 +23,11 @@ const table = computed(() => store.tableById(props.tableId));
const HEAD_W = 44;
const DEFAULT_W = 104;
+/** How far the in-cell editor may grow beyond the cell before it wraps/scrolls
+ * -- an approximation of "the visible display width/height" rather than
+ * exact viewport math (the canvas can be panned/zoomed independently). */
+const EDIT_MAX_W = 560;
+const EDIT_MAX_H = 320;
const colWidth = (c: number) => table.value?.colWidths[c] ?? DEFAULT_W;
const rowH = (r: number) => store.rowHeightOf(props.tableId, r);
const headerH = computed(() => table.value?.rowHeight ?? DEFAULT_ROW_H);
@@ -58,6 +63,10 @@ function formatStyle(c: number, r: number): Record {
return s;
}
+/** A cell holding an explicit line break (Alt+Enter while editing) wraps and
+ * shows it instead of collapsing to a single ellipsised line. */
+const isMultiline = (c: number, r: number) => store.cellInput(props.tableId, c, r).includes('\n');
+
const root = ref(null);
// ---- fixed viewport + frozen panes -------------------------------
@@ -99,6 +108,17 @@ function cellStyle(c: number, r: number): Record {
if (fr || fc) s.zIndex = fr && fc ? '5' : '3';
return s;
}
+/** Caps a cell's rendered text to the row's own height. A `
` is
+ * only a *minimum* -- a `
`'s own `max-height` doesn't clip it either
+ * (browsers don't cap table-row growth that way); a plain block-level child
+ * with `max-height` + `overflow: hidden` does, so `.cell-content` (not the
+ * `
` itself) carries this. Without it a wrapped multi-line cell (see
+ * `.cell.multiline`) would grow its row past rowH(r), desyncing every pixel
+ * position computed from rowH()/rowTop() -- the clip/fill overlays, sticky
+ * frozen panes, the row-resize handle, ... */
+function cellContentStyle(r: number): Record {
+ return { maxHeight: rowH(r) + 'px' };
+}
function rowHeadStyle(r: number): Record {
if (!isFixed.value || r >= frozenRows.value) return {};
return { top: rowTop(r) + 'px', zIndex: '5' };
@@ -557,14 +577,53 @@ function selectAll() {
}
// ---- editing ---------------------------------------------------
+// The editor overlays the cell (positioned via colLeft/rowTop, like the
+// clip/fill overlays) rather than living inside the
, so it's free to
+// grow past the cell's own width/height -- clipped only by EDIT_MAX_W/H, not
+// by the grid's per-cell `overflow: hidden`.
const editing = ref<{ col: number; row: number } | null>(null);
const editValue = ref('');
-const editInput = ref(null);
+const editInput = ref(null);
+const editMirrorEl = ref(null);
const setEditRef = (el: unknown) => {
- editInput.value = (el as HTMLInputElement) ?? null;
+ editInput.value = (el as HTMLTextAreaElement) ?? null;
};
let committing = false;
+const editStyle = computed(() => {
+ const s = editing.value;
+ if (!s) return {};
+ return {
+ left: colLeft(s.col) + 'px',
+ top: rowTop(s.row) + 'px',
+ minWidth: colWidth(s.col) + 'px',
+ minHeight: rowH(s.row) + 'px',
+ };
+});
+const editMirrorStyle = computed(() => {
+ const s = editing.value;
+ if (!s) return {};
+ return {
+ minWidth: colWidth(s.col) + 'px',
+ minHeight: rowH(s.row) + 'px',
+ maxWidth: EDIT_MAX_W + 'px',
+ };
+});
+
+/** Grow the editor to fit its content (width first, then wrap and grow
+ * height), via a hidden mirror element -- a plain `
@@ -893,6 +973,20 @@ defineExpose({
title="Ziehen zum Ausfüllen/Kopieren"
@mousedown="onFillHandleDown"
/>
+
+
@@ -990,24 +1084,35 @@ defineExpose({
z-index: 5;
}
.cell {
- padding: 0 7px;
+ padding: 0; /* all padding lives on .cell-content -- see cellContentStyle() */
border-right: 1px solid var(--grid-line);
border-bottom: 1px solid var(--grid-line);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
background: var(--surface);
vertical-align: middle;
cursor: cell;
position: relative;
}
-.cell.numeric {
+/* the cell's text, in its own block box: a
's own max-height doesn't cap
+ table-row growth, but a plain block child's max-height + overflow:hidden
+ does -- see cellContentStyle(). */
+.cell-content {
+ padding: 0 7px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.cell-content.numeric {
text-align: right;
}
-.cell.error {
+.cell-content.error {
color: var(--danger);
text-align: right;
}
+.cell-content.multiline {
+ white-space: pre-wrap;
+ text-overflow: clip;
+ word-break: break-word;
+}
.cell.in-range {
background: var(--accent-weak);
}
@@ -1058,17 +1163,42 @@ defineExpose({
background: var(--accent-weak);
z-index: 8;
}
+/* the in-cell editor -- an overlay (like the clip/fill markers above), not a
+ child of its
, so it's free to grow past the cell (request: a longer
+ formula gets a box sized to its content / the display's max width, not
+ squeezed into the cell). Sized imperatively by resizeEditor(); left/top and
+ the starting min-width/min-height come from editStyle. */
.cell-input {
position: absolute;
- inset: 0;
- width: 100%;
- height: 100%;
+ resize: none;
border: 2px solid var(--accent);
padding: 0 5px;
font: inherit;
+ line-height: 1.35;
color: var(--text);
background: var(--surface);
outline: none;
- z-index: 7;
+ overflow: auto;
+ white-space: pre-wrap;
+ word-break: break-word;
+ box-sizing: border-box;
+ z-index: 12;
+}
+/* hidden clone of .cell-input's text box, measured to size the real one --
+ same font/line-height/padding/border, capped at EDIT_MAX_W so it wraps at
+ the same point the real editor will. */
+.cell-input-mirror {
+ position: fixed;
+ top: -9999px;
+ left: -9999px;
+ visibility: hidden;
+ display: inline-block;
+ white-space: pre-wrap;
+ word-break: break-word;
+ box-sizing: border-box;
+ padding: 0 5px;
+ border: 2px solid transparent;
+ font: inherit;
+ line-height: 1.35;
}
diff --git a/src/engine/README.md b/src/engine/README.md
index 1470c4f..9b784e5 100644
--- a/src/engine/README.md
+++ b/src/engine/README.md
@@ -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.
diff --git a/src/engine/__tests__/refs.spec.ts b/src/engine/__tests__/refs.spec.ts
index 7d106a0..ff1832e 100644
--- a/src/engine/__tests__/refs.spec.ts
+++ b/src/engine/__tests__/refs.spec.ts
@@ -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();
});
});
diff --git a/src/engine/index.ts b/src/engine/index.ts
index d287dab..3e537e4 100644
--- a/src/engine/index.ts
+++ b/src/engine/index.ts
@@ -24,7 +24,7 @@ export {
toA1,
looksLikeCell,
expandRange,
- cycleReferenceLock,
+ cycleReferenceLocks,
} from './refs';
export {
Workbook,
diff --git a/src/engine/refs.ts b/src/engine/refs.ts
index f94a48d..cb3d46d 100644
--- a/src/engine/refs.ts
+++ b/src/engine/refs.ts
@@ -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 ``/`