diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index fd47693..b10c4e5 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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 diff --git a/docs/SPEC.md b/docs/SPEC.md index 28cc41b..97225be 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -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 diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 19ff9b3..24f04c9 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -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 diff --git a/frontend/src/app.js b/frontend/src/app.js index f8751f4..33d9fa9 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -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 diff --git a/frontend/src/parser.js b/frontend/src/parser.js index e2328fc..95ad23a 100644 --- a/frontend/src/parser.js +++ b/frontend/src/parser.js @@ -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 diff --git a/frontend/src/render.js b/frontend/src/render.js index da07c4c..88f8472 100644 --- a/frontend/src/render.js +++ b/frontend/src/render.js @@ -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 - ? `${inner}` - : `