diff --git a/README.md b/README.md
index 42ee4c0..ead5f17 100644
--- a/README.md
+++ b/README.md
@@ -190,11 +190,32 @@ Zeilenhöhe der Tabelle geklemmt.
an der unteren Kante lässt sie sich per **Maus** (natives `resize: vertical`)
auf beliebig viele Zeilen aufziehen, um mehr auf einmal zu sehen. `Alt`+`Enter`
bzw. `Cmd`+`Enter`, `F4` und `Esc` verhalten sich wie im In-Zellen-Editor. Das
-Namensfeld links davon (ebenso das Pendant links in der **Statuszeile**, die
-mit den Blatt-Reitern eine gemeinsame Zeile am unteren Rand teilt) zeigt bei
-einer Mehrfachauswahl nicht nur die aktive Zelle, sondern den ganzen markierten
-**Bereich** (`Umsatz::B2:E6`), bei einer diskontinuierlichen Auswahl zusätzlich
-`+N` für die weiteren Rechtecke.
+Namensfeld links davon (ebenso das rein lesende Pendant links in der
+**Statuszeile**, die mit den Blatt-Reitern eine gemeinsame Zeile am unteren
+Rand teilt) zeigt bei einer Mehrfachauswahl nicht nur die aktive Zelle,
+sondern den ganzen markierten **Bereich** (`Umsatz::B2:E6`), bei einer
+diskontinuierlichen Auswahl zusätzlich `+N` für die weiteren Rechtecke.
+
+**Namensfeld als Sprungmarke** (Exceleigenschaft, hier zusätzlich mit
+Autovervollständigung): das Feld selbst ist editierbar. Eingetippt und mit
+`Enter` bestätigt springt es zu jedem gültigen Ziel — derselben Syntax, die
+auch eine Formel verwenden würde:
+- eine **Zelle** (`A1`) oder ein **Bereich** (`A1:C10`) auf der gerade
+ aktiven Tabelle,
+- ein **qualifizierter** Bezug auf eine andere Tabelle/ein anderes Blatt
+ (`Tabelle::A1`, `Blatt!Tabelle::A1:B5`) — wechselt Blatt/Auswahl mit,
+- ein **benannter Bereich** (`Umsatz_Q1`) — markiert dessen Zielbereich,
+- ein bloßer **Tabellenname** (`Kosten`) — markiert die ganze Tabelle,
+- ein bloßer **Blattname** (`Blatt 2`) — wechselt dorthin.
+
+Während des Tippens schlägt eine Dropdown-Liste passende **benannte
+Bereiche**, **Tabellennamen** und **Blattnamen** vor (Präfix, groß-/
+kleinschreibungsunabhängig; `↑`/`↓` navigiert, `Tab` übernimmt den markierten
+Vorschlag ins Feld, ein Klick springt direkt dorthin). Ein nicht auflösbarer
+Text (z. B. eine echte Formel wie `SUM(A1;A2)` — das Feld navigiert, es
+wertet nicht aus) meldet sich per Toast und lässt den Text zum Korrigieren
+markiert stehen; `Esc` verwirft die Eingabe und zeigt wieder die aktuelle
+Auswahl.
**Bezüge per Maus einfügen ("Point-Mode")**: sobald der Editor (Zelle oder
Formelleiste) eine Formel (`=…`) enthält, fügt ein **Klick** auf eine Zelle —
diff --git a/src/components/FormulaBar.vue b/src/components/FormulaBar.vue
index f7e5310..2d2afa7 100644
--- a/src/components/FormulaBar.vue
+++ b/src/components/FormulaBar.vue
@@ -1,12 +1,14 @@
diff --git a/src/store/__tests__/document.spec.ts b/src/store/__tests__/document.spec.ts
index cacc999..158f1f6 100644
--- a/src/store/__tests__/document.spec.ts
+++ b/src/store/__tests__/document.spec.ts
@@ -2423,6 +2423,109 @@ describe('formula "point mode" (click/drag a cell to insert a reference)', () =>
});
});
+describe('Name Box (jumpToNameInput / nameBoxSuggestions)', () => {
+ it('a bare cell/range navigates on the currently active table', () => {
+ const s = fresh();
+ s.setSelection({ tableId: tableId(s, 'Umsatz'), anchor: coords('A1'), focus: coords('A1') });
+ expect(s.jumpToNameInput('B3')).toBe(true);
+ expect(s.doc.selection).toMatchObject({
+ tableId: tableId(s, 'Umsatz'),
+ anchor: coords('B3'),
+ focus: coords('B3'),
+ });
+ expect(s.jumpToNameInput('B2:D4')).toBe(true);
+ expect(s.doc.selection).toMatchObject({ anchor: coords('B2'), focus: coords('D4') });
+ });
+
+ it('a qualified reference ("Tabelle::A1") jumps to a different table, switching sheet/object', () => {
+ const s = fresh();
+ expect(s.jumpToNameInput('Kosten::B2')).toBe(true);
+ expect(s.doc.selection).toMatchObject({
+ tableId: tableId(s, 'Kosten'),
+ anchor: coords('B2'),
+ focus: coords('B2'),
+ });
+ expect(s.doc.activeObjectId).toBe(tableId(s, 'Kosten'));
+ });
+
+ it('a named range name jumps to and selects its target range', () => {
+ const s = fresh();
+ expect(s.jumpToNameInput('Umsatz_Q1')).toBe(true);
+ expect(s.doc.selection).toMatchObject({
+ tableId: tableId(s, 'Umsatz'),
+ anchor: coords('B2'),
+ focus: coords('B6'),
+ });
+ });
+
+ it('a qualified reference works when the table name has non-ASCII letters', () => {
+ // regression: the lexer used to reject "Ü" outright (an unquoted
+ // qualifier is tokenized the same as any other identifier), so
+ // "Übersicht::B4" threw instead of parsing -- see engine/lexer.ts.
+ const s = fresh();
+ expect(s.jumpToNameInput('Übersicht::B4')).toBe(true);
+ expect(s.doc.selection).toMatchObject({
+ tableId: tableId(s, 'Übersicht'),
+ anchor: coords('B4'),
+ focus: coords('B4'),
+ });
+ });
+
+ it('a bare table name selects the whole table', () => {
+ const s = fresh();
+ const id = tableId(s, 'Kosten');
+ const t = s.tableById(id)!;
+ expect(s.jumpToNameInput('Kosten')).toBe(true);
+ expect(s.doc.selection).toMatchObject({
+ tableId: id,
+ anchor: { col: 0, row: 0 },
+ focus: { col: t.cols - 1, row: t.rows - 1 },
+ });
+ });
+
+ it('a bare sheet name switches to that sheet', () => {
+ const s = fresh();
+ expect(s.doc.activeSheetId).not.toBe(s.doc.sheets[1].id);
+ expect(s.jumpToNameInput('Blatt 2')).toBe(true);
+ expect(s.doc.activeSheetId).toBe(s.doc.sheets[1].id);
+ });
+
+ it('garbage input, an unknown name, or a non-reference formula (e.g. a function call) resolves to nothing', () => {
+ const s = fresh();
+ const before = s.doc.selection;
+ expect(s.jumpToNameInput('')).toBe(false);
+ expect(s.jumpToNameInput(' ')).toBe(false);
+ expect(s.jumpToNameInput('NichtVorhanden')).toBe(false);
+ expect(s.jumpToNameInput('SUM(A1;A2)')).toBe(false); // navigates, doesn't evaluate
+ expect(s.jumpToNameInput('1+1')).toBe(false);
+ expect(s.doc.selection).toEqual(before); // no partial/incidental navigation on failure
+ });
+
+ it('out-of-bounds coordinates clamp into the target table instead of erroring', () => {
+ const s = fresh();
+ const id = tableId(s, 'Umsatz');
+ const t = s.tableById(id)!;
+ expect(s.jumpToNameInput('Z999')).toBe(true);
+ expect(s.doc.selection).toMatchObject({
+ tableId: id,
+ anchor: { col: t.cols - 1, row: t.rows - 1 },
+ focus: { col: t.cols - 1, row: t.rows - 1 },
+ });
+ });
+
+ it('nameBoxSuggestions offers named ranges, then tables, then sheets, prefix-matched', () => {
+ const s = fresh();
+ expect(s.nameBoxSuggestions('')).toEqual([]); // empty prefix -> no suggestions
+ const hits = s.nameBoxSuggestions('u');
+ expect(hits.map((h) => h.name)).toEqual(['Umsatz_Q1', 'Umsatz']); // namedrange before table
+ expect(hits[0]).toMatchObject({ kind: 'namedrange', hint: 'Umsatz!B2:B6' });
+ expect(hits[1]).toMatchObject({ kind: 'table' });
+
+ const blattHits = s.nameBoxSuggestions('blatt');
+ expect(blattHits).toEqual([{ name: 'Blatt 2', hint: 'Blatt', kind: 'sheet' }]);
+ });
+});
+
describe('cross-sheet formulas: sheet qualifier + per-sheet table name uniqueness', () => {
it('renaming a table to a name already used on a DIFFERENT sheet is now allowed', () => {
const s = fresh();
diff --git a/src/store/document.ts b/src/store/document.ts
index 33f4d0a..d8d35a0 100644
--- a/src/store/document.ts
+++ b/src/store/document.ts
@@ -68,6 +68,7 @@ import type {
DocumentState,
FnSuggestion,
Macro,
+ NameSuggestion,
Sheet,
SelectionState,
SheetObject,
@@ -2590,6 +2591,116 @@ function createStore() {
.map((f) => ({ name: f.name, hint: `${f.name}(${f.params.join('; ')})`, kind: 'custom' }));
return [...builtins, ...custom].sort((a, b) => a.name.localeCompare(b.name));
}
+ /** Name Box autocomplete candidates for a prefix already typed -- named
+ * ranges, table names (any sheet), then sheet names, each group
+ * case-insensitively prefix-matched and name-sorted, in that priority
+ * order (matching `jumpToNameInput`'s own resolution order below). Empty
+ * prefix -> no suggestions, same convention as `functionSuggestions`. */
+ function nameBoxSuggestions(prefix: string): NameSuggestion[] {
+ const p = prefix.trim().toLowerCase();
+ if (!p) return [];
+ const namedRanges: NameSuggestion[] = doc.namedRanges
+ .filter((r) => r.name.toLowerCase().startsWith(p))
+ .map((r) => {
+ const t = tableById(r.tableId);
+ const addr =
+ r.start.col === r.end.col && r.start.row === r.end.row
+ ? toA1(r.start.col, r.start.row)
+ : `${toA1(r.start.col, r.start.row)}:${toA1(r.end.col, r.end.row)}`;
+ return { name: r.name, hint: `${t?.name ?? '?'}!${addr}`, kind: 'namedrange' as const };
+ })
+ .sort((a, b) => a.name.localeCompare(b.name));
+ const tables: NameSuggestion[] = allTables(doc)
+ .filter((t) => t.name.toLowerCase().startsWith(p))
+ .map((t) => {
+ const sheetId = sheetIdOfObject(t.id);
+ const sheetName = (sheetId && sheetById(sheetId)?.name) || '?';
+ return { name: t.name, hint: `Tabelle · Blatt „${sheetName}"`, kind: 'table' as const };
+ })
+ .sort((a, b) => a.name.localeCompare(b.name));
+ const sheets: NameSuggestion[] = doc.sheets
+ .filter((s) => s.name.toLowerCase().startsWith(p))
+ .map((s) => ({ name: s.name, hint: 'Blatt', kind: 'sheet' as const }))
+ .sort((a, b) => a.name.localeCompare(b.name));
+ return [...namedRanges, ...tables, ...sheets];
+ }
+
+ /**
+ * Resolves free-form Name Box input and navigates to it -- same syntax a
+ * formula reference would use (`Tabelle::A1`, `Blatt!Tabelle::A1:B5`, a
+ * bare `A1`/`A1:B5` meaning "on the currently active table", or a named
+ * range's own name), falling back to a bare **table** name (selects the
+ * whole table) or a bare **sheet** name (switches to it) -- neither of
+ * those two is valid formula syntax on its own, so they're only tried
+ * once the reference parse produced nothing usable. Returns whether it
+ * found something to jump to; the caller (the Name Box itself) reports a
+ * failure to the user.
+ */
+ function jumpToNameInput(text: string): boolean {
+ const trimmed = text.trim();
+ if (!trimmed) return false;
+ const home = doc.selection?.tableId ?? activeTable.value?.id ?? null;
+
+ let node: ReturnType | null = null;
+ try {
+ node = parseFormula(trimmed);
+ } catch {
+ node = null;
+ }
+ if (node) {
+ let qualifier: string | null = null;
+ let sheetQualifier: string | undefined;
+ let start: { col: number; row: number } | null = null;
+ let end: { col: number; row: number } | null = null;
+ if (node.kind === 'cell') {
+ qualifier = node.qualifier;
+ sheetQualifier = node.sheetQualifier;
+ start = end = { col: node.col, row: node.row };
+ } else if (node.kind === 'range') {
+ qualifier = node.qualifier;
+ sheetQualifier = node.sheetQualifier;
+ start = node.start;
+ end = node.end;
+ } else if (node.kind === 'namedref') {
+ const r = wb.resolveNamedRange(node.name);
+ if (r) {
+ qualifier = r.qualifier;
+ sheetQualifier = r.sheetQualifier;
+ start = r.start;
+ end = r.end;
+ }
+ }
+ if (start && end) {
+ const tableId = qualifier === null ? home : wb.resolveTableName(qualifier, sheetQualifier ?? null);
+ const t = tableId ? tableById(tableId) : undefined;
+ if (t) {
+ const clampC = (c: number) => Math.max(0, Math.min(t.cols - 1, c));
+ const clampR = (r: number) => Math.max(0, Math.min(t.rows - 1, r));
+ setSelection({
+ tableId: t.id,
+ anchor: { col: clampC(start.col), row: clampR(start.row) },
+ focus: { col: clampC(end.col), row: clampR(end.row) },
+ });
+ return true;
+ }
+ }
+ }
+
+ // Not a (resolvable) reference -- try a bare table or sheet name.
+ const tableId = wb.resolveTableName(trimmed, null);
+ const t = tableId ? tableById(tableId) : undefined;
+ if (t) {
+ setSelection({ tableId: t.id, anchor: { col: 0, row: 0 }, focus: { col: t.cols - 1, row: t.rows - 1 } });
+ return true;
+ }
+ const sheet = sheetByName(trimmed);
+ if (sheet) {
+ setActiveSheet(sheet.id);
+ return true;
+ }
+ return false;
+ }
+
function qualifyRef(homeTableId: string, targetTableId: string, body: string): string {
if (targetTableId === homeTableId) return body;
const target = tableById(targetTableId);
@@ -2912,6 +3023,8 @@ function createStore() {
noteFormulaTyped,
formulaPointModeActive,
functionSuggestions,
+ nameBoxSuggestions,
+ jumpToNameInput,
pointClick,
pointDrag,
// document
diff --git a/src/store/types.ts b/src/store/types.ts
index cb2ccc8..40c1328 100644
--- a/src/store/types.ts
+++ b/src/store/types.ts
@@ -272,6 +272,17 @@ export interface FnSuggestion {
kind: 'builtin' | 'custom';
}
+/** One entry in the Name Box's autocomplete dropdown (see document.ts's
+ * `nameBoxSuggestions()`) -- a named range, table, or sheet the typed
+ * prefix matches. */
+export interface NameSuggestion {
+ name: string;
+ /** one-line description, e.g. the range a named range points at, or
+ * which sheet a table lives on. */
+ hint: string;
+ kind: 'namedrange' | 'table' | 'sheet';
+}
+
/** One node of the interactive dependency-graph view (document.ts's
* `cellGraphNeighbors()`/`cellGraphSelf()`) -- a precedent/dependent cell
* (or the centered cell itself), enriched with what the UI needs to