From cbe798d7460009afb8abcba5ce0354694e0c0882 Mon Sep 17 00:00:00 2001 From: mhoennig Date: Sat, 22 Aug 2026 16:47:34 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Faltmarken=20(`>`/`<`)=20=E2=80=94=20Te?= =?UTF-8?q?ilb=C3=A4ume=20ein-/ausklappbar=20(SPEC=20=C2=A71/=C2=A79,=20D3?= =?UTF-8?q?8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parser liest die Marke zwischen Zeichen und Statusbox (Leerraum-Regel, `- >Achtung` bleibt Label); den Anfangszustand rechnet initialCollapsed() in model.js: `>` klappt ein, `<` (und die Fokusmarke !!!) holt seinen Teilbaum hervor, indem die Faltung die Pfad-Ebenen hinunterwandert — Geschwister stehen als einzelne eingeklappte Knoten da, jede Kante bleibt eine echte. Im Diagramm: Falt-Zeichen ▾/„▸ n" vor dem Label (n = alle verborgenen Knoten), Klick oder ←/→ am fokussierten Knoten klappt um; Eingriffe je Label-Pfad, nur für die Sitzung, Dokumentwechsel setzt zurück. Eingeklappte Kinder werden nicht gerendert (Export, Messungen, Pfadlinie automatisch konsistent), ihre Warnungen aber weiter gemeldet. Export/Druck: „▸ n" bleibt, das ▾ offener Knoten fällt weg. Legende + a11y in allen 9 Sprachen; SPEC-§11-Abschnitt in §1/§9 überführt; 15 neue Tests (tests/fold.test.js), Snapshots aktualisiert. Co-Authored-By: Claude Fable 5 --- docs/DECISIONS.md | 63 +++++++++ docs/ROADMAP.md | 1 + docs/SPEC.md | 72 ++++++---- docs/TASKS.md | 6 +- docs/examples/werkbaum.werkbaum | 6 +- frontend/CLAUDE.md | 13 ++ frontend/index.html | 1 + frontend/src/app.js | 98 ++++++++++++-- frontend/src/model.js | 40 ++++++ frontend/src/parser.js | 20 ++- frontend/src/render.js | 83 ++++++++---- frontend/src/style.css | 15 ++ .../tests/__snapshots__/render.test.js.snap | 6 +- frontend/tests/fold.test.js | 128 ++++++++++++++++++ frontend/tests/render.test.js | 4 +- 15 files changed, 480 insertions(+), 76 deletions(-) create mode 100644 frontend/tests/fold.test.js diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 72a5e92..b8ab4e8 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -1771,3 +1771,66 @@ auch die Selbst-Abhängigkeit ist stumm. dieselbe Zurückhaltung wie bei den IDs (D36): keine eigene Diagramm- Darstellung, bis die Querverbindungen (§11) gebaut sind; der Pfeil im Tooltip sagt die Richtung („hängt ab von“), ohne ein neues Zeichen einzuführen. + +## D38 — Faltmarken gebaut: `<` wandert die Faltung hinunter, Eingriffe sind flüchtig +Die in D34 entschiedene Schreibweise (`- > [x] …`, Export folgt der Faltung) +ist umgesetzt (SPEC §1/§9). Die Bau-Entscheidungen: + +**`<` holt seinen Teilbaum hervor, indem die Faltung die Pfad-Ebenen +hinunterwandert.** Die naive Lesart — `<` öffnet einfach alle Vorfahren — +machte das `>` am Vorfahren wirkungslos: Öffnen zeigt **alle** Kinder samt +Teilbäumen, nicht den einen gemeinten. Die andere naheliegende Lesart — nur +den Pfad einblenden, alles andere desselben Vorfahren verbergen — bräuchte +Knoten, die ohne ihre sichtbaren Eltern gezeichnet werden, oder Kanten, die +Ebenen überspringen: Beides log über die Struktur (dieselbe Sorte Fehler, die +D29 beim Treppen-Export korrigiert hat). Das Hinunterwandern vermeidet beides: +Der eingeklappte Vorfahr öffnet sich, seine **übrigen** Kinder werden +stattdessen eingeklappt — sichtbar ist der Pfad samt Teilbaum, die Geschwister +stehen als je ein Knoten mit „▸ n“ da, und jede gezeichnete Kante ist eine +echte. Ein `>` innerhalb des hervorgeholten Teilbaums bleibt respektiert. +Ausdrückbar ist das alles in einem schlichten Je-Knoten-Zustand +(`initialCollapsed()` in model.js, headless getestet). + +**Auch die Fokusmarke `!!!` holt sich hervor** — über denselben Mechanismus +(sie geht als zweite Rettungsmarke in `initialCollapsed()` ein). Ein +Zeigefinger auf etwas Unsichtbares zeigte ins Leere; und weil Nutzer-Eingriffe +den Anfangszustand **überlagern**, kann man den Bereich danach trotzdem wieder +zuklappen — die Marke reißt ihn nicht bei jedem Neubau wieder auf. + +**Interaktive Eingriffe: je Knoten, Label-Pfad-Identität, nur für die +Sitzung.** Der Baum wird bei jedem Tastendruck neu geparst — ein Eingriff muss +Neu-Renderings überleben, also hängt er nicht am Knotenobjekt, sondern am +**Label-Pfad** (dieselbe Identität wie „Was ist neu?“, D28: Umsortieren +überlebt, Umbenennen gilt als neuer Knoten). **Nicht persistiert**: Die +dauerhafte Aussage über den Anfangszustand steht im Text (D34 — „so wird +dieses Dokument eröffnet“); der localStorage-Ansichtszustand ist zudem global +über alle Dokumente (D22), ein Je-Dokument-Faltzustand wäre dort ein +Fremdkörper. Dokumentwechsel setzt zurück. + +**Bedienung: Falt-Zeichen ▾/„▸ n“ vor dem Label, Pfeiltasten ←/→.** Der +einfache Klick ist der Link (§6), Alt+Klick der Sprung (D25), der lange Druck +ebenso — für das Falten blieb nur ein eigenes Klickziel oder eine weitere +Modifier-Geste; das sichtbare Zeichen ist zugleich die Auffindbarkeit (die +Lehre aus D25: eine Geste, die niemand sieht, ist keine Funktion). ←/→ am +fokussierten Knoten ist das WAI-ARIA-Baum-Idiom; Enter bleibt dem Link, +Alt+Enter dem Sprung. Der Zähler nennt **alle** verborgenen Knoten (nicht nur +die direkten Kinder) — er sagt, wie viel Plan dort zusammengefaltet liegt. +Nach dem Umklappen wird der Fokus auf denselben Knoten zurückgesetzt (der +Neubau hätte ihn sonst verschluckt und die Tastaturbedienung abgerissen). + +**Faltung ist reine Ansicht — mit zwei bewussten Konsequenzen.** Warnungen +aus eingeklappten Teilbäumen werden weiter gemeldet: `renderChildren()` +überspringt nur das HTML, ein eigener Lauf (`walkFolded`) sammelt Warnungen +und zählt zugleich die verborgenen Knoten — sonst verschwände eine +`mixedGate`-Meldung je nach Faltzustand, obwohl sie den **Text** betrifft. +Und der günstigste Pfad rechnet unverändert über den ganzen Baum; seine +Spline-Linie läuft ohnehin nur durch DOM-Knoten, führt also durch die +sichtbaren Endknoten. Ein eingeklappter Zweig kann Pfadknoten verbergen — +hinnehmbar, die Inversion an den sichtbaren Knoten bleibt richtig. + +**Export und Druck:** Verborgene Kinder stehen nicht im DOM — Export, +Stiel-Messung, Treppe und Pfadlinie sind damit ohne Zusatzcode konsistent +(derselbe Grund, aus dem der Renderer sie gar nicht erst erzeugt, statt sie +per CSS zu verstecken). Die Kennzeichnung „▸ n“ ist Teil des Knotentexts und +wandert von selbst in den SVG-Export; das ▾ offener Knoten wird dort und im +Druck entfernt — es ist Bedienelement, keine Aussage über den Plan. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index dd7fb6b..5a0b5bc 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -164,6 +164,7 @@ Pfad, der die Wahrheit sagt. Anfangszustand — im Diagramm wird danach unabhängig gefaltet. Schreibweise und Export-Verhalten sind entschieden (D34-Nachtrag): `- > [x] …`, Export/Druck folgen der sichtbaren Faltung mit Kennzeichnung. + **Umgesetzt** (SPEC §1/§9, D38). Der gestaffelte „günstigste Pfad" aus *Kleinere Ideen* (Status-Bewusstsein, Ausbaustufen) baut hierauf auf: Er braucht denselben Umbau der Kostenrechnung diff --git a/docs/SPEC.md b/docs/SPEC.md index 48e7f32..eccc482 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -7,15 +7,16 @@ Syntaxänderungen werden zuerst hier dokumentiert, dann implementiert. ## 1. Zeilenformat ``` -[Einrückung][Zeichen] [Statusbox] Label (Größe) URL @tag … !!! %% Kommentar +[Einrückung][Zeichen] [Faltmarke] [Statusbox] Label (Größe) URL @tag … !!! %% Kommentar ``` Alle Bestandteile außer dem Label sind optional. Die Extraktion erfolgt in dieser Reihenfolge (wichtig für Kollisionsfreiheit): 1. Kommentar entfernen: alles ab `%%` bis Zeilenende. -2. Einrückung, Zeichen (`-` / `+` / `|` / `=`) und Statusbox `[…]` per - Zeilen-Regex; `=` nur mit folgendem Leerraum (§3). +2. Einrückung, Zeichen (`-` / `+` / `|` / `=`), Faltmarke (`>` / `<`) und + Statusbox `[…]` per Zeilen-Regex; `=` nur mit folgendem Leerraum (§3), + die Faltmarke ebenso (siehe unten). 3. URL: erstes Token, das auf `https?://\S+` passt (dadurch stören `@` in URLs nicht). 4. Größe: erstes `(XS|S|M|L|XL|XXL)`, Groß-/Kleinschreibung egal. 5. Tags: alle `@name`-Vorkommen. @@ -24,6 +25,19 @@ dieser Reihenfolge (wichtig für Kollisionsfreiheit): 8. Fokusmarke: `!!!` als **alleinstehendes** Token (siehe unten). 9. Rest, whitespace-normalisiert = Label. Leeres Label ⇒ Zeile ignorieren. +**Faltmarke `>` / `<`** — bestimmt, wie das Dokument **eröffnet** wird: + +- Steht **zwischen** Zerlegungszeichen und Statusbox (`- > [x] Backend`); bei + Wurzelknoten (ohne Zeichen) am Zeilenanfang. Erkannt nur mit **folgendem + Leerraum** — `- >Achtung` bleibt damit ein Label. Begründung der Stellung: + D34-Nachtrag. +- `>` heißt: der Teilbaum dieses Knotens ist beim Öffnen **eingeklappt**. +- `<` innerhalb eines eingeklappten Bereichs holt den **eigenen Teilbaum** + gezielt wieder hervor (Mechanik: §9). +- Die Marken bestimmen nur den **Anfangszustand** — im Diagramm wird danach + unabhängig vom Text gefaltet (§9). Sie sagen nichts über Fortschritt (§4) + oder Notwendigkeit (§3) und ändern weder Kosten noch Warnungen. + **Knoten-ID `#name`** — benennt einen Knoten im **ganzen Dokument** eindeutig; sie ist die Adresse für Abhängigkeiten und Beschreibungsblöcke (§11). @@ -83,7 +97,7 @@ hat etwas, das ein Cursor nicht hat: **alle** sehen dieselbe Stelle. Referenz-Regex der Implementierung: ``` -^([ \t]*)([-|+]|=(?=[ \t]))?\s*(?:\[([ ?~xX^/-])\]\s*)?(.*)$ +^([ \t]*)([-|+]|=(?=[ \t]))?\s*(?:([><])(?=[ \t])\s*)?(?:\[([ ?~xX^/-])\]\s*)?(.*)$ ``` Für die Knoten-ID (Schritt 6, nur der erste Treffer): @@ -508,6 +522,36 @@ gehört und lesbar bleibt. Kein Blinken. - Wie die Cursor-Zeile erscheint sie **nicht** im Druck und **nicht** im Grafikexport: Sie sagt „schau jetzt hierhin", nicht „so ist der Plan". +### Ein- und ausklappbare Teilbäume (`>` / `<`, §1) +Jeder Knoten mit sichtbaren Kindern trägt ein kleines **Falt-Zeichen** vor dem +Label: **▾** offen, **„▸ n“** eingeklappt (n = Zahl der verborgenen Knoten). +Klick auf das Zeichen klappt um — der einfache Klick auf den Knoten selbst +bleibt der Link (§6); Tastatur: **←** klappt zu, **→** klappt auf am +fokussierten Knoten (WAI-ARIA-Baum-Idiom). + +- **Anfangszustand aus dem Text (§1):** `>` klappt ein. `<` holt seinen + Teilbaum hervor, indem die Faltung die Pfad-Ebenen **hinunterwandert**: + Jeder eingeklappte Vorfahr öffnet sich, seine übrigen Kinder stehen + stattdessen als einzelne eingeklappte Knoten da — sichtbar ist genau der + Pfad samt Teilbaum, der Rest bleibt kompakt. Ein `>` **innerhalb** des + hervorgeholten Teilbaums bleibt respektiert. +- Auch ein **`!!!`-markierter Knoten** (§1) holt sich auf diese Weise hervor — + ein Zeigefinger auf etwas Unsichtbares zeigte ins Leere. +- Der interaktive Eingriff gilt **je Knoten** (Identität = Label-Pfad, wie bei + „Was ist neu?“) und **für die Sitzung**; er wird nicht gespeichert — die + dauerhafte Aussage steht im Text (D34). Ein Dokumentwechsel setzt zurück. +- Faltung ist **reine Ansicht**: Warnungen aus eingeklappten Teilbäumen werden + weiter gemeldet (sie gelten dem Text), und der günstigste Pfad rechnet + unverändert über den ganzen Baum — seine Linie zeigt nur die sichtbaren + Endknoten. +- **Export und Druck folgen der sichtbaren Faltung** (dieselbe Regel wie beim + „verworfene einblenden“-Filter): Verborgene Teilbäume fehlen, eingeklappte + Knoten behalten die Kennzeichnung „▸ n“ — das Bild behauptet damit keine + Vollständigkeit. Das ▾ offener Knoten ist ein Bedienelement und erscheint + weder im Export noch im Druck. + +Siehe D38. + ### Grafikexport des Diagramms Das Diagramm wird aus der Live-Geometrie in ein eigenständiges SVG (nur Formen + Text, keine externen Ressourcen) nachgezeichnet. Zwei Icon-Schaltflächen: @@ -646,26 +690,6 @@ ist, damit der gewählte Knoten effektiv fertig werden kann. Gemeinsam benötigte Abhängigkeiten werden dabei **nur einmal** gezählt. Genau das macht die Rechnung schwerer als heute — siehe D34. -### Ein- und ausklappbare Teilbäume (`>` / `<`) - -- `>` an einem Knoten heißt: **ab hier eingeklappt** — sein Teilbaum ist beim - Öffnen des Dokuments zunächst verborgen. -- `<` innerhalb eines eingeklappten Bereichs holt einen einzelnen Teilbaum - gezielt wieder hervor. -- Im Diagramm wird danach **unabhängig vom Text** interaktiv auf- und - zugeklappt; die Marken im Text bestimmen nur den Anfangszustand. -- **Stellung im Zeilenformat (entschieden):** **hinter** dem Zerlegungszeichen, - vor der Statusbox — `- > [x] Backend`. Die Spalte der Zerlegungszeichen - bleibt so bündig (an ihr liest das Auge die Hierarchie ab), und ein `>` am - Zeilenanfang wäre die Markdown-Blockquote-Konvention. Wurzelknoten (ohne - Zeichen) tragen die Marke am Zeilenanfang. Begründung: D34-Nachtrag. -- **Grafikexport und Druck (entschieden):** folgen der **sichtbar - eingeklappten** Struktur — dieselbe Regel wie beim „verworfene - einblenden“-Filter (§9: „Es wird genau die sichtbare Struktur exportiert“). - Damit das Bild nicht stillschweigend Vollständigkeit behauptet, wird ein - eingeklappter Knoten **gekennzeichnet** (z. B. „▸“ oder die Anzahl der - verborgenen Kinder; genaue Form wird beim Bauen festgelegt). - ### Knotenbeschreibungen Erläuternder Text zu einem Knoten, im Diagramm als Tooltip oder Pop-up. diff --git a/docs/TASKS.md b/docs/TASKS.md index 9f73be0..938fc28 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -91,10 +91,14 @@ entscheiden, **bevor** Code entsteht. **benennen**. - [ ] Querverbindungen zeichnen (eigene SVG-Ebene, optisch sekundär); bei ausgewähltem Knoten ein-/ausgehende hervorheben. -- [ ] Faltmarken `>` / `<` parsen; interaktives Auf-/Zuklappen im Diagramm. +- [x] Faltmarken `>` / `<` parsen; interaktives Auf-/Zuklappen im Diagramm. → Schreibweise und Export-Verhalten entschieden (D34-Nachtrag): Marke hinter dem Zerlegungszeichen (`- > [x] …`), Export/Druck folgen der sichtbar eingeklappten Struktur mit Kennzeichnung am Knoten. + Umgesetzt (D38): `initialCollapsed()` (`<` wandert die Faltung die + Pfad-Ebenen hinunter, `!!!` holt sich mit hervor); Falt-Zeichen ▾/„▸ n" + + Tastatur ←/→; Eingriffe je Label-Pfad, nur für die Sitzung; Warnungen + aus eingeklappten Teilbäumen bleiben; `tests/fold.test.js`. - [x] XOR-Gruppe (`=`) parsen (nur mit folgendem Leerraum, Mischung → `mixedGate`), zwei realisierte Alternativen melden. → Zeichen entschieden: `=` statt `x` (D34-Nachtrag). Umgesetzt (D35): diff --git a/docs/examples/werkbaum.werkbaum b/docs/examples/werkbaum.werkbaum index c58490f..66ea85b 100644 --- a/docs/examples/werkbaum.werkbaum +++ b/docs/examples/werkbaum.werkbaum @@ -60,9 +60,9 @@ - [^] Load a document from ?sourceUrl= (S) - [^] Jump between diagram and text (S) - [x] Line numbers in the text editor (XS) %% the warnings name them - - [?] Collapsible subtrees (M) - - [?] Initial state from the text: > collapses, < re-opens (S) - - [?] Fold and unfold in the diagram, independently of the text (S) + - [x] Collapsible subtrees (M) + - [x] Initial state from the text: > collapses, < re-opens (S) + - [x] Fold and unfold in the diagram, independently of the text (S) - [ ] Open and save .werkbaum files (S) + [^] Show what is new since your last visit (S) + [?] A pastel colour per person (S) diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 93b7644..8eb82e9 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -267,6 +267,19 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der passiert: Zähler stimmte, nichts leuchtete). Vorgehalten wird nur `freshPrevRoots` (Basis, einmal geparst). Basis je Dokument in `werkbaum-seen`, fortgeschrieben **erst beim Bestätigen** über `#freshBtn`. +- Faltmarken `>`/`<` (SPEC §1/§9, D38): Parser setzt nur `fold` ('>'|'<'|null, + Leerraum-Regel); den wirksamen Anfangszustand rechnet `initialCollapsed()` + in model.js — `<` (und die Fokusmarke) wandert die Faltung die Pfad-Ebenen + **hinunter** statt Vorfahren bloß zu öffnen. `render()` überlagert ihn mit + `foldOverrides` (Schlüssel = Label-Pfad via `nodeKeys()`, Sitzung, beim + Dokumentwechsel geleert) und übergibt `collapsedSet` an den Renderer; der + lässt eingeklappte Kinder **weg** (nicht CSS-verstecken — Export, Messungen + und Pfadlinie bleiben so von selbst konsistent), meldet deren Warnungen aber + weiter (`walkFolded`, zählt zugleich fürs „▸ n"). Umklappen: Klick aufs + `.fold`-Zeichen (preventDefault — es sitzt bei Link-Knoten im ``) oder + ←/→ am fokussierten Knoten; nach `render()` den Fokus per `data-line` + wiederherstellen. Export/Druck: „▸ n" bleibt, das ▾ offener Knoten fällt weg + (`stripFold` in `diagramToSvg`, Print-Regel `.node:not(.folded) .fold`). - Knoten-IDs `#name` (SPEC §1/D36): nur **alleinstehend angesetzt** und nur der **erste** Treffer der Zeile (kein `/g`!) — weitere `#`-Token bleiben im Label (reservierte Ticket-Referenzen), und `:#a,#b` (künftige Abhängigkeiten) diff --git a/frontend/index.html b/frontend/index.html index 3791225..54fc7c0 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -122,6 +122,7 @@ Personen mit @name — erscheinen unten rechts am Knoten. Knoten-ID mit #name — erscheint im Tooltip des Knotens. Abhängigkeiten mit :#name,#name — erscheinen im Tooltip. + Falten: - > [x] … startet eingeklappt, < holt hervor; ▾/▸ am Knoten klappt um (Tastatur: ←/→). diff --git a/frontend/src/app.js b/frontend/src/app.js index 64a5d9d..b771052 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -1,6 +1,6 @@ import './style.css'; import { parse } from './parser.js'; -import { computeCheapSet, freshProdSet } from './model.js'; +import { computeCheapSet, freshProdSet, initialCollapsed, nodeKeys } from './model.js'; import { esc, renderTreeHtml } from './render.js'; import { formatWarning } from './warnings.js'; import { padUrls } from './remote.js'; @@ -61,6 +61,12 @@ let sourceWarning = null; Vergleichsfassung. `freshBaseline` ist der Text, gegen den verglichen wurde; er wird erst beim Bestätigen fortgeschrieben. */ let freshSet = new Set(), freshDocId = null, freshBaseline = null, freshPrevRoots = null; +/* Faltung (SPEC §9, D38): `foldOverrides` sind die interaktiven Eingriffe des + Nutzers (Schlüssel = Label-Pfad wie bei D28, damit sie das Neu-Parsen bei + jedem Tastendruck überleben); sie überlagern den Anfangszustand aus den + Textmarken, gelten nur für die Sitzung und fallen beim Dokumentwechsel weg. + `foldByLine` ist der Zustand des letzten Renders für Klick/Tastatur. */ +let foldOverrides = new Map(), foldByLine = new Map(); /* ---------- Renderer (Anbindung an den DOM) ---------- parse -> Wurzeln filtern (verworfene) -> günstigen Pfad markieren -> @@ -82,6 +88,7 @@ function render(){ if(!roots.length){ out.innerHTML = `
${esc(t('empty'))}
`; freshSet = new Set(); + foldByLine = new Map(); } else { const cheapSet = cheapPathOn ? computeCheapSet(roots) : new Set(); out.classList.toggle('cheap-on', cheapPathOn); @@ -91,8 +98,21 @@ function render(){ Objektidentität nie zu (D28). */ freshSet = (freshDocId === activeId && freshPrevRoots) ? freshProdSet(freshPrevRoots, roots) : new Set(); + /* Faltung (D38): Anfangszustand aus den Textmarken (`!!!` holt sich mit + hervor), überlagert von den Sitzungs-Eingriffen des Nutzers. Wie bei + `freshSet` muss die Menge aus den gerade geparsten Knoten bestehen. */ + const initFold = initialCollapsed(roots, true); + const keys = nodeKeys(roots); + const collapsedSet = new Set(); + foldByLine = new Map(); + keys.forEach((key, n) => { + const ov = foldOverrides.get(key); + const collapsed = (ov !== undefined ? ov : initFold.has(n)) && n.children.length > 0; + if(collapsed) collapsedSet.add(n); + foldByLine.set(n.line, {key, collapsed, canFold: n.children.length > 0}); + }); const r = renderTreeHtml(roots, {t, showDiscarded, cheapPath: cheapPathOn, cheapSet, - freshSet}); + freshSet, collapsedSet}); out.innerHTML = r.html; warnings = warnings.concat(r.warnings); } @@ -378,7 +398,11 @@ function diagramToSvg(){ const dashed = cs.borderTopStyle === 'dashed'; parts.push(``); const clone = node.cloneNode(true); - clone.querySelectorAll('.size,.tags,.ext,.risk').forEach(e => e.remove()); + /* Das „▸ n"-Kennzeichen eingeklappter Knoten gehört in den Export (SPEC + §9/D38 — das Bild darf keine Vollständigkeit behaupten); das ▾ offener + Knoten ist Bedienelement und fällt weg. */ + const stripFold = node.classList.contains('folded') ? '' : ',.fold'; + clone.querySelectorAll('.size,.tags,.ext,.risk' + stripFold).forEach(e => e.remove()); const label = clone.textContent.replace(/\s+/g,' ').trim(); const deco = cs.textDecorationLine.includes('line-through') ? ' text-decoration="line-through"' : ''; parts.push(`${esc(label)}`); @@ -685,6 +709,29 @@ function nodeFromEvent(e){ return el && out.contains(el) ? el : null; } +/* Faltung umklappen (SPEC §9, D38). Der Eingriff überlagert den Anfangszustand + aus dem Text; nach dem Neubau bekommt derselbe Knoten den Fokus zurück, + sonst risse die Tastaturbedienung ab (das alte Element ist weg). */ +function toggleFold(el){ + const st = foldByLine.get(+el.dataset.line); + if(!st || !st.canFold) return; + foldOverrides.set(st.key, !st.collapsed); + render(); + const again = out.querySelector('.node[data-line="' + el.dataset.line + '"]'); + if(again) again.focus({preventScroll: true}); +} + +/* Klick auf das Falt-Zeichen ▾/▸ klappt um. preventDefault, weil das Zeichen + bei Link-Knoten IM
sitzt — sonst öffnete der Klick zusätzlich die URL. */ +out.addEventListener('click', e => { + const f = e.target && e.target.closest ? e.target.closest('.fold') : null; + if(!f || !out.contains(f) || e.altKey) return; + e.preventDefault(); + e.stopPropagation(); + const el = f.closest('.node[data-line]'); + if(el) toggleFold(el); +}); + out.addEventListener('click', e => { if(!e.altKey) return; const el = nodeFromEvent(e); @@ -705,6 +752,20 @@ out.addEventListener('keydown', e => { jumpToLine(+el.dataset.line); }); +/* Tastatur-Faltung (SPEC §9, D38): ← klappt zu, → klappt auf — das + WAI-ARIA-Baum-Idiom. Nur ohne Modifier, damit nichts anderes kollidiert. */ +out.addEventListener('keydown', e => { + if(e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; + if(e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; + const el = nodeFromEvent(e); + if(!el) return; + const st = foldByLine.get(+el.dataset.line); + if(!st || !st.canFold) return; + e.preventDefault(); + const want = e.key === 'ArrowLeft'; + if(st.collapsed !== want) toggleFold(el); +}); + /* Mobil gibt es kein Alt: langer Druck (500 ms) auf einen Knoten springt. Der Sprung passiert erst beim LOSLASSEN: `focus()` aus einem Timer heraus gilt in mobilen Browsern nicht als Nutzergeste — der Fokus fiel sofort wieder @@ -1109,7 +1170,7 @@ const I18N = { unknownStatusWarn:"Zeile {line}: unbekanntes Statuszeichen „{code}“ — als neutral dargestellt.", sourceLoadWarn:"„{url}“ konnte nicht geladen werden ({error}). Die Datei muss per http(s) erreichbar sein und CORS erlauben (Access-Control-Allow-Origin).", sourceTimeoutWarn:"„{url}“ hat innerhalb von {seconds} s nicht geantwortet — der Abruf wurde abgebrochen. Etherpad begrenzt, wie oft der Export geholt werden darf (serienmäßig 10-mal pro 90 s); warte einen Moment und lade dann erneut.", - a11yStatus:"Status: {status}", a11ySize:"Aufwand: {size}", a11ySizeImplicit:"Aufwand: M (angenommen)", a11yTags:"Zuständig: {names}", a11yId:"ID: #{id}", a11yDeps:"hängt ab von: {ids}", a11yOptional:"optional", a11yFocusMark:"hierhin schauen", a11yLink:"verlinkt", + a11yStatus:"Status: {status}", a11ySize:"Aufwand: {size}", a11ySizeImplicit:"Aufwand: M (angenommen)", a11yTags:"Zuständig: {names}", a11yId:"ID: #{id}", a11yDeps:"hängt ab von: {ids}", a11yFolded:"eingeklappt, {n} verborgen", a11yOptional:"optional", a11yFocusMark:"hierhin schauen", a11yLink:"verlinkt", hint_indent:"Einrückung (2 Leerzeichen oder Tab) definiert die Hierarchie.", hint_all:"Teilpaket, alle erforderlich", hint_any:"Alternative, eine wählen", hint_xor:"Alternative, genau eine", @@ -1123,6 +1184,7 @@ const I18N = { hint_people:"Personen mit @name — erscheinen unten rechts am Knoten.", hint_id:"Knoten-ID mit #name — erscheint im Tooltip des Knotens.", hint_deps:"Abhängigkeiten mit :#name,#name — erscheinen im Tooltip.", + hint_fold:"Falten: - > [x] … startet eingeklappt, < holt hervor; ▾/▸ am Knoten klappt um (Tastatur: ←/→).", hint_jump:"Alt+Klick auf einen Knoten (mobil: langer Druck) springt zur zugehörigen Textzeile; Alt+Klick im Text holt den Knoten ins Bild." }, en: { @@ -1179,7 +1241,7 @@ const I18N = { unknownStatusWarn:"Line {line}: unknown status code “{code}” — shown as neutral.", sourceLoadWarn:"Could not load “{url}” ({error}). The file must be reachable via http(s) and allow CORS (Access-Control-Allow-Origin).", sourceTimeoutWarn:"“{url}” did not answer within {seconds} s — the request was aborted. Etherpad limits how often the export may be fetched (10 times per 90 s by default); wait a moment, then reload.", - a11yStatus:"Status: {status}", a11ySize:"Effort: {size}", a11ySizeImplicit:"Effort: M (assumed)", a11yTags:"Assigned: {names}", a11yId:"ID: #{id}", a11yDeps:"depends on: {ids}", a11yOptional:"optional", a11yFocusMark:"look here", a11yLink:"has link", + a11yStatus:"Status: {status}", a11ySize:"Effort: {size}", a11ySizeImplicit:"Effort: M (assumed)", a11yTags:"Assigned: {names}", a11yId:"ID: #{id}", a11yDeps:"depends on: {ids}", a11yFolded:"collapsed, {n} hidden", a11yOptional:"optional", a11yFocusMark:"look here", a11yLink:"has link", hint_indent:"Indentation (2 spaces or a tab) defines the hierarchy.", hint_all:"sub-task, all required", hint_any:"alternative, choose one", hint_xor:"alternative, exactly one", @@ -1193,6 +1255,7 @@ const I18N = { hint_people:"People with @name — shown at the bottom-right of the node.", hint_id:"Node ID with #name — shown in the node's tooltip.", hint_deps:"Dependencies with :#name,#name — shown in the tooltip.", + hint_fold:"Folding: - > [x] … starts collapsed, < brings it back; ▾/▸ on a node toggles (keyboard: ←/→).", hint_jump:"Alt+click a node (long press on touch) jumps to its line in the text; Alt+click in the text brings the node into view." }, es: { @@ -1249,7 +1312,7 @@ const I18N = { unknownStatusWarn:"Línea {line}: código de estado desconocido «{code}» — mostrado como neutral.", sourceLoadWarn:"No se pudo cargar «{url}» ({error}). El archivo debe ser accesible por http(s) y permitir CORS (Access-Control-Allow-Origin).", sourceTimeoutWarn:"«{url}» no respondió en {seconds} s — se canceló la petición. Etherpad limita la frecuencia de descarga del export (10 veces por 90 s de forma predeterminada); espera un momento y vuelve a cargar.", - a11yStatus:"Estado: {status}", a11ySize:"Esfuerzo: {size}", a11ySizeImplicit:"Esfuerzo: M (asumido)", a11yTags:"Responsable: {names}", a11yId:"ID: #{id}", a11yDeps:"depende de: {ids}", a11yOptional:"opcional", a11yFocusMark:"mirar aquí", a11yLink:"con enlace", + a11yStatus:"Estado: {status}", a11ySize:"Esfuerzo: {size}", a11ySizeImplicit:"Esfuerzo: M (asumido)", a11yTags:"Responsable: {names}", a11yId:"ID: #{id}", a11yDeps:"depende de: {ids}", a11yFolded:"plegado, {n} ocultos", a11yOptional:"opcional", a11yFocusMark:"mirar aquí", a11yLink:"con enlace", hint_indent:"La sangría (2 espacios o un tabulador) define la jerarquía.", hint_all:"subtarea, todas obligatorias", hint_any:"alternativa, elige una", hint_xor:"alternativa, exactamente una", @@ -1263,6 +1326,7 @@ const I18N = { hint_people:"Personas con @nombre — aparecen abajo a la derecha del nodo.", hint_id:"ID de nodo con #nombre — visible en el tooltip del nodo.", hint_deps:"Dependencias con :#nombre,#nombre — visibles en el tooltip.", + hint_fold:"Plegado: - > [x] … empieza plegado, < lo recupera; ▾/▸ en el nodo alterna (teclado: ←/→).", hint_jump:"Alt+clic en un nodo (pulsación larga en táctil) salta a su línea en el texto; Alt+clic en el texto trae el nodo a la vista." }, fr: { @@ -1319,7 +1383,7 @@ const I18N = { unknownStatusWarn:"Ligne {line} : code de statut inconnu « {code} » — affiché comme neutre.", sourceLoadWarn:"Impossible de charger « {url} » ({error}). Le fichier doit être accessible en http(s) et autoriser CORS (Access-Control-Allow-Origin).", sourceTimeoutWarn:"« {url} » n’a pas répondu en {seconds} s — la requête a été interrompue. Etherpad limite la fréquence de récupération de l’export (10 fois par 90 s par défaut) ; attends un instant, puis recharge.", - a11yStatus:"Statut : {status}", a11ySize:"Effort : {size}", a11ySizeImplicit:"Effort : M (supposé)", a11yTags:"Responsable : {names}", a11yId:"ID : #{id}", a11yDeps:"dépend de : {ids}", a11yOptional:"facultatif", a11yFocusMark:"regarder ici", a11yLink:"avec lien", + a11yStatus:"Statut : {status}", a11ySize:"Effort : {size}", a11ySizeImplicit:"Effort : M (supposé)", a11yTags:"Responsable : {names}", a11yId:"ID : #{id}", a11yDeps:"dépend de : {ids}", a11yFolded:"replié, {n} masqués", a11yOptional:"facultatif", a11yFocusMark:"regarder ici", a11yLink:"avec lien", hint_indent:"L'indentation (2 espaces ou une tabulation) définit la hiérarchie.", hint_all:"sous-tâche, toutes requises", hint_any:"alternative, en choisir une", hint_xor:"alternative, exactement une", @@ -1333,6 +1397,7 @@ const I18N = { hint_people:"Personnes avec @nom — affichées en bas à droite du nœud.", hint_id:"ID de nœud avec #nom — visible dans l’infobulle du nœud.", hint_deps:"Dépendances avec :#nom,#nom — visibles dans l’infobulle.", + hint_fold:"Pliage : - > [x] … démarre replié, < le fait ressortir ; ▾/▸ sur le nœud bascule (clavier : ←/→).", hint_jump:"Alt+clic sur un nœud (appui long sur tactile) saute à sa ligne dans le texte ; Alt+clic dans le texte amène le nœud à l’écran." }, pl: { @@ -1389,7 +1454,7 @@ const I18N = { unknownStatusWarn:"Wiersz {line}: nieznany znak statusu „{code}” — pokazany jako neutralny.", sourceLoadWarn:"Nie udało się wczytać „{url}” ({error}). Plik musi być dostępny przez http(s) i zezwalać na CORS (Access-Control-Allow-Origin).", sourceTimeoutWarn:"„{url}” nie odpowiedział w ciągu {seconds} s — żądanie przerwano. Etherpad ogranicza częstość pobierania eksportu (domyślnie 10 razy na 90 s); odczekaj chwilę i wczytaj ponownie.", - a11yStatus:"Status: {status}", a11ySize:"Nakład: {size}", a11ySizeImplicit:"Nakład: M (założony)", a11yTags:"Przypisano: {names}", a11yId:"ID: #{id}", a11yDeps:"zależy od: {ids}", a11yOptional:"opcjonalny", a11yFocusMark:"spójrz tutaj", a11yLink:"z linkiem", + a11yStatus:"Status: {status}", a11ySize:"Nakład: {size}", a11ySizeImplicit:"Nakład: M (założony)", a11yTags:"Przypisano: {names}", a11yId:"ID: #{id}", a11yDeps:"zależy od: {ids}", a11yFolded:"zwinięte, ukrytych: {n}", a11yOptional:"opcjonalny", a11yFocusMark:"spójrz tutaj", a11yLink:"z linkiem", hint_indent:"Wcięcie (2 spacje lub tabulator) definiuje hierarchię.", hint_all:"podzadanie, wszystkie wymagane", hint_any:"alternatywa, wybierz jedną", hint_xor:"alternatywa, dokładnie jedna", @@ -1403,6 +1468,7 @@ const I18N = { hint_people:"Osoby z @nazwa — pokazywane w prawym dolnym rogu węzła.", hint_id:"ID węzła przez #nazwa — widoczne w podpowiedzi węzła.", hint_deps:"Zależności przez :#nazwa,#nazwa — widoczne w podpowiedzi.", + hint_fold:"Zwijanie: - > [x] … zaczyna zwinięte, < przywraca; ▾/▸ na węźle przełącza (klawiatura: ←/→).", hint_jump:"Alt+kliknięcie węzła (długie naciśnięcie na dotyku) przechodzi do jego wiersza w tekście; Alt+kliknięcie w tekście pokazuje węzeł na diagramie." }, ru: { @@ -1459,7 +1525,7 @@ const I18N = { unknownStatusWarn:"Строка {line}: неизвестный код статуса «{code}» — показан как нейтральный.", sourceLoadWarn:"Не удалось загрузить «{url}» ({error}). Файл должен быть доступен по http(s) и разрешать CORS (Access-Control-Allow-Origin).", sourceTimeoutWarn:"«{url}» не ответил за {seconds} с — запрос прерван. Etherpad ограничивает частоту загрузки экспорта (по умолчанию 10 раз за 90 с); подождите немного и обновите снова.", - a11yStatus:"Статус: {status}", a11ySize:"Оценка: {size}", a11ySizeImplicit:"Оценка: M (предполагается)", a11yTags:"Ответственные: {names}", a11yId:"ID: #{id}", a11yDeps:"зависит от: {ids}", a11yOptional:"необязательно", a11yFocusMark:"смотрите здесь", a11yLink:"со ссылкой", + a11yStatus:"Статус: {status}", a11ySize:"Оценка: {size}", a11ySizeImplicit:"Оценка: M (предполагается)", a11yTags:"Ответственные: {names}", a11yId:"ID: #{id}", a11yDeps:"зависит от: {ids}", a11yFolded:"свёрнуто, скрыто: {n}", a11yOptional:"необязательно", a11yFocusMark:"смотрите здесь", a11yLink:"со ссылкой", hint_indent:"Отступ (2 пробела или табуляция) задаёт иерархию.", hint_all:"подзадача, все обязательны", hint_any:"альтернатива, выберите одну", hint_xor:"альтернатива, ровно одна", @@ -1473,6 +1539,7 @@ const I18N = { hint_people:"Люди через @имя — показываются справа внизу узла.", hint_id:"ID узла через #имя — виден во всплывающей подсказке узла.", hint_deps:"Зависимости через :#имя,#имя — видны в подсказке.", + hint_fold:"Сворачивание: - > [x] … открывается свёрнутым, < возвращает; ▾/▸ на узле переключает (клавиши: ←/→).", hint_jump:"Alt+клик по узлу (долгое нажатие на сенсоре) переходит к его строке в тексте; Alt+клик в тексте показывает узел на диаграмме." }, hi: { @@ -1529,7 +1596,7 @@ const I18N = { unknownStatusWarn:"पंक्ति {line}: अज्ञात स्थिति कोड „{code}“ — तटस्थ रूप में दिखाया गया।", sourceLoadWarn:"„{url}“ लोड नहीं हो सका ({error})। फ़ाइल http(s) से उपलब्ध होनी चाहिए और CORS की अनुमति देनी चाहिए (Access-Control-Allow-Origin)।", sourceTimeoutWarn:"„{url}“ ने {seconds} स॰ में उत्तर नहीं दिया — अनुरोध रद्द कर दिया गया। Etherpad सीमित करता है कि एक्सपोर्ट कितनी बार लिया जा सके (डिफ़ॉल्ट रूप से 90 स॰ में 10 बार); कुछ क्षण रुकें, फिर दोबारा लोड करें।", - a11yStatus:"स्थिति: {status}", a11ySize:"आकार: {size}", a11ySizeImplicit:"आकार: M (अनुमानित)", a11yTags:"जिम्मेदार: {names}", a11yId:"आईडी: #{id}", a11yDeps:"निर्भर: {ids}", a11yOptional:"वैकल्पिक", a11yFocusMark:"यहाँ देखें", a11yLink:"लिंक सहित", + a11yStatus:"स्थिति: {status}", a11ySize:"आकार: {size}", a11ySizeImplicit:"आकार: M (अनुमानित)", a11yTags:"जिम्मेदार: {names}", a11yId:"आईडी: #{id}", a11yDeps:"निर्भर: {ids}", a11yFolded:"समेटा हुआ, {n} छिपे", a11yOptional:"वैकल्पिक", a11yFocusMark:"यहाँ देखें", a11yLink:"लिंक सहित", hint_indent:"इंडेंट (2 स्पेस या टैब) पदानुक्रम तय करता है।", hint_all:"उप-कार्य, सभी आवश्यक", hint_any:"विकल्प, एक चुनें", hint_xor:"विकल्प, ठीक एक", @@ -1543,6 +1610,7 @@ const I18N = { hint_people:"@नाम से व्यक्ति — नोड के नीचे-दाएँ दिखते हैं।", hint_id:"#नाम से नोड आईडी — नोड के टूलटिप में दिखती है।", hint_deps:":#नाम,#नाम से निर्भरताएँ — टूलटिप में दिखती हैं।", + hint_fold:"फ़ोल्डिंग: - > [x] … समेटा हुआ खुलता है, < वापस लाता है; नोड पर ▾/▸ टॉगल करता है (कीबोर्ड: ←/→)।", hint_jump:"किसी नोड पर Alt+क्लिक (टच पर लंबा दबाव) टेक्स्ट में उसकी पंक्ति पर ले जाता है; टेक्स्ट में Alt+क्लिक उस नोड को आरेख में दिखाता है।" }, zh: { @@ -1599,7 +1667,7 @@ const I18N = { unknownStatusWarn:"第 {line} 行:未知状态代码“{code}”——显示为中性。", sourceLoadWarn:"无法加载“{url}”({error})。该文件必须可通过 http(s) 访问并允许 CORS(Access-Control-Allow-Origin)。", sourceTimeoutWarn:"“{url}” 在 {seconds} 秒内没有响应 — 请求已中止。Etherpad 会限制导出的获取频率(默认每 90 秒 10 次);请稍候再重新加载。", - a11yStatus:"状态:{status}", a11ySize:"工作量:{size}", a11ySizeImplicit:"工作量:M(假定)", a11yTags:"负责人:{names}", a11yId:"ID:#{id}", a11yDeps:"依赖:{ids}", a11yOptional:"可选", a11yFocusMark:"看这里", a11yLink:"含链接", + a11yStatus:"状态:{status}", a11ySize:"工作量:{size}", a11ySizeImplicit:"工作量:M(假定)", a11yTags:"负责人:{names}", a11yId:"ID:#{id}", a11yDeps:"依赖:{ids}", a11yFolded:"已折叠,隐藏 {n} 项", a11yOptional:"可选", a11yFocusMark:"看这里", a11yLink:"含链接", hint_indent:"缩进(2 个空格或制表符)定义层级。", hint_all:"子任务,全部必需", hint_any:"备选项,择其一", hint_xor:"备选项,恰好一个", @@ -1613,6 +1681,7 @@ const I18N = { hint_people:"用 @姓名 表示人员——显示在节点右下角。", hint_id:"用 #名称 指定节点 ID——显示在节点提示中。", hint_deps:"用 :#名称,#名称 表示依赖——显示在提示中。", + hint_fold:"折叠:- > [x] … 打开时即折叠,< 将其展开;节点上的 ▾/▸ 切换(键盘:←/→)。", hint_jump:"Alt+点击节点(触摸屏为长按)可跳转到文本中对应的行;在文本中 Alt+点击则把该节点带入视野。" }, ja: { @@ -1669,7 +1738,7 @@ const I18N = { unknownStatusWarn:"{line} 行目: 不明なステータス記号「{code}」— 中立として表示。", sourceLoadWarn:"「{url}」を読み込めませんでした({error})。ファイルは http(s) でアクセス可能で、CORS(Access-Control-Allow-Origin)を許可する必要があります。", sourceTimeoutWarn:"「{url}」が {seconds} 秒以内に応答しませんでした — 要求を中止しました。Etherpad はエクスポートの取得回数を制限します(既定で 90 秒あたり 10 回)。少し待ってから再読み込みしてください。", - a11yStatus:"ステータス: {status}", a11ySize:"規模: {size}", a11ySizeImplicit:"規模: M(想定)", a11yTags:"担当: {names}", a11yId:"ID: #{id}", a11yDeps:"依存先: {ids}", a11yOptional:"任意", a11yFocusMark:"ここを見る", a11yLink:"リンクあり", + a11yStatus:"ステータス: {status}", a11ySize:"規模: {size}", a11ySizeImplicit:"規模: M(想定)", a11yTags:"担当: {names}", a11yId:"ID: #{id}", a11yDeps:"依存先: {ids}", a11yFolded:"折りたたみ中、{n} 件非表示", a11yOptional:"任意", a11yFocusMark:"ここを見る", a11yLink:"リンクあり", hint_indent:"インデント(スペース2つまたはタブ)で階層を定義します。", hint_all:"サブタスク、すべて必須", hint_any:"選択肢、1つを選ぶ", hint_xor:"選択肢、ちょうど1つ", @@ -1683,6 +1752,7 @@ const I18N = { hint_people:"@名前 で担当者 — ノードの右下に表示されます。", hint_id:"#名前 でノード ID — ノードのツールチップに表示されます。", hint_deps:":#名前,#名前 で依存関係 — ツールチップに表示されます。", + hint_fold:"折りたたみ:- > [x] … は折りたたんだ状態で開き、< は呼び戻します。ノードの ▾/▸ で切替(キー:←/→)。", hint_jump:"ノードを Alt+クリック(タッチでは長押し)すると、テキストの該当行へ移動します。テキスト内で Alt+クリックすると、そのノードが図の中央に表示されます。" } }; @@ -1717,6 +1787,7 @@ function buildHint(){ ${esc(t('hint_people'))} ${esc(t('hint_id'))} ${esc(t('hint_deps'))} + ${esc(t('hint_fold'))} !!!  ${esc(t('hint_focus'))}
${esc(t('hint_jump'))}
`; } @@ -2076,6 +2147,7 @@ function switchDoc(id){ if(id === activeId) return; flushActive(); activeId = id; + foldOverrides.clear(); /* Falt-Eingriffe gelten je Dokument-Sitzung (D38) */ loadActiveIntoEditor(); persistDocs(); } @@ -2084,6 +2156,7 @@ function newDoc(){ const d = { id: uid(), name: uniqueName(t('docNewName')), text: '' }; docs.push(d); activeId = d.id; + foldOverrides.clear(); loadActiveIntoEditor(); persistDocs(); keyboardOnJump(false); /* neues, leeres Dokument = tippen ist gemeint */ @@ -2112,6 +2185,7 @@ function deleteDoc(){ docs = docs.filter(x => x.id !== d.id); if(!docs.length) docs = [{ id: EXAMPLE_ID, name: EXAMPLE_NAME, text: INITIAL }]; activeId = docs[0].id; + foldOverrides.clear(); loadActiveIntoEditor(); persistDocs(); closeDocMenu(); diff --git a/frontend/src/model.js b/frontend/src/model.js index 948ab8d..c310a89 100644 --- a/frontend/src/model.js +++ b/frontend/src/model.js @@ -100,6 +100,46 @@ function walkKeys(nodes, parentKey, fn){ walkKeys(n.children, key, fn); } } +/* Knoten -> stabiler Schlüssel (Label-Pfad) über den ganzen Baum. Dieselbe + Identität wie bei „Was ist neu?" — sie überlebt Umsortieren und Neu-Parsen; + genutzt für die interaktiven Falt-Eingriffe (D38). */ +export function nodeKeys(roots){ + const map = new Map(); + walkKeys(roots, '', (key, n) => map.set(n, key)); + return map; +} + +/* ---------- Faltmarken (SPEC §1/§9, D38) ---------- + Anfangszustand der Faltung aus den Textmarken: `>` klappt den Knoten ein. + `<` (und mit `rescueFocus` auch die Fokusmarke `!!!`) holt den eigenen + Teilbaum hervor, indem die Faltung die Pfad-Ebenen HINUNTERWANDERT: Jeder + eingeklappte Vorfahr wird geöffnet, seine Nicht-Pfad-Kinder werden + stattdessen eingeklappt. Sichtbar ist genau der Pfad samt Teilbaum, die + Geschwister stehen als einzelne eingeklappte Knoten da — und jede + gezeichnete Kante bleibt eine echte. Ein `>` innerhalb des hervorgeholten + Teilbaums bleibt respektiert. */ +export function initialCollapsed(roots, rescueFocus){ + const set = new Set(); + const paths = []; + const walk = (n, path) => { + const p = path.concat(n); + if(n.fold === '>') set.add(n); + if(n.fold === '<' || (rescueFocus && n.focus)) paths.push(p); + n.children.forEach(c => walk(c, p)); + }; + roots.forEach(r => walk(r, [])); + for(const p of paths){ + for(let i = 0; i < p.length - 1; i++){ + if(!set.has(p[i])) continue; + set.delete(p[i]); + for(const c of p[i].children) + if(c !== p[i+1] && c.children.length) set.add(c); + } + set.delete(p[p.length - 1]); /* der geholte Knoten selbst ist offen */ + } + return set; +} + /* key -> Status-Schlüssel ('' für neutrale Knoten) über den ganzen Baum. */ export function statusByKey(roots){ const map = new Map(); diff --git a/frontend/src/parser.js b/frontend/src/parser.js index 3bc566a..a5d0e54 100644 --- a/frontend/src/parser.js +++ b/frontend/src/parser.js @@ -25,8 +25,10 @@ export const STATUS_BY_CODE = { const REALIZED = new Set(['arbeit', 'durchstich', 'fertig', 'prod']); /* Parst den Notationstext zu { roots, warnings }. - Jeder Knoten: {label, type:'and'|'or'|'xor', optional, status, url, size, - tags, id, deps, focus, children, line}. + Jeder Knoten: {label, type:'and'|'or'|'xor', optional, fold, status, url, + size, tags, id, deps, focus, children, line}. + `fold` ('>'|'<'|null, SPEC §1/D38) ist nur der ANFANGSZUSTAND der Faltung — + den wirksamen Zustand rechnet `initialCollapsed()` in model.js. `deps` sind ID-Strings, keine Knoten-Referenzen — aufgelöst wird erst beim Konsumenten (D37); der Parser prüft nur die Existenz (`unknownDep`). `type` ist das Gate der Geschwistergruppe, `optional` (Zeichen `+`, SPEC §3) @@ -51,14 +53,18 @@ export function parse(text){ Statusposition. Gültige Codes -> Status; unbekannte -> Warnung + neutral (fehlertolerant: die Zeile geht nicht verloren). */ /* `=` (XOR, SPEC §3) nur mit folgendem Leerraum — die Leerraum-Regel hält - Labels wie `=SUMME(A1:B2)` heraus; `-`/`+`/`|` bleiben wie bisher. */ - const m = raw.match(/^([ \t]*)([-|+]|=(?=[ \t]))?\s*(?:\[([^\]])\]\s*)?(.*)$/); + Labels wie `=SUMME(A1:B2)` heraus; `-`/`+`/`|` bleiben wie bisher. + Die Faltmarke `>`/`<` (SPEC §1, D38) steht zwischen Zeichen und + Statusbox (bei Wurzeln am Zeilenanfang), ebenfalls nur mit folgendem + Leerraum — `- >Achtung` bleibt ein Label. */ + const m = raw.match(/^([ \t]*)([-|+]|=(?=[ \t]))?\s*(?:([><])(?=[ \t])\s*)?(?:\[([^\]])\]\s*)?(.*)$/); const width = m[1].replace(/\t/g,' ').length; const type = m[2] === '|' ? 'or' : m[2] === '=' ? 'xor' : 'and'; const optional = m[2] === '+'; - const boxChar = m[3]; // undefined, wenn keine Statusbox + const fold = m[3] || null; + const boxChar = m[4]; // undefined, wenn keine Statusbox - let rest = m[4], url = null, size = null; + let rest = m[5], url = null, size = null; const tags = []; rest = rest.replace(/https?:\/\/\S+/i, s => { url = s; return ''; }); rest = rest.replace(/\((XXL|XS|XL|S|M|L)\)/i, (s, g) => { size = g.toUpperCase(); return ''; }); @@ -102,7 +108,7 @@ export function parse(text){ while(stack.length > 1 && stack[stack.length-1].width >= width) stack.pop(); const parent = stack[stack.length-1].node; - const node = {label, type, optional, status, url, size, tags, id, deps, focus, children:[], line:i+1}; + const node = {label, type, optional, fold, status, url, size, tags, id, deps, focus, children:[], line:i+1}; parent.children.push(node); stack.push({node, width}); }); diff --git a/frontend/src/render.js b/frontend/src/render.js index 7c20d72..11958a3 100644 --- a/frontend/src/render.js +++ b/frontend/src/render.js @@ -10,6 +10,7 @@ cheapPath, // günstigster Pfad aktiv? (steuert das implizite M-Badge) cheapSet, // Set der nötigen Knoten (leer, wenn Pfad aus) freshSet, // optional: Knoten, die neu in Produktion sind (D28) + collapsedSet, // optional: eingeklappte Knoten (Faltung, SPEC §9/D38) } */ import { gateOf, needsBreakdown, visibleChildren, cheapCls } from './model.js'; @@ -44,9 +45,12 @@ function attr(s){ return esc(String(s)).replace(/"/g,'"'); } Link. Die visuellen Badges (Größe, Tags, ↗) sind aria-hidden — ihre Information steckt hier, sonst würde der Screenreader Kryptisches („M", „anna", „↗") vorlesen. */ -function nodeAria(n, opts){ +function nodeAria(n, opts, fold){ const { t, cheapPath } = opts; const parts = [n.label]; + /* Eingeklappt (SPEC §9/D38): das ▾/▸-Zeichen ist aria-hidden — ohne diese + Ansage wüsste ein Screenreader nicht, dass hier etwas verborgen ist. */ + if(fold && fold.collapsed) parts.push(t('a11yFolded', {n: fold.count})); if(n.status) parts.push(t('a11yStatus', {status: t('st_' + n.status.key)})); if(n.size) parts.push(t('a11ySize', {size: n.size})); else if(cheapPath) parts.push(t('a11ySizeImplicit')); @@ -65,10 +69,11 @@ function nodeAria(n, opts){ return parts.join(', '); } -function nodeHtml(n, extra, opts){ +function nodeHtml(n, extra, opts, fold){ const { t, cheapPath } = opts; const need = needsBreakdown(n); - const cls = ['node', extra || '', n.status ? 'st-' + n.status.key : ''] + const cls = ['node', extra || '', fold && fold.collapsed ? 'folded' : '', + n.status ? 'st-' + n.status.key : ''] .filter(Boolean).join(' '); /* Zeilennummer am Knoten (D25): Grundlage für den Sprung ins Textfeld und für die Gegenrichtung (Cursor-Zeile -> Knoten hervorheben). Der Hinweis im @@ -92,20 +97,62 @@ function nodeHtml(n, extra, opts){ const riskMark = n.status && n.status.key === 'highrisk' ? `` : ''; - const inner = esc(n.label) + + /* Falt-Zeichen (SPEC §9/D38): ▾ offen, „▸ n" eingeklappt — das Klickziel + fürs Umklappen (der einfache Klick auf den Knoten bleibt der Link, §6). + aria-hidden: die Information steht im aria-label (a11yFolded). */ + const foldHtml = fold + ? `` + : ''; + const expanded = fold ? ` aria-expanded="${!fold.collapsed}"` : ''; + const inner = foldHtml + + esc(n.label) + (n.url ? '' : '') + riskMark + sizeBadge + tagsHtml; - const aria = ` aria-label="${attr(nodeAria(n, opts))}"`; + const aria = ` aria-label="${attr(nodeAria(n, opts, fold))}"`; const html = n.url - ? `
${inner}` - : `
${inner}
`; + ? `${inner}` + : `
${inner}
`; const ghostTip = attr(t('ghostTooltip')); const ghost = `
${esc(t('ghost'))}
`; return html + (need ? ghost : ''); } +/* Eingeklappter Teilbaum (SPEC §9/D38): Das HTML entfällt, aber die + Warnungen des verborgenen Teils werden trotzdem gemeldet — sie sind eine + Aussage über den TEXT, nicht über die Ansicht. Derselbe Lauf zählt die + verborgenen Knoten für das „▸ n"-Kennzeichen. */ +function walkFolded(node, warnings, opts){ + const kids = visibleChildren(node, opts.showDiscarded); + if(!kids.length) return 0; + const types = new Set(kids.map(k => k.type)); + if(types.size > 1){ + warnings.push({type: 'mixedGate', line: kids[0].line, label: node.label}); + } + let count = kids.length; + for(const k of kids) count += walkFolded(k, warnings, opts); + return count; +} + +/* Ein Knoten samt
  • und (sofern nicht eingeklappt) seiner Kinder. */ +function itemHtml(n, extra, warnings, opts){ + const vk = visibleChildren(n, opts.showDiscarded); + const canFold = vk.length > 0; + const collapsed = canFold && !!(opts.collapsedSet && opts.collapsedSet.has(n)); + const fold = canFold + ? {collapsed, count: collapsed ? walkFolded(n, warnings, opts) : 0} + : null; + /* `opt` auch am
  • : den Abzweig zeichnen dessen Pseudoelemente, er wird + für optionale Knoten gestrichelt (D29). Eingeklappt ist der Knoten ein + Blatt — kein has-*-Layout, keine Kinderliste. */ + const liCls = liClass(collapsed ? [] : vk, opts, n.optional); + return `` + + nodeHtml(n, extra, opts, fold) + + (collapsed ? '' : renderChildren(n, warnings, opts)) + + `
  • `; +} + function renderChildren(node, warnings, opts){ const kids = visibleChildren(node, opts.showDiscarded); if(!kids.length) return ''; @@ -122,16 +169,7 @@ function renderChildren(node, warnings, opts){ die Klasse `or` (alle Modi, Export-Routing); `xor` ergänzt nur die „1"-Plakette an der Sammelleiste (D35). */ const ulCls = gate === 'xor' ? 'or xor' : gate; - const items = kids.map(k => { - const vk = visibleChildren(k, opts.showDiscarded); - /* `opt` auch am
  • : den Abzweig zeichnen dessen Pseudoelemente, er wird - für optionale Knoten gestrichelt (D29). */ - const liCls = liClass(vk, opts, k.optional); - return `` + - nodeHtml(k, extraCls(k, opts), opts) + - renderChildren(k, warnings, opts) + - `
  • `; - }).join(''); + const items = kids.map(k => itemHtml(k, extraCls(k, opts), warnings, opts)).join(''); return ``; } @@ -140,13 +178,8 @@ function renderChildren(node, warnings, opts){ Leere Wurzelliste ⇒ leerer String. */ export function renderTreeHtml(roots, opts){ const warnings = []; - const html = roots.map(root => { - const vk = visibleChildren(root, opts.showDiscarded); - const liCls = liClass(vk, opts, root.optional); - return `` + - nodeHtml(root, ('root-node ' + extraCls(root, opts)).trim(), opts) + - renderChildren(root, warnings, opts) + - ``; - }).join(''); + const html = roots.map(root => + itemHtml(root, ('root-node ' + extraCls(root, opts)).trim(), warnings, opts) + ).join(''); return { html, warnings }; } diff --git a/frontend/src/style.css b/frontend/src/style.css index 1b48ca6..84b531e 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -549,6 +549,18 @@ } a.node{text-decoration:none;cursor:pointer} a.node:hover{box-shadow:0 2px 8px rgba(36,52,71,.22)} + /* Falt-Zeichen (SPEC §9, D38): ▾ offen / „▸ n" eingeklappt, Klickziel fürs + Umklappen. Gedeckt in Grau — es ist Bedienelement, keine Statusaussage; + beim Zeigen färbt es sich zur Tinte. */ + .node .fold{ + cursor:pointer;color:var(--muted); + font-size:.72em;margin-right:6px; + user-select:none;-webkit-user-select:none; + } + .node .fold:hover{color:var(--line)} + /* Der dunkle Wurzelknoten braucht ein helleres Zeichen als das Grau. */ + .root-node .fold{color:rgba(255,255,255,.75)} + .root-node .fold:hover{color:#fff} /* 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)} @@ -1040,5 +1052,8 @@ /* Editierhilfe bzw. Zuruf, nicht drucken (D25, Fokusmarke SPEC §1) */ .node.current,.node.focusmark{box-shadow:none!important} .node.fresh{box-shadow:none!important} /* „neu seit dem letzten Besuch" ist persönlich (D28) */ + /* Faltung (D38): das ▾ offener Knoten ist Bedienelement — nicht drucken; + das „▸ n" eingeklappter Knoten ist Kennzeichnung und bleibt. */ + .node:not(.folded) .fold{display:none!important} @page{margin:12mm} } diff --git a/frontend/tests/__snapshots__/render.test.js.snap b/frontend/tests/__snapshots__/render.test.js.snap index 13df568..9b01342 100644 --- a/frontend/tests/__snapshots__/render.test.js.snap +++ b/frontend/tests/__snapshots__/render.test.js.snap @@ -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`] = `"
  • Website-Relaunch
    • Konzeption
      • Zielgruppenanalyse
      • Sitemap
    • Umsetzung
      • Frontend
      • Backend
        ghost
      • Dark Mode
      • CMS-Anbindung
        • WordPress
        • Headless CMS
    • Hosting
      • Cloud
      • On-Premise
  • "`; +exports[`renderTreeHtml — kanonisches Beispiel > Grundzustand (Pfad aus, verworfene aus): Struktur-Snapshot 1`] = `"
  • Website-Relaunch
    • Konzeption
      • Zielgruppenanalyse
      • Sitemap
    • Umsetzung
      • Frontend
      • Backend
        ghost
      • Dark Mode
      • CMS-Anbindung
        • WordPress
        • Headless CMS
    • Hosting
      • Cloud
      • On-Premise
  • "`; -exports[`renderTreeHtml — kanonisches Beispiel > günstigster Pfad an: cheap/cheap-leaf + implizite M-Badges 1`] = `"
  • Website-Relaunch
    • Konzeption
      • Zielgruppenanalyse
      • Sitemap
    • Umsetzung
      • Frontend
      • Backend
        ghost
      • Dark Mode
      • CMS-Anbindung
        • WordPress
        • Headless CMS
    • Hosting
      • Cloud
      • On-Premise
  • "`; +exports[`renderTreeHtml — kanonisches Beispiel > günstigster Pfad an: cheap/cheap-leaf + implizite M-Badges 1`] = `"
  • Website-Relaunch
    • Konzeption
      • Zielgruppenanalyse
      • Sitemap
    • Umsetzung
      • Frontend
      • Backend
        ghost
      • Dark Mode
      • CMS-Anbindung
        • WordPress
        • Headless CMS
    • Hosting
      • Cloud
      • On-Premise
  • "`; -exports[`renderTreeHtml — kanonisches Beispiel > verworfene einblenden: Eigenentwicklung erscheint (durchgestrichen) 1`] = `"
  • Website-Relaunch
    • Konzeption
      • Zielgruppenanalyse
      • Sitemap
    • Umsetzung
      • Frontend
      • Backend
        ghost
      • Dark Mode
      • CMS-Anbindung
        • WordPress
        • Headless CMS
        • Eigenentwicklung
    • Hosting
      • Cloud
      • On-Premise
  • "`; +exports[`renderTreeHtml — kanonisches Beispiel > verworfene einblenden: Eigenentwicklung erscheint (durchgestrichen) 1`] = `"
  • Website-Relaunch
    • Konzeption
      • Zielgruppenanalyse
      • Sitemap
    • Umsetzung
      • Frontend
      • Backend
        ghost
      • Dark Mode
      • CMS-Anbindung
        • WordPress
        • Headless CMS
        • Eigenentwicklung
    • Hosting
      • Cloud
      • On-Premise
  • "`; diff --git a/frontend/tests/fold.test.js b/frontend/tests/fold.test.js new file mode 100644 index 0000000..832efb5 --- /dev/null +++ b/frontend/tests/fold.test.js @@ -0,0 +1,128 @@ +import { describe, it, expect } from 'vitest'; +import { parse } from '../src/parser.js'; +import { initialCollapsed, computeCheapSet } from '../src/model.js'; +import { renderTreeHtml } from '../src/render.js'; + +const t = key => key; +const roots = txt => parse(txt).roots; +const render = (txt, collapsedSet) => renderTreeHtml(roots(txt), + {t, showDiscarded: false, cheapPath: false, cheapSet: new Set(), + collapsedSet: collapsedSet || new Set()}); +/* Rendert mit dem Anfangszustand aus den Textmarken (wie app.js, ohne Overrides). */ +const renderFolded = txt => { + const r = roots(txt); + return renderTreeHtml(r, {t, showDiscarded: false, cheapPath: false, + cheapSet: new Set(), collapsedSet: initialCollapsed(r, true)}); +}; +const collapsedLabels = (txt, rescueFocus = true) => + [...initialCollapsed(roots(txt), rescueFocus)].map(n => n.label).sort(); + +/* Faltmarken `>`/`<` (SPEC §1/§9, D38): Anfangszustand der Faltung im Text. */ +describe('Parser — Faltmarke zwischen Zeichen und Statusbox', () => { + it('erkennt `>` und `<` und nimmt sie aus dem Label', () => { + const [wurzel] = roots(`[ ] Wurzel\n - > [x] Zu (M)\n - < [ ] Auf`); + expect(wurzel.children.map(k => [k.label, k.fold, k.status?.key])) + .toEqual([['Zu', '>', 'fertig'], ['Auf', '<', 'geplant']]); + expect(wurzel.fold).toBe(null); + }); + + it('erkennt die Marke am Wurzelknoten (ohne Zeichen) am Zeilenanfang', () => { + const [wurzel] = roots(`> [~] Kapitel\n - [ ] Kind`); + expect([wurzel.fold, wurzel.label]).toEqual(['>', 'Kapitel']); + }); + + it('verlangt folgenden Leerraum — `- >Achtung` bleibt ein Label', () => { + const [wurzel] = roots(`[ ] Wurzel\n - >Achtung`); + expect(wurzel.children.map(k => [k.label, k.fold])) + .toEqual([['>Achtung', null]]); + }); + + it('lässt ein `>` mitten im Label unberührt', () => { + const [wurzel] = roots(`[ ] a > b`); + expect([wurzel.label, wurzel.fold]).toEqual(['a > b', null]); + }); +}); + +describe('initialCollapsed — `>` klappt ein, `<` wandert die Faltung hinunter', () => { + it('klappt `>`-Knoten ein', () => { + expect(collapsedLabels(`[ ] W\n - > [ ] A\n - [ ] A1\n - [ ] B`)) + .toEqual(['A']); + }); + + it('holt einen `<`-Teilbaum hervor: Vorfahr öffnet, Geschwister klappen ein', () => { + const txt = `> [ ] W + - [ ] A + - [ ] A1 + - [ ] B + - < [ ] B1 + - [ ] B1a + - [ ] C`; + /* W öffnet sich (Pfad zu B1), A klappt stattdessen ein; B öffnet den Weg, + B1 samt Teilbaum ist sichtbar. C ist Blatt und braucht keine Faltung. */ + expect(collapsedLabels(txt)).toEqual(['A']); + }); + + it('respektiert ein `>` innerhalb des hervorgeholten Teilbaums', () => { + const txt = `> [ ] W + - < [ ] B + - > [ ] B1 + - [ ] B1a`; + expect(collapsedLabels(txt)).toEqual(['B1']); + }); + + it('holt auch einen `!!!`-markierten Knoten hervor', () => { + const txt = `> [ ] W\n - [ ] A\n - [ ] A1\n - [ ] B !!!\n - [ ] B1`; + expect(collapsedLabels(txt)).toEqual(['A']); + /* … aber nur mit rescueFocus — headless-Aufrufer können es abschalten. */ + expect(collapsedLabels(txt, false)).toEqual(['W']); + }); + + it('lässt Bäume ohne Marken vollständig offen', () => { + expect(collapsedLabels(`[ ] W\n - [ ] A\n - [ ] B`)).toEqual([]); + }); +}); + +describe('Renderer — eingeklappte Teilbäume', () => { + const TXT = `[ ] W\n - > [~] A (M)\n - [ ] A1\n - [ ] A2\n - [ ] A2a\n - [ ] B`; + + it('lässt die Kinder eines eingeklappten Knotens weg', () => { + const {html} = renderFolded(TXT); + expect(html).not.toContain('A1'); + expect(html).toContain('>B<'); + }); + + it('kennzeichnet mit Klasse `folded` und „▸ n" (n = alle verborgenen Knoten)', () => { + const {html} = renderFolded(TXT); + expect(html).toContain('folded'); + expect(html).toContain(''); + expect(html).toContain('aria-expanded="false"'); + expect(html).toContain('a11yFolded'); + }); + + it('gibt offenen Eltern das ▾, Blättern gar kein Falt-Zeichen', () => { + const {html} = render(`[ ] W\n - [ ] Blatt`); + expect((html.match(/class="fold"/g) || []).length).toBe(1); + expect(html).toContain('>▾'); + expect(html).toContain('aria-expanded="true"'); + }); + + it('macht das eingeklappte
  • zum Blatt (kein has-*-Layout)', () => { + /* Nur W verzweigt sichtbar; das eingeklappte A steht als klassenloses
  • . */ + const {html} = renderFolded(TXT); + expect((html.match(/has-and|has-or/g) || []).length).toBe(1); + expect(html).toContain('
  • { + const txt = `[ ] W\n - > [ ] A\n | [ ] X\n - [ ] Y`; + const {html, warnings} = renderFolded(txt); + expect(html).not.toContain('>X<'); + expect(warnings).toEqual([{type: 'mixedGate', line: 3, label: 'A'}]); + }); + + it('lässt den günstigsten Pfad unberührt — Faltung ist reine Ansicht', () => { + const r = roots(`[ ] W (XS)\n - > [ ] A (S)\n | [ ] A1 (XL)\n | [ ] A2 (S)`); + expect([...computeCheapSet(r)].map(n => n.label).sort()) + .toEqual(['A', 'A2', 'W']); + }); +}); diff --git a/frontend/tests/render.test.js b/frontend/tests/render.test.js index 2f3b86e..53b1f92 100644 --- a/frontend/tests/render.test.js +++ b/frontend/tests/render.test.js @@ -110,7 +110,9 @@ describe('renderTreeHtml — Moduswechsel ist CSS, nicht Renderer', () => { (die als 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); + /* Eltern-Knoten tragen seit D38 das Falt-Zeichen (▾/▸) vor dem Label. */ + const re = new RegExp('<(?:a|div) class="node[^"]*"[^>]*?data-line="(\\d+)"[^>]*>' + + '(?:]*>[^<]*)?' + label); const m = html.match(re); return m ? Number(m[1]) : null; };