diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9c64a34..72ee12f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,7 @@ reverse. ## 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 - 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 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 4de3d42..5b1ad13 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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 ü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 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). diff --git a/docs/SPEC.md b/docs/SPEC.md index 22a332f..bdf119b 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -804,7 +804,11 @@ verknüpft (siehe D25): 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 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 hervor. Die Hervorhebung arbeitet auf einer **eigenen Achse — Tiefe**: weißer Halo + Ring in Tinte, dazu ein Schlagschatten und eine leichte Vergrößerung, diff --git a/frontend/src/app.js b/frontend/src/app.js index 6254d28..0844f11 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -1,6 +1,6 @@ import './style.css'; 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 { formatWarning, warningText } from './warnings.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. `foldByLine` ist der Zustand des letzten Renders für Klick/Tastatur. */ 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) ---------- parse -> Wurzeln filtern (verworfene) -> günstigen Pfad markieren -> @@ -117,6 +121,7 @@ function render(){ out.innerHTML = `
${esc(t('empty'))}
`; freshSet = new Set(); foldByLine = new Map(); + lineTargetMap = new Map(); } else { /* Günstigster Pfad auf der Dependency Closure (D42): scheitert die exakte 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 dass etwas zugeklappt ist. */ 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, freshSet, collapsedSet, effStatus: effectiveStatus(roots)}); @@ -1399,8 +1407,18 @@ document.querySelector('.diagram').addEventListener('scroll', closeNodeTip, {pas `data-desc-lines` (vom Renderer, gefüllt aus `node.descLines`). */ function nodeOfLine(line){ 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 + '"]'); + 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). */ diff --git a/frontend/src/model.js b/frontend/src/model.js index 2c4295e..00b2833 100644 --- a/frontend/src/model.js +++ b/frontend/src/model.js @@ -327,6 +327,31 @@ export function nodeKeys(roots){ 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) ---------- Anfangszustand der Faltung aus den Textmarken: `>` klappt den Knoten ein. `<` (und mit `rescueFocus` auch die Fokusmarke `!!!`) holt den eigenen diff --git a/frontend/tests/fold.test.js b/frontend/tests/fold.test.js index 8e5ca0a..6231d7c 100644 --- a/frontend/tests/fold.test.js +++ b/frontend/tests/fold.test.js @@ -282,3 +282,55 @@ describe('atMostM — Auswahl für „Größe M und kleiner zuklappen"', () => { 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); + }); +});