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>
197 lines
7.4 KiB
TypeScript
197 lines
7.4 KiB
TypeScript
/**
|
|
* Reference helpers: A1 <-> (col,row) conversion and range expansion.
|
|
*
|
|
* All coordinates the engine uses internally are 0-based. The A1 notation
|
|
* exposed to the user is 1-based for rows and letter-based for columns
|
|
* (A=0, B=1, ..., Z=25, AA=26, ...).
|
|
*/
|
|
|
|
import { tokenize, type Token } from './lexer';
|
|
|
|
const CELL_RE = /^(\$?)([A-Za-z]{1,3})(\$?)([0-9]{1,7})$/;
|
|
|
|
/** "A" -> 0, "B" -> 1, "Z" -> 25, "AA" -> 26 ... */
|
|
export function colToNumber(letters: string): number {
|
|
let n = 0;
|
|
const up = letters.toUpperCase();
|
|
for (let i = 0; i < up.length; i++) {
|
|
n = n * 26 + (up.charCodeAt(i) - 64); // 'A' -> 1
|
|
}
|
|
return n - 1;
|
|
}
|
|
|
|
/** 0 -> "A", 25 -> "Z", 26 -> "AA" ... */
|
|
export function numberToCol(n: number): string {
|
|
let s = '';
|
|
let x = n + 1;
|
|
while (x > 0) {
|
|
const rem = (x - 1) % 26;
|
|
s = String.fromCharCode(65 + rem) + s;
|
|
x = Math.floor((x - 1) / 26);
|
|
}
|
|
return s;
|
|
}
|
|
|
|
/** True if `text` looks like a bare cell reference (e.g. "A1", "$B$2", "aa10"). */
|
|
export function looksLikeCell(text: string): boolean {
|
|
return CELL_RE.test(text);
|
|
}
|
|
|
|
/**
|
|
* Parse "A1" / "$A1" / "A$1" / "$A$1" -> 0-based coords plus which axes are
|
|
* `$`-locked (absolute). A locked axis is exempt from the reference shift a
|
|
* copy/fill applies (see `shiftFormula` in the store); an unlocked one is
|
|
* "relative" and always shifts with it. Throws for malformed input.
|
|
*/
|
|
export function parseA1(
|
|
text: string,
|
|
): { col: number; row: number; colAbs: boolean; rowAbs: boolean } {
|
|
const m = CELL_RE.exec(text);
|
|
if (!m) throw new Error(`Bad cell reference: ${text}`);
|
|
return { col: colToNumber(m[2]), row: Number(m[4]) - 1, colAbs: m[1] === '$', rowAbs: m[3] === '$' };
|
|
}
|
|
|
|
/** Build "A1" from 0-based coordinates; `colAbs`/`rowAbs` add the `$` lock markers. */
|
|
export function toA1(col: number, row: number, colAbs = false, rowAbs = false): string {
|
|
return `${colAbs ? '$' : ''}${numberToCol(col)}${rowAbs ? '$' : ''}${row + 1}`;
|
|
}
|
|
|
|
/**
|
|
* Excel/Numbers-style F4: cycle every cell reference through
|
|
* relative -> absolute -> row-locked -> column-locked -> relative, each
|
|
* from its own current state.
|
|
*
|
|
* With a collapsed selection (`selStart === selEnd`, the common case -- just
|
|
* a caret), this toggles the single reference at/adjacent to it. With a real
|
|
* text selection, it toggles *every* reference the selection overlaps at
|
|
* once -- select a stretch of formula text spanning several references and
|
|
* F4 adjusts them all in one press, same as Excel.
|
|
*
|
|
* `selStart`/`selEnd` are 0-based offsets into `formula` (which must start
|
|
* with "="), i.e. straight from an `<input>`/`<textarea>`'s
|
|
* `selectionStart`/`selectionEnd`. Returns the rewritten formula and a
|
|
* selection re-spanning the (possibly resized) references, so a repeated F4
|
|
* keeps cycling the same ones; `null` when none were found.
|
|
*/
|
|
export function cycleReferenceLocks(
|
|
formula: string,
|
|
selStart: number,
|
|
selEnd: number,
|
|
): { text: string; start: number; end: number } | null {
|
|
if (!formula.startsWith('=')) return null;
|
|
const body = formula.slice(1);
|
|
const lo = Math.min(selStart, selEnd) - 1;
|
|
const hi = Math.max(selStart, selEnd) - 1;
|
|
const collapsed = lo === hi;
|
|
let toks: Token[];
|
|
try {
|
|
toks = tokenize(body);
|
|
} catch {
|
|
return null;
|
|
}
|
|
const targets: Token[] = [];
|
|
for (let i = 0; i < toks.length; i++) {
|
|
const t = toks[i];
|
|
if (t.type !== 'IDENT' || !looksLikeCell(t.value)) continue;
|
|
const next = toks[i + 1];
|
|
if (next && (next.type === 'BANG' || next.type === 'DBLCOLON')) continue; // a table-name qualifier, not a cell
|
|
const tEnd = t.pos + t.value.length;
|
|
const hit = collapsed ? lo >= t.pos && lo <= tEnd : t.pos < hi && tEnd > lo;
|
|
if (hit) targets.push(t);
|
|
}
|
|
if (!targets.length) return null;
|
|
|
|
let out = '';
|
|
let last = 0;
|
|
let start = -1;
|
|
let end = -1;
|
|
for (const tok of targets) {
|
|
const { col, row, colAbs, rowAbs } = parseA1(tok.value);
|
|
const [nColAbs, nRowAbs] = nextLock(colAbs, rowAbs);
|
|
const rewritten = toA1(col, row, nColAbs, nRowAbs);
|
|
out += body.slice(last, tok.pos) + rewritten;
|
|
if (start === -1) start = out.length - rewritten.length;
|
|
end = out.length;
|
|
last = tok.pos + tok.value.length;
|
|
}
|
|
out += body.slice(last);
|
|
// a collapsed caret in -> a collapsed caret out (after the one toggled
|
|
// reference), same as before; a real selection in keeps spanning every
|
|
// reference it touched, so a repeated F4 keeps cycling all of them.
|
|
return collapsed
|
|
? { text: '=' + out, start: 1 + end, end: 1 + end }
|
|
: { text: '=' + out, start: 1 + start, end: 1 + end };
|
|
}
|
|
|
|
/** A1 -> $A$1 -> A$1 -> $A1 -> A1 -> ... (the classic F4 cycle). */
|
|
function nextLock(colAbs: boolean, rowAbs: boolean): [boolean, boolean] {
|
|
if (!colAbs && !rowAbs) return [true, true];
|
|
if (colAbs && rowAbs) return [false, true];
|
|
if (!colAbs && rowAbs) return [true, false];
|
|
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.
|
|
*/
|
|
export function expandRange(
|
|
a: { col: number; row: number },
|
|
b: { col: number; row: number },
|
|
): Array<{ col: number; row: number }> {
|
|
const c0 = Math.min(a.col, b.col);
|
|
const c1 = Math.max(a.col, b.col);
|
|
const r0 = Math.min(a.row, b.row);
|
|
const r1 = Math.max(a.row, b.row);
|
|
const out: Array<{ col: number; row: number }> = [];
|
|
for (let r = r0; r <= r1; r++) {
|
|
for (let c = c0; c <= c1; c++) {
|
|
out.push({ col: c, row: r });
|
|
}
|
|
}
|
|
return out;
|
|
}
|