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
+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`);