feat: ID-Vorschläge beim Tippen von Abhängigkeiten (:#) — D63

Wer :# tippt, bekommt die vergebenen IDs als Liste an der Schreibmarke:
Präfix- vor Teilstring-Treffern, Knotentitel als Kontext, schon gelistete
und die eigene ID ausgenommen. ↑/↓ wählt, Enter/Tab übernimmt (undo-fähig),
Esc schließt; Live-Region für Screenreader. Eingabehilfe wie die Kurzform
(D55) — der Parser sieht nie etwas davon. Regeln headless in autocomplete.js
(20 Tests), app.js verdrahtet Popup, Tasten und Einfügen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-24 17:13:18 +02:00
co-authored by Claude Fable 5
parent 9cb6ec1197
commit 1debb7d9da
9 changed files with 507 additions and 4 deletions
+1
View File
@@ -19,6 +19,7 @@ reverse.
## 2026-08-24 ## 2026-08-24
- Typing `:#` suggests the document's ids — arrows choose, Enter inserts
- When the caret's node is folded away, the nearest visible ancestor takes the highlight — also on Alt+click from the text - When the caret's node is folded away, the nearest visible ancestor takes the highlight — also on Alt+click from the text
- A size that cannot hold its sub-packages turns its badge amber and gets a warning — sizes are read as ranges, so only sure conflicts are reported - A size that cannot hold its sub-packages turns its badge amber and gets a warning — sizes are read as ranges, so only sure conflicts are reported
- Fix: the exported SVG drew the assumed-M badge filled like a real size instead of inverted - Fix: the exported SVG drew the assumed-M badge filled like a real size instead of inverted
+81
View File
@@ -4688,3 +4688,84 @@ disjunktive Gruppen summiert statt Minimum: genau zwei. Im Browser: Badge
den Grund; im exportierten SVG das Konflikt-Badge bernstein und das implizite den Grund; im exportierten SVG das Konflikt-Badge bernstein und das implizite
M weiß mit Petrol-Rand. Kanonisches Beispiel §10 und der mitgelieferte Plan: M weiß mit Petrol-Rand. Kanonisches Beispiel §10 und der mitgelieferte Plan:
0 Warnungen. 0 Warnungen.
## D63 — ID-Vorschläge beim Tippen von Abhängigkeiten (`:#`)
Wer eine Abhängigkeit tippt, muss die Ziel-ID auswendig wissen oder im Text
suchen — bei 180 IDs im mitgelieferten Plan keine Kleinigkeit. Jetzt öffnet
`:#` eine Vorschlagsliste an der Schreibmarke: die vergebenen IDs, gefiltert
nach dem getippten Fragment, mit dem Knotentitel als Kontext.
**Dieselbe Kategorie wie die ID-Kurzform (D55): Eingabehilfe, keine
Notation.** Der Parser sieht nie etwas davon, SPEC-Syntax und `llms.md`
bleiben unberührt; übernommen wird undo-fähig über das vorhandene `writeAt()`
(D53). Die drei schwierigen Zutaten lagen schon im Haus: Die **Kandidaten**
kommen aus dem Parse-Baum, der bei jedem Tastendruck ohnehin frisch ist; die
**Pixel-Position der Schreibmarke** misst der Spiegel-`div` aus D25/D33 (ein
Marker-Span, `offsetTop`/`offsetLeft` — seit `wrap="off"` (D49) trivial); das
**Einfügen ohne Undo-Verlust** ist die D53-Lehre. Kein CodeMirror/Monaco —
das wäre eine Laufzeit-Abhängigkeit (D11/D19) für etwas, das drei vorhandene
Mechanismen zusammensetzen.
**Ausgelöst nur im Abhängigkeits-Kontext, nicht bei jedem `#`.** Ein
alleinstehendes `#` *definiert* meist eine neue ID — dort wäre die Liste im
Weg. Erkannt wird dieselbe Form, die der Parser liest: `(^|\s):#…` (§1) und
die Kopf-Form `#auth:#…` (D36); `(:#a` bleibt Zitat, `Regel: #x` bleibt
Label, im Kommentar und hinter `---` gibt es keinen Kontext. Die Erkennung
(`depFragment`) und die Kandidaten-Auswahl (`collectIds`/`matchIds`) stehen
headless in **`autocomplete.js`** — die Hausregel aus D54-Nachtrag 3; app.js
verdrahtet nur Popup, Tasten und Einfügen.
**Angeboten wird alles, ausgenommen das Sinnlose.** Auch IDs verworfener und
eingeklappter Knoten stehen in der Liste — eine Abhängigkeit darf dorthin
zeigen (§1), und die Faltung ist nur Ansicht (D38). Ausgenommen sind die im
Token **schon gelisteten** IDs und die **eigene ID der Zeile** (die
Selbst-Abhängigkeit ist zulässig, aber nie das, was man tippen will).
Sortierung: Präfix-Treffer vor Teilstring-Treffern, je in
Dokumentreihenfolge, Groß-/Kleinschreibung egal — die IDs selbst bleiben, wie
sie geschrieben sind. Ersetzt wird bis ans **Ende der ID-Zeichen** hinter der
Schreibmarke, sonst ergäbe eine Übernahme mitten im Wort `#authth`.
**Die Tasten-Arbitrierung ist der heikle Teil, nicht das Popup.** ↑/↓, Enter,
Tab und Esc gehören sonst dem Textfeld — abgefangen werden sie **nur bei
offener Liste**, über einen Capture-Handler auf `document`: Die
Textfeld-Handler (Tab rückt ein, Esc löst die Tab-Falle — beide D53) sind
früher registriert und kämen sonst zuerst; `stopPropagation` hält sie heraus.
Zwei Folgen, beide gemessen: Tab übernimmt bei offener Liste, **ohne**
zusätzlich einzurücken, und rückt bei geschlossener unverändert ein; Esc
schließt die Liste, ohne die Tab-Falle zu lösen. **Nach Esc und nach einer
Übernahme bleibt derselbe Kontext zu** (`acSuppress`) — sonst öffnete ihn das
nächste keyup sofort wieder; Weitertippen ändert das Fragment und hebt die
Sperre.
**Ein Echo wird nicht angeboten:** Steht die ID schon vollständig da (der
eine exakte Treffer, Schreibmarke am Token-Ende), bleibt die Liste zu — so
schließt sie nach der Übernahme von selbst und meldet sich nicht bei jedem
Cursor-Besuch einer fertigen Zeile.
**Barrierefreiheit als benannte Grenze, nicht als Behauptung.** Das saubere
ARIA-Combobox-Muster passt nicht auf ein `<textarea>` (kein
`aria-activedescendant` über Elementgrenzen). Das Popup ist deshalb
`aria-hidden` wie das Knoten-Fenster (D57); eine höfliche **Live-Region**
meldet die Trefferzahl beim Öffnen und die gewählte ID beim Blättern. Die
Liste blockiert nie normales Tippen — wer sie nicht wahrnimmt, verliert
nichts.
**Position wie das Knoten-Fenster:** `position:fixed` auf `<body>` — in
einem Vorfahren mit `overflow` würde die Liste geklippt (D50), und `#out`s
`zoom` geht sie nichts an. Verankert unter dem `#` des Fragments, nach oben
ausweichend, wenn unten kein Platz ist; zu geht sie bei Blur, Scrollen des
Textfelds, Fenstergröße — allem, was ihre Position hinfällig macht (die
D52-Liste). In Pad-Dokumenten (schreibgeschützt, D31) öffnet sie nie.
**Nachgemessen** im Browser mit echten Ereignisfolgen: `:` allein öffnet
nichts, `:#` zeigt alle vier IDs des Testbaums (verworfene eingeschlossen),
`a` filtert auf drei, ↑/↓ wandert (Live-Region nennt die gewählte ID), Enter
übernimmt (`- Frontend :#api`), `,#` öffnet ohne das schon gelistete `api`,
Esc schließt und bleibt bei Cursorbewegung zu, `d` öffnet wieder, Klick
übernimmt, und **jede** Übernahme ist ein einzelner Undo-Schritt. Tab: bei
offener Liste übernehmen ohne Einrücken, danach einrücken wie immer. 20 neue
Tests in `tests/autocomplete.test.js` (397 gesamt): Kontext-Erkennung
(Kopf-Form, Zitier-Klammer, Kommentar, Beschreibungsteil, Fortsetzungszeile
D59, Ersetzen über die Schreibmarke hinaus), Sammeln (Dokumentreihenfolge,
verworfene, D60-Knoten ohne Titel) und Sortierung (Präfix vor Teilstring,
case-insensitiv, exclude).
+24
View File
@@ -869,6 +869,30 @@ und `llms.md` (§13) bleibt unberührt. Angefasst wird nur die Zeile, in der auc
bloßes Durchklicken ein fremdes Dokument um; Abhängigkeiten (`:#…`) und der bloßes Durchklicken ein fremdes Dokument um; Abhängigkeiten (`:#…`) und der
Beschreibungsteil hinter `---` bleiben außen vor. Siehe D55. Beschreibungsteil hinter `---` bleiben außen vor. Siehe D55.
### ID-Vorschläge beim Tippen von Abhängigkeiten (`:#`)
Ebenfalls eine **Eingabehilfe**, keine Notation: Wer im Baumteil ein
Abhängigkeits-Token beginnt (`:#`, auch in der Fortsetzung `,#` und in der
Kopf-Form `#auth:#…`), bekommt an der Schreibmarke eine **Liste der vergebenen
IDs** — gefiltert nach dem schon getippten Fragment (Präfix-Treffer zuerst,
dann Teilstring-Treffer, je in Dokumentreihenfolge), mit dem Knotentitel als
Kontext daneben. Angeboten werden **alle** IDs des Dokuments, auch die
verworfener oder eingeklappter Knoten — eine Abhängigkeit darf überallhin
zeigen (§1); nicht angeboten werden die im Token schon gelisteten IDs und die
eigene ID der Zeile.
- **Bedienung:** ↑/↓ wählt, Enter oder Tab übernimmt (undo-fähig, ein
Schritt), Esc schließt — Weitertippen öffnet wieder. Klick bzw. Tipp auf
einen Eintrag übernimmt ebenso. Wer die Liste ignoriert, tippt einfach
weiter: Sie fängt keine Taste ab, solange sie nichts anzeigt, und Tab rückt
bei geschlossener Liste unverändert ein.
- **Kein Vorschlag** bei bloßem `#` (das *definiert* eine ID), im Kommentar
(`%%`), im Beschreibungsteil hinter `---` und in schreibgeschützten
Pad-Dokumenten (§9).
- Der Parser sieht nie etwas davon; `llms.md` (§13) bleibt unberührt. Für
Screenreader meldet eine höfliche Live-Region die Trefferzahl und den
gewählten Eintrag; das Popup selbst ist `aria-hidden`, normales Tippen
bleibt unberührt. Siehe D63.
### 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**
dieselben, die die Warnungen nennen („Zeile 12: …", §4). Ohne ihn muss man sie dieselben, die die Warnungen nennen („Zeile 12: …", §4). Ohne ihn muss man sie
+6
View File
@@ -72,6 +72,7 @@
- [^] #ed.jump: Jump between diagram and text (S) - [^] #ed.jump: Jump between diagram and text (S)
- [^] #ed.lineno: Line numbers in the text editor (XS) %% the warnings name them - [^] #ed.lineno: Line numbers in the text editor (XS) %% the warnings name them
- [^] #ed.shortid: Type #.kc under #prod-stage, get #prod-stage.kc (XS) %% an input aid, not notation - [^] #ed.shortid: Type #.kc under #prod-stage, get #prod-stage.kc (XS) %% an input aid, not notation
- [x] #ed.depcomplete: Typing :# suggests the known ids (S) %% arrows choose, Enter inserts
- [^] #ed.showids: A # button shows the ids before the titles (XS) %% written as in the text, with a colon - [^] #ed.showids: A # button shows the ids before the titles (XS) %% written as in the text, with a colon
- [^] #ed.lineno.tip: The warning is the tooltip of its number (XS) %% no hunting in the list - [^] #ed.lineno.tip: The warning is the tooltip of its number (XS) %% no hunting in the list
- [^] #ed.fold: Collapsible subtrees (L) - [^] #ed.fold: Collapsible subtrees (L)
@@ -526,6 +527,11 @@
typing, not while parsing: the file keeps the full id, so it stays greppable typing, not while parsing: the file keeps the full id, so it stays greppable
and survives re-ordering, which is what the :#... references rely on. and survives re-ordering, which is what the :#... references rely on.
#ed.depcomplete
A dependency needs its target's id, and nobody remembers 180 of them. The
list at the caret offers the document's ids filtered by what is typed, with
the node title as context; an input aid like the shorthand, not notation.
#ed.showids #ed.showids
The id is the address of a node, but the diagram only carried it in the The id is the address of a node, but the diagram only carried it in the
tooltip. The switch writes it before the title the same way the text does, tooltip. The switch writes it before the title the same way the text does,
+9
View File
@@ -587,6 +587,15 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
`src.value =` ist nur beim **Laden eines anderen** Dokuments richtig `src.value =` ist nur beim **Laden eines anderen** Dokuments richtig
(`loadActiveIntoEditor`, Dokumentwechsel, Pad-Abruf) — dorthin gibt es nichts (`loadActiveIntoEditor`, Dokumentwechsel, Pad-Abruf) — dorthin gibt es nichts
zurückzunehmen. zurückzunehmen.
- **ID-Vorschläge `:#` (D63):** Die Regeln stehen headless in `autocomplete.js`
(`depFragment`/`collectIds`/`matchIds`, Tests); app.js verdrahtet nur Popup,
Tasten, Einfügen (`writeAt`, undo-fähig). Der Tasten-Handler hängt an
`document` in der **Capture-Phase** — die Textfeld-Handler (Tab rückt ein,
Esc löst die Tab-Falle, D53) sind früher registriert und kämen sonst zuerst;
`stopPropagation` hält sie nur bei **offener** Liste heraus. `acSuppress`
hält denselben Kontext nach Übernahme/Esc geschlossen — ohne das öffnet ihn
das nächste keyup sofort wieder. Popup auf `<body>` mit `position:fixed`
(Klipp-Falle D50, wie das Knoten-Fenster).
- **Undo lässt sich hier nicht per Tastendruck prüfen.** Ein synthetisches - **Undo lässt sich hier nicht per Tastendruck prüfen.** Ein synthetisches
`ctrl+z` aus der Automatisierung löst **kein** natives Undo aus (gemessen: `ctrl+z` aus der Automatisierung löst **kein** natives Undo aus (gemessen:
Text unverändert), während `document.execCommand('undo')` im selben Moment Text unverändert), während `document.execCommand('undo')` im selben Moment
+157 -3
View File
@@ -4,6 +4,7 @@ import { computeCheapPlan, freshProdSet, initialCollapsed, nodeKeys, effectiveSt
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';
import { padUrls } from './remote.js'; import { padUrls } from './remote.js';
import { depFragment, collectIds, matchIds } from './autocomplete.js';
import { LS_SNAPS, SNAP_EVERY, parseSnaps, addSnapshot, persistSnaps, snapLabel } import { LS_SNAPS, SNAP_EVERY, parseSnaps, addSnapshot, persistSnaps, snapLabel }
from './snapshots.js'; from './snapshots.js';
/* Neuigkeiten (D58): die git-Historie, zur BAUZEIT eingelesen (Vite-Plugin in /* Neuigkeiten (D58): die git-Historie, zur BAUZEIT eingelesen (Vite-Plugin in
@@ -97,6 +98,11 @@ let foldOverrides = new Map(), foldByLine = new Map();
`nodeOfLine()` greift darauf zurück, wenn die Zeile keinen DOM-Knoten hat. */ `nodeOfLine()` greift darauf zurück, wenn die Zeile keinen DOM-Knoten hat. */
let lineTargetMap = new Map(); let lineTargetMap = new Map();
/* Der zuletzt geparste Baum, UNGEFILTERT die ID-Vorschläge (D63) lesen
daraus die vergebenen IDs, und eine Abhängigkeit darf auch auf Verworfenes
zeigen. */
let acRoots = [];
/* ---------- Renderer (Anbindung an den DOM) ---------- /* ---------- Renderer (Anbindung an den DOM) ----------
parse -> Wurzeln filtern (verworfene) -> günstigen Pfad markieren -> parse -> Wurzeln filtern (verworfene) -> günstigen Pfad markieren ->
render.js baut den HTML-String -> in #out schreiben -> Pfadlinie zeichnen. */ render.js baut den HTML-String -> in #out schreiben -> Pfadlinie zeichnen. */
@@ -105,6 +111,7 @@ function render(){
und seine Position ist ohnehin gemessen, also gleich hinfällig. */ und seine Position ist ohnehin gemessen, also gleich hinfällig. */
closeNodeTip(); closeNodeTip();
const parsed = parse(src.value); const parsed = parse(src.value);
acRoots = parsed.roots;
let roots = parsed.roots; let roots = parsed.roots;
const showDiscarded = discardedShown(); const showDiscarded = discardedShown();
if(!showDiscarded){ if(!showDiscarded){
@@ -847,15 +854,21 @@ function syncMirror(){
return mirrorEl; return mirrorEl;
} }
const ZWSP = ''; const ZWSP = '';
function offsetTopInEditor(offset){ /* Position eines Zeichenoffsets im Spiegel `top` im Koordinatensystem von
`src.scrollTop`, `left` in dem von `src.scrollLeft` (Innenabstände stecken
je drin). Das `left` braucht nur die Vorschlagsliste (D63). */
function caretPosInEditor(offset){
const m = syncMirror(); const m = syncMirror();
m.textContent = src.value.slice(0, offset); m.textContent = src.value.slice(0, offset);
const marker = document.createElement('span'); const marker = document.createElement('span');
marker.textContent = ZWSP; marker.textContent = ZWSP;
m.appendChild(marker); m.appendChild(marker);
const top = marker.offsetTop; const pos = {top: marker.offsetTop, left: marker.offsetLeft};
m.textContent = ''; m.textContent = '';
return top; return pos;
}
function offsetTopInEditor(offset){
return caretPosInEditor(offset).top;
} }
/* Oberkante jeder **logischen** Zeile, im selben Koordinatensystem wie /* Oberkante jeder **logischen** Zeile, im selben Koordinatensystem wie
@@ -1456,6 +1469,7 @@ function syncCaret(){
if(moved) resolveShortId(caretLine); if(moved) resolveShortId(caretLine);
caretLine = caretLineOf(); /* neu lesen: das Auflösen kann den Text ändern */ caretLine = caretLineOf(); /* neu lesen: das Auflösen kann den Text ändern */
highlightCurrentNode(moved); highlightCurrentNode(moved);
updateAc(); /* ID-Vorschläge folgen der Schreibmarke (D63) */
} }
for(const ev of ['click','keyup','input','focus']) src.addEventListener(ev, syncCaret); for(const ev of ['click','keyup','input','focus']) src.addEventListener(ev, syncCaret);
@@ -1561,6 +1575,137 @@ window.addEventListener('keydown', e => { if(e.key === 'Alt') setAltMode(true);
window.addEventListener('keyup', e => { if(e.key === 'Alt' || !e.altKey) setAltMode(false); }); window.addEventListener('keyup', e => { if(e.key === 'Alt' || !e.altKey) setAltMode(false); });
window.addEventListener('blur', () => setAltMode(false)); window.addEventListener('blur', () => setAltMode(false));
/* ---------- ID-Vorschläge beim Tippen von Abhängigkeiten (D63) ----------
Wer `:#` tippt, bekommt die vergebenen IDs als Liste an der Schreibmarke.
Die Regeln (wann ein Kontext vorliegt, welche IDs passen) stehen headless
in autocomplete.js; hier hängen nur Popup, Tasten und das Einfügen. Eine
Eingabehilfe wie die ID-Kurzform (D55): Der Parser sieht nie etwas davon,
und wer die Liste ignoriert, tippt einfach weiter. */
let acEl = null, acLiveEl = null, acItems = [], acIndex = 0, acCtx = null;
/* Nach Übernahme oder Esc bleibt DERSELBE Kontext zu sonst öffnete ihn das
nächste keyup sofort wieder. Weitertippen ändert das Fragment und löst ihn. */
let acSuppress = null;
function acBox(){
if(!acEl){
acEl = document.createElement('div');
acEl.className = 'aclist';
/* aria-hidden wie das Knoten-Fenster (D57): Das saubere Combobox-Muster
passt nicht auf ein <textarea>; die Live-Region unten sagt, was es gibt,
und normales Tippen bleibt von der Liste unberührt. */
acEl.setAttribute('aria-hidden', 'true');
acEl.hidden = true;
/* pointerdown statt click: läuft VOR dem Fokuswechsel, und preventDefault
lässt den Fokus im Textfeld auch auf Touch. */
acEl.addEventListener('pointerdown', e => {
const it = e.target.closest('.acitem');
if(!it) return;
e.preventDefault();
acIndex = Number(it.dataset.i);
acAccept();
});
document.body.appendChild(acEl);
acLiveEl = document.createElement('div');
acLiveEl.className = 'vh';
acLiveEl.setAttribute('role', 'status');
document.body.appendChild(acLiveEl);
}
return acEl;
}
function acIsOpen(){ return !!acEl && !acEl.hidden; }
function closeAc(){
if(acEl) acEl.hidden = true;
if(acLiveEl) acLiveEl.textContent = '';
acCtx = null;
}
function updateAc(){
if(src.readOnly || document.activeElement !== src ||
src.selectionStart !== src.selectionEnd){ closeAc(); return; }
const ctx = depFragment(src.value, src.selectionStart);
if(!ctx){ acSuppress = null; closeAc(); return; }
if(acSuppress && acSuppress.start === ctx.start && acSuppress.fragment === ctx.fragment){
closeAc(); return;
}
acSuppress = null;
/* Pfeiltasten ändern nur die Auswahl, nicht den Kontext Liste und
gewählter Eintrag bleiben dann stehen. */
if(acIsOpen() && acCtx && acCtx.start === ctx.start && acCtx.fragment === ctx.fragment) return;
const cands = matchIds(collectIds(acRoots), ctx.fragment, ctx.exclude);
/* Nichts zu zeigen oder der eine exakte Treffer wäre nur ein Echo dessen,
was schon vollständig dasteht. */
if(!cands.length ||
(cands.length === 1 && cands[0].id === ctx.fragment && ctx.end === src.selectionStart)){
closeAc(); return;
}
acCtx = ctx; acItems = cands; acIndex = 0;
renderAc();
acLiveEl.textContent = t('acHint', {n: acItems.length});
}
function renderAc(){
const box = acBox();
box.innerHTML = acItems.map((c, i) =>
`<div class="acitem${i === acIndex ? ' sel' : ''}" data-i="${i}">` +
`<span class="acid">#${esc(c.id)}</span>` +
(c.label ? `<span class="aclabel">${esc(c.label)}</span>` : '') +
`</div>`).join('');
box.hidden = false;
placeAc();
const sel = box.children[acIndex];
if(sel) sel.scrollIntoView({block: 'nearest'});
}
/* Unter dem `#` des Fragments; nach oben ausweichend, wenn unten kein Platz
ist. Wie das Knoten-Fenster (D52) `position:fixed` auf <body> in einem
Vorfahren mit `overflow` würde die Liste geklippt (D50). */
function placeAc(){
const rect = src.getBoundingClientRect();
const pos = caretPosInEditor(Math.max(0, acCtx.start - 1));
const lh = parseFloat(getComputedStyle(src).lineHeight) || 18;
let x = rect.left + pos.left - src.scrollLeft;
let y = rect.top + pos.top - src.scrollTop + lh;
x = Math.max(8, Math.min(x, window.innerWidth - acEl.offsetWidth - 8));
if(y + acEl.offsetHeight > window.innerHeight - 8){
y = Math.max(8, rect.top + pos.top - src.scrollTop - acEl.offsetHeight - 4);
}
acEl.style.left = x + 'px';
acEl.style.top = y + 'px';
}
function acMove(d){
acIndex = (acIndex + d + acItems.length) % acItems.length;
renderAc();
acLiveEl.textContent = '#' + acItems[acIndex].id; /* der gewählte Eintrag */
}
function acAccept(){
const c = acItems[acIndex], ctx = acCtx;
closeAc();
if(!c || !ctx) return;
acSuppress = {start: ctx.start, fragment: c.id};
const p = ctx.start + c.id.length;
/* writeAt (D53) ersetzt undo-fähig hier läuft es aus keydown/pointerdown,
nicht re-entrant aus `input`, execCommand greift also (anders als D55). */
writeAt(ctx.start, ctx.end, c.id, p, p);
}
/* Auf `document` in der Capture-Phase: Die Handler am Textfeld (Tab rückt ein,
Esc löst die Tab-Falle, D53) sind früher registriert und kämen sonst zuerst.
stopPropagation hält sie heraus, solange die Liste offen ist. */
document.addEventListener('keydown', e => {
if(!acIsOpen() || e.target !== src) return;
if(e.key === 'ArrowDown' || e.key === 'ArrowUp'){
e.preventDefault();
acMove(e.key === 'ArrowDown' ? 1 : -1);
} else if(e.key === 'Enter' || e.key === 'Tab'){
e.preventDefault();
e.stopPropagation();
acAccept();
} else if(e.key === 'Escape'){
e.stopPropagation();
acSuppress = {start: acCtx.start, fragment: acCtx.fragment};
closeAc();
}
}, true);
/* Zu, wenn die Position nicht mehr stimmt oder niemand mehr tippt. */
src.addEventListener('blur', closeAc);
src.addEventListener('scroll', closeAc);
window.addEventListener('resize', closeAc);
const app = document.getElementById('app'); const app = document.getElementById('app');
function applyLayout(mode){ function applyLayout(mode){
out.classList.toggle('vertical', mode === 'vertikal'); out.classList.toggle('vertical', mode === 'vertikal');
@@ -1917,6 +2062,7 @@ const I18N = {
jumpHint:"Alt+Klick: zur Zeile im Text", jumpHint:"Alt+Klick: zur Zeile im Text",
/* Auf Touch nennt das Knoten-Fenster (D52) den langen Druck — Alt gibt es dort nicht. */ /* Auf Touch nennt das Knoten-Fenster (D52) den langen Druck — Alt gibt es dort nicht. */
jumpHintTouch:"Langer Druck: zur Zeile im Text", jumpHintTouch:"Langer Druck: zur Zeile im Text",
acHint:"{n} ID-Vorschläge ↑/↓ wählt, Enter übernimmt",
tipClose:"Schließen", tipClose:"Schließen",
tipOpenLink:"Link öffnen", tipOpenLink:"Link öffnen",
padReadonly:"Wird im Pad bearbeitet — hier nur lesen.", padReadonly:"Wird im Pad bearbeitet — hier nur lesen.",
@@ -2021,6 +2167,7 @@ const I18N = {
ghostTooltip:"From size M upward, an item should be broken down further.", ghostTooltip:"From size M upward, an item should be broken down further.",
jumpHint:"Alt+click: jump to the line in the text", jumpHint:"Alt+click: jump to the line in the text",
jumpHintTouch:"Long press: jump to the line in the text", jumpHintTouch:"Long press: jump to the line in the text",
acHint:"{n} id suggestions ↑/↓ to choose, Enter to insert",
tipClose:"Close", tipClose:"Close",
tipOpenLink:"Open link", tipOpenLink:"Open link",
padReadonly:"Edited in the pad — read-only here.", padReadonly:"Edited in the pad — read-only here.",
@@ -2124,6 +2271,7 @@ const I18N = {
ghostTooltip:"A partir de la talla M, un elemento debería desglosarse más.", ghostTooltip:"A partir de la talla M, un elemento debería desglosarse más.",
jumpHint:"Alt+clic: ir a la línea en el texto", jumpHint:"Alt+clic: ir a la línea en el texto",
jumpHintTouch:"Pulsación larga: ir a la línea en el texto", jumpHintTouch:"Pulsación larga: ir a la línea en el texto",
acHint:"{n} sugerencias de ID ↑/↓ elige, Intro inserta",
tipClose:"Cerrar", tipClose:"Cerrar",
tipOpenLink:"Abrir enlace", tipOpenLink:"Abrir enlace",
padReadonly:"Se edita en el pad — aquí solo lectura.", padReadonly:"Se edita en el pad — aquí solo lectura.",
@@ -2227,6 +2375,7 @@ const I18N = {
ghostTooltip:"À partir de la taille M, un élément devrait être décomposé davantage.", ghostTooltip:"À partir de la taille M, un élément devrait être décomposé davantage.",
jumpHint:"Alt+clic : aller à la ligne dans le texte", jumpHint:"Alt+clic : aller à la ligne dans le texte",
jumpHintTouch:"Appui long : aller à la ligne dans le texte", jumpHintTouch:"Appui long : aller à la ligne dans le texte",
acHint:"{n} suggestions d'ID ↑/↓ pour choisir, Entrée pour insérer",
tipClose:"Fermer", tipClose:"Fermer",
tipOpenLink:"Ouvrir le lien", tipOpenLink:"Ouvrir le lien",
padReadonly:"Modifié dans le pad — lecture seule ici.", padReadonly:"Modifié dans le pad — lecture seule ici.",
@@ -2330,6 +2479,7 @@ const I18N = {
ghostTooltip:"Od rozmiaru M element powinien być dalej podzielony.", ghostTooltip:"Od rozmiaru M element powinien być dalej podzielony.",
jumpHint:"Alt+kliknięcie: przejdź do wiersza w tekście", jumpHint:"Alt+kliknięcie: przejdź do wiersza w tekście",
jumpHintTouch:"Długie przytrzymanie: przejdź do wiersza w tekście", jumpHintTouch:"Długie przytrzymanie: przejdź do wiersza w tekście",
acHint:"{n} podpowiedzi ID ↑/↓ wybiera, Enter wstawia",
tipClose:"Zamknij", tipClose:"Zamknij",
tipOpenLink:"Otwórz link", tipOpenLink:"Otwórz link",
padReadonly:"Edytowane w padzie — tu tylko do czytania.", padReadonly:"Edytowane w padzie — tu tylko do czytania.",
@@ -2433,6 +2583,7 @@ const I18N = {
ghostTooltip:"Начиная с размера M элемент следует далее декомпозировать.", ghostTooltip:"Начиная с размера M элемент следует далее декомпозировать.",
jumpHint:"Alt+клик: перейти к строке в тексте", jumpHint:"Alt+клик: перейти к строке в тексте",
jumpHintTouch:"Долгое нажатие: перейти к строке в тексте", jumpHintTouch:"Долгое нажатие: перейти к строке в тексте",
acHint:"{n} подсказок ID – ↑/↓ выбирает, Enter вставляет",
tipClose:"Закрыть", tipClose:"Закрыть",
tipOpenLink:"Открыть ссылку", tipOpenLink:"Открыть ссылку",
padReadonly:"Редактируется в паде — здесь только чтение.", padReadonly:"Редактируется в паде — здесь только чтение.",
@@ -2536,6 +2687,7 @@ const I18N = {
ghostTooltip:"आकार M से ऊपर किसी तत्व को और अधिक उप-विभाजित करना चाहिए।", ghostTooltip:"आकार M से ऊपर किसी तत्व को और अधिक उप-विभाजित करना चाहिए।",
jumpHint:"Alt+क्लिक: टेक्स्ट में उस पंक्ति पर जाएँ", jumpHint:"Alt+क्लिक: टेक्स्ट में उस पंक्ति पर जाएँ",
jumpHintTouch:"देर तक दबाएँ: टेक्स्ट में उस पंक्ति पर जाएँ", jumpHintTouch:"देर तक दबाएँ: टेक्स्ट में उस पंक्ति पर जाएँ",
acHint:"{n} आईडी सुझाव – ↑/↓ से चुनें, Enter से डालें",
tipClose:"बंद करें", tipClose:"बंद करें",
tipOpenLink:"लिंक खोलें", tipOpenLink:"लिंक खोलें",
padReadonly:"पैड में संपादित होता है — यहाँ केवल पढ़ें।", padReadonly:"पैड में संपादित होता है — यहाँ केवल पढ़ें।",
@@ -2646,6 +2798,7 @@ const I18N = {
ghostTooltip:"从 M 号起,元素应进一步细分。", ghostTooltip:"从 M 号起,元素应进一步细分。",
jumpHint:"Alt+点击:跳转到文本中的该行", jumpHint:"Alt+点击:跳转到文本中的该行",
jumpHintTouch:"长按:跳转到文本中的该行", jumpHintTouch:"长按:跳转到文本中的该行",
acHint:"{n} 个 ID 建议 ↑/↓ 选择,Enter 插入",
tipClose:"关闭", tipClose:"关闭",
tipOpenLink:"打开链接", tipOpenLink:"打开链接",
padReadonly:"在 Pad 中编辑 — 此处只读。", padReadonly:"在 Pad 中编辑 — 此处只读。",
@@ -2749,6 +2902,7 @@ const I18N = {
ghostTooltip:"サイズ M 以上の要素はさらに分解すべきです。", ghostTooltip:"サイズ M 以上の要素はさらに分解すべきです。",
jumpHint:"Alt+クリック:テキストの該当行へ移動", jumpHint:"Alt+クリック:テキストの該当行へ移動",
jumpHintTouch:"長押し:テキストの該当行へ移動", jumpHintTouch:"長押し:テキストの該当行へ移動",
acHint:"ID候補 {n} 件 ↑/↓で選択、Enterで挿入",
tipClose:"閉じる", tipClose:"閉じる",
tipOpenLink:"リンクを開く", tipOpenLink:"リンクを開く",
padReadonly:"パッドで編集します — ここでは読み取り専用です。", padReadonly:"パッドで編集します — ここでは読み取り専用です。",
+87
View File
@@ -0,0 +1,87 @@
/* ID-Vorschläge beim Tippen von Abhängigkeiten (D63).
Eine Eingabehilfe wie die ID-Kurzform (D55), keine Notation: Der Parser
sieht nie etwas davon, SPEC und llms.md bleiben unberührt. Hier steht, WAS
gilt wann ein `:#…`-Kontext vorliegt und welche IDs dazu passen; app.js
verdrahtet nur (Popup, Tasten, Einfügen). Frontend-Hausregel: Was
entscheidbar ist, gehört in ein Modul (D54-Nachtrag 3). */
const ID_CHARS = '[\\p{L}\\p{N}._-]';
/* Der Abhängigkeits-Kontext an der Schreibmarke: null, oder
{start, end, fragment, exclude}. `start`..caret ist das angefangene
ID-Fragment hinter dem letzten `#`; `end` reicht über die Schreibmarke
hinaus bis ans Ende der ID-Zeichen (wer mitten im Wort ersetzt, soll kein
`#authth` bekommen). `exclude` sind die IDs, die eine Auswahl nicht mehr
anbieten soll: die schon gelisteten des Tokens und die eigene ID der Zeile
(die Selbst-Abhängigkeit ist zulässig, aber nie das, was man tippen will).
Erkannt wird dieselbe Form, die der Parser liest (SPEC §1): das Token
alleinstehend angesetzt `(^|\s):#…` oder unmittelbar hinter der
Knoten-ID (`#auth:#db`, D36). `(:#a` bleibt damit Zitat, `Regel: #x`
bleibt Label. Kein Kontext im Kommentar (hinter `%%`) und nicht im
Beschreibungsteil hinter `---` dort ist alles Freitext. */
const TOKEN_RE = new RegExp(
'(?:^|[ \\t])(?:#(' + ID_CHARS + '+))?:(#' + ID_CHARS + '*(?:,#' + ID_CHARS + '*)*)$', 'u');
const OWN_ID_RE = new RegExp('(?:^|\\s)#(' + ID_CHARS + '+)', 'u');
const TAIL_RE = new RegExp('^' + ID_CHARS + '*', 'u');
export function depFragment(text, caret){
const before = text.slice(0, caret);
const lines = before.split('\n');
const cur = lines[lines.length - 1];
/* Beschreibungsteil (§1): alles hinter dem ersten `---`-Trenner ist Freitext. */
for(let i = 0; i < lines.length - 1; i++){
if(/^\s*-{3,}\s*$/.test(lines[i])) return null;
}
if(cur.includes('%%')) return null; /* die Schreibmarke steht im Kommentar */
const m = TOKEN_RE.exec(cur);
if(!m) return null;
const token = m[2]; /* "#a,#b…" bis zur Schreibmarke */
const parts = token.slice(1).split(',#');
const fragment = parts[parts.length - 1];
const exclude = parts.slice(0, -1);
if(m[1]) exclude.push(m[1]); /* Kopf-Form `#auth:#…` */
else {
/* Eigene ID der Zeile, wenn sie weiter vorn steht (`#auth: :#`)
erster alleinstehender `#`-Treffer, wie in der Extraktion (Schritt 6).
Der Zeilenrest vor dem Token genügt: Die IDs im Token selbst sind nie
alleinstehend (`:`/`,` davor). */
const own = OWN_ID_RE.exec(cur.slice(0, m.index + 1));
if(own) exclude.push(own[1]);
}
const end = caret + TAIL_RE.exec(text.slice(caret))[0].length;
return {start: caret - fragment.length, end, fragment, exclude};
}
/* Alle vergebenen IDs in Dokumentreihenfolge, mit Titel als Kontext.
Bewusst ALLE Knoten auch verworfene und eingeklappte: Eine Abhängigkeit
darf überallhin zeigen, und die Faltung ist nur Ansicht (D38). */
export function collectIds(roots){
const out = [];
const walk = ns => {
for(const n of ns){
if(n.id) out.push({id: n.id, label: n.labelFromId ? '' : n.label});
walk(n.children);
}
};
walk(roots);
return out;
}
/* Passende Kandidaten: erst Präfix-Treffer, dann Teilstring-Treffer, je in
Dokumentreihenfolge; Groß-/Kleinschreibung egal (die IDs selbst bleiben,
wie sie geschrieben sind). Leeres Fragment (direkt nach `:#`) zeigt alle. */
export function matchIds(ids, fragment, exclude = []){
const ex = new Set(exclude);
const pool = ids.filter(c => !ex.has(c.id));
if(!fragment) return pool;
const f = fragment.toLowerCase();
const pre = [], sub = [];
for(const c of pool){
const lo = c.id.toLowerCase();
if(lo.startsWith(f)) pre.push(c);
else if(lo.includes(f)) sub.push(c);
}
return pre.concat(sub);
}
+28 -1
View File
@@ -1006,6 +1006,33 @@
`ul.or .node{box-shadow:none}` spezifischer ist. */ `ul.or .node{box-shadow:none}` spezifischer ist. */
#out .node.tipped{box-shadow:0 0 0 3px rgba(15,118,110,.55)} #out .node.tipped{box-shadow:0 0 0 3px rgba(15,118,110,.55)}
/* ---------- ID-Vorschläge beim Tippen von Abhängigkeiten (D63) ----------
`position:fixed` auf <body> wie das Knoten-Fenster: In einem Vorfahren
mit `overflow` würde die Liste geklippt (D50). Die IDs in der
Mono-Schrift des Textfelds, der Titel als gedämpfter Kontext daneben. */
.aclist{
position:fixed;z-index:70;min-width:160px;max-width:min(24rem, calc(100vw - 16px));
max-height:12.5rem;overflow-y:auto;
background:var(--card);border:1px solid rgba(36,52,71,.15);border-radius:8px;
box-shadow:0 8px 24px rgba(36,52,71,.25);padding:4px;
}
.aclist[hidden]{display:none}
.acitem{
display:flex;align-items:baseline;gap:8px;padding:4px 8px;border-radius:5px;
cursor:pointer;white-space:nowrap;
}
.acitem.sel,.acitem:hover{background:rgba(15,118,110,.12)}
.acid{font-family:'IBM Plex Mono',monospace;font-size:.74rem;color:var(--ink)}
.aclabel{
font-size:.7rem;color:var(--muted);
overflow:hidden;text-overflow:ellipsis;max-width:14rem;
}
/* Visuell versteckt, für Screenreader da — die Live-Region der Vorschläge. */
.vh{
position:absolute;width:1px;height:1px;margin:-1px;overflow:hidden;
clip-path:inset(50%);white-space:nowrap;
}
/* --- ALL OF: Kinder nebeneinander, durchgezogener Verteiler --- */ /* --- ALL OF: Kinder nebeneinander, durchgezogener Verteiler --- */
ul.and{display:flex;align-items:flex-start;position:relative;padding-top:14px} ul.and{display:flex;align-items:flex-start;position:relative;padding-top:14px}
ul.and::before{ ul.and::before{
@@ -1431,7 +1458,7 @@
@media print{ @media print{
html,body{height:auto!important;overflow:visible!important;background:#fff!important} html,body{height:auto!important;overflow:visible!important;background:#fff!important}
header,.panel.editor,.gutter,.panel.right .panel-head, header,.panel.editor,.gutter,.panel.right .panel-head,
.warnings,.zoomctl,.site-footer,.nodetip{display:none!important} .warnings,.zoomctl,.site-footer,.nodetip,.aclist{display:none!important}
.app,.app.side{display:block!important;max-width:none!important;width:auto!important; .app,.app.side{display:block!important;max-width:none!important;width:auto!important;
height:auto!important;margin:0!important;gap:0!important} height:auto!important;margin:0!important;gap:0!important}
.panel.right{grid-area:auto!important;background:#fff!important;border:0!important; .panel.right{grid-area:auto!important;background:#fff!important;border:0!important;
+114
View File
@@ -0,0 +1,114 @@
// ID-Vorschläge beim Tippen von Abhängigkeiten (D63): Kontext-Erkennung und
// Kandidaten-Auswahl. Die Verdrahtung (Popup, Tasten, Einfügen) bleibt
// Browser-Sache — hier steht, WAS gilt.
import { describe, it, expect } from 'vitest';
import { depFragment, collectIds, matchIds } from '../src/autocomplete.js';
import { parse } from '../src/parser.js';
/* Kontext am Zeilenende: Text bis `|` ist alles vor der Schreibmarke. */
const at = text => {
const caret = text.indexOf('|');
return depFragment(text.replace('|', ''), caret);
};
describe('depFragment: wann ein :#-Kontext vorliegt', () => {
it('öffnet direkt nach :# mit leerem Fragment', () => {
const ctx = at('- Backend :#|');
expect(ctx).toMatchObject({fragment: '', exclude: []});
expect(ctx.start).toBe(12);
expect(ctx.end).toBe(12);
});
it('liefert das angefangene Fragment', () => {
expect(at('- Backend :#au|')).toMatchObject({fragment: 'au', start: 12});
});
it('setzt in der Liste fort und schließt Gelistetes aus', () => {
expect(at('- X :#auth,#a|')).toMatchObject({fragment: 'a', exclude: ['auth']});
});
it('erkennt die Kopf-Form #auth:#… und schließt die eigene ID aus', () => {
expect(at('- #auth:#d|')).toMatchObject({fragment: 'd', exclude: ['auth']});
});
it('schließt die eigene ID der Zeile auch weiter vorn aus', () => {
expect(at('- #auth: Backend :#|')).toMatchObject({fragment: '', exclude: ['auth']});
});
it('reicht über die Schreibmarke bis ans Ende der ID-Zeichen (end)', () => {
const text = '- X :#auth (S)';
const ctx = depFragment(text, 8); /* Schreibmarke mitten in `auth` */
expect(ctx).toMatchObject({fragment: 'au', start: 6, end: 10});
});
it('kein Kontext bei bloßem # — das definiert eine ID', () => {
expect(at('- Backend #au|')).toBeNull();
});
it('kein Kontext in der Zitier-Klammer und nach Label-Doppelpunkten', () => {
expect(at('- siehe (:#auth|')).toBeNull();
expect(at('- Regel:#x|')).toBeNull();
});
it('kein Kontext im Kommentar', () => {
expect(at('- A %% braucht :#auth|')).toBeNull();
});
it('kein Kontext im Beschreibungsteil hinter ---', () => {
expect(at('- A\n---\n#a\n siehe :#|')).toBeNull();
});
it('kein Kontext, wenn das Token nicht bis zur Schreibmarke reicht', () => {
expect(at('- A :#auth |')).toBeNull();
});
it('am Anfang einer Fortsetzungszeile gilt der Zeilenanfang als Leerraum', () => {
expect(at('- Langer Titel \\\n:#au|')).toMatchObject({fragment: 'au'});
});
});
describe('collectIds: alle vergebenen IDs in Dokumentreihenfolge', () => {
it('sammelt über alle Ebenen, mit Titel als Kontext', () => {
const { roots } = parse('#a: Wurzel\n - #b: Kind\n - Ohne ID\n - #c: Kind 2');
expect(collectIds(roots)).toEqual([
{id: 'a', label: 'Wurzel'}, {id: 'b', label: 'Kind'}, {id: 'c', label: 'Kind 2'},
]);
});
it('nimmt auch verworfene Knoten mit — Abhängigkeiten dürfen dorthin zeigen', () => {
const { roots } = parse('A\n - [-] #alt: Verworfen');
expect(collectIds(roots)).toEqual([{id: 'alt', label: 'Verworfen'}]);
});
it('lässt den Titel leer, wenn die ID ihn nur vertritt (D60)', () => {
const { roots } = parse('- #US-123');
expect(collectIds(roots)).toEqual([{id: 'US-123', label: ''}]);
});
});
describe('matchIds: Präfix vor Teilstring, Dokumentreihenfolge, ohne exclude', () => {
const ids = [
{id: 'auth', label: ''}, {id: 'db', label: ''},
{id: 'be.auth', label: ''}, {id: 'author', label: ''},
];
it('leeres Fragment zeigt alle', () => {
expect(matchIds(ids, '').map(c => c.id)).toEqual(['auth', 'db', 'be.auth', 'author']);
});
it('Präfix-Treffer stehen vor Teilstring-Treffern, je in Reihenfolge', () => {
expect(matchIds(ids, 'au').map(c => c.id)).toEqual(['auth', 'author', 'be.auth']);
});
it('vergleicht ohne Groß-/Kleinschreibung, behält die Schreibweise', () => {
expect(matchIds([{id: 'US-123', label: ''}], 'us').map(c => c.id)).toEqual(['US-123']);
});
it('ausgeschlossene IDs erscheinen nicht', () => {
expect(matchIds(ids, 'au', ['auth']).map(c => c.id)).toEqual(['author', 'be.auth']);
});
it('ohne Treffer bleibt die Liste leer', () => {
expect(matchIds(ids, 'xyz')).toEqual([]);
});
});