update
This commit is contained in:
1564
frontend/package-lock.json
generated
1564
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,9 +12,11 @@
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"dompurify": "^3.4.7",
|
||||
"highlight.js": "^11.11.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"katex": "^0.17.0",
|
||||
"marked": "^18.0.4",
|
||||
"marked-highlight": "^2.2.4",
|
||||
"mermaid": "^11.4.1",
|
||||
"vue": "^3.5.32"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -91,8 +91,8 @@ async function starten() {
|
||||
}
|
||||
|
||||
async function stoppen() {
|
||||
await api.stop(topic.value)
|
||||
await stateLaden()
|
||||
try { fehler.value = ''; await api.stop(topic.value); await stateLaden() }
|
||||
catch (e) { fehler.value = String(e) } // sonst läuft der Lauf weiter, ohne Signal
|
||||
}
|
||||
|
||||
const transferOk = ref(false) // nur im lokalen Dev-Betrieb (Server: 403)
|
||||
@@ -205,7 +205,8 @@ function runBadge(run) {
|
||||
</select>
|
||||
</div>
|
||||
<div class="inhalt">
|
||||
<Board v-if="tab === 'board'" :state="state" :topic="topic" @reload="stateLaden" />
|
||||
<Board v-if="tab === 'board'" :state="state" :topic="topic" @reload="stateLaden"
|
||||
@fehler="(e) => (fehler = e)" />
|
||||
<Guide v-else-if="tab === 'guide'" :topic="topic" :state="state" />
|
||||
<Ueben v-else-if="tab === 'üben'" :topic="topic" />
|
||||
<Kennzahlen v-else :run="state?.run" />
|
||||
|
||||
@@ -9,28 +9,32 @@ async function req(pfad, opts = {}) {
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// Topic-Namen in Pfaden IMMER kodieren — sonst zerlegen „#"/„/" im Namen die
|
||||
// URL und das Topic wird über die UI unbenutzbar/unlöschbar.
|
||||
const e = encodeURIComponent
|
||||
|
||||
export const api = {
|
||||
topics: () => req('/api/topics'),
|
||||
topicAnlegen: (daten) => req('/api/topics', { method: 'POST', body: JSON.stringify(daten) }),
|
||||
start: (topic, budget) =>
|
||||
req(`/api/topics/${topic}/start`, { method: 'POST', body: JSON.stringify({ budget }) }),
|
||||
stop: (topic) => req(`/api/topics/${topic}/stop`, { method: 'POST', body: '{}' }),
|
||||
sollReset: (topic) => req(`/api/topics/${topic}/soll-reset`, { method: 'POST', body: '{}' }),
|
||||
vollReset: (topic) => req(`/api/topics/${topic}/voll-reset`, { method: 'POST', body: '{}' }),
|
||||
req(`/api/topics/${e(topic)}/start`, { method: 'POST', body: JSON.stringify({ budget }) }),
|
||||
stop: (topic) => req(`/api/topics/${e(topic)}/stop`, { method: 'POST', body: '{}' }),
|
||||
sollReset: (topic) => req(`/api/topics/${e(topic)}/soll-reset`, { method: 'POST', body: '{}' }),
|
||||
vollReset: (topic) => req(`/api/topics/${e(topic)}/voll-reset`, { method: 'POST', body: '{}' }),
|
||||
setAuto: (topic, ebene, an) =>
|
||||
req(`/api/topics/${topic}/auto`, { method: 'PATCH', body: JSON.stringify({ ebene, an }) }),
|
||||
req(`/api/topics/${e(topic)}/auto`, { method: 'PATCH', body: JSON.stringify({ ebene, an }) }),
|
||||
ebeneEntfernen: (topic, ebene) =>
|
||||
req(`/api/topics/${topic}/ebene/${ebene}/entfernen`, { method: 'POST', body: '{}' }),
|
||||
topicLoeschen: (topic) => req(`/api/topics/${topic}`, { method: 'DELETE' }),
|
||||
state: (topic) => req(`/api/topics/${topic}/state`),
|
||||
guide: (topic) => req(`/api/topics/${topic}/guide`),
|
||||
ueben: (topic) => req(`/api/topics/${topic}/ueben`),
|
||||
req(`/api/topics/${e(topic)}/ebene/${e(ebene)}/entfernen`, { method: 'POST', body: '{}' }),
|
||||
topicLoeschen: (topic) => req(`/api/topics/${e(topic)}`, { method: 'DELETE' }),
|
||||
state: (topic) => req(`/api/topics/${e(topic)}/state`),
|
||||
guide: (topic) => req(`/api/topics/${e(topic)}/guide`),
|
||||
ueben: (topic) => req(`/api/topics/${e(topic)}/ueben`),
|
||||
antwort: (artefakt_id, richtig) =>
|
||||
req('/api/ueben/antwort', { method: 'POST', body: JSON.stringify({ artefakt_id, richtig }) }),
|
||||
kennzahlen: (runId) => req(`/api/runs/${runId}/kennzahlen`),
|
||||
kennzahlen: (runId) => req(`/api/runs/${e(runId)}/kennzahlen`),
|
||||
transferInfo: () => req('/api/transfer'),
|
||||
transfer: (topic, richtung) =>
|
||||
req(`/api/topics/${topic}/transfer/${richtung}`, { method: 'POST', body: '{}' }),
|
||||
req(`/api/topics/${e(topic)}/transfer/${e(richtung)}`, { method: 'POST', body: '{}' }),
|
||||
}
|
||||
|
||||
export function wsVerbinden(onEvent) {
|
||||
@@ -38,18 +42,19 @@ export function wsVerbinden(onEvent) {
|
||||
const wsProto = location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const url = (import.meta.env.DEV ? 'ws://localhost:8000' : `${wsProto}://${location.host}`) + '/ws'
|
||||
let ws
|
||||
let offen = true // Cleanup-Flag: nach dem Schließen NICHT mehr reconnecten
|
||||
const verbinden = () => {
|
||||
try {
|
||||
ws = new WebSocket(url)
|
||||
} catch (e) {
|
||||
} catch (err) {
|
||||
// nie den Aufrufer mitreißen (der SecurityError killte sonst den Poll)
|
||||
console.error('WebSocket nicht verfügbar:', e)
|
||||
console.error('WebSocket nicht verfügbar:', err)
|
||||
return
|
||||
}
|
||||
ws.onopen = () => onEvent({ typ: 'verbunden' }) // nach Reconnect: Stand neu laden
|
||||
ws.onmessage = (e) => onEvent(JSON.parse(e.data))
|
||||
ws.onclose = () => setTimeout(verbinden, 2000)
|
||||
ws.onmessage = (ev) => onEvent(JSON.parse(ev.data))
|
||||
ws.onclose = () => { if (offen) setTimeout(verbinden, 2000) }
|
||||
}
|
||||
verbinden()
|
||||
return () => ws && ws.close()
|
||||
return () => { offen = false; if (ws) ws.close() } // sonst Zombie-Reconnect (HMR)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { api } from '../api.js'
|
||||
|
||||
const props = defineProps({ state: Object, topic: String })
|
||||
const emit = defineEmits(['reload'])
|
||||
const emit = defineEmits(['reload', 'fehler'])
|
||||
|
||||
// Agenten-Zeit lokal weiterticken: der Server liefert laufzeit nur mit dem
|
||||
// State-Snapshot — zwischen Deltas (lange LLM-Calls) fröre die Anzeige ein.
|
||||
@@ -41,6 +41,7 @@ const EBENEN = [
|
||||
{ key: 'inventar', titel: 'Atome' },
|
||||
{ key: 'artefakte', titel: 'Artefakte' },
|
||||
{ key: 'struktur', titel: 'Bausteine' },
|
||||
{ key: 'diagramme', titel: 'Diagramme' },
|
||||
{ key: 'guide', titel: 'Guide' },
|
||||
]
|
||||
|
||||
@@ -79,6 +80,10 @@ function karten(key) {
|
||||
return s.bausteine.map((b) => ({ id: b.id, text: `${b.ord + 1}. ${b.titel}`,
|
||||
badges: [[b.status, statusFarbe(b.status)]] }))
|
||||
}
|
||||
if (key === 'diagramme') {
|
||||
return (s.diagramme || []).map((d) => ({ id: d.id, text: `${d.ord + 1}. ${d.titel}`,
|
||||
badges: [[d.status, statusFarbe(d.status)], [`${d.typ} · ${d.quelle}`, 'blau']] }))
|
||||
}
|
||||
return s.bausteine.map((b) => ({ id: b.id, text: `${b.ord + 1}. ${b.titel}`,
|
||||
badges: [[b.stage || 'offen', b.stage === 'done' ? 'gruen' : 'blau']] }))
|
||||
}
|
||||
@@ -100,21 +105,21 @@ async function menueGenerieren() {
|
||||
const m = menue.value
|
||||
menue.value = null
|
||||
if (!m || !generierenErlaubt(m.idx)) return
|
||||
await api.start(props.topic)
|
||||
emit('reload')
|
||||
try { await api.start(props.topic); emit('reload') }
|
||||
catch (e) { emit('fehler', String(e)) } // sonst schlägt der Start still fehl
|
||||
}
|
||||
|
||||
async function menueEntfernen() {
|
||||
const m = menue.value
|
||||
menue.value = null
|
||||
if (!m) return
|
||||
await api.ebeneEntfernen(props.topic, m.key)
|
||||
emit('reload')
|
||||
try { await api.ebeneEntfernen(props.topic, m.key); emit('reload') }
|
||||
catch (e) { emit('fehler', String(e)) }
|
||||
}
|
||||
|
||||
async function autoSetzen(key, ev) {
|
||||
await api.setAuto(props.topic, key, ev.target.checked)
|
||||
emit('reload')
|
||||
try { await api.setAuto(props.topic, key, ev.target.checked); emit('reload') }
|
||||
catch (e) { emit('fehler', String(e)) }
|
||||
}
|
||||
|
||||
const dauer = (s) => (s >= 3600 ? `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
import mermaid from 'mermaid'
|
||||
import { api } from '../api.js'
|
||||
import { render, lesestat } from '../markdown.js'
|
||||
|
||||
// securityLevel:'strict' → kein foreignObject/HTML-Label (umgeht DOMPurify-Filter),
|
||||
// keine Klick-Handler. startOnLoad:false → wir rendern selbst nach jedem Einblenden.
|
||||
mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', theme: 'neutral' })
|
||||
let mmId = 0
|
||||
|
||||
const props = defineProps({ topic: String, state: Object })
|
||||
const daten = ref(null)
|
||||
const level = ref('E') // Durchgang-Wahl: jede Stufe ist ein eigener Lese-Durchgang
|
||||
@@ -15,10 +21,14 @@ watch(fokus, (f) => document.body.classList.toggle('vollbild', f))
|
||||
onUnmounted(() => document.body.classList.remove('vollbild'))
|
||||
const aktiv = ref(0)
|
||||
const kapEls = []
|
||||
const scrollEl = ref(null)
|
||||
|
||||
let ladeSeq = 0
|
||||
async function laden() {
|
||||
if (!props.topic) return
|
||||
daten.value = await api.guide(props.topic)
|
||||
if (!props.topic) { daten.value = null; return }
|
||||
const seq = ++ladeSeq
|
||||
const d = await api.guide(props.topic)
|
||||
if (seq === ladeSeq) daten.value = d // späte Antwort eines alten Topics verwerfen
|
||||
}
|
||||
watch(() => props.topic, laden, { immediate: true })
|
||||
watch(() => props.state?.run?.status, laden) // nach Lauf-Ende neu ziehen
|
||||
@@ -36,6 +46,36 @@ function html(s) {
|
||||
return htmlCache.get(key)
|
||||
}
|
||||
|
||||
// „Prüfe dich"-Antworten ebenfalls cachen — sonst rechnet jeder Scroll-Tick
|
||||
// Markdown+KaTeX für alle sichtbaren Antworten neu (genau der Kostenpfad oben).
|
||||
function htmlAntwort(s, fi) {
|
||||
const key = `a|${s.baustein}|${fi}`
|
||||
if (!htmlCache.has(key)) htmlCache.set(key, render(s.fragen[fi].antwort))
|
||||
return htmlCache.get(key)
|
||||
}
|
||||
|
||||
// Mermaid rendert asynchron ins DOM — nach jedem Einblenden (Scroll/Level/Ansicht)
|
||||
// die noch nicht verarbeiteten ```mermaid-Blöcke im sichtbaren Bereich rendern.
|
||||
async function renderMermaid() {
|
||||
await nextTick()
|
||||
const wurzel = scrollEl.value
|
||||
if (!wurzel) return
|
||||
for (const code of wurzel.querySelectorAll('code.language-mermaid:not([data-mm])')) {
|
||||
code.setAttribute('data-mm', '1')
|
||||
const pre = code.closest('pre')
|
||||
try {
|
||||
const { svg } = await mermaid.render(`mm-${mmId++}`, code.textContent)
|
||||
const figur = document.createElement('div')
|
||||
figur.className = 'mermaid-figur'
|
||||
figur.innerHTML = svg
|
||||
pre?.replaceWith(figur)
|
||||
} catch {
|
||||
pre?.classList.add('mermaid-fehler') // kaputt: als Codeblock stehen lassen
|
||||
}
|
||||
}
|
||||
}
|
||||
watch([aktiv, ansicht, level, daten], renderMermaid, { flush: 'post' })
|
||||
|
||||
// Nur aktives Kapitel ±1 im DOM; der Rest ist ein Platzhalter mit Schätzhöhe
|
||||
const sichtbar = (i) => Math.abs(i - aktiv.value) <= 1
|
||||
|
||||
@@ -49,11 +89,13 @@ function setAnsicht(a) {
|
||||
ansicht.value = a
|
||||
}
|
||||
|
||||
// Nur Durchgänge anbieten, die es in diesem Thema gibt (ohne E- oder
|
||||
// S-Bausteine überspringt der Toggle die Stufe)
|
||||
// Nur Durchgänge anbieten, die WIRKLICH Inhalt haben (≥1 gefüllte Section) —
|
||||
// sonst schaltet der Toggle in einen leeren Durchgang und läuft in eine Sackgasse
|
||||
const levels = computed(() => {
|
||||
const da = new Set((daten.value?.kapitel || []).map((k) => k.level))
|
||||
const folge = ['E', 'M', 'S'].filter((l) => da.has(l))
|
||||
const kaps = daten.value?.kapitel || []
|
||||
const hatInhalt = (l) => kaps.some((k) => k.level === l
|
||||
&& k.sections.some((s) => ((s.lang || '') + (s.kompakt || '')).trim()))
|
||||
const folge = ['E', 'M', 'S'].filter(hatInhalt)
|
||||
return folge.length ? folge : ['E']
|
||||
})
|
||||
watch(levels, (l) => { if (!l.includes(level.value)) level.value = l[0] })
|
||||
@@ -74,7 +116,13 @@ const anzeige = computed(() => {
|
||||
}
|
||||
return out
|
||||
})
|
||||
watch([level, ansicht, daten], () => { kapEls.length = 0; aktiv.value = 0 })
|
||||
watch([level, ansicht, daten], () => {
|
||||
kapEls.length = 0
|
||||
aktiv.value = 0
|
||||
// Scrollposition zurücksetzen — sonst steht der Viewport nach dem Wechsel
|
||||
// mitten in Platzhalter-Divs (echter Inhalt nur oben) und wirkt leer.
|
||||
nextTick(() => { if (scrollEl.value) scrollEl.value.scrollTop = 0 })
|
||||
})
|
||||
|
||||
// Lesezeit des SICHTBAREN Umfangs: 150 Wörter/min (technischer Text)
|
||||
// + 5 s je Display-Formel (Medium ~265 wpm gilt nicht für Mathe)
|
||||
@@ -121,7 +169,7 @@ function onScroll(e) {
|
||||
|
||||
<template>
|
||||
<div class="guide-layout" :class="{ fokus }">
|
||||
<nav v-if="anzeige.length" class="guide-nav">
|
||||
<nav v-if="daten" class="guide-nav">
|
||||
<div class="schalter-zeile">
|
||||
<div class="schalter">
|
||||
<button :class="{ aktiv: ansicht === 'erklaerend' }" title="ausführlich (viel Text)"
|
||||
@@ -154,7 +202,7 @@ function onScroll(e) {
|
||||
<span class="zeit">{{ zeit(stats.je[i]?.minuten || 0) }}</span>
|
||||
</a>
|
||||
</nav>
|
||||
<div class="seite guide-text" @scroll="onScroll">
|
||||
<div ref="scrollEl" class="seite guide-text" @scroll="onScroll">
|
||||
<button v-if="fokus" class="fokus-aus" title="Fokus beenden"
|
||||
@click="fokus = false">✕</button>
|
||||
<template v-if="daten">
|
||||
@@ -175,7 +223,7 @@ function onScroll(e) {
|
||||
<summary>Antworten zu „Prüfe dich"</summary>
|
||||
<div v-for="(f, fi) in s.fragen" :key="fi" class="antwort">
|
||||
<div class="frage">{{ f.frage }}</div>
|
||||
<div v-html="render(f.antwort)"></div>
|
||||
<div v-html="htmlAntwort(s, fi)"></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ const props = defineProps({ run: Object })
|
||||
const daten = ref(null)
|
||||
|
||||
async function laden() {
|
||||
if (!props.run) return
|
||||
if (!props.run) { daten.value = null; return } // sonst bleiben Alt-Daten stehen
|
||||
daten.value = await api.kennzahlen(props.run.id)
|
||||
}
|
||||
watch(() => props.run?.id, laden, { immediate: true })
|
||||
|
||||
@@ -7,6 +7,7 @@ const props = defineProps({ topic: String })
|
||||
const stapel = ref([])
|
||||
const zeigeAntwort = ref(false)
|
||||
const fertigGelernt = ref(0)
|
||||
const sende = ref(false) // Doppelklick-Sperre: sonst POSTet die Karte zweimal
|
||||
|
||||
async function laden() {
|
||||
if (!props.topic) return
|
||||
@@ -17,11 +18,17 @@ async function laden() {
|
||||
watch(() => props.topic, laden, { immediate: true })
|
||||
|
||||
async function antworten(richtig) {
|
||||
const karte = stapel.value[0]
|
||||
await api.antwort(karte.id, richtig)
|
||||
stapel.value = richtig ? stapel.value.slice(1) : [...stapel.value.slice(1), karte]
|
||||
if (richtig) fertigGelernt.value++
|
||||
zeigeAntwort.value = false
|
||||
if (sende.value) return
|
||||
sende.value = true
|
||||
try {
|
||||
const karte = stapel.value[0]
|
||||
await api.antwort(karte.id, richtig)
|
||||
stapel.value = richtig ? stapel.value.slice(1) : [...stapel.value.slice(1), karte]
|
||||
if (richtig) fertigGelernt.value++
|
||||
zeigeAntwort.value = false
|
||||
} finally {
|
||||
sende.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -36,8 +43,10 @@ async function antworten(richtig) {
|
||||
<template v-else>
|
||||
<div class="antwort markdown" v-html="render(stapel[0].inhalt.antwort)"></div>
|
||||
<div style="display: flex; gap: 10px; justify-content: center">
|
||||
<button style="background: var(--gruen)" @click="antworten(true)">Richtig</button>
|
||||
<button style="background: var(--rot)" @click="antworten(false)">Falsch</button>
|
||||
<button style="background: var(--gruen)" :disabled="sende"
|
||||
@click="antworten(true)">Richtig</button>
|
||||
<button style="background: var(--rot)" :disabled="sende"
|
||||
@click="antworten(false)">Falsch</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -27,39 +27,49 @@ function kx(tex, displayMode) {
|
||||
.replace(/\n/g, ' ')
|
||||
}
|
||||
|
||||
function mathe(text) {
|
||||
// $$…$$/\\[…\\] (Display) und $…$/\\(…\\) (Inline) vor dem Markdown-Pass rendern
|
||||
return text
|
||||
.replace(/\$\$([\s\S]+?)\$\$/g, (_, tex) => {
|
||||
try { return kx(tex, true) } catch { return _ }
|
||||
})
|
||||
.replace(/\\\[([\s\S]+?)\\\]/g, (_, tex) => {
|
||||
try { return kx(tex, true) } catch { return _ }
|
||||
})
|
||||
.replace(/\$([^$\n]+?)\$/g, (_, tex) => {
|
||||
try { return kx(tex, false) } catch { return _ }
|
||||
})
|
||||
.replace(/\\\(([\s\S]+?)\\\)/g, (_, tex) => {
|
||||
try { return kx(tex, false) } catch { return _ }
|
||||
})
|
||||
// Formeln VOR dem Markdown-Pass ausheben und durch Platzhalter ersetzen. Sonst
|
||||
// lecken die rohen TeX-Zeichen (*, _, ^) aus den MathML-Annotationen des
|
||||
// gerenderten KaTeX in marked — z. B. zwei „$\Sigma^*$" machen den Text
|
||||
// dazwischen kursiv. Nach dem Markdown-Pass die Formeln wieder einsetzen.
|
||||
function mathAusheben(text) {
|
||||
const formeln = []
|
||||
const platz = (roh, tex, display) => {
|
||||
let html
|
||||
try { html = kx(tex, display) } catch { html = roh } // KaTeX-Fehler: Rohtext behalten
|
||||
formeln.push(html)
|
||||
return `@@MATH${formeln.length - 1}@@`
|
||||
}
|
||||
const t = text
|
||||
.replace(/\$\$([\s\S]+?)\$\$/g, (m, tex) => platz(m, tex, true))
|
||||
.replace(/\\\[([\s\S]+?)\\\]/g, (m, tex) => platz(m, tex, true))
|
||||
.replace(/\$([^$\n]+?)\$/g, (m, tex) => platz(m, tex, false))
|
||||
.replace(/\\\(([\s\S]+?)\\\)/g, (m, tex) => platz(m, tex, false))
|
||||
return { t, formeln }
|
||||
}
|
||||
|
||||
export function render(text) {
|
||||
return DOMPurify.sanitize(marked.parse(mathe(text || '')))
|
||||
const { t, formeln } = mathAusheben(text || '')
|
||||
const html = marked.parse(t).replace(/@@MATH(\d+)@@/g, (_, i) => formeln[Number(i)] ?? '')
|
||||
return DOMPurify.sanitize(html)
|
||||
}
|
||||
|
||||
// Atom-Marker sind eine unsichtbare Backend-Schnittstelle — vor der Anzeige
|
||||
// ausblenden. Die Stufen-Trennung passiert seit dem Umbau über getrennte
|
||||
// Bausteine je Level, nicht mehr über Marker-Filterung im Text.
|
||||
const MARKER = /<!--\s*atom:[^>]*-->/g
|
||||
// ausblenden. non-greedy statt [^>]*, damit ein „>" im Atom-Titel den Match
|
||||
// nicht abschneidet (spiegelt backend/guide.py _MARKER_VOLL).
|
||||
const MARKER = /<!--\s*atom:.*?-->/g
|
||||
|
||||
// Wortzahl + Display-Formeln des sichtbaren Texts (für die Lesezeit-Schätzung)
|
||||
// Wortzahl + Display-Formeln des sichtbaren Texts (für die Lesezeit-Schätzung).
|
||||
// Alle vier Formel-Delimiter wie in mathe() behandeln — sonst zählte eine
|
||||
// \[…\]-Display-Formel als Wörter und die +5-s-Pauschale fiel weg.
|
||||
export function lesestat(text) {
|
||||
let displayFormeln = 0
|
||||
const t = (text || '')
|
||||
.replace(/```mermaid[\s\S]*?```/g, ' ') // Diagramm-Quelltext zählt nicht als Text
|
||||
.replace(MARKER, ' ')
|
||||
.replace(/\$\$[\s\S]+?\$\$/g, () => { displayFormeln += 1; return ' ' })
|
||||
.replace(/\\\[[\s\S]+?\\\]/g, () => { displayFormeln += 1; return ' ' })
|
||||
.replace(/\$[^$\n]+?\$/g, ' Formel ')
|
||||
.replace(/\\\([\s\S]+?\\\)/g, ' Formel ')
|
||||
const woerter = t.split(/\s+/).filter(Boolean).length
|
||||
return { woerter, displayFormeln }
|
||||
}
|
||||
|
||||
@@ -222,7 +222,9 @@ body.nav-zu .hauptbereich { padding-top: 38px; }
|
||||
padding: 16px 22px; margin-bottom: 16px;
|
||||
}
|
||||
.guide-section .ziel { color: var(--akzent); font-size: 13px; }
|
||||
.markdown .katex-display { overflow-x: auto; overflow-y: hidden; padding: 4px 0; }
|
||||
:is(.markdown, .kompakt) .katex-display { overflow-x: auto; overflow-y: hidden; padding: 4px 0; }
|
||||
.mermaid-figur { overflow-x: auto; text-align: center; margin: 16px 0; }
|
||||
.mermaid-figur svg { max-width: 100%; height: auto; }
|
||||
.markdown blockquote {
|
||||
margin: 12px 0; padding: 8px 14px; border-left: 3px solid var(--akzent);
|
||||
background: var(--karte); border-radius: 0 6px 6px 0; color: var(--text);
|
||||
@@ -248,7 +250,7 @@ table.kennzahlen th:first-child, table.kennzahlen td:first-child { text-align: l
|
||||
table.kennzahlen th { color: var(--dim); }
|
||||
|
||||
/* ── Mobil (≤768px): Navigationen als Drawer, Board mit Spalten-Snap ────────── */
|
||||
.markdown .katex { max-width: 100%; overflow-x: auto; overflow-y: hidden; }
|
||||
:is(.markdown, .kompakt) .katex { max-width: 100%; overflow-x: auto; overflow-y: hidden; }
|
||||
@media (max-width: 768px) {
|
||||
.sidebar, .guide-nav {
|
||||
position: fixed; top: 0; bottom: 0; left: 0; z-index: 30;
|
||||
|
||||
Reference in New Issue
Block a user