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:
@@ -13,7 +13,7 @@ Vorbild von **Apple Numbers**. Zwei Dinge stehen im Mittelpunkt:
|
||||
```bash
|
||||
npm install
|
||||
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 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 ↶ ↷) |
|
||||
| **`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 |
|
||||
| `↑`/`↓`/`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
|
||||
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
|
||||
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`),
|
||||
`MIN`, `MAX`, `COUNT`, `IF(Bedingung; Dann; Sonst?)`, `DATE(Jahr; Monat;
|
||||
Tag)`, `TIME(Std; Min; Sek)`, `TODAY()`, `NOW()`, `XLOOKUP(Suchwert;
|
||||
@@ -593,7 +603,7 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
|
||||
|
||||
## Tests
|
||||
|
||||
`npm run test` — Vitest, 318 Tests:
|
||||
`npm run test` — Vitest, 331 Tests:
|
||||
|
||||
- **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^`
|
||||
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
|
||||
in eine andere Tabelle qualifiziert mit deren Namen (Quoting bei Leerzeichen);
|
||||
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`):
|
||||
Parametersubstitution, verschachtelte Aufrufe (eingebaut + benutzerdefiniert,
|
||||
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
|
||||
XLOOKUP-spezifisch — funktioniert in jeder Funktion.
|
||||
- **Benutzerdefinierte Funktionen** (Phase 1, siehe oben) bewusst ohne:
|
||||
Autovervollständigen des Funktionsnamens im Editor, Live-Vorschau des
|
||||
Ergebnisses im Bearbeiten-Dialog, variadische Parameter/Default-Werte, und
|
||||
automatisches Nachführen bestehender Aufrufe bei Umbenennung (Verhalten
|
||||
entspricht bewusst dem einer Tabellen-Umbenennung, s. o.).
|
||||
Live-Vorschau des Ergebnisses im Bearbeiten-Dialog, variadische Parameter/
|
||||
Default-Werte, und automatisches Nachführen bestehender Aufrufe bei
|
||||
Umbenennung (Verhalten entspricht bewusst dem einer Tabellen-Umbenennung,
|
||||
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`,
|
||||
nur ein Vergleich pro Ausdruck), Text-Vergleich (Vergleichsoperatoren
|
||||
bleiben numerisch), `AND`/`OR`/`NOT` für zusammengesetzte Bedingungen,
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
import { useDocument } from '../store/document';
|
||||
import { cycleReferenceLocks } from '../engine';
|
||||
import { useFormulaAutocomplete } from '../composables/useFormulaAutocomplete';
|
||||
import FormulaSuggest from './FormulaSuggest.vue';
|
||||
|
||||
const store = useDocument();
|
||||
const draft = ref('');
|
||||
const focused = ref(false);
|
||||
const fieldEl = ref<HTMLTextAreaElement | null>(null);
|
||||
const autocomplete = useFormulaAutocomplete();
|
||||
|
||||
// keep the field in sync with the active cell unless the user is editing it
|
||||
watch(
|
||||
@@ -54,6 +57,7 @@ function onBlur() {
|
||||
focused.value = false;
|
||||
commit();
|
||||
store.registerFormulaEditor(null);
|
||||
autocomplete.close();
|
||||
}
|
||||
function onEscape() {
|
||||
draft.value = store.activeCellInput.value;
|
||||
@@ -61,8 +65,28 @@ function onEscape() {
|
||||
}
|
||||
/** Actual typing (not a point-mode click inserting a reference programmatically)
|
||||
* ends the "next click replaces the last reference" window. */
|
||||
function onInput() {
|
||||
function onInput(e: Event) {
|
||||
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. */
|
||||
function onMousedown() {
|
||||
@@ -81,6 +105,25 @@ function onF4(el: HTMLTextAreaElement) {
|
||||
}
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
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 --
|
||||
// Option is a compose key there, so `altKey` alone isn't fully reliable;
|
||||
// `e.code` sidesteps `key` being altered by that composition).
|
||||
@@ -112,20 +155,28 @@ function onKeydown(e: KeyboardEvent) {
|
||||
{{ store.selectionRangeName.value }}
|
||||
</span>
|
||||
<span class="fx">fx</span>
|
||||
<textarea
|
||||
ref="fieldEl"
|
||||
v-model="draft"
|
||||
class="formula-input"
|
||||
spellcheck="false"
|
||||
rows="1"
|
||||
placeholder="Wert oder =Formel"
|
||||
title="Ziehen an der unteren Kante, um mehr Zeilen zu zeigen"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@keydown="onKeydown"
|
||||
@input="onInput"
|
||||
@mousedown="onMousedown"
|
||||
/>
|
||||
<div class="input-wrap">
|
||||
<textarea
|
||||
ref="fieldEl"
|
||||
v-model="draft"
|
||||
class="formula-input"
|
||||
spellcheck="false"
|
||||
rows="1"
|
||||
placeholder="Wert oder =Formel"
|
||||
title="Ziehen an der unteren Kante, um mehr Zeilen zu zeigen"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@keydown="onKeydown"
|
||||
@input="onInput"
|
||||
@mousedown="onMousedown"
|
||||
/>
|
||||
<FormulaSuggest
|
||||
v-if="autocomplete.suggestions.value.length"
|
||||
:items="autocomplete.suggestions.value"
|
||||
:active-index="autocomplete.activeIndex.value"
|
||||
@pick="onPickSuggestion"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -165,12 +216,17 @@ function onKeydown(e: KeyboardEvent) {
|
||||
color: var(--text-dim);
|
||||
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
|
||||
(bottom-right corner) -- exactly the "erweiterbar/erhöhbar mit der Maus"
|
||||
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. */
|
||||
.formula-input {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-height: 30px;
|
||||
max-height: 50vh;
|
||||
border: 1px solid var(--border);
|
||||
|
||||
@@ -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>
|
||||
@@ -14,7 +14,9 @@ import { computed, nextTick, onBeforeUnmount, ref } from 'vue';
|
||||
import { numberToCol, cycleReferenceLocks } from '../engine';
|
||||
import { useDocument } from '../store/document';
|
||||
import { DEFAULT_ROW_H } from '../store/types';
|
||||
import { useFormulaAutocomplete } from '../composables/useFormulaAutocomplete';
|
||||
import ContextMenu from './ContextMenu.vue';
|
||||
import FormulaSuggest from './FormulaSuggest.vue';
|
||||
|
||||
const props = defineProps<{ tableId: string }>();
|
||||
|
||||
@@ -630,6 +632,7 @@ const editMirrorEl = ref<HTMLDivElement | null>(null);
|
||||
const setEditRef = (el: unknown) => {
|
||||
editInput.value = (el as HTMLTextAreaElement) ?? null;
|
||||
};
|
||||
const autocomplete = useFormulaAutocomplete();
|
||||
let committing = false;
|
||||
|
||||
const editStyle = computed(() => {
|
||||
@@ -711,15 +714,55 @@ function commitEdit() {
|
||||
store.setCellInput(props.tableId, col, row, editValue.value);
|
||||
editing.value = null;
|
||||
store.registerFormulaEditor(null);
|
||||
autocomplete.close();
|
||||
focusRoot();
|
||||
setTimeout(() => (committing = false), 0);
|
||||
}
|
||||
function cancelEdit() {
|
||||
editing.value = null;
|
||||
store.registerFormulaEditor(null);
|
||||
autocomplete.close();
|
||||
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-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 --
|
||||
// Option is a compose key there, e.g. for Control+Option+Return, the
|
||||
// traditional Mac Excel shortcut, or some layouts/browsers, so `altKey`
|
||||
@@ -778,12 +821,22 @@ function toggleReferenceLock() {
|
||||
}
|
||||
function onEditBlur() {
|
||||
if (!committing && editing.value) commitEdit();
|
||||
autocomplete.close();
|
||||
}
|
||||
/** Actual typing (not a point-mode click inserting a reference programmatically)
|
||||
* ends the "next click replaces the last reference" window. */
|
||||
function onEditInput() {
|
||||
resizeEditor();
|
||||
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
|
||||
* window (mousedown so it fires before any selection change is read). */
|
||||
@@ -1072,19 +1125,25 @@ defineExpose({
|
||||
title="Ziehen zum Ausfüllen/Kopieren"
|
||||
@mousedown="onFillHandleDown"
|
||||
/>
|
||||
<textarea
|
||||
v-if="editing"
|
||||
:ref="setEditRef"
|
||||
v-model="editValue"
|
||||
class="cell-input"
|
||||
:style="editStyle"
|
||||
spellcheck="false"
|
||||
rows="1"
|
||||
@keydown="onEditKeydown"
|
||||
@input="onEditInput"
|
||||
@blur="onEditBlur"
|
||||
@mousedown="onEditMousedown"
|
||||
/>
|
||||
<div v-if="editing" class="cell-input-wrap" :style="editStyle">
|
||||
<textarea
|
||||
:ref="setEditRef"
|
||||
v-model="editValue"
|
||||
class="cell-input"
|
||||
spellcheck="false"
|
||||
rows="1"
|
||||
@keydown="onEditKeydown"
|
||||
@input="onEditInput"
|
||||
@blur="onEditBlur"
|
||||
@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>
|
||||
|
||||
@@ -1266,9 +1325,14 @@ defineExpose({
|
||||
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
|
||||
squeezed into the cell). Sized imperatively by resizeEditor(); left/top and
|
||||
the starting min-width/min-height come from editStyle. */
|
||||
.cell-input {
|
||||
the starting min-width/min-height come from editStyle. Positioning lives
|
||||
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;
|
||||
z-index: 12;
|
||||
}
|
||||
.cell-input {
|
||||
resize: none;
|
||||
border: 2px solid var(--accent);
|
||||
padding: 0 5px;
|
||||
@@ -1281,7 +1345,6 @@ defineExpose({
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
box-sizing: border-box;
|
||||
z-index: 12;
|
||||
}
|
||||
/* 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
|
||||
|
||||
@@ -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
@@ -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. |
|
||||
| `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). |
|
||||
| `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. |
|
||||
| `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. |
|
||||
@@ -95,6 +95,18 @@ darin gefundenen Bezug auf einmal, jeden ausgehend vom eigenen Zustand.
|
||||
Tabellennamen werden **case-insensitiv** und whitespace-tolerant aufgelöst:
|
||||
`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
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
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', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -139,6 +139,27 @@ export const FUNCTIONS: Record<string, FunctionFn> = {
|
||||
// AVERAGE is an alias of 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 {
|
||||
return Object.prototype.hasOwnProperty.call(FUNCTIONS, name.toUpperCase());
|
||||
}
|
||||
|
||||
+2
-1
@@ -18,7 +18,7 @@ export {
|
||||
} from './parser';
|
||||
export { evaluate } from './evaluator';
|
||||
export { DependencyGraph } from './graph';
|
||||
export { FUNCTIONS, getFunction, hasFunction } from './functions';
|
||||
export { FUNCTIONS, getFunction, hasFunction, BUILTIN_SIGNATURES } from './functions';
|
||||
export {
|
||||
colToNumber,
|
||||
numberToCol,
|
||||
@@ -27,6 +27,7 @@ export {
|
||||
looksLikeCell,
|
||||
expandRange,
|
||||
cycleReferenceLocks,
|
||||
currentIdentifierAtCaret,
|
||||
} from './refs';
|
||||
export {
|
||||
Workbook,
|
||||
|
||||
@@ -131,6 +131,49 @@ function nextLock(colAbs: boolean, rowAbs: boolean): [boolean, boolean] {
|
||||
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
|
||||
* cell coordinates it covers, row-major.
|
||||
|
||||
@@ -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")', () => {
|
||||
const productBody = 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x END RETURN ergebnis';
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
findInvalidForSource,
|
||||
blockAlwaysReturns,
|
||||
parseDateTimeLiteral,
|
||||
BUILTIN_SIGNATURES,
|
||||
type CellValue,
|
||||
type TableModel,
|
||||
type Token,
|
||||
@@ -52,6 +53,7 @@ import type {
|
||||
ChartObject,
|
||||
CustomFunction,
|
||||
DocumentState,
|
||||
FnSuggestion,
|
||||
Sheet,
|
||||
SelectionState,
|
||||
SheetObject,
|
||||
@@ -1471,6 +1473,23 @@ function createStore() {
|
||||
function formulaPointModeActive(): boolean {
|
||||
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,
|
||||
* punctuation, ...) -- shared by the table- and sheet-qualifier cases. */
|
||||
function quoteQualifier(name: string): string {
|
||||
@@ -1768,6 +1787,7 @@ function createStore() {
|
||||
registerFormulaEditor,
|
||||
noteFormulaTyped,
|
||||
formulaPointModeActive,
|
||||
functionSuggestions,
|
||||
pointClick,
|
||||
pointDrag,
|
||||
// document
|
||||
|
||||
@@ -173,6 +173,15 @@ export interface CustomFunction {
|
||||
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 {
|
||||
version: 2;
|
||||
sheets: Sheet[];
|
||||
|
||||
Reference in New Issue
Block a user