Interaktives Abhängigkeiten-Panel (Vorgänger/Nachfolger, andockbar)
Rechtsklick auf eine Zelle → "🕸 Abhängigkeiten anzeigen" öffnet ein an der rechten Seite angedocktes Panel (wie das Einstellungen-Panel links) mit den direkten Vorgängern (was die Zelle liest) und Nachfolgern (was sie liest), auch über Tabellen-/Blattgrenzen hinweg. Ein Klick auf einen Eintrag navigiert wirklich dorthin: wählt die echte Zelle im Dokument aus, wechselt bei Bedarf das Blatt-Tab, und das Panel zentriert sich neu — Blatt-Tabs, Canvas, Formel- und Statusleiste bleiben dabei die ganze Zeit sichtbar und bedienbar (kein Vollbild-Dialog). Jeder Eintrag zeigt immer den Blattnamen, nie nur implizit. Zirkelbezüge werden rot markiert. Neu in der Engine: Workbook.graphPrecedents/graphDependents, dünne öffentliche Wrapper um den bereits vorhandenen DependencyGraph. Im Store: cellGraphSelf/cellGraphNeighbors (Anreicherung für die Anzeige) sowie openDependencyGraph/closeDependencyGraph (dokumentweiter UI-State, ruft intern das bestehende selectCell auf). 424 Tests (Engine-/Store-Ebene vollständig abgedeckt), vue-tsc/eslint/build sauber. Das Panel selbst nur live per Playwright verifiziert (kein Komponenten-Test-Setup vorhanden, in README dokumentiert). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -411,6 +411,40 @@ mitgezogen:
|
|||||||
Tabelle liegt.
|
Tabelle liegt.
|
||||||
- **Undo/Redo-fähig** wie jede andere Änderung.
|
- **Undo/Redo-fähig** wie jede andere Änderung.
|
||||||
|
|
||||||
|
### Abhängigkeiten erkunden (🕸)
|
||||||
|
|
||||||
|
Rechtsklick auf eine Zelle → **„🕸 Abhängigkeiten anzeigen"** öffnet ein
|
||||||
|
andockbares Panel rechts (wie das Einstellungen-Panel links) — **kein**
|
||||||
|
Vollbild-Dialog, damit Blatt-Tabs, Statusleiste und Canvas die ganze Zeit
|
||||||
|
sichtbar und bedienbar bleiben: der Blattbezug geht beim Erkunden nie
|
||||||
|
verloren. Zeigt für die Zelle:
|
||||||
|
|
||||||
|
- **Vorgänger** — jede Zelle, die ihre Formel direkt liest (bei einem
|
||||||
|
Bereich wie `SUM(B2:E2)` jede einzelne Zelle darin).
|
||||||
|
- **Nachfolger** — jede Zelle, die *diese* Zelle direkt liest, auch über
|
||||||
|
Tabellen-/Blattgrenzen hinweg.
|
||||||
|
- Jeder Eintrag zeigt **Blatt · Tabelle!Zelle** (Blattname **immer**, nicht
|
||||||
|
nur bei Mehrdeutigkeit) sowie den aktuellen Wert.
|
||||||
|
|
||||||
|
**Ein Klick auf einen Eintrag navigiert wirklich dorthin** — nicht nur
|
||||||
|
innerhalb des Panels: Er wählt die echte Zelle im Dokument aus, wechselt bei
|
||||||
|
Bedarf das Blatt-Tab, und das Panel zentriert sich neu auf sie. Formelleiste,
|
||||||
|
Statusleiste und die Tabellen selbst zeigen danach exakt dieselbe Zelle, die
|
||||||
|
im Panel zentriert ist — das Erkunden des Formel-Netzwerks *ist* Navigieren,
|
||||||
|
keine isolierte Nebenansicht. **„← Zurück"** geht die so besuchten Zellen
|
||||||
|
rückwärts durch (auch das mit echter Navigation, nicht nur Panel-intern).
|
||||||
|
|
||||||
|
Ein Zirkelbezug (`#CIRC`) wird rot markiert (⚠), sowohl als Zentrum als auch
|
||||||
|
als Vorgänger/Nachfolger-Eintrag — die Kante existiert im Abhängigkeitsgraphen
|
||||||
|
unabhängig davon, dass sie bei der Neuberechnung übersprungen wird (s.
|
||||||
|
„Fehlermodell" unten).
|
||||||
|
|
||||||
|
**Bewusst ohne**: kein automatisches Scrollen der Zieltabelle zur
|
||||||
|
navigierten Zelle, falls sie außerhalb des sichtbaren Ausschnitts liegt
|
||||||
|
(bei einer „Feste Größe"-Tabelle mit vielen Zeilen); keine Gruppierung
|
||||||
|
vieler Vorgänger/Nachfolger zu einem Bereichs-Eintrag (`Umsatz!B2:B100`
|
||||||
|
erscheint als 99 einzelne Zeilen, nicht als ein zusammengefasster Eintrag).
|
||||||
|
|
||||||
### Zell-Formatierung
|
### Zell-Formatierung
|
||||||
|
|
||||||
Auswahl (Zelle, Bereich, Mehrfachauswahl, ganze Zeile/Spalte über den Kopf) → im
|
Auswahl (Zelle, Bereich, Mehrfachauswahl, ganze Zeile/Spalte über den Kopf) → im
|
||||||
@@ -972,6 +1006,16 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
|
|||||||
Bereich, `$`-Sperren bleiben wirkungslos (anders als beim Kopieren),
|
Bereich, `$`-Sperren bleiben wirkungslos (anders als beim Kopieren),
|
||||||
unveränderte Referenzen bleiben byte-identisch, Undo/Redo, Schutz gegen
|
unveränderte Referenzen bleiben byte-identisch, Undo/Redo, Schutz gegen
|
||||||
Löschen der letzten verbleibenden Zeile/Spalte.
|
Löschen der letzten verbleibenden Zeile/Spalte.
|
||||||
|
- **Abhängigkeiten erkunden** (`graphPrecedents`/`graphDependents` in
|
||||||
|
`workbook.spec.ts`, `cellGraphSelf`/`cellGraphNeighbors`/
|
||||||
|
`openDependencyGraph`/`closeDependencyGraph` in `document.spec.ts`) —
|
||||||
|
exakte Vorgänger/Nachfolger-Kanten, Auflösung über Tabellen-/Blattgrenzen,
|
||||||
|
bleibt korrekt für eine Zelle auf einem Zirkelbezug, aktualisiert sich
|
||||||
|
nach einer Neuzuordnung der Formel, `openDependencyGraph` löst wirklich
|
||||||
|
`selectCell` aus (inkl. Blatt-Tab-Wechsel), `closeDependencyGraph` rührt
|
||||||
|
die Auswahl nicht an. Das Panel selbst (`DependencyGraphView.vue`) ist
|
||||||
|
nur live per Playwright verifiziert, kein Komponenten-Test-Setup
|
||||||
|
vorhanden (s. „Bekannte Grenzen").
|
||||||
- **„Mit Schleife"-Code** — Parser-Ebene (`parseFunctionBlock`): ein
|
- **„Mit Schleife"-Code** — Parser-Ebene (`parseFunctionBlock`): ein
|
||||||
`FOR...DO...END` mit mehreren Anweisungen, mehrere unabhängige Schleifen
|
`FOR...DO...END` mit mehreren Anweisungen, mehrere unabhängige Schleifen
|
||||||
nacheinander, `IF...THEN...END` und `IF...THEN...ELSE...END`, `IF`
|
nacheinander, `IF...THEN...END` und `IF...THEN...ELSE...END`, `IF`
|
||||||
@@ -1043,6 +1087,14 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
|
|||||||
Komponenten-Test-Setup (z. B. Vue Test Utils), nur Engine-/Store-Tests.
|
Komponenten-Test-Setup (z. B. Vue Test Utils), nur Engine-/Store-Tests.
|
||||||
Nur live per Playwright verifiziert (Wachsen, Schrumpfen mit
|
Nur live per Playwright verifiziert (Wachsen, Schrumpfen mit
|
||||||
Inhaltsschutz, deutliches Überschreiten der bestehenden Größe).
|
Inhaltsschutz, deutliches Überschreiten der bestehenden Größe).
|
||||||
|
- **Abhängigkeiten-Panel** (`DependencyGraphView.vue`) bewusst ohne: Gruppierung
|
||||||
|
vieler Vorgänger/Nachfolger zu einem Bereichs-Eintrag (jede Zelle einzeln,
|
||||||
|
ungeachtet der Anzahl), automatisches Scrollen der Zieltabelle zur
|
||||||
|
navigierten Zelle. Wie bei der Tabellen-Größenänderung oben: rein
|
||||||
|
UI-seitig, nur live per Playwright verifiziert, kein Komponenten-Test-Setup
|
||||||
|
vorhanden — die zugrundeliegenden Store-/Engine-Funktionen
|
||||||
|
(`graphPrecedents`/`graphDependents`/`cellGraphNeighbors`/
|
||||||
|
`openDependencyGraph`) sind es aber (s. „Tests" oben).
|
||||||
- **Objekt-Bezüge beim Anhängen.** Beim JSON-*Anhängen* zweier Dokumente werden
|
- **Objekt-Bezüge beim Anhängen.** Beim JSON-*Anhängen* zweier Dokumente werden
|
||||||
nur Blatt-/Objekt-IDs neu vergeben, nicht Tabellennamen. Kollidiert ein
|
nur Blatt-/Objekt-IDs neu vergeben, nicht Tabellennamen. Kollidiert ein
|
||||||
angehängter Tabellenname mit einem bestehenden, gewinnt bei der Auflösung der
|
angehängter Tabellenname mit einem bestehenden, gewinnt bei der Auflösung der
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import SheetCanvas from './components/SheetCanvas.vue';
|
|||||||
import SheetTabs from './components/SheetTabs.vue';
|
import SheetTabs from './components/SheetTabs.vue';
|
||||||
import StatusBar from './components/StatusBar.vue';
|
import StatusBar from './components/StatusBar.vue';
|
||||||
import SettingsPanel from './components/SettingsPanel.vue';
|
import SettingsPanel from './components/SettingsPanel.vue';
|
||||||
|
import DependencyGraphView from './components/DependencyGraphView.vue';
|
||||||
import { useToasts } from './composables/useToasts';
|
import { useToasts } from './composables/useToasts';
|
||||||
import { useDocument } from './store/document';
|
import { useDocument } from './store/document';
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown));
|
|||||||
<main class="workspace">
|
<main class="workspace">
|
||||||
<SheetCanvas @files="onCanvasFiles" />
|
<SheetCanvas @files="onCanvasFiles" />
|
||||||
<SettingsPanel />
|
<SettingsPanel />
|
||||||
|
<DependencyGraphView />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div class="bottom-bar">
|
<div class="bottom-bar">
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Interactive dependency-graph explorer, docked to the right (like
|
||||||
|
* SettingsPanel.vue docks left) -- NOT a full-screen modal: the canvas and
|
||||||
|
* sheet tabs stay visible and interactive the whole time, so navigating the
|
||||||
|
* graph never loses track of which sheet/table you're actually looking at.
|
||||||
|
*
|
||||||
|
* A single document-wide instance (controlled via `store.ui.depGraph`, the
|
||||||
|
* same pattern as `settingsOpen`), not owned by any one `TableGrid` -- opened
|
||||||
|
* from a cell's context menu ("🕸 Abhängigkeiten anzeigen"). Clicking any
|
||||||
|
* precedent/dependent node *really* navigates there (store.openDependencyGraph
|
||||||
|
* also calls selectCell, which switches the active sheet/object as a side
|
||||||
|
* effect) -- the drawer re-centers on it exactly like a fresh open would.
|
||||||
|
*/
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useDocument } from '../store/document';
|
||||||
|
import type { CellGraphNode } from '../store/types';
|
||||||
|
|
||||||
|
const store = useDocument();
|
||||||
|
|
||||||
|
interface Loc {
|
||||||
|
tableId: string;
|
||||||
|
col: number;
|
||||||
|
row: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loc = computed(() => store.ui.depGraph);
|
||||||
|
const open = computed(() => loc.value !== null);
|
||||||
|
|
||||||
|
const history = ref<Loc[]>([]);
|
||||||
|
// a closed -> open transition always starts a fresh navigation history
|
||||||
|
watch(open, (isOpen, wasOpen) => {
|
||||||
|
if (isOpen && !wasOpen) history.value = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
function goTo(node: CellGraphNode) {
|
||||||
|
if (loc.value) history.value.push(loc.value);
|
||||||
|
store.openDependencyGraph(node.tableId, node.col, node.row);
|
||||||
|
}
|
||||||
|
function goBack() {
|
||||||
|
const prev = history.value.pop();
|
||||||
|
if (prev) store.openDependencyGraph(prev.tableId, prev.col, prev.row);
|
||||||
|
}
|
||||||
|
function close() {
|
||||||
|
store.closeDependencyGraph();
|
||||||
|
}
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') close();
|
||||||
|
}
|
||||||
|
watch(open, (isOpen) => {
|
||||||
|
if (isOpen) document.addEventListener('keydown', onKey);
|
||||||
|
else document.removeEventListener('keydown', onKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
const self = computed(() => (loc.value ? store.cellGraphSelf(loc.value.tableId, loc.value.col, loc.value.row) : null));
|
||||||
|
const neighbors = computed(() =>
|
||||||
|
loc.value
|
||||||
|
? store.cellGraphNeighbors(loc.value.tableId, loc.value.col, loc.value.row)
|
||||||
|
: { precedents: [], dependents: [] },
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Always "Blatt · Tabelle!Zelle" -- the sheet name is never omitted here
|
||||||
|
* (unlike an in-formula reference, which only qualifies when needed): the
|
||||||
|
* whole point of this panel is never losing track of *where* something is. */
|
||||||
|
function label(node: CellGraphNode): string {
|
||||||
|
return `${node.sheetName ? node.sheetName + ' · ' : ''}${node.tableName}!${node.label}`;
|
||||||
|
}
|
||||||
|
function display(node: CellGraphNode): string {
|
||||||
|
return store.cellDisplay(node.tableId, node.col, node.row);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside class="dg-drawer" :class="{ open }" aria-label="Abhängigkeiten">
|
||||||
|
<header>
|
||||||
|
<button class="back" :disabled="!history.length" title="Zurück" @click="goBack">←</button>
|
||||||
|
<h2>🕸 Abhängigkeiten</h2>
|
||||||
|
<button class="x" title="Schließen" @click="close">×</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="self" class="body">
|
||||||
|
<section class="self-card" :class="{ circular: self.isCircular }">
|
||||||
|
<p v-if="self.isCircular" class="circ-warn">⚠ Teil eines Zirkelbezugs</p>
|
||||||
|
<p class="cell-label">{{ label(self) }}</p>
|
||||||
|
<p class="cell-value">{{ display(self) }}</p>
|
||||||
|
<p v-if="self.raw" class="cell-raw">{{ self.raw }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>Vorgänger — liest von ({{ neighbors.precedents.length }})</h3>
|
||||||
|
<p v-if="!neighbors.precedents.length" class="empty">keine</p>
|
||||||
|
<ul v-else class="node-list">
|
||||||
|
<li v-for="(n, i) in neighbors.precedents" :key="'p' + i">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="node-btn"
|
||||||
|
:class="{ circular: n.isCircular }"
|
||||||
|
:title="n.raw || display(n)"
|
||||||
|
@click="goTo(n)"
|
||||||
|
>
|
||||||
|
<span v-if="n.isCircular" class="warn">⚠</span>
|
||||||
|
<span class="node-label">{{ label(n) }}</span>
|
||||||
|
<span class="node-value">{{ display(n) }}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>Nachfolger — liest davon ({{ neighbors.dependents.length }})</h3>
|
||||||
|
<p v-if="!neighbors.dependents.length" class="empty">keine</p>
|
||||||
|
<ul v-else class="node-list">
|
||||||
|
<li v-for="(n, i) in neighbors.dependents" :key="'d' + i">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="node-btn"
|
||||||
|
:class="{ circular: n.isCircular }"
|
||||||
|
:title="n.raw || display(n)"
|
||||||
|
@click="goTo(n)"
|
||||||
|
>
|
||||||
|
<span v-if="n.isCircular" class="warn">⚠</span>
|
||||||
|
<span class="node-label">{{ label(n) }}</span>
|
||||||
|
<span class="node-value">{{ display(n) }}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<p v-else class="body-empty">Zelle nicht gefunden.</p>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dg-drawer {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 60;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 380px;
|
||||||
|
max-width: 88vw;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--surface);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
transform: translateX(102%);
|
||||||
|
transition: transform 0.18s ease;
|
||||||
|
}
|
||||||
|
.dg-drawer.open {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.back,
|
||||||
|
.x {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-dim);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.back {
|
||||||
|
font-size: 15px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 3px 9px;
|
||||||
|
}
|
||||||
|
.back:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.back:not(:disabled):hover,
|
||||||
|
.x:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.x {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.body {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px 14px 20px;
|
||||||
|
}
|
||||||
|
.body-empty {
|
||||||
|
padding: 14px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
section {
|
||||||
|
padding: 14px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
section:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.self-card {
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.self-card.circular {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
.cell-label {
|
||||||
|
margin: 0;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.cell-value {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.cell-raw {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
opacity: 0.8;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.circ-warn {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.node-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.node-btn {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
padding: 6px 9px;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.node-btn:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.node-btn.circular {
|
||||||
|
border-color: #dc2626;
|
||||||
|
}
|
||||||
|
.node-label {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.node-value {
|
||||||
|
margin-left: auto;
|
||||||
|
flex: none;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 40%;
|
||||||
|
}
|
||||||
|
.warn {
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: #dc2626;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -74,7 +74,7 @@ function selectObj(id: string) {
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="canvas-wrap"
|
class="canvas-wrap"
|
||||||
:class="{ 'drawer-open': store.ui.settingsOpen }"
|
:class="{ 'drawer-open': store.ui.settingsOpen, 'dep-open': store.ui.depGraph }"
|
||||||
:style="appearance"
|
:style="appearance"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -118,15 +118,21 @@ function selectObj(id: string) {
|
|||||||
position: relative;
|
position: relative;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: margin-left 0.18s ease;
|
transition: margin-left 0.18s ease, margin-right 0.18s ease;
|
||||||
}
|
}
|
||||||
.canvas-wrap.drawer-open {
|
.canvas-wrap.drawer-open {
|
||||||
margin-left: 320px;
|
margin-left: 320px;
|
||||||
}
|
}
|
||||||
|
.canvas-wrap.dep-open {
|
||||||
|
margin-right: 380px;
|
||||||
|
}
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.canvas-wrap.drawer-open {
|
.canvas-wrap.drawer-open {
|
||||||
margin-left: 0; /* drawer overlays on narrow screens */
|
margin-left: 0; /* drawer overlays on narrow screens */
|
||||||
}
|
}
|
||||||
|
.canvas-wrap.dep-open {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.canvas-scroll {
|
.canvas-scroll {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -969,6 +969,18 @@ function onContextMenu(e: MouseEvent) {
|
|||||||
if (hit && !inSelection(hit.c, hit.r)) select(hit.c, hit.r);
|
if (hit && !inSelection(hit.c, hit.r)) select(hit.c, hit.r);
|
||||||
menu.value = { x: e.clientX, y: e.clientY, open: true };
|
menu.value = { x: e.clientX, y: e.clientY, open: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- dependency-graph view ---------------------------------
|
||||||
|
/** Opens the (single, document-wide) dependency-graph drawer on the cell
|
||||||
|
* the context menu was invoked on -- `select()` in onContextMenu above
|
||||||
|
* already made it the active cell by the time this runs, so the active
|
||||||
|
* selection's focus is exactly the right cell. */
|
||||||
|
function openDependencyGraph() {
|
||||||
|
const s = store.doc.selection;
|
||||||
|
if (!s || s.tableId !== props.tableId) return;
|
||||||
|
store.openDependencyGraph(props.tableId, s.focus.col, s.focus.row);
|
||||||
|
}
|
||||||
|
|
||||||
const menuItems = computed(() => {
|
const menuItems = computed(() => {
|
||||||
const t = table.value;
|
const t = table.value;
|
||||||
const ctx = menuCtx.value;
|
const ctx = menuCtx.value;
|
||||||
@@ -996,6 +1008,7 @@ const menuItems = computed(() => {
|
|||||||
];
|
];
|
||||||
if (store.hasClipboard.value) items.push({ label: 'Einfügen', action: () => store.pasteClipboard() });
|
if (store.hasClipboard.value) items.push({ label: 'Einfügen', action: () => store.pasteClipboard() });
|
||||||
items.push(
|
items.push(
|
||||||
|
{ label: '🕸 Abhängigkeiten anzeigen', action: openDependencyGraph },
|
||||||
{ label: 'Zeile hinzufügen', action: () => store.addRows(props.tableId) },
|
{ label: 'Zeile hinzufügen', action: () => store.addRows(props.tableId) },
|
||||||
{ label: 'Spalte hinzufügen', action: () => store.addColumns(props.tableId) },
|
{ label: 'Spalte hinzufügen', action: () => store.addColumns(props.tableId) },
|
||||||
{ label: 'Letzte Zeile entfernen', action: () => store.removeLastRow(props.tableId) },
|
{ label: 'Letzte Zeile entfernen', action: () => store.removeLastRow(props.tableId) },
|
||||||
|
|||||||
@@ -558,6 +558,16 @@ gefunden wird — dieselbe konservative Konvention wie bei `IF`.
|
|||||||
Strukturänderungen (Tabelle umbenennen/löschen, Zeilen/Spalten, Blatt löschen)
|
Strukturänderungen (Tabelle umbenennen/löschen, Zeilen/Spalten, Blatt löschen)
|
||||||
rufen `rebuild()` für einen vollständigen – trotzdem billigen – Durchlauf.
|
rufen `rebuild()` für einen vollständigen – trotzdem billigen – Durchlauf.
|
||||||
|
|
||||||
|
**`Workbook.graphPrecedents`/`graphDependents`** (dünne öffentliche Wrapper
|
||||||
|
um `DependencyGraph.getPrecedents`/`getDependents`) sind der einzige Ort, an
|
||||||
|
dem der Graph *abgefragt* statt nur für die Neuberechnung *benutzt* wird –
|
||||||
|
die Datenquelle des interaktiven Abhängigkeiten-Panels (`src/store/
|
||||||
|
document.ts`s `cellGraphNeighbors`/`cellGraphSelf`, `src/components/
|
||||||
|
DependencyGraphView.vue`, s. Haupt-README „Abhängigkeiten erkunden"). Rein
|
||||||
|
lesend, ohne jeden Effekt auf Auswertung/Neuberechnung; bleiben auch für
|
||||||
|
eine Zelle auf einem Zirkelbezug korrekt (die Kante existiert im Graphen
|
||||||
|
unabhängig davon, dass `topoOrder` sie aus der Auswertung ausschließt).
|
||||||
|
|
||||||
## Fehlermodell
|
## Fehlermodell
|
||||||
|
|
||||||
| Wert | Bedeutung |
|
| Wert | Bedeutung |
|
||||||
|
|||||||
@@ -193,6 +193,71 @@ describe('Workbook named ranges', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Workbook graph queries (graphPrecedents/graphDependents)', () => {
|
||||||
|
const coords = (a1: string) => {
|
||||||
|
const m = /^([A-Z]+)([0-9]+)$/.exec(a1)!;
|
||||||
|
return { col: m[1].split('').reduce((n, ch) => n * 26 + (ch.charCodeAt(0) - 64), 0) - 1, row: Number(m[2]) - 1 };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('graphPrecedents lists exactly the cells a formula reads, same-table', () => {
|
||||||
|
const wb = fixture();
|
||||||
|
const { col, row } = coords('D2'); // Übersicht!D2 = B2-C2
|
||||||
|
const precs = wb.graphPrecedents('s-ueber', col, row);
|
||||||
|
expect(precs).toHaveLength(2);
|
||||||
|
expect(precs).toContainEqual({ tableId: 's-ueber', ...coords('B2') });
|
||||||
|
expect(precs).toContainEqual({ tableId: 's-ueber', ...coords('C2') });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('graphPrecedents follows a cross-table range reference to every cell in it', () => {
|
||||||
|
const wb = fixture();
|
||||||
|
const { col, row } = coords('B2'); // Übersicht!B2 = SUM(Umsatz!B2:E2)
|
||||||
|
const precs = wb.graphPrecedents('s-ueber', col, row);
|
||||||
|
expect(precs).toHaveLength(4);
|
||||||
|
for (const a1 of ['B2', 'C2', 'D2', 'E2']) {
|
||||||
|
expect(precs).toContainEqual({ tableId: 's-umsatz', ...coords(a1) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('graphDependents is the exact reverse of graphPrecedents', () => {
|
||||||
|
const wb = fixture();
|
||||||
|
const { col, row } = coords('B2'); // Übersicht!B2 -- read by D2 and (via D2) E2 directly reads B2 too
|
||||||
|
const deps = wb.graphDependents('s-ueber', col, row);
|
||||||
|
expect(deps).toContainEqual({ tableId: 's-ueber', ...coords('D2') });
|
||||||
|
expect(deps).toContainEqual({ tableId: 's-ueber', ...coords('E2') }); // E2 = D2/B2*100, reads B2 directly
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a cell with no formula (or a literal) has no precedents', () => {
|
||||||
|
const wb = fixture();
|
||||||
|
const { col, row } = coords('B2'); // Umsatz!B2 is a plain literal, "1200"
|
||||||
|
expect(wb.graphPrecedents('s-umsatz', col, row)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a cell nothing reads has no dependents', () => {
|
||||||
|
const wb = fixture();
|
||||||
|
const { col, row } = coords('A2'); // Umsatz!A2 = "Nord", never referenced by a formula
|
||||||
|
expect(wb.graphDependents('s-umsatz', col, row)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays accurate for cells on a circular reference (edges exist even though topoOrder drops them)', () => {
|
||||||
|
const a = sheet('s1', 'S1', { A1: '=B1', B1: '=A1' });
|
||||||
|
const wb = new Workbook([a]);
|
||||||
|
const a1 = coords('A1');
|
||||||
|
expect(wb.graphPrecedents('s1', a1.col, a1.row)).toContainEqual({ tableId: 's1', ...coords('B1') });
|
||||||
|
expect(wb.graphDependents('s1', a1.col, a1.row)).toContainEqual({ tableId: 's1', ...coords('B1') });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates live after a cell is re-edited to reference something else', () => {
|
||||||
|
const wb = fixture();
|
||||||
|
const c1 = coords('C1');
|
||||||
|
wb.setCell('s-umsatz', c1.col, c1.row, '=B2');
|
||||||
|
expect(wb.graphPrecedents('s-umsatz', c1.col, c1.row)).toContainEqual({ tableId: 's-umsatz', ...coords('B2') });
|
||||||
|
wb.setCell('s-umsatz', c1.col, c1.row, '=B3');
|
||||||
|
const precs = wb.graphPrecedents('s-umsatz', c1.col, c1.row);
|
||||||
|
expect(precs).toContainEqual({ tableId: 's-umsatz', ...coords('B3') });
|
||||||
|
expect(precs).not.toContainEqual({ tableId: 's-umsatz', ...coords('B2') });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Workbook recalculation order', () => {
|
describe('Workbook recalculation order', () => {
|
||||||
it('evaluates precedents before dependents (chain across sheets)', () => {
|
it('evaluates precedents before dependents (chain across sheets)', () => {
|
||||||
const wb = fixture();
|
const wb = fixture();
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export {
|
|||||||
ERROR_CODES,
|
ERROR_CODES,
|
||||||
type ErrorCode,
|
type ErrorCode,
|
||||||
type CellValue,
|
type CellValue,
|
||||||
|
type CellRef,
|
||||||
type Scalar,
|
type Scalar,
|
||||||
type EngineHost,
|
type EngineHost,
|
||||||
type NamedRangeDef,
|
type NamedRangeDef,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
isError,
|
isError,
|
||||||
keyOf,
|
keyOf,
|
||||||
parseKey,
|
parseKey,
|
||||||
|
type CellRef,
|
||||||
type CellValue,
|
type CellValue,
|
||||||
type EngineHost,
|
type EngineHost,
|
||||||
type NamedRangeDef,
|
type NamedRangeDef,
|
||||||
@@ -158,6 +159,21 @@ export class Workbook implements EngineHost {
|
|||||||
return v === undefined ? null : v;
|
return v === undefined ? null : v;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Direct precedents of a cell -- the cells its own formula reads (a
|
||||||
|
* formula's `#REF`/named-range/etc. resolution already baked into the
|
||||||
|
* graph edges, see `precedentsOf()` above). Public wrapper around the
|
||||||
|
* internal `DependencyGraph`, for UI features (the dependency-graph
|
||||||
|
* view) that need to *query* the graph -- evaluation itself never calls
|
||||||
|
* this, it only calls `setPrecedents`/`affected`/`topoOrder`. */
|
||||||
|
graphPrecedents(tableId: string, col: number, row: number): CellRef[] {
|
||||||
|
return [...this.graph.getPrecedents(keyOf(tableId, col, row))].map(parseKey);
|
||||||
|
}
|
||||||
|
/** Direct dependents of a cell -- every cell whose formula reads *this*
|
||||||
|
* one. See graphPrecedents() above. */
|
||||||
|
graphDependents(tableId: string, col: number, row: number): CellRef[] {
|
||||||
|
return [...this.graph.getDependents(keyOf(tableId, col, row))].map(parseKey);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- writes ---------------------------------------------------------
|
// ---- writes ---------------------------------------------------------
|
||||||
|
|
||||||
/** Set (or clear, with `''`) one cell and recalculate the affected cells. */
|
/** Set (or clear, with `''`) one cell and recalculate the affected cells. */
|
||||||
|
|||||||
@@ -1233,6 +1233,145 @@ describe('insertRow/deleteRow/insertColumn/deleteColumn (mid-grid, not just edge
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('dependency-graph view (cellGraphSelf/cellGraphNeighbors)', () => {
|
||||||
|
it('cellGraphSelf describes a formula cell: label, raw text, value, table/sheet name', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
const name = s.tableById(id)!.name;
|
||||||
|
setCell(s, name, 'B5', '10');
|
||||||
|
setCell(s, name, 'C1', '=B5+1');
|
||||||
|
const { col, row } = coords('C1');
|
||||||
|
const node = s.cellGraphSelf(id, col, row)!;
|
||||||
|
expect(node).toMatchObject({
|
||||||
|
tableId: id,
|
||||||
|
tableName: name,
|
||||||
|
col,
|
||||||
|
row,
|
||||||
|
label: 'C1',
|
||||||
|
raw: '=B5+1',
|
||||||
|
value: 11,
|
||||||
|
isCircular: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cellGraphNeighbors.precedents lists exactly the cells a formula reads', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
const name = s.tableById(id)!.name;
|
||||||
|
setCell(s, name, 'B2', '1');
|
||||||
|
setCell(s, name, 'B3', '2');
|
||||||
|
setCell(s, name, 'D1', '=SUM(B2:B3)');
|
||||||
|
const { col, row } = coords('D1');
|
||||||
|
const { precedents } = s.cellGraphNeighbors(id, col, row);
|
||||||
|
expect(precedents.map((n) => n.label).sort()).toEqual(['B2', 'B3']);
|
||||||
|
expect(precedents.every((n) => n.tableId === id)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cellGraphNeighbors.dependents is the reverse: who reads this cell', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
const name = s.tableById(id)!.name;
|
||||||
|
setCell(s, name, 'A1', '5');
|
||||||
|
setCell(s, name, 'B1', '=A1*2');
|
||||||
|
setCell(s, name, 'C1', '=A1+1');
|
||||||
|
const { col, row } = coords('A1');
|
||||||
|
const { dependents } = s.cellGraphNeighbors(id, col, row);
|
||||||
|
expect(dependents.map((n) => n.label).sort()).toEqual(['B1', 'C1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows a cross-table reference, labelling with the other table/sheet name', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const src = s.addTable(s.doc.activeSheetId);
|
||||||
|
s.renameObject(src, 'Quelle4');
|
||||||
|
const dst = s.addTable(s.doc.activeSheetId);
|
||||||
|
s.renameObject(dst, 'Ziel4');
|
||||||
|
setCell(s, 'Quelle4', 'B2', '42');
|
||||||
|
setCell(s, 'Ziel4', 'A1', '=Quelle4!B2');
|
||||||
|
const { col, row } = coords('A1');
|
||||||
|
const { precedents } = s.cellGraphNeighbors(dst, col, row);
|
||||||
|
expect(precedents).toHaveLength(1);
|
||||||
|
expect(precedents[0]).toMatchObject({ tableId: src, tableName: 'Quelle4', label: 'B2', value: 42 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags a cell on a circular reference via isCircular', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
const name = s.tableById(id)!.name;
|
||||||
|
setCell(s, name, 'A1', '=B1');
|
||||||
|
setCell(s, name, 'B1', '=A1');
|
||||||
|
const { col, row } = coords('A1');
|
||||||
|
expect(s.cellGraphSelf(id, col, row)!.isCircular).toBe(true);
|
||||||
|
// the edge itself is still reported even though the cell is circular
|
||||||
|
const { precedents } = s.cellGraphNeighbors(id, col, row);
|
||||||
|
expect(precedents.map((n) => n.label)).toEqual(['B1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a literal (non-formula) cell has no precedents; an unreferenced cell has no dependents', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
const name = s.tableById(id)!.name;
|
||||||
|
setCell(s, name, 'A1', '5');
|
||||||
|
const { col, row } = coords('A1');
|
||||||
|
const { precedents, dependents } = s.cellGraphNeighbors(id, col, row);
|
||||||
|
expect(precedents).toEqual([]);
|
||||||
|
expect(dependents).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reflects a re-edit (e.g. after changing which cell a formula reads)', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
const name = s.tableById(id)!.name;
|
||||||
|
setCell(s, name, 'B2', '1');
|
||||||
|
setCell(s, name, 'B3', '2');
|
||||||
|
setCell(s, name, 'C1', '=B2');
|
||||||
|
const { col, row } = coords('C1');
|
||||||
|
expect(s.cellGraphNeighbors(id, col, row).precedents.map((n) => n.label)).toEqual(['B2']);
|
||||||
|
setCell(s, name, 'C1', '=B3');
|
||||||
|
expect(s.cellGraphNeighbors(id, col, row).precedents.map((n) => n.label)).toEqual(['B3']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('openDependencyGraph/closeDependencyGraph (the drawer, not just the data)', () => {
|
||||||
|
it('opens the drawer centered on the given cell', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
const { col, row } = coords('B2');
|
||||||
|
expect(s.ui.depGraph).toBeNull();
|
||||||
|
s.openDependencyGraph(id, col, row);
|
||||||
|
expect(s.ui.depGraph).toEqual({ tableId: id, col, row });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('also really selects the cell -- the rest of the app (selection) reflects where you navigated', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
const { col, row } = coords('C3');
|
||||||
|
s.openDependencyGraph(id, col, row);
|
||||||
|
expect(s.doc.selection).toMatchObject({ tableId: id, anchor: { col, row }, focus: { col, row } });
|
||||||
|
expect(s.doc.activeObjectId).toBe(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches the active sheet tab when navigating to a cell on a different sheet', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const otherSheet = s.addSheet();
|
||||||
|
const id = s.addTable(otherSheet);
|
||||||
|
expect(s.doc.activeSheetId).toBe(otherSheet); // addTable already switched here
|
||||||
|
s.setActiveSheet(s.doc.sheets[0].id); // move away
|
||||||
|
expect(s.doc.activeSheetId).not.toBe(otherSheet);
|
||||||
|
|
||||||
|
s.openDependencyGraph(id, 0, 0);
|
||||||
|
expect(s.doc.activeSheetId).toBe(otherSheet); // navigated back automatically
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closeDependencyGraph clears it without touching the current selection', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
s.openDependencyGraph(id, 0, 0);
|
||||||
|
s.closeDependencyGraph();
|
||||||
|
expect(s.ui.depGraph).toBeNull();
|
||||||
|
expect(s.doc.selection).toMatchObject({ tableId: id, focus: { col: 0, row: 0 } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('functionSuggestions (formula autocomplete)', () => {
|
describe('functionSuggestions (formula autocomplete)', () => {
|
||||||
it('filters built-ins by prefix, case-insensitively', () => {
|
it('filters built-ins by prefix, case-insensitively', () => {
|
||||||
const s = fresh();
|
const s = fresh();
|
||||||
|
|||||||
+78
-1
@@ -35,6 +35,7 @@ import {
|
|||||||
blockAlwaysReturns,
|
blockAlwaysReturns,
|
||||||
parseDateTimeLiteral,
|
parseDateTimeLiteral,
|
||||||
BUILTIN_SIGNATURES,
|
BUILTIN_SIGNATURES,
|
||||||
|
type CellRef,
|
||||||
type CellValue,
|
type CellValue,
|
||||||
type TableModel,
|
type TableModel,
|
||||||
type Token,
|
type Token,
|
||||||
@@ -51,6 +52,7 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
CellCoord,
|
CellCoord,
|
||||||
CellFormat,
|
CellFormat,
|
||||||
|
CellGraphNode,
|
||||||
ChartObject,
|
ChartObject,
|
||||||
CustomFunction,
|
CustomFunction,
|
||||||
DocumentState,
|
DocumentState,
|
||||||
@@ -423,7 +425,14 @@ function createStore() {
|
|||||||
const bump = () => (recalcTick.value += 1);
|
const bump = () => (recalcTick.value += 1);
|
||||||
|
|
||||||
/** View-only state: not part of the document, not persisted. */
|
/** View-only state: not part of the document, not persisted. */
|
||||||
const ui = reactive({ settingsOpen: false });
|
const ui = reactive({
|
||||||
|
settingsOpen: false,
|
||||||
|
/** The dependency-graph drawer's centered cell, or `null` when closed
|
||||||
|
* -- a single document-wide panel (like `settingsOpen`), not owned by
|
||||||
|
* any one `TableGrid` instance, so navigating across tables/sheets
|
||||||
|
* inside it doesn't depend on which grid happened to open it. */
|
||||||
|
depGraph: null as { tableId: string; col: number; row: number } | null,
|
||||||
|
});
|
||||||
/** Copy/cut buffer -- not part of the persisted document. */
|
/** Copy/cut buffer -- not part of the persisted document. */
|
||||||
const clipboard = ref<ClipboardData | null>(null);
|
const clipboard = ref<ClipboardData | null>(null);
|
||||||
/** Whichever formula editor is currently open (in-cell editor or formula
|
/** Whichever formula editor is currently open (in-cell editor or formula
|
||||||
@@ -1244,6 +1253,70 @@ function createStore() {
|
|||||||
const cellIsError = (t: string, c: number, r: number) => isError(cellValue(t, c, r));
|
const cellIsError = (t: string, c: number, r: number) => isError(cellValue(t, c, r));
|
||||||
const cellIsFormula = (t: string, c: number, r: number) => cellInput(t, c, r).startsWith('=');
|
const cellIsFormula = (t: string, c: number, r: number) => cellInput(t, c, r).startsWith('=');
|
||||||
|
|
||||||
|
// ---- dependency-graph view (precedents/dependents of a cell) ---------
|
||||||
|
const isCellCircular = (v: CellValue): boolean => isError(v) && v.code === '#CIRC';
|
||||||
|
function describeCellRef(ref: CellRef): CellGraphNode | null {
|
||||||
|
const table = tableById(ref.tableId);
|
||||||
|
if (!table) return null; // dangling (table deleted mid-navigation) -- drop it
|
||||||
|
const sheetId = sheetIdOfObject(ref.tableId);
|
||||||
|
const sheetName = (sheetId && sheetById(sheetId)?.name) || '';
|
||||||
|
const value = wb.cellValue(ref.tableId, ref.col, ref.row);
|
||||||
|
return {
|
||||||
|
tableId: ref.tableId,
|
||||||
|
tableName: table.name,
|
||||||
|
sheetName,
|
||||||
|
col: ref.col,
|
||||||
|
row: ref.row,
|
||||||
|
label: toA1(ref.col, ref.row),
|
||||||
|
raw: cellInput(ref.tableId, ref.col, ref.row),
|
||||||
|
value,
|
||||||
|
isCircular: isCellCircular(value),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/** Stable render order -- a `Set`'s iteration order is otherwise
|
||||||
|
* insertion-order-dependent and would make the graph view jump around
|
||||||
|
* on every re-render for no reason. */
|
||||||
|
const graphNodeSort = (a: CellGraphNode, b: CellGraphNode): number =>
|
||||||
|
a.tableName.localeCompare(b.tableName) || a.row - b.row || a.col - b.col;
|
||||||
|
|
||||||
|
/** The centered cell itself, described the same way as a precedent/
|
||||||
|
* dependent node -- null if its table no longer exists. */
|
||||||
|
function cellGraphSelf(tableId: string, col: number, row: number): CellGraphNode | null {
|
||||||
|
void recalcTick.value;
|
||||||
|
return describeCellRef({ tableId, col, row });
|
||||||
|
}
|
||||||
|
/** Direct precedents/dependents of a cell (see engine/graph.ts), enriched
|
||||||
|
* for display -- the data source for the interactive dependency-graph
|
||||||
|
* view (`DependencyGraphView.vue`). */
|
||||||
|
function cellGraphNeighbors(
|
||||||
|
tableId: string,
|
||||||
|
col: number,
|
||||||
|
row: number,
|
||||||
|
): { precedents: CellGraphNode[]; dependents: CellGraphNode[] } {
|
||||||
|
void recalcTick.value;
|
||||||
|
const toNodes = (refs: CellRef[]) =>
|
||||||
|
refs.map(describeCellRef).filter((n): n is CellGraphNode => n !== null).sort(graphNodeSort);
|
||||||
|
return {
|
||||||
|
precedents: toNodes(wb.graphPrecedents(tableId, col, row)),
|
||||||
|
dependents: toNodes(wb.graphDependents(tableId, col, row)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/** Opens the dependency-graph drawer centered on this cell -- also *really*
|
||||||
|
* selects it (via the existing `selectCell`, which already switches the
|
||||||
|
* active sheet/object as a side effect), so the sheet tabs, canvas,
|
||||||
|
* formula bar and status bar all reflect where you are, not just the
|
||||||
|
* drawer itself. Reused as-is for navigating *inside* the already-open
|
||||||
|
* drawer (clicking a precedent/dependent node calls this again) -- a
|
||||||
|
* jump across sheets/tables inside the graph is then indistinguishable
|
||||||
|
* from clicking that cell directly. */
|
||||||
|
function openDependencyGraph(tableId: string, col: number, row: number) {
|
||||||
|
selectCell(tableId, col, row);
|
||||||
|
ui.depGraph = { tableId, col, row };
|
||||||
|
}
|
||||||
|
function closeDependencyGraph() {
|
||||||
|
ui.depGraph = null;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- cell writes --------------------------------------
|
// ---- cell writes --------------------------------------
|
||||||
function setCellInput(tableId: string, col: number, row: number, raw: string) {
|
function setCellInput(tableId: string, col: number, row: number, raw: string) {
|
||||||
const table = tableById(tableId);
|
const table = tableById(tableId);
|
||||||
@@ -2157,6 +2230,10 @@ function createStore() {
|
|||||||
cellIsNumeric,
|
cellIsNumeric,
|
||||||
cellIsError,
|
cellIsError,
|
||||||
cellIsFormula,
|
cellIsFormula,
|
||||||
|
cellGraphSelf,
|
||||||
|
cellGraphNeighbors,
|
||||||
|
openDependencyGraph,
|
||||||
|
closeDependencyGraph,
|
||||||
setCellInput,
|
setCellInput,
|
||||||
clearRange,
|
clearRange,
|
||||||
addRows,
|
addRows,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { NumberLocale } from './format';
|
import type { NumberLocale } from './format';
|
||||||
|
import type { CellValue } from '../engine';
|
||||||
|
|
||||||
export interface CellCoord {
|
export interface CellCoord {
|
||||||
col: number;
|
col: number;
|
||||||
@@ -201,6 +202,28 @@ export interface FnSuggestion {
|
|||||||
kind: 'builtin' | 'custom';
|
kind: 'builtin' | 'custom';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One node of the interactive dependency-graph view (document.ts's
|
||||||
|
* `cellGraphNeighbors()`/`cellGraphSelf()`) -- a precedent/dependent cell
|
||||||
|
* (or the centered cell itself), enriched with what the UI needs to
|
||||||
|
* render and label it without a second lookup. */
|
||||||
|
export interface CellGraphNode {
|
||||||
|
tableId: string;
|
||||||
|
tableName: string;
|
||||||
|
/** the sheet the table lives on -- shown only when navigating makes the
|
||||||
|
* centered cell's own sheet ambiguous otherwise. */
|
||||||
|
sheetName: string;
|
||||||
|
col: number;
|
||||||
|
row: number;
|
||||||
|
/** A1 label within its table, e.g. "B5" (never table/sheet-qualified --
|
||||||
|
* the UI adds that itself from `tableName`/`sheetName` as needed). */
|
||||||
|
label: string;
|
||||||
|
/** raw input (formula text or literal), for a tooltip/detail view. */
|
||||||
|
raw: string;
|
||||||
|
value: CellValue;
|
||||||
|
/** this cell's *own* value is `#CIRC` -- it sits on a circular reference. */
|
||||||
|
isCircular: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DocumentState {
|
export interface DocumentState {
|
||||||
version: 2;
|
version: 2;
|
||||||
sheets: Sheet[];
|
sheets: Sheet[];
|
||||||
|
|||||||
Reference in New Issue
Block a user