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:
co-authored by
Claude Fable 5
parent
6dfcb2b65c
commit
9cb6ec1197
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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). */
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user