From 36d79b82c4a6896daeaaed8c38f7522c2ce97a5c Mon Sep 17 00:00:00 2001 From: mhoennig Date: Thu, 23 Jul 2026 11:48:31 +0200 Subject: [PATCH] =?UTF-8?q?frontend:=20Mehrere=20umschaltbare=20Dokumente?= =?UTF-8?q?=20(W=C3=A4hler=20in=20der=20Editor-Titelzeile)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Editor kann jetzt mehrere Notationstexte halten und zwischen ihnen umschalten. Der Name des aktiven Dokuments ersetzt die feste Beschriftung „Struktur (Text)" und öffnet als Dropdown den Wähler mit Wechseln, Neu, Umbenennen und Löschen. - Datenmodell (noch kein Backend): Dokumente als [{id,name,text}] in localStorage (werkbaum-docs), aktives per id in werkbaum-active; der aktive Text bleibt zusätzlich in werkbaum-src gespiegelt. - Migration verlustfrei: fehlt werkbaum-docs, wird der bestehende Einzeltext (oder INITIAL) in ein Dokument gepackt. Reset und Löschen des letzten Dokuments säen das Beispiel neu. - Jedes Dokument ist nur Text + Name (kein Strukturformat, D14) — vorwärtskompatibel zum geplanten Backend (D13). Ansichts-State (Modus, Zoom, Aufteilung) bleibt global. - i18n in allen 9 Sprachen; Doc-Namen bleiben Nutzerdaten (nicht übersetzt). - Dokumentiert als ADR D22 (DECISIONS.md) + frontend/CLAUDE.md. Co-Authored-By: Claude Opus 4.8 --- docs/DECISIONS.md | 34 ++++++++ frontend/CLAUDE.md | 21 +++-- frontend/index.html | 13 ++- frontend/src/app.js | 191 ++++++++++++++++++++++++++++++++++++++++- frontend/src/style.css | 45 +++++++++- 5 files changed, 293 insertions(+), 11 deletions(-) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 2cf7dad..4954fdc 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -329,3 +329,37 @@ Not eine deutsche Oberfläche. Die Erkennung greift auch **nach dem Reset** (CLAUDE: neue UI-Texte zuerst auf Deutsch) — das betrifft die Autoren-/Pflege­ seite und ist unabhängig vom Anzeige-Default für Besucher. Eine bewusste Sprachwahl überschreibt die Erkennung dauerhaft (Persistenz in `werkbaum-lang`). + +## D22 — Mehrere Dokumente client-seitig, Wähler in der Editor-Titelzeile +Der Editor kann mehrere Notationstexte halten, zwischen denen umgeschaltet wird +(z. B. verschiedene Projekte/Bäume). Umgesetzt **ohne Backend** (noch keins, +D13): die Dokumente liegen als `[{id, name, text}]` im localStorage +(`werkbaum-docs`), das aktive per `id` in `werkbaum-active`. Der aktive Text +wird zusätzlich in `werkbaum-src` gespiegelt (Abwärtskompatibilität + Migration). + +**Platzierung: Dropdown in der Editor-Titelzeile.** Der Name des aktiven +Dokuments **ersetzt** die statische Beschriftung „Struktur (Text)" und ist +zugleich der Auslöser eines Dropdowns zum Wechseln, Anlegen (`+ Neu`), +Umbenennen und Löschen. Begründung: Der Wähler bestimmt, *welchen Text* man +bearbeitet — er gehört auf das Textpanel, nicht ins Diagramm. Verworfen: +**Kopfzeile oben** (schon eng, besonders mobil) und **Tab-Leiste** (kostet eine +ganze Zeile Höhe, D17, und skaliert nicht über ~5 Dokumente). Ein Dropdown +skaliert und passt zum bereits etablierten Overlay-Idiom (Sprache/Download, D17). +Auf kleinem Bildschirm öffnet dasselbe Menü als absolut positioniertes Overlay +unter der Titelzeile. + +**Vereinbarkeit mit D14 (Text ist das führende Format):** Jedes Dokument ist nur +ein Notationstext plus `name` (Metadatum) — **kein** erfundenes Strukturformat. +Damit ist das Modell vorwärtskompatibel zum geplanten Persistenz-/Taiga-Backend +(D13/D14: „Text als Ganzes + explizite Metadaten"): „mehrere Dokumente" bildet +1:1 auf „mehrere gespeicherte Notationstexte mit Name/id" ab; der localStorage- +Array ist der client-seitige Platzhalter, bis das Backend existiert. + +**Migration & Reset:** Fehlt `werkbaum-docs`, wird der bestehende Einzeltext +(oder `INITIAL`) verlustfrei in **ein** Dokument gepackt (Name in der erkannten +UI-Sprache, `docDefaultName`). Der Reset löscht wie bisher alle `werkbaum-*`- +Schlüssel → beim nächsten Laden greift dieselbe Migration. Wird das letzte +Dokument gelöscht, entsteht wieder das Beispiel-Dokument (INITIAL). Die +GUI-Ansichts-Einstellungen (Modus, Zoom, Aufteilung; `werkbaum-ui`) bleiben +bewusst **global** über alle Dokumente — pro-Dokument-Ansichtszustand wäre eine +spätere Erweiterung. diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index a8d023d..8a84c28 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -81,13 +81,24 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der (`a11y*`) in **allen 9 Sprachen** anlegen. Knoten sind `tabindex="0"` (Fokus = Lesereihenfolge), `#warn` ist eine Live-Region. - Zustand wird im `localStorage` gehalten (noch kein Backend): `werkbaum-lang` - (Sprache), `werkbaum-src` (Editortext), `werkbaum-ui` (JSON: Modus, - verworfene, günstigster Pfad, Split-Zustand inkl. `--col`/`--drow`, Zoom, - Vollbild). Neue + (Sprache), `werkbaum-docs` (JSON-Array der Dokumente `[{id,name,text}]`), + `werkbaum-active` (id des aktiven Dokuments), `werkbaum-src` (Spiegel des + aktiven Texts, Abwärtskompatibilität), `werkbaum-ui` (JSON: Modus, verworfene, + günstigster Pfad, Split-Zustand inkl. `--col`/`--drow`, Zoom, Vollbild). Neue GUI-Einstellungen in `saveUI()`/`restoreState()` mitführen; `saveUI` liefert während `restoring===true` nichts, damit das Wiederherstellen nicht sofort - zurückschreibt. Fehlender `werkbaum-src` fällt auf `INITIAL` zurück, ein - leerer String bleibt jedoch leer. + zurückschreibt. +- Dokumente (D22): mehrere umschaltbare Notationstexte. `loadDocs()` migriert bei + fehlendem `werkbaum-docs` den bestehenden `werkbaum-src` (oder `INITIAL`) in + **ein** Dokument; `initDocs()` (Aufruf **nach** `applyLang`, damit der + Standardname in der erkannten UI-Sprache steht) holt den aktiven Text in den + Editor. `saveSrc()` schreibt den Editortext ins aktive Dokument. Der Wähler ist + ein Dropdown in der Editor-Titelzeile (`#docTrigger`/`#docMenu`, ersetzt die + frühere feste „Struktur (Text)"-Beschriftung); Wechseln/Neu/Umbenennen/Löschen + in `switchDoc/newDoc/renameDoc/deleteDoc`. Jedes Dokument ist nur Text + Name + (kein Strukturformat, D14) — vorwärtskompatibel zum Backend (D13). Ansichts- + State (`werkbaum-ui`) bleibt global über alle Dokumente. Ein leerer Editortext + bleibt leer; das letzte gelöschte Dokument wird als `INITIAL` neu gesät. - 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 diff --git a/frontend/index.html b/frontend/index.html index a1b71bf..69e36e6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -46,7 +46,18 @@
- Struktur (Text) + + ` + ).join(''); +} +function openDocMenu(){ renderDocMenu(); docMenu.hidden = false; docTrigger.setAttribute('aria-expanded', 'true'); } +function closeDocMenu(){ docMenu.hidden = true; docTrigger.setAttribute('aria-expanded', 'false'); } +function toggleDocMenu(){ docMenu.hidden ? openDocMenu() : closeDocMenu(); } +/* Beim Wechseln/Anlegen/Löschen zuerst den aktuellen Editortext ins aktive + Dokument sichern, dann das Ziel laden und neu rendern. */ +function flushActive(){ const d = activeDoc(); if(d) d.text = src.value; } +function loadActiveIntoEditor(){ const d = activeDoc(); src.value = d ? d.text : ''; render(); updateDocName(); } +function switchDoc(id){ + if(id === activeId) return; + flushActive(); + activeId = id; + loadActiveIntoEditor(); + persistDocs(); +} +function newDoc(){ + flushActive(); + const d = { id: uid(), name: uniqueName(t('docNewName')), text: '' }; + docs.push(d); + activeId = d.id; + loadActiveIntoEditor(); + persistDocs(); + src.focus(); +} +function renameDoc(){ + const d = activeDoc(); + if(!d) return; + const name = window.prompt(t('docRenamePrompt'), d.name); + if(name === null) return; + const trimmed = name.trim(); + if(!trimmed) return; + d.name = trimmed; + persistDocs(); + updateDocName(); + renderDocMenu(); +} +function deleteDoc(){ + const d = activeDoc(); + if(!d) return; + if(!window.confirm(t('docDeleteConfirm', {name: d.name}))) return; + docs = docs.filter(x => x.id !== d.id); + if(!docs.length) docs = [{ id: uid(), name: t('docDefaultName'), text: INITIAL }]; + activeId = docs[0].id; + loadActiveIntoEditor(); + persistDocs(); + closeDocMenu(); +} +/* Dokumente laden + aktiven Text in den Editor holen. Nach applyLang aufgerufen, + damit der migrierte Standardname in der erkannten UI-Sprache steht. */ +function initDocs(){ + restoring = true; + loadDocs(); + const d = activeDoc(); + src.value = d ? d.text : ''; + restoring = false; + persistDocs(); /* migrierte/geladene Liste festschreiben (stabil über Reload) */ + updateDocName(); + render(); +} +docTrigger.addEventListener('click', e => { + /* Desktop: ist der Editor minimiert, stellt ein Klick ihn wieder her + (Bubbling zur Titelzeile) statt das Menü zu öffnen. */ + if(!isMobile() && editorPanel.classList.contains('collapsed')) return; + e.stopPropagation(); + toggleDocMenu(); +}); +docTrigger.addEventListener('keydown', e => { + if(e.key === 'Enter' || e.key === ' '){ e.preventDefault(); toggleDocMenu(); } + else if(e.key === 'Escape') closeDocMenu(); +}); +docList.addEventListener('click', e => { + const btn = e.target.closest('.docitem'); + if(!btn) return; + e.stopPropagation(); + switchDoc(btn.dataset.id); + closeDocMenu(); +}); +document.getElementById('docNew').addEventListener('click', e => { e.stopPropagation(); newDoc(); renderDocMenu(); }); +document.getElementById('docRename').addEventListener('click', e => { e.stopPropagation(); renameDoc(); }); +document.getElementById('docDelete').addEventListener('click', e => { e.stopPropagation(); deleteDoc(); }); +/* Klick außerhalb schließt das Menü. */ +document.addEventListener('click', e => { + if(!docMenu.hidden && !docMenu.contains(e.target) && !docTrigger.contains(e.target)) closeDocMenu(); +}); + /* ---------- Kleiner Bildschirm: nur ein Panel, schlanke Sprachwahl, Vollbild ---------- */ const mqMobile = window.matchMedia('(max-width:640px)'); function isMobile(){ return mqMobile.matches; } @@ -1162,6 +1344,7 @@ function detectLang(){ let startLang = 'de'; try{ startLang = localStorage.getItem('werkbaum-lang') || detectLang(); }catch(_){ startLang = detectLang(); } applyLang(I18N[startLang] ? startLang : 'de'); /* setzt Texte + rendert */ +initDocs(); /* Dokumente laden + aktiven Text in den Editor (nach Sprache) */ applyMobile(); /* Mobil-Verhalten (nach Sprache/Restore) anwenden */ /* ---------- Build-Hinweis (Vorschau/Dev + „latest build") ---------- diff --git a/frontend/src/style.css b/frontend/src/style.css index 5868b10..0af724d 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -239,7 +239,7 @@ display:flex;flex-direction:column;min-height:0;min-width:0; } .panel-head{ - flex:0 0 auto; + flex:0 0 auto;position:relative; padding:10px 16px; display:flex;justify-content:space-between;align-items:center;gap:14px; font-family:'IBM Plex Mono',monospace; @@ -308,6 +308,49 @@ .dlmenu{display:inline-flex;gap:3px} .dltrigger{display:none} .legendbtn{display:none} /* nur auf kleinem Bildschirm sichtbar */ + /* Dokument-Wähler: der Name des aktiven Dokuments in der Editor-Titelzeile ist + zugleich der Auslöser eines Dropdowns zum Wechseln/Anlegen/Umbenennen/Löschen + (localStorage-Dokumente, noch kein Backend). */ + .doctrigger{ + display:inline-flex;align-items:center;gap:5px;cursor:pointer; + margin-right:auto;max-width:60%;color:var(--muted); + padding:2px 6px;margin-left:-6px;border-radius:6px; + text-transform:none;letter-spacing:0;font-family:'IBM Plex Mono',monospace; + } + .doctrigger:hover,.doctrigger[aria-expanded="true"]{color:var(--or)} + .doctrigger:focus-visible{outline:2px solid var(--or);outline-offset:2px} + .docname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600} + .doc-caret{width:13px;height:13px;flex:0 0 auto;opacity:.7} + .panel.collapsed .doc-caret{display:none} + .docmenu{ + position:absolute;top:calc(100% + 4px);left:12px;z-index:50;min-width:210px; + background:var(--card);border:1px solid rgba(36,52,71,.15);border-radius:10px; + padding:6px;box-shadow:0 8px 24px rgba(36,52,71,.22); + text-transform:none;letter-spacing:0;font-family:'IBM Plex Sans',sans-serif; + } + .docmenu[hidden]{display:none} + .docmenu-list{display:flex;flex-direction:column;gap:2px;max-height:260px;overflow:auto} + .docitem{ + display:flex;align-items:center;gap:8px;width:100%; + border:0;background:transparent;cursor:pointer;text-align:left; + padding:7px 9px;border-radius:7px;color:var(--ink); + font-family:inherit;font-size:.86rem; + } + .docitem:hover{background:rgba(15,118,110,.08)} + .docitem[aria-checked="true"]{color:var(--or);font-weight:600} + .docitem .doccheck{flex:0 0 auto;width:14px;text-align:center;opacity:0} + .docitem[aria-checked="true"] .doccheck{opacity:1} + .docmenu-actions{ + display:flex;flex-wrap:wrap;gap:4px;margin-top:6px;padding-top:6px; + border-top:1px solid rgba(36,52,71,.12); + } + .docact{ + flex:1 1 auto;border:1px solid rgba(36,52,71,.2);border-radius:7px; + background:var(--card);color:var(--muted);cursor:pointer; + padding:5px 8px;font-family:inherit;font-size:.78rem;white-space:nowrap; + } + .docact:hover{border-color:var(--or);color:var(--or)} + .docdel:hover{border-color:#D897A8;color:#B4415E} .togglebtn[aria-pressed="true"]{background:var(--or);color:#fff;border-color:var(--or)} .togglebtn[aria-pressed="true"]:hover{color:#fff} textarea{