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:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -24,7 +24,7 @@ export {
|
||||
toA1,
|
||||
looksLikeCell,
|
||||
expandRange,
|
||||
cycleReferenceLock,
|
||||
cycleReferenceLocks,
|
||||
} from './refs';
|
||||
export {
|
||||
Workbook,
|
||||
|
||||
+54
-18
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user