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 <noreply@anthropic.com>
This commit is contained in:
@@ -190,11 +190,32 @@ Zeilenhöhe der Tabelle geklemmt.
|
|||||||
an der unteren Kante lässt sie sich per **Maus** (natives `resize: vertical`)
|
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`
|
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
|
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
|
Namensfeld links davon (ebenso das rein lesende Pendant links in der
|
||||||
mit den Blatt-Reitern eine gemeinsame Zeile am unteren Rand teilt) zeigt bei
|
**Statuszeile**, die mit den Blatt-Reitern eine gemeinsame Zeile am unteren
|
||||||
einer Mehrfachauswahl nicht nur die aktive Zelle, sondern den ganzen markierten
|
Rand teilt) zeigt bei einer Mehrfachauswahl nicht nur die aktive Zelle,
|
||||||
**Bereich** (`Umsatz::B2:E6`), bei einer diskontinuierlichen Auswahl zusätzlich
|
sondern den ganzen markierten **Bereich** (`Umsatz::B2:E6`), bei einer
|
||||||
`+N` für die weiteren Rechtecke.
|
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
|
**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 —
|
Formelleiste) eine Formel (`=…`) enthält, fügt ein **Klick** auf eine Zelle —
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { nextTick, ref, watch } from 'vue';
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
import { useDocument } from '../store/document';
|
import { useDocument } from '../store/document';
|
||||||
import { cycleReferenceLocks } from '../engine';
|
import { cycleReferenceLocks } from '../engine';
|
||||||
import { useFormulaAutocomplete } from '../composables/useFormulaAutocomplete';
|
import { useFormulaAutocomplete } from '../composables/useFormulaAutocomplete';
|
||||||
import { useKeyboardPointMode } from '../composables/useKeyboardPointMode';
|
import { useKeyboardPointMode } from '../composables/useKeyboardPointMode';
|
||||||
|
import { useToasts } from '../composables/useToasts';
|
||||||
import FormulaSuggest from './FormulaSuggest.vue';
|
import FormulaSuggest from './FormulaSuggest.vue';
|
||||||
|
|
||||||
const store = useDocument();
|
const store = useDocument();
|
||||||
|
const toast = useToasts();
|
||||||
const draft = ref('');
|
const draft = ref('');
|
||||||
const focused = ref(false);
|
const focused = ref(false);
|
||||||
const fieldEl = ref<HTMLTextAreaElement | null>(null);
|
const fieldEl = ref<HTMLTextAreaElement | null>(null);
|
||||||
@@ -22,6 +24,84 @@ watch(
|
|||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ---- Name Box (top-left) ---------------------------------------------
|
||||||
|
// Doubles as (1) a live "where am I" display (e.g. "Umsatz::B2:E6") and (2)
|
||||||
|
// a jump/select field: type a cell/range/table/sheet/named-range name and
|
||||||
|
// press Enter -- see document.ts's `jumpToNameInput` for exactly what it
|
||||||
|
// accepts. Autocompletes against named ranges, table names, and sheet
|
||||||
|
// names (`nameBoxSuggestions`), same dropdown component as the formula
|
||||||
|
// function-name autocomplete.
|
||||||
|
const nameDraft = ref('');
|
||||||
|
const nameFocused = ref(false);
|
||||||
|
const nameActiveIndex = ref(0);
|
||||||
|
const nameEl = ref<HTMLInputElement | null>(null);
|
||||||
|
const nameSuggestions = computed(() =>
|
||||||
|
nameFocused.value ? store.nameBoxSuggestions(nameDraft.value) : [],
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => store.selectionRangeName.value,
|
||||||
|
(v) => {
|
||||||
|
if (!nameFocused.value) nameDraft.value = v;
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
function onNameFocus() {
|
||||||
|
nameFocused.value = true;
|
||||||
|
nameActiveIndex.value = 0;
|
||||||
|
nextTick(() => nameEl.value?.select());
|
||||||
|
}
|
||||||
|
function onNameBlur() {
|
||||||
|
nameFocused.value = false;
|
||||||
|
nameDraft.value = store.selectionRangeName.value; // discard an unconfirmed edit
|
||||||
|
}
|
||||||
|
function onNameInput() {
|
||||||
|
nameActiveIndex.value = 0;
|
||||||
|
}
|
||||||
|
/** Tries to navigate to `text`; on success blurs back to the grid (so the
|
||||||
|
* Name Box promptly shows the new selection), on failure keeps the field
|
||||||
|
* focused/selected (so the user can see and immediately correct it) and
|
||||||
|
* reports the problem via a toast, same as other invalid-input feedback
|
||||||
|
* in this app. */
|
||||||
|
function jumpTo(text: string) {
|
||||||
|
if (store.jumpToNameInput(text)) {
|
||||||
|
(document.activeElement as HTMLElement)?.blur();
|
||||||
|
} else {
|
||||||
|
toast.error(`„${text}" ist kein gültiger Bezug oder Name.`);
|
||||||
|
nextTick(() => nameEl.value?.select());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onNamePick(i: number) {
|
||||||
|
const item = nameSuggestions.value[i];
|
||||||
|
if (item) jumpTo(item.name);
|
||||||
|
}
|
||||||
|
function onNameKeydown(e: KeyboardEvent) {
|
||||||
|
const hasDropdown = nameSuggestions.value.length > 0;
|
||||||
|
if (hasDropdown && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
|
||||||
|
e.preventDefault();
|
||||||
|
const n = nameSuggestions.value.length;
|
||||||
|
nameActiveIndex.value = (nameActiveIndex.value + (e.key === 'ArrowDown' ? 1 : -1) + n) % n;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasDropdown && e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
nameDraft.value = nameSuggestions.value[nameActiveIndex.value].name;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
jumpTo(nameDraft.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
nameDraft.value = store.selectionRangeName.value;
|
||||||
|
(document.activeElement as HTMLElement)?.blur();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function commit() {
|
function commit() {
|
||||||
const sel = store.doc.selection;
|
const sel = store.doc.selection;
|
||||||
if (sel) store.setCellInput(sel.tableId, sel.anchor.col, sel.anchor.row, draft.value);
|
if (sel) store.setCellInput(sel.tableId, sel.anchor.col, sel.anchor.row, draft.value);
|
||||||
@@ -165,9 +245,25 @@ function onKeydown(e: KeyboardEvent) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="formula-bar">
|
<div class="formula-bar">
|
||||||
<span class="cell-name" :title="store.selectionRangeName.value">
|
<div class="name-box">
|
||||||
{{ store.selectionRangeName.value }}
|
<input
|
||||||
</span>
|
ref="nameEl"
|
||||||
|
v-model="nameDraft"
|
||||||
|
class="cell-name"
|
||||||
|
spellcheck="false"
|
||||||
|
title="Name/Bezug eingeben und Enter drücken, um dorthin zu springen"
|
||||||
|
@focus="onNameFocus"
|
||||||
|
@blur="onNameBlur"
|
||||||
|
@keydown="onNameKeydown"
|
||||||
|
@input="onNameInput"
|
||||||
|
/>
|
||||||
|
<FormulaSuggest
|
||||||
|
v-if="nameSuggestions.length"
|
||||||
|
:items="nameSuggestions"
|
||||||
|
:active-index="nameActiveIndex"
|
||||||
|
@pick="onNamePick"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<span class="fx">fx</span>
|
<span class="fx">fx</span>
|
||||||
<div class="input-wrap">
|
<div class="input-wrap">
|
||||||
<textarea
|
<textarea
|
||||||
@@ -203,10 +299,22 @@ function onKeydown(e: KeyboardEvent) {
|
|||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.cell-name {
|
/* holds the FormulaSuggest dropdown, positioned relative to this -- same
|
||||||
|
role as .input-wrap below, for the Name Box instead of the formula field */
|
||||||
|
.name-box {
|
||||||
|
position: relative;
|
||||||
min-width: 128px;
|
min-width: 128px;
|
||||||
max-width: 128px;
|
max-width: 128px;
|
||||||
margin-top: 6px;
|
}
|
||||||
|
.cell-name {
|
||||||
|
width: 100%;
|
||||||
|
height: 30px;
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0 6px;
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
@@ -214,8 +322,14 @@ function onKeydown(e: KeyboardEvent) {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
.cell-name:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.cell-name {
|
.name-box {
|
||||||
min-width: 64px;
|
min-width: 64px;
|
||||||
max-width: 64px;
|
max-width: 64px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* Function-name autocomplete dropdown for a formula editor. Purely
|
* Generic name/function autocomplete dropdown -- used both for a formula
|
||||||
|
* editor's function-name autocomplete (see useFormulaAutocomplete.ts) and
|
||||||
|
* the Name Box's table/sheet/named-range autocomplete (see document.ts's
|
||||||
|
* `nameBoxSuggestions()`), anything shaped like {name, hint}. Purely
|
||||||
* presentational -- positions itself just under whatever it's rendered
|
* presentational -- positions itself just under whatever it's rendered
|
||||||
* inside of (that parent needs `position: relative`); no caret-pixel
|
* inside of (that parent needs `position: relative`); no caret-pixel
|
||||||
* tracking. See useFormulaAutocomplete.ts for the state/logic this displays.
|
* tracking.
|
||||||
*/
|
*/
|
||||||
import type { FnSuggestion } from '../store/types';
|
defineProps<{ items: { name: string; hint: string }[]; activeIndex: number }>();
|
||||||
|
|
||||||
defineProps<{ items: FnSuggestion[]; activeIndex: number }>();
|
|
||||||
const emit = defineEmits<{ (e: 'pick', index: number): void }>();
|
const emit = defineEmits<{ (e: 'pick', index: number): void }>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -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', () => {
|
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', () => {
|
it('renaming a table to a name already used on a DIFFERENT sheet is now allowed', () => {
|
||||||
const s = fresh();
|
const s = fresh();
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ import type {
|
|||||||
DocumentState,
|
DocumentState,
|
||||||
FnSuggestion,
|
FnSuggestion,
|
||||||
Macro,
|
Macro,
|
||||||
|
NameSuggestion,
|
||||||
Sheet,
|
Sheet,
|
||||||
SelectionState,
|
SelectionState,
|
||||||
SheetObject,
|
SheetObject,
|
||||||
@@ -2590,6 +2591,116 @@ function createStore() {
|
|||||||
.map((f) => ({ name: f.name, hint: `${f.name}(${f.params.join('; ')})`, kind: 'custom' }));
|
.map((f) => ({ name: f.name, hint: `${f.name}(${f.params.join('; ')})`, kind: 'custom' }));
|
||||||
return [...builtins, ...custom].sort((a, b) => a.name.localeCompare(b.name));
|
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<typeof parseFormula> | 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 {
|
function qualifyRef(homeTableId: string, targetTableId: string, body: string): string {
|
||||||
if (targetTableId === homeTableId) return body;
|
if (targetTableId === homeTableId) return body;
|
||||||
const target = tableById(targetTableId);
|
const target = tableById(targetTableId);
|
||||||
@@ -2912,6 +3023,8 @@ function createStore() {
|
|||||||
noteFormulaTyped,
|
noteFormulaTyped,
|
||||||
formulaPointModeActive,
|
formulaPointModeActive,
|
||||||
functionSuggestions,
|
functionSuggestions,
|
||||||
|
nameBoxSuggestions,
|
||||||
|
jumpToNameInput,
|
||||||
pointClick,
|
pointClick,
|
||||||
pointDrag,
|
pointDrag,
|
||||||
// document
|
// document
|
||||||
|
|||||||
@@ -272,6 +272,17 @@ export interface FnSuggestion {
|
|||||||
kind: 'builtin' | 'custom';
|
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
|
/** One node of the interactive dependency-graph view (document.ts's
|
||||||
* `cellGraphNeighbors()`/`cellGraphSelf()`) -- a precedent/dependent cell
|
* `cellGraphNeighbors()`/`cellGraphSelf()`) -- a precedent/dependent cell
|
||||||
* (or the centered cell itself), enriched with what the UI needs to
|
* (or the centered cell itself), enriched with what the UI needs to
|
||||||
|
|||||||
Reference in New Issue
Block a user