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:
co-authored by
Claude Fable 5
parent
9cb6ec1197
commit
1debb7d9da
@@ -19,6 +19,7 @@ reverse.
|
||||
|
||||
## 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
|
||||
- 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
|
||||
|
||||
@@ -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
|
||||
M weiß mit Petrol-Rand. Kanonisches Beispiel §10 und der mitgelieferte Plan:
|
||||
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).
|
||||
|
||||
@@ -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
|
||||
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
|
||||
Links neben dem Textfeld steht ein schmaler Streifen mit den **Zeilennummern** —
|
||||
dieselben, die die Warnungen nennen („Zeile 12: …", §4). Ohne ihn muss man sie
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
- [^] #ed.jump: Jump between diagram and text (S)
|
||||
- [^] #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
|
||||
- [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.lineno.tip: The warning is the tooltip of its number (XS) %% no hunting in the list
|
||||
- [^] #ed.fold: Collapsible subtrees (L)
|
||||
@@ -526,6 +527,11 @@
|
||||
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.
|
||||
|
||||
#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
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
(`loadActiveIntoEditor`, Dokumentwechsel, Pad-Abruf) — dorthin gibt es nichts
|
||||
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
|
||||
`ctrl+z` aus der Automatisierung löst **kein** natives Undo aus (gemessen:
|
||||
Text unverändert), während `document.execCommand('undo')` im selben Moment
|
||||
|
||||
+157
-3
@@ -4,6 +4,7 @@ import { computeCheapPlan, freshProdSet, initialCollapsed, nodeKeys, effectiveSt
|
||||
import { esc, renderTreeHtml, TIP_RULE } from './render.js';
|
||||
import { formatWarning, warningText } from './warnings.js';
|
||||
import { padUrls } from './remote.js';
|
||||
import { depFragment, collectIds, matchIds } from './autocomplete.js';
|
||||
import { LS_SNAPS, SNAP_EVERY, parseSnaps, addSnapshot, persistSnaps, snapLabel }
|
||||
from './snapshots.js';
|
||||
/* 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. */
|
||||
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) ----------
|
||||
parse -> Wurzeln filtern (verworfene) -> günstigen Pfad markieren ->
|
||||
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. */
|
||||
closeNodeTip();
|
||||
const parsed = parse(src.value);
|
||||
acRoots = parsed.roots;
|
||||
let roots = parsed.roots;
|
||||
const showDiscarded = discardedShown();
|
||||
if(!showDiscarded){
|
||||
@@ -847,15 +854,21 @@ function syncMirror(){
|
||||
return mirrorEl;
|
||||
}
|
||||
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();
|
||||
m.textContent = src.value.slice(0, offset);
|
||||
const marker = document.createElement('span');
|
||||
marker.textContent = ZWSP;
|
||||
m.appendChild(marker);
|
||||
const top = marker.offsetTop;
|
||||
const pos = {top: marker.offsetTop, left: marker.offsetLeft};
|
||||
m.textContent = '';
|
||||
return top;
|
||||
return pos;
|
||||
}
|
||||
function offsetTopInEditor(offset){
|
||||
return caretPosInEditor(offset).top;
|
||||
}
|
||||
|
||||
/* Oberkante jeder **logischen** Zeile, im selben Koordinatensystem wie
|
||||
@@ -1456,6 +1469,7 @@ function syncCaret(){
|
||||
if(moved) resolveShortId(caretLine);
|
||||
caretLine = caretLineOf(); /* neu lesen: das Auflösen kann den Text ändern */
|
||||
highlightCurrentNode(moved);
|
||||
updateAc(); /* ID-Vorschläge folgen der Schreibmarke (D63) */
|
||||
}
|
||||
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('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');
|
||||
function applyLayout(mode){
|
||||
out.classList.toggle('vertical', mode === 'vertikal');
|
||||
@@ -1917,6 +2062,7 @@ const I18N = {
|
||||
jumpHint:"Alt+Klick: zur Zeile im Text",
|
||||
/* Auf Touch nennt das Knoten-Fenster (D52) den langen Druck — Alt gibt es dort nicht. */
|
||||
jumpHintTouch:"Langer Druck: zur Zeile im Text",
|
||||
acHint:"{n} ID-Vorschläge – ↑/↓ wählt, Enter übernimmt",
|
||||
tipClose:"Schließen",
|
||||
tipOpenLink:"Link öffnen",
|
||||
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.",
|
||||
jumpHint:"Alt+click: 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",
|
||||
tipOpenLink:"Open link",
|
||||
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.",
|
||||
jumpHint:"Alt+clic: 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",
|
||||
tipOpenLink:"Abrir enlace",
|
||||
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.",
|
||||
jumpHint:"Alt+clic : 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",
|
||||
tipOpenLink:"Ouvrir le lien",
|
||||
padReadonly:"Modifié dans le pad — lecture seule ici.",
|
||||
@@ -2330,6 +2479,7 @@ const I18N = {
|
||||
ghostTooltip:"Od rozmiaru M element powinien być dalej podzielony.",
|
||||
jumpHint:"Alt+kliknięcie: 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",
|
||||
tipOpenLink:"Otwórz link",
|
||||
padReadonly:"Edytowane w padzie — tu tylko do czytania.",
|
||||
@@ -2433,6 +2583,7 @@ const I18N = {
|
||||
ghostTooltip:"Начиная с размера M элемент следует далее декомпозировать.",
|
||||
jumpHint:"Alt+клик: перейти к строке в тексте",
|
||||
jumpHintTouch:"Долгое нажатие: перейти к строке в тексте",
|
||||
acHint:"{n} подсказок ID – ↑/↓ выбирает, Enter вставляет",
|
||||
tipClose:"Закрыть",
|
||||
tipOpenLink:"Открыть ссылку",
|
||||
padReadonly:"Редактируется в паде — здесь только чтение.",
|
||||
@@ -2536,6 +2687,7 @@ const I18N = {
|
||||
ghostTooltip:"आकार M से ऊपर किसी तत्व को और अधिक उप-विभाजित करना चाहिए।",
|
||||
jumpHint:"Alt+क्लिक: टेक्स्ट में उस पंक्ति पर जाएँ",
|
||||
jumpHintTouch:"देर तक दबाएँ: टेक्स्ट में उस पंक्ति पर जाएँ",
|
||||
acHint:"{n} आईडी सुझाव – ↑/↓ से चुनें, Enter से डालें",
|
||||
tipClose:"बंद करें",
|
||||
tipOpenLink:"लिंक खोलें",
|
||||
padReadonly:"पैड में संपादित होता है — यहाँ केवल पढ़ें।",
|
||||
@@ -2646,6 +2798,7 @@ const I18N = {
|
||||
ghostTooltip:"从 M 号起,元素应进一步细分。",
|
||||
jumpHint:"Alt+点击:跳转到文本中的该行",
|
||||
jumpHintTouch:"长按:跳转到文本中的该行",
|
||||
acHint:"{n} 个 ID 建议 – ↑/↓ 选择,Enter 插入",
|
||||
tipClose:"关闭",
|
||||
tipOpenLink:"打开链接",
|
||||
padReadonly:"在 Pad 中编辑 — 此处只读。",
|
||||
@@ -2749,6 +2902,7 @@ const I18N = {
|
||||
ghostTooltip:"サイズ M 以上の要素はさらに分解すべきです。",
|
||||
jumpHint:"Alt+クリック:テキストの該当行へ移動",
|
||||
jumpHintTouch:"長押し:テキストの該当行へ移動",
|
||||
acHint:"ID候補 {n} 件 – ↑/↓で選択、Enterで挿入",
|
||||
tipClose:"閉じる",
|
||||
tipOpenLink:"リンクを開く",
|
||||
padReadonly:"パッドで編集します — ここでは読み取り専用です。",
|
||||
|
||||
@@ -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
@@ -1006,6 +1006,33 @@
|
||||
`ul.or .node{box-shadow:none}` spezifischer ist. */
|
||||
#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 --- */
|
||||
ul.and{display:flex;align-items:flex-start;position:relative;padding-top:14px}
|
||||
ul.and::before{
|
||||
@@ -1431,7 +1458,7 @@
|
||||
@media print{
|
||||
html,body{height:auto!important;overflow:visible!important;background:#fff!important}
|
||||
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;
|
||||
height:auto!important;margin:0!important;gap:0!important}
|
||||
.panel.right{grid-area:auto!important;background:#fff!important;border:0!important;
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user