Zwei Knöpfe in der Editor-Titelzeile und Alt+↑/↓ verschieben den Block des Knotens der Cursor-Zeile (Zeile, \-Fortsetzungen, "-Beschreibungen, Teilbaum) vor den vorherigen bzw. hinter den nächsten Geschwisterknoten — als reine Text→Text-Regel neben setFoldMark/expandShortIds, ein Undo-Schritt über writeAt, ohne neue Editor-Komponente. Das RFC legt Blockgrenzen, Nachbarsuche, Zusammensetzen, Verdrahtung, i18n, 15 Testfälle samt Mutations-Gegenproben, Browser-Nachmessung und die nachzuziehenden Dokumente (SPEC §9, CHANGELOG, D95, Plan-Knoten #ed.move) fest. Nichts gebaut. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
36 KiB
RFC 003 — Knoten im Text verschieben: zwei Knöpfe, ein Block, ein Undo-Schritt
| Status | Entschieden, nicht gebaut (2026-09-03). Dieses Dokument ist die vollständige Bauanleitung; ein Coding-Agent soll es ohne Rückfrage umsetzen können. |
| Anlass | Nutzerwunsch: In der Editor-Titelzeile Knöpfe „nach oben“/„nach unten“, die den Knoten der Cursor-Zeile vor den vorherigen bzw. hinter den nächsten Geschwisterknoten schieben — samt "-Zeilen und \-Fortsetzungen |
| Plan-Knoten | #ed.move in docs/examples/werkbaum.werkbaum (anzulegen, §9.6) |
| Entscheidung | D95 in docs/DECISIONS.md (anzulegen, §9.5) |
| Berührt | frontend/src/parser.js (eine neue Text→Text-Funktion), frontend/src/app.js (zwei Knöpfe, Tastatur, Zustand), frontend/index.html, frontend/src/style.css (höchstens eine Regel), i18n ×9, frontend/tests/move.test.js (neu), SPEC §9 (ein Abschnitt), CHANGELOG, Plan |
| Berührt nicht | Notation (kein neues Zeichen, llms.md bleibt unberührt), Renderer, Modell, Backend, Live-Protokoll (D76), Deploy |
| Neue Technologie / Abhängigkeit | keine — reine Textoperation auf dem vorhandenen <textarea> |
1. Zusammenfassung
Der Text-Editor bekommt in seiner Titelzeile zwei Knöpfe (▲ / ▼) und die
Tastenkürzel Alt+↑ / Alt+↓. Sie verschieben den Block des
Knotens, in dem die Schreibmarke steht, vor seinen vorherigen bzw. hinter
seinen nächsten Geschwister-Block. Ein Block ist alles, was zu dem Knoten
gehört: seine Zeile, ihre \-Fortsetzungen, seine "-Beschreibungszeilen
und sein ganzer Teilbaum (jede Zeile darunter mit größerer Einrückung,
samt deren Fortsetzungen, Beschreibungen und Kommentaren). Geschrieben wird
als ein Undo-Schritt über den vorhandenen execCommand-Weg; die
Schreibmarke wandert mit ihrem Knoten mit. Am Rand der Geschwistergruppe
(kein Geschwister in dieser Richtung) sind Knopf und Taste wirkungslos, der
Knopf ist dann deaktiviert.
Das braucht keine neue Editor-Komponente (kein CodeMirror, kein Monaco —
D76 hält das als eigene Frage offen). Verschieben ist eine Text→Text-Regel,
und für genau diese Sorte gibt es im Haus drei Vorbilder: setFoldMark
(D38), expandShortIds (D55) und setStatusBox (D91-Nachtrag 8) in
parser.js, alle headless getestet, alle über app.js undo-fähig ins
Textfeld geschrieben.
2. Begriffe, wie der Code sie schon kennt
Wer das baut, sollte diese Stellen zuerst lesen:
logicalLines(text)infrontend/src/parser.js(~Zeile 77): liefert je logischer Zeile{raw, line, cont}—lineist die Nummer der ersten Textzeile,contdie Nummern der angehängten\-Fortsetzungen (SPEC §1, D59). Hinter dem----Trenner wird nicht mehr verbunden. Die Menge allercont-Nummern ist genau die Menge der Zeilen, die keine eigene Zeile sind, sondern zu der davor gehören.RE_CONT,RE_SEP(parser.js ~Zeile 60–63): Fortsetzungsmarke (Leerraum +\am Ende, Leerraum ist Pflicht) und----Trenner.- Einrückungsbreite:
m[1].replace(/\t/g, ' ').length(parser.js ~Zeile 130 und 295) — Tab zählt als zwei Leerzeichen (SPEC §2). - Beschreibungszeile (Kurzform): erstes Zeichen nach der Einrückung ist
", gefolgt von Leerraum oder Zeilenende, auf einer Zeile ohne Zerlegungszeichen (SPEC §1). Sie gehört zum vorangehenden Knoten. node.lineundnode.descLines(parser.js ~Zeile 184 und 378):lineist die Nummer der Knotenzeile,descLinesdie Nummern aller Zeilen, die zu diesem Knoten gehören, ohne einen eigenen zu tragen —"-Zeilen,\-Fortsetzungen und die Zeilen seines ID-Blocks im----Beschreibungsteil. Damit wird die Cursor-Zeile zum Knoten aufgelöst (§4.1).replaceTextUndoable(neu)infrontend/src/app.js(~Zeile 1519) undwriteAt(von, bis, ein, selA, selB)(~Zeile 1240): die beiden Schreibwege überdocument.execCommand('insertText').value =undsetRangeTextzerstören die Undo-Historie (D38-Nachtrag 2, D53) und sind tabu. Für dieses Feature istwriteAtder richtige (§5.3).viewOnly(app.js Zeile 135): Nur-Ansehen-Modus aus D94 — kein programmatischer Schreibzugriff;replaceTextUndoableprüft das selbst,writeAtnicht. Die Knöpfe müssen es selbst prüfen.caretLineOf()(app.js ~Zeile 2816) undsyncCaret()(~Zeile 2819): Cursor-Zeile lesen;syncCaretläuft beiclick,keyup,input,focusam Textfeld — dort hängt der Knopfzustand (§5.4) mit ein.updateDocButtons()(app.js ~Zeile 5333): das Vorbild für Knöpfe der Editor-Titelzeile, die je nach Lage sichtbar/deaktiviert sind.keyboardOnJump(true)(app.js ~Zeile 1448): hält auf dem Telefon die Bildschirmtastatur unten, wenn das Textfeld programmatisch fokussiert wird (D25);scrollEditorToOffset(offset)(~Zeile 1419) scrollt eine Textstelle per Spiegel-divin Sicht.- Editor-Titelzeile:
frontend/index.html~Zeile 106–137, die Gruppe<span class="standgroup">mitsaveBtn,snapAddBtn,snapBtn,reloadBtn,shareBtn. Knöpfe tragenclass="copybtn", ein Inline-SVG (24×24,stroke="currentColor" stroke-width="1.8") unddata-i18n-title/data-i18n-aria. - i18n:
const I18N = {de:{…}, en:{…}, es:{…}, fr:{…}, pl:{…}, ru:{…}, hi:{…}, zh:{…}, ja:{…}}in app.js (~Zeile 3468 ff.), Deutsch ist die Quellsprache. Die Legende endet mit<div class="hint-op">${esc(t('hint_jump'))}<br>${esc(t('hint_save'))}</div>(~Zeile 4787) — dort kommt die neue Bedienungs-Zeile dazu. Anführungszeichen in i18n-Texten nur typografisch („…“,“…”,«…»), ein gerades"bricht den Bundle, undnpm testmerkt es nicht (D91-Nachtrag 8).
3. Was ein „Knoten“ beim Verschieben umfasst
Verschoben wird der Block eines Knotens. Der Block ist eine zusammen-
hängende Folge von Textzeilen im Baumteil (vor dem ----Trenner):
- Die Knotenzeile
N(die Zeile, dienode.linenennt). - Danach jede Zeile, solange sie eine der folgenden ist:
- eine Fortsetzungszeile (steht in einer
cont-Liste vonlogicalLines()) — egal, wie sie eingerückt ist; - eine
"-Zeile (Beschreibung, Kurzform) — egal, wie sie eingerückt ist; - eine Leerzeile oder eine Zeile, die nur aus Leerraum und einem
%%-Kommentar besteht; - eine Knotenzeile mit größerer Einrückungsbreite als
N(ein Nachkomme) — samt allem, was nach diesen Regeln zu ihr gehört.
- eine Fortsetzungszeile (steht in einer
- Der Block endet vor der ersten Zeile, die nichts davon ist: die
nächste Knotenzeile mit Einrückungsbreite ≤ der von
N, der----Trenner oder das Dateiende. - Nachlaufende Leer- und Kommentar-only-Zeilen gehören nicht zum Block — sie sind der Abstand zwischen zwei Blöcken und bleiben beim Verschieben zwischen den beiden Blöcken stehen (§4.3). Leer- und Kommentarzeilen innerhalb des Blocks (zwischen zwei Nachkommen) wandern mit.
Was nicht mitwandert und nicht angefasst wird:
- Der Beschreibungsteil hinter
---: ID-Blöcke sind per ID adressiert, nicht per Position. Sie bleiben, wo sie sind. - Alles außerhalb der beiden getauschten Blöcke und ihres Abstands. Die Operation ist ein reiner Tausch zweier Zeilenfolgen; die Gesamtlänge des Textes ändert sich nicht — jedes Zeichen vor und nach dem geänderten Bereich behält seinen Offset (das macht die Schreibmarken-Korrektur trivial, §5.3).
Geschwister sind Knotenzeilen mit derselben Einrückungsbreite unter demselben Elternknoten — also ohne dass zwischen ihnen eine Knotenzeile mit kleinerer Breite steht. Wurzelknoten (Breite 0, SPEC §2) sind untereinander Geschwister; die Regel ist dieselbe.
4. Die Regel als Funktion (parser.js)
4.1 Signatur
/* Verschiebt den Block des Knotens der Zeile `line` um eine Position unter
seinen Geschwistern. `dir` ist -1 (nach oben) oder +1 (nach unten).
Liefert null, wenn nichts zu tun ist (Zeile trägt keinen Knoten, kein
Geschwister in dieser Richtung, Zeile liegt hinter `---`); sonst
{text, from, to, lines, shift}:
text — der neue Gesamttext
from — 0-basierter Index der ersten geänderten Zeile
to — 0-basierter Index der letzten geänderten Zeile (einschließlich)
lines — die neuen Zeilen des Bereichs [from, to]
shift — um wie viele Zeilen die Knotenzeile gewandert ist (negativ = hoch) */
export function moveNodeBlock(text, line, dir)
line ist 1-basiert wie überall im Projekt (node.line, Warnungen,
caretLine). Die Funktion ist rein (kein DOM, kein Zustand) und liegt
neben setFoldMark, setStatusBox und expandShortIds in parser.js.
Zusätzlich, für den Knopfzustand ohne Schreiben:
/* Sagt, ob der Knoten der Zeile `line` ein Geschwister vor (`up`) bzw.
nach (`down`) sich hat. Ohne Knoten an der Zeile: {up:false, down:false}. */
export function moveTargets(text, line)
Beide teilen sich eine interne Hilfsfunktion, die die Blockgrenzen und die
Nachbarblöcke bestimmt (§4.3); moveNodeBlock setzt danach nur noch zusammen.
4.2 Die Cursor-Zeile zum Knoten auflösen
Die Schreibmarke kann auf einer Zeile stehen, die keinen eigenen Knoten
trägt, aber zu einem gehört ("-Zeile, Fortsetzung, Zeile eines ID-Blocks
hinter ---). SPEC §9 sagt: Dann gilt dieser Knoten als ausgewählt — und
der Nutzer, der dort tippt, arbeitet an genau diesem Knoten. Dieselbe Lesart
gilt hier.
Auflösung: parse(text).roots rekursiv durchlaufen; der Knoten mit
node.line === line oder node.descLines?.includes(line) ist der gemeinte;
N = node.line. Gibt es keinen (Kommentarzeile, Leerzeile, --- selbst,
Zeile hinter --- ohne Block-Zuordnung) → null.
Achtung: parse() rechnet mit der kommentarfreien, verbundenen Fassung;
die Blockgrenzen (§4.3) werden aber auf den Rohzeilen text.split('\n')
bestimmt. Das passt zusammen, weil node.line immer die Nummer der ersten
Rohzeile ist (D59: „alles gehört zur ersten Zeile“).
4.3 Blockgrenzen und Nachbar bestimmen
Auf lines = text.split('\n') (0-basiert; Rohzeile i ist Textzeile i+1):
-
sep= Index der ersten Zeile, dieRE_SEPerfüllt (auf der Rohzeile ohne Kommentar geprüft), sonstlines.length. Alles absepist tabu. -
contSet= Menge allercont-Nummern auslogicalLines(text), umgerechnet auf 0-basierte Indizes. -
Klassifikation einer Rohzeile
i < sep:strip = lines[i].replace(/%%.*$/, '')- cont, wenn
contSet.has(i) - blank, wenn
strip.trim() === ''(deckt Leerzeile und Kommentar-only ab) - desc, wenn
/^[ \t]*"([ \t]|$)/.test(strip) - sonst node mit
width = strip.match(/^[ \t]*/)[0].replace(/\t/g, ' ').length
(Eine Zeile, die nach Zeichen und Statusbox kein Label hat, wäre laut SPEC §1 kein Knoten — für die Blockrechnung darf sie trotzdem als
nodemit ihrer Breite zählen; sie gehört dann zu dem, wo sie steht. Solche Zeilen kommen praktisch nicht vor.) -
Blockende
end(start):j = start + 1; solangej < sepund die Zeilejcont, blank, desc oder node mitwidth > width(start)ist,j++. Dannjrückwärts über blank-Zeilen zurückziehen (nachlaufende Abstände gehören nicht dazu). Ergebnis: letzter Index des Blocks (einschließlich), mindestensstartplus seine eigenen cont-Zeilen. -
Vorheriges Geschwister
prevStart(N):k = N - 1; solangek ≥ 0: ist Zeilekcont, blank oder desc →k--; ist sie node mitwidth > width(N)→k--(ein Nachkomme des Vorgängers); ist sie node mitwidth === width(N)→ gefunden,P = k; ist sie node mitwidth < width(N)→ kein Vorgänger (Elternknoten erreicht) →null. Beik < 0ebensonull. Vorsicht: Beim Rückwärtslaufen trifft man einedesc-Zeile oder einecont-Zeile, bevor man deren Knotenzeile trifft. Deshalb werden sie übersprungen — die Zuordnung ergibt sich, sobald man die Knotenzeile erreicht. Der gefundene Vorgänger-Block ist[P, end(P)]; per Konstruktion istend(P) < N. -
Nächstes Geschwister
nextStart(N):k = end(N) + 1; über blank-Zeilen vorwärts; die erste Nicht-blank-Zeile muss node mitwidth === width(N)sein →Q = k; alles andere (k ≥ sep, node mit kleinerer Breite) →null. (Größere Breite, cont oder desc können hier nicht auftreten — sie wären Teil des Blocks.) Der Nachbar-Block ist[Q, end(Q)].
4.4 Zusammensetzen
Nach oben (dir = -1, Vorgänger [P, endP], eigener Block [N, endN],
Abstand gap = lines.slice(endP + 1, N)):
neu = lines.slice(0, P)
.concat(lines.slice(N, endN + 1)) // eigener Block zuerst
.concat(gap) // Abstand bleibt zwischen beiden
.concat(lines.slice(P, endP + 1)) // dann der Vorgänger
.concat(lines.slice(endN + 1));
from = P; to = endN; shift = P - N;
Nach unten (dir = +1, Nachbar [Q, endQ], gap = lines.slice(endN + 1, Q)):
neu = lines.slice(0, N)
.concat(lines.slice(Q, endQ + 1)) // Nachbar zuerst
.concat(gap)
.concat(lines.slice(N, endN + 1)) // dann der eigene Block
.concat(lines.slice(endQ + 1));
from = N; to = endQ; shift = (endQ - Q + 1) + gap.length;
text = neu.join('\n'). Weil nur Zeilen umgestellt werden, ist
neu.length === lines.length und text.length === alt.length.
4.5 Was ausdrücklich nicht passiert
- Keine Änderung an Einrückung, Zeichen, Statusbox, Faltmarke, IDs, Tags,
Abhängigkeiten. Eine Faltmarke
>wandert mit ihrer Zeile — die Faltung bleibt am Knoten. - Kein Verschieben über die Geschwistergruppe hinaus (nicht zum Onkel, nicht in einen anderen Elternknoten). Das wäre Umhängen, nicht Verschieben, und eine eigene Entscheidung.
- Kein Verschieben, wenn die Zeile keinen Knoten trägt oder hinter
---liegt, ohne zu einem ID-Block zu gehören. - Keine Prüfung der Gates: Wer in einer gemischten Gruppe verschiebt, kann
das erste Kind wechseln und damit die Darstellung der Gruppe (SPEC §3:
Darstellung nach dem ersten Kind). Das ist dieselbe Wirkung wie beim
händischen Umsortieren;
mixedGatewarnt ohnehin.
5. Verdrahtung im Editor (app.js, index.html)
5.1 Zwei Knöpfe in der Editor-Titelzeile
In index.html innerhalb von <span class="standgroup">, vor
saveBtn (die Verschiebe-Knöpfe gehören zum Bearbeiten, Speichern & Co. zur
Ablage; so stehen sie am Anfang der Gruppe):
<!-- Knoten der Cursor-Zeile unter seinen Geschwistern verschieben (RFC 003,
D95): der ganze Block — Zeile, Fortsetzungen, "-Zeilen, Teilbaum — als
ein Undo-Schritt. Deaktiviert ohne Geschwister in der Richtung. -->
<button type="button" class="copybtn" id="moveUpBtn" data-i18n-title="moveUpTooltip" data-i18n-aria="moveUpTooltip" title="Knoten nach oben (Alt+↑)" aria-label="Knoten nach oben (Alt+↑)" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19V6"/><path d="M6 12l6-6 6 6"/></svg>
</button>
<button type="button" class="copybtn" id="moveDownBtn" data-i18n-title="moveDownTooltip" data-i18n-aria="moveDownTooltip" title="Knoten nach unten (Alt+↓)" aria-label="Knoten nach unten (Alt+↓)" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v13"/><path d="M6 12l6 6 6-6"/></svg>
</button>
Eine CSS-Regel in frontend/src/style.css neben den .copybtn-Regeln
(geprüft: es gibt bisher keine allgemeine Regel für deaktivierte Knöpfe, der
reloadBtn verlässt sich auf die Browser-Voreinstellung):
.copybtn:disabled{opacity:.45;cursor:default}
Sie gilt damit auch dem reloadBtn — gewollt, derselbe Zustand soll gleich
aussehen. Auf dem Telefon (D17, body.mobile) stehen die Knöpfe in der
Text-Titelzeile wie die übrigen; nach dem Einbau bei 375 px nachmessen, dass
die Zeile einreihig bleibt (Vorbild D17-Nachtrag 5 / D50 — die Umbruch-
Schwelle liegt bei 440 px; die Editor-Titelzeile hat weniger Elemente als
die Diagramm-Titelzeile und war bisher nie knapp).
5.2 Tastatur
Am Textfeld, neben den vorhandenen keydown-Handlern (~Zeile 2915):
/* Alt+↑ / Alt+↓ verschieben den Knoten der Cursor-Zeile (RFC 003). Ohne
Alt gehören die Pfeile der Schreibmarke bzw. der ID-Vorschlagsliste (D63). */
src.addEventListener('keydown', e => {
if(!e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
if(e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
e.preventDefault();
moveCaretNode(e.key === 'ArrowUp' ? -1 : 1);
});
e.repeat nicht ausfiltern: gehaltenes Alt+↓ soll den Knoten
weiterschieben, jeder Schritt ist ein eigener Undo-Schritt — das ist das
Verhalten von IntelliJ und VS Code. Die ID-Vorschlagsliste (D63) fängt
ArrowUp/Down nur ohne Alt ab; kein Konflikt.
5.3 Der Schreibvorgang
function moveCaretNode(dir){
if(viewOnly) return; /* Nur-Ansehen (D94 §6.5) */
const line = caretLineOf();
const col = src.selectionStart - (src.value.lastIndexOf('\n', src.selectionStart - 1) + 1);
const r = moveNodeBlock(src.value, line, dir);
if(!r) return;
const alt = src.value;
/* Nur den geänderten Bereich ersetzen — die Zeilen [from, to]. */
const von = offsetOfLine(alt, r.from); /* Offset des Zeilenanfangs von Rohzeile from */
const bis = offsetOfLine(alt, r.to + 1) - 1; /* Ende der Zeile to, ohne deren '\n' */
const ein = r.lines.join('\n');
/* Schreibmarke: dieselbe Spalte in derselben Zeile des Blocks, der um `shift`
Zeilen gewandert ist. Zeilen, die nicht zum eigenen Block gehörten (Cursor
in einem ID-Block hinter ---), liegen außerhalb des Bereichs und behalten
ihren Offset — der Text ist gleich lang geblieben. */
const newLine = (line - 1 >= r.from && line - 1 <= r.to && istImEigenenBlock)
? line + r.shift : line;
keyboardOnJump(true); /* Telefon: Fokus ohne Tastatur (D25) */
src.focus({preventScroll: true});
const top = src.scrollTop;
writeAt(von, bis, ein, 0, 0); /* Auswahl setzen wir gleich selbst */
const pos = Math.min(offsetOfLine(src.value, newLine - 1) + col,
offsetOfLine(src.value, newLine) - 1);
src.setSelectionRange(pos, pos);
src.scrollTop = top;
scrollEditorToOffset(pos); /* nur, wenn die Zeile aus dem Bild gerückt ist */
const btn = dir < 0 ? moveUpBtn : moveDownBtn;
if(btn) flashBtn(btn);
}
Dabei ist offsetOfLine(text, idx) eine kleine Hilfsfunktion: Offset des
Anfangs der 0-basierten Rohzeile idx (idx === Zeilenzahl ⇒
text.length + 1, damit bis für die letzte Zeile stimmt). Ob es sie schon
gibt, vorher mit grep -n "function offsetOf\|lineStart" frontend/src/app.js
prüfen; sonst anlegen.
istImEigenenBlock ist wahr, wenn die Cursor-Zeile (0-basiert line-1) im
Bereich [N, endN] des eigenen Blocks lag — moveNodeBlock sollte dafür
from/to des eigenen Blocks mitliefern (im Rückgabeobjekt z. B. als
own: [N, endN]); die Signatur in §4.1 entsprechend ergänzen. Stand die
Schreibmarke im Nachbar-Block (kann sie nicht — die Cursor-Zeile bestimmt
den eigenen Block), braucht es keinen Fall.
Warum writeAt und nicht replaceTextUndoable: Letztere sucht den
kleinsten abweichenden Bereich und klemmt eine Schreibmarke innerhalb
des Bereichs an dessen Anfang — für einen Block, der gerade darin wandert,
wäre das die falsche Stelle. Hier ist der Bereich bekannt und die neue
Position berechenbar. writeAt feuert input, daran hängen render(),
saveSrc() und der Live-Push (D76) von selbst; kein zusätzlicher
Aufruf nötig. Der Rückfall in writeAt (src.value = …, wenn execCommand
scheitert) ist Absicht (D14: der richtige Zustand geht vor der Historie).
flashBtn (~Zeile 3435) gibt den kurzen Petrol-Blitz mit Haken — dafür
braucht der Knopf die beiden SVGs ic-main/ic-done wie saveBtn; oder
der Blitz entfällt, weil der Text sich sichtbar bewegt. Empfehlung:
weglassen. Die Bewegung des Textes ist Rückmeldung genug (wie beim Falten,
D38-Nachtrag 2: „die neue Farbe des Knotens ist die Rückmeldung“). Dann auch
die flashBtn-Zeile oben streichen und nur ein SVG je Knopf.
5.4 Knopfzustand
Der Zustand hängt an der Cursor-Zeile und am Text. In syncCaret() (~Zeile
2819) am Ende ergänzen:
updateMoveButtons();
und
function updateMoveButtons(){
const up = document.getElementById('moveUpBtn'), dn = document.getElementById('moveDownBtn');
if(!up || !dn) return;
const tgt = viewOnly ? {up:false, down:false} : moveTargets(src.value, caretLineOf());
up.disabled = !tgt.up;
dn.disabled = !tgt.down;
}
Zusätzlich aufrufen in loadActiveIntoEditor() (Dokumentwechsel) und dort,
wo viewOnly gesetzt wird (setViewOnly o. ä., app.js ~Zeile 144).
moveTargets parst den Text; bei jedem keyup ist das für den
mitgelieferten Plan (≈ 1000 Zeilen) im einstelligen Millisekundenbereich —
render() parst bei jedem input ohnehin. Wird es messbar, den letzten
parse()-Baum aus render() wiederverwenden statt neu zu parsen; zuerst
die einfache Fassung.
Die Klicks:
document.getElementById('moveUpBtn') ?.addEventListener('click', () => moveCaretNode(-1));
document.getElementById('moveDownBtn')?.addEventListener('click', () => moveCaretNode(1));
Der Klick nimmt dem Textfeld den Fokus — moveCaretNode holt ihn zurück
(src.focus, §5.3), damit execCommand greift und die Schreibmarke danach
sichtbar im Text steht. Auf dem Telefon hält keyboardOnJump(true) die
Bildschirmtastatur dabei unten.
5.5 i18n (Deutsch als Quelle; die übrigen sieben Sprachen übersetzt der Umsetzende)
| Schlüssel | de | en |
|---|---|---|
moveUpTooltip |
Knoten nach oben – vor den vorherigen Geschwisterknoten, samt Teilbaum (Alt+↑) | Move node up – before its previous sibling, subtree included (Alt+↑) |
moveDownTooltip |
Knoten nach unten – hinter den nächsten Geschwisterknoten, samt Teilbaum (Alt+↓) | Move node down – after its next sibling, subtree included (Alt+↓) |
hint_move |
Alt+↑/↓ verschiebt den Knoten der Cursor-Zeile unter seinen Geschwistern – mit Beschreibung und Teilbaum. | Alt+↑/↓ moves the node of the caret line among its siblings – description and subtree included. |
Die Legenden-Zeile einhängen: in der Legenden-Funktion (~Zeile 4787)
…${esc(t('hint_save'))}<br>${esc(t('hint_move'))}</div>. Alle neun
Sprachen (de en es fr pl ru hi zh ja) bekommen alle drei Schlüssel — ein
Loch in der Tabelle wird beim nächsten Durchsehen für einen Fehler gehalten
(D58-Nachtrag). Nach dem Eintragen die Syntaxprobe laufen lassen:
cd frontend && npx esbuild src/app.js --outfile=/dev/null — npm test
importiert app.js nicht (D91-Nachtrag 8).
5.6 Verhalten in besonderen Lagen
- Geteiltes Dokument (
?live=): nichts Besonderes. Der Umbau ist eine gewöhnliche Textänderung; nach 0,6 s Ruhe geht sie als Zeilen-Diff an den Server (D76/D79). Der Diff ist größer als beim Tippen (zwei Blöcke), der Server rebased ihn wie jeden anderen; bei echter Überlappung mit fremden Änderungen an denselben Zeilen kommt das Konflikt-Band. Kein Sonderfall. - Nur-Ansehen (D94 §6.5): Knöpfe deaktiviert, Taste wirkungslos
(
viewOnly-Wächter inmoveCaretNodeundupdateMoveButtons). - Faltung: Marken wandern mit ihrer Zeile; die Sitzungs-Überlagerungen
(
foldOverrides) hängen am Label-Pfad (D38) und bleiben gültig. Nichts zu tun. - Cursor in einem eingeklappten Teilbaum: Das Verschieben arbeitet auf dem Text, nicht auf dem DOM — der eingeklappte Knoten wird wie jeder andere verschoben. Der Neubau zeichnet den Zustand.
- Cursor in einem ID-Block hinter
---: Der beschriebene Knoten wird verschoben (§4.2); die Schreibmarke bleibt an Ort und Stelle (Offsets hinter dem Bereich ändern sich nicht). - Cursor auf Leer-/Kommentarzeile /
---/ ohne Knoten: Knöpfe deaktiviert, Taste tut nichts. Dieselbe stille Regel wie beim Alt+Klick (D25).
6. Tests (frontend/tests/move.test.js, neu)
Vitest, Muster wie tests/fold.test.js. Mindestens diese Fälle; jeder als
Text → erwarteter Text, zeichengenau (toBe, nicht nur „enthält“):
- Einfacher Tausch nach oben/unten zweier Blatt-Geschwister:
A\n - B\n - C→ Cursor Zeile 3,dir -1⇒A\n - C\n - B;shift === -1; nach unten von Zeile 2 ⇒ dasselbe Ergebnis,shift === 1. - Teilbaum wandert mit:
A\n - B\n - B1\n - B2\n - C→ C nach oben ⇒A\n - C\n - B\n - B1\n - B2. "-Zeilen wandern mit:A\n - B\n " Beschreibung B\n - C→ B nach unten ⇒A\n - C\n - B\n " Beschreibung B.\-Fortsetzungen wandern mit (auch wenn die Fortsetzung anders eingerückt ist):A\n - B lang \\\n weiter (L)\n - C→ C hoch ⇒A\n - C\n - B lang \\\n weiter (L).- Cursor in der Fortsetzung / in der
"-Zeile wählt den Knoten: Cursor Zeile 3 in Fall 4 (die Fortsetzung) unddir +1⇒ B wandert hinter C; Rückgabeshift === 1, undowndeckt Zeile 2–3 ab. - Abstand bleibt zwischen den Blöcken:
A\n - B\n\n - C→ C hoch ⇒A\n - C\n\n - B. Kommentar-only-Zeile im Abstand ebenso. - Kommentar-/Leerzeile innerhalb des Blocks wandert mit:
A\n - B\n - B1\n %% Notiz\n - B2\n - C→ C hoch ⇒ Notiz steht weiter zwischen B1 und B2. - Kein Geschwister: einziges Kind, erstes Kind nach oben, letztes Kind
nach unten ⇒
null;moveTargetsliefert entsprechend{up:false, down:true}usw. - Nicht über den Elternknoten hinaus:
A\n - B\n - B1\n - C→ B1 nach unten ⇒null(C ist kein Geschwister von B1). - Wurzelknoten sind untereinander Geschwister:
A\n - A1\nB\n - B1→ B hoch ⇒B\n - B1\nA\n - A1. - Beschreibungsteil bleibt stehen:
A\n - #b: B\n - #c: C\n---\n#b\n Text b\n#c\n Text c→ C hoch ⇒ Baumteil getauscht, alles ab---byte-identisch; Cursor auf Zeile 8 (Text c) verschiebt C ebenfalls (Auflösung überdescLines); Cursor auf Zeile 4 (---) ⇒null. - Faltmarke wandert mit:
A\n - [ ] > B\n - B1\n - C→ C hoch ⇒A\n - C\n - [ ] > B\n - B1. - Tab-Einrückung zählt als zwei Leerzeichen:
A\n\t- B\n - C→ C hoch ⇒A\n - C\n\t- B(B und C sind Geschwister). - Invariante: für jeden Fall
neu.length === alt.lengthund die sortierten Zeilen beider Texte sind gleich (nichts geht verloren, nichts kommt dazu). - Idempotenz-Probe: hoch und dann runter ergibt den Ausgangstext.
Gegenprobe per Mutation (Hausregel D54-Nachtrag 3): Entfernt man in
end() das Überspringen der desc-Zeilen, müssen genau Fall 3 und 5
fallen; entfernt man die Tab-Umrechnung, genau Fall 13; entfernt man das
Zurückziehen nachlaufender Leerzeilen, genau Fall 6. Das Ergebnis der
Gegenprobe gehört in den Commit-Text.
7. Browser-Nachmessung (vor dem Merge)
Dev-Server cd frontend && npm run dev, Werkbaum-Plan öffnen:
- Cursor auf
#ed.render.vert, Alt+↑ ⇒ die Zeile steht über#ed.render.horiz, Schreibmarke in derselben Spalte derselben Zeile, Diagramm zeigt die neue Reihenfolge, ein Strg+Z stellt alles her (Text und Bild). - Cursor auf
#ed.fold(hat Kinder und Faltmarken im Plan), Knopf ▼ ⇒ der ganze Block samt Kindern steht hinter#ed.lens; Beschreibungsteil unverändert (pergit diffder Textarea-Fassung oder Zeilenzahl prüfen: Zeilenzahl gleich). - Cursor in eine
\-Fortsetzung oder"-Zeile setzen ⇒ Knöpfe aktiv, Verschieben nimmt die Zeile mit. - Cursor auf eine Kommentarzeile oder in
---⇒ beide Knöpfe deaktiviert. - Erstes Kind: ▲ deaktiviert, ▼ aktiv; letztes Kind umgekehrt.
- Nur-Ansehen (zweites Fenster auf demselben lokalen Dokument, D94) ⇒ beide deaktiviert, Alt+↑ tut nichts.
- 375 px (
body.mobile): Editor-Titelzeile bleibt einreihig; Tipp auf ▲ verschiebt, ohne dass die Bildschirmtastatur hochkommt (soweit in der Emulation prüfbar — die Tastatur selbst ist Handtest, D17-Nachtrag 4). - Geteiltes Dokument gegen ein lokales Backend: Verschieben kommt beim zweiten Client an (Feed) — sofern ein Backend läuft; sonst als Handtest notieren.
Werkzeuggrenzen, die dabei zuschnappen können (alle schon bezahlt):
execCommand braucht Fensterfokus (D57, D91-Nachtrag 8) — in einer
verborgenen Automations-Fläche greift der src.value-Rückfall; synthetische
Tastendrücke kommen ggf. mit e.key === '' an (D67); document.hidden
drosselt Timer (D79).
8. Alternativen (verworfen)
- Neue Editor-Komponente (CodeMirror 6): bringt „Zeile verschieben“ mit, kostet 120 kB gzip und berührt ein Dutzend Entscheidungen (D76 misst das). Für ein Feature, das eine Text→Text-Funktion und zwei Knöpfe ist, außer Verhältnis. Bleibt eine eigene Frage.
- Verschieben per Drag & Drop im Diagramm: die stärkere Geste, aber ein anderes Feature (Umhängen zwischen Eltern eingeschlossen, Touch- Konflikt mit langem Druck D25 und Scrollen). Nicht hier.
- Nur die Cursor-Zeile verschieben (wie ein Texteditor): zerreißt in
dieser Notation Teilbäume und Beschreibungen — der Nutzerwunsch nennt
ausdrücklich
"-Zeilen und\-Fortsetzungen. Der Block ist die Einheit. - Abstand-Leerzeilen mitnehmen: dann sammelten sich Leerzeilen an einem Ende der Gruppe; als Abstand zwischen den Blöcken bleiben sie neutral.
e.repeatfiltern: ein gehaltener Pfeil soll weiterschieben — das Vorbild der IDEs; jeder Schritt bleibt ein eigener Undo-Schritt.
9. Nachziehen der Dokumente (im selben Zug, nicht am Ende)
9.1 SPEC §9 — neuer Abschnitt, hinter „Zeilennummern im Texteditor“
### Knoten verschieben (Editor-Titelzeile)
Zwei Knöpfe (▲ / ▼) in der Titelzeile des Text-Editors — Tastatur **Alt+↑**
/ **Alt+↓** im Textfeld — verschieben den Knoten der **Cursor-Zeile** vor
seinen vorherigen bzw. hinter seinen nächsten **Geschwisterknoten**.
Verschoben wird der ganze **Block**: die Zeile, ihre Fortsetzungen (`\`,
§1), ihre Beschreibungszeilen (`"`, §1) und der Teilbaum darunter, samt
Kommentar- und Leerzeilen darin; Leerzeilen **zwischen** zwei Blöcken bleiben
zwischen ihnen stehen. Steht der Cursor in einer Fortsetzungs- oder
Beschreibungszeile oder in einem ID-Block hinter `---`, gilt der beschriebene
Knoten (§9, Sprung). Der Beschreibungsteil hinter `---` wird nie angefasst —
seine Blöcke sind per ID adressiert. Über die Geschwistergruppe hinaus wird
nicht verschoben; ohne Geschwister in der Richtung ist der Knopf deaktiviert.
Die Änderung ist eine gewöhnliche, **undo-fähige** Textänderung (ein
Schritt) — in einem geteilten Dokument (`?live=`) sehen sie damit alle. Im
Nur-Ansehen-Modus (D94) ist sie gesperrt. Eine Eingabehilfe, keine Notation:
Der Parser sieht nie etwas davon, `llms.md` (§13) bleibt unberührt. Siehe D95.
9.2 CHANGELOG (## 2026-09-…, oberster Tag)
- Two buttons in the editor title bar — and Alt+↑/↓ in the text — move the node of the caret line before its previous or after its next sibling, taking its continuation lines, `"` descriptions and whole subtree along in one undo step
9.3 Legende
hint_move als dritte Zeile der Bedienungs-Zeile (§5.5).
9.4 frontend/CLAUDE.md
Falls dort eine Liste der Text→Text-Rückschreiber steht (setFoldMark,
expandShortIds, setStatusBox), moveNodeBlock ergänzen. Sonst nichts.
9.5 DECISIONS — D95 anhängen (Entwurf; beim Bauen um Messwerte ergänzen)
## D95 — Knoten im Text verschieben: Block statt Zeile, Alt+↑/↓, kein neuer Editor
Nutzerwunsch: Knöpfe in der Editor-Titelzeile, die den Knoten der
Cursor-Zeile vor den vorherigen bzw. hinter den nächsten Geschwisterknoten
schieben, samt `"`-Zeilen und `\`-Fortsetzungen. Gebaut als reine
Text→Text-Regel (`moveNodeBlock` in parser.js, neben `setFoldMark`,
`expandShortIds`, `setStatusBox`), geschrieben über `writeAt` als ein
Undo-Schritt; kein CodeMirror (D76 hält das als eigene Frage offen).
Verschoben wird der **Block** — Zeile, Fortsetzungen, Beschreibungen,
Teilbaum —, nicht die Zeile: In dieser Notation ist die Zeile keine
Einheit, der Knoten ist es. Leerzeilen zwischen Blöcken bleiben Abstand.
Nicht über die Geschwistergruppe hinaus (das wäre Umhängen, eine eigene
Entscheidung); der Beschreibungsteil hinter `---` bleibt stehen (per ID
adressiert). Tastatur Alt+↑/↓ wie in IntelliJ und VS Code; ohne Alt
gehören die Pfeile der Schreibmarke und der ID-Vorschlagsliste (D63).
`e.repeat` bewusst nicht gefiltert. Vollständige Bauanleitung, Fälle und
Alternativen: docs/rfc/003-knoten-verschieben.md.
9.6 Plan (docs/examples/werkbaum.werkbaum)
Im Baumteil unter #ed, nach der Zeile #ed.depcomplete (Zeile ≈ 88):
- [x] #ed.move: Move a node up or down among its siblings, block and all (S) %% docs/rfc/003-knoten-verschieben.md, D95
([x] beim Mergen; [^] setzt erst der Deploy, D30. Bis zum Bauen: [ ].)
Im Beschreibungsteil, alphabetisch bei den #ed.*-Blöcken:
#ed.move
Two buttons in the editor title bar and Alt+Up/Down move the node of the
caret line before its previous or after its next sibling. The whole block
travels — continuation lines, quote descriptions, the subtree — as one
undo step; the description part behind --- stays where it is.
Danach npm test — der Plan hat Snapshot-/Warnungs-Tests; 0 Warnungen
müssen bleiben (Größenprüfung D62: #ed ist XXL und warnt nie, aber die
Elterngröße einer Zwischenebene könnte kippen — dann ehrlich nachziehen wie
in D64/D91-Nachtrag 11).
10. Umsetzungsreihenfolge
moveNodeBlock/moveTargetsinparser.js+tests/move.test.js(§4, §6), Gegenprobe per Mutation.npm testgrün.- Knöpfe, Tastatur,
moveCaretNode,updateMoveButtons, i18n ×9, Legenden-Zeile (§5).npx esbuild src/app.js --outfile=/dev/null. - Browser-Nachmessung (§7), Befunde in den Commit-Text.
- SPEC §9, CHANGELOG, D95, Plan-Knoten (§9) — im selben Commit oder direkt danach, nie später.
Commit-Texte auf Deutsch, Betreff nach dem Muster der Historie
(feat(editor): …), Abschluss Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>. Direkt auf main, kein PR.
11. Revisionsgeschichte
- 2026-09-03 — erste Fassung aus dem Nutzerwunsch; als Bauanleitung für einen Coding-Agenten geschrieben. Nichts gebaut.