frontend: Zeilennummern im Texteditor + Alt+Klick auch im Text

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 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-05 13:57:47 +02:00
co-authored by Claude
parent 400bdbdd35
commit 837922aaa7
7 changed files with 312 additions and 23 deletions
+82
View File
@@ -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 Bedienelement in einer engen Kopfzeile plus i18n in 9 Sprachen, für ein
Verhalten, das kaum jemand umstellen will). 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 `<details>`, plus Splitter ## D26 — Legende scrollbar: eigener Container statt `<details>`, plus Splitter
Die Legende („Agenda") im Editor-Panel war zu hoch für ihren Platz und wurde Die Legende („Agenda") im Editor-Panel war zu hoch für ihren Platz und wurde
**abgeschnitten** statt scrollbar zu sein — obwohl `.hint` seit jeher **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 gemessenen Sackgassen als `[-]` mit dem Messergebnis im Kommentar, damit niemand
sie erneut aufmacht. Der günstigste Pfad wählt dort von selbst die sie erneut aufmacht. Der günstigste Pfad wählt dort von selbst die
Rate-Limit-Anhebung — die billigste wirksame Änderung. 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 `<textarea>`
kennt keine Auszeichnung — man kann in seinen Textfluss nichts einfügen. Also
ein zweiter Kasten, der nicht selbst scrollt, sondern gegen `src.scrollTop`
verschoben wird (`translateY`). So kann er nicht auseinanderlaufen: Es gibt nur
eine Scrollposition, nicht zwei.
**Die Zahlen stehen auf gemessenen Höhen, nicht auf „Zeilenhöhe × n".** Der Text
bricht weich um; eine lange Zeile belegt mehrere Bildzeilen, behält aber **eine**
Nummer. Gerechnet liefe der Streifen deshalb schon nach der ersten langen Zeile
davon — im Test bei 375 px Breite brechen 134 der 143 Zeilen um, meist vierfach.
Gemessen wird am **Spiegel-`div`**, das es für das Scrollen beim Sprung schon
gibt (D25): ein Marker je Zeile, einmal schreiben, dann alle `offsetTop` in einem
Durchgang lesen — sonst erzwingt jede einzelne Messung ein eigenes Neu-Layout.
**Dabei fiel ein Fehler im Spiegel auf, der schon D25 betraf.** Der Spiegel bekam
`width = src.clientWidth`, war aber `content-box`: `clientWidth` **enthält** die
Innenabstände, der Spiegel war also um genau 32 px breiter als das Textfeld und
brach später um. Mit `box-sizing:border-box` stimmen die Umbrüche jetzt
nachweislich überein — Zeilen im Spiegel und Bildzeilen im Textfeld ergeben
dieselbe Gesamthöhe (gemessen: 170 Zeilen beide). Vorher war der Sprung zu einer
langen Zeile um die Höhe der übersprungenen Umbrüche daneben.
**Zwei Zeilen heben sich ab: die Cursor-Zeile und Zeilen mit Warnung** (in
`--warn`). Genau die beiden Fälle, für die man in den Streifen sieht. Die
Warnungsmenge kommt aus **derselben** Liste, die im Warnungsbereich steht —
`render()` reicht sie weiter, statt sie ein zweites Mal zu ermitteln; sonst
liefen Text und Streifen irgendwann auseinander.
**Verworfen: `wrap="off"`.** Ohne weichen Umbruch wäre jede logische Zeile genau
eine Bildzeile, die Rechnung trivial und der Spiegel überflüssig. Preis wäre
waagerechtes Scrollen — im schmalen Spiegel neben einem eingebetteten Pad (D31)
oder auf dem Telefon (D17) wäre der Text damit unlesbar. Der Umbruch ist die
wichtigere Eigenschaft.
**Kein Umschalter.** Die Kopfzeile ist eng (D17), und ein Bedienelement kostet
i18n in neun Sprachen für etwas, das keinen Zustand hat, den jemand pflegen
will. Der Streifen ist so schmal wie die Ziffern es verlangen
(`calc(<Stellen>ch + 12px)`), auf dem Telefon sind das 36 px.
**Stolperfalle bei der Prüfung:** Ein programmatisch gesetztes `scrollTop` löst
`scroll` erst im nächsten Bild aus — in einem nicht gezeichneten Tab womöglich
gar nicht. `scrollEditorToOffset()` zieht die Zahlen deshalb selbst gleich mit,
statt sich auf das Ereignis zu verlassen.
+21
View File
@@ -386,6 +386,13 @@ verknüpft (siehe D25):
hervorgehoben (weißer Halo + Ring in Tinte) und beim Zeilenwechsel ins Bild hervorgehoben (weißer Halo + Ring in Tinte) und beim Zeilenwechsel ins Bild
gescrollt. Zeilen ohne Knoten (Kommentar, Leerzeile, ausgeblendetes gescrollt. Zeilen ohne Knoten (Kommentar, Leerzeile, ausgeblendetes
verworfenes Element) heben nichts hervor. verworfenes Element) heben nichts hervor.
- **Text → Diagramm, ausdrücklich:** **Alt+Klick** im Textfeld (Tastatur:
**Alt+Enter**) holt den Knoten der Cursor-Zeile in die **Mitte** des Diagramms
und gibt ihm den Tastaturfokus — dieselbe Geste in beide Richtungen. Sie
ergänzt das Mitlaufen der Cursor-Zeile: Das scrollt bewusst nur so weit wie
nötig und nur beim Zeilenwechsel (sonst ruckelte das Diagramm beim Tippen).
Wer einen Knoten wirklich **sehen** will, sagt es mit Alt. Auf einer Zeile ohne
Knoten geschieht nichts.
- Der **einfache** Klick bleibt unverändert der Link (§6): Ein Knoten mit URL - Der **einfache** Klick bleibt unverändert der Link (§6): Ein Knoten mit URL
öffnet sie weiterhin im neuen Tab. Deshalb Alt und nicht der einfache Klick. öffnet sie weiterhin im neuen Tab. Deshalb Alt und nicht der einfache Klick.
- **Auffindbarkeit:** Solange **Alt gedrückt** ist, zeigen alle Knoten den - **Auffindbarkeit:** Solange **Alt gedrückt** ist, zeigen alle Knoten den
@@ -396,6 +403,20 @@ verknüpft (siehe D25):
- Die Hervorhebung ist eine reine Editierhilfe: nicht im Grafikexport, nicht im - Die Hervorhebung ist eine reine Editierhilfe: nicht im Grafikexport, nicht im
Druck. Druck.
### Zeilennummern im Texteditor
Links neben dem Textfeld steht ein schmaler Streifen mit den **Zeilennummern**
dieselben, die die Warnungen nennen („Zeile 12: …", §4). Ohne ihn muss man sie
im Text abzählen.
- Gezählt werden **logische** Zeilen des Notationstexts. Eine weich umbrochene
Zeile behält **eine** Nummer; die Nummer steht an ihrer ersten Bildzeile.
- Die **Cursor-Zeile** und Zeilen mit einer **Warnung** heben sich ab (Warnung in
`--warn`, `#B45309`) — genau die beiden Fälle, für die man hinsieht.
- Reine Lesehilfe: kein Bestandteil des Notationstexts, nicht im Grafikexport
und nicht im Druck (dort ist ohnehin nur das Diagramm zu sehen).
Siehe D33.
### Fokusmarke im Diagramm (`!!!`, §1) ### Fokusmarke im Diagramm (`!!!`, §1)
Ein mit `!!!` markierter Knoten trägt einen **Strahlenkranz in Petrol** Ein mit `!!!` markierter Knoten trägt einen **Strahlenkranz in Petrol**
kräftiger Ring in `--or` (`#0F766E`) plus leuchtender Schein in hellem Teal kräftiger Ring in `--or` (`#0F766E`) plus leuchtender Schein in hellem Teal
+1
View File
@@ -37,6 +37,7 @@
- [^] Switcher in the editor title bar (S) - [^] Switcher in the editor title bar (S)
- [^] Load a document from ?sourceUrl= (S) - [^] Load a document from ?sourceUrl= (S)
- [^] Jump between diagram and text (S) - [^] Jump between diagram and text (S)
- [x] Line numbers in the text editor (XS) %% the warnings name them
- [ ] Open and save .werkbaum files (S) - [ ] Open and save .werkbaum files (S)
+ [^] Show what is new since your last visit (S) + [^] Show what is new since your last visit (S)
+ [?] A pastel colour per person (S) + [?] A pastel colour per person (S)
+25
View File
@@ -208,6 +208,31 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
die Klasse `alt` an `#out` (Cursor + Ring am Knoten unter dem Zeiger) — der die Klasse `alt` an `#out` (Cursor + Ring am Knoten unter dem Zeiger) — der
`blur`-Handler ist Pflicht, sonst bleibt der Modus nach Alt+Tab hängen; dazu `blur`-Handler ist Pflicht, sonst bleibt der Modus nach Alt+Tab hängen; dazu
`jumpHint` im Knoten-Tooltip und `hint_jump` als letzte Zeile der Legende. `jumpHint` im Knoten-Tooltip und `hint_jump` als letzte Zeile der Legende.
- Alt+Klick **im Textfeld** (D25, Nachtrag): `focusNodeOfCaret()` zentriert den
Knoten der Cursor-Zeile und gibt ihm den Fokus (`focus({preventScroll:true})`
**vor** `scrollIntoView({block:'center'})` — sonst scrollt der Browser zweimal).
Das ist nicht dasselbe wie das Mitlaufen der Cursor-Zeile: `syncCaret()` scrollt
absichtlich nur `nearest` und nur beim Zeilenwechsel. Beim Tastaturweg
(Alt+Enter) ist `preventDefault()` Pflicht, sonst bekommt der Text einen
Umbruch. Die Legenden-Zeile `hint_jump` nennt **beide** Richtungen — kein
eigener i18n-Schlüssel.
- Zeilennummern (D33): `renderLineNos()` misst die Zeilenoberkanten am
Spiegel-`div` (`lineTops()`) — **nicht** `Zeilenhöhe × n`, das läuft beim
ersten weichen Umbruch davon. Der Streifen scrollt nicht selbst, sondern wird
per `translateY(-src.scrollTop)` mitgezogen (`syncLineNoScroll()`); wer
`src.scrollTop` **selbst** setzt, muss ihn gleich mitziehen — das
`scroll`-Ereignis kommt erst im nächsten Bild (`scrollEditorToOffset()` tut es).
Der Spiegel braucht `box-sizing:border-box`: `src.clientWidth` enthält die
Innenabstände, ohne das ist er 32 px zu breit und bricht später um (betraf
vorher auch schon das Scrollen beim Sprung, D25). Der Marker ist ein
Inline-Kasten und sitzt in seiner Zeilenbox mittig — die Zahlen müssen um
diesen Versatz (`drop`) nach oben, sonst stehen sie durchgehend zu tief.
Aufgerufen wird aus `render()` (dort kommt auch die Warnungsmenge her) und aus
einem `ResizeObserver` auf `#src` (Umbruch hängt an der Breite). Markup:
`#srcWrap` umschließt Streifen + Textfeld; die Pad-Regeln (D31) teilen gegen
**diese Spalte** (`.src-area.pv-both>.src-wrap`), nicht gegen das nackte
Textfeld. `caretLine` steht deshalb **oben** bei `padView` — der Streifen liest
sie und hängt an `render()` (temporale Todeszone, siehe D31).
- Touch-Langdruck (D25): Der Timer (500 ms) setzt **nur** die Klasse `armed` - Touch-Langdruck (D25): Der Timer (500 ms) setzt **nur** die Klasse `armed`
(Petrol-Ring); `jumpToLine()` läuft im `touchend`-Handler. Nicht zurück in den (Petrol-Ring); `jumpToLine()` läuft im `touchend`-Handler. Nicht zurück in den
Timer verlegen — **`focus()` aus einem Timer gilt in mobilen Browsern nicht als Timer verlegen — **`focus()` aus einem Timer gilt in mobilen Browsern nicht als
+5
View File
@@ -87,8 +87,13 @@
<div class="src-area pv-text" id="srcArea"> <div class="src-area pv-text" id="srcArea">
<iframe class="padframe" id="padFrame" title="Etherpad" hidden></iframe> <iframe class="padframe" id="padFrame" title="Etherpad" hidden></iframe>
<div class="pad-gutter" id="padGutter" role="separator" data-i18n-title="gutterTooltip" data-i18n-aria="padGutterAria" title="Ziehen zum Verschieben, Doppelklick setzt zurück" aria-label="Pad und Texteditor größenverändern" hidden></div> <div class="pad-gutter" id="padGutter" role="separator" data-i18n-title="gutterTooltip" data-i18n-aria="padGutterAria" title="Ziehen zum Verschieben, Doppelklick setzt zurück" aria-label="Pad und Texteditor größenverändern" hidden></div>
<!-- Zeilennummern + Textfeld sind eine Spalte: der Splitter oben teilt den
Rahmen gegen diese Spalte, nicht gegen das nackte Textfeld. -->
<div class="src-wrap" id="srcWrap">
<div class="lineno" id="lineNos" aria-hidden="true"><div class="lineno-inner"></div></div>
<textarea id="src" spellcheck="false" data-i18n-aria="editorTitle" aria-label="Struktur (Text)"></textarea> <textarea id="src" spellcheck="false" data-i18n-aria="editorTitle" aria-label="Struktur (Text)"></textarea>
</div> </div>
</div>
<div class="hint-gutter" id="hintGutter" role="separator" data-i18n-title="gutterTooltip" data-i18n-aria="hintGutterAria" title="Ziehen zum Verschieben, Doppelklick setzt zurück" aria-label="Editor und Legende größenverändern" hidden></div> <div class="hint-gutter" id="hintGutter" role="separator" data-i18n-title="gutterTooltip" data-i18n-aria="hintGutterAria" title="Ziehen zum Verschieben, Doppelklick setzt zurück" aria-label="Editor und Legende größenverändern" hidden></div>
<div class="agenda" id="agenda"> <div class="agenda" id="agenda">
<button type="button" class="agenda-summary" id="agendaToggle" aria-expanded="false" aria-controls="hint" data-i18n="agenda">Agenda</button> <button type="button" class="agenda-summary" id="agendaToggle" aria-expanded="false" aria-controls="hint" data-i18n="agenda">Agenda</button>
+148 -19
View File
@@ -34,6 +34,10 @@ const INITIAL = `%% Project structure Sprint 14
const src = document.getElementById('src'); const src = document.getElementById('src');
const out = document.getElementById('out'); const out = document.getElementById('out');
const warnBox = document.getElementById('warn'); 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 /* Baum-/Kostenlogik (gateOf, needsBreakdown, visibleChildren, günstigster
Pfad) lebt headless in model.js, das HTML-Erzeugen in render.js. Hier bleibt 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. */ kann weiter unten stünde sie dann noch in der temporalen Todeszone. */
const PAD_VIEWS = ['both', 'pad', 'text']; const PAD_VIEWS = ['both', 'pad', 'text'];
let padView = 'both'; 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(). */ /* Warnung des ?sourceUrl-Ladens (D23) — zeilenlos und persistent, siehe render(). */
let sourceWarning = null; let sourceWarning = null;
/* Was ist neu?" (D28): Knoten, die gegenüber der zuletzt GESEHENEN Fassung neu /* 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)); warnings = warnings.slice().sort((a, b) => (a.line || 0) - (b.line || 0));
warnBox.innerHTML = warnings.map(w => `<div>⚠ ${formatWarning(w, t)}</div>`).join(''); warnBox.innerHTML = warnings.map(w => `<div>⚠ ${formatWarning(w, t)}</div>`).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 */ applyOptStairs(); /* muss vor dem Messen laufen — es verschiebt Knoten */
alignStems(); alignStems();
drawCheapPath(); drawCheapPath();
@@ -488,11 +501,17 @@ function lineRange(line){
an weichen Umbrüchen (lange Zeilen belegen mehrere Bildzeilen), deshalb ein an weichen Umbrüchen (lange Zeilen belegen mehrere Bildzeilen), deshalb ein
unsichtbarer Spiegel mit gleicher Typografie und Breite plus Marker-Span. */ unsichtbarer Spiegel mit gleicher Typografie und Breite plus Marker-Span. */
let mirrorEl = null; let mirrorEl = null;
function offsetTopInEditor(offset){ function syncMirror(){
if(!mirrorEl){ if(!mirrorEl){
mirrorEl = document.createElement('div'); mirrorEl = document.createElement('div');
mirrorEl.setAttribute('aria-hidden', 'true'); 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;' + mirrorEl.style.cssText = 'position:absolute;visibility:hidden;top:0;left:-9999px;' +
'box-sizing:border-box;' +
'white-space:pre-wrap;overflow-wrap:break-word;'; 'white-space:pre-wrap;overflow-wrap:break-word;';
document.body.appendChild(mirrorEl); document.body.appendChild(mirrorEl);
} }
@@ -502,20 +521,104 @@ function offsetTopInEditor(offset){
mirrorEl.style[p] = cs[p]; mirrorEl.style[p] = cs[p];
} }
mirrorEl.style.width = src.clientWidth + 'px'; 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'); const marker = document.createElement('span');
marker.textContent = ''; marker.textContent = ZWSP;
mirrorEl.appendChild(marker); m.appendChild(marker);
const top = marker.offsetTop; const top = marker.offsetTop;
mirrorEl.textContent = ''; m.textContent = '';
return top; 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 `<textarea>` kennt kein
Markup. Gescrollt wird er nicht selbst, sondern gegen `src.scrollTop`
verschoben; so kann er nie auseinanderlaufen. */
let lineNoWarn = new Set();
function renderLineNos(){
const tops = lineTops();
/* Der Marker ist ein Inline-Kasten und steht in seiner Zeilenbox mittig sein
`offsetTop` liegt also ein paar Pixel unter der Zeilen**oberkante**. Die Zahl
bekommt eine eigene Zeilenbox gleicher Höhe und muss deshalb an der
Oberkante ansetzen, sonst stünde sie durchgehend zu tief. Der Versatz ist an
der ersten Zeile ablesbar: dort ist die Oberkante der obere Innenabstand. */
const drop = tops.length ? tops[0] - (parseFloat(getComputedStyle(src).paddingTop) || 0) : 0;
/* Breite nur schreiben, wenn sie sich ändert: sie ändert die Breite des
Textfelds, das weckt den ResizeObserver und der ruft wieder hierher. */
const w = 'calc(' + String(tops.length).length + 'ch + 12px)';
if(lineNoBox.style.width !== w) lineNoBox.style.width = w;
/* Vorhandene Zahlen weiterverwenden: bei jedem Tastendruck N Elemente neu zu
bauen wäre Müll für nichts die Zahlen ändern sich fast nie, nur ihre Höhe. */
const have = lineNoInner.children.length;
for(let i = have; i < tops.length; i++){
lineNoInner.appendChild(document.createElement('span'));
}
for(let i = have - 1; i >= tops.length; i--) lineNoInner.children[i].remove();
for(let i = 0; i < tops.length; i++){
const s = lineNoInner.children[i], n = i + 1;
if(s.textContent !== String(n)) s.textContent = n;
s.style.top = (tops[i] - drop) + 'px';
s.classList.toggle('warn', lineNoWarn.has(n));
}
markCurrentLineNo();
syncLineNoScroll();
}
function syncLineNoScroll(){
lineNoInner.style.transform = 'translateY(' + (-src.scrollTop) + 'px)';
}
function markCurrentLineNo(){
const cur = lineNoInner.querySelector('span.cur');
if(cur) cur.classList.remove('cur');
const s = caretLine == null ? null : lineNoInner.children[caretLine - 1];
if(s) s.classList.add('cur');
}
src.addEventListener('scroll', syncLineNoScroll);
/* Der Umbruch hängt an der Breite: Splitter, Fenster, Drehung, Tastatur. Beim
Ziehen am Splitter kämen sonst je Bild mehrere Messungen einmal je Bild
genügt. */
let lineNoPending = false;
if(window.ResizeObserver) new ResizeObserver(() => {
if(lineNoPending) return;
lineNoPending = true;
requestAnimationFrame(() => { lineNoPending = false; renderLineNos(); });
}).observe(src);
/* Nur scrollen, wenn die Zeile nicht ohnehin bequem sichtbar ist. */ /* Nur scrollen, wenn die Zeile nicht ohnehin bequem sichtbar ist. */
function scrollEditorToOffset(offset){ function scrollEditorToOffset(offset){
const top = offsetTopInEditor(offset), h = src.clientHeight; const top = offsetTopInEditor(offset), h = src.clientHeight;
if(top < src.scrollTop + 8 || top > src.scrollTop + h - 28){ if(top < src.scrollTop + 8 || top > src.scrollTop + h - 28){
src.scrollTop = Math.max(0, top - h / 2); src.scrollTop = Math.max(0, top - h / 2);
/* Das `scroll`-Ereignis kommt erst im nächsten Bild. Wer selbst scrollt,
zieht die Zeilennummern deshalb gleich mit sonst stünden sie bis dahin
um eine ganze Bildhöhe daneben. */
syncLineNoScroll();
} }
} }
@@ -623,11 +726,9 @@ out.addEventListener('touchend', e => {
out.addEventListener('touchcancel', disarmPress); out.addEventListener('touchcancel', disarmPress);
out.addEventListener('contextmenu', e => { if(pressTimer || armedEl) e.preventDefault(); }); out.addEventListener('contextmenu', e => { if(pressTimer || armedEl) e.preventDefault(); });
/* Text -> Diagramm: Knoten der Cursor-Zeile hervorheben. `caretLine` bleibt /* Text -> Diagramm: Knoten der Cursor-Zeile hervorheben (`caretLine` steht oben). */
null, bis der Cursor das erste Mal bewegt wurde sonst wäre nach dem Laden
ohne Zutun die Wurzel markiert. */
let caretLine = null, currentNodeEl = null;
function highlightCurrentNode(scroll){ function highlightCurrentNode(scroll){
markCurrentLineNo(); /* die eine Stelle, an der die Cursor-Zeile neu gesetzt wird */
if(currentNodeEl) currentNodeEl.classList.remove('current'); if(currentNodeEl) currentNodeEl.classList.remove('current');
currentNodeEl = caretLine == null currentNodeEl = caretLine == null
? null ? null
@@ -637,14 +738,41 @@ function highlightCurrentNode(scroll){
/* Nur beim Zeilenwechsel scrollen, sonst ruckelte das Diagramm beim Tippen. */ /* Nur beim Zeilenwechsel scrollen, sonst ruckelte das Diagramm beim Tippen. */
if(scroll) currentNodeEl.scrollIntoView({block:'nearest', inline:'nearest', behavior:'smooth'}); if(scroll) currentNodeEl.scrollIntoView({block:'nearest', inline:'nearest', behavior:'smooth'});
} }
function caretLineOf(){
return src.value.slice(0, src.selectionStart).split('\n').length;
}
function syncCaret(){ function syncCaret(){
const line = src.value.slice(0, src.selectionStart).split('\n').length; const line = caretLineOf();
const moved = line !== caretLine; const moved = line !== caretLine;
caretLine = line; caretLine = line;
highlightCurrentNode(moved); highlightCurrentNode(moved);
} }
for(const ev of ['click','keyup','input','focus']) src.addEventListener(ev, syncCaret); for(const ev of ['click','keyup','input','focus']) src.addEventListener(ev, syncCaret);
/* Gegenstück zum Alt+Klick am Knoten (D25, Nachtrag): Alt+Klick im Textfeld
Tastatur Alt+Enter holt den Knoten der Cursor-Zeile in die **Mitte** des
Diagramms und gibt ihm den Fokus. Der gewöhnliche Klick markiert ihn zwar
ohnehin, scrollt aber absichtlich nur `nearest` und nur beim Zeilenwechsel
(sonst ruckelte das Diagramm beim Tippen) genau das reicht nicht, wenn man
den Knoten wirklich **sehen** will. Derselbe Modifier in beide Richtungen:
eine Geste, zwei Richtungen. */
function focusNodeOfCaret(){
const line = caretLineOf();
const el = out.querySelector('.node[data-line="' + line + '"]');
if(!el) return; /* Kommentar, Leerzeile, ausgeblendet Verworfenes */
caretLine = line;
highlightCurrentNode(false);
/* Erst den Fokus (ohne eigenes Scrollen), dann bewusst zentrieren. */
el.focus({preventScroll: true});
el.scrollIntoView({block:'center', inline:'center', behavior:'smooth'});
}
src.addEventListener('click', e => { if(e.altKey) focusNodeOfCaret(); });
src.addEventListener('keydown', e => {
if(e.key !== 'Enter' || !e.altKey) return;
e.preventDefault(); /* sonst bekäme der Text einen Umbruch */
focusNodeOfCaret();
});
/* Alt-Modus sichtbar machen: solange Alt gedrückt ist, zeigt jeder Knoten den /* Alt-Modus sichtbar machen: solange Alt gedrückt ist, zeigt jeder Knoten den
Sprung-Cursor und der Knoten unter dem Zeiger einen Petrol-Ring. Das ist die Sprung-Cursor und der Knoten unter dem Zeiger einen Petrol-Ring. Das ist die
einzige Rückmeldung, die auch auf verlinkten Knoten funktioniert (dort gehört einzige Rückmeldung, die auch auf verlinkten Knoten funktioniert (dort gehört
@@ -976,7 +1104,7 @@ const I18N = {
hint_break:"Ab (M) gilt: weiter untergliedern — fehlt die Untergliederung, erscheint ein Platzhalter im Diagramm.", hint_break:"Ab (M) gilt: weiter untergliedern — fehlt die Untergliederung, erscheint ein Platzhalter im Diagramm.",
hint_comment:"Kommentare mit %% — als ganze Zeile oder am Zeilenende.", hint_comment:"Kommentare mit %% — als ganze Zeile oder am Zeilenende.",
hint_people:"Personen mit @name — erscheinen unten rechts am Knoten.", hint_people:"Personen mit @name — erscheinen unten rechts am Knoten.",
hint_jump:"Alt+Klick auf einen Knoten (mobil: langer Druck) springt zur zugehörigen Textzeile." hint_jump:"Alt+Klick auf einen Knoten (mobil: langer Druck) springt zur zugehörigen Textzeile; Alt+Klick im Text holt den Knoten ins Bild."
}, },
en: { en: {
subtitle:"Werkbaum Work Breakdown Structure / Lean Pathfinding · Project structure editor (also feature-tree & requirements)", subtitle:"Werkbaum Work Breakdown Structure / Lean Pathfinding · Project structure editor (also feature-tree & requirements)",
@@ -1040,7 +1168,7 @@ const I18N = {
hint_break:"From (M) on: break it down further — if the breakdown is missing, a placeholder appears in the diagram.", hint_break:"From (M) on: break it down further — if the breakdown is missing, a placeholder appears in the diagram.",
hint_comment:"Comments with %% — whole line or at the end of a line.", hint_comment:"Comments with %% — whole line or at the end of a line.",
hint_people:"People with @name — shown at the bottom-right of the node.", hint_people:"People with @name — shown at the bottom-right of the node.",
hint_jump:"Alt+click a node (long press on touch) jumps to its line in the text." hint_jump:"Alt+click a node (long press on touch) jumps to its line in the text; Alt+click in the text brings the node into view."
}, },
es: { es: {
subtitle:"Werkbaum EDT / Lean Pathfinding · Editor de estructura de proyectos (también árboles de características y requisitos)", subtitle:"Werkbaum EDT / Lean Pathfinding · Editor de estructura de proyectos (también árboles de características y requisitos)",
@@ -1104,7 +1232,7 @@ const I18N = {
hint_break:"A partir de (M): sigue desglosando — si falta el desglose, aparece un marcador de posición en el diagrama.", hint_break:"A partir de (M): sigue desglosando — si falta el desglose, aparece un marcador de posición en el diagrama.",
hint_comment:"Comentarios con %% — línea completa o al final de la línea.", hint_comment:"Comentarios con %% — línea completa o al final de la línea.",
hint_people:"Personas con @nombre — aparecen abajo a la derecha del nodo.", hint_people:"Personas con @nombre — aparecen abajo a la derecha del nodo.",
hint_jump:"Alt+clic en un nodo (pulsación larga en táctil) salta a su línea en el texto." hint_jump:"Alt+clic en un nodo (pulsación larga en táctil) salta a su línea en el texto; Alt+clic en el texto trae el nodo a la vista."
}, },
fr: { fr: {
subtitle:"Werkbaum WBS / Lean Pathfinding · Éditeur de structure de projet (aussi pour arbres de fonctionnalités et d'exigences)", subtitle:"Werkbaum WBS / Lean Pathfinding · Éditeur de structure de projet (aussi pour arbres de fonctionnalités et d'exigences)",
@@ -1168,7 +1296,7 @@ const I18N = {
hint_break:"À partir de (M) : décomposer davantage — si la décomposition manque, un espace réservé apparaît dans le diagramme.", hint_break:"À partir de (M) : décomposer davantage — si la décomposition manque, un espace réservé apparaît dans le diagramme.",
hint_comment:"Commentaires avec %% — ligne entière ou en fin de ligne.", hint_comment:"Commentaires avec %% — ligne entière ou en fin de ligne.",
hint_people:"Personnes avec @nom — affichées en bas à droite du nœud.", hint_people:"Personnes avec @nom — affichées en bas à droite du nœud.",
hint_jump:"Alt+clic sur un nœud (appui long sur tactile) saute à sa ligne dans le texte." hint_jump:"Alt+clic sur un nœud (appui long sur tactile) saute à sa ligne dans le texte ; Alt+clic dans le texte amène le nœud à l’écran."
}, },
pl: { pl: {
subtitle:"Werkbaum WBS / Lean Pathfinding · Edytor struktury projektów (również dla drzew funkcji i wymagań)", subtitle:"Werkbaum WBS / Lean Pathfinding · Edytor struktury projektów (również dla drzew funkcji i wymagań)",
@@ -1232,7 +1360,7 @@ const I18N = {
hint_break:"Od (M): dziel dalej — gdy brakuje podziału, w diagramie pojawia się symbol zastępczy.", hint_break:"Od (M): dziel dalej — gdy brakuje podziału, w diagramie pojawia się symbol zastępczy.",
hint_comment:"Komentarze z %% — cały wiersz lub na końcu wiersza.", hint_comment:"Komentarze z %% — cały wiersz lub na końcu wiersza.",
hint_people:"Osoby z @nazwa — pokazywane w prawym dolnym rogu węzła.", hint_people:"Osoby z @nazwa — pokazywane w prawym dolnym rogu węzła.",
hint_jump:"Alt+kliknięcie węzła (długie naciśnięcie na dotyku) przechodzi do jego wiersza w tekście." hint_jump:"Alt+kliknięcie węzła (długie naciśnięcie na dotyku) przechodzi do jego wiersza w tekście; Alt+kliknięcie w tekście pokazuje węzeł na diagramie."
}, },
ru: { ru: {
subtitle:"Werkbaum СДР / Lean Pathfinding · Редактор структуры проектов (также для деревьев функций и требований)", subtitle:"Werkbaum СДР / Lean Pathfinding · Редактор структуры проектов (также для деревьев функций и требований)",
@@ -1296,7 +1424,7 @@ const I18N = {
hint_break:"С (M): дробите дальше — если декомпозиции нет, в диаграмме появляется заполнитель.", hint_break:"С (M): дробите дальше — если декомпозиции нет, в диаграмме появляется заполнитель.",
hint_comment:"Комментарии через %% — вся строка или в конце строки.", hint_comment:"Комментарии через %% — вся строка или в конце строки.",
hint_people:"Люди через @имя — показываются справа внизу узла.", hint_people:"Люди через @имя — показываются справа внизу узла.",
hint_jump:"Alt+клик по узлу (долгое нажатие на сенсоре) переходит к его строке в тексте." hint_jump:"Alt+клик по узлу (долгое нажатие на сенсоре) переходит к его строке в тексте; Alt+клик в тексте показывает узел на диаграмме."
}, },
hi: { hi: {
subtitle:"Werkbaum WBS / Lean Pathfinding · परियोजना संरचना संपादक (फ़ीचर और रिक्वायरमेंट ट्री के लिए भी)", subtitle:"Werkbaum WBS / Lean Pathfinding · परियोजना संरचना संपादक (फ़ीचर और रिक्वायरमेंट ट्री के लिए भी)",
@@ -1360,7 +1488,7 @@ const I18N = {
hint_break:"(M) से आगे: और विभाजित करें — विभाजन न होने पर आरेख में प्लेसहोल्डर दिखता है।", hint_break:"(M) से आगे: और विभाजित करें — विभाजन न होने पर आरेख में प्लेसहोल्डर दिखता है।",
hint_comment:"%% से टिप्पणियाँ — पूरी पंक्ति या पंक्ति के अंत में।", hint_comment:"%% से टिप्पणियाँ — पूरी पंक्ति या पंक्ति के अंत में।",
hint_people:"@नाम से व्यक्ति — नोड के नीचे-दाएँ दिखते हैं।", hint_people:"@नाम से व्यक्ति — नोड के नीचे-दाएँ दिखते हैं।",
hint_jump:"किसी नोड पर Alt+क्लिक (टच पर लंबा दबाव) टेक्स्ट में उसकी पंक्ति पर ले जाता है।" hint_jump:"किसी नोड पर Alt+क्लिक (टच पर लंबा दबाव) टेक्स्ट में उसकी पंक्ति पर ले जाता है; टेक्स्ट में Alt+क्लिक उस नोड को आरेख में दिखाता है।"
}, },
zh: { zh: {
subtitle:"Werkbaum WBS / Lean Pathfinding · 项目结构编辑器(也支持功能树和需求树)", subtitle:"Werkbaum WBS / Lean Pathfinding · 项目结构编辑器(也支持功能树和需求树)",
@@ -1424,7 +1552,7 @@ const I18N = {
hint_break:"从 (M) 起:继续细分——若缺少细分,图表中会出现占位符。", hint_break:"从 (M) 起:继续细分——若缺少细分,图表中会出现占位符。",
hint_comment:"用 %% 注释——整行或行尾。", hint_comment:"用 %% 注释——整行或行尾。",
hint_people:"用 @姓名 表示人员——显示在节点右下角。", hint_people:"用 @姓名 表示人员——显示在节点右下角。",
hint_jump:"Alt+点击节点(触摸屏为长按)可跳转到文本中对应的行。" hint_jump:"Alt+点击节点(触摸屏为长按)可跳转到文本中对应的行;在文本中 Alt+点击则把该节点带入视野。"
}, },
ja: { ja: {
subtitle:"Werkbaum WBS / Lean Pathfinding · プロジェクト構造エディター(フィーチャーツリーと要件ツリーにも対応)", subtitle:"Werkbaum WBS / Lean Pathfinding · プロジェクト構造エディター(フィーチャーツリーと要件ツリーにも対応)",
@@ -1488,7 +1616,7 @@ const I18N = {
hint_break:"(M) 以上:さらに分解 — 分解がないと図にプレースホルダーが表示されます。", hint_break:"(M) 以上:さらに分解 — 分解がないと図にプレースホルダーが表示されます。",
hint_comment:"%% でコメント — 行全体または行末。", hint_comment:"%% でコメント — 行全体または行末。",
hint_people:"@名前 で担当者 — ノードの右下に表示されます。", hint_people:"@名前 で担当者 — ノードの右下に表示されます。",
hint_jump:"ノードを Alt+クリック(タッチでは長押し)すると、テキストの該当行へ移動します。" hint_jump:"ノードを Alt+クリック(タッチでは長押し)すると、テキストの該当行へ移動します。テキスト内で Alt+クリックすると、そのノードが図の中央に表示されます。"
} }
}; };
let lang = 'de'; let lang = 'de';
@@ -2203,6 +2331,7 @@ function updatePadLink(){
const isPad = !!(d && padSource && d.id === padSource.id); const isPad = !!(d && padSource && d.id === padSource.id);
src.readOnly = isPad; src.readOnly = isPad;
src.classList.toggle('readonly', isPad); src.classList.toggle('readonly', isPad);
if(srcWrap) srcWrap.classList.toggle('readonly', isPad); /* Streifen mit abtönen */
if(isPad) src.title = t('padReadonly'); else src.removeAttribute('title'); if(isPad) src.title = t('padReadonly'); else src.removeAttribute('title');
if(padLink){ if(padLink){
padLink.hidden = !isPad; padLink.hidden = !isPad;
+29 -3
View File
@@ -217,13 +217,17 @@
.src-area{display:flex;align-items:stretch;flex:1 1 auto;min-width:0;min-height:0} .src-area{display:flex;align-items:stretch;flex:1 1 auto;min-width:0;min-height:0}
.padframe{flex:1 1 auto;min-width:0;min-height:0;border:0;background:var(--card)} .padframe{flex:1 1 auto;min-width:0;min-height:0;border:0;background:var(--card)}
.padframe[hidden]{display:none} .padframe[hidden]{display:none}
.src-area.pv-both>textarea{ /* Zeilennummern + Textfeld sind eine Spalte (`.src-wrap`). Der Pad-Splitter
teilt gegen diese Spalte nicht gegen das nackte Textfeld, sonst stünde der
Zahlenstreifen außerhalb der geteilten Breite. */
.src-wrap{display:flex;align-items:stretch;flex:1 1 auto;min-width:0;min-height:0}
.src-area.pv-both>.src-wrap{
flex:0 0 var(--pcol,300px);max-width:85%; flex:0 0 var(--pcol,300px);max-width:85%;
border-left:1px dashed rgba(36,52,71,.18); border-left:1px dashed rgba(36,52,71,.18);
} }
.src-area.pv-pad>textarea{display:none} .src-area.pv-pad>.src-wrap{display:none}
.side .src-area,body.mobile .src-area{flex-direction:column} .side .src-area,body.mobile .src-area{flex-direction:column}
.side .src-area.pv-both>textarea,body.mobile .src-area.pv-both>textarea{ .side .src-area.pv-both>.src-wrap,body.mobile .src-area.pv-both>.src-wrap{
flex:0 0 var(--prow,180px);max-width:none;max-height:85%; flex:0 0 var(--prow,180px);max-width:none;max-height:85%;
border-left:0;border-top:1px dashed rgba(36,52,71,.18); border-left:0;border-top:1px dashed rgba(36,52,71,.18);
} }
@@ -459,6 +463,28 @@
erscheint die Schrift bleibt aber Tinte: hier wird gelesen, der Plantext erscheint die Schrift bleibt aber Tinte: hier wird gelesen, der Plantext
ist der Hauptinhalt und kein ausgegrautes Formularfeld. */ ist der Hauptinhalt und kein ausgegrautes Formularfeld. */
textarea.readonly{background:#F1F4F7;cursor:default} textarea.readonly{background:#F1F4F7;cursor:default}
/* Zeilennummern (D33). Die Warnungen nennen Zeilennummern ohne Streifen
muss man sie im Textfeld abzählen. Weich umbrochene Zeilen belegen mehrere
Bildzeilen, deshalb steht jede Zahl auf einer **gemessenen** Höhe
(`renderLineNos()` in app.js), nicht auf Zeilenhöhe × n". Typografie muss
mit dem Textfeld übereinstimmen, sonst wandern die Zahlen weg. */
.lineno{
position:relative;overflow:hidden;flex:0 0 auto;
background:var(--card);border-right:1px solid rgba(36,52,71,.10);
font-family:'IBM Plex Mono',monospace;font-size:.85rem;line-height:1.7;
color:var(--muted);
-webkit-user-select:none;user-select:none;
}
.src-wrap.readonly .lineno{background:#F1F4F7}
.lineno-inner{position:absolute;top:0;left:0;right:0}
.lineno-inner span{
position:absolute;right:6px;
opacity:.5;font-variant-numeric:tabular-nums;
}
/* Cursor-Zeile und Zeilen mit Warnung heben sich ab genau die beiden Fälle,
für die man im Streifen überhaupt nachsieht. */
.lineno-inner span.cur{opacity:1;color:var(--ink);font-weight:600}
.lineno-inner span.warn{opacity:1;color:var(--warn);font-weight:600}
.hint{ .hint{
padding:12px 16px;border-top:1px dashed rgba(36,52,71,.18); padding:12px 16px;border-top:1px dashed rgba(36,52,71,.18);
font-size:.8rem;color:var(--muted);line-height:1.7; font-size:.8rem;color:var(--muted);line-height:1.7;