feat: ID-Kurzform wird beim Doppelpunkt aufgelöst (D55-Nachtrag)

`#.kc` wird jetzt aufgelöst, sobald ein Doppelpunkt die ID abschließt —
beim Tippen des Doppelpunkts ebenso wie beim nächsten Tastendruck in
einer Zeile, die ihn schon trägt. Ohne Doppelpunkt bleibt es beim
Verlassen der Zeile.

Der Cursor allein löst weiterhin nichts aus: Angefasst wird nur die
Zeile, in der auch getippt wurde (D55/D27).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-24 14:52:29 +02:00
co-authored by Claude Opus 5
parent a60c466d82
commit 4900eb3428
6 changed files with 176 additions and 19 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ reverse.
- A warning triangle replaces the question mark as the pointer over faulty line numbers - A warning triangle replaces the question mark as the pointer over faulty line numbers
- The node window replaces the browser tooltip everywhere — at the pointer, on keyboard focus and on touch - The node window replaces the browser tooltip everywhere — at the pointer, on keyboard focus and on touch
- A `#` button in the diagram header puts node IDs in front of the titles - A `#` button in the diagram header puts node IDs in front of the titles
- Typing `#.kc` under `#prod-stage` now expands to `#prod-stage.kc` when you leave the line - Typing `#.kc` under `#prod-stage` expands to `#prod-stage.kc`, right when you close the id with a colon
- A camera button next to the history saves a snapshot on demand - A camera button next to the history saves a snapshot on demand
- Fix: the manual snapshot button confirmed without saving anything while nothing had changed yet - Fix: the manual snapshot button confirmed without saving anything while nothing had changed yet
- Warning line numbers carry their message as a tooltip - Warning line numbers carry their message as a tooltip
+60
View File
@@ -4006,6 +4006,66 @@ und Faltmarke, und dass das Ergebnis denselben Baum ergibt wie die von Hand
ausgeschriebene Fassung). Das Zusammenspiel mit dem Textfeld bleibt ausgeschriebene Fassung). Das Zusammenspiel mit dem Textfeld bleibt
Browser-Sache — die Lehre aus D54-Nachtrag 3. Browser-Sache — die Lehre aus D54-Nachtrag 3.
**Nachtrag — aufgelöst wird schon beim Doppelpunkt, nicht erst beim Verlassen
der Zeile.** Gefragt war, ob es „schon in dem Moment" gehen könnte, „wo ein `:`
geschrieben wird oder der Cursor hinter einem vorhandenen `:` in der Zeile
landet". Beides ist im Kern **derselbe** Anlass, und er ist besser als der
bisherige: **Der Doppelpunkt schließt die ID ab.** Er ist per D36 der übliche
Trenner vor dem Titel und zugleich der Anfang einer Abhängigkeitsliste
(`#.kc:#db`) — steht er da, kann sich der Name der ID durch nichts mehr ändern,
was danach getippt wird. Das Verlassen der Zeile war nie das eigentliche
Kriterium, sondern nur der späteste Zeitpunkt, zu dem man sicher sein kann.
**Der Gewinn ist nicht Bequemlichkeit, sondern Nachprüfbarkeit.** Aufgelöst
wird gegen den **Baum**-Vorfahren, und der ist nach D48 nicht immer der, dessen
Namen das gepunktete Präfix trägt — im eigenen Plan bei **50 von 181 IDs
(28 %)** nicht (gemessen oben). Wer das Ergebnis erst zwei Zeilen später bekommt, sieht es
womöglich gar nicht mehr; wer es beim Doppelpunkt bekommt, liest es dort, wo
sein Blick ohnehin steht, und kann es sofort hinschreiben, wie er es will.
**Eine Regel, zwei Auslöser.** Der Doppelpunkt wird entweder gerade getippt
oder er steht schon da; im zweiten Fall greift es beim **nächsten
Tastendruck** in dieser Zeile. Formuliert ist es deshalb als Zustand („die
Kurzform ist abgeschlossen"), nicht als zwei Ereignisse.
**Der Cursor allein genügt nicht — bewusst gegen den Wortlaut der Frage.**
„Der Cursor landet hinter einem `:`" wäre der dritte Auslöser gewesen und
hätte die D55-Regel gebrochen, die genau hier steht: Angefasst wird nur die
Zeile, **in der auch getippt wurde**. Ohne die Einschränkung schriebe bloßes
Durchklicken ein fremdes Dokument um — es fiele damit aus dem Nachziehen
mitgelieferter Fassungen (D27) und zeigte fortan „Original wiederherstellen".
Ein Klick ist keine Absicht, ein Tastendruck ist eine.
**Umgesetzt als Vorfilter, nicht als zweite Regel-Instanz.**
`shortIdClosed(zeile)` in `parser.js` beantwortet nur die billige Frage („folgt
der Kurzform unmittelbar ein `:`?"), damit nicht bei jedem Tastendruck der
ganze Text durchgesehen wird. Ob die Zeile überhaupt einen Knoten trägt und ob
es einen Vorfahren mit ID gibt, weiß weiterhin allein `expandShortIds()`
dieselbe Haltung wie bei `initialCollapsed()` (D38-Nachtrag 2): eine Stelle
kennt die Bedeutung, alles andere fragt sie. Scheitert die Auflösung (noch kein
Vorfahr mit ID), bleibt die Zeile stehen und der nächste Tastendruck versucht
es erneut; das Verlassen der Zeile fängt es ohnehin auf.
`writeShortId()` ist aus `resolveShortId()` herausgelöst, weil der neue Weg die
Prüfung „ist das die getippte Zeile?" nicht braucht — er hängt am
`input`-Ereignis, ist also per Definition darin. Das `setTimeout(…, 0)` bleibt
für **beide** Wege nötig, und beim Doppelpunkt sogar offensichtlicher als
vorher: Der Aufruf steckt jetzt unmittelbar im `input`-Ereignis, und
`execCommand` verweigert dort den Dienst — `replaceTextUndoable` fiele auf
`src.value =` zurück und löschte die Undo-Historie (D38-Nachtrag 2, D53).
**Nachgemessen** mit echten Tastendrücken: ` - #.kc` + `:` ergibt sofort
` - #prod-stage.kc:`, die Schreibmarke steht unmittelbar hinter dem
Doppelpunkt; ` Keycloak` tippt sich normal weiter, und zweimal Rückgängig nimmt
erst den Titel, dann die Auflösung zurück (die Historie lebt also). In eine
Zeile mit fertigem `#.kc: Keycloak` **hineinzuklicken** ändert nichts; **ein**
Tastendruck darin löst auf, und das getippte Zeichen bleibt an seiner Stelle
(`Keycloa!k`). Ohne Vorfahren mit ID bleibt `#.kc: …` über mehrere
Tastendrücke stehen und wird aufgelöst, sobald es einen gibt. Zehn neue
Zusicherungen für den Vorfilter; Gegenprobe: Lockert man ihn auf „irgendwo ein
`:`", fällt genau die danach benannte, nimmt man die Punkt-Prüfung heraus,
fallen genau die drei anderen.
## D56 — `#`-Umschalter: die Knoten-ID vor dem Titel, geschrieben wie im Text ## D56 — `#`-Umschalter: die Knoten-ID vor dem Titel, geschrieben wie im Text
Die ID ist die **Adresse** eines Knotens — Ziel der Abhängigkeiten (§1), Schlüssel Die ID ist die **Adresse** eines Knotens — Ziel der Abhängigkeiten (§1), Schlüssel
der Beschreibungsblöcke, und seit D55 auch das, was man beim Tippen abkürzt. Im der Beschreibungsblöcke, und seit D55 auch das, was man beim Tippen abkürzt. Im
+14 -7
View File
@@ -813,18 +813,25 @@ ohnehin über das `aria-label`. Siehe D56.
### Kurzschreibweise der Knoten-ID beim Tippen (`#.name`) ### Kurzschreibweise der Knoten-ID beim Tippen (`#.name`)
Eine **Eingabehilfe**, keine Notation: Wer `#.kc` unter einem Knoten mit der ID Eine **Eingabehilfe**, keine Notation: Wer `#.kc` unter einem Knoten mit der ID
`#prod-stage` schreibt, findet beim **Verlassen der Zeile** `#prod-stage.kc` im `#prod-stage` schreibt, findet `#prod-stage.kc` im Text. Aufgelöst wird gegen
Text. Aufgelöst wird gegen den nächsten Vorfahren **mit** ID; gibt es keinen den nächsten Vorfahren **mit** ID; gibt es keinen oder trägt er selbst noch eine
oder trägt er selbst noch eine Kurzform, bleibt die Zeile stehen (`#.kc` ist Kurzform, bleibt die Zeile stehen (`#.kc` ist ohnehin eine gültige ID — der
ohnehin eine gültige ID — der Punkt gehört zur Zeichenmenge, §1). `#..x` wird Punkt gehört zur Zeichenmenge, §1). `#..x` wird nicht angefasst.
nicht angefasst.
**Aufgelöst wird, sobald die ID abgeschlossen ist** — also sobald ihr
unmittelbar ein **Doppelpunkt** folgt (`#.kc:`), der übliche Trenner vor dem
Titel (§1) und zugleich der Anfang einer Abhängigkeitsliste. Das gilt beim
Tippen des Doppelpunkts ebenso wie beim nächsten Tastendruck in einer Zeile,
die ihn schon trägt. Ohne Doppelpunkt bleibt es beim **Verlassen der Zeile**
spätestens dann ist die ID fertig.
Die Datei enthält danach immer die volle ID: Sie bleibt durchsuchbar und Die Datei enthält danach immer die volle ID: Sie bleibt durchsuchbar und
überlebt das Umsortieren, worauf die Abhängigkeiten `:#…` bauen. **An der überlebt das Umsortieren, worauf die Abhängigkeiten `:#…` bauen. **An der
Notation ändert sich dadurch nichts** — der Parser sieht nie eine Kurzform, Notation ändert sich dadurch nichts** — der Parser sieht nie eine Kurzform,
und `llms.md` (§13) bleibt unberührt. Angefasst wird nur die Zeile, in der auch und `llms.md` (§13) bleibt unberührt. Angefasst wird nur die Zeile, in der auch
getippt wurde; Abhängigkeiten (`:#…`) und der Beschreibungsteil hinter `---` **getippt** wurde — den Cursor hineinzusetzen genügt nicht, sonst schriebe
bleiben außen vor. Siehe D55. bloßes Durchklicken ein fremdes Dokument um; Abhängigkeiten (`:#…`) und der
Beschreibungsteil hinter `---` bleiben außen vor. Siehe D55.
### Zeilennummern im Texteditor ### Zeilennummern im Texteditor
Links neben dem Textfeld steht ein schmaler Streifen mit den **Zeilennummern** Links neben dem Textfeld steht ein schmaler Streifen mit den **Zeilennummern**
+34 -10
View File
@@ -1,5 +1,5 @@
import './style.css'; import './style.css';
import { parse, setFoldMark, expandShortIds } from './parser.js'; import { parse, setFoldMark, expandShortIds, shortIdClosed } from './parser.js';
import { computeCheapPlan, freshProdSet, initialCollapsed, nodeKeys, effectiveStatus, atMostM } from './model.js'; import { computeCheapPlan, freshProdSet, initialCollapsed, nodeKeys, effectiveStatus, atMostM } from './model.js';
import { esc, renderTreeHtml, TIP_RULE } from './render.js'; import { esc, renderTreeHtml, TIP_RULE } from './render.js';
import { formatWarning, warningText } from './warnings.js'; import { formatWarning, warningText } from './warnings.js';
@@ -1427,10 +1427,14 @@ function syncCaret(){
} }
for(const ev of ['click','keyup','input','focus']) src.addEventListener(ev, syncCaret); for(const ev of ['click','keyup','input','focus']) src.addEventListener(ev, syncCaret);
/* Kurzschreibweise der ID auflösen (D55): `#.kc` wird beim **Verlassen der /* Kurzschreibweise der ID auflösen (D55): `#.kc` wird zu `#prod-stage.kc`.
Zeile** zu `#prod-stage.kc`. Eingabehilfe, keine Notation — in der Datei Eingabehilfe, keine Notation — in der Datei steht danach die volle ID, sie
steht danach die volle ID, sie bleibt also durchsuchbar und überlebt das bleibt also durchsuchbar und überlebt das Umsortieren.
Umsortieren.
**Wann:** sobald die ID abgeschlossen ist, also sobald ihr ein Doppelpunkt
folgt (`#.kc:`) — beim Tippen des Doppelpunkts ebenso wie beim nächsten
Tastendruck in einer Zeile, die ihn schon trägt. Sonst beim **Verlassen der
Zeile**, spätestens dann ist sie fertig (D55-Nachtrag).
Angefasst wird nur die **eine** Zeile, in der auch getippt wurde. Beides ist Angefasst wird nur die **eine** Zeile, in der auch getippt wurde. Beides ist
nötig: `#.foo` ist schon heute eine gültige ID, und wer ein fremdes Dokument nötig: `#.foo` ist schon heute eine gültige ID, und wer ein fremdes Dokument
@@ -1438,13 +1442,33 @@ for(const ev of ['click','keyup','input','focus']) src.addEventListener(ev, sync
Nachziehen mitgelieferter Fassungen fallen, D27). Geschrieben wird Nachziehen mitgelieferter Fassungen fallen, D27). Geschrieben wird
undo-fähig — ein Griff daneben kostet ein Strg+Z (D53). */ undo-fähig — ein Griff daneben kostet ein Strg+Z (D53). */
let touchedLine = null; let touchedLine = null;
src.addEventListener('input', () => { touchedLine = caretLineOf(); }); src.addEventListener('input', () => {
touchedLine = caretLineOf();
/* Der Vorfilter fragt nur die Zeile unter der Schreibmarke — ob wirklich
etwas aufzulösen ist, entscheidet `expandShortIds()`. Scheitert es (noch
kein Vorfahr mit ID), bleibt `touchedLine` stehen: Der nächste Tastendruck
versucht es erneut, und das Verlassen der Zeile fängt es ohnehin auf. */
if(shortIdClosed(caretLineText())) writeShortId(touchedLine);
});
/* Die Zeile unter der Schreibmarke, ohne den ganzen Text zu zerlegen — das
liefe bei jedem Tastendruck über alle Zeilen. */
function caretLineText(){
const v = src.value, p = src.selectionStart;
const s = v.lastIndexOf('\n', p - 1) + 1;
const e = v.indexOf('\n', p);
return e === -1 ? v.slice(s) : v.slice(s, e);
}
function resolveShortId(line){ function resolveShortId(line){
if(src.readOnly || line == null || line !== touchedLine) return; if(line == null || line !== touchedLine) return;
touchedLine = null; touchedLine = null;
/* **Nicht** sofort schreiben: Der Zeilenwechsel kommt oft aus dem writeShortId(line);
`input`-Ereignis der Enter-Taste, und `execCommand` verweigert den Dienst, }
wenn es re-entrant darin aufgerufen wird. `replaceTextUndoable` fiele dann function writeShortId(line){
if(src.readOnly || line == null) return;
/* **Nicht** sofort schreiben: Beide Wege hierher hängen am
`input`-Ereignis — der Doppelpunkt unmittelbar, der Zeilenwechsel über die
Enter-Taste —, und `execCommand` verweigert den Dienst, wenn es
re-entrant darin aufgerufen wird. `replaceTextUndoable` fiele dann
auf `src.value =` zurück — und das löscht die Undo-Historie (D38-Nachtrag auf `src.value =` zurück — und das löscht die Undo-Historie (D38-Nachtrag
2). Gemessen: erstes Rückgängig ohne Wirkung, jedes weitere `false`. 2). Gemessen: erstes Rückgängig ohne Wirkung, jedes weitere `false`.
Deshalb ein Zug später, wenn das Ereignis zugestellt ist. Deshalb ein Zug später, wenn das Ereignis zugestellt ist.
+18
View File
@@ -131,6 +131,24 @@ export function expandShortIds(text){
return changed ? lines.join('\n') : text; return changed ? lines.join('\n') : text;
} }
/* Ist die Kurzform in dieser Zeile schon **abgeschlossen**? Also folgt ihr
unmittelbar ein Doppelpunkt — der übliche Trenner vor dem Titel (§1/D36) und
zugleich der Anfang einer Abhängigkeitsliste (`#.kc:#db`). Dann steht fest,
wie die ID heißt, und der Editor löst sofort auf, statt das Verlassen der
Zeile abzuwarten (D55-Nachtrag).
Bewusst nur ein **Vorfilter**: Ob die Zeile überhaupt einen Knoten trägt und
ob es einen Vorfahren mit ID gibt, weiß allein `expandShortIds()` — es bleibt
die eine Stelle, die die Regel kennt. */
export function shortIdClosed(line){
const k = line.indexOf('%%');
const head = k === -1 ? line : line.slice(0, k);
const m = head.match(RE_ID_TOKEN);
/* `.kc` ja, `..kc` nein — dieselbe Prüfung wie oben. */
if(!m || !/^\.[^.]/.test(m[2])) return false;
return head[m.index + m[0].length] === ':';
}
/* Status, die als „realisiert" zählen (XOR-Regel, SPEC §3/D35): Kosten sind /* Status, die als „realisiert" zählen (XOR-Regel, SPEC §3/D35): Kosten sind
investiert oder mehr. Absicht (`[?]`, `[ ]`, `[!]`), Ablehnung (`[-]`) und investiert oder mehr. Absicht (`[?]`, `[ ]`, `[!]`), Ablehnung (`[-]`) und
neutrale Knoten zählen nicht. */ neutrale Knoten zählen nicht. */
+49 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { expandShortIds, parse } from '../src/parser.js'; import { expandShortIds, shortIdClosed, parse } from '../src/parser.js';
/* Kurzschreibweise der Knoten-ID (D55): `#.kc` unter `#prod-stage` wird beim /* Kurzschreibweise der Knoten-ID (D55): `#.kc` unter `#prod-stage` wird beim
Verlassen der Zeile zu `#prod-stage.kc`. Eingabehilfe, keine Notation — in Verlassen der Zeile zu `#prod-stage.kc`. Eingabehilfe, keine Notation — in
@@ -115,3 +115,51 @@ describe('expandShortIds — das Ergebnis parst wie eine von Hand geschriebene I
expect(r.warnings).toEqual([]); /* kein unknownDep mehr */ expect(r.warnings).toEqual([]); /* kein unknownDep mehr */
}); });
}); });
/* Vorfilter für den frühen Zeitpunkt (D55-Nachtrag): Folgt der Kurzform
unmittelbar ein Doppelpunkt, steht fest, wie die ID heißt — der Editor löst
dann sofort auf, statt das Verlassen der Zeile abzuwarten. */
describe('shortIdClosed — die Kurzform ist mit einem Doppelpunkt abgeschlossen', () => {
it('erkennt den Trenner vor dem Titel', () => {
expect(shortIdClosed(' - #.kc: Keycloak')).toBe(true);
});
it('erkennt ihn auch am Zeilenende, noch ohne Titel', () => {
expect(shortIdClosed(' - #.kc:')).toBe(true);
});
it('zählt auch den Anfang einer Abhängigkeitsliste', () => {
expect(shortIdClosed(' - #.kc:#db')).toBe(true);
});
it('sagt ohne Doppelpunkt nein — dort wird beim Verlassen der Zeile aufgelöst', () => {
expect(shortIdClosed(' - #.kc Keycloak')).toBe(false);
});
it('verlangt ihn unmittelbar, nicht irgendwo in der Zeile', () => {
expect(shortIdClosed(' - #.kc Keycloak: Stage')).toBe(false);
});
it('lässt die volle ID in Ruhe — da gibt es nichts aufzulösen', () => {
expect(shortIdClosed(' - #prod-stage.kc: Keycloak')).toBe(false);
});
it('fasst `#..x` nicht an, wie expandShortIds auch nicht', () => {
expect(shortIdClosed(' - #..x: X')).toBe(false);
});
it('sieht nur das erste `#`-Token', () => {
expect(shortIdClosed(' - #a: siehe #.b: dort')).toBe(false);
});
it('übergeht den Kommentar', () => {
expect(shortIdClosed(' - #.kc Keycloak %% #.x: Notiz')).toBe(false);
});
it('was er meldet, löst expandShortIds auch wirklich auf', () => {
const zeile = ' - #.kc: Keycloak';
expect(shortIdClosed(zeile)).toBe(true);
expect(expandShortIds(['#prod-stage: Stage', zeile].join('\n')))
.toContain('#prod-stage.kc: Keycloak');
});
});