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 **Nicht im Druck** (`.nodetip` und der `.tipped`-Ring ausgeblendet) und nicht
im Grafikexport — Bedienung, keine Aussage über den Plan; der Export liest im Grafikexport — Bedienung, keine Aussage über den Plan; der Export liest
ohnehin nur `#out`. 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, Es schließt bei allem, was seine Aussage hinfällig macht — Tipp daneben,
zweiter Tipp, Esc, ×, Diagramm-Scroll, Bereichswechsel, Sprung in den Text, zweiter Tipp, Esc, ×, Diagramm-Scroll, Bereichswechsel, Sprung in den Text,
`render()`. Die letzten drei sind beim Bauen zuerst vergessen worden. `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'); if(png && png.blob) saveBlob(png.blob, 'werkbaum-diagramm.png');
} }
/* Tab-Taste rückt ein statt den Fokus zu wechseln */ /* ---------- Tab rückt ein (D53) ----------
src.addEventListener('keydown', e => { Einrückung IST hier die Hierarchie (SPEC §2), Tab also die häufigste Taste
if(e.key === 'Tab'){ nach den Buchstaben. Zwei Regeln:
e.preventDefault();
const {selectionStart:s, selectionEnd:eEnd, value} = src; - **Ohne Auswahl** zwei Leerzeichen an der Schreibmarke (Tab zählt in dieser
src.value = value.slice(0, s) + ' ' + value.slice(eEnd); Notation als zwei, SPEC §2); Shift+Tab nimmt sie der Zeile wieder weg.
src.selectionStart = src.selectionEnd = s + 2; - **Mit Auswahl** wird jede berührte ZEILE ein-/ausgerückt, nie die Auswahl
render(); ersetzt. In einem Notationstext ist Einrücken praktisch immer gemeint, und
saveSrc(); 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); src.addEventListener('input', render);