import './style.css'; import { parse, setFoldMark, expandShortIds, shortIdClosed } from './parser.js'; import { computeCheapPlan, overloadedAssignee, freshProdSet, initialCollapsed, nodeKeys, effectiveStatus, presetFoldSet, lineTargets } from './model.js'; import { esc, renderTreeHtml, TIP_RULE } from './render.js'; import { formatWarning, warningText } from './warnings.js'; import * as live from './live.js'; import { depFragment, collectIds, matchIds, depIdAt, idLine } from './autocomplete.js'; import { LS_SNAPS, SNAP_EVERY, parseSnaps, addSnapshot, persistSnaps, snapLabel } from './snapshots.js'; import { FILE_ACCEPT, FILE_TYPES, saveFileName } from './localfile.js'; import { LIVE_PARAM, SOURCE_PARAM, ETHERPAD_PARAM, docSearch, docKind } from './docurl.js'; /* Neuigkeiten (D58): die git-Historie, zur BAUZEIT eingelesen (Vite-Plugin in vite.config.js). Zur Laufzeit gibt es kein git — und keinen Server, der nachliefern könnte (D11/D19). Leer, wo git nicht erreichbar war. */ import NEWS from 'virtual:werkbaum-news'; /* 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 " Folded chapters are done — click the ▸ to peek inside. - [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: CMS integration (M) | [ ] WordPress | [?] Headless CMS | [-] Custom build %% too much effort - [x] Landing page (S) :#cms %% done, but effectively waiting for the CMS + [?] Dark mode (S) %% nice to have, never on the cheapest path - [?] Hosting (M) %% exactly one of these, hence = = [ ] Cooperative Community Cloud https://hostsharing.net = [?] On-premise --- #cms The articles live in the CMS, so everything that shows content depends on it — the dotted arrows point at what is needed.`; 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; let depLinksOn = true; /* Querverbindungen der Abhängigkeiten anzeigen (D75) */ let showIds = false; /* Knoten-IDs im Diagramm einblenden (D56) */ /* Falt-Durchschalter (SPEC §9/D75): vier Voreinstellungen, reihum. `Next` ist der Schritt, den der nächste Druck ausführt (Icon + Tooltip zeigen IHN), `Applied` der zuletzt hergestellte — render() prüft ihn gegen den Baum und setzt die Position zurück, sobald er nicht mehr stimmt. Nicht persistiert: Der Faltzustand steht im Text (D38), die Position ist nur der Zeiger darin. */ const FOLD_CYCLE = ['small', 'path', 'closed', 'open']; let foldCycleNext = 0, foldCycleApplied = -1; /* Sichtbarer Bereich auf kleinem Bildschirm (D17-Nachtrag): dort ist immer genau einer zu sehen. Hier oben deklariert, weil saveUI() ihn liest und schon aus applySplit() heraus laufen kann — weiter unten stünde er dann noch in der temporalen Todeszone. */ let mobilePane = 'diagram'; /* Cursor-Zeile (D25) — bleibt `null`, bis der Cursor das erste Mal bewegt wurde, sonst wäre nach dem Laden ungefragt die Wurzel markiert. Aus demselben Grund hier oben: 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; /* Steht die App? Erst danach folgt die Adresszeile dem aktiven Dokument (D80) und wird eine Live-Sitzung beim Umschalten übernommen. Während des Starts dürfen beide nicht laufen: `loadRemoteSource()`/`loadLive()` lesen ihre Parameter erst nach dem Wiederherstellen des zuletzt aktiven Dokuments — ein vorschnelles Aufräumen der Adresszeile nähme ihnen die Vorlage. Hier oben deklariert, weil `loadActiveIntoEditor()` schon beim Start läuft. */ let bootDone = false; /* „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; /* Führt das Neuigkeiten-Popup gerade einen Tag vor (D58)? Dann tritt dessen Knotenmenge an die Stelle des Besuchsvergleichs — `newsDay` ist das Datum (auch die Marke im Popup), `newsKeySet` die Label-Pfade. Sitzungssache: Der Faltzustand steht im Text (D38), der Besuchsstand im localStorage — eine vorgeführte Chronik ist weder das eine noch das andere. */ let newsDay = null, newsKeySet = null; /* Faltung (SPEC §9, D38): `foldOverrides` sind die interaktiven Eingriffe des Nutzers (Schlüssel = Label-Pfad wie bei D28, damit sie das Neu-Parsen bei jedem Tastendruck überleben); sie überlagern den Anfangszustand aus den Textmarken, gelten nur für die Sitzung und fallen beim Dokumentwechsel weg. `foldByLine` ist der Zustand des letzten Renders für Klick/Tastatur. */ let foldOverrides = new Map(), foldByLine = new Map(); /* Zeile -> Zeile des sichtbaren Vertreters (D38-Nachtrag 4): Für Zeilen in eingeklappten Teilbäumen zeigt sie auf den nächsten sichtbaren Vorfahren; `nodeOfLine()` greift darauf zurück, wenn die Zeile keinen DOM-Knoten hat. */ let lineTargetMap = new Map(); /* Der zuletzt geparste Baum, UNGEFILTERT — die ID-Vorschläge (D63) lesen daraus die vergebenen IDs, und eine Abhängigkeit darf auch auf Verworfenes zeigen. */ let acRoots = []; /* ---------- 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(){ /* Das Knoten-Fenster (D52) hängt an einem Element, das der Neubau ersetzt — und seine Position ist ohnehin gemessen, also gleich hinfällig. */ closeNodeTip(); const parsed = parse(src.value); acRoots = parsed.roots; 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(); foldByLine = new Map(); lineTargetMap = new Map(); } else { /* Günstigster Pfad auf der Dependency Closure (D42): scheitert die exakte Suche an zu vielen gekoppelten Gruppen, wird die gierige Schätzung BENANNT statt stillschweigend geliefert (zeilenlose Warnung). */ let cheapSet = new Set(), overload = null; if(cheapPathOn){ const plan = computeCheapPlan(roots); cheapSet = plan.set; if(!plan.exact) warnings = warnings.concat([{type: 'cheapApprox'}]); /* Zuständigen-Engpass (SPEC §9/D71): Trägt eine Person mehr als die Hälfte der offenen Pfad-Arbeit, wird das zeilenlos gemeldet und ihre Pillen an offenen Pfad-Knoten warnfarben (overloadTag unten). */ overload = overloadedAssignee(roots, cheapSet); if(overload) warnings = warnings.concat([{type: 'assigneeOverload', ...overload}]); } out.classList.toggle('cheap-on', cheapPathOn); /* Faltung (D38): Anfangszustand aus den Textmarken (`!!!` holt sich mit hervor), überlagert von den Sitzungs-Eingriffen des Nutzers. Wie bei `freshSet` muss die Menge aus den gerade geparsten Knoten bestehen. */ const initFold = initialCollapsed(roots, true); const keys = nodeKeys(roots); /* 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). Führt das Neuigkeiten-Popup gerade einen Tag vor (D58), gilt dessen Knotenmenge STATT des Vergleichs mit dem letzten Besuch: Es ist dieselbe Ansicht, nur mit einer anderen Frage — „was geschah am 24.08." statt „was ist seit deinem letzten Besuch live gegangen". */ if(newsKeySet){ freshSet = new Set(); keys.forEach((key, n) => { if(newsKeySet.has(key)) freshSet.add(n); }); } else { freshSet = (freshDocId === activeId && freshPrevRoots) ? freshProdSet(freshPrevRoots, roots) : new Set(); } const collapsedSet = new Set(); foldByLine = new Map(); /* Reihum-Position des Falt-Durchschalters (SPEC §9/D75): nicht gemerkt, sondern am Baum GEPRÜFT — die D44-Linie, nur dass der Knopf jetzt vier Voreinstellungen durchläuft. Beschreibt der Baum nicht mehr den zuletzt hergestellten Schritt (Hand-Faltung, Textänderung, Dokumentwechsel), beginnt der nächste Druck wieder vorn. Der 'path'-Schritt braucht den günstigsten Pfad auch bei ausgeschaltetem Pfad-Umschalter — dann wird er hier eigens für die Prüfung gerechnet (dieselbe Rechnung, die sonst ohnehin je Tastendruck läuft). */ let presetSet = null, presetMatch = true; if(foldCycleApplied >= 0){ const mode = FOLD_CYCLE[foldCycleApplied]; const cs = (mode === 'path' && !cheapPathOn) ? computeCheapPlan(roots).set : cheapSet; presetSet = presetFoldSet(roots, mode, cs); } keys.forEach((key, n) => { const ov = foldOverrides.get(key); const collapsed = (ov !== undefined ? ov : initFold.has(n)) && n.children.length > 0; if(collapsed) collapsedSet.add(n); foldByLine.set(n.line, {key, collapsed, canFold: n.children.length > 0}); if(presetSet && n.children.length > 0 && collapsed !== presetSet.has(n)) presetMatch = false; }); if(presetSet && !presetMatch){ foldCycleApplied = -1; foldCycleNext = 0; } /* Aus DENSELBEN Mengen wie das Rendern — die Map muss dieselbe Faltung beschreiben, die gleich im DOM steht (dieselbe Regel wie bei freshSet). */ lineTargetMap = lineTargets(roots, collapsedSet, showDiscarded); const r = renderTreeHtml(roots, {t, showDiscarded, cheapPath: cheapPathOn, cheapSet, showIds, freshSet, collapsedSet, effStatus: effectiveStatus(roots), overloadTag: overload ? overload.tag : null}); 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). Der Text wandert gleich mit in den `title` der Zahl (D33-Nachtrag); mehrere Warnungen einer Zeile stehen dort untereinander. */ lineNoWarn = new Map(); for(const w of warnings){ if(!w.line) continue; const vorher = lineNoWarn.get(w.line); const text = warningText(w, t); lineNoWarn.set(w.line, vorher ? vorher + '\n' + text : text); } renderLineNos(); applyOptStairs(); /* muss vor dem Messen laufen — es verschiebt Knoten */ alignStems(); alignVRails(); drawCheapPath(); /* Querverbindungen (D41) zeichnet highlightCurrentNode() unten mit — es kennt die zweite Hälfte der Auswahl (Cursor-Zeile). */ /* 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) */ updateLeanBtn(); /* Stationen des Pfads haben sich geändert (D47) */ updateFoldBtn(); /* Icon + Tooltip = nächster Schritt des Durchschalters (D75) */ } /* 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 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 = effZoom() || 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'); }); } /* Sammelleisten-Verlängerung im vertikalen Modus (D65). Der Eltern-Stub dockt bei 50 % der Gruppenhöhe an, die Leiste endet aber am 23-px-Abzweig des letzten Kindes — trägt das einen großen Teilbaum, liegt die Gruppenmitte TIEFER und der Stub hinge in der Luft (gemessen: 4,5 bis 98 px Lücke). CSS kann die Gruppenmitte relativ zum letzten
  • nicht ausdrücken; wie bei `alignStems()` misst deshalb JS und setzt `--vrail-ext` (unskalierte px, durch effZoom() zurückgerechnet). Nur nicht-has-and-Letztkinder: Bei has-and liegt der Abzweig bei 50 % der Zelle, und die Gruppenmitte liegt beweisbar nie darunter. Nach oben kann die Mitte nie aus der Leiste fallen (der erste Abzweig liegt höchstens 23 px unter dem Gruppenanfang). */ function alignVRails(){ out.querySelectorAll('ul.and>li').forEach(li => li.style.removeProperty('--vrail-ext')); if(!out.classList.contains('vertical')) return; const z = effZoom() || 1; out.querySelectorAll('li.has-and>ul.and').forEach(ul => { const kids = [...ul.children].filter(e => e.tagName === 'LI'); if(kids.length < 2) return; /* :only-child löst CSS allein (50 %) */ const last = kids[kids.length - 1]; if(last.classList.contains('has-and')) return; const ur = ul.getBoundingClientRect(), lr = last.getBoundingClientRect(); if(!ur.height) return; /* Panel eingeklappt */ const ext = (ur.top + ur.height/2 - lr.top)/z; if(ext > 23.5) last.style.setProperty('--vrail-ext', ext.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 */ /* EINE Station ist ein gültiger Pfad: Ein eingeklappter Knoten vertritt seinen ganzen Teilbaum (D38-Nachtrag), oben im Baum bleibt davon leicht nur eine einzige sichtbare Station übrig. Die Linie entfällt dann (durch einen Punkt führt keine), der Stationspunkt darf es nicht — sonst verschwände der Pfad genau dort ganz, wo er am dichtesten gefaltet ist. */ if(!leaves.length) return; const outRect = out.getBoundingClientRect(); const z = effZoom() || 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 = pts.length > 1 ? catmullRom(pts) : null; /* kräftige Linie HINTER die Knoten (als erstes Kind → hinterste Paint-Ebene) */ if(d){ 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); if(d) 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); } /* ---------- Querverbindungen der Abhängigkeiten (SPEC §9, D41) ---------- Die erste Linienart, die nicht der Zerlegung folgt — deshalb eine eigene Zeichenebene wie beim Pfad-Spline. Basis-Kanten dünn, blassgrau und GEKRÜMMT (die Krümmung unterscheidet sie von den orthogonalen Baumlinien), hinter den Knoten, mit Pfeilspitze auf das Gebrauchte. Die Kanten des fokussierten Knotens bzw. der Cursor-Zeile liegen hervorgehoben in Tinte auf einer vorderen Ebene. */ function depEdges(){ /* Eingeklappte Knoten vertreten ihre Teilbäume (SPEC §9/D75): Der Renderer hängt IDs und Abhängigkeiten der verborgenen Knoten als `data-sub-ids`/ `data-sub-deps` an den eingeklappten Vertreter — Quelle wie Ziel enden damit am nächsten sichtbaren Vorfahren. querySelectorAll liefert Dokumentreihenfolge, und die Sub-IDs stehen (DFS) ebenso darin: „erste Vergabe gewinnt" (D36) gilt so auch über die Faltgrenze hinweg. */ const byId = new Map(); out.querySelectorAll('.node[data-id], .node[data-sub-ids]').forEach(el => { if(el.dataset.id && !byId.has(el.dataset.id)) byId.set(el.dataset.id, el); if(el.dataset.subIds) for(const id of el.dataset.subIds.split(' ')) if(!byId.has(id)) byId.set(id, el); }); const edges = [], seen = new Set(); out.querySelectorAll('.node[data-deps], .node[data-sub-deps]').forEach(el => { const deps = ((el.dataset.deps || '') + ' ' + (el.dataset.subDeps || '')) .split(' ').filter(Boolean); for(const d of deps){ const target = byId.get(d); /* ausgeblendete (verworfene) oder unbekannte Ziele: keine Kante; beide Endpunkte im selben sichtbaren Knoten zusammengefallen: ebenso — und mehrere zusammengefallene Kanten desselben Paars werden EINE. */ if(!target || target === el) continue; const key = el.dataset.line + '>' + target.dataset.line; if(seen.has(key)) continue; seen.add(key); edges.push([el, target]); } }); return edges; } /* Gekrümmte Kante zwischen zwei Knotenkästen: Endpunkte auf den Kanten (Strahl von Mitte zu Mitte, am Rechteck geklippt), Kontrollpunkt senkrecht zur Verbindung versetzt. */ function depCurve(a, b){ const dx = b.cx - a.cx, dy = b.cy - a.cy; const len = Math.hypot(dx, dy) || 1; const clip = (r, tx, ty) => { const ex = tx - r.cx, ey = ty - r.cy; const sx = ex ? (r.w/2) / Math.abs(ex) : Infinity; const sy = ey ? (r.h/2) / Math.abs(ey) : Infinity; const s = Math.min(sx, sy, 1); return {x: r.cx + ex*s, y: r.cy + ey*s}; }; const p1 = clip(a, b.cx, b.cy), p2 = clip(b, a.cx, a.cy); const bow = Math.min(40, len/4); const ctrl = {x:(p1.x + p2.x)/2 - dy/len*bow, y:(p1.y + p2.y)/2 + dx/len*bow}; return {d:`M${p1.x.toFixed(1)},${p1.y.toFixed(1)} Q${ctrl.x.toFixed(1)},${ctrl.y.toFixed(1)} ${p2.x.toFixed(1)},${p2.y.toFixed(1)}`, end:p2, ctrl}; } /* Offene Pfeilspitze (Winkel aus zwei Strichen, D41-Nachtrag): ein gefülltes Dreieck stach als einziger satter Fleck aus der gepunkteten Linie heraus. */ function depArrow(end, from){ const dx = end.x - from.x, dy = end.y - from.y; const l = Math.hypot(dx, dy) || 1; const ux = dx/l, uy = dy/l, s = 5; const a = {x: end.x - ux*s*1.8 - uy*s, y: end.y - uy*s*1.8 + ux*s}; const b = {x: end.x - ux*s*1.8 + uy*s, y: end.y - uy*s*1.8 - ux*s}; return `M${a.x.toFixed(1)},${a.y.toFixed(1)} L${end.x.toFixed(1)},${end.y.toFixed(1)} L${b.x.toFixed(1)},${b.y.toFixed(1)}`; } /* „Ausgewählt" heißt: Tastaturfokus im Diagramm, sonst der Knoten der Cursor-Zeile (D25) — beide Lesarten von „ich schaue auf diesen Knoten". */ function activeDepNode(){ const f = document.activeElement; if(f && out.contains(f) && f.closest) { const n = f.closest('.node[data-line]'); if(n) return n; } return currentNodeEl; } function drawDepLinks(){ out.querySelectorAll('svg.dep-overlay').forEach(e => e.remove()); if(!depLinksOn) return; /* Umschalter (SPEC §9/D75), Voreinstellung an */ const edges = depEdges(); if(!edges.length) return; const outRect = out.getBoundingClientRect(); if(!outRect.width || !outRect.height) return; /* Panel eingeklappt */ const z = effZoom() || 1; const rect = el => { const r = el.getBoundingClientRect(); return {x:(r.left - outRect.left)/z, y:(r.top - outRect.top)/z, w:r.width/z, h:r.height/z, cx:(r.left - outRect.left + r.width/2)/z, cy:(r.top - outRect.top + r.height/2)/z}; }; const w = outRect.width/z, h = outRect.height/z; /* EINE Ebene, und die liegt hinter den Knoten (D41-Nachtrag 2): Die Kanten laufen durch fremde Knoten hindurch, statt über sie hinweg. Die hervorgehobenen werden zuletzt eingehängt — damit liegen sie über den übrigen Kanten, aber weiterhin unter jedem Knoten. */ const back = overlaySvg('dep-overlay dep-back', w, h); const hi = activeDepNode(); const hoch = []; for(const [from, to] of edges){ const hl = hi && (from === hi || to === hi); const c = depCurve(rect(from), rect(to)); const kante = svgEl('path', {class:'dep-edge' + (hl ? ' hl' : ''), d:c.d}); const spitze = svgEl('path', {class:'dep-arrow' + (hl ? ' hl' : ''), d:depArrow(c.end, c.ctrl)}); if(hl) hoch.push(kante, spitze); else { back.appendChild(kante); back.appendChild(spitze); } } for(const p of hoch) back.appendChild(p); out.insertBefore(back, out.firstChild); } /* Hervorhebung folgt dem Fokus; die Basis-Kanten selbst ändern sich nicht. */ out.addEventListener('focusin', drawDepLinks); out.addEventListener('focusout', () => setTimeout(drawDepLinks, 0)); /* ---------- 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. */ /* Die gerenderten Textzeilen eines Knotens, gemessen am LIVE-Element: je Zeile der (whitespace-normalisierte) Text und die Box in Viewport-Pixeln. Zeichenweise per Range — eine neue Zeilen-Oberkante beginnt eine neue Zeile; das deckt auch den Umbruch INNERHALB eines langen Worts (overflow-wrap) ab, den eine Nachbildung der Wort-Umbruchlogik verfehlte. Kollabierter Leerraum (etwa das Leerzeichen, an dem umbrochen wurde) hat eine Null-Box und fällt heraus. `excludeSel` sind die Elemente, die der Export nicht ins Label nimmt (Badges, ↗, ”-Marke, je nach Faltzustand das Falt-Zeichen). */ function labelLines(node, excludeSel){ const range = document.createRange(); const lines = []; const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, { acceptNode: tn => tn.parentElement.closest(excludeSel) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT, }); for(let tn = walker.nextNode(); tn; tn = walker.nextNode()){ const s = tn.nodeValue; for(let i = 0; i < s.length; i++){ range.setStart(tn, i); range.setEnd(tn, i + 1); const r = range.getBoundingClientRect(); if(!r.width && !r.height) continue; const last = lines[lines.length - 1]; if(last && Math.abs(r.top - last.top) < 2){ last.text += s[i]; last.left = Math.min(last.left, r.left); last.right = Math.max(last.right, r.right); last.bottom = Math.max(last.bottom, r.bottom); } else { lines.push({text: s[i], top: r.top, bottom: r.bottom, left: r.left, right: r.right}); } } } for(const l of lines) l.text = l.text.replace(/\s+/g, ' ').trim(); return lines.filter(l => l.text); } function diagramToSvg(){ /* Der Export misst die **Live-Geometrie**. Den Ring der Cursor-Zeile liest er nie aus (`box-shadow` steht nicht in der Liste), ihre Erhebung (D25-Nachtrag) schlüge aber über `getBoundingClientRect()` durch und exportierte genau einen Knoten 4 % zu groß. Neutralisiert wird sie per Klasse statt durch Abnehmen von `.current`/`.pulse` — so reißt der Export keine laufende Puls-Animation ab und startet sie hinterher nicht neu. */ out.classList.add('exporting'); /* Zoom für die Messung auf 1 stellen. Die Schriftgrößen im Ausgabe-SVG sind feste Zahlen (14 für Labels, 9–11 für Badges), die Kästen kommen dagegen aus der Messung — bei jedem Zoom ≠ 1 passten Text und Kasten also nicht zueinander. Das fiel bisher kaum auf, weil 100 % der Normalfall war; mit der Mobil-Verkleinerung (D17-Nachtrag 2) wäre es der Regelfall geworden. Derselbe Griff wie bei `exporting`: kurz neutralisieren, hinterher zurück — die Funktion läuft synchron, es wird nichts davon gezeichnet. */ const zoomBefore = out.style.zoom; out.style.zoom = 1; 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}); } }); }); /* 1a) Querverbindungen der Abhängigkeiten (D41) — Basis-Kanten hinter den Knoten; die Fokus-Hervorhebung ist Interaktion und wird nicht exportiert. Der Export folgt dem Umschalter (D75) wie den übrigen Ansichts-Filtern. */ (depLinksOn ? depEdges() : []).forEach(([from, to]) => { const c = depCurve(R(from), R(to)); parts.push(``); parts.push(``); }); /* 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(``); /* Das „▸ n"-Kennzeichen eingeklappter Knoten gehört in den Export (SPEC §9/D38 — das Bild darf keine Vollständigkeit behaupten); das ▾ offener Knoten ist Bedienelement und fällt weg. */ const stripFold = node.classList.contains('folded') ? '' : ',.fold'; const deco = cs.textDecorationLine.includes('line-through') ? ' text-decoration="line-through"' : ''; /* Seit Knoten umbrechen (D64), belegt ein Label mehrere Zeilenboxen — ein SVG- bricht nicht von selbst; je gemessene Zeile ein Element. */ for(const ln of labelLines(node, '.size,.tags,.ext,.risk,.ownst,.desc-mark' + stripFold)){ const cx = (ln.left + ln.right) / 2 + ox, cy = (ln.top + ln.bottom) / 2 + oy; parts.push(`${esc(ln.text)}`); } const riskEl = node.querySelector('.risk'); if(riskEl){ const rb = R(riskEl); parts.push(``); parts.push(`⚠︎`); } /* Gemessene Farben statt festem Petrol: Das invertierte implizite M (D18) und das warnfarbene Konflikt-Badge (SPEC §5/D62) kämen sonst als gewöhnliches gefülltes Badge ins Bild — beim impliziten M war genau das seit jeher der Fall (Nebenbefund D62). */ const sizeEl = node.querySelector('.size'); if(sizeEl){ const s = getComputedStyle(sizeEl); /* Der Rand gehört mit: beim invertierten Badge ist er die einzige Kontur (weiße Füllung auf weißer Karte). */ drawBadge(sizeEl, s.backgroundColor, s.color, s.borderTopColor); } node.querySelectorAll('.tag').forEach(tg => { const t = getComputedStyle(tg); drawBadge(tg, t.backgroundColor, t.color, t.borderTopColor); }); /* Diskrepanz-Marke des effektiven Status (D39) — Aussage über den Plan, gehört in den Export wie Größe und Tags. */ node.querySelectorAll('.ownst').forEach(c => { const s = getComputedStyle(c); drawBadge(c, s.backgroundColor, s.color, s.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. Die Linie braucht zwei Punkte, die Stationen nicht — bei stark gefaltetem Baum bleibt leicht nur eine sichtbare Station übrig (siehe `drawCheapPath`). */ 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('')}`; out.style.zoom = zoomBefore; out.classList.remove('exporting'); 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 rückt ein (D53) ---------- Einrückung IST hier die Hierarchie (SPEC §2), Tab also die häufigste Taste nach den Buchstaben. Zwei Regeln: - **Ohne Auswahl** zwei Leerzeichen an der Schreibmarke (Tab zählt in dieser Notation als zwei, SPEC §2); Shift+Tab nimmt sie der Zeile wieder weg. - **Mit Auswahl** wird jede berührte ZEILE ein-/ausgerückt, nie die Auswahl ersetzt. In einem Notationstext ist Einrücken praktisch immer gemeint, und diese Regel kann nichts löschen. Danach ist der ganze Zeilenblock ausgewählt, sodass wiederholtes Tab weiter einrückt. Geschrieben wird über `execCommand('insertText')` — die einzige Art, ein Textfeld zu ändern, ohne die Rückgängig-Historie zu zerstören (D38-Nachtrag 2, hier erneut gemessen). Das alte `src.value = …` hat sie bei JEDEM Tastendruck gelöscht, also auch das davor Getippte. */ const IND = ' '; function outdentLine(l){ if(l.startsWith(IND)) return l.slice(IND.length); if(l.startsWith('\t') || l.startsWith(' ')) return l.slice(1); return l; } function indentSelection(out){ const v = src.value, s = src.selectionStart, e = src.selectionEnd; const von = v.lastIndexOf('\n', s - 1) + 1; if(s === e){ if(!out) return writeAt(s, e, IND, s + IND.length, s + IND.length); /* Ausrücken ohne Auswahl: der Zeile den Einzug nehmen, die Schreibmarke um dasselbe Stück mitziehen — sie soll am selben Zeichen stehen bleiben. */ let bisZ = v.indexOf('\n', s); if(bisZ === -1) bisZ = v.length; const zeile = v.slice(von, bisZ), kurz = outdentLine(zeile); if(kurz === zeile) return false; const weg = zeile.length - kurz.length; const p = Math.max(von, s - weg); return writeAt(von, bisZ, kurz, p, p); } /* Endet die Auswahl genau auf einem Zeilenanfang, gehört diese Zeile nicht mehr dazu — sonst rückte ein Zug bis zum nächsten Zeilenbeginn eine Zeile zu viel ein. */ const eAdj = e > s && e > von && v[e-1] === '\n' ? e - 1 : e; let bis = v.indexOf('\n', eAdj); if(bis === -1) bis = v.length; const alt = v.slice(von, bis); const neu = alt.split('\n') .map(l => out ? outdentLine(l) : (l ? IND + l : l)) /* Leerzeilen bleiben leer */ .join('\n'); if(neu === alt) return false; return writeAt(von, bis, neu, von, von + neu.length); } /* Ersetzt [von,bis) durch `ein` und setzt danach die Auswahl — undo-fähig. `input` feuert dabei von selbst, render() und saveSrc() hängen daran. */ function writeAt(von, bis, ein, selA, selB){ src.setSelectionRange(von, bis); let ok = false; try{ ok = document.execCommand('insertText', false, ein); }catch(_){} if(!ok){ /* Rückfall: der richtige Text geht vor der Historie (wie D38-Nachtrag 2). */ src.value = src.value.slice(0, von) + ein + src.value.slice(bis); src.dispatchEvent(new Event('input', {bubbles: true})); } src.setSelectionRange(selA, selB); return true; } /* Tab im Textfeld ist eine Tastenfalle (WCAG 2.1.2): Wer nur die Tastatur benutzt, käme sonst nicht mehr heraus. Esc hebt sie für den NÄCHSTEN Tastendruck auf — der übliche Ausweg. */ let tabEscapes = false; src.addEventListener('keydown', e => { if(e.key === 'Escape'){ tabEscapes = true; return; } if(e.key !== 'Tab'){ tabEscapes = false; return; } if(tabEscapes){ tabEscapes = false; return; } /* Fokus darf weiterwandern */ e.preventDefault(); indentSelection(e.shiftKey); }); src.addEventListener('input', render); src.addEventListener('input', saveSrc); src.addEventListener('input', scheduleLivePush); /* Server-Dokument: Diff nach 1,5 s Ruhe (D76) */ /* Der Neu-laden-Knopf (D81) hängt bei mitgelieferten Dokumenten an der Abweichung vom Auslieferungsstand — und die entsteht beim TIPPEN, nicht erst beim Dokumentwechsel. Der Vergleich ist ein String-Vergleich mit frühem Ausstieg, je Tastendruck unbedenklich. */ src.addEventListener('input', () => updateDocButtons()); /* ---------- 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: ein unsichtbarer Spiegel mit gleicher Typografie plus Marker-Span. Seit `wrap="off"` (D49) bricht das Textfeld nicht mehr um — der Spiegel deshalb ebenfalls `white-space:pre` und **ohne** vorgegebene Breite. Beides gehört zusammen: Eine feste Breite bei `pre-wrap` bräche im Spiegel Zeilen um, die im Textfeld ungebrochen stehen, und alles darunter läge zu tief. Gemessen wird trotzdem weiter, statt `Zeilenhöhe × n` zu rechnen — die Schriftgröße unterscheidet sich zwischen Telefon und Schreibtisch (D17), und die Messung stimmt in beiden Fällen von selbst. */ let mirrorEl = null; function syncMirror(){ if(!mirrorEl){ mirrorEl = document.createElement('div'); mirrorEl.setAttribute('aria-hidden', 'true'); mirrorEl.style.cssText = 'position:absolute;visibility:hidden;top:0;left:-9999px;' + 'box-sizing:border-box;white-space:pre;'; 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]; } return mirrorEl; } const ZWSP = '​'; /* Position eines Zeichenoffsets im Spiegel — `top` im Koordinatensystem von `src.scrollTop`, `left` in dem von `src.scrollLeft` (Innenabstände stecken je drin). Das `left` braucht nur die Vorschlagsliste (D63). */ function caretPosInEditor(offset){ const m = syncMirror(); m.textContent = src.value.slice(0, offset); const marker = document.createElement('span'); marker.textContent = ZWSP; m.appendChild(marker); const pos = {top: marker.offsetTop, left: marker.offsetLeft}; m.textContent = ''; return pos; } function offsetTopInEditor(offset){ return caretPosInEditor(offset).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 `