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
|
**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.
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user