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