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
+17 -5
View File
@@ -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.
+8 -5
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue';
import { useDocument } from '../store/document';
import { cycleReferenceLock } from '../engine';
import { cycleReferenceLocks } from '../engine';
const store = useDocument();
const draft = ref('');
@@ -32,14 +32,17 @@ function onEscape() {
draft.value = store.activeCellInput.value;
(document.activeElement as HTMLElement)?.blur();
}
/** F4: cycle relative -> absolute -> row-locked -> col-locked for the cell
* reference at the caret (Excel/Numbers "Bezug sperren"). */
/** F4: cycle relative -> absolute -> row-locked -> col-locked (Excel/Numbers
* "Bezug sperren") -- for the reference at the caret, or every reference a
* real text selection overlaps at once. */
function onF4(e: KeyboardEvent) {
const el = e.target as HTMLInputElement;
const res = cycleReferenceLock(draft.value, el.selectionStart ?? draft.value.length);
const start = el.selectionStart ?? draft.value.length;
const end = el.selectionEnd ?? start;
const res = cycleReferenceLocks(draft.value, start, end);
if (!res) return;
draft.value = res.text;
nextTick(() => el.setSelectionRange(res.caret, res.caret));
nextTick(() => el.setSelectionRange(res.start, res.end));
}
</script>
+163 -33
View File
@@ -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<string, string> {
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<HTMLElement | null>(null);
// ---- fixed viewport + frozen panes -------------------------------
@@ -99,6 +108,17 @@ function cellStyle(c: number, r: number): Record<string, string> {
if (fr || fc) s.zIndex = fr && fc ? '5' : '3';
return s;
}
/** Caps a cell's rendered text to the row's own height. A `<tr height>` is
* only a *minimum* -- a `<td>`'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
* `<td>` 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<string, string> {
return { maxHeight: rowH(r) + 'px' };
}
function rowHeadStyle(r: number): Record<string, string> {
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 <td>, 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<HTMLInputElement | null>(null);
const editInput = ref<HTMLTextAreaElement | null>(null);
const editMirrorEl = ref<HTMLDivElement | null>(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 `<textarea>` can't size
* itself to its value with CSS alone. Capped at EDIT_MAX_W/H; beyond that
* it wraps/scrolls internally instead of growing further. */
function resizeEditor() {
const ta = editInput.value;
const mirror = editMirrorEl.value;
if (!ta || !mirror || !editing.value) return;
// a trailing zero-width char keeps a trailing "\n"'s empty last line measurable
mirror.textContent = editValue.value + '\u200b';
ta.style.width = mirror.offsetWidth + 'px';
ta.style.height = Math.min(mirror.offsetHeight, EDIT_MAX_H) + 'px';
}
function beginEdit(c: number, r: number, initial?: string) {
editing.value = { col: c, row: r };
editValue.value = initial ?? store.cellInput(props.tableId, c, r);
@@ -574,6 +633,7 @@ function beginEdit(c: number, r: number, initial?: string) {
el.focus();
if (initial === undefined) el.select();
else el.setSelectionRange(el.value.length, el.value.length);
resizeEditor();
});
}
function commitEdit() {
@@ -590,6 +650,23 @@ function cancelEdit() {
focusRoot();
}
function onEditKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && e.altKey) {
// insert a literal newline at the caret ourselves rather than relying on
// the textarea's native Enter handling, which some browsers/OSes divert
// Alt+Enter away from (e.g. as a window/fullscreen shortcut)
e.preventDefault();
e.stopPropagation();
const el = editInput.value;
if (!el) return;
const start = el.selectionStart ?? editValue.value.length;
const end = el.selectionEnd ?? start;
editValue.value = editValue.value.slice(0, start) + '\n' + editValue.value.slice(end);
nextTick(() => {
el.setSelectionRange(start + 1, start + 1);
resizeEditor();
});
return;
}
if (e.key === 'Enter') {
e.preventDefault();
commitEdit();
@@ -607,15 +684,21 @@ function onEditKeydown(e: KeyboardEvent) {
}
e.stopPropagation();
}
/** F4: cycle relative -> absolute -> row-locked -> col-locked for the cell
* reference at the caret (Excel/Numbers "Bezug sperren"). */
/** F4: cycle relative -> absolute -> row-locked -> col-locked (Excel/Numbers
* "Bezug sperren") -- for the reference at the caret, or every reference a
* real text selection overlaps at once. */
function toggleReferenceLock() {
const el = editInput.value;
if (!el) return;
const res = cycleReferenceLock(editValue.value, el.selectionStart ?? editValue.value.length);
const start = el.selectionStart ?? editValue.value.length;
const end = el.selectionEnd ?? start;
const res = cycleReferenceLocks(editValue.value, start, end);
if (!res) return;
editValue.value = res.text;
nextTick(() => el.setSelectionRange(res.caret, res.caret));
nextTick(() => {
el.setSelectionRange(res.start, res.end);
resizeEditor();
});
}
function onEditBlur() {
if (!committing && editing.value) commitEdit();
@@ -855,8 +938,6 @@ defineExpose({
:class="{
'is-active': isActive(c - 1, r - 1),
'in-range': inSelection(c - 1, r - 1),
numeric: store.cellIsNumeric(tableId, c - 1, r - 1),
error: store.cellIsError(tableId, c - 1, r - 1),
frozen: (c - 1 < frozenCols) || (r - 1 < frozenRows),
'freeze-edge-right': c - 1 === frozenCols - 1,
'freeze-edge-bottom': r - 1 === frozenRows - 1,
@@ -865,20 +946,19 @@ defineExpose({
@mousedown="onCellMouseDown($event, c - 1, r - 1)"
@dblclick="beginEdit(c - 1, r - 1)"
>
<input
v-if="editing && editing.col === c - 1 && editing.row === r - 1"
:ref="setEditRef"
v-model="editValue"
class="cell-input"
type="text"
spellcheck="false"
@keydown="onEditKeydown"
@blur="onEditBlur"
@mousedown.stop
/>
<template v-else>
{{ store.cellDisplay(tableId, c - 1, r - 1) }}
</template>
<div
class="cell-content"
:class="{
numeric: store.cellIsNumeric(tableId, c - 1, r - 1),
error: store.cellIsError(tableId, c - 1, r - 1),
multiline: isMultiline(c - 1, r - 1),
}"
:style="cellContentStyle(r - 1)"
>
<template v-if="!(editing && editing.col === c - 1 && editing.row === r - 1)">
{{ store.cellDisplay(tableId, c - 1, r - 1) }}
</template>
</div>
</td>
</tr>
</tbody>
@@ -893,6 +973,20 @@ defineExpose({
title="Ziehen zum Ausfüllen/Kopieren"
@mousedown="onFillHandleDown"
/>
<textarea
v-if="editing"
:ref="setEditRef"
v-model="editValue"
class="cell-input"
:style="editStyle"
spellcheck="false"
rows="1"
@keydown="onEditKeydown"
@input="resizeEditor"
@blur="onEditBlur"
@mousedown.stop
/>
<div v-if="editing" ref="editMirrorEl" class="cell-input-mirror" :style="editMirrorStyle" aria-hidden="true" />
</div>
<ContextMenu v-model:open="menu.open" :x="menu.x" :y="menu.y" :items="menuItems" />
@@ -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 <td>'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 <td>, 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;
}
</style>
+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). */