frontend: Sprung zwischen Diagramm und Text (beide Richtungen)
Knoten und Notationszeile sind nun verknüpft. Der Parser hängte die
Zeilennummer ohnehin schon an jeden Knoten — sie wandert jetzt als data-line
ins Markup.
- Diagramm -> Text: Alt+Klick markiert die ganze Zeile im Editor (Fokus,
in Sicht gescrollt); Tastatur Alt+Enter, Touch langer Druck (500 ms,
Wischen bricht ab). Ein zugeklapptes Editor-Panel wird zuerst geöffnet.
- Text -> Diagramm: der Knoten der Cursor-Zeile bekommt weißen Halo +
Tinte-Ring und wird beim Zeilenwechsel ins Bild gescrollt.
- Alt statt einfachem Klick, weil ein Knoten mit URL als <a> den ganzen
Kasten belegt (SPEC §6 bleibt unverändert). Der Handler ruft
preventDefault() — sonst lädt Alt+Klick auf einen Link das Ziel herunter.
Neuer Tooltip-Hinweis an jedem Knoten (i18n `jumpHint`, 9 Sprachen).
- Gescrollt wird über einen Spiegel-div, nicht über Zeilenhöhe x n: lange
Zeilen brechen weich um, die naive Rechnung lag im Test bei 60 Zeilen um
bis zu 525 px daneben.
Beim Verifizieren gefunden und behoben: `ul.or .node{box-shadow:none}` ist
spezifischer als `.node.current` und schluckte den Ring überall unterhalb
einer any-of-Gruppe — die Regel braucht den #out-Präfix.
Verifiziert im Browser (Alt+Klick auf <a>- und div-Knoten, Klick aufs
Größen-Badge, Klick/Enter ohne Alt unverändert, Alt+Enter, kurzer/langer
Druck und Wischen auf Mobil, Panel-Aufklappen aus dem minimierten Zustand,
Zeile nach Sprung immer im sichtbaren Band, Kommentar-/verworfene Zeilen
heben nichts hervor). Vitest 37/37 (3 neue data-line-Tests, Snapshots
aktualisiert). Dokumentiert als D25 + SPEC §9 + frontend/CLAUDE.md + README.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
cdf843e2e3
commit
1f6988b047
@@ -125,6 +125,17 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
|
||||
Endung `.werkbaum` ist reine Konvention (D24, SPEC §12). Beispieldateien zum
|
||||
Ausprobieren: `docs/examples/*.werkbaum` (nacheinander geöffnet ergeben sie
|
||||
mehrere Dokumente im Wähler).
|
||||
- Sprung Diagramm ↔ Text (D25): `render.js` schreibt die Parser-Zeilennummer als
|
||||
`data-line` an jeden Knoten (Geister-Knoten bekommen keine). `jumpToLine()` in
|
||||
`app.js` klappt bei Bedarf das Editor-Panel auf (`revealEditor()`), markiert die
|
||||
**ganze Zeile** und scrollt über einen Spiegel-`div` (`offsetTopInEditor()`) —
|
||||
Zeilenhöhe × n scheitert an weichen Umbrüchen. Ausgelöst per Alt+Klick,
|
||||
Alt+Enter und langem Druck; der Klick-Handler **muss `preventDefault()`** rufen,
|
||||
sonst lädt Alt+Klick auf einen Link-Knoten das Ziel herunter. Gegenrichtung:
|
||||
`syncCaret()` setzt die Klasse `current` auf den Knoten der Cursor-Zeile;
|
||||
`render()` stellt sie nach jedem Neubau wieder her (ohne zu scrollen). Die
|
||||
CSS-Regel braucht den `#out`-Präfix (`ul.or .node{box-shadow:none}` ist
|
||||
spezifischer).
|
||||
- Kleiner Bildschirm: `body.mobile` (per `matchMedia`, ≤ 640 px) stapelt
|
||||
Diagramm/Editor mit **stufenlosem** Splitter (kein Snap/Collapse wie auf
|
||||
Desktop): der Gutter-Drag ruft `setMobileDrow()` (klemmt `--drow` zwischen den
|
||||
|
||||
@@ -66,6 +66,9 @@ function render(){
|
||||
warnings = warnings.slice().sort((a, b) => (a.line || 0) - (b.line || 0));
|
||||
warnBox.innerHTML = warnings.map(w => `<div>⚠ ${formatWarning(w, t)}</div>`).join('');
|
||||
drawCheapPath();
|
||||
/* Der Baum ist neu gebaut — die Markierung der Cursor-Zeile neu setzen (D25).
|
||||
Ohne Scrollen: beim Tippen soll das Diagramm stehen bleiben. */
|
||||
highlightCurrentNode(false);
|
||||
}
|
||||
|
||||
/* ---------- Günstigster-Pfad-Linie ----------
|
||||
@@ -305,6 +308,152 @@ src.addEventListener('keydown', e => {
|
||||
src.addEventListener('input', render);
|
||||
src.addEventListener('input', saveSrc);
|
||||
|
||||
/* ---------- Sprung zwischen Diagramm und Text (D25) ----------
|
||||
Jeder Knoten trägt seine Zeilennummer als `data-line` (render.js).
|
||||
Diagramm -> Text: Alt+Klick (bzw. Alt+Enter am fokussierten Knoten, mobil
|
||||
langer Druck) markiert die Zeile im Textfeld. Text -> Diagramm: die Zeile
|
||||
des Cursors hebt den zugehörigen Knoten hervor. Alt statt einfachem Klick,
|
||||
weil ein Knoten mit URL als <a> den ganzen Kasten belegt (SPEC §6). */
|
||||
|
||||
/* Zeichenbereich einer 1-basierten Zeile; null, wenn es sie nicht (mehr) gibt. */
|
||||
function lineRange(line){
|
||||
const lines = src.value.split('\n');
|
||||
if(!(line >= 1 && line <= lines.length)) return null;
|
||||
let start = 0;
|
||||
for(let i = 0; i < line - 1; i++) start += lines[i].length + 1;
|
||||
return {start, end: start + lines[line - 1].length};
|
||||
}
|
||||
|
||||
/* Vertikale Position eines Zeichenoffsets im Textfeld. Zeilenhöhe × n scheitert
|
||||
an weichen Umbrüchen (lange Zeilen belegen mehrere Bildzeilen), deshalb ein
|
||||
unsichtbarer Spiegel mit gleicher Typografie und Breite plus Marker-Span. */
|
||||
let mirrorEl = null;
|
||||
function offsetTopInEditor(offset){
|
||||
if(!mirrorEl){
|
||||
mirrorEl = document.createElement('div');
|
||||
mirrorEl.setAttribute('aria-hidden', 'true');
|
||||
mirrorEl.style.cssText = 'position:absolute;visibility:hidden;top:0;left:-9999px;' +
|
||||
'white-space:pre-wrap;overflow-wrap:break-word;';
|
||||
document.body.appendChild(mirrorEl);
|
||||
}
|
||||
const cs = getComputedStyle(src);
|
||||
for(const p of ['fontFamily','fontSize','fontWeight','lineHeight','letterSpacing',
|
||||
'paddingTop','paddingLeft','paddingRight','borderTopWidth','tabSize']){
|
||||
mirrorEl.style[p] = cs[p];
|
||||
}
|
||||
mirrorEl.style.width = src.clientWidth + 'px';
|
||||
mirrorEl.textContent = src.value.slice(0, offset);
|
||||
const marker = document.createElement('span');
|
||||
marker.textContent = '';
|
||||
mirrorEl.appendChild(marker);
|
||||
const top = marker.offsetTop;
|
||||
mirrorEl.textContent = '';
|
||||
return top;
|
||||
}
|
||||
|
||||
/* Nur scrollen, wenn die Zeile nicht ohnehin bequem sichtbar ist. */
|
||||
function scrollEditorToOffset(offset){
|
||||
const top = offsetTopInEditor(offset), h = src.clientHeight;
|
||||
if(top < src.scrollTop + 8 || top > src.scrollTop + h - 28){
|
||||
src.scrollTop = Math.max(0, top - h / 2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Ist das Editor-Panel zugeklappt, muss der Sprung es erst öffnen. */
|
||||
function revealEditor(){
|
||||
if(isMobile()){
|
||||
const raw = app.style.getPropertyValue('--drow');
|
||||
const collapsed = raw ? parseFloat(raw) > mobileMaxDrow() - 40 : splitState === 'b';
|
||||
if(collapsed) setMobileDrow(app.getBoundingClientRect().height * 0.45, true);
|
||||
} else if(splitState === 'b'){
|
||||
splitState = 'normal';
|
||||
applySplit();
|
||||
}
|
||||
}
|
||||
|
||||
/* Diagramm -> Text: ganze Zeile markieren (die native Auswahl ist die einzige
|
||||
Hervorhebung, die ein <textarea> kennt — und sie verschwindet beim Tippen). */
|
||||
function jumpToLine(line){
|
||||
const r = lineRange(line);
|
||||
if(!r) return;
|
||||
revealEditor();
|
||||
src.focus({preventScroll: true});
|
||||
src.setSelectionRange(r.start, r.end);
|
||||
scrollEditorToOffset(r.start);
|
||||
caretLine = line;
|
||||
highlightCurrentNode(true);
|
||||
}
|
||||
|
||||
function nodeFromEvent(e){
|
||||
const el = e.target && e.target.closest ? e.target.closest('.node[data-line]') : null;
|
||||
return el && out.contains(el) ? el : null;
|
||||
}
|
||||
|
||||
out.addEventListener('click', e => {
|
||||
if(!e.altKey) return;
|
||||
const el = nodeFromEvent(e);
|
||||
if(!el) return;
|
||||
/* Ohne preventDefault lädt der Browser bei Alt+Klick auf einen Link das Ziel
|
||||
herunter (Chrome/Firefox) — das ist hier ausdrücklich nicht gemeint. */
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
jumpToLine(+el.dataset.line);
|
||||
});
|
||||
|
||||
/* Tastatur: Alt+Enter am fokussierten Knoten. Enter allein bleibt dem Link. */
|
||||
out.addEventListener('keydown', e => {
|
||||
if(e.key !== 'Enter' || !e.altKey) return;
|
||||
const el = nodeFromEvent(e);
|
||||
if(!el) return;
|
||||
e.preventDefault();
|
||||
jumpToLine(+el.dataset.line);
|
||||
});
|
||||
|
||||
/* Mobil gibt es kein Alt: langer Druck (500 ms) auf einen Knoten springt.
|
||||
Der folgende Klick wird unterdrückt, sonst öffnete ein Link-Knoten zusätzlich
|
||||
seine URL; das Kontextmenü/Callout ebenso (die Geste ist hier vergeben). */
|
||||
let pressTimer = null, pressFired = false;
|
||||
out.addEventListener('touchstart', e => {
|
||||
const el = nodeFromEvent(e);
|
||||
pressFired = false;
|
||||
if(!el) return;
|
||||
pressTimer = setTimeout(() => {
|
||||
pressTimer = null;
|
||||
pressFired = true;
|
||||
jumpToLine(+el.dataset.line);
|
||||
}, 500);
|
||||
}, {passive: true});
|
||||
function cancelPress(){ if(pressTimer){ clearTimeout(pressTimer); pressTimer = null; } }
|
||||
out.addEventListener('touchmove', cancelPress, {passive: true});
|
||||
out.addEventListener('touchend', e => {
|
||||
if(pressFired) e.preventDefault();
|
||||
cancelPress();
|
||||
}, {passive: false});
|
||||
out.addEventListener('touchcancel', () => { cancelPress(); pressFired = false; });
|
||||
out.addEventListener('contextmenu', e => { if(pressTimer || pressFired) e.preventDefault(); });
|
||||
|
||||
/* Text -> Diagramm: Knoten der Cursor-Zeile hervorheben. `caretLine` bleibt
|
||||
null, bis der Cursor das erste Mal bewegt wurde — sonst wäre nach dem Laden
|
||||
ohne Zutun die Wurzel markiert. */
|
||||
let caretLine = null, currentNodeEl = null;
|
||||
function highlightCurrentNode(scroll){
|
||||
if(currentNodeEl) currentNodeEl.classList.remove('current');
|
||||
currentNodeEl = caretLine == null
|
||||
? null
|
||||
: out.querySelector('.node[data-line="' + caretLine + '"]');
|
||||
if(!currentNodeEl) return;
|
||||
currentNodeEl.classList.add('current');
|
||||
/* Nur beim Zeilenwechsel scrollen, sonst ruckelte das Diagramm beim Tippen. */
|
||||
if(scroll) currentNodeEl.scrollIntoView({block:'nearest', inline:'nearest', behavior:'smooth'});
|
||||
}
|
||||
function syncCaret(){
|
||||
const line = src.value.slice(0, src.selectionStart).split('\n').length;
|
||||
const moved = line !== caretLine;
|
||||
caretLine = line;
|
||||
highlightCurrentNode(moved);
|
||||
}
|
||||
for(const ev of ['click','keyup','input','focus']) src.addEventListener(ev, syncCaret);
|
||||
|
||||
const app = document.getElementById('app');
|
||||
function applyLayout(mode){
|
||||
out.classList.toggle('vertical', mode === 'vertikal');
|
||||
@@ -569,6 +718,7 @@ const I18N = {
|
||||
privacy:"Datenschutz",
|
||||
legendTooltip:"Legende ein-/ausblenden",
|
||||
ghostTooltip:"Ab Größe M sollte ein Element weiter untergliedert werden.",
|
||||
jumpHint:"Alt+Klick: zur Zeile im Text",
|
||||
riskTooltip:"High Risk – Aufwand noch unklar.",
|
||||
discardedTooltip:"Verworfene Knoten samt Teilbaum ein-/ausblenden",
|
||||
cheapTooltip:"Günstigsten Pfad hervorheben – nicht benötigte Alternativen treten zurück",
|
||||
@@ -615,6 +765,7 @@ const I18N = {
|
||||
privacy:"Privacy",
|
||||
legendTooltip:"Show/hide legend",
|
||||
ghostTooltip:"From size M upward, an item should be broken down further.",
|
||||
jumpHint:"Alt+click: jump to the line in the text",
|
||||
riskTooltip:"High risk – effort still unclear.",
|
||||
discardedTooltip:"Show/hide discarded nodes and their subtree",
|
||||
cheapTooltip:"Highlight the cheapest path – unneeded alternatives recede",
|
||||
@@ -661,6 +812,7 @@ const I18N = {
|
||||
privacy:"Privacidad",
|
||||
legendTooltip:"Mostrar u ocultar la leyenda",
|
||||
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",
|
||||
riskTooltip:"Alto riesgo – esfuerzo aún incierto.",
|
||||
discardedTooltip:"Mostrar u ocultar los nodos descartados y su subárbol",
|
||||
cheapTooltip:"Resaltar la ruta más económica: las alternativas no necesarias se atenúan",
|
||||
@@ -707,6 +859,7 @@ const I18N = {
|
||||
privacy:"Confidentialité",
|
||||
legendTooltip:"Afficher/masquer la légende",
|
||||
ghostTooltip:"À partir de la taille M, un élément devrait être décomposé davantage.",
|
||||
jumpHint:"Alt+clic : aller à la ligne dans le texte",
|
||||
riskTooltip:"Risque élevé – effort encore incertain.",
|
||||
discardedTooltip:"Afficher/masquer les nœuds abandonnés et leur sous-arbre",
|
||||
cheapTooltip:"Mettre en évidence le chemin le moins coûteux – les alternatives inutiles s'estompent",
|
||||
@@ -753,6 +906,7 @@ const I18N = {
|
||||
privacy:"Prywatność",
|
||||
legendTooltip:"Pokaż/ukryj legendę",
|
||||
ghostTooltip:"Od rozmiaru M element powinien być dalej podzielony.",
|
||||
jumpHint:"Alt+kliknięcie: przejdź do wiersza w tekście",
|
||||
riskTooltip:"Wysokie ryzyko – nakład jeszcze niejasny.",
|
||||
discardedTooltip:"Pokaż/ukryj odrzucone węzły wraz z poddrzewem",
|
||||
cheapTooltip:"Wyróżnij najtańszą ścieżkę – niepotrzebne alternatywy są przygaszone",
|
||||
@@ -799,6 +953,7 @@ const I18N = {
|
||||
privacy:"Конфиденциальность",
|
||||
legendTooltip:"Показать/скрыть легенду",
|
||||
ghostTooltip:"Начиная с размера M элемент следует далее декомпозировать.",
|
||||
jumpHint:"Alt+клик: перейти к строке в тексте",
|
||||
riskTooltip:"Высокий риск – оценка ещё не ясна.",
|
||||
discardedTooltip:"Показать/скрыть отклонённые узлы вместе с поддеревом",
|
||||
cheapTooltip:"Выделить самый дешёвый путь — ненужные альтернативы приглушаются",
|
||||
@@ -845,6 +1000,7 @@ const I18N = {
|
||||
privacy:"गोपनीयता",
|
||||
legendTooltip:"लेजेंड दिखाएँ/छिपाएँ",
|
||||
ghostTooltip:"आकार M से ऊपर किसी तत्व को और अधिक उप-विभाजित करना चाहिए।",
|
||||
jumpHint:"Alt+क्लिक: टेक्स्ट में उस पंक्ति पर जाएँ",
|
||||
riskTooltip:"उच्च जोखिम – प्रयास अभी अस्पष्ट।",
|
||||
discardedTooltip:"अस्वीकृत नोड्स और उनके उप-वृक्ष दिखाएँ/छिपाएँ",
|
||||
cheapTooltip:"सबसे किफ़ायती पथ को उजागर करें – अनावश्यक विकल्प मंद हो जाते हैं",
|
||||
@@ -896,6 +1052,7 @@ const I18N = {
|
||||
cheapTooltip:"突出显示成本最低的路径——不需要的备选项将淡化",
|
||||
implicitSizeTooltip:"未指定尺寸——成本估算时按 M 计",
|
||||
ghostTooltip:"从 M 号起,元素应进一步细分。",
|
||||
jumpHint:"Alt+点击:跳转到文本中的该行",
|
||||
riskTooltip:"高风险 – 工作量尚不明确。",
|
||||
editorTitle:"结构(文本)", diagramTitle:"图表",
|
||||
docSwitchTooltip:"选择或管理文档", docMenuAria:"文档",
|
||||
@@ -942,6 +1099,7 @@ const I18N = {
|
||||
cheapTooltip:"最も低コストの経路を強調 – 不要な選択肢は控えめに表示",
|
||||
implicitSizeTooltip:"サイズ未指定 – コスト見積もりのため M として扱う",
|
||||
ghostTooltip:"サイズ M 以上の要素はさらに分解すべきです。",
|
||||
jumpHint:"Alt+クリック:テキストの該当行へ移動",
|
||||
riskTooltip:"高リスク – 規模はまだ不明。",
|
||||
editorTitle:"構造(テキスト)", diagramTitle:"ダイアグラム",
|
||||
docSwitchTooltip:"ドキュメントを選択・管理", docMenuAria:"ドキュメント",
|
||||
|
||||
@@ -39,7 +39,13 @@ function nodeHtml(n, extra, opts){
|
||||
const need = needsBreakdown(n);
|
||||
const cls = ['node', extra || '', n.status ? 'st-' + n.status.key : '']
|
||||
.filter(Boolean).join(' ');
|
||||
const title = n.status ? ` title="${attr(t('st_' + n.status.key))}"` : '';
|
||||
/* Zeilennummer am Knoten (D25): Grundlage für den Sprung ins Textfeld und
|
||||
für die Gegenrichtung (Cursor-Zeile -> Knoten hervorheben). Der Hinweis im
|
||||
Tooltip macht die sonst unsichtbare Alt-Klick-Geste auffindbar. */
|
||||
const lineAttr = n.line ? ` data-line="${n.line}"` : '';
|
||||
const tip = [n.status ? t('st_' + n.status.key) : '', t('jumpHint')]
|
||||
.filter(Boolean).join(' · ');
|
||||
const title = ` title="${attr(tip)}"`;
|
||||
const tagsHtml = n.tags && n.tags.length
|
||||
? `<span class="tags" aria-hidden="true">${n.tags.map(tag => `<span class="tag">${esc(tag)}</span>`).join('')}</span>`
|
||||
: '';
|
||||
@@ -59,8 +65,8 @@ function nodeHtml(n, extra, opts){
|
||||
tagsHtml;
|
||||
const aria = ` aria-label="${attr(nodeAria(n, opts))}"`;
|
||||
const html = n.url
|
||||
? `<a class="${cls}" href="${attr(n.url)}" target="_blank" rel="noopener"${aria}${title}>${inner}</a>`
|
||||
: `<div class="${cls}" tabindex="0"${aria}${title}>${inner}</div>`;
|
||||
? `<a class="${cls}" href="${attr(n.url)}" target="_blank" rel="noopener"${lineAttr}${aria}${title}>${inner}</a>`
|
||||
: `<div class="${cls}" tabindex="0"${lineAttr}${aria}${title}>${inner}</div>`;
|
||||
const ghostTip = attr(t('ghostTooltip'));
|
||||
const ghost = `<div class="ghost-node" aria-label="${ghostTip}" title="${ghostTip}">${esc(t('ghost'))}</div>`;
|
||||
return html + (need ? ghost : '');
|
||||
|
||||
@@ -434,6 +434,15 @@
|
||||
/* Sichtbarer Tastatur-Fokus (Knoten sind fokussierbar: Fokusreihenfolge =
|
||||
Lese-/Dokumentreihenfolge; Screenreader liest den aria-label). */
|
||||
.node:focus-visible{outline:2px solid var(--or);outline-offset:2px;box-shadow:0 2px 8px rgba(36,52,71,.22)}
|
||||
/* Knoten der Cursor-Zeile im Editor (D25). Weißer Halo + Tinte-Ring: hebt sich
|
||||
von allen Pastell-Status UND vom dunklen Wurzelknoten ab und ist vom
|
||||
petrolfarbenen Fokusring unterscheidbar. Rein visuelle Editierhilfe — nicht
|
||||
im Druck. Der `#out`-Präfix ist nötig: `ul.or .node{box-shadow:none}`
|
||||
(weiter unten) ist spezifischer als `.node.current` und schluckte den Ring
|
||||
sonst überall unterhalb einer any-of-Gruppe. */
|
||||
#out .node.current{box-shadow:0 0 0 2px var(--card),0 0 0 4px var(--ink)}
|
||||
/* Auf Touch ist der lange Druck die Sprung-Geste — kein iOS-Link-Callout. */
|
||||
a.node{-webkit-touch-callout:none}
|
||||
.ext{font-size:.72em;margin-left:5px;opacity:.55}
|
||||
.size{
|
||||
position:absolute;top:-9px;right:-9px;
|
||||
@@ -747,5 +756,6 @@
|
||||
*{-webkit-print-color-adjust:exact!important;print-color-adjust:exact!important}
|
||||
/* Knoten nicht über den Seitenrand zerschneiden */
|
||||
#out li{break-inside:avoid}
|
||||
.node.current{box-shadow:none!important} /* Editierhilfe, nicht drucken (D25) */
|
||||
@page{margin:12mm}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`renderTreeHtml — kanonisches Beispiel > Grundzustand (Pfad aus, verworfene aus): Struktur-Snapshot 1`] = `"<li class="has-and"><a class="node root-node st-arbeit" href="https://wiki.example.de/relaunch" target="_blank" rel="noopener" aria-label="Website-Relaunch, a11yStatus, a11ySize, a11yLink" title="st_arbeit">Website-Relaunch<span class="ext" aria-hidden="true">↗</span><span class="size" aria-hidden="true">XL</span></a><ul class="and"><li class="has-and"><div class="node st-fertig" tabindex="0" aria-label="Konzeption, a11yStatus, a11ySize" title="st_fertig">Konzeption<span class="size" aria-hidden="true">M</span></div><ul class="and"><li><div class="node st-fertig" tabindex="0" aria-label="Zielgruppenanalyse, a11yStatus, a11ySize" title="st_fertig">Zielgruppenanalyse<span class="size" aria-hidden="true">S</span></div></li><li><div class="node st-fertig" tabindex="0" aria-label="Sitemap, a11yStatus, a11ySize" title="st_fertig">Sitemap<span class="size" aria-hidden="true">XS</span></div></li></ul></li><li class="has-and"><div class="node st-arbeit" tabindex="0" aria-label="Umsetzung, a11yStatus, a11ySize" title="st_arbeit">Umsetzung<span class="size" aria-hidden="true">XL</span></div><ul class="and"><li><a class="node st-durchstich" href="https://git.example.de/frontend" target="_blank" rel="noopener" aria-label="Frontend, a11yStatus, a11ySize, a11yTags, a11yLink" title="st_durchstich">Frontend<span class="ext" aria-hidden="true">↗</span><span class="size" aria-hidden="true">S</span><span class="tags" aria-hidden="true"><span class="tag">anna</span></span></a></li><li><div class="node st-geplant" tabindex="0" aria-label="Backend, a11yStatus, a11ySize, a11yTags" title="st_geplant">Backend<span class="size" aria-hidden="true">L</span><span class="tags" aria-hidden="true"><span class="tag">ben</span><span class="tag">carla</span></span></div><div class="ghost-node" aria-label="ghostTooltip" title="ghostTooltip">ghost</div></li><li class="has-or"><div class="node st-geplant" tabindex="0" aria-label="CMS-Anbindung, a11yStatus, a11ySize" title="st_geplant">CMS-Anbindung<span class="size" aria-hidden="true">M</span></div><ul class="or"><li><div class="node st-geplant" tabindex="0" aria-label="WordPress, a11yStatus" title="st_geplant">WordPress</div></li><li><div class="node st-idee" tabindex="0" aria-label="Headless CMS, a11yStatus" title="st_idee">Headless CMS</div></li></ul></li></ul></li><li class="has-or"><div class="node st-idee" tabindex="0" aria-label="Hosting, a11yStatus, a11ySize" title="st_idee">Hosting<span class="size" aria-hidden="true">M</span></div><ul class="or"><li><div class="node" tabindex="0" aria-label="Cloud">Cloud</div></li><li><div class="node" tabindex="0" aria-label="On-Premise">On-Premise</div></li></ul></li></ul></li>"`;
|
||||
exports[`renderTreeHtml — kanonisches Beispiel > Grundzustand (Pfad aus, verworfene aus): Struktur-Snapshot 1`] = `"<li class="has-and"><a class="node root-node st-arbeit" href="https://wiki.example.de/relaunch" target="_blank" rel="noopener" data-line="2" aria-label="Website-Relaunch, a11yStatus, a11ySize, a11yLink" title="st_arbeit · jumpHint">Website-Relaunch<span class="ext" aria-hidden="true">↗</span><span class="size" aria-hidden="true">XL</span></a><ul class="and"><li class="has-and"><div class="node st-fertig" tabindex="0" data-line="3" aria-label="Konzeption, a11yStatus, a11ySize" title="st_fertig · jumpHint">Konzeption<span class="size" aria-hidden="true">M</span></div><ul class="and"><li><div class="node st-fertig" tabindex="0" data-line="4" aria-label="Zielgruppenanalyse, a11yStatus, a11ySize" title="st_fertig · jumpHint">Zielgruppenanalyse<span class="size" aria-hidden="true">S</span></div></li><li><div class="node st-fertig" tabindex="0" data-line="5" aria-label="Sitemap, a11yStatus, a11ySize" title="st_fertig · jumpHint">Sitemap<span class="size" aria-hidden="true">XS</span></div></li></ul></li><li class="has-and"><div class="node st-arbeit" tabindex="0" data-line="6" aria-label="Umsetzung, a11yStatus, a11ySize" title="st_arbeit · jumpHint">Umsetzung<span class="size" aria-hidden="true">XL</span></div><ul class="and"><li><a class="node st-durchstich" href="https://git.example.de/frontend" target="_blank" rel="noopener" data-line="7" aria-label="Frontend, a11yStatus, a11ySize, a11yTags, a11yLink" title="st_durchstich · jumpHint">Frontend<span class="ext" aria-hidden="true">↗</span><span class="size" aria-hidden="true">S</span><span class="tags" aria-hidden="true"><span class="tag">anna</span></span></a></li><li><div class="node st-geplant" tabindex="0" data-line="8" aria-label="Backend, a11yStatus, a11ySize, a11yTags" title="st_geplant · jumpHint">Backend<span class="size" aria-hidden="true">L</span><span class="tags" aria-hidden="true"><span class="tag">ben</span><span class="tag">carla</span></span></div><div class="ghost-node" aria-label="ghostTooltip" title="ghostTooltip">ghost</div></li><li class="has-or"><div class="node st-geplant" tabindex="0" data-line="9" aria-label="CMS-Anbindung, a11yStatus, a11ySize" title="st_geplant · jumpHint">CMS-Anbindung<span class="size" aria-hidden="true">M</span></div><ul class="or"><li><div class="node st-geplant" tabindex="0" data-line="10" aria-label="WordPress, a11yStatus" title="st_geplant · jumpHint">WordPress</div></li><li><div class="node st-idee" tabindex="0" data-line="11" aria-label="Headless CMS, a11yStatus" title="st_idee · jumpHint">Headless CMS</div></li></ul></li></ul></li><li class="has-or"><div class="node st-idee" tabindex="0" data-line="13" aria-label="Hosting, a11yStatus, a11ySize" title="st_idee · jumpHint">Hosting<span class="size" aria-hidden="true">M</span></div><ul class="or"><li><div class="node" tabindex="0" data-line="14" aria-label="Cloud" title="jumpHint">Cloud</div></li><li><div class="node" tabindex="0" data-line="15" aria-label="On-Premise" title="jumpHint">On-Premise</div></li></ul></li></ul></li>"`;
|
||||
|
||||
exports[`renderTreeHtml — kanonisches Beispiel > günstigster Pfad an: cheap/cheap-leaf + implizite M-Badges 1`] = `"<li class="has-and"><a class="node root-node cheap st-arbeit" href="https://wiki.example.de/relaunch" target="_blank" rel="noopener" aria-label="Website-Relaunch, a11yStatus, a11ySize, a11yLink" title="st_arbeit">Website-Relaunch<span class="ext" aria-hidden="true">↗</span><span class="size" aria-hidden="true">XL</span></a><ul class="and"><li class="has-and"><div class="node cheap st-fertig" tabindex="0" aria-label="Konzeption, a11yStatus, a11ySize" title="st_fertig">Konzeption<span class="size" aria-hidden="true">M</span></div><ul class="and"><li><div class="node cheap cheap-leaf st-fertig" tabindex="0" aria-label="Zielgruppenanalyse, a11yStatus, a11ySize" title="st_fertig">Zielgruppenanalyse<span class="size" aria-hidden="true">S</span></div></li><li><div class="node cheap cheap-leaf st-fertig" tabindex="0" aria-label="Sitemap, a11yStatus, a11ySize" title="st_fertig">Sitemap<span class="size" aria-hidden="true">XS</span></div></li></ul></li><li class="has-and"><div class="node cheap st-arbeit" tabindex="0" aria-label="Umsetzung, a11yStatus, a11ySize" title="st_arbeit">Umsetzung<span class="size" aria-hidden="true">XL</span></div><ul class="and"><li><a class="node cheap cheap-leaf st-durchstich" href="https://git.example.de/frontend" target="_blank" rel="noopener" aria-label="Frontend, a11yStatus, a11ySize, a11yTags, a11yLink" title="st_durchstich">Frontend<span class="ext" aria-hidden="true">↗</span><span class="size" aria-hidden="true">S</span><span class="tags" aria-hidden="true"><span class="tag">anna</span></span></a></li><li><div class="node cheap cheap-leaf st-geplant" tabindex="0" aria-label="Backend, a11yStatus, a11ySize, a11yTags" title="st_geplant">Backend<span class="size" aria-hidden="true">L</span><span class="tags" aria-hidden="true"><span class="tag">ben</span><span class="tag">carla</span></span></div><div class="ghost-node" aria-label="ghostTooltip" title="ghostTooltip">ghost</div></li><li class="has-or"><div class="node cheap st-geplant" tabindex="0" aria-label="CMS-Anbindung, a11yStatus, a11ySize" title="st_geplant">CMS-Anbindung<span class="size" aria-hidden="true">M</span></div><ul class="or"><li><div class="node cheap cheap-leaf st-geplant" tabindex="0" aria-label="WordPress, a11yStatus, a11ySizeImplicit" title="st_geplant">WordPress<span class="size implicit" aria-hidden="true" title="implicitSizeTooltip">M</span></div></li><li><div class="node st-idee" tabindex="0" aria-label="Headless CMS, a11yStatus, a11ySizeImplicit" title="st_idee">Headless CMS<span class="size implicit" aria-hidden="true" title="implicitSizeTooltip">M</span></div></li></ul></li></ul></li><li class="has-or"><div class="node cheap st-idee" tabindex="0" aria-label="Hosting, a11yStatus, a11ySize" title="st_idee">Hosting<span class="size" aria-hidden="true">M</span></div><ul class="or"><li><div class="node cheap cheap-leaf" tabindex="0" aria-label="Cloud, a11ySizeImplicit">Cloud<span class="size implicit" aria-hidden="true" title="implicitSizeTooltip">M</span></div></li><li><div class="node" tabindex="0" aria-label="On-Premise, a11ySizeImplicit">On-Premise<span class="size implicit" aria-hidden="true" title="implicitSizeTooltip">M</span></div></li></ul></li></ul></li>"`;
|
||||
exports[`renderTreeHtml — kanonisches Beispiel > günstigster Pfad an: cheap/cheap-leaf + implizite M-Badges 1`] = `"<li class="has-and"><a class="node root-node cheap st-arbeit" href="https://wiki.example.de/relaunch" target="_blank" rel="noopener" data-line="2" aria-label="Website-Relaunch, a11yStatus, a11ySize, a11yLink" title="st_arbeit · jumpHint">Website-Relaunch<span class="ext" aria-hidden="true">↗</span><span class="size" aria-hidden="true">XL</span></a><ul class="and"><li class="has-and"><div class="node cheap st-fertig" tabindex="0" data-line="3" aria-label="Konzeption, a11yStatus, a11ySize" title="st_fertig · jumpHint">Konzeption<span class="size" aria-hidden="true">M</span></div><ul class="and"><li><div class="node cheap cheap-leaf st-fertig" tabindex="0" data-line="4" aria-label="Zielgruppenanalyse, a11yStatus, a11ySize" title="st_fertig · jumpHint">Zielgruppenanalyse<span class="size" aria-hidden="true">S</span></div></li><li><div class="node cheap cheap-leaf st-fertig" tabindex="0" data-line="5" aria-label="Sitemap, a11yStatus, a11ySize" title="st_fertig · jumpHint">Sitemap<span class="size" aria-hidden="true">XS</span></div></li></ul></li><li class="has-and"><div class="node cheap st-arbeit" tabindex="0" data-line="6" aria-label="Umsetzung, a11yStatus, a11ySize" title="st_arbeit · jumpHint">Umsetzung<span class="size" aria-hidden="true">XL</span></div><ul class="and"><li><a class="node cheap cheap-leaf st-durchstich" href="https://git.example.de/frontend" target="_blank" rel="noopener" data-line="7" aria-label="Frontend, a11yStatus, a11ySize, a11yTags, a11yLink" title="st_durchstich · jumpHint">Frontend<span class="ext" aria-hidden="true">↗</span><span class="size" aria-hidden="true">S</span><span class="tags" aria-hidden="true"><span class="tag">anna</span></span></a></li><li><div class="node cheap cheap-leaf st-geplant" tabindex="0" data-line="8" aria-label="Backend, a11yStatus, a11ySize, a11yTags" title="st_geplant · jumpHint">Backend<span class="size" aria-hidden="true">L</span><span class="tags" aria-hidden="true"><span class="tag">ben</span><span class="tag">carla</span></span></div><div class="ghost-node" aria-label="ghostTooltip" title="ghostTooltip">ghost</div></li><li class="has-or"><div class="node cheap st-geplant" tabindex="0" data-line="9" aria-label="CMS-Anbindung, a11yStatus, a11ySize" title="st_geplant · jumpHint">CMS-Anbindung<span class="size" aria-hidden="true">M</span></div><ul class="or"><li><div class="node cheap cheap-leaf st-geplant" tabindex="0" data-line="10" aria-label="WordPress, a11yStatus, a11ySizeImplicit" title="st_geplant · jumpHint">WordPress<span class="size implicit" aria-hidden="true" title="implicitSizeTooltip">M</span></div></li><li><div class="node st-idee" tabindex="0" data-line="11" aria-label="Headless CMS, a11yStatus, a11ySizeImplicit" title="st_idee · jumpHint">Headless CMS<span class="size implicit" aria-hidden="true" title="implicitSizeTooltip">M</span></div></li></ul></li></ul></li><li class="has-or"><div class="node cheap st-idee" tabindex="0" data-line="13" aria-label="Hosting, a11yStatus, a11ySize" title="st_idee · jumpHint">Hosting<span class="size" aria-hidden="true">M</span></div><ul class="or"><li><div class="node cheap cheap-leaf" tabindex="0" data-line="14" aria-label="Cloud, a11ySizeImplicit" title="jumpHint">Cloud<span class="size implicit" aria-hidden="true" title="implicitSizeTooltip">M</span></div></li><li><div class="node" tabindex="0" data-line="15" aria-label="On-Premise, a11ySizeImplicit" title="jumpHint">On-Premise<span class="size implicit" aria-hidden="true" title="implicitSizeTooltip">M</span></div></li></ul></li></ul></li>"`;
|
||||
|
||||
exports[`renderTreeHtml — kanonisches Beispiel > verworfene einblenden: Eigenentwicklung erscheint (durchgestrichen) 1`] = `"<li class="has-and"><a class="node root-node st-arbeit" href="https://wiki.example.de/relaunch" target="_blank" rel="noopener" aria-label="Website-Relaunch, a11yStatus, a11ySize, a11yLink" title="st_arbeit">Website-Relaunch<span class="ext" aria-hidden="true">↗</span><span class="size" aria-hidden="true">XL</span></a><ul class="and"><li class="has-and"><div class="node st-fertig" tabindex="0" aria-label="Konzeption, a11yStatus, a11ySize" title="st_fertig">Konzeption<span class="size" aria-hidden="true">M</span></div><ul class="and"><li><div class="node st-fertig" tabindex="0" aria-label="Zielgruppenanalyse, a11yStatus, a11ySize" title="st_fertig">Zielgruppenanalyse<span class="size" aria-hidden="true">S</span></div></li><li><div class="node st-fertig" tabindex="0" aria-label="Sitemap, a11yStatus, a11ySize" title="st_fertig">Sitemap<span class="size" aria-hidden="true">XS</span></div></li></ul></li><li class="has-and"><div class="node st-arbeit" tabindex="0" aria-label="Umsetzung, a11yStatus, a11ySize" title="st_arbeit">Umsetzung<span class="size" aria-hidden="true">XL</span></div><ul class="and"><li><a class="node st-durchstich" href="https://git.example.de/frontend" target="_blank" rel="noopener" aria-label="Frontend, a11yStatus, a11ySize, a11yTags, a11yLink" title="st_durchstich">Frontend<span class="ext" aria-hidden="true">↗</span><span class="size" aria-hidden="true">S</span><span class="tags" aria-hidden="true"><span class="tag">anna</span></span></a></li><li><div class="node st-geplant" tabindex="0" aria-label="Backend, a11yStatus, a11ySize, a11yTags" title="st_geplant">Backend<span class="size" aria-hidden="true">L</span><span class="tags" aria-hidden="true"><span class="tag">ben</span><span class="tag">carla</span></span></div><div class="ghost-node" aria-label="ghostTooltip" title="ghostTooltip">ghost</div></li><li class="has-or"><div class="node st-geplant" tabindex="0" aria-label="CMS-Anbindung, a11yStatus, a11ySize" title="st_geplant">CMS-Anbindung<span class="size" aria-hidden="true">M</span></div><ul class="or"><li><div class="node st-geplant" tabindex="0" aria-label="WordPress, a11yStatus" title="st_geplant">WordPress</div></li><li><div class="node st-idee" tabindex="0" aria-label="Headless CMS, a11yStatus" title="st_idee">Headless CMS</div></li><li><div class="node st-verworfen" tabindex="0" aria-label="Eigenentwicklung, a11yStatus" title="st_verworfen">Eigenentwicklung</div></li></ul></li></ul></li><li class="has-or"><div class="node st-idee" tabindex="0" aria-label="Hosting, a11yStatus, a11ySize" title="st_idee">Hosting<span class="size" aria-hidden="true">M</span></div><ul class="or"><li><div class="node" tabindex="0" aria-label="Cloud">Cloud</div></li><li><div class="node" tabindex="0" aria-label="On-Premise">On-Premise</div></li></ul></li></ul></li>"`;
|
||||
exports[`renderTreeHtml — kanonisches Beispiel > verworfene einblenden: Eigenentwicklung erscheint (durchgestrichen) 1`] = `"<li class="has-and"><a class="node root-node st-arbeit" href="https://wiki.example.de/relaunch" target="_blank" rel="noopener" data-line="2" aria-label="Website-Relaunch, a11yStatus, a11ySize, a11yLink" title="st_arbeit · jumpHint">Website-Relaunch<span class="ext" aria-hidden="true">↗</span><span class="size" aria-hidden="true">XL</span></a><ul class="and"><li class="has-and"><div class="node st-fertig" tabindex="0" data-line="3" aria-label="Konzeption, a11yStatus, a11ySize" title="st_fertig · jumpHint">Konzeption<span class="size" aria-hidden="true">M</span></div><ul class="and"><li><div class="node st-fertig" tabindex="0" data-line="4" aria-label="Zielgruppenanalyse, a11yStatus, a11ySize" title="st_fertig · jumpHint">Zielgruppenanalyse<span class="size" aria-hidden="true">S</span></div></li><li><div class="node st-fertig" tabindex="0" data-line="5" aria-label="Sitemap, a11yStatus, a11ySize" title="st_fertig · jumpHint">Sitemap<span class="size" aria-hidden="true">XS</span></div></li></ul></li><li class="has-and"><div class="node st-arbeit" tabindex="0" data-line="6" aria-label="Umsetzung, a11yStatus, a11ySize" title="st_arbeit · jumpHint">Umsetzung<span class="size" aria-hidden="true">XL</span></div><ul class="and"><li><a class="node st-durchstich" href="https://git.example.de/frontend" target="_blank" rel="noopener" data-line="7" aria-label="Frontend, a11yStatus, a11ySize, a11yTags, a11yLink" title="st_durchstich · jumpHint">Frontend<span class="ext" aria-hidden="true">↗</span><span class="size" aria-hidden="true">S</span><span class="tags" aria-hidden="true"><span class="tag">anna</span></span></a></li><li><div class="node st-geplant" tabindex="0" data-line="8" aria-label="Backend, a11yStatus, a11ySize, a11yTags" title="st_geplant · jumpHint">Backend<span class="size" aria-hidden="true">L</span><span class="tags" aria-hidden="true"><span class="tag">ben</span><span class="tag">carla</span></span></div><div class="ghost-node" aria-label="ghostTooltip" title="ghostTooltip">ghost</div></li><li class="has-or"><div class="node st-geplant" tabindex="0" data-line="9" aria-label="CMS-Anbindung, a11yStatus, a11ySize" title="st_geplant · jumpHint">CMS-Anbindung<span class="size" aria-hidden="true">M</span></div><ul class="or"><li><div class="node st-geplant" tabindex="0" data-line="10" aria-label="WordPress, a11yStatus" title="st_geplant · jumpHint">WordPress</div></li><li><div class="node st-idee" tabindex="0" data-line="11" aria-label="Headless CMS, a11yStatus" title="st_idee · jumpHint">Headless CMS</div></li><li><div class="node st-verworfen" tabindex="0" data-line="12" aria-label="Eigenentwicklung, a11yStatus" title="st_verworfen · jumpHint">Eigenentwicklung</div></li></ul></li></ul></li><li class="has-or"><div class="node st-idee" tabindex="0" data-line="13" aria-label="Hosting, a11yStatus, a11ySize" title="st_idee · jumpHint">Hosting<span class="size" aria-hidden="true">M</span></div><ul class="or"><li><div class="node" tabindex="0" data-line="14" aria-label="Cloud" title="jumpHint">Cloud</div></li><li><div class="node" tabindex="0" data-line="15" aria-label="On-Premise" title="jumpHint">On-Premise</div></li></ul></li></ul></li>"`;
|
||||
|
||||
@@ -69,7 +69,7 @@ describe('renderTreeHtml — „Untergliederung fehlt" (Geister-Knoten, SPEC §5
|
||||
const {html} = renderTreeHtml(roots, {t, showDiscarded: false, cheapPath: false, cheapSet: new Set()});
|
||||
expect(count(html, 'ghost-node')).toBe(1);
|
||||
expect(html).toContain('title="ghostTooltip"');
|
||||
expect(html).toMatchInlineSnapshot(`"<li><div class="node root-node st-geplant" tabindex="0" aria-label="Großes Paket, a11yStatus, a11ySize" title="st_geplant">Großes Paket<span class="size" aria-hidden="true">L</span></div><div class="ghost-node" aria-label="ghostTooltip" title="ghostTooltip">ghost</div></li>"`);
|
||||
expect(html).toMatchInlineSnapshot(`"<li><div class="node root-node st-geplant" tabindex="0" data-line="1" aria-label="Großes Paket, a11yStatus, a11ySize" title="st_geplant · jumpHint">Großes Paket<span class="size" aria-hidden="true">L</span></div><div class="ghost-node" aria-label="ghostTooltip" title="ghostTooltip">ghost</div></li>"`);
|
||||
});
|
||||
|
||||
it('verworfenes M+ löst die Regel nicht aus', () => {
|
||||
@@ -102,3 +102,37 @@ describe('renderTreeHtml — Moduswechsel ist CSS, nicht Renderer', () => {
|
||||
// vertikal, kompakt) — sie unterscheiden sich nur in der Container-Klasse.
|
||||
});
|
||||
});
|
||||
|
||||
/* Grundlage für den Sprung Diagramm <-> Text (D25, SPEC §9): Jeder Knoten muss
|
||||
die Zeilennummer aus dem Parser als data-line tragen — auch verlinkte Knoten
|
||||
(die als <a> gerendert werden) und Knoten in any-of-Gruppen. */
|
||||
describe('renderTreeHtml — data-line je Knoten (D25)', () => {
|
||||
const lineOf = (html, label) => {
|
||||
const re = new RegExp('<(?:a|div) class="node[^"]*"[^>]*?data-line="(\\d+)"[^>]*>' + label);
|
||||
const m = html.match(re);
|
||||
return m ? Number(m[1]) : null;
|
||||
};
|
||||
|
||||
it('trägt die Parser-Zeilennummer an jeden Knoten', () => {
|
||||
const {html} = renderExample({showDiscarded: true});
|
||||
expect(lineOf(html, 'Website-Relaunch')).toBe(2); // Zeile 1 ist ein %%-Kommentar
|
||||
expect(lineOf(html, 'Zielgruppenanalyse')).toBe(4);
|
||||
expect(lineOf(html, 'Frontend')).toBe(7); // verlinkt -> <a>
|
||||
expect(lineOf(html, 'Headless CMS')).toBe(11); // any-of-Alternative
|
||||
expect(lineOf(html, 'Eigenentwicklung')).toBe(12); // verworfen, eingeblendet
|
||||
});
|
||||
|
||||
it('vergibt jede Zeilennummer genau einmal', () => {
|
||||
const {html} = renderExample({showDiscarded: true});
|
||||
const lines = [...html.matchAll(/class="node[^"]*"[^>]*?data-line="(\d+)"/g)].map(m => m[1]);
|
||||
expect(lines.length).toBe(new Set(lines).size);
|
||||
expect(lines.length).toBe(14); // alle Zeilen des SPEC-Beispiels außer dem Kommentar
|
||||
});
|
||||
|
||||
it('der Geister-Knoten bekommt keine Zeilennummer (er steht für keine Zeile)', () => {
|
||||
const {html} = renderTreeHtml(parse('[ ] Großes Paket (L)').roots,
|
||||
{t, showDiscarded: false, cheapPath: false, cheapSet: new Set()});
|
||||
expect(html).toMatch(/class="ghost-node"/);
|
||||
expect(html).not.toMatch(/ghost-node[^>]*data-line/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user