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:
+101
-2
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user