diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f0aa761..8b1dad7 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -28,7 +28,7 @@ reverse. - 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 - 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 - Fix: the manual snapshot button confirmed without saving anything while nothing had changed yet - Warning line numbers carry their message as a tooltip diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index fe002f8..dbdf5da 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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 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 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 diff --git a/docs/SPEC.md b/docs/SPEC.md index 5267273..2e142ce 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -813,18 +813,25 @@ ohnehin über das `aria-label`. Siehe D56. ### Kurzschreibweise der Knoten-ID beim Tippen (`#.name`) Eine **Eingabehilfe**, keine Notation: Wer `#.kc` unter einem Knoten mit der ID -`#prod-stage` schreibt, findet beim **Verlassen der Zeile** `#prod-stage.kc` im -Text. Aufgelöst wird gegen den nächsten Vorfahren **mit** ID; gibt es keinen -oder trägt er selbst noch eine Kurzform, bleibt die Zeile stehen (`#.kc` ist -ohnehin eine gültige ID — der Punkt gehört zur Zeichenmenge, §1). `#..x` wird -nicht angefasst. +`#prod-stage` schreibt, findet `#prod-stage.kc` im Text. Aufgelöst wird gegen +den nächsten Vorfahren **mit** ID; gibt es keinen oder trägt er selbst noch eine +Kurzform, bleibt die Zeile stehen (`#.kc` ist ohnehin eine gültige ID — der +Punkt gehört zur Zeichenmenge, §1). `#..x` wird 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 überlebt das Umsortieren, worauf die Abhängigkeiten `:#…` bauen. **An der 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 -getippt wurde; Abhängigkeiten (`:#…`) und der Beschreibungsteil hinter `---` -bleiben außen vor. Siehe D55. +**getippt** wurde — den Cursor hineinzusetzen genügt nicht, sonst schriebe +bloßes Durchklicken ein fremdes Dokument um; Abhängigkeiten (`:#…`) und der +Beschreibungsteil hinter `---` bleiben außen vor. Siehe D55. ### Zeilennummern im Texteditor Links neben dem Textfeld steht ein schmaler Streifen mit den **Zeilennummern** — diff --git a/frontend/src/app.js b/frontend/src/app.js index 5c70e85..2a36cbf 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -1,5 +1,5 @@ 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 { esc, renderTreeHtml, TIP_RULE } from './render.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); -/* Kurzschreibweise der ID auflösen (D55): `#.kc` wird beim **Verlassen der - Zeile** zu `#prod-stage.kc`. Eingabehilfe, keine Notation — in der Datei - steht danach die volle ID, sie bleibt also durchsuchbar und überlebt das - Umsortieren. +/* Kurzschreibweise der ID auflösen (D55): `#.kc` wird zu `#prod-stage.kc`. + Eingabehilfe, keine Notation — in der Datei steht danach die volle ID, sie + bleibt also durchsuchbar und überlebt das 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 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 undo-fähig — ein Griff daneben kostet ein Strg+Z (D53). */ 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){ - if(src.readOnly || line == null || line !== touchedLine) return; + if(line == null || line !== touchedLine) return; touchedLine = null; - /* **Nicht** sofort schreiben: Der Zeilenwechsel kommt oft aus dem - `input`-Ereignis der Enter-Taste, und `execCommand` verweigert den Dienst, - wenn es re-entrant darin aufgerufen wird. `replaceTextUndoable` fiele dann + writeShortId(line); +} +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 2). Gemessen: erstes Rückgängig ohne Wirkung, jedes weitere `false`. Deshalb ein Zug später, wenn das Ereignis zugestellt ist. diff --git a/frontend/src/parser.js b/frontend/src/parser.js index f64ae85..8c47ba3 100644 --- a/frontend/src/parser.js +++ b/frontend/src/parser.js @@ -131,6 +131,24 @@ export function expandShortIds(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 investiert oder mehr. Absicht (`[?]`, `[ ]`, `[!]`), Ablehnung (`[-]`) und neutrale Knoten zählen nicht. */ diff --git a/frontend/tests/shortids.test.js b/frontend/tests/shortids.test.js index 7c88a18..eb42683 100644 --- a/frontend/tests/shortids.test.js +++ b/frontend/tests/shortids.test.js @@ -1,5 +1,5 @@ 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 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 */ }); }); + +/* 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'); + }); +});