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:
co-authored by
Claude Opus 5
parent
7904c82481
commit
6935b4882d
@@ -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.
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user