Funktions-Autovervollständigung in der Formelbearbeitung

Beim Tippen eines Funktionsnamens (nach "=", einem Operator, "(" oder
";" -- nicht bei einem Zellbezug wie A1) zeigt sowohl die Formelleiste
als auch der In-Zellen-Editor jetzt ein Dropdown mit passenden
eingebauten UND eigenen Funktionen samt Kurzsignatur. ↑/↓ navigieren,
Tab/Enter/Klick übernehmen (fügt "NAME(" ein), Esc schließt nur das
Dropdown, ohne die Bearbeitung abzubrechen.

- engine/refs.ts: neue reine Funktion currentIdentifierAtCaret() --
  strukturell wie das schon vorhandene cycleReferenceLocks (=
  abstreifen, tokenize, am Cursor nachsehen), aber nur der Text bis
  zum Cursor wird tokenisiert und nur das letzte Token zählt; ein
  IDENT, das bis an den Cursor reicht und nicht wie ein Zellbezug
  aussieht, ist der Funktionsname. tokenize() hängt ein EOF-Token an
  -- muss beim Suchen des "letzten Tokens" herausgefiltert werden
  (von den eigenen Tests aufgedeckt).
- engine/functions.ts: BUILTIN_SIGNATURES (Name -> Kurzsignatur je
  eingebauter Funktion), gegen FUNCTIONS' Keys testabgesichert, damit
  eine künftige neue Funktion ohne Eintrag hier nicht still im
  Dropdown fehlt.
- store/document.ts: functionSuggestions(prefix) kombiniert eingebaute
  + dokumenteigene Funktionen, case-insensitiv gefiltert, sortiert.
- composables/useFormulaAutocomplete.ts: pro Editor-Instanz eigener
  State (suggestions/activeIndex), orchestriert die drei Bausteine
  oben; die Übernahme eines Vorschlags läuft über dieselbe
  replace(start,end,value)-Mechanik, die Point-Mode für den Bezug-
  Einfügeklick schon nutzt -- keine neue Einfüge-Infrastruktur nötig.
- components/FormulaSuggest.vue: geteiltes, rein präsentationelles
  Dropdown (position:absolute; top:100% relativ zum Editor-Wrapper,
  kein Caret-Pixel-Tracking).
- FormulaBar.vue + TableGrid.vue: gleiches Einbindungsmuster in
  beiden Editoren (Wrapper bekommt position:relative, onInput ruft
  autocomplete.update(), onKeydown fängt Pfeiltasten/Tab/Enter/Esc
  vor der bestehenden Logik ab, wenn Vorschläge offen sind, onBlur
  schließt das Dropdown).

Tests: 13 neue (refs.spec.ts: 7 Fälle für currentIdentifierAtCaret;
functions.spec.ts neu: Signatur-Tabelle deckungsgleich mit FUNCTIONS;
document.spec.ts: functionSuggestions Filter/Mischung/Sortierung).
318 -> 331 Tests, alle grün. vue-tsc/eslint/build sauber. Live per
Playwright verifiziert in Formelleiste UND In-Zellen-Editor: Filterung,
Pfeiltasten-Navigation, Tab-Übernahme, Maus-Klick-Übernahme, Esc
schließt nur das Dropdown, eigene Funktion (MARGE) erscheint neben
eingebauten (MAX) korrekt einsortiert.

READMEs aktualisiert (neuer Abschnitt "Funktions-Autovervollständigung",
Tastaturtabelle, Testliste, Bekannte Grenzen -- dabei eine veraltete
Aussage korrigiert, die Autovervollständigung für benutzerdefinierte
Funktionen noch als "bewusst fehlend" auflistete).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-03 08:43:57 +02:00
co-authored by Claude Sonnet 5
parent 552f1cfae8
commit 0b7602b874
14 changed files with 493 additions and 41 deletions
+31 -6
View File
@@ -13,7 +13,7 @@ Vorbild von **Apple Numbers**. Zwei Dinge stehen im Mittelpunkt:
```bash ```bash
npm install npm install
npm run dev # Vite-Devserver npm run dev # Vite-Devserver
npm run test # Vitest-Suite (Engine + Store + IO), 318 Tests npm run test # Vitest-Suite (Engine + Store + IO), 331 Tests
npm run build # Typecheck + Produktionsbuild npm run build # Typecheck + Produktionsbuild
npm run lint # ESLint (flat config) npm run lint # ESLint (flat config)
``` ```
@@ -102,6 +102,7 @@ umbenennen, löschen, per Drag sortieren). Zoom 40–200 % pro Blatt
| **`Strg`/`Cmd`+`Z`** · **`Strg`/`Cmd`+`Y`** / **`Strg`/`Cmd`+`Shift`+`Z`** | Rückgängig · Wiederholen (auch als Symbolleisten-Buttons ↶ ↷) | | **`Strg`/`Cmd`+`Z`** · **`Strg`/`Cmd`+`Y`** / **`Strg`/`Cmd`+`Shift`+`Z`** | Rückgängig · Wiederholen (auch als Symbolleisten-Buttons ↶ ↷) |
| **`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 | | **`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`** bzw. **`Cmd`+`Enter`** (im Editor) | Zeilenumbruch in der Zelle einfügen, statt zu committen — `Cmd`+`Enter` als Mac-Fallback, da `⌥` (Option) dort eine Compose-Taste ist und nicht überall zuverlässig als reines Modifier ankommt | | **`Alt`+`Enter`** bzw. **`Cmd`+`Enter`** (im Editor) | Zeilenumbruch in der Zelle einfügen, statt zu committen — `Cmd`+`Enter` als Mac-Fallback, da `⌥` (Option) dort eine Compose-Taste ist und nicht überall zuverlässig als reines Modifier ankommt |
| `↑`/`↓`/`Tab`/`Enter`/`Esc` (bei offenem Funktions-Dropdown) | navigieren / Vorschlag übernehmen / nur das Dropdown schließen — hat Vorrang vor der sonstigen Bedeutung dieser Tasten (s. u. „Funktions-Autovervollständigung") |
Bei **festen (scrollbaren) Tabellen** wird die aktive Zelle bei jeder Navigation Bei **festen (scrollbaren) Tabellen** wird die aktive Zelle bei jeder Navigation
automatisch in den sichtbaren Bereich gescrollt (unter Beachtung fixierter automatisch in den sichtbaren Bereich gescrollt (unter Beachtung fixierter
@@ -400,6 +401,15 @@ verschiebt sich die referenzierte Zelle selbst, also folgt jede Referenz
darauf, `$` hin oder her. `F4` schaltet den Bezug unter dem Cursor durch die darauf, `$` hin oder her. `F4` schaltet den Bezug unter dem Cursor durch die
vier Kombinationen. vier Kombinationen.
**Funktions-Autovervollständigung**: sobald in der Formelleiste oder im
In-Zellen-Editor ein Funktionsname zu tippen beginnt (nach `=`, einem
Operator, `(` oder `;` — nicht bei einem Zellbezug wie `A1`), erscheint ein
Dropdown mit den passenden eingebauten **und** eigenen Funktionen samt
Kurzsignatur (z. B. `XLOOKUP(Suchwert; Suchbereich; Rückgabebereich; …)`).
`↑`/`↓` navigieren, `Tab`/`Enter` oder ein Klick übernehmen den markierten
Vorschlag (fügt `NAME(` ein, bereit für die Argumente), `Esc` schließt nur
das Dropdown, ohne die Bearbeitung selbst abzubrechen.
**Funktionen** (case-insensitiv, Trenner `;`): `SUM`, `AVG` (Alias `AVERAGE`), **Funktionen** (case-insensitiv, Trenner `;`): `SUM`, `AVG` (Alias `AVERAGE`),
`MIN`, `MAX`, `COUNT`, `IF(Bedingung; Dann; Sonst?)`, `DATE(Jahr; Monat; `MIN`, `MAX`, `COUNT`, `IF(Bedingung; Dann; Sonst?)`, `DATE(Jahr; Monat;
Tag)`, `TIME(Std; Min; Sek)`, `TODAY()`, `NOW()`, `XLOOKUP(Suchwert; Tag)`, `TIME(Std; Min; Sek)`, `TODAY()`, `NOW()`, `XLOOKUP(Suchwert;
@@ -593,7 +603,7 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
## Tests ## Tests
`npm run test` — Vitest, 318 Tests: `npm run test` — Vitest, 331 Tests:
- **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^` - **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^`
rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`, rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`,
@@ -679,6 +689,14 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
hängt er einen neuen an; Ziehen fügt/erweitert einen Bereichsbezug; ein Klick hängt er einen neuen an; Ziehen fügt/erweitert einen Bereichsbezug; ein Klick
in eine andere Tabelle qualifiziert mit deren Namen (Quoting bei Leerzeichen); in eine andere Tabelle qualifiziert mit deren Namen (Quoting bei Leerzeichen);
ohne Formel im Puffer greift nichts. ohne Formel im Puffer greift nichts.
- **Funktions-Autovervollständigung** — Engine (`refs.spec.ts`):
`currentIdentifierAtCaret` erkennt den getippten Namen nach `=`/Operator/`(`/
`;`, ignoriert Zellbezüge (`A1`) und bereits abgeschlossene Aufrufe (`SUM(`),
schaut nie über den Cursor hinaus, verkraftet einen kaputten Formel-Präfix
ohne zu werfen. Store (`document.spec.ts`): `functionSuggestions` filtert
case-insensitiv, mischt eingebaute und eigene Funktionen sortiert.
`functions.spec.ts`: die Signatur-Tabelle deckt sich exakt mit den
registrierten eingebauten Funktionen (kein künftiges Vergessen möglich).
- **Benutzerdefinierte Funktionen** — Engine-Ebene (`userFunctions.spec.ts`): - **Benutzerdefinierte Funktionen** — Engine-Ebene (`userFunctions.spec.ts`):
Parametersubstitution, verschachtelte Aufrufe (eingebaut + benutzerdefiniert, Parametersubstitution, verschachtelte Aufrufe (eingebaut + benutzerdefiniert,
auch bei kollidierenden Parameternamen zwischen den Scopes), Arity-Mismatch → auch bei kollidierenden Parameternamen zwischen den Scopes), Arity-Mismatch →
@@ -783,10 +801,17 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
Argument (`;;`) ist eine allgemeine Grammatik-Erweiterung, nicht Argument (`;;`) ist eine allgemeine Grammatik-Erweiterung, nicht
XLOOKUP-spezifisch — funktioniert in jeder Funktion. XLOOKUP-spezifisch — funktioniert in jeder Funktion.
- **Benutzerdefinierte Funktionen** (Phase 1, siehe oben) bewusst ohne: - **Benutzerdefinierte Funktionen** (Phase 1, siehe oben) bewusst ohne:
Autovervollständigen des Funktionsnamens im Editor, Live-Vorschau des Live-Vorschau des Ergebnisses im Bearbeiten-Dialog, variadische Parameter/
Ergebnisses im Bearbeiten-Dialog, variadische Parameter/Default-Werte, und Default-Werte, und automatisches Nachführen bestehender Aufrufe bei
automatisches Nachführen bestehender Aufrufe bei Umbenennung (Verhalten Umbenennung (Verhalten entspricht bewusst dem einer Tabellen-Umbenennung,
entspricht bewusst dem einer Tabellen-Umbenennung, s. o.). s. o.) — der Funktionsname selbst wird beim Formeltippen aber inzwischen
vervollständigt (s. o. „Funktions-Autovervollständigung"), auch für eigene
Funktionen.
- **Funktions-Autovervollständigung** bewusst ohne: Zell-/Tabellennamen-
Vorschläge (dafür existiert der Point-Mode, s. o.), Vervollständigung, die
auch Text *nach* dem Cursor berücksichtigt (nur der Text bis zum Cursor
zählt — wie in den meisten Editoren), Live-Update der Vorschlagsliste bei
reiner Cursor-Bewegung ohne Tippen.
- **„Mit Schleife"-Code** bewusst ohne: verkettete Vergleiche (`a < b < c`, - **„Mit Schleife"-Code** bewusst ohne: verkettete Vergleiche (`a < b < c`,
nur ein Vergleich pro Ausdruck), Text-Vergleich (Vergleichsoperatoren nur ein Vergleich pro Ausdruck), Text-Vergleich (Vergleichsoperatoren
bleiben numerisch), `AND`/`OR`/`NOT` für zusammengesetzte Bedingungen, bleiben numerisch), `AND`/`OR`/`NOT` für zusammengesetzte Bedingungen,
+72 -16
View File
@@ -2,11 +2,14 @@
import { nextTick, ref, watch } from 'vue'; import { 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 FormulaSuggest from './FormulaSuggest.vue';
const store = useDocument(); const store = useDocument();
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);
const autocomplete = useFormulaAutocomplete();
// keep the field in sync with the active cell unless the user is editing it // keep the field in sync with the active cell unless the user is editing it
watch( watch(
@@ -54,6 +57,7 @@ function onBlur() {
focused.value = false; focused.value = false;
commit(); commit();
store.registerFormulaEditor(null); store.registerFormulaEditor(null);
autocomplete.close();
} }
function onEscape() { function onEscape() {
draft.value = store.activeCellInput.value; draft.value = store.activeCellInput.value;
@@ -61,8 +65,28 @@ function onEscape() {
} }
/** Actual typing (not a point-mode click inserting a reference programmatically) /** Actual typing (not a point-mode click inserting a reference programmatically)
* ends the "next click replaces the last reference" window. */ * ends the "next click replaces the last reference" window. */
function onInput() { function onInput(e: Event) {
store.noteFormulaTyped(); store.noteFormulaTyped();
const el = e.target as HTMLTextAreaElement;
autocomplete.update(draft.value, el.selectionStart ?? draft.value.length);
}
/** Accept the highlighted autocomplete suggestion (name + "(") via the same
* replace() mechanism point-mode uses. */
function applySuggestion(el: HTMLTextAreaElement) {
const caret = el.selectionStart ?? draft.value.length;
const rep = autocomplete.accept(draft.value, caret);
if (!rep) return false;
draft.value = draft.value.slice(0, rep.start) + rep.value + draft.value.slice(rep.end);
autocomplete.close();
nextTick(() => el.setSelectionRange(rep.start + rep.value.length, rep.start + rep.value.length));
return true;
}
/** A mouse click on a dropdown entry (see FormulaSuggest's `@mousedown.prevent`
* keeping focus in the textarea, so `fieldEl` is still the live editor). */
function onPickSuggestion(i: number) {
autocomplete.activeIndex.value = i;
const el = fieldEl.value;
if (el) applySuggestion(el);
} }
/** Clicking inside the field to reposition the caret should also end that window. */ /** Clicking inside the field to reposition the caret should also end that window. */
function onMousedown() { function onMousedown() {
@@ -81,6 +105,25 @@ function onF4(el: HTMLTextAreaElement) {
} }
function onKeydown(e: KeyboardEvent) { function onKeydown(e: KeyboardEvent) {
const el = e.target as HTMLTextAreaElement; const el = e.target as HTMLTextAreaElement;
// Function-name autocomplete takes priority over every other key below
// while its dropdown is open (see useFormulaAutocomplete.ts).
if (autocomplete.suggestions.value.length) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
autocomplete.move(e.key === 'ArrowDown' ? 1 : -1);
return;
}
if (e.key === 'Tab' || e.key === 'Enter') {
e.preventDefault();
if (applySuggestion(el)) return;
}
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
autocomplete.close();
return;
}
}
// Alt+Enter (Windows/Excel-365-Mac) or Cmd+Enter (a macOS-safe fallback -- // Alt+Enter (Windows/Excel-365-Mac) or Cmd+Enter (a macOS-safe fallback --
// Option is a compose key there, so `altKey` alone isn't fully reliable; // Option is a compose key there, so `altKey` alone isn't fully reliable;
// `e.code` sidesteps `key` being altered by that composition). // `e.code` sidesteps `key` being altered by that composition).
@@ -112,20 +155,28 @@ function onKeydown(e: KeyboardEvent) {
{{ store.selectionRangeName.value }} {{ store.selectionRangeName.value }}
</span> </span>
<span class="fx">fx</span> <span class="fx">fx</span>
<textarea <div class="input-wrap">
ref="fieldEl" <textarea
v-model="draft" ref="fieldEl"
class="formula-input" v-model="draft"
spellcheck="false" class="formula-input"
rows="1" spellcheck="false"
placeholder="Wert oder =Formel" rows="1"
title="Ziehen an der unteren Kante, um mehr Zeilen zu zeigen" placeholder="Wert oder =Formel"
@focus="onFocus" title="Ziehen an der unteren Kante, um mehr Zeilen zu zeigen"
@blur="onBlur" @focus="onFocus"
@keydown="onKeydown" @blur="onBlur"
@input="onInput" @keydown="onKeydown"
@mousedown="onMousedown" @input="onInput"
/> @mousedown="onMousedown"
/>
<FormulaSuggest
v-if="autocomplete.suggestions.value.length"
:items="autocomplete.suggestions.value"
:active-index="autocomplete.activeIndex.value"
@pick="onPickSuggestion"
/>
</div>
</div> </div>
</template> </template>
@@ -165,12 +216,17 @@ function onKeydown(e: KeyboardEvent) {
color: var(--text-dim); color: var(--text-dim);
font-size: 13px; font-size: 13px;
} }
/* holds the FormulaSuggest dropdown, positioned relative to this */
.input-wrap {
position: relative;
flex: 1;
}
/* a plain <textarea> gives a free, native mouse-draggable resize handle /* a plain <textarea> gives a free, native mouse-draggable resize handle
(bottom-right corner) -- exactly the "erweiterbar/erhöhbar mit der Maus" (bottom-right corner) -- exactly the "erweiterbar/erhöhbar mit der Maus"
ask -- and, unlike the old <input>, actually wraps a long formula instead ask -- and, unlike the old <input>, actually wraps a long formula instead
of just scrolling it sideways, so growing the box has something to show. */ of just scrolling it sideways, so growing the box has something to show. */
.formula-input { .formula-input {
flex: 1; width: 100%;
min-height: 30px; min-height: 30px;
max-height: 50vh; max-height: 50vh;
border: 1px solid var(--border); border: 1px solid var(--border);
+70
View File
@@ -0,0 +1,70 @@
<script setup lang="ts">
/**
* Function-name autocomplete dropdown for a formula editor. Purely
* presentational -- positions itself just under whatever it's rendered
* inside of (that parent needs `position: relative`); no caret-pixel
* tracking. See useFormulaAutocomplete.ts for the state/logic this displays.
*/
import type { FnSuggestion } from '../store/types';
defineProps<{ items: FnSuggestion[]; activeIndex: number }>();
const emit = defineEmits<{ (e: 'pick', index: number): void }>();
</script>
<template>
<ul class="formula-suggest" role="listbox">
<li
v-for="(item, i) in items"
:key="item.name"
role="option"
:aria-selected="i === activeIndex"
:class="{ on: i === activeIndex }"
@mousedown.prevent="emit('pick', i)"
>
<span class="name">{{ item.name }}</span>
<span class="hint">{{ item.hint }}</span>
</li>
</ul>
</template>
<style scoped>
.formula-suggest {
position: absolute;
top: 100%;
left: 0;
z-index: 30;
margin: 2px 0 0;
padding: 4px;
min-width: 220px;
max-width: 420px;
max-height: 220px;
overflow-y: auto;
list-style: none;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
font-size: 12px;
}
.formula-suggest li {
display: flex;
align-items: baseline;
gap: 8px;
padding: 5px 8px;
border-radius: 5px;
cursor: pointer;
white-space: nowrap;
}
.formula-suggest li.on {
background: var(--accent-weak);
}
.name {
font-weight: 600;
color: var(--text);
}
.hint {
color: var(--text-dim);
overflow: hidden;
text-overflow: ellipsis;
}
</style>
+79 -16
View File
@@ -14,7 +14,9 @@ import { computed, nextTick, onBeforeUnmount, ref } from 'vue';
import { numberToCol, cycleReferenceLocks } from '../engine'; import { numberToCol, cycleReferenceLocks } from '../engine';
import { useDocument } from '../store/document'; import { useDocument } from '../store/document';
import { DEFAULT_ROW_H } from '../store/types'; import { DEFAULT_ROW_H } from '../store/types';
import { useFormulaAutocomplete } from '../composables/useFormulaAutocomplete';
import ContextMenu from './ContextMenu.vue'; import ContextMenu from './ContextMenu.vue';
import FormulaSuggest from './FormulaSuggest.vue';
const props = defineProps<{ tableId: string }>(); const props = defineProps<{ tableId: string }>();
@@ -630,6 +632,7 @@ const editMirrorEl = ref<HTMLDivElement | null>(null);
const setEditRef = (el: unknown) => { const setEditRef = (el: unknown) => {
editInput.value = (el as HTMLTextAreaElement) ?? null; editInput.value = (el as HTMLTextAreaElement) ?? null;
}; };
const autocomplete = useFormulaAutocomplete();
let committing = false; let committing = false;
const editStyle = computed(() => { const editStyle = computed(() => {
@@ -711,15 +714,55 @@ function commitEdit() {
store.setCellInput(props.tableId, col, row, editValue.value); store.setCellInput(props.tableId, col, row, editValue.value);
editing.value = null; editing.value = null;
store.registerFormulaEditor(null); store.registerFormulaEditor(null);
autocomplete.close();
focusRoot(); focusRoot();
setTimeout(() => (committing = false), 0); setTimeout(() => (committing = false), 0);
} }
function cancelEdit() { function cancelEdit() {
editing.value = null; editing.value = null;
store.registerFormulaEditor(null); store.registerFormulaEditor(null);
autocomplete.close();
focusRoot(); focusRoot();
} }
/** Accept the highlighted autocomplete suggestion (name + "(") via the same
* replace() mechanism point-mode uses. */
function applySuggestion(el: HTMLTextAreaElement) {
const caret = el.selectionStart ?? editValue.value.length;
const rep = autocomplete.accept(editValue.value, caret);
if (!rep) return false;
editValue.value = editValue.value.slice(0, rep.start) + rep.value + editValue.value.slice(rep.end);
autocomplete.close();
nextTick(() => {
el.setSelectionRange(rep.start + rep.value.length, rep.start + rep.value.length);
resizeEditor();
});
return true;
}
function onEditKeydown(e: KeyboardEvent) { function onEditKeydown(e: KeyboardEvent) {
// Function-name autocomplete takes priority over every other key below
// while its dropdown is open (see useFormulaAutocomplete.ts).
if (autocomplete.suggestions.value.length) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
e.stopPropagation();
autocomplete.move(e.key === 'ArrowDown' ? 1 : -1);
return;
}
if (e.key === 'Tab' || e.key === 'Enter') {
const el = editInput.value;
if (el && applySuggestion(el)) {
e.preventDefault();
e.stopPropagation();
return;
}
}
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
autocomplete.close();
return;
}
}
// Alt+Enter (Windows/Excel-365-Mac) or Cmd+Enter (a macOS-safe fallback -- // Alt+Enter (Windows/Excel-365-Mac) or Cmd+Enter (a macOS-safe fallback --
// Option is a compose key there, e.g. for Control+Option+Return, the // Option is a compose key there, e.g. for Control+Option+Return, the
// traditional Mac Excel shortcut, or some layouts/browsers, so `altKey` // traditional Mac Excel shortcut, or some layouts/browsers, so `altKey`
@@ -778,12 +821,22 @@ function toggleReferenceLock() {
} }
function onEditBlur() { function onEditBlur() {
if (!committing && editing.value) commitEdit(); if (!committing && editing.value) commitEdit();
autocomplete.close();
} }
/** Actual typing (not a point-mode click inserting a reference programmatically) /** Actual typing (not a point-mode click inserting a reference programmatically)
* ends the "next click replaces the last reference" window. */ * ends the "next click replaces the last reference" window. */
function onEditInput() { function onEditInput() {
resizeEditor(); resizeEditor();
store.noteFormulaTyped(); store.noteFormulaTyped();
const el = editInput.value;
autocomplete.update(editValue.value, el?.selectionStart ?? editValue.value.length);
}
/** A mouse click on a dropdown entry (see FormulaSuggest's `@mousedown.prevent`
* keeping focus in the textarea, so `editInput` is still the live editor). */
function onPickSuggestion(i: number) {
autocomplete.activeIndex.value = i;
const el = editInput.value;
if (el) applySuggestion(el);
} }
/** Clicking inside the field to reposition the caret should also end that /** Clicking inside the field to reposition the caret should also end that
* window (mousedown so it fires before any selection change is read). */ * window (mousedown so it fires before any selection change is read). */
@@ -1072,19 +1125,25 @@ defineExpose({
title="Ziehen zum Ausfüllen/Kopieren" title="Ziehen zum Ausfüllen/Kopieren"
@mousedown="onFillHandleDown" @mousedown="onFillHandleDown"
/> />
<textarea <div v-if="editing" class="cell-input-wrap" :style="editStyle">
v-if="editing" <textarea
:ref="setEditRef" :ref="setEditRef"
v-model="editValue" v-model="editValue"
class="cell-input" class="cell-input"
:style="editStyle" spellcheck="false"
spellcheck="false" rows="1"
rows="1" @keydown="onEditKeydown"
@keydown="onEditKeydown" @input="onEditInput"
@input="onEditInput" @blur="onEditBlur"
@blur="onEditBlur" @mousedown="onEditMousedown"
@mousedown="onEditMousedown" />
/> <FormulaSuggest
v-if="autocomplete.suggestions.value.length"
:items="autocomplete.suggestions.value"
:active-index="autocomplete.activeIndex.value"
@pick="onPickSuggestion"
/>
</div>
<div v-if="editing" ref="editMirrorEl" class="cell-input-mirror" :style="editMirrorStyle" aria-hidden="true" /> <div v-if="editing" ref="editMirrorEl" class="cell-input-mirror" :style="editMirrorStyle" aria-hidden="true" />
</div> </div>
@@ -1266,9 +1325,14 @@ defineExpose({
child of its <td>, so it's free to grow past the cell (request: a longer 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 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 squeezed into the cell). Sized imperatively by resizeEditor(); left/top and
the starting min-width/min-height come from editStyle. */ the starting min-width/min-height come from editStyle. Positioning lives
.cell-input { on this wrapper (not .cell-input itself) so FormulaSuggest's dropdown,
`position: absolute; top: 100%`, lands right under the textarea. */
.cell-input-wrap {
position: absolute; position: absolute;
z-index: 12;
}
.cell-input {
resize: none; resize: none;
border: 2px solid var(--accent); border: 2px solid var(--accent);
padding: 0 5px; padding: 0 5px;
@@ -1281,7 +1345,6 @@ defineExpose({
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
box-sizing: border-box; box-sizing: border-box;
z-index: 12;
} }
/* hidden clone of .cell-input's text box, measured to size the real one -- /* 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 same font/line-height/padding/border, capped at EDIT_MAX_W so it wraps at
+49
View File
@@ -0,0 +1,49 @@
/**
* Function-name autocomplete for a formula editor (see FormulaBar.vue and
* TableGrid.vue's in-cell editor, which both wire this in the same way).
*
* Unlike useTheme.ts/useToasts.ts (module-level singletons), this factory
* creates fresh, independent state per call -- each formula editor gets its
* own dropdown/selection, since two editors are never showing suggestions
* for the same formula at once.
*/
import { ref } from 'vue';
import { currentIdentifierAtCaret } from '../engine';
import { useDocument } from '../store/document';
import type { FnSuggestion } from '../store/types';
export function useFormulaAutocomplete() {
const store = useDocument();
const suggestions = ref<FnSuggestion[]>([]);
const activeIndex = ref(0);
/** Recompute the dropdown for the current buffer + caret -- call this
* after every edit to the text (an `input` handler). */
function update(text: string, caret: number) {
const hit = currentIdentifierAtCaret(text, caret);
suggestions.value = hit && hit.text ? store.functionSuggestions(hit.text) : [];
activeIndex.value = 0;
}
function close() {
suggestions.value = [];
}
/** Move the highlighted suggestion by `delta` (wraps). No-op while closed. */
function move(delta: number) {
const n = suggestions.value.length;
if (!n) return;
activeIndex.value = (activeIndex.value + delta + n) % n;
}
/** The replacement the caller should apply via its own `replace(start, end,
* value)` (the same one point-mode already uses) to accept the currently
* highlighted suggestion -- `null` when no dropdown is open, so the
* caller falls through to its normal Tab/Enter handling. Appends "(" ,
* ready for arguments, same as Excel/Sheets' own autocomplete. */
function accept(text: string, caret: number): { start: number; end: number; value: string } | null {
if (!suggestions.value.length) return null;
const hit = currentIdentifierAtCaret(text, caret);
if (!hit) return null;
return { start: hit.start, end: caret, value: suggestions.value[activeIndex.value].name + '(' };
}
return { suggestions, activeIndex, update, close, move, accept };
}
+13 -1
View File
@@ -18,7 +18,7 @@ genau die genannte Tabelle. Siehe "Blatt-Qualifier" weiter unten.
| `parser.ts` | Recursive-Descent → AST. Grammatik als EBNF-Kommentarblock im Kopf der Datei. | | `parser.ts` | Recursive-Descent → AST. Grammatik als EBNF-Kommentarblock im Kopf der Datei. |
| `evaluator.ts` | AST + `EngineHost` → `CellValue`. Numerisches Auswertungsmodell, Fehler-Propagierung. Enthält auch den kleinen Interpreter für `kind: 'block'`-Funktionsaufrufe (`BlockCallNode`) – siehe „Schleifen" unten. | | `evaluator.ts` | AST + `EngineHost` → `CellValue`. Numerisches Auswertungsmodell, Fehler-Propagierung. Enthält auch den kleinen Interpreter für `kind: 'block'`-Funktionsaufrufe (`BlockCallNode`) – siehe „Schleifen" unten. |
| `graph.ts` | Dokumentweiter Abhängigkeitsgraph: `affected()`, `topoOrder()`, Zirkel-Erkennung (iterative DFS, white/grey/black). | | `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 (`cycleReferenceLocks`). | | `refs.ts` | A1 ↔ `(col,row)` inkl. `$`-Sperrachsen, Spaltenbuchstaben, Bereichs-Expansion, `F4`-Zyklus (`cycleReferenceLocks`), Funktionsnamen-Erkennung fürs Autovervollständigen (`currentIdentifierAtCaret`). |
| `functions.ts` | `SUM/AVG/MIN/MAX/COUNT` (+ Alias `AVERAGE`), `DATE/TIME/TODAY/NOW` als Tabelle `{ NAME → fn(values) }`. `IF` und `XLOOKUP` sind hier nur als Platzhalter registriert (für `hasFunction()`/Namensreservierung); die echte Auswertung sitzt in `evaluator.ts`, s. u. Eingebaute Namen sind nie überschreibbar. | | `functions.ts` | `SUM/AVG/MIN/MAX/COUNT` (+ Alias `AVERAGE`), `DATE/TIME/TODAY/NOW` als Tabelle `{ NAME → fn(values) }`. `IF` und `XLOOKUP` sind hier nur als Platzhalter registriert (für `hasFunction()`/Namensreservierung); die echte Auswertung sitzt in `evaluator.ts`, s. u. Eingebaute Namen sind nie überschreibbar. |
| `dates.ts` | Datum/Zeit als Zahl ("Serial", Tage seit 1899-12-30): `dateToSerial`/`timeSerial` (Bausteine für `DATE`/`TIME`), `todaySerial`/`nowSerial`, `serialToUtcDate` (für die Anzeige), `parseDateTimeLiteral` (Eingabe-Erkennung, s. u. „Datum & Zeit"). | | `dates.ts` | Datum/Zeit als Zahl ("Serial", Tage seit 1899-12-30): `dateToSerial`/`timeSerial` (Bausteine für `DATE`/`TIME`), `todaySerial`/`nowSerial`, `serialToUtcDate` (für die Anzeige), `parseDateTimeLiteral` (Eingabe-Erkennung, s. u. „Datum & Zeit"). |
| `userFunctions.ts` | Benutzerdefinierte Funktionen (dokumentweite Bibliothek, angelegt über die UI — siehe Haupt-`README.md`). `expandUserFunctions()` setzt einen `kind: 'expression'`-Aufruf beim Parsen der aufrufenden Zelle als Kopie des rekursiv expandierten, parametersubstituierten Funktionskörpers in deren AST ein; einen `kind: 'block'`-Aufruf (FOR/IF/RETURN-Code) verpackt sie stattdessen in einen `BlockCallNode` mit bereits aufgelösten verschachtelten Aufrufen, siehe „Schleifen" unten. | | `userFunctions.ts` | Benutzerdefinierte Funktionen (dokumentweite Bibliothek, angelegt über die UI — siehe Haupt-`README.md`). `expandUserFunctions()` setzt einen `kind: 'expression'`-Aufruf beim Parsen der aufrufenden Zelle als Kopie des rekursiv expandierten, parametersubstituierten Funktionskörpers in deren AST ein; einen `kind: 'block'`-Aufruf (FOR/IF/RETURN-Code) verpackt sie stattdessen in einen `BlockCallNode` mit bereits aufgelösten verschachtelten Aufrufen, siehe „Schleifen" unten. |
@@ -95,6 +95,18 @@ darin gefundenen Bezug auf einmal, jeden ausgehend vom eigenen Zustand.
Tabellennamen werden **case-insensitiv** und whitespace-tolerant aufgelöst: Tabellennamen werden **case-insensitiv** und whitespace-tolerant aufgelöst:
`Kosten!A2`, `'Kosten'!A2`, `kosten!a2`, `Kosten::A2` sind identisch. `Kosten!A2`, `'Kosten'!A2`, `kosten!a2`, `Kosten::A2` sind identisch.
`refs.ts#currentIdentifierAtCaret(formula, caret)` treibt die Funktions-
Autovervollständigung (Formelleiste und Zell-Editor, `src/composables/
useFormulaAutocomplete.ts`): strukturell dasselbe Muster wie
`cycleReferenceLocks` (`=` abstreifen, tokenizen, am Cursor nachsehen), aber
nur der Text **bis** zum Cursor wird überhaupt tokenisiert (was danach
kommt, spielt nie eine Rolle — wie bei den meisten Editoren), und nur das
*letzte* Token zählt. Ein `IDENT`-Token, das bis genau an den Cursor reicht
und nicht wie ein Zellbezug aussieht (`looksLikeCell`), ist der gerade
getippte Funktionsname; alles andere (kein Token dort, ein Zellbezug, der
Cursor sitzt hinter Leerraum nach einem abgeschlossenen Wort, `tokenize()`
wirft bei einem kaputten Präfix) liefert `null` — kein Dropdown.
`parseFormula(src, paramNames?)` akzeptiert einen optionalen zweiten `parseFormula(src, paramNames?)` akzeptiert einen optionalen zweiten
Parameter: eine Liste von Namen, gegen die ein bare `IDENT` (das nicht wie Parameter: eine Liste von Namen, gegen die ein bare `IDENT` (das nicht wie
eine Zelle aussieht) zusätzlich geprüft wird — ein Treffer erzeugt statt eines eine Zelle aussieht) zusätzlich geprüft wird — ein Treffer erzeugt statt eines
+18
View File
@@ -0,0 +1,18 @@
import { describe, it, expect } from 'vitest';
import { FUNCTIONS, BUILTIN_SIGNATURES } from '../functions';
describe('BUILTIN_SIGNATURES', () => {
it('has exactly one entry per registered built-in function, names matching', () => {
const registered = Object.keys(FUNCTIONS).sort();
const signatures = BUILTIN_SIGNATURES.map((s) => s.name).sort();
// A future new built-in without a matching hint entry fails loudly here
// instead of just silently being missing from the autocomplete dropdown.
expect(signatures).toEqual(registered);
});
it('every hint actually starts with its own name', () => {
for (const s of BUILTIN_SIGNATURES) {
expect(s.hint.startsWith(s.name)).toBe(true);
}
});
});
+36 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { parseA1, toA1, cycleReferenceLocks } from '../refs'; import { parseA1, toA1, cycleReferenceLocks, currentIdentifierAtCaret } from '../refs';
describe('parseA1 / toA1: $-locked (absolute/mixed) references', () => { describe('parseA1 / toA1: $-locked (absolute/mixed) references', () => {
it('parseA1 reports which axes are $-locked', () => { it('parseA1 reports which axes are $-locked', () => {
@@ -86,3 +86,38 @@ describe('cycleReferenceLocks (F4) -- a real text selection spanning several ref
expect(cycleReferenceLocks('=SUM(1;2)', 4, 8)).toBeNull(); expect(cycleReferenceLocks('=SUM(1;2)', 4, 8)).toBeNull();
}); });
}); });
describe('currentIdentifierAtCaret -- function-name autocomplete detection', () => {
it('right after "=", right after an operator, right after "(" or ";"', () => {
expect(currentIdentifierAtCaret('=S', 2)).toEqual({ text: 'S', start: 1 });
expect(currentIdentifierAtCaret('=A1+S', 5)).toEqual({ text: 'S', start: 4 });
expect(currentIdentifierAtCaret('=SUM(A', 6)).toEqual({ text: 'A', start: 5 });
expect(currentIdentifierAtCaret('=SUM(1;A', 8)).toEqual({ text: 'A', start: 7 });
});
it('null outside a formula (no leading "=")', () => {
expect(currentIdentifierAtCaret('SUM', 3)).toBeNull();
});
it('null once the word looks like a cell reference (has a digit)', () => {
expect(currentIdentifierAtCaret('=SUM(A1', 7)).toBeNull();
expect(currentIdentifierAtCaret('=A1', 3)).toBeNull();
});
it('null right after an already-open, complete function call', () => {
expect(currentIdentifierAtCaret('=SUM(', 5)).toBeNull();
});
it('null once the caret has moved past the word (trailing whitespace)', () => {
expect(currentIdentifierAtCaret('=SUM ', 5)).toBeNull();
});
it('only looks at text up to the caret, ignoring what follows it', () => {
// caret between "SUM" and "X" in "SUMX" -- same result as if "X" weren't there
expect(currentIdentifierAtCaret('=SUMX', 4)).toEqual({ text: 'SUM', start: 1 });
});
it('null on a malformed prefix (unterminated string) rather than throwing', () => {
expect(currentIdentifierAtCaret('=SUM("x', 7)).toBeNull();
});
});
+21
View File
@@ -139,6 +139,27 @@ export const FUNCTIONS: Record<string, FunctionFn> = {
// AVERAGE is an alias of AVG. // AVERAGE is an alias of AVG.
FUNCTIONS.AVERAGE = FUNCTIONS.AVG; FUNCTIONS.AVERAGE = FUNCTIONS.AVG;
/** One-line signature hint per built-in, for the formula-autocomplete
* dropdown (see store/document.ts's `functionSuggestions()` and
* src/composables/useFormulaAutocomplete.ts). Hand-maintained -- kept
* honest by a test asserting its names exactly match `FUNCTIONS`'s keys,
* so a future new built-in without a matching entry here fails loudly
* instead of just silently missing from the dropdown. */
export const BUILTIN_SIGNATURES: ReadonlyArray<{ name: string; hint: string }> = [
{ name: 'SUM', hint: 'SUM(Zahl1; Zahl2; …)' },
{ name: 'AVG', hint: 'AVG(Zahl1; Zahl2; …)' },
{ name: 'AVERAGE', hint: 'AVERAGE(Zahl1; Zahl2; …)' },
{ name: 'MIN', hint: 'MIN(Zahl1; Zahl2; …)' },
{ name: 'MAX', hint: 'MAX(Zahl1; Zahl2; …)' },
{ name: 'COUNT', hint: 'COUNT(Zahl1; Zahl2; …)' },
{ name: 'IF', hint: 'IF(Bedingung; Dann; Sonst?)' },
{ name: 'DATE', hint: 'DATE(Jahr; Monat; Tag)' },
{ name: 'TIME', hint: 'TIME(Std; Min; Sek)' },
{ name: 'TODAY', hint: 'TODAY()' },
{ name: 'NOW', hint: 'NOW()' },
{ name: 'XLOOKUP', hint: 'XLOOKUP(Suchwert; Suchbereich; Rückgabebereich; …)' },
];
export function hasFunction(name: string): boolean { export function hasFunction(name: string): boolean {
return Object.prototype.hasOwnProperty.call(FUNCTIONS, name.toUpperCase()); return Object.prototype.hasOwnProperty.call(FUNCTIONS, name.toUpperCase());
} }
+2 -1
View File
@@ -18,7 +18,7 @@ export {
} from './parser'; } from './parser';
export { evaluate } from './evaluator'; export { evaluate } from './evaluator';
export { DependencyGraph } from './graph'; export { DependencyGraph } from './graph';
export { FUNCTIONS, getFunction, hasFunction } from './functions'; export { FUNCTIONS, getFunction, hasFunction, BUILTIN_SIGNATURES } from './functions';
export { export {
colToNumber, colToNumber,
numberToCol, numberToCol,
@@ -27,6 +27,7 @@ export {
looksLikeCell, looksLikeCell,
expandRange, expandRange,
cycleReferenceLocks, cycleReferenceLocks,
currentIdentifierAtCaret,
} from './refs'; } from './refs';
export { export {
Workbook, Workbook,
+43
View File
@@ -131,6 +131,49 @@ function nextLock(colAbs: boolean, rowAbs: boolean): [boolean, boolean] {
return [false, false]; // colAbs && !rowAbs return [false, false]; // colAbs && !rowAbs
} }
/**
* If the caret sits inside (anywhere within, or right at the end of) a bare
* identifier being typed in a formula -- not yet a resolved cell reference,
* not the start of an already-open function call -- return the identifier's
* text *up to the caret* and its start offset in `formula` (which still
* includes the leading "="). Drives function-name autocomplete; `null`
* means "nothing to suggest here" (not a formula at all, a cell reference
* like "A1", the caret sitting in whitespace or right after a completed
* token rather than still touching it, ...).
*
* Same shape as {@link cycleReferenceLocks} above: strip the leading "=",
* tokenize, and inspect the token(s) around the caret -- except here only
* the prefix up to the caret is tokenized (nothing after it is ever looked
* at, same as how autocomplete works in most editors), so the "identifier"
* found is always exactly what's to the *left* of the caret, regardless of
* whether more of the same word continues to its right.
*/
export function currentIdentifierAtCaret(
formula: string,
caret: number,
): { text: string; start: number } | null {
if (!formula.startsWith('=')) return null;
const pos = caret - 1; // caret position relative to body = formula.slice(1)
if (pos < 0) return null;
let toks: Token[];
try {
toks = tokenize(formula.slice(1, caret));
} catch {
return null;
}
// tokenize() appends a trailing EOF marker -- skip it to get the last
// *real* token.
const real = toks.filter((t) => t.type !== 'EOF');
const last = real[real.length - 1];
if (!last || last.type !== 'IDENT') return null;
// the token must reach all the way to the caret -- if it doesn't, the
// caret has moved past it (trailing whitespace, e.g. "=SUM |") and
// there's no word actively being typed right at the caret any more.
if (last.pos + last.value.length !== pos) return null;
if (looksLikeCell(last.value)) return null; // looks like a cell reference, not a function name
return { text: last.value, start: last.pos + 1 };
}
/** /**
* Expand a rectangular range (given by two corners, any order) into the list of * Expand a rectangular range (given by two corners, any order) into the list of
* cell coordinates it covers, row-major. * cell coordinates it covers, row-major.
+30
View File
@@ -871,6 +871,36 @@ describe('custom functions (document-level library)', () => {
}); });
}); });
describe('functionSuggestions (formula autocomplete)', () => {
it('filters built-ins by prefix, case-insensitively', () => {
const s = fresh();
const names = s.functionSuggestions('su').map((x) => x.name);
expect(names).toContain('SUM');
expect(names).not.toContain('AVG');
});
it('an empty prefix suggests nothing', () => {
const s = fresh();
expect(s.functionSuggestions('')).toEqual([]);
});
it('mixes in custom functions, sorted alphabetically with built-ins', () => {
const s = fresh();
s.addCustomFunction('SUperMarge', ['x'], 'x');
const names = s.functionSuggestions('SU').map((x) => x.name);
expect(names).toEqual(['SUM', 'SUperMarge']); // SUM < SUperMarge
const custom = s.functionSuggestions('SU').find((x) => x.name === 'SUperMarge');
expect(custom).toEqual({ name: 'SUperMarge', hint: 'SUperMarge(x)', kind: 'custom' });
});
it('a built-in hint always starts with its own name', () => {
const s = fresh();
for (const x of s.functionSuggestions('X')) {
expect(x.hint.startsWith(x.name)).toBe(true);
}
});
});
describe('custom functions: "block" kind (FOR/IF/RETURN "code")', () => { describe('custom functions: "block" kind (FOR/IF/RETURN "code")', () => {
const productBody = 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x END RETURN ergebnis'; const productBody = 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x END RETURN ergebnis';
+20
View File
@@ -34,6 +34,7 @@ import {
findInvalidForSource, findInvalidForSource,
blockAlwaysReturns, blockAlwaysReturns,
parseDateTimeLiteral, parseDateTimeLiteral,
BUILTIN_SIGNATURES,
type CellValue, type CellValue,
type TableModel, type TableModel,
type Token, type Token,
@@ -52,6 +53,7 @@ import type {
ChartObject, ChartObject,
CustomFunction, CustomFunction,
DocumentState, DocumentState,
FnSuggestion,
Sheet, Sheet,
SelectionState, SelectionState,
SheetObject, SheetObject,
@@ -1471,6 +1473,23 @@ function createStore() {
function formulaPointModeActive(): boolean { function formulaPointModeActive(): boolean {
return !!formulaEditor.value && formulaEditor.value.getState().text.startsWith('='); return !!formulaEditor.value && formulaEditor.value.getState().text.startsWith('=');
} }
/** Function-name autocomplete candidates for a prefix already typed (built-in
* + this document's custom functions), case-insensitive, name-sorted. Used
* by src/composables/useFormulaAutocomplete.ts, driven by
* currentIdentifierAtCaret() finding the word being typed. Empty prefix ->
* no suggestions (matches Excel/Sheets: autocomplete only kicks in once
* something's actually been typed, not on a bare "="). */
function functionSuggestions(prefix: string): FnSuggestion[] {
const p = prefix.toUpperCase();
if (!p) return [];
const builtins: FnSuggestion[] = BUILTIN_SIGNATURES.filter((s) => s.name.startsWith(p)).map(
(s) => ({ name: s.name, hint: s.hint, kind: 'builtin' }),
);
const custom: FnSuggestion[] = doc.customFunctions
.filter((f) => f.name.toUpperCase().startsWith(p))
.map((f) => ({ name: f.name, hint: `${f.name}(${f.params.join('; ')})`, kind: 'custom' }));
return [...builtins, ...custom].sort((a, b) => a.name.localeCompare(b.name));
}
/** `'` needs quoting whenever a name isn't a bare identifier (spaces, /** `'` needs quoting whenever a name isn't a bare identifier (spaces,
* punctuation, ...) -- shared by the table- and sheet-qualifier cases. */ * punctuation, ...) -- shared by the table- and sheet-qualifier cases. */
function quoteQualifier(name: string): string { function quoteQualifier(name: string): string {
@@ -1768,6 +1787,7 @@ function createStore() {
registerFormulaEditor, registerFormulaEditor,
noteFormulaTyped, noteFormulaTyped,
formulaPointModeActive, formulaPointModeActive,
functionSuggestions,
pointClick, pointClick,
pointDrag, pointDrag,
// document // document
+9
View File
@@ -173,6 +173,15 @@ export interface CustomFunction {
kind: 'expression' | 'block'; kind: 'expression' | 'block';
} }
/** One entry in the formula-autocomplete dropdown (see document.ts's
* `functionSuggestions()`). */
export interface FnSuggestion {
name: string;
/** one-line signature hint, e.g. "SUM(Zahl1; Zahl2; …)" */
hint: string;
kind: 'builtin' | 'custom';
}
export interface DocumentState { export interface DocumentState {
version: 2; version: 2;
sheets: Sheet[]; sheets: Sheet[];