From 837922aaa7331e532b298c39258e379ab2e334c3 Mon Sep 17 00:00:00 2001 From: mhoennig Date: Wed, 5 Aug 2026 13:57:47 +0200 Subject: [PATCH] frontend: Zeilennummern im Texteditor + Alt+Klick auch im Text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zwei Lücken, die beim Arbeiten am Text auffallen: **Zeilennummern (D33).** Die Warnungen nennen Zeilennummern („Zeile 12: …"), das Textfeld zeigte keine — man musste abzählen. Der Streifen links vom Textfeld hebt zusätzlich die Cursor-Zeile und Zeilen mit Warnung hervor. Die Zahlen stehen auf **gemessenen** Höhen (Spiegel-`div` aus D25, ein Marker je Zeile), nicht auf „Zeilenhöhe × n": Der Text bricht weich um, eine lange Zeile belegt mehrere Bildzeilen und behält doch eine Nummer. Bei 375 px Breite brechen 134 der 143 Zeilen um — gerechnet liefe der Streifen sofort davon. Dabei fiel ein älterer Fehler im Spiegel auf: `width = src.clientWidth` bei `content-box` machte ihn um genau die 32 px Innenabstand zu breit, er brach später um als das Textfeld. Mit `box-sizing:border-box` stimmen die Umbrüche nachweislich überein (170 Bildzeilen beiderseits) — das behebt nebenbei die Zielgenauigkeit des Sprungs zu langen Zeilen (D25). **Alt+Klick im Textfeld (Nachtrag zu D25).** Die Rückrichtung lief bisher allein über die Cursor-Zeile — und die scrollt bewusst nur `nearest` und nur beim Zeilenwechsel, sonst ruckelte das Diagramm beim Tippen. Als „zeig mir das" taugt sie damit nicht. Alt+Klick im Text (Tastatur Alt+Enter) zentriert den Knoten jetzt und gibt ihm den Fokus: derselbe Modifier in beide Richtungen, und mit Fokus wird daraus ein vollständiger Hin- und Rückweg. Die Legenden-Zeile nennt beide Richtungen (kein zehnter i18n-Schlüssel), 9 Sprachen nachgezogen. SPEC §9 ergänzt, D33 angelegt, Plan-Dokument und frontend/CLAUDE.md nachgeführt. Co-Authored-By: Claude --- docs/DECISIONS.md | 82 ++++++++++++ docs/SPEC.md | 21 +++ docs/examples/example-werkbaum.werkbaum | 1 + frontend/CLAUDE.md | 25 ++++ frontend/index.html | 7 +- frontend/src/app.js | 167 +++++++++++++++++++++--- frontend/src/style.css | 32 ++++- 7 files changed, 312 insertions(+), 23 deletions(-) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 45339b5..b352ac4 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -587,6 +587,38 @@ es nur auf einem Gerät), sowie ein eigener Umschalter dafür (weiteres Bedienelement in einer engen Kopfzeile plus i18n in 9 Sprachen, für ein Verhalten, das kaum jemand umstellen will). +**Nachtrag — Alt+Klick gibt es jetzt auch im Textfeld.** Gemeldet als „das +Alt+Klick muss im Text-Editor wieder funktionieren, um den Knoten im Diagramm zu +fokussieren". Nachgesehen: Die Geste hat es dort nie gegeben — die Rückrichtung +lief bisher **allein** über die Cursor-Zeile. Das ist trotzdem kein +Missverständnis, sondern ein Befund: Die Rückrichtung fühlt sich schwächer an, +weil sie es ist. + +Der Grund steckt in ihrer eigenen Auslegung. Die Cursor-Zeile scrollt bewusst +**nur beim Zeilenwechsel** und **nur `nearest`** — beides notwendig, sonst +ruckelte das Diagramm bei jedem Tastendruck. Genau das macht sie aber als *Zeig +mir das* untauglich: Wer denselben Knoten noch einmal sucht, bekommt nichts, und +wer ihn am Bildrand hat, bekommt ihn an den Bildrand. Es fehlte also nicht die +Verknüpfung, sondern eine **ausdrückliche** Geste daneben. + +Deshalb: **Alt+Klick im Textfeld** (Tastatur **Alt+Enter**) zentriert den Knoten +der Cursor-Zeile und gibt ihm den **Tastaturfokus**. Derselbe Modifier wie in der +Gegenrichtung — eine Geste, zwei Richtungen, nichts Neues zu lernen; die +vorhandene Legenden-Zeile (`hint_jump`) nennt jetzt beide Richtungen, statt einen +zehnten i18n-Schlüssel in neun Sprachen aufzumachen. + +**Der Fokus wandert wirklich mit** (`el.focus({preventScroll:true})`, dann +bewusst `scrollIntoView({block:'center'})`). Erwogen war, nur zu scrollen und den +Cursor im Text zu lassen — weniger störend beim Tippen. Dagegen sprechen zwei +Dinge: Die Gegenrichtung nimmt den Fokus ebenso mit (in den Text hinein), und ein +Screenreader erfährt vom Scrollen nichts. Mit Fokus wird daraus ein +vollständiger Hin- und Rückweg: Alt+Klick in den Baum, dort mit Tab/Pfeil +weiter, Alt+Enter zurück in die Zeile. + +`preventDefault()` beim Tastaturweg ist Pflicht, sonst bekommt der Text einen +Umbruch. Auf einer Zeile ohne Knoten (Kommentar, Leerzeile, ausgeblendetes +Verworfenes) geschieht nichts — dieselbe stille Regel wie bei der Cursor-Zeile. + ## D26 — Legende scrollbar: eigener Container statt `
`, plus Splitter Die Legende („Agenda") im Editor-Panel war zu hoch für ihren Platz und wurde **abgeschnitten** statt scrollbar zu sein — obwohl `.hint` seit jeher @@ -1389,3 +1421,53 @@ Die Alternativen stehen als any-of-Gruppe im mitgelieferten Plan gemessenen Sackgassen als `[-]` mit dem Messergebnis im Kommentar, damit niemand sie erneut aufmacht. Der günstigste Pfad wählt dort von selbst die Rate-Limit-Anhebung — die billigste wirksame Änderung. + +## D33 — Zeilennummern im Texteditor, gemessen statt gerechnet +Die Warnungen nennen Zeilennummern („Zeile 12: unbekannter Statuscode", SPEC §4) +— und das Textfeld zeigte keine. Man musste abzählen. Der Streifen links vom +Textfeld schließt diese Lücke; sie ist umso spürbarer, je größer der Plan ist +(der mitgelieferte hat 143 Zeilen). + +**Ein eigener Kasten neben dem Textfeld, kein Markup im Text.** Ein ` + +
+ + +
diff --git a/frontend/src/app.js b/frontend/src/app.js index 1e20e21..f28edbb 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -34,6 +34,10 @@ const INITIAL = `%% Project structure – Sprint 14 const src = document.getElementById('src'); const out = document.getElementById('out'); const warnBox = document.getElementById('warn'); +/* Zeilennummern-Streifen (D33) — hier oben geholt, weil render() ihn füllt. */ +const srcWrap = document.getElementById('srcWrap'); +const lineNoBox = document.getElementById('lineNos'); +const lineNoInner = lineNoBox.firstElementChild; /* Baum-/Kostenlogik (gateOf, needsBreakdown, visibleChildren, günstigster Pfad) lebt headless in model.js, das HTML-Erzeugen in render.js. Hier bleibt @@ -44,6 +48,11 @@ let cheapPathOn = true; kann — weiter unten stünde sie dann noch in der temporalen Todeszone. */ const PAD_VIEWS = ['both', 'pad', 'text']; let padView = 'both'; +/* Cursor-Zeile (D25) — bleibt `null`, bis der Cursor das erste Mal bewegt wurde, + sonst wäre nach dem Laden ungefragt die Wurzel markiert. Hier oben deklariert + wie `padView`: der Zeilennummern-Streifen (D33) liest sie, und der hängt an + render(). */ +let caretLine = null, currentNodeEl = null; /* Warnung des ?sourceUrl-Ladens (D23) — zeilenlos und persistent, siehe render(). */ let sourceWarning = null; /* „Was ist neu?" (D28): Knoten, die gegenüber der zuletzt GESEHENEN Fassung neu @@ -90,6 +99,10 @@ function render(){ warnings = warnings.slice().sort((a, b) => (a.line || 0) - (b.line || 0)); warnBox.innerHTML = warnings.map(w => `
⚠ ${formatWarning(w, t)}
`).join(''); + /* Der Zeilennummern-Streifen zeigt genau die Zeilen an, die hier genannt + werden — deshalb hängt er an derselben Warnungsliste (D33). */ + lineNoWarn = new Set(warnings.map(w => w.line).filter(Boolean)); + renderLineNos(); applyOptStairs(); /* muss vor dem Messen laufen — es verschiebt Knoten */ alignStems(); drawCheapPath(); @@ -488,11 +501,17 @@ function lineRange(line){ an weichen Umbrüchen (lange Zeilen belegen mehrere Bildzeilen), deshalb ein unsichtbarer Spiegel mit gleicher Typografie und Breite plus Marker-Span. */ let mirrorEl = null; -function offsetTopInEditor(offset){ +function syncMirror(){ if(!mirrorEl){ mirrorEl = document.createElement('div'); mirrorEl.setAttribute('aria-hidden', 'true'); + /* `box-sizing:border-box` ist Pflicht: `src.clientWidth` **enthält** die + Innenabstände. Ohne das ist der Spiegel um genau diese 32 px breiter als + das Textfeld und bricht später um — lange Zeilen landeten dadurch zu weit + oben (fiel beim Bau der Zeilennummern auf, D33; betraf auch schon das + Scrollen beim Sprung, D25). */ mirrorEl.style.cssText = 'position:absolute;visibility:hidden;top:0;left:-9999px;' + + 'box-sizing:border-box;' + 'white-space:pre-wrap;overflow-wrap:break-word;'; document.body.appendChild(mirrorEl); } @@ -502,20 +521,104 @@ function offsetTopInEditor(offset){ mirrorEl.style[p] = cs[p]; } mirrorEl.style.width = src.clientWidth + 'px'; - mirrorEl.textContent = src.value.slice(0, offset); + return mirrorEl; +} +const ZWSP = '​'; +function offsetTopInEditor(offset){ + const m = syncMirror(); + m.textContent = src.value.slice(0, offset); const marker = document.createElement('span'); - marker.textContent = '​'; - mirrorEl.appendChild(marker); + marker.textContent = ZWSP; + m.appendChild(marker); const top = marker.offsetTop; - mirrorEl.textContent = ''; + m.textContent = ''; return top; } +/* Oberkante jeder **logischen** Zeile, im selben Koordinatensystem wie + `src.scrollTop` (die Innenabstände stecken schon drin). Ein Marker je Zeile + im selben Spiegel: einmal schreiben, dann alle `offsetTop` in einem Durchgang + lesen — sonst erzwingt jede Messung ein eigenes Neu-Layout. */ +function lineTops(){ + const m = syncMirror(); + m.textContent = ''; + const marks = []; + for(const line of src.value.split('\n')){ + const s = document.createElement('span'); + s.textContent = ZWSP; + m.appendChild(s); + marks.push(s); + m.appendChild(document.createTextNode(line + '\n')); + } + const tops = marks.map(s => s.offsetTop); + m.textContent = ''; + return tops; +} + +/* ---------- Zeilennummern (D33) ---------- + Die Warnungen nennen Zeilennummern (SPEC §4); ohne Streifen muss man sie im + Textfeld abzählen. Der Streifen ist ein eigener Kasten neben dem Textfeld — + in den Textfluss lässt sich nichts einfügen, ein `