diff --git a/docs/SPEC.md b/docs/SPEC.md index ff221f1..b7eb9b3 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -134,16 +134,21 @@ ist grau (`#6B7A8C`) — kein Petrol mehr im Diagramm. Der Modus ändert nur die + halbe Knotenhöhe). Abzweige zielen auf den **Knoten**, nie auf die Mitte des Teilbaums. -### Textexport des Diagramms -Das Diagramm ist per Icon-Schaltfläche als einfacher, überall einsetzbarer -ASCII-Baum in die Zwischenablage kopierbar. Format: +### Grafikexport des Diagramms +Das Diagramm ist per Icon-Schaltfläche als **Bild** in die Zwischenablage +kopierbar (grafisches Format, in Dokumente/Chats einfügbar): -- Eingerückter Baum mit `├─`/`└─` (all-of-Abzweige) bzw. `├◇`/`└◇` - (any-of-Alternativen); Fortsetzungsspalten mit `│`. -- Je Knoten: `[Status] Label (Größe) @tags` — jeder Teil optional, Reihenfolge - wie hier. Der Status-Code entspricht §4 (`[ ]`, `[x]`, `[~]`, …). +- Das gerenderte Diagramm wird aus der Live-Geometrie in ein eigenständiges + SVG (nur Formen + Text, keine externen Ressourcen) nachgezeichnet und als + **PNG** (`image/png`) in die Zwischenablage gelegt. Fällt der Bild-Clipboard + aus (fehlende `ClipboardItem`-Unterstützung), wird der **SVG-Quelltext** + kopiert (ebenfalls ein Grafikformat). +- Übernommen werden Knotenfarben (Status §4), Größen-Badge, Tags und der + Geister-Knoten; die Verbindungslinien werden je Gate neu gezogen + (und = durchgezogen Tinte, oder = gestrichelt Grau) und treffen die Knoten + unabhängig vom Darstellungsmodus. - Es wird genau die **sichtbare** Struktur exportiert (der „verworfene - einblenden“-Filter wirkt auch hier). Mehrere Wurzeln durch Leerzeile getrennt. + einblenden“-Filter wirkt auch hier). ## 10. Beispiel (kanonisch) diff --git a/frontend/index.html b/frontend/index.html index d7c2a39..de06453 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -564,7 +564,7 @@ - @@ -615,9 +615,6 @@ const STATUS_BY_CODE = { '-': {key:'verworfen', name:'verworfen'} }; -/* Rück-Abbildung Status-Key → Code (für den Text-Export des Diagramms) */ -const CODE_BY_KEY = {idee:'?', geplant:' ', arbeit:'~', durchstich:'/', fertig:'x', prod:'^', verworfen:'-'}; - const src = document.getElementById('src'); const out = document.getElementById('out'); const warnBox = document.getElementById('warn'); @@ -738,41 +735,121 @@ function render(){ warnBox.innerHTML = warnings.map(w => `
⚠ ${w}
`).join(''); } -/* ---------- Diagramm als einfacher Text-Baum ---------- */ -/* Einfachstes, aber überall einsetzbares Format: eingerückter ASCII-Baum. - all-of-Abzweige mit ├─/└─, any-of-Alternativen mit ├◇/└◇. Je Knoten: - [Status] Label (Größe) @tags. Respektiert den „verworfene“-Filter, gibt - also genau die sichtbare Struktur wieder. */ -function nodeLabelText(n){ - let s = n.status ? '[' + (CODE_BY_KEY[n.status.key] ?? ' ') + '] ' : ''; - s += n.label; - if(n.size) s += ' (' + n.size + ')'; - if(n.tags && n.tags.length) s += ' ' + n.tags.map(x => '@' + x).join(' '); - return s; +/* ---------- Diagramm als Grafik (SVG → PNG) ---------- */ +/* Das gerenderte Diagramm wird aus der Live-Geometrie in ein eigenständiges + SVG (nur Formen + Text, keine externen Ressourcen) nachgezeichnet und als + PNG in die Zwischenablage gelegt. Knotenfarben, Größen-Badges, Tags und + der Geister-Knoten werden übernommen; Verbindungslinien werden je Gate + (und = durchgezogen Tinte, oder = gestrichelt Grau) neu gezogen und treffen + so garantiert die Knoten — unabhängig vom Darstellungsmodus. */ +function diagramToSvg(){ + const treeRect = out.getBoundingClientRect(); + const PAD = 24; + const W = Math.ceil(treeRect.width) + PAD*2; + const H = Math.ceil(treeRect.height) + PAD*2; + const ox = -treeRect.left + PAD, oy = -treeRect.top + PAD; + const R = el => { const r = el.getBoundingClientRect(); + return {x:r.left+ox, y:r.top+oy, w:r.width, h:r.height, + cx:r.left+ox+r.width/2, cy:r.top+oy+r.height/2, r:r.right+ox, b:r.bottom+oy}; }; + const parts = [``]; + const nodes = [...out.querySelectorAll('.node')]; + + /* 1) Verbindungslinien (hinter den Knoten) */ + const seg = (x1,y1,x2,y2,stroke,dash) => + ``; + nodes.forEach(parentEl => { + const li = parentEl.closest('li'); + const childUl = li && [...li.children].find(c => c.tagName === 'UL'); + if(!childUl) return; + const gate = childUl.classList.contains('or') ? 'or' : 'and'; + const stroke = gate === 'or' ? '#6B7A8C' : '#41556E'; + const dash = gate === 'or'; + const kids = [...childUl.children] + .map(cli => cli.querySelector(':scope > .node, :scope > a.node')).filter(Boolean).map(R); + if(!kids.length) return; + const p = R(parentEl); + const avgdx = kids.reduce((s,k)=>s+(k.cx-p.cx),0)/kids.length; + const avgdy = kids.reduce((s,k)=>s+(k.cy-p.cy),0)/kids.length; + if(Math.abs(avgdx) >= Math.abs(avgdy)){ /* links→rechts */ + const toRight = avgdx >= 0; + const px = toRight ? p.r : p.x; + const busX = toRight ? Math.min(...kids.map(k=>k.x))-14 : Math.max(...kids.map(k=>k.r))+14; + const ys = kids.map(k=>k.cy).concat(p.cy); + parts.push(seg(px, p.cy, busX, p.cy, stroke, dash)); + parts.push(seg(busX, Math.min(...ys), busX, Math.max(...ys), stroke, dash)); + kids.forEach(k => parts.push(seg(busX, k.cy, toRight?k.x:k.r, k.cy, stroke, dash))); + } else { /* oben→unten */ + const toDown = avgdy >= 0; + const py = toDown ? p.b : p.y; + const busY = toDown ? Math.min(...kids.map(k=>k.y))-14 : Math.max(...kids.map(k=>k.b))+14; + const xs = kids.map(k=>k.cx).concat(p.cx); + parts.push(seg(p.cx, py, p.cx, busY, stroke, dash)); + parts.push(seg(Math.min(...xs), busY, Math.max(...xs), busY, stroke, dash)); + kids.forEach(k => parts.push(seg(k.cx, busY, k.cx, toDown?k.y:k.b, stroke, dash))); + } + }); + + /* 2) Badge/Pille (Größe, Tags) */ + const drawBadge = (el, fill, textColor, strokeColor) => { + const b = R(el); + parts.push(``); + parts.push(`${esc(el.textContent.trim())}`); + }; + + /* 3) Knoten */ + nodes.forEach(node => { + const b = R(node), cs = getComputedStyle(node); + const dashed = cs.borderTopStyle === 'dashed'; + parts.push(``); + const clone = node.cloneNode(true); + clone.querySelectorAll('.size,.tags,.ext').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)}`); + const sizeEl = node.querySelector('.size'); + if(sizeEl) drawBadge(sizeEl, '#0F766E', '#ffffff'); + node.querySelectorAll('.tag').forEach(tg => { + const t = getComputedStyle(tg); + drawBadge(tg, t.backgroundColor, t.color, t.borderTopColor); + }); + }); + + /* 4) Geister-Knoten „Untergliederung fehlt“ */ + out.querySelectorAll('.ghost-node').forEach(g => { + const b = R(g); + parts.push(seg(b.cx, b.y-14, b.cx, b.y, '#B45309', true)); + parts.push(``); + parts.push(`${esc(g.textContent.trim())}`); + }); + + const svg = `${parts.join('')}`; + return {svg, W, H}; } -function walkText(node, prefix, lines){ - const kids = visibleChildren(node); - const gate = gateOf(kids); - kids.forEach((k, i) => { - const last = i === kids.length - 1; - const conn = gate === 'or' - ? (last ? '└◇ ' : '├◇ ') - : (last ? '└─ ' : '├─ '); - lines.push(prefix + conn + nodeLabelText(k)); - walkText(k, prefix + (last ? ' ' : '│ '), lines); +function svgToPngBlob(svg, W, H, scale){ + return new Promise(resolve => { + const img = new Image(); + img.onload = () => { + const c = document.createElement('canvas'); + c.width = Math.round(W*scale); c.height = Math.round(H*scale); + const ctx = c.getContext('2d'); + ctx.scale(scale, scale); + ctx.drawImage(img, 0, 0); + c.toBlob(b => resolve(b), 'image/png'); + }; + img.onerror = () => resolve(null); + img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg); }); } -function treeToText(){ - let {roots} = parse(src.value); - if(!showc.checked){ - roots = roots.filter(r => !r.status || r.status.key !== 'verworfen'); +async function copyDiagramImage(){ + const {svg, W, H} = diagramToSvg(); + const blob = await svgToPngBlob(svg, W, H, 2); + if(blob && navigator.clipboard && window.ClipboardItem){ + try{ + await navigator.clipboard.write([new ClipboardItem({'image/png': blob})]); + return; + }catch(_){} } - const blocks = roots.map(root => { - const lines = [nodeLabelText(root)]; - walkText(root, '', lines); - return lines.join('\n'); - }); - return blocks.join('\n\n'); /* mehrere Wurzeln durch Leerzeile trennen */ + await writeClipboard(svg); /* Fallback: SVG-Quelltext (ebenfalls Grafik) */ } /* Tab-Taste rückt ein statt den Fokus zu wechseln */ @@ -937,7 +1014,7 @@ document.getElementById('copy').addEventListener('click', async () => { flashCopied(document.getElementById('copy'), 'copyTooltip'); }); document.getElementById('copyDiagram').addEventListener('click', async () => { - await writeClipboard(treeToText()); + await copyDiagramImage(); flashCopied(document.getElementById('copyDiagram'), 'copyDiagramTooltip'); }); @@ -951,7 +1028,7 @@ const I18N = { brandTooltip:"„Werkbaum“ bedeutet so viel wie ‚Werk-Baum‘ — der Baum des Projektstrukturplans (WBS).", editorTitle:"Struktur (Text)", diagramTitle:"Diagramm", copy:"kopieren", copyDone:"kopiert ✓", copyTooltip:"Text in die Zwischenablage kopieren", - copyDiagramTooltip:"Diagramm als Textbaum kopieren", + copyDiagramTooltip:"Diagramm als Bild kopieren", minimize:"minimieren", normal:"normal", maximize:"maximieren", agenda:"Agenda", discarded:"verworfene", modeHorizontal:"Horizontal – Organigramm, Diagramm über dem Editor", @@ -980,7 +1057,7 @@ const I18N = { brandTooltip:"“Werkbaum” means roughly ‘work tree’ — the tree of the work breakdown structure (WBS).", editorTitle:"Structure (text)", diagramTitle:"Diagram", copy:"copy", copyDone:"copied ✓", copyTooltip:"Copy text to clipboard", - copyDiagramTooltip:"Copy diagram as a text tree", + copyDiagramTooltip:"Copy diagram as an image", minimize:"minimize", normal:"normal", maximize:"maximize", agenda:"Legend", discarded:"discarded", modeHorizontal:"Horizontal – org chart, diagram above the editor", @@ -1009,7 +1086,7 @@ const I18N = { brandTooltip:"«Werkbaum» significa algo así como ‘árbol de trabajo’ — el árbol de la estructura de desglose del trabajo (EDT).", editorTitle:"Estructura (texto)", diagramTitle:"Diagrama", copy:"copiar", copyDone:"copiado ✓", copyTooltip:"Copiar el texto al portapapeles", - copyDiagramTooltip:"Copiar el diagrama como árbol de texto", + copyDiagramTooltip:"Copiar el diagrama como imagen", minimize:"minimizar", normal:"normal", maximize:"maximizar", agenda:"Leyenda", discarded:"descartados", modeHorizontal:"Horizontal – organigrama, diagrama sobre el editor", @@ -1038,7 +1115,7 @@ const I18N = { brandTooltip:"« Werkbaum » signifie à peu près « arbre de travail » — l’arbre de l’organigramme des tâches (WBS).", editorTitle:"Structure (texte)", diagramTitle:"Diagramme", copy:"copier", copyDone:"copié ✓", copyTooltip:"Copier le texte dans le presse-papiers", - copyDiagramTooltip:"Copier le diagramme en arbre de texte", + copyDiagramTooltip:"Copier le diagramme comme image", minimize:"réduire", normal:"normal", maximize:"agrandir", agenda:"Légende", discarded:"abandonnés", modeHorizontal:"Horizontal – organigramme, diagramme au-dessus de l'éditeur", @@ -1067,7 +1144,7 @@ const I18N = { brandTooltip:"„Werkbaum” znaczy mniej więcej ‚drzewo pracy’ — drzewo struktury podziału pracy (WBS).", editorTitle:"Struktura (tekst)", diagramTitle:"Diagram", copy:"kopiuj", copyDone:"skopiowano ✓", copyTooltip:"Kopiuj tekst do schowka", - copyDiagramTooltip:"Kopiuj diagram jako drzewo tekstowe", + copyDiagramTooltip:"Kopiuj diagram jako obraz", minimize:"minimalizuj", normal:"normalny", maximize:"maksymalizuj", agenda:"Legenda", discarded:"odrzucone", modeHorizontal:"Poziomo – schemat organizacyjny, diagram nad edytorem", @@ -1096,7 +1173,7 @@ const I18N = { brandTooltip:"«Werkbaum» примерно означает ‚дерево работ’ — дерево структуры декомпозиции работ (СДР).", editorTitle:"Структура (текст)", diagramTitle:"Диаграмма", copy:"копировать", copyDone:"скопировано ✓", copyTooltip:"Скопировать текст в буфер обмена", - copyDiagramTooltip:"Скопировать диаграмму как текстовое дерево", + copyDiagramTooltip:"Скопировать диаграмму как изображение", minimize:"свернуть", normal:"обычный", maximize:"развернуть", agenda:"Легенда", discarded:"отклонённые", modeHorizontal:"Горизонтально – оргсхема, диаграмма над редактором", @@ -1125,7 +1202,7 @@ const I18N = { brandTooltip:"„Werkbaum“ का अर्थ लगभग ‚कार्य-वृक्ष‘ है — कार्य विभाजन संरचना (WBS) का वृक्ष।", editorTitle:"संरचना (टेक्स्ट)", diagramTitle:"आरेख", copy:"कॉपी करें", copyDone:"कॉपी हो गया ✓", copyTooltip:"टेक्स्ट को क्लिपबोर्ड पर कॉपी करें", - copyDiagramTooltip:"आरेख को टेक्स्ट-ट्री के रूप में कॉपी करें", + copyDiagramTooltip:"आरेख को छवि के रूप में कॉपी करें", minimize:"छोटा करें", normal:"सामान्य", maximize:"बड़ा करें", agenda:"लेजेंड", discarded:"अस्वीकृत", modeHorizontal:"क्षैतिज – संगठन-चार्ट, संपादक के ऊपर आरेख",