From 06fea34f82be50e3d15e32cb144ddf0fda6e2cb9 Mon Sep 17 00:00:00 2001 From: Wild Karl-Heinz Date: Wed, 9 Sep 2026 23:54:01 +0200 Subject: [PATCH] Make the Name Box interactive: jump-to-reference + table/sheet/named-range autocomplete The top-left Name Box was a plain read-only display of the current selection ("Umsatz::B2:E6"). It's now an editable input that navigates on Enter, using the same reference syntax a formula would: a bare cell or range on the active table, a table/sheet-qualified reference (Tabelle::A1, Blatt!Tabelle::A1:B5), a named range's name, or -- beyond what's valid formula syntax -- a bare table name (selects the whole table) or bare sheet name (switches to it). Autocompletes against named ranges, table names, and sheet names as you type (Tab accepts the highlighted suggestion, click jumps there directly); an unresolvable entry reports via a toast and leaves the text selected for a quick retry rather than silently reverting. document.ts gains two new functions (jumpToNameInput, nameBoxSuggestions) built on the existing parseFormula/resolveTableName/resolveNamedRange machinery already used elsewhere in the store -- no engine changes. FormulaSuggest.vue's prop type was loosened so the same dropdown component serves both this and the existing formula function-name autocomplete. Co-Authored-By: Claude Sonnet 5 --- README.md | 31 +++++-- src/components/FormulaBar.vue | 128 +++++++++++++++++++++++++-- src/components/FormulaSuggest.vue | 11 +-- src/store/__tests__/document.spec.ts | 103 +++++++++++++++++++++ src/store/document.ts | 113 +++++++++++++++++++++++ src/store/types.ts | 11 +++ 6 files changed, 380 insertions(+), 17 deletions(-) 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