From aa6ca85bdf54904e09785efa42a9043e7036c19b Mon Sep 17 00:00:00 2001 From: mhoennig Date: Fri, 28 Aug 2026 20:21:03 +0200 Subject: [PATCH] =?UTF-8?q?fix(diagram):=20umgeklappter=20Knoten=20beh?= =?UTF-8?q?=C3=A4lt=20seine=20Position=20im=20Bild=20=E2=80=94=20Scrollsta?= =?UTF-8?q?nd=20zieht=20nach=20(D38-Nachtrag=205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/CHANGELOG.md | 1 + docs/DECISIONS.md | 40 ++++++++++++++++++++++++++++++++++++++++ docs/SPEC.md | 5 ++++- frontend/src/app.js | 22 ++++++++++++++++++---- 4 files changed, 63 insertions(+), 5 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 0d6ceca..b4ec06e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,7 @@ reverse. ## 2026-08-28 +- Folding or unfolding a node keeps it in place on screen now: the scroll position follows the layout shift, so the clicked node no longer jumps away - The node window no longer opens on hover: click a node to open it, click again or elsewhere to close — folding and moving across the tree stay snappy - A linked node's plain click now opens the window everywhere, like on touch — the URL sits behind the ↗ button, and Enter on the focused node or Ctrl+click still open it directly - The Taiga buttons in the node window sit side by side in one row now — the window grows a little wider for them, and they wrap only when space truly runs out diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 8082e95..9a68a46 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -2695,6 +2695,46 @@ eingeklappte Elternknoten trägt Ring und Puls; Alt+Klick fokussiert ihn ausgeblendeten verworfenen Zeile und ihrem Kind → weiterhin keine Hervorhebung. 377 Tests, davon 5 neue in `tests/fold.test.js`. +**Nachtrag 5 — der umgeklappte Knoten behält seine Position im Bild +(2026-08-28).** Gemeldet als Frage: „Ist es hinzubekommen, dass beim +Auf-/Zuklappen eines Knotens dieser seine Position auf dem Canvas +beibehält?" Der Sprung hatte eine einfache Ursache: `toggleFold` baut den +Baum neu, und im horizontalen Fächer steht ein Elternknoten **zentriert über +seinem Teilbaum** — klappt der zu, schrumpft die Zelle und der Knoten rückt +um die halbe Breitendifferenz nach links, während der Scrollstand von +`.diagram` stehen bleibt. Gemessen am Testbaum: **421 px** Versatz für ein +Paket mit vier M-Kindern — der geklickte Knoten war nach dem Klick woanders. + +**Der Anker: vorher messen, nachher nachziehen.** `toggleFold` hält vor dem +Neubau das `getBoundingClientRect()` des Knotens fest; `refocusNode` — die +Stelle, die nach dem Neubau ohnehin denselben Knoten wiederfindet (per +`data-line`, das Falten ändert keine Zeilennummern) — misst das neue Rect +und verschiebt `scrollLeft`/`scrollTop` von `.diagram` um die Differenz. +Beide Rects sind Viewport-Koordinaten des **ungezoomten** Scroll-Containers +(der `zoom` sitzt auf `#out` darin, D17-Nachtrag 2) — die Differenz ist +also direkt scrollbar, ohne Zoom-Rückrechnung; nachgemessen bei Zoom 0,8: +1 px Abweichung (Rundung). + +**Am Rand klemmt der Browser — bestmöglich statt exakt.** Steht das +Diagramm ganz links (`scrollLeft` 0) und der Knoten will nach der Korrektur +weiter links stehen, gibt es nichts zu scrollen: Der Inhalt ist physisch +gerückt, der Knoten springt dann doch (gemessen: −421 px bei Klemme auf 0). +Das ist der ehrliche Rest — eine Gegenkorrektur per `transform` o. Ä. wäre +eine zweite Positionswahrheit neben dem Layout. + +**Gilt für alle Wege durch `toggleFold`:** Falt-Chip, Tastatur ←/→ und die +Hand-Faltung unter der Personen-Linse (D87). Der **Falt-Durchschalter** +(D75) bleibt außen vor — er baut den ganzen Baum um, es gibt keinen einen +Knoten, an dem man ankern könnte. + +**Nachgemessen** im Browser (Testbaum 31 Knoten, 6560 px breit in 1183 px +Sichtfenster): Zu- und Aufklappen von „Paket 3" mittig im Bild → dx 0 px, +dy 1 px über die Rundreise; die Korrektur bewegte den Scrollstand um exakt +die 421 px des Versatzes; Klemm-Fall wie oben; Zoom-Gegenprobe 0,8 → 1 px. +Der Falt-Chip-Klick öffnet weiterhin kein Knoten-Fenster (D92). 628 Tests +unverändert — die Korrektur ist DOM-Geometrie und damit Browser-geprüft +(dieselbe Grenze wie `alignStems()`, D29). + ## D39 — Effektiver Status: die Farbe sagt die Wahrheit, die Marke die Box Mit den Abhängigkeiten (D37) gibt es zwei Aussagen je Knoten: was in der Statusbox steht (intrinsisch) und wie weit er wirklich ist (effektiv, §4). diff --git a/docs/SPEC.md b/docs/SPEC.md index 1e1a225..9a9b4dc 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -1214,7 +1214,10 @@ als kleiner **gerahmter Chip**, damit das Klickziel zu treffen ist (D64; das nackte Glyph war zu klein). Klick auf das Zeichen klappt um — der einfache Klick auf den Knoten selbst öffnet das Knoten-Fenster (§9, D92); Tastatur: **←** klappt zu, **→** klappt -auf am fokussierten Knoten (WAI-ARIA-Baum-Idiom). +auf am fokussierten Knoten (WAI-ARIA-Baum-Idiom). Der umgeklappte Knoten +**behält dabei seine Position im Bild**: Der Neubau verschiebt den Inhalt, +der Scrollstand wird um die Differenz nachgezogen — soweit der Rand es +zulässt (D38-Nachtrag 5). - **Anfangszustand aus dem Text (§1):** `>` klappt ein. `<` holt seinen Teilbaum hervor, indem die Faltung die Pfad-Ebenen **hinunterwandert**: diff --git a/frontend/src/app.js b/frontend/src/app.js index a1c0a2a..b2ea6d9 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -1465,11 +1465,24 @@ function applyFoldPreset(mode){ sonst risse die Tastaturbedienung ab (das alte Element ist weg) — aber ohne das Knoten-Fenster zu rufen: Falten ist genau die Geste, der das Fenster im Weg stand (D92), und die `:focus-visible`-Heuristik ist beim - programmatischen Fokus nicht verlässlich genug für diese Zusage. */ + programmatischen Fokus nicht verlässlich genug für diese Zusage. + Und er behält seine POSITION IM BILD (D38-Nachtrag 5): Der Neubau verschiebt + den Inhalt (der Teilbaum ändert die Breite alles Umliegenden), der Scrollstand + von `.diagram` bliebe stehen — der geklickte Knoten spränge. Vorher wird seine + Sichtfenster-Position gemessen, danach der Scrollstand um die Differenz + nachgezogen. Beide Rects sind Viewport-Koordinaten des ungezoomten + Scroll-Containers (der `zoom` sitzt auf #out DARIN), die Differenz ist also + direkt scrollbar; am Rand klemmt der Browser — bestmöglich statt exakt. */ let suppressTipFocus = false; -function refocusNode(line){ +function refocusNode(line, before){ const again = out.querySelector('.node[data-line="' + line + '"]'); if(!again) return; + if(before){ + const after = again.getBoundingClientRect(); + const sc = document.querySelector('.diagram'); + sc.scrollLeft += after.left - before.left; + sc.scrollTop += after.top - before.top; + } suppressTipFocus = true; /* focusin läuft synchron im focus() */ again.focus({preventScroll: true}); suppressTipFocus = false; @@ -1478,20 +1491,21 @@ function toggleFold(el){ const line = +el.dataset.line; const st = foldByLine.get(line); if(!st || !st.canFold) return; + const before = el.getBoundingClientRect(); /* Bei aktiver Personen-Linse (D87) bleibt die Hand-Faltung in deren Sitzungs-Überlagerung — in den Text geschrieben würde sonst der persönliche Filter, in geteilten Dokumenten für alle. */ if(lens){ lensOverrides.set(st.key, !st.collapsed); render(); - refocusNode(line); + refocusNode(line, before); return; } foldOverrides.set(st.key, !st.collapsed); /* Das Schreiben löst per `input`-Ereignis schon ein render() aus. */ if(writeFoldToText(line, !st.collapsed)) foldOverrides.clear(); else render(); - refocusNode(line); + refocusNode(line, before); } /* Klick auf das Falt-Zeichen ▾/▸ klappt um. preventDefault, weil das Zeichen