From 6888762ac8e28abb4cb3811328e50a4dbcad749b Mon Sep 17 00:00:00 2001 From: mhoennig Date: Mon, 27 Jul 2026 14:02:56 +0200 Subject: [PATCH] frontend: Legende scrollbar + Splitter zwischen Editor und Legende MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Legende wurde abgeschnitten statt zu scrollen — obwohl `.hint` seit jeher `flex:1 1 auto; min-height:0; overflow:auto` trägt. Ursache: Chrome legt den Inhalt eines
in das Pseudo-Element `::details-content`. Damit war `.hint` kein Flex-Kind von `.agenda` mehr, die Begrenzung griff nicht und der Kasten wuchs auf seine Inhaltshöhe (gemessen 585 px in einem 282 px hohen Container) und wurde geclippt. Eine Regel für `::details-content` wirkte im Test, ist aber Chrome-eigen — Firefox und Safari kennen das Pseudo-Element nicht. - Die Legende ist deshalb kein
mehr, sondern `div.agenda` + `button.agenda-summary`; der Zustand hängt an der Klasse `open` (`aria-expanded` am Button, `#legendBtn` und Splitter-Sichtbarkeit über `setAgendaOpen()` synchron). `.hint` ist wieder echtes Flex-Kind und scrollt in allen Browsern. - Neuer Splitter `#hintGutter` im selben Idiom wie der große (Pointer-Capture, Doppelklick setzt zurück): horizontal nebeneinander -> Breite `--hcol`, gestapelt (side/mobil) -> Höhe `--hrow`. Beide getrennt gehalten und in `werkbaum-ui` persistiert (samt Auf-/Zu-Zustand), damit ein Moduswechsel die jeweils andere Aufteilung nicht zerstört. Grenzen 90 px bis 85 %. - Die 85-%-Obergrenze steht zusätzlich als max-width/max-height im CSS: die gespeicherte Größe ist ein fester px-Wert und würde den Editor sonst auf 0 drücken, wenn das Panel später schrumpft (großer Splitter, Drehung, Bildschirmtastatur). Verifiziert mit --hrow:9999px -> gekappt bei 85 %. - Nebenbei: der große Splitter war als einziges Bedienelement nicht lokalisiert (fest deutsche title/aria-label). Beide teilen sich nun `gutterTooltip`, dazu `gutterAria`/`hintGutterAria` — 9 Sprachen. Verifiziert im Browser: Legende scrollt (247 px Fenster, 607 px Inhalt); Ziehen in beiden Ausrichtungen inkl. Unter-/Obergrenze und Doppelklick-Reset; `--hcol` und `--hrow` überleben den Moduswechsel; nach Neuladen sind Auf-Zustand, Breite und beide Variablen wiederhergestellt; auf Mobil ist die Summary-Zeile aus und der Kopf-Knopf steuert. Vitest 37/37. Co-Authored-By: Claude Opus 4.8 --- docs/DECISIONS.md | 33 +++++++++++++++ docs/SPEC.md | 10 +++++ frontend/CLAUDE.md | 11 +++++ frontend/index.html | 9 ++-- frontend/src/app.js | 93 +++++++++++++++++++++++++++++++++++++++--- frontend/src/style.css | 46 +++++++++++++++------ 6 files changed, 181 insertions(+), 21 deletions(-) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index a4e1ba5..7515805 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -586,3 +586,36 @@ Verworfen: die Tastatur zuzulassen und auf die OS-Einstellung zu verweisen (lös es nur auf einem Gerät), sowie ein eigener Umschalter dafür (weiteres Bedienelement in einer engen Kopfzeile plus i18n in 9 Sprachen, für ein Verhalten, das kaum jemand umstellen will). + +## D26 — Legende scrollbar: eigener Container statt `
`, plus Splitter +Die Legende („Agenda") im Editor-Panel war zu hoch für ihren Platz und wurde +**abgeschnitten** statt scrollbar zu sein — obwohl `.hint` seit jeher +`flex:1 1 auto; min-height:0; overflow:auto` trug. + +**Ursache:** Chrome legt den Inhalt eines `
` seit einiger Zeit in das +Pseudo-Element **`::details-content`**. Damit ist `.hint` **kein Flex-Kind** von +`.agenda` mehr; die Flex-Begrenzung greift nicht, `.hint` wächst auf seine +Inhaltshöhe (gemessen 585 px in einem 282 px hohen Container) und wird vom +`overflow:hidden` der Agenda geclippt. Ein Gegenmittel wäre +`.agenda::details-content{display:flex;…}` (im Test wirksam: 585 → 248 px, +scrollbar), aber das Pseudo-Element ist Chrome-eigen — Firefox und Safari kennen +es nicht, dort hinge dieselbe Layout-Kette an einem anderen anonymen Kasten. + +**Entscheidung: kein `
` mehr.** Die Legende ist ein gewöhnliches +`
` mit `
-
- Agenda + +
+
Einrückung (2 Leerzeichen oder Tab) definiert die Hierarchie.
-  Teilpaket, alle erforderlich (all of)
@@ -96,11 +97,11 @@ Kommentare mit %% — als ganze Zeile oder am Zeilenende. Personen mit @name — erscheinen unten rechts am Knoten.
-
+
- +
diff --git a/frontend/src/app.js b/frontend/src/app.js index 962e700..dcfc855 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -770,6 +770,8 @@ const I18N = { downloadMenu:"Diagramm herunterladen (SVG/PNG)", minimize:"minimieren", normal:"normal", maximize:"maximieren", agenda:"Agenda", discarded:"verworfene", + gutterTooltip:"Ziehen zum Verschieben, Doppelklick setzt zurück", gutterAria:"Bereiche größenverändern", + hintGutterAria:"Editor und Legende größenverändern", modeHorizontal:"Horizontal – Organigramm, Diagramm über dem Editor", modeKompakt:"Kompakt – alles nach unten, platzsparend", modeVertikal:"Vertikal – Baum nach rechts, Diagramm neben dem Editor", @@ -818,6 +820,8 @@ const I18N = { downloadMenu:"Download diagram (SVG/PNG)", minimize:"minimize", normal:"normal", maximize:"maximize", agenda:"Legend", discarded:"discarded", + gutterTooltip:"Drag to resize, double-click resets", gutterAria:"Resize the areas", + hintGutterAria:"Resize editor and legend", modeHorizontal:"Horizontal – org chart, diagram above the editor", modeKompakt:"Compact – everything downward, space-saving", modeVertikal:"Vertical – tree to the right, diagram beside the editor", @@ -866,6 +870,8 @@ const I18N = { downloadMenu:"Descargar el diagrama (SVG/PNG)", minimize:"minimizar", normal:"normal", maximize:"maximizar", agenda:"Leyenda", discarded:"descartados", + gutterTooltip:"Arrastra para redimensionar, doble clic restablece", gutterAria:"Redimensionar las áreas", + hintGutterAria:"Redimensionar editor y leyenda", modeHorizontal:"Horizontal – organigrama, diagrama sobre el editor", modeKompakt:"Compacto – todo hacia abajo, ahorra espacio", modeVertikal:"Vertical – árbol hacia la derecha, diagrama junto al editor", @@ -914,6 +920,8 @@ const I18N = { downloadMenu:"Télécharger le diagramme (SVG/PNG)", minimize:"réduire", normal:"normal", maximize:"agrandir", agenda:"Légende", discarded:"abandonnés", + gutterTooltip:"Glisser pour redimensionner, double-clic pour réinitialiser", gutterAria:"Redimensionner les zones", + hintGutterAria:"Redimensionner l'éditeur et la légende", modeHorizontal:"Horizontal – organigramme, diagramme au-dessus de l'éditeur", modeKompakt:"Compact – tout vers le bas, gain de place", modeVertikal:"Vertical – arbre vers la droite, diagramme à côté de l'éditeur", @@ -962,6 +970,8 @@ const I18N = { downloadMenu:"Pobierz diagram (SVG/PNG)", minimize:"minimalizuj", normal:"normalny", maximize:"maksymalizuj", agenda:"Legenda", discarded:"odrzucone", + gutterTooltip:"Przeciągnij, aby zmienić rozmiar; dwuklik przywraca", gutterAria:"Zmień rozmiar obszarów", + hintGutterAria:"Zmień rozmiar edytora i legendy", modeHorizontal:"Poziomo – schemat organizacyjny, diagram nad edytorem", modeKompakt:"Kompaktowo – wszystko w dół, oszczędza miejsce", modeVertikal:"Pionowo – drzewo w prawo, diagram obok edytora", @@ -1010,6 +1020,8 @@ const I18N = { downloadMenu:"Скачать диаграмму (SVG/PNG)", minimize:"свернуть", normal:"обычный", maximize:"развернуть", agenda:"Легенда", discarded:"отклонённые", + gutterTooltip:"Потяните, чтобы изменить размер; двойной щелчок сбрасывает", gutterAria:"Изменить размер областей", + hintGutterAria:"Изменить размер редактора и легенды", modeHorizontal:"Горизонтально – оргсхема, диаграмма над редактором", modeKompakt:"Компактно – всё вниз, экономит место", modeVertikal:"Вертикально – дерево вправо, диаграмма рядом с редактором", @@ -1058,6 +1070,8 @@ const I18N = { downloadMenu:"आरेख डाउनलोड करें (SVG/PNG)", minimize:"छोटा करें", normal:"सामान्य", maximize:"बड़ा करें", agenda:"लेजेंड", discarded:"अस्वीकृत", + gutterTooltip:"आकार बदलने के लिए खींचें, डबल-क्लिक रीसेट करता है", gutterAria:"क्षेत्रों का आकार बदलें", + hintGutterAria:"संपादक और लेजेंड का आकार बदलें", modeHorizontal:"क्षैतिज – संगठन-चार्ट, संपादक के ऊपर आरेख", modeKompakt:"सघन – सब नीचे की ओर, जगह बचाता है", modeVertikal:"लंबवत – पेड़ दाईं ओर, संपादक के बगल में आरेख", @@ -1106,6 +1120,8 @@ const I18N = { downloadMenu:"下载图表(SVG/PNG)", minimize:"最小化", normal:"正常", maximize:"最大化", agenda:"图例", discarded:"已放弃", + gutterTooltip:"拖动可调整大小,双击可重置", gutterAria:"调整区域大小", + hintGutterAria:"调整编辑器和图例大小", modeHorizontal:"横向——组织结构图,图表在编辑器上方", modeKompakt:"紧凑——全部向下,节省空间", modeVertikal:"纵向——树向右展开,图表在编辑器旁边", @@ -1154,6 +1170,8 @@ const I18N = { downloadMenu:"ダイアグラムをダウンロード(SVG/PNG)", minimize:"最小化", normal:"標準", maximize:"最大化", agenda:"凡例", discarded:"破棄", + gutterTooltip:"ドラッグでサイズ変更、ダブルクリックでリセット", gutterAria:"領域のサイズを変更", + hintGutterAria:"エディターと凡例のサイズを変更", modeHorizontal:"横 — 組織図、ダイアグラムはエディターの上", modeKompakt:"コンパクト — すべて下方向、省スペース", modeVertikal:"縦 — ツリーを右へ、ダイアグラムはエディターの横", @@ -1353,6 +1371,12 @@ function saveUI(){ split: splitState, col: app.style.getPropertyValue('--col') || null, drow: app.style.getPropertyValue('--drow') || null, + /* Legende: auf/zu + Aufteilung Editor|Legende (D26). Bewusst per + DOM-Abfrage statt über die `agenda`-Konstante — die wird erst weiter + unten deklariert, und saveUI() läuft schon aus applySplit() heraus. */ + agenda: !!document.querySelector('.agenda.open'), + hcol: app.style.getPropertyValue('--hcol') || null, + hrow: app.style.getPropertyValue('--hrow') || null, zoom: zoom, fullscreen: document.body.classList.contains('fullscreen') })); @@ -1387,6 +1411,10 @@ function restoreState(){ if(ui && ui.col) app.style.setProperty('--col', ui.col); if(ui && ui.drow) app.style.setProperty('--drow', ui.drow); } + /* Legende (D26): Aufteilung gilt unabhängig vom Preset des großen Splitters. */ + if(ui && ui.hcol) app.style.setProperty('--hcol', ui.hcol); + if(ui && ui.hrow) app.style.setProperty('--hrow', ui.hrow); + setAgendaOpen(!!(ui && ui.agenda)); applyZoom(); restoring = false; } @@ -1595,13 +1623,68 @@ window.addEventListener('orientationchange', setAppHeight); if(window.visualViewport){ window.visualViewport.addEventListener('resize', setAppHeight); } +/* ---------- Legende: auf/zu + Splitter zum Editor (D26) ---------- + Die Legende ist ein gewöhnlicher Container (kein
mehr, siehe + style.css/D26); der Auf-/Zu-Zustand hängt an der Klasse `open`. */ const legendBtn = document.getElementById('legendBtn'); -const agenda = document.querySelector('.agenda'); -legendBtn.addEventListener('click', () => { - agenda.open = !agenda.open; - legendBtn.classList.toggle('active', agenda.open); +const agenda = document.getElementById('agenda'); +const agendaToggle = document.getElementById('agendaToggle'); +const hintGutter = document.getElementById('hintGutter'); +const editorBody = document.querySelector('.editor-body'); +function agendaOpen(){ return agenda.classList.contains('open'); } +function setAgendaOpen(on){ + agenda.classList.toggle('open', on); + agendaToggle.setAttribute('aria-expanded', on ? 'true' : 'false'); + legendBtn.classList.toggle('active', on); + hintGutter.hidden = !on; /* ohne offene Legende gibt es nichts zu teilen */ + saveUI(); +} +legendBtn.addEventListener('click', () => setAgendaOpen(!agendaOpen())); +agendaToggle.addEventListener('click', () => setAgendaOpen(!agendaOpen())); + +/* Splitter Editor|Legende. Im horizontalen Modus liegen sie nebeneinander + (Legendenbreite `--hcol`), in den gestapelten Modi untereinander + (Legendenhöhe `--hrow`) — dieselbe Fallunterscheidung wie beim großen + Splitter. Gezogen wird von der Editorseite aus, die Legende behält also + den Abstand zum jeweils gegenüberliegenden Rand. */ +const HINT_MIN = 90; /* Titelzeile + ein paar Zeilen bleiben immer da */ +const HINT_MAX_SHARE = 0.85; +function editorStacked(){ return isMobile() || app.classList.contains('side'); } +let hintDragging = false; +hintGutter.addEventListener('pointerdown', e => { + hintDragging = true; + hintGutter.classList.add('dragging'); + hintGutter.setPointerCapture(e.pointerId); + document.body.style.userSelect = 'none'; + e.preventDefault(); +}); +hintGutter.addEventListener('pointermove', e => { + if(!hintDragging) return; + const b = editorBody.getBoundingClientRect(); + if(editorStacked()){ + const h = Math.min(Math.max(b.bottom - e.clientY, HINT_MIN), b.height * HINT_MAX_SHARE); + app.style.setProperty('--hrow', Math.round(h) + 'px'); + } else { + const w = Math.min(Math.max(b.right - e.clientX, HINT_MIN), b.width * HINT_MAX_SHARE); + app.style.setProperty('--hcol', Math.round(w) + 'px'); + } +}); +function endHintDrag(e){ + if(!hintDragging) return; + hintDragging = false; + hintGutter.classList.remove('dragging'); + document.body.style.userSelect = ''; + try{ hintGutter.releasePointerCapture(e.pointerId); }catch(_){} + saveUI(); +} +hintGutter.addEventListener('pointerup', endHintDrag); +hintGutter.addEventListener('pointercancel', endHintDrag); +/* Doppelklick stellt die Vorgabegröße wieder her (CSS-Default greift dann). */ +hintGutter.addEventListener('dblclick', () => { + app.style.removeProperty('--hcol'); + app.style.removeProperty('--hrow'); + saveUI(); }); -agenda.addEventListener('toggle', () => legendBtn.classList.toggle('active', agenda.open)); /* Modus-Wähler auf kleinem Bildschirm: es ist nur das aktive Icon sichtbar, Tippen schaltet zum nächsten Modus (reihum). Auf normaler Größe bleibt es der Dreier-Umschalter — dort kehrt der Handler sofort zurück. */ diff --git a/frontend/src/style.css b/frontend/src/style.css index fb913d8..120b528 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -208,28 +208,48 @@ Horizontal nebeneinander, vertikal (.side) untereinander. */ .editor-body{display:flex;align-items:stretch;flex:1 1 auto;min-height:0} .editor-body textarea{flex:1 1 auto;min-width:0;min-height:0} + /* Legende: bewusst KEIN
mehr. Chrome legt den Details-Inhalt in + `::details-content`; dadurch war `.hint` kein Flex-Kind von `.agenda` mehr, + `flex:1/min-height:0` liefen ins Leere und der Inhalt wurde geclippt statt + scrollbar (D26). Als gewöhnlicher Container ist die Kette wieder dicht. */ .agenda{ flex:0 0 auto; border-left:1px dashed rgba(36,52,71,.18); - display:flex;flex-direction:column; + display:flex;flex-direction:column;min-height:0;min-width:0; } - .agenda[open]{width:300px} - .agenda>summary{ - list-style:none;cursor:pointer;white-space:nowrap; + /* Die Aufteilung steht als feste px-Größe. Schrumpft das Panel später (Zug am + großen Splitter, Drehung, Tastatur), begrenzt max-* die Legende, damit der + Editor nicht auf 0 gedrückt wird — gleiche Schranke wie beim Ziehen. */ + .agenda.open{width:var(--hcol,300px);max-width:85%;overflow:hidden} + .agenda-summary{ + flex:0 0 auto; + background:none;border:0;text-align:left; + cursor:pointer;white-space:nowrap; padding:10px 16px; font-family:'IBM Plex Mono',monospace; font-size:.7rem;letter-spacing:.08em;text-transform:uppercase;color:var(--muted); display:flex;align-items:center;gap:7px; } - .agenda>summary::-webkit-details-marker{display:none} - .agenda>summary::before{content:'▸';display:inline-block;transition:transform .15s} - .agenda[open]>summary::before{transform:rotate(90deg)} - .agenda{min-height:0} - .agenda[open]{overflow:hidden} + .agenda-summary::before{content:'▸';display:inline-block;transition:transform .15s} + .agenda.open>.agenda-summary::before{transform:rotate(90deg)} + .agenda-summary:focus-visible{outline:2px solid var(--or);outline-offset:-2px} + .agenda:not(.open) .hint{display:none} .agenda .hint{border-top:0;flex:1 1 auto;min-height:0;overflow:auto} + /* Splitter zwischen Editor und Legende — dieselbe Sprache wie der große + Splitter, nur schmaler. Ohne offene Legende gibt es nichts zu teilen. */ + .hint-gutter{ + flex:0 0 10px;align-self:stretch; + cursor:col-resize;touch-action:none; + display:flex;align-items:center;justify-content:center; + } + .hint-gutter[hidden]{display:none} + .hint-gutter::before{content:'';width:3px;height:38px;border-radius:2px;background:rgba(36,52,71,.18)} + .hint-gutter:hover::before,.hint-gutter.dragging::before{background:var(--or)} .side .editor-body{flex-direction:column;align-items:stretch} .side .agenda{border-left:0;border-top:1px dashed rgba(36,52,71,.18)} - .side .agenda[open]{width:auto} + .side .agenda.open{width:auto;max-width:none;height:var(--hrow,220px);max-height:85%} + .side .hint-gutter{cursor:row-resize} + .side .hint-gutter::before{width:38px;height:3px} .panel{ background:var(--card); @@ -713,9 +733,11 @@ /* Editor immer gestapelt (Textarea oben, Legende darunter) */ body.mobile .editor-body{flex-direction:column;align-items:stretch} body.mobile .agenda{border-left:0;border-top:1px dashed rgba(36,52,71,.18)} - body.mobile .agenda[open]{width:auto} + body.mobile .agenda.open{width:auto;max-width:none;height:var(--hrow,220px);max-height:85%} + body.mobile .hint-gutter{cursor:row-resize} + body.mobile .hint-gutter::before{width:38px;height:3px} /* Legende: keine dauerhafte Summary-Zeile, dafür der #legendBtn im Kopf */ - body.mobile .agenda>summary{display:none} + body.mobile .agenda-summary{display:none} body.mobile .legendbtn{display:inline-flex} /* Sprachwahl schlank: eingeklappt nur die aktive Sprache. Ein Tipp klappt die volle Leiste als Overlay über die Kopfzeile auf (verdeckt fsToggle usw.,