Text-Vergleich, &-Verkettung und benannte Bereiche

- Vergleichsoperatoren (= <> > < >= <=) unterscheiden jetzt Text- von
  Zahlenvergleich statt jede Seite zwangsweise durch toNumber() zu
  schicken (=A1="Nord" gab vorher #VALUE! statt 1); ermöglicht
  Text-Kriterien in selbstgebauten COUNTIF/SUMIF/AVERAGEIF-Funktionen
  (neue README-Sektion "SUMIF/COUNTIF/AVERAGEIF nachbauen").
- Neuer &-Operator (Excel-Präzedenz zwischen Vergleich und +/-) plus
  CONCAT/CONCATENATE als Funktions-Äquivalent.
- Benannte Bereiche: neuer Toolbar-Button "🏷" verwaltet dokumentweite
  Namen für eine feste Zelle/einen festen Bereich (z. B. Umsatz_Q1statt
  Umsatz!B2:B6), unqualifiziert in jeder Top-Level-Formel nutzbar.
  Live gegen die Tabellen-ID aufgelöst (übersteht Tabellen-Umbenennung),
  bewusst nicht innerhalb einer benutzerdefinierten Funktion selbst
  nutzbar (Aufrufer können sie aber als Argument übergeben).
- Nebenbei: COUNT2s Autovervollständigungs-Hint korrigiert (zeigte
  "COUNT(...)" statt "COUNT2(...)").

374 Tests, vue-tsc/eslint/build sauber, Playwright-live-verifiziert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-08 21:59:47 +02:00
co-authored by Claude Sonnet 5
parent 9015a991aa
commit 2c54537427
22 changed files with 1460 additions and 86 deletions
+101 -2
View File
@@ -39,6 +39,7 @@ import {
type TableModel,
type Token,
type FunctionDef,
type NamedRangeDef,
} from '../engine';
import {
formatValue,
@@ -104,6 +105,16 @@ function toFunctionDefs(doc: DocumentState): FunctionDef[] {
}));
}
function toNamedRangeDefs(doc: DocumentState): NamedRangeDef[] {
return doc.namedRanges.map((r) => ({
id: r.id,
name: r.name,
tableId: r.tableId,
start: r.start,
end: r.end,
}));
}
/** A normalised rectangle (corners in either order) used by selection + clipboard. */
type Rect = { c0: number; c1: number; r0: number; r1: number };
@@ -210,7 +221,7 @@ function loadInitial(): DocumentState {
function createStore() {
const doc = reactive<DocumentState>(loadInitial());
let wb = new Workbook(toTableModels(doc), toFunctionDefs(doc));
let wb = new Workbook(toTableModels(doc), toFunctionDefs(doc), toNamedRangeDefs(doc));
const recalcTick = ref(0);
const bump = () => (recalcTick.value += 1);
@@ -230,7 +241,7 @@ function createStore() {
const clampZoom = (z: number) => Math.min(2, Math.max(0.4, Math.round(z * 20) / 20));
function syncWorkbook() {
wb = new Workbook(toTableModels(doc), toFunctionDefs(doc));
wb = new Workbook(toTableModels(doc), toFunctionDefs(doc), toNamedRangeDefs(doc));
bump();
}
@@ -280,6 +291,7 @@ function createStore() {
locale: d.locale,
csvDelimiter: d.csvDelimiter,
customFunctions: d.customFunctions,
namedRanges: d.namedRanges,
});
let historySnapshot = snapshot();
let historyFingerprint = contentFingerprint(historySnapshot);
@@ -865,6 +877,89 @@ function createStore() {
syncWorkbook(); // affected calls become #NAME? on rebuild, same as a deleted table
}
// ---- named ranges (document-level names for a fixed cell/range) -------
// Created/edited/removed exclusively through the "🏷 Namen" UI. Unlike a
// custom function there is no expression of its own -- just fixed
// coordinates on a specific table -- so there's nothing that could
// cyclically reference itself, and no "does every path RETURN" check
// either. `tableId` (not the table's name) is what's stored, so a named
// range survives its target table being *renamed* (see
// engine/workbook.ts's `resolveNamedRange`); only an outright *deletion*
// of that table leaves it dangling -> #NAME? on the next rebuild, same
// "nothing is auto-rewritten" convention as a plain formula reference to a
// deleted table.
function validateNamedRange(
name: string,
tableId: string,
start: CellCoord,
end: CellCoord,
ignoreId: string | null,
): string | null {
const cleanName = name.trim();
if (!IDENT_RE.test(cleanName)) {
return `"${name}" ist kein gültiger Name.`;
}
if (looksLikeCell(cleanName)) {
return `"${cleanName}" sieht wie ein Zellbezug aus und ist als Name nicht erlaubt.`;
}
if (RESERVED_NAMES.has(cleanName.toUpperCase())) {
return `"${cleanName}" ist ein reserviertes Schlüsselwort (FOR/IN/DO/RETURN) und kann nicht als Name verwendet werden.`;
}
if (hasFunction(cleanName)) {
return `"${cleanName}" ist bereits eine eingebaute Funktion und kann nicht überschrieben werden.`;
}
const fnCollision = doc.customFunctions.find(
(f) => f.name.toLowerCase() === cleanName.toLowerCase(),
);
if (fnCollision) return `Es gibt bereits eine Funktion namens "${fnCollision.name}".`;
const rangeCollision = doc.namedRanges.find(
(r) => r.id !== ignoreId && r.name.toLowerCase() === cleanName.toLowerCase(),
);
if (rangeCollision) return `Es gibt bereits einen Namen "${rangeCollision.name}".`;
if (!tableById(tableId)) return 'Tabelle nicht gefunden.';
if (start.col < 0 || start.row < 0 || end.col < 0 || end.row < 0) {
return 'Ungültiger Bereich.';
}
return null;
}
function addNamedRange(
name: string,
tableId: string,
start: CellCoord,
end: CellCoord,
): string | { error: string } {
const error = validateNamedRange(name, tableId, start, end, null);
if (error) return { error };
const id = uid('nr');
doc.namedRanges.push({ id, name: name.trim(), tableId, start: { ...start }, end: { ...end } });
syncWorkbook();
return id;
}
function updateNamedRange(
id: string,
name: string,
tableId: string,
start: CellCoord,
end: CellCoord,
): void | { error: string } {
const r = doc.namedRanges.find((x) => x.id === id);
if (!r) return { error: 'Name nicht gefunden.' };
const error = validateNamedRange(name, tableId, start, end, id);
if (error) return { error };
r.name = name.trim();
r.tableId = tableId;
r.start = { ...start };
r.end = { ...end };
syncWorkbook();
}
function deleteNamedRange(id: string) {
doc.namedRanges = doc.namedRanges.filter((r) => r.id !== id);
syncWorkbook(); // affected formulas become #NAME? on rebuild, same as a deleted table
}
function moveObject(objId: string, x: number, y: number) {
const o = objectById(objId);
if (!o) return;
@@ -1616,6 +1711,7 @@ function createStore() {
doc.locale = isNumberLocale(next.locale) ? next.locale : DEFAULT_LOCALE;
doc.csvDelimiter = next.csvDelimiter === ';' ? ';' : ',';
doc.customFunctions = next.customFunctions ?? [];
doc.namedRanges = next.namedRanges ?? [];
syncWorkbook();
}
/** Loading a different document outright (JSON import "replace", "reset to
@@ -1710,6 +1806,9 @@ function createStore() {
addCustomFunction,
updateCustomFunction,
deleteCustomFunction,
addNamedRange,
updateNamedRange,
deleteNamedRange,
resizeChart,
bringToFront,
setChartView,