import './style.css'; import { parse } from './parser.js'; import { computeCheapSet } from './model.js'; import { esc, renderTreeHtml } from './render.js'; import { formatWarning } from './warnings.js'; const INITIAL = `%% Project structure – Sprint 14 [~] Website relaunch (XL) https://wiki.example.com/relaunch - [x] Concept (M) - [x] Audience analysis (S) - [x] Sitemap (XS) - [~] Implementation (XL) - [~] Frontend (M) https://git.example.com/frontend @anna | [ ] PWA (S) | [ ] Web+Native - [/] Web (S) - [ ] Android (M) - [ ] iOS (M) - [!] Backend (L) @ben @carla - [ ] CMS integration (M) | [ ] WordPress | [?] Headless CMS | [-] Custom build %% too much effort - [?] Hosting (M) | Cooperative Community Cloud https://hostsharing.net | On-premise`; const src = document.getElementById('src'); const out = document.getElementById('out'); const warnBox = document.getElementById('warn'); /* Baum-/Kostenlogik (gateOf, needsBreakdown, visibleChildren, günstigster Pfad) lebt headless in model.js, das HTML-Erzeugen in render.js. Hier bleibt nur der UI-State des Günstigster-Pfad-Toggles (persistiert). */ let cheapPathOn = true; /* Warnung des ?sourceUrl-Ladens (D23) — zeilenlos und persistent, siehe render(). */ let sourceWarning = null; /* ---------- Renderer (Anbindung an den DOM) ---------- parse -> Wurzeln filtern (verworfene) -> günstigen Pfad markieren -> render.js baut den HTML-String -> in #out schreiben -> Pfadlinie zeichnen. */ function render(){ const parsed = parse(src.value); let roots = parsed.roots; const showDiscarded = discardedShown(); if(!showDiscarded){ roots = roots.filter(r => !r.status || r.status.key !== 'verworfen'); } /* Warnungen aus Parser (unbekannte Statuszeichen) + Renderer (gemischte Gates) zusammenführen, nach Zeile sortiert anzeigen. `sourceWarning` (?sourceUrl nicht ladbar, D23) gehört keiner Zeile und bleibt über Neu-Renderings bestehen, bis das Laden gelingt. */ let warnings = sourceWarning ? [sourceWarning].concat(parsed.warnings) : parsed.warnings; if(!roots.length){ out.innerHTML = `
${esc(t('empty'))}
`; } else { const cheapSet = cheapPathOn ? computeCheapSet(roots) : new Set(); out.classList.toggle('cheap-on', cheapPathOn); const r = renderTreeHtml(roots, {t, showDiscarded, cheapPath: cheapPathOn, cheapSet}); out.innerHTML = r.html; warnings = warnings.concat(r.warnings); } warnings = warnings.slice().sort((a, b) => (a.line || 0) - (b.line || 0)); warnBox.innerHTML = warnings.map(w => `
⚠ ${formatWarning(w, t)}
`).join(''); drawCheapPath(); /* Der Baum ist neu gebaut — die Markierung der Cursor-Zeile neu setzen (D25). Ohne Scrollen: beim Tippen soll das Diagramm stehen bleiben. */ highlightCurrentNode(false); } /* ---------- Günstigster-Pfad-Linie ---------- Eine gestrichelte, geschwungene Petrol-Linie fädelt durch die Endknoten (Blätter) des günstigen Pfads. Das Overlay-SVG liegt in #out und erbt damit dessen CSS-`zoom`; die Punkte werden in unskalierte #out-Koordinaten umgerechnet (getBoundingClientRect / zoom). Neu gezeichnet nach jedem render() und nach Moduswechsel (applyLayout ruft nicht render). */ const SVGNS = 'http://www.w3.org/2000/svg'; function catmullRom(p){ if(p.length < 2) return ''; if(p.length === 2) return `M${p[0].x.toFixed(1)},${p[0].y.toFixed(1)} L${p[1].x.toFixed(1)},${p[1].y.toFixed(1)}`; let d = `M${p[0].x.toFixed(1)},${p[0].y.toFixed(1)}`; for(let i = 0; i < p.length - 1; i++){ const p0 = p[i-1] || p[i], p1 = p[i], p2 = p[i+1], p3 = p[i+2] || p2; const c1x = p1.x + (p2.x - p0.x)/6, c1y = p1.y + (p2.y - p0.y)/6; const c2x = p2.x - (p3.x - p1.x)/6, c2y = p2.y - (p3.y - p1.y)/6; d += ` C${c1x.toFixed(1)},${c1y.toFixed(1)} ${c2x.toFixed(1)},${c2y.toFixed(1)} ${p2.x.toFixed(1)},${p2.y.toFixed(1)}`; } return d; } function svgEl(name, attrs){ const e = document.createElementNS(SVGNS, name); for(const k in attrs) e.setAttribute(k, attrs[k]); return e; } function overlaySvg(cls, w, h){ return svgEl('svg', {class:'cheap-overlay ' + cls, width:w, height:h, viewBox:`0 0 ${w.toFixed(1)} ${h.toFixed(1)}`}); } function drawCheapPath(){ out.querySelectorAll('svg.cheap-overlay').forEach(e => e.remove()); if(!cheapPathOn) return; const leaves = [...out.querySelectorAll('.node.cheap-leaf')]; /* Dokument-Reihenfolge = Lese-Reihenfolge */ if(leaves.length < 2) return; const outRect = out.getBoundingClientRect(); const z = zoom || 1; if(!outRect.width || !outRect.height) return; /* Panel eingeklappt */ const pts = leaves.map(el => { const r = el.getBoundingClientRect(); return {x:(r.left + r.width/2 - outRect.left)/z, y:(r.top + r.height/2 - outRect.top)/z}; }); const w = outRect.width/z, h = outRect.height/z; const d = catmullRom(pts); /* kräftige Linie HINTER die Knoten (als erstes Kind → hinterste Paint-Ebene) */ const back = overlaySvg('cheap-back', w, h); back.appendChild(svgEl('path', {class:'cheap-path', d})); out.insertBefore(back, out.firstChild); /* davor: abgetönte Kopie (deutet den Verlauf über Knoten an) + Stationspunkte */ const front = overlaySvg('cheap-front', w, h); front.appendChild(svgEl('path', {class:'cheap-path faint', d})); pts.forEach(p => front.appendChild( svgEl('circle', {class:'cheap-dot', cx:p.x.toFixed(1), cy:p.y.toFixed(1), r:10}))); out.appendChild(front); } /* ---------- 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')]; const cheapPts = cheapPathOn ? [...out.querySelectorAll('.node.cheap-leaf')].map(el => { const b = R(el); return {x:b.cx, y:b.cy}; }) : []; const cheapLine = op => ``; /* 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))); } }); /* 1b) Günstigster-Pfad: kräftige Linie hinter den Knoten */ if(cheapPts.length >= 2) parts.push(cheapLine('0.9')); /* 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,.risk').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 riskEl = node.querySelector('.risk'); if(riskEl){ const rb = R(riskEl); parts.push(``); parts.push(`⚠︎`); } 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); }); }); /* 3b) Günstigster-Pfad: abgetönte Kopie über den Knoten + Stationspunkte */ if(cheapPts.length >= 2){ parts.push(cheapLine('0.2')); cheapPts.forEach(p => parts.push( ``)); } /* 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 svgToPng(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({blob:b, dataUrl:c.toDataURL('image/png')}), 'image/png'); }; img.onerror = () => resolve(null); img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg); }); } /* Kopiert das Diagramm als PNG-Bild. Zusätzlich wird eine text/html-Variante mit eingebettetem PNG mitgegeben — Office-Programme wie LibreOffice Writer bevorzugen den HTML-Flavor und betten das Bild dann korrekt ein. */ async function copyDiagramImage(){ const {svg, W, H} = diagramToSvg(); const png = await svgToPng(svg, W, H, 2); if(png && png.blob && navigator.clipboard && window.ClipboardItem){ const html = new Blob( [`Werkbaum-Diagramm`], {type:'text/html'}); try{ await navigator.clipboard.write([new ClipboardItem({'image/png':png.blob, 'text/html':html})]); return; }catch(_){ try{ await navigator.clipboard.write([new ClipboardItem({'image/png':png.blob})]); return; }catch(_){} } } await writeClipboard(svg); /* Fallback: SVG-Quelltext (ebenfalls Grafik) */ } /* Als Datei speichern (SVG, Vektor). Verlässlicher Weg z. B. für LibreOffice Writer: Einfügen → Bild → die Datei; das Bild-Clipboard aus dem Browser erkennt LibreOffice nicht zuverlässig. */ function saveBlob(blob, filename){ const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); } function downloadDiagramSvg(){ const {svg} = diagramToSvg(); saveBlob(new Blob([`\n` + svg], {type:'image/svg+xml'}), 'werkbaum-diagramm.svg'); } async function downloadDiagramPng(){ const {svg, W, H} = diagramToSvg(); const png = await svgToPng(svg, W, H, 2); if(png && png.blob) saveBlob(png.blob, 'werkbaum-diagramm.png'); } /* Tab-Taste rückt ein statt den Fokus zu wechseln */ src.addEventListener('keydown', e => { if(e.key === 'Tab'){ e.preventDefault(); const {selectionStart:s, selectionEnd:eEnd, value} = src; src.value = value.slice(0, s) + ' ' + value.slice(eEnd); src.selectionStart = src.selectionEnd = s + 2; render(); saveSrc(); } }); src.addEventListener('input', render); src.addEventListener('input', saveSrc); /* ---------- Sprung zwischen Diagramm und Text (D25) ---------- Jeder Knoten trägt seine Zeilennummer als `data-line` (render.js). Diagramm -> Text: Alt+Klick (bzw. Alt+Enter am fokussierten Knoten, mobil langer Druck) markiert die Zeile im Textfeld. Text -> Diagramm: die Zeile des Cursors hebt den zugehörigen Knoten hervor. Alt statt einfachem Klick, weil ein Knoten mit URL als den ganzen Kasten belegt (SPEC §6). */ /* Zeichenbereich einer 1-basierten Zeile; null, wenn es sie nicht (mehr) gibt. */ function lineRange(line){ const lines = src.value.split('\n'); if(!(line >= 1 && line <= lines.length)) return null; let start = 0; for(let i = 0; i < line - 1; i++) start += lines[i].length + 1; return {start, end: start + lines[line - 1].length}; } /* Vertikale Position eines Zeichenoffsets im Textfeld. Zeilenhöhe × n scheitert an weichen Umbrüchen (lange Zeilen belegen mehrere Bildzeilen), deshalb ein unsichtbarer Spiegel mit gleicher Typografie und Breite plus Marker-Span. */ let mirrorEl = null; function offsetTopInEditor(offset){ if(!mirrorEl){ mirrorEl = document.createElement('div'); mirrorEl.setAttribute('aria-hidden', 'true'); mirrorEl.style.cssText = 'position:absolute;visibility:hidden;top:0;left:-9999px;' + 'white-space:pre-wrap;overflow-wrap:break-word;'; document.body.appendChild(mirrorEl); } const cs = getComputedStyle(src); for(const p of ['fontFamily','fontSize','fontWeight','lineHeight','letterSpacing', 'paddingTop','paddingLeft','paddingRight','borderTopWidth','tabSize']){ mirrorEl.style[p] = cs[p]; } mirrorEl.style.width = src.clientWidth + 'px'; mirrorEl.textContent = src.value.slice(0, offset); const marker = document.createElement('span'); marker.textContent = '​'; mirrorEl.appendChild(marker); const top = marker.offsetTop; mirrorEl.textContent = ''; return top; } /* Nur scrollen, wenn die Zeile nicht ohnehin bequem sichtbar ist. */ function scrollEditorToOffset(offset){ const top = offsetTopInEditor(offset), h = src.clientHeight; if(top < src.scrollTop + 8 || top > src.scrollTop + h - 28){ src.scrollTop = Math.max(0, top - h / 2); } } /* Ist das Editor-Panel zugeklappt, muss der Sprung es erst öffnen. */ function revealEditor(){ if(isMobile()){ const raw = app.style.getPropertyValue('--drow'); const collapsed = raw ? parseFloat(raw) > mobileMaxDrow() - 40 : splitState === 'b'; if(collapsed) setMobileDrow(app.getBoundingClientRect().height * 0.45, true); } else if(splitState === 'b'){ splitState = 'normal'; applySplit(); } } /* Der Sprung ist „hinschauen", nicht „bearbeiten": `inputmode="none"` hält die **virtuelle** Tastatur unten, die sonst beim Fokussieren den halben Bildschirm nimmt. Hardware-Tastaturen (BT) tippen unverändert weiter. Sobald der Nutzer das Textfeld selbst antippt, ist Bearbeiten gemeint — `pointerdown` läuft vor dem Fokus, die Sperre fällt also rechtzeitig. */ function keyboardOnJump(off){ if(off) src.setAttribute('inputmode', 'none'); else src.removeAttribute('inputmode'); } src.addEventListener('pointerdown', () => keyboardOnJump(false)); /* Diagramm -> Text: ganze Zeile markieren (die native Auswahl ist die einzige Hervorhebung, die ein