feat: eingeklappter Vorfahr vertritt die Cursor-Zeile im Diagramm (D38-Nachtrag 4)

Liegt der Knoten der Cursor-Zeile in einem eingeklappten Teilbaum, hebt
nodeOfLine() den nächsten sichtbaren Vorfahren hervor — Cursor-Mitlaufen,
Alt+Klick, Alt+Enter und der Mobil-Umschalter erben es über die eine
Auflösungsstelle. Ausgeblendete verworfene Elemente heben weiterhin nichts
hervor (SPEC §9). Zeilen-Map als lineTargets() headless in model.js, 5 neue
Tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-24 16:20:48 +02:00
co-authored by Claude Fable 5
parent 6dfcb2b65c
commit 9cb6ec1197
6 changed files with 138 additions and 3 deletions
+1
View File
@@ -19,6 +19,7 @@ reverse.
## 2026-08-24 ## 2026-08-24
- When the caret's node is folded away, the nearest visible ancestor takes the highlight — also on Alt+click from the text
- A size that cannot hold its sub-packages turns its badge amber and gets a warning — sizes are read as ranges, so only sure conflicts are reported - A size that cannot hold its sub-packages turns its badge amber and gets a warning — sizes are read as ranges, so only sure conflicts are reported
- Fix: the exported SVG drew the assumed-M badge filled like a real size instead of inverted - Fix: the exported SVG drew the assumed-M badge filled like a real size instead of inverted
- Dependency links now run behind every node, highlighted ones included — no more lines struck through node titles - Dependency links now run behind every node, highlighted ones included — no more lines struck through node titles
+35
View File
@@ -2619,6 +2619,41 @@ dasselbe. Jetzt meldet die Funktion „nichts geschrieben“, und der Aufrufer
zeichnet selbst neu. Nachgemessen: 144 Knoten/69 Punkte → 1/1 → 144/69 → 1/1 zeichnet selbst neu. Nachgemessen: 144 Knoten/69 Punkte → 1/1 → 144/69 → 1/1
über mehrere Klicks. über mehrere Klicks.
**Nachtrag 4 — der eingeklappte Knoten vertritt seine Zeilen auch für den
Cursor.** Gemeldet als Wunsch: Steht der Cursor im Text auf einer Zeile, deren
Knoten im Diagramm eingeklappt ist, soll der nächste sichtbare Elternknoten
fokussiert werden — auch beim Alt+Klick. Bisher hob so eine Zeile schlicht
**nichts** hervor: `nodeOfLine()` sucht per `data-line` im DOM, und der
Renderer lässt eingeklappte Kinder weg (oben) — die Zeile lief ins Leere,
ausgerechnet dort, wo man ohne Hervorhebung am wenigsten sieht.
Die Regel gibt es schon, sie galt nur noch nicht hier: **Der eingeklappte
Knoten vertritt seinen Teilbaum** — für die Pfad-Station (Nachtrag oben) wie
für den „▸ n"-Zähler. Jetzt auch für die Zeilenauflösung: Liegt die Zeile in
einem eingeklappten Teilbaum, ist ihr Vertreter der **nächste sichtbare
Vorfahr** (bei verschachtelter Faltung der äußerste eingeklappte — nur der
steht im DOM). Beschreibungs- und Fortsetzungszeilen wandern mit ihrem Knoten
mit (D40-Nachtrag 2/D59). Beide Richtungen bekommen es über die **eine**
Auflösungsstelle `nodeOfLine()` (D25-Regel: wer eine neue Zeilensuche
schreibt, nimmt sie) — Cursor-Mitlaufen, Alt+Klick, Alt+Enter und der
Mobil-Umschalter sind damit automatisch abgedeckt.
**Die Grenze bleibt gezogen: ausgeblendete verworfene Elemente heben weiter
nichts hervor.** SPEC §9 nennt sie ausdrücklich neben Kommentar und
Leerzeile — Faltung ist Ansicht („hier, aber zusammengelegt"), der
Verworfen-Filter ist Aussage („nicht Teil des Plans"). Umgesetzt fällt die
Unterscheidung von selbst: Die Zeilen-Map (`lineTargets()` in model.js,
headless getestet) läuft über `visibleChildren()` — ausgefilterte Teilbäume
stehen gar nicht erst darin. Die Map wird in `render()` aus **denselben**
Mengen gebildet wie das DOM (`collapsedSet`, `showDiscarded`) — dieselbe
Identitäts-Regel wie bei `freshSet` (D28).
**Nachgemessen** im Browser: Cursor auf einem verborgenen Kind → der
eingeklappte Elternknoten trägt Ring und Puls; Alt+Klick fokussiert ihn
(`document.activeElement` ist der „▸ 2"-Knoten); Cursor auf einer
ausgeblendeten verworfenen Zeile und ihrem Kind → weiterhin keine
Hervorhebung. 377 Tests, davon 5 neue in `tests/fold.test.js`.
## D39 — Effektiver Status: die Farbe sagt die Wahrheit, die Marke die Box ## D39 — Effektiver Status: die Farbe sagt die Wahrheit, die Marke die Box
Mit den Abhängigkeiten (D37) gibt es zwei Aussagen je Knoten: was in der Mit den Abhängigkeiten (D37) gibt es zwei Aussagen je Knoten: was in der
Statusbox steht (intrinsisch) und wie weit er wirklich ist (effektiv, §4). Statusbox steht (intrinsisch) und wie weit er wirklich ist (effektiv, §4).
+5 -1
View File
@@ -804,7 +804,11 @@ verknüpft (siehe D25):
Knoten, gehören aber zu einem, und wer darin schreibt, arbeitet an genau Knoten, gehören aber zu einem, und wer darin schreibt, arbeitet an genau
diesem Knoten. Das gilt für beide Richtungen der Geste (also auch für den diesem Knoten. Das gilt für beide Richtungen der Geste (also auch für den
ausdrücklichen Alt+Klick unten) und für die Hervorhebung der ausdrücklichen Alt+Klick unten) und für die Hervorhebung der
Abhängigkeits-Kanten (§9). Zeilen ohne Knoten (Kommentar, Leerzeile, der Abhängigkeits-Kanten (§9). **Liegt der Knoten der Cursor-Zeile in einem
eingeklappten Teilbaum** (§9, Falten), wird stattdessen sein **nächster
sichtbarer Vorfahr** hervorgehoben — der eingeklappte Knoten vertritt seinen
Teilbaum (D38), auch hier; das gilt ebenso für den ausdrücklichen Alt+Klick
unten. Zeilen ohne Knoten (Kommentar, Leerzeile, der
`---`-Trenner selbst, ausgeblendetes verworfenes Element) heben nichts `---`-Trenner selbst, ausgeblendetes verworfenes Element) heben nichts
hervor. Die Hervorhebung arbeitet auf einer **eigenen Achse — Tiefe**: weißer hervor. Die Hervorhebung arbeitet auf einer **eigenen Achse — Tiefe**: weißer
Halo + Ring in Tinte, dazu ein Schlagschatten und eine leichte Vergrößerung, Halo + Ring in Tinte, dazu ein Schlagschatten und eine leichte Vergrößerung,
+20 -2
View File
@@ -1,6 +1,6 @@
import './style.css'; import './style.css';
import { parse, setFoldMark, expandShortIds, shortIdClosed } from './parser.js'; import { parse, setFoldMark, expandShortIds, shortIdClosed } from './parser.js';
import { computeCheapPlan, freshProdSet, initialCollapsed, nodeKeys, effectiveStatus, atMostM } from './model.js'; import { computeCheapPlan, freshProdSet, initialCollapsed, nodeKeys, effectiveStatus, atMostM, lineTargets } from './model.js';
import { esc, renderTreeHtml, TIP_RULE } from './render.js'; import { esc, renderTreeHtml, TIP_RULE } from './render.js';
import { formatWarning, warningText } from './warnings.js'; import { formatWarning, warningText } from './warnings.js';
import { padUrls } from './remote.js'; import { padUrls } from './remote.js';
@@ -92,6 +92,10 @@ let newsDay = null, newsKeySet = null;
Textmarken, gelten nur für die Sitzung und fallen beim Dokumentwechsel weg. Textmarken, gelten nur für die Sitzung und fallen beim Dokumentwechsel weg.
`foldByLine` ist der Zustand des letzten Renders für Klick/Tastatur. */ `foldByLine` ist der Zustand des letzten Renders für Klick/Tastatur. */
let foldOverrides = new Map(), foldByLine = new Map(); let foldOverrides = new Map(), foldByLine = new Map();
/* Zeile -> Zeile des sichtbaren Vertreters (D38-Nachtrag 4): Für Zeilen in
eingeklappten Teilbäumen zeigt sie auf den nächsten sichtbaren Vorfahren;
`nodeOfLine()` greift darauf zurück, wenn die Zeile keinen DOM-Knoten hat. */
let lineTargetMap = new Map();
/* ---------- Renderer (Anbindung an den DOM) ---------- /* ---------- Renderer (Anbindung an den DOM) ----------
parse -> Wurzeln filtern (verworfene) -> günstigen Pfad markieren -> parse -> Wurzeln filtern (verworfene) -> günstigen Pfad markieren ->
@@ -117,6 +121,7 @@ function render(){
out.innerHTML = `<div class="empty">${esc(t('empty'))}</div>`; out.innerHTML = `<div class="empty">${esc(t('empty'))}</div>`;
freshSet = new Set(); freshSet = new Set();
foldByLine = new Map(); foldByLine = new Map();
lineTargetMap = new Map();
} else { } else {
/* Günstigster Pfad auf der Dependency Closure (D42): scheitert die exakte /* Günstigster Pfad auf der Dependency Closure (D42): scheitert die exakte
Suche an zu vielen gekoppelten Gruppen, wird die gierige Schätzung Suche an zu vielen gekoppelten Gruppen, wird die gierige Schätzung
@@ -168,6 +173,9 @@ function render(){
„alles offen" derselbe Zustand — der Knopf säße dann gedrückt da, ohne „alles offen" derselbe Zustand — der Knopf säße dann gedrückt da, ohne
dass etwas zugeklappt ist. */ dass etwas zugeklappt ist. */
foldSmallBtn.setAttribute('aria-pressed', foldSmallExact && foldSmallAny ? 'true' : 'false'); foldSmallBtn.setAttribute('aria-pressed', foldSmallExact && foldSmallAny ? 'true' : 'false');
/* Aus DENSELBEN Mengen wie das Rendern — die Map muss dieselbe Faltung
beschreiben, die gleich im DOM steht (dieselbe Regel wie bei freshSet). */
lineTargetMap = lineTargets(roots, collapsedSet, showDiscarded);
const r = renderTreeHtml(roots, {t, showDiscarded, cheapPath: cheapPathOn, cheapSet, showIds, const r = renderTreeHtml(roots, {t, showDiscarded, cheapPath: cheapPathOn, cheapSet, showIds,
freshSet, collapsedSet, freshSet, collapsedSet,
effStatus: effectiveStatus(roots)}); effStatus: effectiveStatus(roots)});
@@ -1399,8 +1407,18 @@ document.querySelector('.diagram').addEventListener('scroll', closeNodeTip, {pas
`data-desc-lines` (vom Renderer, gefüllt aus `node.descLines`). */ `data-desc-lines` (vom Renderer, gefüllt aus `node.descLines`). */
function nodeOfLine(line){ function nodeOfLine(line){
if(line == null) return null; if(line == null) return null;
return out.querySelector('.node[data-line="' + line + '"]') const el = out.querySelector('.node[data-line="' + line + '"]')
|| out.querySelector('.node[data-desc-lines~="' + line + '"]'); || out.querySelector('.node[data-desc-lines~="' + line + '"]');
if(el) return el;
/* Kein DOM-Knoten: Die Zeile liegt in einem eingeklappten Teilbaum — dann
vertritt der nächste sichtbare Vorfahr sie (SPEC §9, D38-Nachtrag 4),
für die Cursor-Hervorhebung wie für den Alt+Klick. Ausgeblendete
verworfene Elemente stehen nicht in der Map und heben weiter nichts
hervor. */
const target = lineTargetMap.get(line);
return target != null && target !== line
? out.querySelector('.node[data-line="' + target + '"]')
: null;
} }
/* Text -> Diagramm: Knoten der Cursor-Zeile hervorheben (`caretLine` steht oben). */ /* Text -> Diagramm: Knoten der Cursor-Zeile hervorheben (`caretLine` steht oben). */
+25
View File
@@ -327,6 +327,31 @@ export function nodeKeys(roots){
return map; return map;
} }
/* Textzeile -> Zeile des Knotens, der sie im Diagramm VERTRITT (SPEC §9,
D38-Nachtrag 4): der Knoten selbst, solange er sichtbar ist; liegt er in
einem eingeklappten Teilbaum, der nächste sichtbare Vorfahr — der
eingeklappte Knoten vertritt seinen Teilbaum, auch für die
Cursor-Hervorhebung und den Alt+Klick aus dem Text. Beschreibungs- und
Fortsetzungszeilen (`descLines`) zählen zu ihrem Knoten. Ausgeblendete
verworfene Elemente fehlen in der Map (visibleChildren) und heben damit
weiterhin nichts hervor — die SPEC-§9-Regel bleibt. */
export function lineTargets(roots, collapsed, showDiscarded){
const map = new Map();
/* `anchor` ist null, solange wir im Sichtbaren sind; darunter die Zeile des
äußersten eingeklappten Knotens — tiefere Faltungen ändern sie nicht. */
const walk = (ns, anchor) => {
for(const n of ns){
const target = anchor ?? n.line;
if(n.line != null) map.set(n.line, target);
if(n.descLines) n.descLines.forEach(l => map.set(l, target));
const next = anchor ?? (collapsed.has(n) ? n.line : null);
walk(visibleChildren(n, showDiscarded), next);
}
};
walk(roots, null);
return map;
}
/* ---------- Faltmarken (SPEC §1/§9, D38) ---------- /* ---------- Faltmarken (SPEC §1/§9, D38) ----------
Anfangszustand der Faltung aus den Textmarken: `>` klappt den Knoten ein. Anfangszustand der Faltung aus den Textmarken: `>` klappt den Knoten ein.
`<` (und mit `rescueFocus` auch die Fokusmarke `!!!`) holt den eigenen `<` (und mit `rescueFocus` auch die Fokusmarke `!!!`) holt den eigenen
+52
View File
@@ -282,3 +282,55 @@ describe('atMostM — Auswahl für „Größe M und kleiner zuklappen"', () => {
expect(atMostM(w.children[0].children[0])).toBe(true); expect(atMostM(w.children[0].children[0])).toBe(true);
}); });
}); });
/* Zeile -> sichtbarer Vertreter (SPEC §9, D38-Nachtrag 4): Liegt der Knoten
der Cursor-Zeile in einem eingeklappten Teilbaum, vertritt ihn der nächste
sichtbare Vorfahr für die Hervorhebung wie für den Alt+Klick. */
import { lineTargets } from '../src/model.js';
describe('lineTargets — der eingeklappte Knoten vertritt seine Zeilen', () => {
const byLabel = (ns, label) => {
for(const n of ns){
if(n.label === label) return n;
const hit = byLabel(n.children, label);
if(hit) return hit;
}
return null;
};
const txt = 'A\n - B\n - C\n - D\n - [-] E\n - F';
it('sichtbare Knoten zeigen auf sich selbst', () => {
const r = roots(txt);
const map = lineTargets(r, new Set(), true);
expect(map.get(1)).toBe(1);
expect(map.get(3)).toBe(3);
});
it('Zeilen unter einem eingeklappten Knoten zeigen auf ihn', () => {
const r = roots(txt);
const map = lineTargets(r, new Set([byLabel(r, 'B')]), true);
expect(map.get(2)).toBe(2); /* der eingeklappte selbst ist sichtbar */
expect(map.get(3)).toBe(2);
expect(map.get(4)).toBe(2);
});
it('bei verschachtelter Faltung gilt der ÄUSSERSTE eingeklappte Vorfahr', () => {
const r = roots(txt);
const map = lineTargets(r, new Set([byLabel(r, 'B'), byLabel(r, 'C')]), true);
expect(map.get(4)).toBe(2);
});
it('Beschreibungs- und Fortsetzungszeilen wandern mit ihrem Knoten', () => {
const t2 = 'A\n - B mit einem \\\n langen Titel\n " Notiz';
const r = roots(t2);
const map = lineTargets(r, new Set([byLabel(r, 'A')]), true);
expect(map.get(3)).toBe(1); /* Fortsetzung */
expect(map.get(4)).toBe(1); /* "-Zeile */
});
it('ausgeblendete verworfene Elemente fehlen — sie heben weiter nichts hervor', () => {
const r = roots(txt);
expect(lineTargets(r, new Set(), false).has(6)).toBe(false);
expect(lineTargets(r, new Set(), true).get(6)).toBe(6);
});
});