import './style.css'; import { parse } from './parser.js'; import { computeCheapSet, freshProdSet } from './model.js'; import { esc, renderTreeHtml } from './render.js'; import { formatWarning } from './warnings.js'; import { padUrls } from './remote.js'; /* Werkbaum, mit Werkbaum geplant — als mitgeliefertes Dokument „Werkbank" (D27). Dieselbe Datei, die auch per ?sourceUrl= geladen werden kann; `?raw` bettet sie beim Build in die eine Ausgabedatei ein (D19), es wird nichts nachgeladen (D20). Quelle bleibt docs/examples/ — keine Kopie, die auseinanderläuft. */ import WERKBAUM_DOC from '../../docs/examples/werkbaum.werkbaum?raw'; 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'); /* Zeilennummern-Streifen (D33) — hier oben geholt, weil render() ihn füllt. */ const srcWrap = document.getElementById('srcWrap'); const lineNoBox = document.getElementById('lineNos'); const lineNoInner = lineNoBox.firstElementChild; /* 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; /* Ansicht bei einem Pad-Dokument (D31): beide | nur Pad | nur Spiegel. Hier oben deklariert, weil saveUI() sie liest und schon aus applySplit() heraus laufen kann — weiter unten stünde sie dann noch in der temporalen Todeszone. */ const PAD_VIEWS = ['both', 'pad', 'text']; let padView = 'both'; /* Cursor-Zeile (D25) — bleibt `null`, bis der Cursor das erste Mal bewegt wurde, sonst wäre nach dem Laden ungefragt die Wurzel markiert. Hier oben deklariert wie `padView`: der Zeilennummern-Streifen (D33) liest sie, und der hängt an render(). */ let caretLine = null, currentNodeEl = null; /* Warnung des ?sourceUrl-Ladens (D23) — zeilenlos und persistent, siehe render(). */ let sourceWarning = null; /* „Was ist neu?" (D28): Knoten, die gegenüber der zuletzt GESEHENEN Fassung neu in Produktion sind. Gilt immer für genau ein Dokument (`freshDocId`) — nur Dokumente von außen (mitgeliefert oder ?sourceUrl=) haben eine Vergleichsfassung. `freshBaseline` ist der Text, gegen den verglichen wurde; er wird erst beim Bestätigen fortgeschrieben. */ let freshSet = new Set(), freshDocId = null, freshBaseline = null, freshPrevRoots = 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'))}
`; freshSet = new Set(); } else { const cheapSet = cheapPathOn ? computeCheapSet(roots) : new Set(); out.classList.toggle('cheap-on', cheapPathOn); /* Die Menge MUSS aus denselben Knotenobjekten gebildet werden, die gerade gerendert werden — `freshProdSet` liefert Knoten aus `roots`. Eine früher berechnete Menge stammte aus einem anderen Parse-Durchlauf und träfe per Objektidentität nie zu (D28). */ freshSet = (freshDocId === activeId && freshPrevRoots) ? freshProdSet(freshPrevRoots, roots) : new Set(); const r = renderTreeHtml(roots, {t, showDiscarded, cheapPath: cheapPathOn, cheapSet, freshSet}); 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(''); /* Der Zeilennummern-Streifen zeigt genau die Zeilen an, die hier genannt werden — deshalb hängt er an derselben Warnungsliste (D33). */ lineNoWarn = new Set(warnings.map(w => w.line).filter(Boolean)); renderLineNos(); applyOptStairs(); /* muss vor dem Messen laufen — es verschiebt Knoten */ alignStems(); 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); revealFocusMark(); /* `!!!` ins Bild holen, wenn die Marke neu ist (SPEC §1) */ updateFreshBtn(); /* Zähler folgt der gerade gerenderten Menge (D28) */ } /* Fokusmarke `!!!` (SPEC §1): Der erste markierte Knoten wird ins Bild geholt — aber nur, wenn sich die Marke **geändert** hat. Sonst zöge jeder Neubau des Baums den Blick zurück und man käme nicht weg. Verglichen wird das Label des Knotens, nicht die Zeilennummer: Umsortieren im Pad soll nicht als neue Marke gelten. Die Hervorhebung selbst macht die Klasse `focusmark` aus dem Renderer, hier geht es nur ums Scrollen. */ let lastFocusMark = null; function revealFocusMark(){ const el = out.querySelector('.node.focusmark'); const key = el ? el.textContent : null; if(key && key !== lastFocusMark){ el.scrollIntoView({block:'center', inline:'center', behavior:'smooth'}); } lastFocusMark = key; } /* ---------- 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)}`}); } /* ---------- Treppe für optionale Endknoten (D29, Nachtrag 3) ---------- Im horizontalen Fächer kostet jedes optionale Geschwister eine eigene Spalte — Breite für gerade das, was am wenigsten wichtig ist. Aufeinanderfolgende optionale Endknoten werden deshalb zu einer Kaskade gestapelt, die an einem Punkt hängt. Warum hier und nicht im Renderer: Die Gruppierung ist reine Darstellung. So bleiben der Renderer-String (SPEC §9: der Modus ändert nur die Anordnung) und die hand-getunte Geometrie der drei übrigen Anordnungen unberührt — dort gibt es das Platzproblem gar nicht, die Kinder stehen ohnehin untereinander. Nur ENDknoten: Der Platzgewinn entsteht gerade daraus, dass kein Teilbaum mitgestapelt werden muss; außerdem setzt die Stufengeometrie voraus, dass die Zelle so hoch ist wie ihr Knoten (kein Teilbaum, kein Geister-Knoten). */ const STAIR_MIN = 2; function applyOptStairs(){ /* Erst auflösen: applyLayout() arbeitet auf einem bereits gruppierten Baum. */ out.querySelectorAll('li.opt-group').forEach(group => { const stair = group.firstElementChild; while(stair.firstElementChild){ const li = stair.firstElementChild; li.style.removeProperty('--i'); group.parentNode.insertBefore(li, group); } group.remove(); }); if(out.classList.contains('vertical') || out.classList.contains('kompakt')) return; out.querySelectorAll('ul.and').forEach(ul => { let run = []; const flush = () => { if(run.length >= STAIR_MIN){ const group = document.createElement('li'); group.className = 'opt-group'; const stair = document.createElement('ul'); stair.className = 'opt-stair'; ul.insertBefore(group, run[0]); group.appendChild(stair); run.forEach((li, i) => { li.style.setProperty('--i', i); stair.appendChild(li); }); } run = []; }; for(const li of [...ul.children]){ const leaf = li.classList.contains('opt') && li.children.length === 1 && li.firstElementChild.classList.contains('node'); if(leaf) run.push(li); else flush(); } flush(); }); } /* Stielposition der all-of-Abzweige im horizontalen Fächer (siehe style.css). Nur `li.has-or` braucht die Messung: dort steht der Knoten linksbündig, das
  • ist aber so breit wie sein any-of-Teilbaum — der Stiel bei 50 % liefe am Knoten vorbei. Die transponierten Modi setzen left/right ohnehin fest und bleiben deshalb unberührt. Messwerte sind durch `zoom` skaliert und werden wie in drawCheapPath() zurückgerechnet. */ function alignStems(){ out.querySelectorAll('ul.and>li').forEach(li => li.style.removeProperty('--stem-x')); if(out.classList.contains('vertical') || out.classList.contains('kompakt')) return; const z = zoom || 1; /* `li.opt-group` (Treppe) hat keinen eigenen Knoten — der Stiel zielt auf den ERSTEN Knoten der Kaskade. */ out.querySelectorAll('ul.and>li.has-or, ul.and>li.opt-group').forEach(li => { const node = li.querySelector(':scope > .node') || li.querySelector('.node'); if(!node) return; const lr = li.getBoundingClientRect(), nr = node.getBoundingClientRect(); if(!lr.width) return; /* Panel eingeklappt */ li.style.setProperty('--stem-x', ((nr.left - lr.left + nr.width/2)/z).toFixed(1) + 'px'); }); } 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) => ``; /* Auftreffpunkte der Abzweige an optionalen Knoten (`+`, SPEC §3/D29). Gesammelt beim Linienzeichnen, gezeichnet erst NACH den Knoten — der Kreis sitzt mittig auf der Kante, das Knoten-Rechteck würde ihn sonst halb überdecken. */ const optMarks = []; /* „1"-Plaketten der XOR-Gruppen (`=`, SPEC §9/D35) — wie die Optional-Kreise erst NACH den Knoten gezeichnet, sitzen aber auf der Leitung, nie auf einer Knotenkante. */ const xorMarks = []; 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 isXor = childUl.classList.contains('xor'); const stroke = gate === 'or' ? '#6B7A8C' : '#41556E'; const dash = gate === 'or'; /* Die Treppe (D29) ist eine Anordnung, keine Ebene: alle Stufen sind Kinder DIESES Elternknotens. An die Sammelleiste kommt nur die erste Stufe; die übrigen hängen an der Kaskade und werden unten nachgezogen. Zöge man jede Stufe einzeln an die Leiste, liefe die Linie zur dritten Stufe hinter der zweiten hindurch — und läse sich wie eine Eltern-Kind-Beziehung. */ const kidEls = [], stairs = []; for(const cli of childUl.children){ if(cli.classList.contains('opt-group')){ const st = [...cli.querySelectorAll(':scope > ul.opt-stair > li > .node')]; if(!st.length) continue; kidEls.push(st[0]); if(st.length > 1) stairs.push(st); } else { const n = cli.querySelector(':scope > .node'); if(n) kidEls.push(n); } } const kids = kidEls.map(R); if(!kids.length) return; const isOpt = i => kidEls[i].classList.contains('opt'); 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)); if(isXor) xorMarks.push({x:(px+busX)/2, y:p.cy}); parts.push(seg(busX, Math.min(...ys), busX, Math.max(...ys), stroke, dash)); kids.forEach((k, i) => { const x = toRight ? k.x : k.r, o = isOpt(i); parts.push(seg(busX, k.cy, x, k.cy, stroke, dash || o)); if(o) optMarks.push({x, y: k.cy}); }); } 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)); if(isXor) xorMarks.push({x:p.cx, y:(py+busY)/2}); parts.push(seg(Math.min(...xs), busY, Math.max(...xs), busY, stroke, dash)); kids.forEach((k, i) => { const y = toDown ? k.y : k.b, o = isOpt(i); parts.push(seg(k.cx, busY, k.cx, y, stroke, dash || o)); if(o) optMarks.push({x: k.cx, y}); }); } /* Kaskade ab der zweiten Stufe: an der linken Kante der vorigen Stufe herab, dann waagerecht in die eigene — dieselbe Führung wie am Bildschirm. */ stairs.forEach(st => { for(let j = 1; j < st.length; j++){ const a = R(st[j-1]), b = R(st[j]); parts.push(seg(a.x, a.b, a.x, b.cy, stroke, true)); parts.push(seg(a.x, b.cy, b.x, b.cy, stroke, true)); optMarks.push({x: b.x, y: b.cy}); } }); }); /* 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); }); }); /* 3a) Optionale Knoten: hohler Kreis auf der Kante (nach den Knoten) */ optMarks.forEach(m => parts.push( ``)); /* 3a′) XOR-Gruppen: „1"-Plakette am Austritt der Sammelleiste (D35) */ xorMarks.forEach(m => { parts.push(``); parts.push(`1`); }); /* 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 syncMirror(){ if(!mirrorEl){ mirrorEl = document.createElement('div'); mirrorEl.setAttribute('aria-hidden', 'true'); /* `box-sizing:border-box` ist Pflicht: `src.clientWidth` **enthält** die Innenabstände. Ohne das ist der Spiegel um genau diese 32 px breiter als das Textfeld und bricht später um — lange Zeilen landeten dadurch zu weit oben (fiel beim Bau der Zeilennummern auf, D33; betraf auch schon das Scrollen beim Sprung, D25). */ mirrorEl.style.cssText = 'position:absolute;visibility:hidden;top:0;left:-9999px;' + 'box-sizing:border-box;' + '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'; return mirrorEl; } const ZWSP = '​'; function offsetTopInEditor(offset){ const m = syncMirror(); m.textContent = src.value.slice(0, offset); const marker = document.createElement('span'); marker.textContent = ZWSP; m.appendChild(marker); const top = marker.offsetTop; m.textContent = ''; return top; } /* Oberkante jeder **logischen** Zeile, im selben Koordinatensystem wie `src.scrollTop` (die Innenabstände stecken schon drin). Ein Marker je Zeile im selben Spiegel: einmal schreiben, dann alle `offsetTop` in einem Durchgang lesen — sonst erzwingt jede Messung ein eigenes Neu-Layout. */ function lineTops(){ const m = syncMirror(); m.textContent = ''; const marks = []; for(const line of src.value.split('\n')){ const s = document.createElement('span'); s.textContent = ZWSP; m.appendChild(s); marks.push(s); m.appendChild(document.createTextNode(line + '\n')); } const tops = marks.map(s => s.offsetTop); m.textContent = ''; return tops; } /* ---------- Zeilennummern (D33) ---------- Die Warnungen nennen Zeilennummern (SPEC §4); ohne Streifen muss man sie im Textfeld abzählen. Der Streifen ist ein eigener Kasten neben dem Textfeld — in den Textfluss lässt sich nichts einfügen, ein `