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:"क्षैतिज – संगठन-चार्ट, संपादक के ऊपर आरेख",