feat: Cursor in einer Beschreibung wählt ihren Knoten aus

Bisher fiel die Hervorhebung weg, sobald der Cursor von der Knotenzeile auf die
`"`-Zeile darunter rückte: Die trägt keinen Knoten, also fand die Zeilensuche
nichts. Falsche Auskunft — sie trägt keinen EIGENEN Knoten, gehört aber zu
einem, und wer in ihr schreibt, arbeitet an genau diesem.

Gilt für beide Formen, nicht nur die Kurzform: Die Zuordnung entsteht an
derselben Stelle im Parser, an der auch die `---`-Blöcke landen, und dort trägt
die Begründung sogar weiter — der Langtext steht am Dateiende, weit weg von
seinem Knoten. Zugeordnet werden Textzeilen, Block-Kopfzeile (sie nennt den
Knoten) und Leerzeilen innerhalb eines Blocks; nicht der `---`-Trenner und
nichts unter unbekannter ID.

`node.descLines` entsteht im Parser, der Renderer gibt sie als
`data-desc-lines="3 4 5"` aus, app.js trifft sie per `[data-desc-lines~="N"]`.
Bewusst getrennt von `descLines` (dem Text): Dort fallen Leerzeilen zu einem
Absatztrenner zusammen und Blocktext unter unbekannter ID kommt gar nicht an.

Beide Richtungen gehen jetzt über eine Auflösung (`nodeOfLine()`) — sonst wäre
der ausdrückliche Alt+Klick aus einer Beschreibung heraus stumm geblieben, und
das ist gerade der Ort, an dem man ihn braucht.

5 neue Tests (204 gesamt). SPEC §9 und D40 (Nachtrag 2) nachgezogen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-23 08:01:56 +02:00
co-authored by Claude Fable 5
parent e223734d58
commit 24a70ca49e
7 changed files with 122 additions and 13 deletions
+31
View File
@@ -2245,6 +2245,37 @@ whitespace-normalisierter Beschreibung: Ein Screenreader läse vierundzwanzig
Striche einzeln vor, und die Trennung, die ein Auge braucht, braucht ein Ohr
nicht — die Aufzählung ist dort ohnehin schon gegliedert.
**Nachtrag 2 — der Cursor in einer Beschreibung wählt ihren Knoten aus.**
Bisher fiel die Hervorhebung (D25) weg, sobald der Cursor eine Zeile
weiterrückte: Die `"`-Zeile trägt keinen Knoten, also fand die Zeilensuche
nichts. Das ist die falsche Auskunft — die Zeile trägt keinen **eigenen**
Knoten, gehört aber zu einem, und wer in ihr schreibt, arbeitet an genau
diesem Knoten.
**Beide Formen, nicht nur die Kurzform.** Gefragt war nach der `"`-Zeile; die
Zuordnung entsteht aber an derselben Stelle im Parser, an der auch die
`---`-Blöcke landen, und dieselbe Begründung trägt dort sogar weiter: Der
Langtext steht am **Dateiende**, weit weg von seinem Knoten — die
Hervorhebung ist die einzige Anzeige, an welchem man gerade schreibt.
Zugeordnet werden die eingerückten Textzeilen, die **Kopfzeile** `#auth`
(sie nennt den Knoten) und Leerzeilen **innerhalb** eines Blocks; nicht der
`---`-Trenner selbst und nichts unter einer unbekannten ID (dort gibt es
keinen Knoten, und `unknownDesc` steht schon).
**Die Zeilennummern liegen am Knoten, nicht in einer Nebenrechnung.**
`node.descLines` entsteht im Parser (`ownLine()`), der Renderer gibt sie als
`data-desc-lines="3 4 5"` aus, app.js findet den Knoten per
`[data-desc-lines~="N"]` — der Attribut-Selektor trifft die Nummer als Glied
der Liste, es braucht keine eigene Datenstruktur im UI. Bewusst **getrennt**
von `descLines` (dem Text): Dort fallen aufeinanderfolgende Leerzeilen zu
einem Absatztrenner zusammen und Blocktext unter unbekannter ID kommt gar
nicht erst an — für die Zeilenzuordnung wäre beides falsch.
Beide Richtungen gehen jetzt über **eine** Auflösung (`nodeOfLine()`): das
Mitlaufen der Cursor-Zeile und der ausdrückliche Alt+Klick (D25-Nachtrag 1).
Sonst wäre die Geste aus einer Beschreibung heraus stumm geblieben — und das
ist genau der Ort, an dem man sie braucht.
## D41 — Querverbindungen: Krümmung statt Farbe, Pfeil auf das Gebrauchte
Die Abhängigkeits-Kanten (SPEC §9) sind die erste Linienart, die nicht der
Zerlegung folgt — §11 verlangte dafür eine eigene Zeichenebene. Gebaut wie der
+9 -2
View File
@@ -577,8 +577,15 @@ verknüpft (siehe D25):
„hinschauen": Er holt **keine Bildschirmtastatur** herauf — die erscheint erst,
wenn das Textfeld selbst angetippt wird.
- **Text → Diagramm:** Der Knoten der **Cursor-Zeile** wird im Diagramm
hervorgehoben und beim Zeilenwechsel ins Bild gescrollt. Zeilen ohne Knoten
(Kommentar, Leerzeile, ausgeblendetes verworfenes Element) heben nichts
hervorgehoben und beim Zeilenwechsel ins Bild gescrollt. **Beschreibungszeilen
zählen zu ihrem Knoten**: Steht der Cursor in einer `"`-Zeile (§1) oder in
einem ID-Block des `---`-Beschreibungsteils (Kopfzeile eingeschlossen), gilt
der beschriebene Knoten als ausgewählt — solche Zeilen tragen keinen eigenen
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
`---`-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,
sodass der Knoten sich aus der Ebene hebt. Das ist der einzige Kanal, den
+4
View File
@@ -208,6 +208,10 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
Zeilenhöhe × n scheitert an weichen Umbrüchen. Ausgelöst per Alt+Klick,
Alt+Enter und langem Druck; der Klick-Handler **muss `preventDefault()`** rufen,
sonst lädt Alt+Klick auf einen Link-Knoten das Ziel herunter. Gegenrichtung:
Welcher Knoten zu einer Zeile gehört, entscheidet **eine** Stelle:
`nodeOfLine()` — erst `data-line`, sonst `data-desc-lines~=` (Cursor in einer
Beschreibung wählt ihren Knoten, D40-Nachtrag 2). Beide Richtungen gehen
darüber; wer eine neue Zeilensuche schreibt, nimmt sie ebenfalls.
`syncCaret()` setzt die Klasse `current` auf den Knoten der Cursor-Zeile;
`render()` stellt sie nach jedem Neubau wieder her (ohne zu scrollen). Die
CSS-Regel braucht den `#out`-Präfix (`ul.or .node{box-shadow:none}` ist
+14 -4
View File
@@ -947,6 +947,18 @@ out.addEventListener('touchend', e => {
out.addEventListener('touchcancel', disarmPress);
out.addEventListener('contextmenu', e => { if(pressTimer || armedEl) e.preventDefault(); });
/* Welcher Knoten gehört zu einer Textzeile? Zuerst der Knoten, DER auf dieser
Zeile steht; sonst der Knoten, dessen **Beschreibung** hier steht (SPEC §9):
Eine `"`-Zeile und die Zeilen eines `---`-Blocks tragen keinen eigenen
Knoten, gehören aber zu einem und wer darin schreibt, arbeitet an genau
diesem Knoten. `~=` trifft die Zeilennummer als Glied der Liste in
`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 + '"]')
|| out.querySelector('.node[data-desc-lines~="' + line + '"]');
}
/* Text -> Diagramm: Knoten der Cursor-Zeile hervorheben (`caretLine` steht oben). */
/* `scroll` sagt, WIE ins Bild geholt wird: 'nearest' beim gewöhnlichen
Zeilenwechsel (nur so weit wie nötig sonst ruckelte das Diagramm),
@@ -955,9 +967,7 @@ out.addEventListener('contextmenu', e => { if(pressTimer || armedEl) e.preventDe
function highlightCurrentNode(moved, scroll = 'nearest'){
markCurrentLineNo(); /* die eine Stelle, an der die Cursor-Zeile neu gesetzt wird */
if(currentNodeEl) currentNodeEl.classList.remove('current', 'pulse');
currentNodeEl = caretLine == null
? null
: out.querySelector('.node[data-line="' + caretLine + '"]');
currentNodeEl = nodeOfLine(caretLine);
if(!currentNodeEl){ drawDepLinks(); return; }
currentNodeEl.classList.add('current');
/* Die Cursor-Zeile ist die zweite Lesart von ausgewählt" ihre
@@ -995,7 +1005,7 @@ for(const ev of ['click','keyup','input','focus']) src.addEventListener(ev, sync
eine Geste, zwei Richtungen. */
function focusNodeOfCaret(){
const line = caretLineOf();
const el = out.querySelector('.node[data-line="' + line + '"]');
const el = nodeOfLine(line);
if(!el) return; /* Kommentar, Leerzeile, ausgeblendet Verworfenes */
caretLine = line;
/* Die Gegenrichtung zu revealEditor(): Auf Mobil steht der Text vorn, das
+25 -5
View File
@@ -31,6 +31,8 @@ const REALIZED = new Set(['arbeit', 'durchstich', 'fertig', 'prod']);
`desc` (SPEC §11/D40) ist der Beschreibungstext: `"`-Zeilen unter dem
Knoten (Kurzform) und ID-Blöcke aus dem `---`-Beschreibungsteil (Langform),
in Dokumentreihenfolge mit Zeilenumbrüchen zusammengefügt; null ohne.
`descLines` sind die ZEILENNUMMERN dieser Beschreibung (SPEC §9): Steht der
Cursor dort, gilt dieser Knoten als ausgewählt.
`fold` ('>'|'<'|null, SPEC §1/D38) ist nur der ANFANGSZUSTAND der Faltung
den wirksamen Zustand rechnet `initialCollapsed()` in model.js.
`deps` sind ID-Strings, keine Knoten-Referenzen aufgelöst wird erst beim
@@ -55,8 +57,20 @@ export function parse(text){
Knoten"), `descTarget` den offenen Block des Beschreibungsteils; SKIP
schluckt Blocktext unter einer unbekannten ID, ohne je Zeile zu warnen. */
const descLines = new Map();
/* Welche ZEILEN zu welchem Knoten gehören (SPEC §9): Steht der Cursor in
einer Beschreibung, gilt ihr Knoten als ausgewählt die Zeile trägt
keinen eigenen Knoten, gehört aber zu einem. Getrennt von `descLines`
gehalten, weil dort Absatztrenner zusammenfallen und Blocktext unter
unbekannter ID gar nicht erst ankommt. */
const descOwner = new Map();
const SKIP = {};
let lastNode = null, inDesc = false, descTarget = null;
const ownLine = (node, i) => {
if(!node || node === SKIP) return;
let arr = descOwner.get(node);
if(!arr) descOwner.set(node, arr = []);
arr.push(i + 1);
};
const addDesc = (node, text) => {
let arr = descLines.get(node);
if(!arr) descLines.set(node, arr = []);
@@ -72,12 +86,15 @@ export function parse(text){
if(/^[ \t]*-{3,}[ \t]*$/.test(raw)){ inDesc = true; return; }
if(inDesc){
if(!raw.trim()){
if(descTarget && descTarget !== SKIP) addDesc(descTarget, ''); /* Absatztrenner */
if(descTarget && descTarget !== SKIP){
addDesc(descTarget, ''); /* Absatztrenner */
ownLine(descTarget, i); /* die Leerzeile gehört noch zum Block */
}
return;
}
if(/^[ \t]/.test(raw)){ /* eingerückt: Blocktext */
if(descTarget == null){ warnings.push({type:'descStray', line:i+1}); return; }
if(descTarget !== SKIP) addDesc(descTarget, raw.trim());
if(descTarget !== SKIP){ addDesc(descTarget, raw.trim()); ownLine(descTarget, i); }
return;
}
/* Der trennende Doppelpunkt (siehe Knoten-ID unten) ist auch hier
@@ -94,7 +111,7 @@ export function parse(text){
}
const target = idNodes.get(idm[1]);
if(!target){ warnings.push({type:'unknownDesc', line:i+1, id:idm[1]}); descTarget = SKIP; }
else descTarget = target;
else { descTarget = target; ownLine(target, i); } /* der Block-Kopf nennt den Knoten */
return;
}
if(!raw.trim()) return;
@@ -104,7 +121,7 @@ export function parse(text){
Die Einrückung der Zeile hat keine Bedeutung. */
const ts = raw.replace(/^[ \t]*/, '');
if(ts[0] === '"' && /[ \t]/.test(ts[1] || '')){
if(lastNode) addDesc(lastNode, ts.slice(2).trim());
if(lastNode){ addDesc(lastNode, ts.slice(2).trim()); ownLine(lastNode, i); }
else warnings.push({type:'descStray', line:i+1});
return;
}
@@ -173,7 +190,7 @@ export function parse(text){
while(stack.length > 1 && stack[stack.length-1].width >= width) stack.pop();
const parent = stack[stack.length-1].node;
const node = {label, type, optional, fold, status, url, size, tags, id, deps, desc:null, focus, children:[], line:i+1};
const node = {label, type, optional, fold, status, url, size, tags, id, deps, desc:null, descLines:null, focus, children:[], line:i+1};
parent.children.push(node);
stack.push({node, width});
lastNode = node;
@@ -187,6 +204,9 @@ export function parse(text){
while(lines.length && lines[0] === '') lines.shift();
if(lines.length) node.desc = lines.join('\n');
});
/* Zeilenzuordnung der Beschreibungen (SPEC §9): unabhängig vom Text auch
ein Block, dessen Zeilen sich zu nichts zusammenfügen, gehört dem Knoten. */
descOwner.forEach((lines, node) => { node.descLines = lines; });
/* Unbekannte Abhängigkeits-IDs (SPEC §1): erst nach dem Einlesen prüfbar
Vorwärts-Referenzen sind normal. Zyklen (auch auf sich selbst) werden
+7 -2
View File
@@ -99,6 +99,11 @@ function nodeHtml(n, extra, opts, fold){
Querverbindungs-Ebene und den Export beide arbeiten auf dem DOM. */
const idAttr = n.id ? ` data-id="${attr(n.id)}"` : '';
const depsAttr = n.deps && n.deps.length ? ` data-deps="${attr(n.deps.join(' '))}"` : '';
/* Zeilen der Beschreibung (SPEC §9): Steht der Cursor dort, gilt dieser
Knoten als ausgewählt die Zeile hat keinen eigenen. Als Liste, damit
der Attribut-Selektor `~=` sie einzeln trifft. */
const descLinesAttr = n.descLines && n.descLines.length
? ` data-desc-lines="${attr(n.descLines.join(' '))}"` : '';
/* Tooltip: erst die Beschreibung (mehrzeilig, D40), dann die Kurz-Fakten.
Die Fakten hängen NICHT mit ` · ` an den Fließtext an sie sind eine
andere Art von Aussage, und in der einen Zeile ging der Übergang unter
@@ -156,8 +161,8 @@ function nodeHtml(n, extra, opts, fold){
ownChip;
const aria = ` aria-label="${attr(nodeAria(n, opts, fold))}"`;
const html = n.url
? `<a class="${cls}" href="${attr(n.url)}" target="_blank" rel="noopener"${lineAttr}${idAttr}${depsAttr}${aria}${expanded}${title}>${inner}</a>`
: `<div class="${cls}" tabindex="0"${lineAttr}${idAttr}${depsAttr}${aria}${expanded}${title}>${inner}</div>`;
? `<a class="${cls}" href="${attr(n.url)}" target="_blank" rel="noopener"${lineAttr}${idAttr}${depsAttr}${descLinesAttr}${aria}${expanded}${title}>${inner}</a>`
: `<div class="${cls}" tabindex="0"${lineAttr}${idAttr}${depsAttr}${descLinesAttr}${aria}${expanded}${title}>${inner}</div>`;
const ghostTip = attr(t('ghostTooltip'));
const ghost = `<div class="ghost-node" aria-label="${ghostTip}" title="${ghostTip}">${esc(t('ghost'))}</div>`;
return html + (need ? ghost : '');
+32
View File
@@ -123,6 +123,38 @@ describe('Darstellung — Tooltip, ”-Marke, aria', () => {
const {html} = render(`[ ] Ohne #auth`);
expect(html.match(/title="([^"]*)"/)[1]).not.toContain('─');
});
});
/* Der Cursor in einer Beschreibung wählt ihren Knoten aus (SPEC §9): Die Zeile
trägt keinen eigenen Knoten, gehört aber zu einem. Grundlage ist
`node.descLines`; app.js findet den Knoten darüber per `data-desc-lines~=`. */
describe('Zeilenzuordnung der Beschreibung (`descLines`)', () => {
it('ordnet `"`-Zeilen dem vorangehenden Knoten zu', () => {
const [a, b] = roots(`[ ] Erster\n " Eine Zeile.\n " Noch eine.\n[ ] Zweiter`);
expect(a.descLines).toEqual([2, 3]);
expect(b.descLines).toBe(null);
});
it('ordnet den `---`-Block samt Kopfzeile und Leerzeilen zu', () => {
const [n] = roots(`[ ] Auth #auth\n---\n#auth\n Erster Absatz.\n\n Zweiter Absatz.`);
expect(n.descLines).toEqual([3, 4, 5, 6]);
});
it('führt Kurz- und Langform am selben Knoten zusammen', () => {
const [n] = roots(`[ ] Auth #auth\n " Kurz.\n---\n#auth\n Lang.`);
expect(n.descLines).toEqual([2, 4, 5]);
});
it('ordnet nichts zu, wo es keinen Knoten gibt', () => {
const {roots: r, warnings} = parse(`---\n#fehlt\n Text.`);
expect(r).toEqual([]);
expect(warnings.map(w => w.type)).toEqual(['unknownDesc']);
});
it('gibt die Zeilen als `data-desc-lines` aus', () => {
const {html} = render(`[ ] Auth\n " Kapselt Login.`);
expect(html).toContain('data-desc-lines="2"');
});
it('ändert Knoten ohne Beschreibung nicht', () => {
const {html} = render(`[ ] Ohne`);