fix: Tab rückt Zeilen ein und zerstört kein Undo mehr (D53)

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 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-24 10:13:54 +02:00
co-authored by Claude Opus 5
parent 7904c82481
commit 6935b4882d
3 changed files with 160 additions and 9 deletions
+73
View File
@@ -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.
+14
View File
@@ -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.
+73 -9
View File
@@ -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);