From 6935b4882d4ce340fd6fef414daafda93a1ef9a2 Mon Sep 17 00:00:00 2001 From: mhoennig Date: Mon, 24 Aug 2026 10:13:54 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Tab=20r=C3=BCckt=20Zeilen=20ein=20und=20?= =?UTF-8?q?zerst=C3=B6rt=20kein=20Undo=20mehr=20(D53)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zwei gemeldete Fehler, eine Zeile: src.value = value.slice(0, s) + ' ' + value.slice(eEnd); 1. Sie schneidet den markierten Bereich heraus — mehrere markierte Zeilen wurden also durch zwei Leerzeichen ERSETZT. Ohne Auswahl fügt derselbe Ausdruck nur ein, deshalb fiel es nie auf. 2. `src.value =` löscht die Undo-Historie des Textfelds vollständig (D38-Nachtrag 2 hatte das gemessen, aber nur beim Zurückschreiben der Faltung beherzigt). Betroffen war damit alles davor Getippte. Erneut gemessen, alte Zeile nachgestellt: erstes `undo` ändert nichts, zweites liefert `false` — Stapel leer. Das ist die Antwort auf „wann geht Undo kaputt": bei jedem Tab-Druck. Die übrigen drei `src.value =` laden ein anderes Dokument, dorthin gibt es nichts zurückzunehmen. Neue Regel: ohne Auswahl zwei Leerzeichen an der Marke (Shift+Tab nimmt sie zurück und zieht die Marke mit), mit Auswahl wird jede berührte ZEILE ein-/ausgerückt. Verworfen war die VS-Code-Feinregel „nur bei mehrzeiliger Auswahl einrücken": Wer eine ganze Zeile markiert, meint auch dann Einrücken, und die einfache Regel kann nie Text löschen. Geschrieben wird über execCommand('insertText') — ein Undo-Schritt. Nebenbefund mitbehoben: Tab war eine Tastenfalle (WCAG 2.1.2). Esc hebt sie für den nächsten Tastendruck auf. Nachgemessen mit echten Tastendrücken: drei markierte Zeilen, Tab -> alle drei von 2 auf 4 Leerzeichen, nichts gelöscht, Block bleibt ausgewählt; Shift+Tab zurück; tippen + Tab + zweimal undo -> erst der Einzug, dann das Getippte. Werkzeuggrenze notiert: synthetisches ctrl+z löst kein natives Undo aus, geprüft wird mit execCommand('undo'). 240 Tests grün. Co-Authored-By: Claude Opus 5 --- docs/DECISIONS.md | 73 ++++++++++++++++++++++++++++++++++++++++ frontend/CLAUDE.md | 14 ++++++++ frontend/src/app.js | 82 ++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 160 insertions(+), 9 deletions(-) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index b1ee742..eff48d5 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -3449,3 +3449,76 @@ folgerichtig auf nichts. Ein Finger kann das nicht. **Nicht im Druck** (`.nodetip` und der `.tipped`-Ring ausgeblendet) und nicht im Grafikexport — Bedienung, keine Aussage über den Plan; der Export liest ohnehin nur `#out`. + +## D53 — Tab rückt Zeilen ein, statt die Auswahl zu ersetzen — und zerstört kein Undo mehr +Zwei gemeldete Fehler, eine Zeile. Der Tab-Handler schrieb: + +```js +src.value = value.slice(0, s) + ' ' + value.slice(eEnd); +``` + +**Fehler 1: Mehrere markierte Zeilen wurden durch zwei Leerzeichen ersetzt.** +Der Ausdruck schneidet den Bereich zwischen Auswahlanfang und -ende heraus. +Ohne Auswahl (`s === eEnd`) fügt er nur ein — deshalb ist es nie aufgefallen, +solange niemand mehrere Zeilen auf einmal einrücken wollte. In einer Notation, +in der die Einrückung die **Hierarchie** ist (SPEC §2), ist das die +naheliegendste Geste überhaupt. + +**Fehler 2: Undo war danach tot.** `src.value = …` löscht die Undo-Historie +eines Textfelds vollständig — das steht seit D38-Nachtrag 2 im Projekt +(„nachgemessen: `value =` und `setRangeText` machen Strg+Z wirkungslos"), war +aber nur für das Zurückschreiben der Faltung beherzigt worden. Hier erneut +gemessen, mit der alten Zeile nachgestellt: Nach dem Schreiben ändert das +erste `undo` **nichts** (Text unverändert), das zweite liefert **`false`** — +der Stapel ist leer. Betroffen ist damit nicht nur das Einrücken selbst, +sondern **alles davor Getippte**. Das ist die Antwort auf „wann geht Undo +kaputt": bei **jedem** Tab-Druck, und sonst nirgends im laufenden Bearbeiten. +Die übrigen drei `src.value =` im Code laden ein **anderes** Dokument +(Dokumentwechsel, Wiederherstellen, Pad-Abruf) — dorthin gibt es nichts +zurückzunehmen, dort ist es richtig. + +**Die neue Regel, bewusst einfach:** + +- **Ohne Auswahl** zwei Leerzeichen an der Schreibmarke (Tab zählt in dieser + Notation als zwei, SPEC §2); **Shift+Tab** nimmt der Zeile den Einzug wieder + und zieht die Schreibmarke um dasselbe Stück mit, damit sie am selben + Zeichen stehen bleibt. +- **Mit Auswahl** wird **jede berührte Zeile** ein- bzw. ausgerückt. + +Erwogen war die Editor-übliche Feinregel „nur bei mehrzeiliger Auswahl +einrücken, sonst die Auswahl ersetzen" (so macht es VS Code). Verworfen: Wer +**eine** ganze Zeile markiert und Tab drückt, meint auch dann Einrücken — und +die einfache Regel hat die bessere Eigenschaft, dass Tab **niemals Text +löschen kann**. Der Preis ist, dass ein markiertes Wort nicht mehr durch +Leerzeichen ersetzt wird; in einem Notationseditor ist das kein Verlust. + +Nach dem Zug ist der **ganze Zeilenblock** ausgewählt, sodass wiederholtes Tab +weiter einrückt. Endet die Auswahl genau auf einem Zeilenanfang, gehört diese +Zeile **nicht** mehr dazu — sonst rückte ein Zug bis zum nächsten Zeilenbeginn +eine Zeile zu viel ein. **Leerzeilen** bekommen keinen Einzug (er wäre +unsichtbarer Weißraum), und beim Ausrücken fällt wahlweise die +Zwei-Leerzeichen-Stufe, ein Tabulator oder ein einzelnes Leerzeichen — sonst +bliebe eine ungerade Einrückung hängen. + +**Nebenbefund, mitbehoben: Tab war eine Tastenfalle.** Der Handler nahm die +Taste bedingungslos; wer nur mit der Tastatur arbeitet, kam aus dem Textfeld +nicht mehr heraus (WCAG 2.1.2 „No Keyboard Trap"). **Esc** hebt sie jetzt für +den nächsten Tastendruck auf — der übliche Ausweg. Das kollidiert nicht mit +dem Esc, das das Knoten-Fenster schließt (D52): Das hängt an `document` und +läuft weiter. + +**Nachgemessen** an einem Wegwerf-Dokument, mit **echten** Tastendrücken: +Drei markierte Zeilen, Tab → alle drei von 2 auf 4 Leerzeichen, **nichts +gelöscht**, Block bleibt ausgewählt, Fokus bleibt im Feld, Diagramm weiter +4 Knoten. Shift+Tab → zurück auf 2. Undo-Kette: tippen `(XL)`, dann Tab, dann +zweimal `undo` → erst der Einzug zurück, dann das Getippte; beide Zustände +zeichengenau wie zuvor. + +**Werkzeuggrenze, die dabei fast zu einem Fehlschluss geführt hätte:** Ein +synthetisches `ctrl+z` aus der Automatisierung löst **kein** natives Undo aus +— der Text blieb stehen, was zunächst wie „Undo weiterhin kaputt" aussah. Im +selben Moment griff `document.execCommand('undo')` einwandfrei. Geprüft wird +Undo deshalb über `execCommand('undo')`; das steht jetzt auch in +`frontend/CLAUDE.md`. Dieselbe Lehre wie D25 (synthetische `TouchEvent`s) und +D17-Nachtrag 4 (Bildschirmtastatur): Was die Umgebung stellt, stellt der +Emulator nicht. diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index cf9f608..98c3ec0 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -545,3 +545,17 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der Es schließt bei allem, was seine Aussage hinfällig macht — Tipp daneben, zweiter Tipp, Esc, ×, Diagramm-Scroll, Bereichswechsel, Sprung in den Text, `render()`. Die letzten drei sind beim Bauen zuerst vergessen worden. +- **Nie `src.value = …` während des Bearbeitens (D53).** Es löscht die + Undo-Historie des Textfelds **komplett** — nicht nur den eigenen Schritt, + sondern alles davor Getippte. Gemessen: nach so einem Schreiben ändert das + erste `undo` nichts und das zweite liefert `false`. Jede Änderung am Text des + **aktuellen** Dokuments geht deshalb über `execCommand('insertText')` + (`replaceTextUndoable()` für ganze Texte, `writeAt()` für Bereiche). + `src.value =` ist nur beim **Laden eines anderen** Dokuments richtig + (`loadActiveIntoEditor`, Dokumentwechsel, Pad-Abruf) — dorthin gibt es nichts + zurückzunehmen. +- **Undo lässt sich hier nicht per Tastendruck prüfen.** Ein synthetisches + `ctrl+z` aus der Automatisierung löst **kein** natives Undo aus (gemessen: + Text unverändert), während `document.execCommand('undo')` im selben Moment + greift. Wer Undo prüft, nimmt `execCommand('undo')` — sonst hält man eine + Werkzeuggrenze für einen Befund. Dieselbe Lehre wie D25 und D17-Nachtrag 4. diff --git a/frontend/src/app.js b/frontend/src/app.js index 6663805..31efdc5 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -673,16 +673,80 @@ async function downloadDiagramPng(){ if(png && png.blob) saveBlob(png.blob, 'werkbaum-diagramm.png'); } -/* Tab-Taste rückt ein statt den Fokus zu wechseln */ -src.addEventListener('keydown', e => { - if(e.key === 'Tab'){ - e.preventDefault(); - const {selectionStart:s, selectionEnd:eEnd, value} = src; - src.value = value.slice(0, s) + ' ' + value.slice(eEnd); - src.selectionStart = src.selectionEnd = s + 2; - render(); - saveSrc(); +/* ---------- Tab rückt ein (D53) ---------- + Einrückung IST hier die Hierarchie (SPEC §2), Tab also die häufigste Taste + nach den Buchstaben. Zwei Regeln: + + - **Ohne Auswahl** zwei Leerzeichen an der Schreibmarke (Tab zählt in dieser + Notation als zwei, SPEC §2); Shift+Tab nimmt sie der Zeile wieder weg. + - **Mit Auswahl** wird jede berührte ZEILE ein-/ausgerückt, nie die Auswahl + ersetzt. In einem Notationstext ist Einrücken praktisch immer gemeint, und + diese Regel kann nichts löschen. Danach ist der ganze Zeilenblock + ausgewählt, sodass wiederholtes Tab weiter einrückt. + + Geschrieben wird über `execCommand('insertText')` — die einzige Art, ein + Textfeld zu ändern, ohne die Rückgängig-Historie zu zerstören (D38-Nachtrag + 2, hier erneut gemessen). Das alte `src.value = …` hat sie bei JEDEM + Tastendruck gelöscht, also auch das davor Getippte. */ +const IND = ' '; +function outdentLine(l){ + if(l.startsWith(IND)) return l.slice(IND.length); + if(l.startsWith('\t') || l.startsWith(' ')) return l.slice(1); + return l; +} +function indentSelection(out){ + const v = src.value, s = src.selectionStart, e = src.selectionEnd; + const von = v.lastIndexOf('\n', s - 1) + 1; + if(s === e){ + if(!out) return writeAt(s, e, IND, s + IND.length, s + IND.length); + /* Ausrücken ohne Auswahl: der Zeile den Einzug nehmen, die Schreibmarke um + dasselbe Stück mitziehen — sie soll am selben Zeichen stehen bleiben. */ + let bisZ = v.indexOf('\n', s); + if(bisZ === -1) bisZ = v.length; + const zeile = v.slice(von, bisZ), kurz = outdentLine(zeile); + if(kurz === zeile) return false; + const weg = zeile.length - kurz.length; + const p = Math.max(von, s - weg); + return writeAt(von, bisZ, kurz, p, p); } + /* Endet die Auswahl genau auf einem Zeilenanfang, gehört diese Zeile nicht + mehr dazu — sonst rückte ein Zug bis zum nächsten Zeilenbeginn eine Zeile + zu viel ein. */ + const eAdj = e > s && e > von && v[e-1] === '\n' ? e - 1 : e; + let bis = v.indexOf('\n', eAdj); + if(bis === -1) bis = v.length; + const alt = v.slice(von, bis); + const neu = alt.split('\n') + .map(l => out ? outdentLine(l) : (l ? IND + l : l)) /* Leerzeilen bleiben leer */ + .join('\n'); + if(neu === alt) return false; + return writeAt(von, bis, neu, von, von + neu.length); +} +/* Ersetzt [von,bis) durch `ein` und setzt danach die Auswahl — undo-fähig. + `input` feuert dabei von selbst, render() und saveSrc() hängen daran. */ +function writeAt(von, bis, ein, selA, selB){ + src.setSelectionRange(von, bis); + let ok = false; + try{ ok = document.execCommand('insertText', false, ein); }catch(_){} + if(!ok){ + /* Rückfall: der richtige Text geht vor der Historie (wie D38-Nachtrag 2). */ + src.value = src.value.slice(0, von) + ein + src.value.slice(bis); + src.dispatchEvent(new Event('input', {bubbles: true})); + } + src.setSelectionRange(selA, selB); + return true; +} + +/* Tab im Textfeld ist eine Tastenfalle (WCAG 2.1.2): Wer nur die Tastatur + benutzt, käme sonst nicht mehr heraus. Esc hebt sie für den NÄCHSTEN + Tastendruck auf — der übliche Ausweg. */ +let tabEscapes = false; +src.addEventListener('keydown', e => { + if(e.key === 'Escape'){ tabEscapes = true; return; } + if(e.key !== 'Tab'){ tabEscapes = false; return; } + if(tabEscapes){ tabEscapes = false; return; } /* Fokus darf weiterwandern */ + e.preventDefault(); + indentSelection(e.shiftKey); }); src.addEventListener('input', render);