feat(desktop): embed BoosAPI Desktop client as a subproject
Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as a monorepo subdirectory under desktop/. Source only — node_modules, dist, Cargo target and the upstream .git history are excluded. desktop/ keeps its own independent Cargo workspace (desktop/src-tauri with crates/*); it is not part of the backend boosapi crate, so a `cargo check` at the metapi root still compiles only the backend. Build the desktop app from desktop/src-tauri per desktop/README.md. Note: app icons are still placeholder assets pending the final BoosAPI logo. The updater signing keys live outside the repo and are not included.
This commit is contained in:
@@ -0,0 +1,581 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import MarkdownRender, {
|
||||
clearKaTeXWorker,
|
||||
clearMermaidWorker,
|
||||
createKaTeXWorkerFromCDN,
|
||||
createMermaidWorkerFromCDN,
|
||||
enableKatex,
|
||||
enableMermaid,
|
||||
setKatexLoader,
|
||||
setKaTeXWorker,
|
||||
setMermaidLoader,
|
||||
setMermaidWorker,
|
||||
} from '../../../src/exports'
|
||||
|
||||
type Mode = 'cdn' | 'local'
|
||||
|
||||
const router = useRouter()
|
||||
const mode = useLocalStorage<Mode>('vmr-playground-peers-mode', 'cdn')
|
||||
const isDark = useDark()
|
||||
const toggleTheme = useToggle(isDark)
|
||||
|
||||
const status = ref<'idle' | 'loading' | 'ready' | 'error'>('idle')
|
||||
const statusDetail = ref<string>('')
|
||||
const renderKey = ref(0)
|
||||
|
||||
const hasWindowKatex = computed(() => !!(globalThis as any)?.katex)
|
||||
const hasWindowMermaid = computed(() => !!(globalThis as any)?.mermaid)
|
||||
|
||||
const content = ref<string>(`# CDN peers demo
|
||||
|
||||
This page demonstrates running **KaTeX + Mermaid** with CDN-loaded peers, including worker injection.
|
||||
|
||||
## Math
|
||||
|
||||
Inline: $E=mc^2$, $a^2+b^2=c^2$.
|
||||
|
||||
Block:
|
||||
|
||||
$$
|
||||
\\int_{-\\infty}^{\\infty} e^{-x^2} \\, dx = \\sqrt{\\pi}
|
||||
$$
|
||||
|
||||
## Mermaid
|
||||
|
||||
\`\`\`mermaid
|
||||
sequenceDiagram
|
||||
participant U as User
|
||||
participant W as Worker
|
||||
participant M as Mermaid
|
||||
U->>W: offthread parse
|
||||
W-->>U: ok + prefix
|
||||
U->>M: render
|
||||
\`\`\`
|
||||
`)
|
||||
|
||||
let katexDispose: (() => void) | null = null
|
||||
let mermaidDispose: (() => void) | null = null
|
||||
|
||||
function loadCssOnce(href: string) {
|
||||
if (typeof document === 'undefined')
|
||||
return Promise.resolve()
|
||||
const existing = document.querySelector(`link[rel="stylesheet"][href="${href}"]`) as HTMLLinkElement | null
|
||||
if (existing)
|
||||
return Promise.resolve()
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.href = href
|
||||
link.onload = () => resolve()
|
||||
link.onerror = () => reject(new Error(`Failed to load CSS: ${href}`))
|
||||
document.head.appendChild(link)
|
||||
})
|
||||
}
|
||||
|
||||
const scriptPromises = new Map<string, Promise<void>>()
|
||||
function loadScriptOnce(src: string) {
|
||||
if (typeof document === 'undefined')
|
||||
return Promise.resolve()
|
||||
const existing = document.querySelector(`script[src="${src}"]`) as HTMLScriptElement | null
|
||||
if (existing)
|
||||
return Promise.resolve()
|
||||
const cached = scriptPromises.get(src)
|
||||
if (cached)
|
||||
return cached
|
||||
const p = new Promise<void>((resolve, reject) => {
|
||||
const script = document.createElement('script')
|
||||
script.src = src
|
||||
script.async = true
|
||||
script.crossOrigin = 'anonymous'
|
||||
script.onload = () => resolve()
|
||||
script.onerror = () => reject(new Error(`Failed to load script: ${src}`))
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
scriptPromises.set(src, p)
|
||||
return p
|
||||
}
|
||||
|
||||
async function setupLocal() {
|
||||
// Workers (bundled by Vite)
|
||||
const [{ default: KatexWorker }, { default: MermaidWorker }] = await Promise.all([
|
||||
import('../../../src/workers/katexRenderer.worker?worker&inline'),
|
||||
import('../../../src/workers/mermaidParser.worker?worker&inline'),
|
||||
])
|
||||
setKaTeXWorker(new (KatexWorker as any)())
|
||||
setMermaidWorker(new (MermaidWorker as any)())
|
||||
|
||||
// Default loaders (package-installed peers)
|
||||
enableKatex()
|
||||
enableMermaid()
|
||||
|
||||
// Use CDN CSS to keep this page independent from local node_modules imports.
|
||||
await loadCssOnce('https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css')
|
||||
}
|
||||
|
||||
async function setupCdn() {
|
||||
// Always reset workers & loaders on mode switch (important if imports already failed once)
|
||||
setKatexLoader(() => (globalThis as any)?.katex)
|
||||
setMermaidLoader(() => (globalThis as any)?.mermaid)
|
||||
|
||||
// Load main-thread peers via CDN globals
|
||||
await Promise.all([
|
||||
loadCssOnce('https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css'),
|
||||
loadScriptOnce('https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.js')
|
||||
.then(() => loadScriptOnce('https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/contrib/mhchem.min.js')),
|
||||
loadScriptOnce('https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js'),
|
||||
])
|
||||
|
||||
// Ensure UMD globals are actually available (script load can succeed but global may be missing due to CSP/misconfigured CDN).
|
||||
if (!(globalThis as any)?.katex)
|
||||
throw new Error('KaTeX CDN loaded but window.katex is missing')
|
||||
if (!(globalThis as any)?.mermaid)
|
||||
throw new Error('Mermaid CDN loaded but window.mermaid is missing')
|
||||
|
||||
// Re-enable after scripts are available to guarantee caches are reset.
|
||||
enableKatex(() => (globalThis as any)?.katex)
|
||||
enableMermaid(() => (globalThis as any)?.mermaid)
|
||||
|
||||
// Inject CDN workers (no bundler required)
|
||||
const katex = createKaTeXWorkerFromCDN({
|
||||
mode: 'classic',
|
||||
katexUrl: 'https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.js',
|
||||
mhchemUrl: 'https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/contrib/mhchem.min.js',
|
||||
})
|
||||
katexDispose = katex.dispose
|
||||
if (katex.worker)
|
||||
setKaTeXWorker(katex.worker)
|
||||
|
||||
const mermaid = createMermaidWorkerFromCDN({
|
||||
mode: 'module',
|
||||
mermaidUrl: 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs',
|
||||
})
|
||||
mermaidDispose = mermaid.dispose
|
||||
if (mermaid.worker)
|
||||
setMermaidWorker(mermaid.worker)
|
||||
}
|
||||
|
||||
async function applyMode(next: Mode) {
|
||||
status.value = 'loading'
|
||||
statusDetail.value = ''
|
||||
|
||||
try {
|
||||
// Clean old workers
|
||||
clearKaTeXWorker()
|
||||
clearMermaidWorker()
|
||||
try {
|
||||
katexDispose?.()
|
||||
}
|
||||
catch {}
|
||||
try {
|
||||
mermaidDispose?.()
|
||||
}
|
||||
catch {}
|
||||
katexDispose = null
|
||||
mermaidDispose = null
|
||||
|
||||
if (next === 'cdn')
|
||||
await setupCdn()
|
||||
else
|
||||
await setupLocal()
|
||||
|
||||
status.value = 'ready'
|
||||
statusDetail.value = next === 'cdn'
|
||||
? 'CDN scripts + CDN workers injected.'
|
||||
: 'Local Vite workers injected.'
|
||||
renderKey.value++
|
||||
}
|
||||
catch (e: any) {
|
||||
status.value = 'error'
|
||||
statusDetail.value = e?.message ?? String(e)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => mode.value,
|
||||
(m) => {
|
||||
applyMode(m)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
try {
|
||||
katexDispose?.()
|
||||
}
|
||||
catch {}
|
||||
try {
|
||||
mermaidDispose?.()
|
||||
}
|
||||
catch {}
|
||||
})
|
||||
|
||||
function goHome() {
|
||||
router.push('/').catch(() => {
|
||||
window.location.href = '/'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cdn-peers-page min-h-screen" :class="{ dark: isDark }">
|
||||
<div class="cdn-peers-bg absolute inset-0 -z-10" />
|
||||
|
||||
<div class="mx-auto max-w-5xl px-4 py-5 sm:px-6 sm:py-8 space-y-4 sm:space-y-5">
|
||||
<header class="cdn-peers-header sticky top-3 z-10">
|
||||
<div class="cdn-peers-header-inner flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="cdn-btn" @click="goHome">
|
||||
<Icon icon="carbon:arrow-left" class="h-4 w-4" />
|
||||
Back
|
||||
</button>
|
||||
<div class="flex flex-col leading-tight">
|
||||
<h2 class="text-base sm:text-lg font-semibold text-slate-900 dark:text-slate-50">
|
||||
CDN peers demo
|
||||
</h2>
|
||||
<div class="text-xs text-slate-500 dark:text-slate-400">
|
||||
KaTeX + Mermaid, CDN scripts + CDN workers
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="cdn-btn" @click="toggleTheme()">
|
||||
<Icon :icon="isDark ? 'carbon:moon' : 'carbon:sun'" class="h-4 w-4" />
|
||||
Theme
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="cdn-card">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-slate-600 dark:text-slate-400">Mode</span>
|
||||
<div class="cdn-segment">
|
||||
<label class="cdn-segment-item">
|
||||
<input v-model="mode" type="radio" value="cdn">
|
||||
<span>CDN</span>
|
||||
</label>
|
||||
<label class="cdn-segment-item">
|
||||
<input v-model="mode" type="radio" value="local">
|
||||
<span>Local</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1" />
|
||||
|
||||
<button
|
||||
class="cdn-btn-primary"
|
||||
:disabled="status === 'loading'"
|
||||
@click="applyMode(mode)"
|
||||
>
|
||||
<Icon icon="carbon:reset" class="h-4 w-4" />
|
||||
Re-apply
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div class="cdn-mini">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-600 dark:text-slate-400">
|
||||
Status
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<span
|
||||
class="cdn-badge"
|
||||
:class="status === 'ready'
|
||||
? 'cdn-badge--ok'
|
||||
: status === 'loading'
|
||||
? 'cdn-badge--loading'
|
||||
: status === 'error'
|
||||
? 'cdn-badge--err'
|
||||
: 'cdn-badge--idle'"
|
||||
>
|
||||
{{ status === 'loading' ? 'loading' : status }}
|
||||
</span>
|
||||
<span class="text-sm text-slate-600 dark:text-slate-300 truncate">{{ statusDetail }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cdn-mini">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-600 dark:text-slate-400">
|
||||
Globals
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2 text-sm text-slate-700 dark:text-slate-200">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="text-slate-500 dark:text-slate-400">window.katex</span>
|
||||
<span class="cdn-dot" :class="hasWindowKatex ? 'cdn-dot--ok' : 'cdn-dot--off'" />
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="text-slate-500 dark:text-slate-400">window.mermaid</span>
|
||||
<span class="cdn-dot" :class="hasWindowMermaid ? 'cdn-dot--ok' : 'cdn-dot--off'" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cdn-mini">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-600 dark:text-slate-400">
|
||||
Notes
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-slate-600 dark:text-slate-300">
|
||||
CDN mode uses UMD globals for main-thread rendering and injects CDN-backed workers for KaTeX render + Mermaid parse.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="cdn-card">
|
||||
<div v-if="status !== 'ready'" class="p-3 text-sm text-slate-600 dark:text-slate-300">
|
||||
Waiting for optional peers to be ready…
|
||||
</div>
|
||||
<MarkdownRender
|
||||
v-else
|
||||
:key="renderKey"
|
||||
:content="content"
|
||||
:is-dark="isDark"
|
||||
class="cdn-render p-1 sm:p-2"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cdn-peers-page {
|
||||
color: rgb(15 23 42);
|
||||
color-scheme: light;
|
||||
}
|
||||
.cdn-peers-bg {
|
||||
background:
|
||||
radial-gradient(800px 400px at 15% 0%, rgba(79, 70, 229, 0.18), transparent 55%),
|
||||
radial-gradient(700px 380px at 85% 10%, rgba(56, 189, 248, 0.16), transparent 55%),
|
||||
radial-gradient(700px 420px at 60% 85%, rgba(16, 185, 129, 0.12), transparent 55%),
|
||||
linear-gradient(180deg, rgba(248, 250, 252, 1), rgba(241, 245, 249, 1));
|
||||
}
|
||||
.cdn-peers-page.dark {
|
||||
color: rgb(241 245 249);
|
||||
color-scheme: dark;
|
||||
}
|
||||
.cdn-peers-page.dark .cdn-peers-bg {
|
||||
background:
|
||||
radial-gradient(900px 520px at 12% 0%, rgba(124, 58, 237, 0.22), transparent 60%),
|
||||
radial-gradient(820px 520px at 88% 10%, rgba(14, 165, 233, 0.14), transparent 60%),
|
||||
radial-gradient(760px 560px at 60% 86%, rgba(16, 185, 129, 0.10), transparent 60%),
|
||||
radial-gradient(1200px 600px at 50% 40%, rgba(255, 255, 255, 0.04), transparent 62%),
|
||||
linear-gradient(180deg, rgba(2, 6, 23, 1), rgba(3, 7, 18, 1));
|
||||
}
|
||||
|
||||
.cdn-peers-header-inner {
|
||||
border-radius: 16px;
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
box-shadow:
|
||||
0 10px 30px rgba(15, 23, 42, 0.08),
|
||||
0 2px 10px rgba(15, 23, 42, 0.05);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
.cdn-peers-page.dark .cdn-peers-header-inner {
|
||||
background: rgba(3, 7, 18, 0.60);
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
box-shadow:
|
||||
0 14px 46px rgba(0, 0, 0, 0.42),
|
||||
0 2px 16px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.cdn-card {
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
box-shadow:
|
||||
0 10px 30px rgba(15, 23, 42, 0.07),
|
||||
0 2px 10px rgba(15, 23, 42, 0.04);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.cdn-peers-page.dark .cdn-card {
|
||||
background: rgba(3, 7, 18, 0.62);
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
box-shadow:
|
||||
0 18px 54px rgba(0, 0, 0, 0.45),
|
||||
0 2px 18px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.cdn-mini {
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
background: rgba(248, 250, 252, 0.7);
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
}
|
||||
.cdn-peers-page.dark .cdn-mini {
|
||||
background: rgba(15, 23, 42, 0.62);
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
.cdn-btn,
|
||||
.cdn-btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: transform 120ms ease, background-color 120ms ease, border-color 120ms ease, box-shadow 120ms ease, opacity 120ms ease;
|
||||
user-select: none;
|
||||
}
|
||||
.cdn-btn {
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
color: rgb(15 23 42);
|
||||
}
|
||||
.cdn-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: 0 10px 20px rgba(15, 23, 42, 0.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.cdn-peers-page.dark .cdn-btn {
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
color: rgb(226 232 240);
|
||||
}
|
||||
.cdn-peers-page.dark .cdn-btn:hover {
|
||||
background: rgba(30, 41, 59, 0.66);
|
||||
box-shadow: 0 18px 34px rgba(0, 0, 0, 0.34);
|
||||
}
|
||||
|
||||
.cdn-btn-primary {
|
||||
background: linear-gradient(135deg, rgba(79, 70, 229, 1), rgba(59, 130, 246, 1));
|
||||
color: white;
|
||||
border: 1px solid rgba(59, 130, 246, 0.25);
|
||||
box-shadow: 0 12px 22px rgba(79, 70, 229, 0.22);
|
||||
}
|
||||
.cdn-btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 18px 28px rgba(79, 70, 229, 0.28);
|
||||
}
|
||||
.cdn-btn-primary:disabled {
|
||||
opacity: 0.65;
|
||||
transform: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cdn-segment {
|
||||
display: inline-flex;
|
||||
border-radius: 999px;
|
||||
padding: 4px;
|
||||
background: rgba(241, 245, 249, 0.9);
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
}
|
||||
.cdn-peers-page.dark .cdn-segment {
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
.cdn-segment-item {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: rgb(51 65 85);
|
||||
cursor: pointer;
|
||||
}
|
||||
.cdn-segment-item input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.cdn-segment-item span {
|
||||
opacity: 0.75;
|
||||
}
|
||||
.cdn-segment-item:has(input:checked) {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: rgb(15 23 42);
|
||||
box-shadow: 0 10px 16px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
.cdn-peers-page.dark .cdn-segment-item {
|
||||
color: rgb(226 232 240);
|
||||
}
|
||||
.cdn-peers-page.dark .cdn-segment-item:has(input:checked) {
|
||||
background: rgba(30, 41, 59, 0.78);
|
||||
color: rgb(241 245 249);
|
||||
box-shadow: 0 18px 26px rgba(0, 0, 0, 0.34);
|
||||
}
|
||||
.cdn-segment-item:has(input:checked) span {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.cdn-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.cdn-badge--ok {
|
||||
background: rgba(16, 185, 129, 0.14);
|
||||
color: rgb(5 150 105);
|
||||
border-color: rgba(16, 185, 129, 0.25);
|
||||
}
|
||||
.cdn-peers-page.dark .cdn-badge--ok {
|
||||
background: rgba(16, 185, 129, 0.18);
|
||||
color: rgb(110 231 183);
|
||||
border-color: rgba(16, 185, 129, 0.25);
|
||||
}
|
||||
.cdn-badge--loading {
|
||||
background: rgba(59, 130, 246, 0.14);
|
||||
color: rgb(37 99 235);
|
||||
border-color: rgba(59, 130, 246, 0.24);
|
||||
}
|
||||
.cdn-peers-page.dark .cdn-badge--loading {
|
||||
background: rgba(59, 130, 246, 0.18);
|
||||
color: rgb(147 197 253);
|
||||
border-color: rgba(59, 130, 246, 0.24);
|
||||
}
|
||||
.cdn-badge--err {
|
||||
background: rgba(239, 68, 68, 0.14);
|
||||
color: rgb(220 38 38);
|
||||
border-color: rgba(239, 68, 68, 0.24);
|
||||
}
|
||||
.cdn-peers-page.dark .cdn-badge--err {
|
||||
background: rgba(239, 68, 68, 0.18);
|
||||
color: rgb(252 165 165);
|
||||
border-color: rgba(239, 68, 68, 0.24);
|
||||
}
|
||||
.cdn-badge--idle {
|
||||
background: rgba(148, 163, 184, 0.18);
|
||||
color: rgb(71 85 105);
|
||||
border-color: rgba(148, 163, 184, 0.24);
|
||||
}
|
||||
.cdn-peers-page.dark .cdn-badge--idle {
|
||||
background: rgba(148, 163, 184, 0.16);
|
||||
color: rgb(226 232 240);
|
||||
border-color: rgba(148, 163, 184, 0.20);
|
||||
}
|
||||
|
||||
.cdn-dot {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.45);
|
||||
background: rgba(148, 163, 184, 0.25);
|
||||
}
|
||||
.cdn-dot--ok {
|
||||
border-color: rgba(16, 185, 129, 0.6);
|
||||
background: rgba(16, 185, 129, 0.65);
|
||||
}
|
||||
.cdn-dot--off {
|
||||
border-color: rgba(148, 163, 184, 0.45);
|
||||
background: rgba(148, 163, 184, 0.22);
|
||||
}
|
||||
|
||||
.cdn-render { color: inherit; }
|
||||
</style>
|
||||
@@ -0,0 +1,222 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import MarkdownRender from '../../../src/components/NodeRenderer'
|
||||
|
||||
type Appearance = 'light' | 'dark'
|
||||
|
||||
const isDark = ref(false)
|
||||
|
||||
const themes = ['vitesse-dark', 'vitesse-light']
|
||||
const codeBlockMonacoOptions = computed(() => ({
|
||||
renderSideBySide: true,
|
||||
useInlineViewWhenSpaceIsLimited: false,
|
||||
maxComputationTime: 0,
|
||||
ignoreTrimWhitespace: false,
|
||||
renderIndicators: true,
|
||||
diffAlgorithm: 'legacy',
|
||||
diffLineStyle: 'background',
|
||||
diffUnchangedRegionStyle: 'line-info',
|
||||
diffHideUnchangedRegions: {
|
||||
enabled: true,
|
||||
contextLineCount: 2,
|
||||
minimumLineCount: 3,
|
||||
revealLineCount: 5,
|
||||
},
|
||||
}))
|
||||
|
||||
function buildDiffMarkdown() {
|
||||
const original = [
|
||||
'{',
|
||||
' "name": "markstream-vue",',
|
||||
' "type": "module",',
|
||||
' "version": "0.0.49",',
|
||||
' "packageManager": "pnpm@10.16.1",',
|
||||
' "description": "A Vue 3 component that renders Markdown string content as HTML, supporting custom components and advanced markdown features.",',
|
||||
' "keywords": [',
|
||||
' "vue",',
|
||||
' "markdown",',
|
||||
' "streaming",',
|
||||
' "diff",',
|
||||
' "preview"',
|
||||
' ],',
|
||||
' "exports": {',
|
||||
' ".": "./dist/index.js"',
|
||||
' },',
|
||||
' "peerDependencies": {',
|
||||
' "vue": ">=3.0.0"',
|
||||
' },',
|
||||
' "license": "MIT"',
|
||||
'}',
|
||||
]
|
||||
|
||||
const modified = [...original]
|
||||
modified[3] = ' "version": "0.0.54-beta.1",'
|
||||
modified[5] = ' "description": "A Vue 3 component that renders Markdown string content as HTML, supporting custom components, streaming diffs, and advanced markdown features.",'
|
||||
|
||||
const max = Math.max(original.length, modified.length)
|
||||
const diffLines: string[] = []
|
||||
for (let i = 0; i < max; i++) {
|
||||
const left = original[i]
|
||||
const right = modified[i]
|
||||
if (left === right) {
|
||||
diffLines.push(left ?? '')
|
||||
continue
|
||||
}
|
||||
if (left != null)
|
||||
diffLines.push(`-${left}`)
|
||||
if (right != null)
|
||||
diffLines.push(`+${right}`)
|
||||
}
|
||||
|
||||
return [
|
||||
'# Diff Line-Info Regression',
|
||||
'',
|
||||
'```diff json:package.json',
|
||||
...diffLines,
|
||||
'```',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
const markdown = buildDiffMarkdown()
|
||||
|
||||
function setAppearance(next: Appearance) {
|
||||
isDark.value = next === 'dark'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="theme-regression-page"
|
||||
:class="isDark ? 'theme-regression-page--dark' : 'theme-regression-page--light'"
|
||||
>
|
||||
<div class="theme-regression-shell">
|
||||
<header class="theme-regression-toolbar">
|
||||
<div>
|
||||
<h1>Diff Line-Info Regression</h1>
|
||||
<p>Validates CodeBlockNode light/dark switching with line-info collapsed regions.</p>
|
||||
</div>
|
||||
|
||||
<div class="theme-regression-actions">
|
||||
<button
|
||||
type="button"
|
||||
data-theme-toggle="light"
|
||||
:data-active="String(!isDark)"
|
||||
@click="setAppearance('light')"
|
||||
>
|
||||
Light
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-theme-toggle="dark"
|
||||
:data-active="String(isDark)"
|
||||
@click="setAppearance('dark')"
|
||||
>
|
||||
Dark
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="theme-regression-preview" data-testid="diff-line-info-preview">
|
||||
<MarkdownRender
|
||||
:content="markdown"
|
||||
:is-dark="isDark"
|
||||
:themes="themes"
|
||||
code-block-dark-theme="vitesse-dark"
|
||||
code-block-light-theme="vitesse-light"
|
||||
:code-block-monaco-options="codeBlockMonacoOptions"
|
||||
:code-block-stream="false"
|
||||
:viewport-priority="false"
|
||||
:batch-rendering="false"
|
||||
:typewriter="false"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.theme-regression-page {
|
||||
min-height: 100vh;
|
||||
padding: 32px 20px 48px;
|
||||
transition: background-color 160ms ease, color 160ms ease;
|
||||
}
|
||||
|
||||
.theme-regression-page--light {
|
||||
background: #f5f7fb;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.theme-regression-page--dark {
|
||||
background: #0b1220;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.theme-regression-shell {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.theme-regression-toolbar,
|
||||
.theme-regression-preview {
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
padding: 20px;
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.theme-regression-page--light .theme-regression-toolbar,
|
||||
.theme-regression-page--light .theme-regression-preview {
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
.theme-regression-page--dark .theme-regression-toolbar,
|
||||
.theme-regression-page--dark .theme-regression-preview {
|
||||
background: rgba(15, 23, 42, 0.82);
|
||||
}
|
||||
|
||||
.theme-regression-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.theme-regression-toolbar h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.theme-regression-toolbar p {
|
||||
margin: 0;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.theme-regression-actions {
|
||||
display: inline-flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.theme-regression-actions button {
|
||||
appearance: none;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 10px 18px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.theme-regression-actions button[data-active="true"] {
|
||||
border-color: rgba(56, 189, 248, 0.55);
|
||||
box-shadow: inset 0 0 0 1px rgba(56, 189, 248, 0.28);
|
||||
}
|
||||
|
||||
.theme-regression-preview {
|
||||
padding: 18px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,222 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import MarkdownRender from '../../../src/components/NodeRenderer'
|
||||
|
||||
type Appearance = 'light' | 'dark'
|
||||
|
||||
const isDark = ref(true)
|
||||
|
||||
const themes = ['vitesse-dark', 'vitesse-light']
|
||||
const codeBlockMonacoOptions = computed(() => ({
|
||||
renderSideBySide: true,
|
||||
useInlineViewWhenSpaceIsLimited: false,
|
||||
maxComputationTime: 0,
|
||||
ignoreTrimWhitespace: false,
|
||||
renderIndicators: true,
|
||||
diffAlgorithm: 'legacy',
|
||||
diffLineStyle: 'background',
|
||||
diffUnchangedRegionStyle: 'metadata',
|
||||
diffHideUnchangedRegions: {
|
||||
enabled: true,
|
||||
contextLineCount: 2,
|
||||
minimumLineCount: 3,
|
||||
revealLineCount: 5,
|
||||
},
|
||||
}))
|
||||
|
||||
function buildDiffMarkdown() {
|
||||
const original = [
|
||||
'{',
|
||||
' "name": "markstream-vue",',
|
||||
' "type": "module",',
|
||||
' "version": "0.0.49",',
|
||||
' "packageManager": "pnpm@10.16.1",',
|
||||
' "description": "A Vue 3 component that renders Markdown string content as HTML, supporting custom components and advanced markdown features.",',
|
||||
' "keywords": [',
|
||||
' "vue",',
|
||||
' "markdown",',
|
||||
' "streaming",',
|
||||
' "diff",',
|
||||
' "preview"',
|
||||
' ],',
|
||||
' "exports": {',
|
||||
' ".": "./dist/index.js"',
|
||||
' },',
|
||||
' "peerDependencies": {',
|
||||
' "vue": ">=3.0.0"',
|
||||
' },',
|
||||
' "license": "MIT"',
|
||||
'}',
|
||||
]
|
||||
|
||||
const modified = [...original]
|
||||
modified[3] = ' "version": "0.0.54-beta.1",'
|
||||
modified[5] = ' "description": "A Vue 3 component that renders Markdown string content as HTML, supporting custom components, streaming diffs, and advanced markdown features.",'
|
||||
|
||||
const max = Math.max(original.length, modified.length)
|
||||
const diffLines: string[] = []
|
||||
for (let i = 0; i < max; i++) {
|
||||
const left = original[i]
|
||||
const right = modified[i]
|
||||
if (left === right) {
|
||||
diffLines.push(left ?? '')
|
||||
continue
|
||||
}
|
||||
if (left != null)
|
||||
diffLines.push(`-${left}`)
|
||||
if (right != null)
|
||||
diffLines.push(`+${right}`)
|
||||
}
|
||||
|
||||
return [
|
||||
'# Diff Theme Regression',
|
||||
'',
|
||||
'```diff json:package.json',
|
||||
...diffLines,
|
||||
'```',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
const markdown = buildDiffMarkdown()
|
||||
|
||||
function setAppearance(next: Appearance) {
|
||||
isDark.value = next === 'dark'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="theme-regression-page"
|
||||
:class="isDark ? 'theme-regression-page--dark' : 'theme-regression-page--light'"
|
||||
>
|
||||
<div class="theme-regression-shell">
|
||||
<header class="theme-regression-toolbar">
|
||||
<div>
|
||||
<h1>Diff Theme Regression</h1>
|
||||
<p>Validates CodeBlockNode light/dark switching with metadata collapsed regions.</p>
|
||||
</div>
|
||||
|
||||
<div class="theme-regression-actions">
|
||||
<button
|
||||
type="button"
|
||||
data-theme-toggle="light"
|
||||
:data-active="String(!isDark)"
|
||||
@click="setAppearance('light')"
|
||||
>
|
||||
Light
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-theme-toggle="dark"
|
||||
:data-active="String(isDark)"
|
||||
@click="setAppearance('dark')"
|
||||
>
|
||||
Dark
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="theme-regression-preview" data-testid="diff-theme-preview">
|
||||
<MarkdownRender
|
||||
:content="markdown"
|
||||
:is-dark="isDark"
|
||||
:themes="themes"
|
||||
code-block-dark-theme="vitesse-dark"
|
||||
code-block-light-theme="vitesse-light"
|
||||
:code-block-monaco-options="codeBlockMonacoOptions"
|
||||
:code-block-stream="false"
|
||||
:viewport-priority="false"
|
||||
:batch-rendering="false"
|
||||
:typewriter="false"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.theme-regression-page {
|
||||
min-height: 100vh;
|
||||
padding: 32px 20px 48px;
|
||||
transition: background-color 160ms ease, color 160ms ease;
|
||||
}
|
||||
|
||||
.theme-regression-page--light {
|
||||
background: #f5f7fb;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.theme-regression-page--dark {
|
||||
background: #0b1220;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.theme-regression-shell {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.theme-regression-toolbar,
|
||||
.theme-regression-preview {
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
padding: 20px;
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.theme-regression-page--light .theme-regression-toolbar,
|
||||
.theme-regression-page--light .theme-regression-preview {
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
.theme-regression-page--dark .theme-regression-toolbar,
|
||||
.theme-regression-page--dark .theme-regression-preview {
|
||||
background: rgba(15, 23, 42, 0.82);
|
||||
}
|
||||
|
||||
.theme-regression-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.theme-regression-toolbar h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.theme-regression-toolbar p {
|
||||
margin: 0;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.theme-regression-actions {
|
||||
display: inline-flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.theme-regression-actions button {
|
||||
appearance: none;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 10px 18px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.theme-regression-actions button[data-active="true"] {
|
||||
border-color: rgba(56, 189, 248, 0.55);
|
||||
box-shadow: inset 0 0 0 1px rgba(56, 189, 248, 0.28);
|
||||
}
|
||||
|
||||
.theme-regression-preview {
|
||||
padding: 18px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,643 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { getUseMonaco } from '../../../src/components/CodeBlockNode/monaco'
|
||||
import MarkdownCodeBlockNode from '../../../src/components/MarkdownCodeBlockNode'
|
||||
import MarkdownRender from '../../../src/components/NodeRenderer'
|
||||
import KatexWorker from '../../../src/workers/katexRenderer.worker?worker&inline'
|
||||
import { setKaTeXWorker } from '../../../src/workers/katexWorkerClient'
|
||||
import MermaidWorker from '../../../src/workers/mermaidParser.worker?worker&inline'
|
||||
import { setMermaidWorker } from '../../../src/workers/mermaidWorkerClient'
|
||||
import 'katex/dist/katex.min.css'
|
||||
|
||||
// Workers
|
||||
setKaTeXWorker(() => new KatexWorker())
|
||||
setMermaidWorker(() => new MermaidWorker())
|
||||
getUseMonaco()
|
||||
|
||||
const isDark = ref(
|
||||
typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches,
|
||||
)
|
||||
// Sync initial state to DOM
|
||||
if (isDark.value)
|
||||
document.documentElement.classList.add('dark')
|
||||
|
||||
function toggleDark() {
|
||||
isDark.value = !isDark.value
|
||||
document.documentElement.classList.toggle('dark', isDark.value)
|
||||
}
|
||||
|
||||
const activeTheme = ref('')
|
||||
const themeList = [
|
||||
'',
|
||||
'airbnb',
|
||||
'airtable',
|
||||
'apple',
|
||||
'bmw',
|
||||
'cal',
|
||||
'claude',
|
||||
'clay',
|
||||
'clickhouse',
|
||||
'cohere',
|
||||
'coinbase',
|
||||
'composio',
|
||||
'cursor',
|
||||
'elevenlabs',
|
||||
'expo',
|
||||
'figma',
|
||||
'framer',
|
||||
'hashicorp',
|
||||
'ibm',
|
||||
'intercom',
|
||||
'kraken',
|
||||
'linear',
|
||||
'lovable',
|
||||
'minimax',
|
||||
'mintlify',
|
||||
'miro',
|
||||
'mistral',
|
||||
'mongodb',
|
||||
'notion',
|
||||
'nvidia',
|
||||
'ollama',
|
||||
'opencode-ai',
|
||||
'pinterest',
|
||||
'posthog',
|
||||
'raycast',
|
||||
'replicate',
|
||||
'resend',
|
||||
'revolut',
|
||||
'runwayml',
|
||||
'sanity',
|
||||
'sentry',
|
||||
'spacex',
|
||||
'spotify',
|
||||
'stripe',
|
||||
'supabase',
|
||||
'superhuman',
|
||||
'together-ai',
|
||||
'uber',
|
||||
'vercel',
|
||||
'voltagent',
|
||||
'warp',
|
||||
'webflow',
|
||||
'wise',
|
||||
'x-ai',
|
||||
'zapier',
|
||||
]
|
||||
|
||||
// ── Color palette data ──
|
||||
const palette = [
|
||||
{
|
||||
title: 'Neutral',
|
||||
tokens: [
|
||||
{ name: 'background', bg: '--ms-background', fg: '--ms-foreground' },
|
||||
{ name: 'muted', bg: '--ms-muted', fg: '--ms-muted-foreground' },
|
||||
{ name: 'secondary', bg: '--ms-secondary', fg: '--ms-secondary-foreground' },
|
||||
{ name: 'accent', bg: '--ms-accent', fg: '--ms-accent-foreground' },
|
||||
{ name: 'border', bg: '--ms-border', fg: '--ms-foreground' },
|
||||
{ name: 'muted-fg', bg: '--ms-muted-foreground', fg: '--ms-background' },
|
||||
{ name: 'foreground', bg: '--ms-foreground', fg: '--ms-background' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Signal',
|
||||
tokens: [
|
||||
{ name: 'info', bg: '--ms-info', fg: '--ms-info-foreground' },
|
||||
{ name: 'success', bg: '--ms-success', fg: '--ms-success-foreground' },
|
||||
{ name: 'warning', bg: '--ms-warning', fg: '--ms-warning-foreground' },
|
||||
{ name: 'destructive', bg: '--ms-destructive', fg: '--ms-destructive-foreground' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Content',
|
||||
tokens: [
|
||||
{ name: 'highlight', bg: '--ms-highlight', fg: '--ms-highlight-foreground' },
|
||||
{ name: 'diff-added', bg: '--ms-diff-added', fg: '--ms-background' },
|
||||
{ name: 'diff-removed', bg: '--ms-diff-removed', fg: '--ms-background' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// Shiki code block node (lightweight, no Monaco)
|
||||
const shikiNode = {
|
||||
type: 'code_block' as const,
|
||||
language: 'typescript',
|
||||
code: `import { createApp } from 'vue'
|
||||
import { VueRendererMarkdown } from 'markstream-vue'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(VueRendererMarkdown, {
|
||||
iconTheme: 'material',
|
||||
})
|
||||
app.mount('#app')`,
|
||||
raw: '',
|
||||
}
|
||||
|
||||
const presets = [
|
||||
{ id: 'default', label: 'Default', desc: 'Polished Editorial — 16px, 1.75 line-height, warm reading rhythm' },
|
||||
{ id: 'compact', label: 'Compact', desc: 'AI chat / dashboard — 14px, 1.5 line-height, tight spacing' },
|
||||
] as const
|
||||
|
||||
const activePreset = ref<string>('default')
|
||||
|
||||
// Static markdown covering all component types
|
||||
const content = `# Heading Level 1
|
||||
|
||||
## Heading Level 2
|
||||
|
||||
### Heading Level 3
|
||||
|
||||
#### Heading Level 4
|
||||
|
||||
##### Heading Level 5
|
||||
|
||||
###### Heading Level 6
|
||||
|
||||
---
|
||||
|
||||
## Paragraphs & Inline Elements
|
||||
|
||||
This is a regular paragraph with **bold text**, *italic text*, ~~strikethrough~~, ==highlighted text==, \`inline code\`, and a [link to GitHub](https://github.com). Here is a footnote reference[^1].
|
||||
|
||||
Text with <sub>subscript</sub> and <sup>superscript</sup> and <ins>inserted text</ins>.
|
||||
|
||||
Another paragraph to show the vertical rhythm between blocks. Good typography creates a sense of order and hierarchy. The spacing between paragraphs should feel intentional — neither too tight nor too loose. Line height matters just as much as font size.
|
||||
|
||||
---
|
||||
|
||||
## Inline Code
|
||||
|
||||
Use \`npm install\` to install dependencies. The \`--save-dev\` flag marks it as a dev dependency.
|
||||
|
||||
In TypeScript, use \`Record<string, unknown>\` for generic object types. The \`Partial<T>\` utility makes all properties optional. You can combine them: \`Partial<Record<string, unknown>>\`.
|
||||
|
||||
The config file exports a \`defineConfig()\` function. Set \`mode: 'production'\` for optimized builds. Environment variables are accessed via \`import.meta.env.VITE_API_URL\`.
|
||||
|
||||
---
|
||||
|
||||
## Blockquote
|
||||
|
||||
> This is a blockquote. It can contain **bold**, *italic*, and \`code\`.
|
||||
>
|
||||
> > Nested blockquotes are also supported.
|
||||
|
||||
---
|
||||
|
||||
## Lists
|
||||
|
||||
### Unordered List
|
||||
|
||||
- First item
|
||||
- Second item with **bold**
|
||||
- Nested item A
|
||||
- Nested item B
|
||||
- Deep nested
|
||||
- Third item
|
||||
|
||||
### Ordered List
|
||||
|
||||
1. First step
|
||||
2. Second step
|
||||
1. Sub-step 2.1
|
||||
2. Sub-step 2.2
|
||||
3. Third step
|
||||
|
||||
### Task List
|
||||
|
||||
- [x] Completed task
|
||||
- [ ] Incomplete task
|
||||
- [x] Another done task
|
||||
|
||||
---
|
||||
|
||||
## Code Blocks
|
||||
|
||||
### JavaScript
|
||||
|
||||
\`\`\`javascript
|
||||
function fibonacci(n) {
|
||||
if (n <= 1) return n;
|
||||
let a = 0, b = 1;
|
||||
for (let i = 2; i <= n; i++) {
|
||||
[a, b] = [b, a + b];
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
console.log(fibonacci(10)); // 55
|
||||
\`\`\`
|
||||
|
||||
### TypeScript with Title
|
||||
|
||||
\`\`\`typescript title="utils/theme.ts"
|
||||
interface ThemeConfig {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
background: string;
|
||||
foreground: string;
|
||||
}
|
||||
|
||||
export function createTheme(config: Partial<ThemeConfig>): ThemeConfig {
|
||||
return {
|
||||
primary: '#0366d6',
|
||||
secondary: '#586069',
|
||||
background: '#ffffff',
|
||||
foreground: '#24292e',
|
||||
...config,
|
||||
};
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### Shell
|
||||
|
||||
\`\`\`bash
|
||||
npm install markstream-vue
|
||||
pnpm dev
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## Table
|
||||
|
||||
### Feature Status
|
||||
|
||||
| Feature | Status | Notes |
|
||||
|---------|--------|-------|
|
||||
| Color tokens | Done | 25 components migrated |
|
||||
| Dark mode | Done | Bridge tokens + \`.dark\` class |
|
||||
| Spacing tokens | Done | Flow, inset, gap, size tokens |
|
||||
| Shell extraction | Done | \`CodeBlockShell.vue\` |
|
||||
| Visual redesign | Not started | Core **#343** deliverable |
|
||||
|
||||
### Performance Benchmarks
|
||||
|
||||
| Metric | v1.0 | v2.0 | Change |
|
||||
|--------|-----:|-----:|-------:|
|
||||
| Bundle size (gzip) | 48.2 KB | 32.7 KB | −32% |
|
||||
| First render | 120 ms | 45 ms | −62% |
|
||||
| Re-render | 18 ms | 6 ms | −67% |
|
||||
| Memory usage | 12.4 MB | 8.1 MB | −35% |
|
||||
| Lighthouse score | 72 | 94 | +31% |
|
||||
| Test coverage | 68% | 91% | +34% |
|
||||
| Components | 18 | 25 | +39% |
|
||||
| CSS tokens | 0 | 82 | — |
|
||||
|
||||
### Compact Table
|
||||
|
||||
| Key | Value |
|
||||
|-----|-------|
|
||||
| License | MIT |
|
||||
| Runtime | Vue 3 |
|
||||
|
||||
---
|
||||
|
||||
## Admonitions
|
||||
|
||||
::: note
|
||||
This is a note admonition. Useful for additional information.
|
||||
:::
|
||||
|
||||
::: tip
|
||||
This is a tip. Helpful advice for the reader.
|
||||
:::
|
||||
|
||||
::: warning
|
||||
This is a warning. Pay attention to this.
|
||||
:::
|
||||
|
||||
::: danger
|
||||
This is a danger notice. Something could go wrong.
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## Math
|
||||
|
||||
### Inline Math
|
||||
|
||||
The quadratic formula is $x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$ and Euler's identity: $e^{i\\pi} + 1 = 0$.
|
||||
|
||||
### Block Math
|
||||
|
||||
$$
|
||||
\\int_{-\\infty}^{\\infty} e^{-x^2} dx = \\sqrt{\\pi}
|
||||
$$
|
||||
|
||||
---
|
||||
|
||||
## Images
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Mermaid Diagram
|
||||
|
||||
\`\`\`mermaid
|
||||
graph LR
|
||||
A[Token System] --> B[Bridge Tokens]
|
||||
A --> C[Semantic Tokens]
|
||||
B --> D[shadcn compat]
|
||||
B --> E[Fallback defaults]
|
||||
C --> F[Component styles]
|
||||
F --> G[Light/Dark auto]
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## Infographic
|
||||
|
||||
\`\`\`infographic
|
||||
infographic list-row-simple-horizontal-arrow
|
||||
data
|
||||
items
|
||||
- label Components
|
||||
desc 25 migrated to tokens
|
||||
- label Tokens
|
||||
desc 82 semantic variables
|
||||
- label Icon Theme
|
||||
desc Material default
|
||||
- label Test Pass
|
||||
desc 1029 passing
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## Definition List
|
||||
|
||||
Term 1
|
||||
: Definition for term 1. This explains what term 1 means.
|
||||
|
||||
Term 2
|
||||
: Definition for term 2. Can contain **formatting**.
|
||||
|
||||
---
|
||||
|
||||
## Footnotes
|
||||
|
||||
[^1]: This is the footnote content. It appears at the bottom.
|
||||
|
||||
---
|
||||
|
||||
*End of static example — compare typography presets using the buttons above.*
|
||||
`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="markstream-vue min-h-screen bg-[hsl(var(--ms-background))] text-[hsl(var(--ms-foreground))] transition-colors"
|
||||
:class="[activePreset, { dark: isDark }]"
|
||||
:data-theme="activeTheme || undefined"
|
||||
>
|
||||
<header class="sticky top-0 z-50 flex flex-wrap items-center gap-3 px-6 py-2.5 border-b border-[hsl(var(--ms-border))] bg-[hsl(var(--ms-background))] backdrop-blur-sm">
|
||||
<span class="text-sm font-semibold mr-2">Typography</span>
|
||||
<button
|
||||
v-for="preset in presets"
|
||||
:key="preset.id"
|
||||
class="px-2.5 py-1 text-xs rounded-md border cursor-pointer transition-colors"
|
||||
:class="activePreset === preset.id
|
||||
? 'bg-[hsl(var(--ms-primary))] text-[hsl(var(--ms-primary-foreground))] border-transparent'
|
||||
: 'bg-transparent text-inherit border-[hsl(var(--ms-border))] hover:bg-[hsl(var(--ms-accent))]'"
|
||||
:title="preset.desc"
|
||||
@click="activePreset = preset.id"
|
||||
>
|
||||
{{ preset.label }}
|
||||
</button>
|
||||
<div class="ml-auto flex items-center gap-3">
|
||||
<select
|
||||
v-model="activeTheme"
|
||||
class="px-2 py-1 text-xs border border-[hsl(var(--ms-border))] rounded-md bg-[hsl(var(--ms-background))] text-inherit cursor-pointer"
|
||||
>
|
||||
<option value="">
|
||||
Default
|
||||
</option>
|
||||
<option v-for="t in themeList.filter(Boolean)" :key="t" :value="t">
|
||||
{{ t.charAt(0).toUpperCase() + t.slice(1).replace(/-/g, ' ') }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
class="px-2.5 py-1 text-xs border border-[hsl(var(--ms-border))] rounded-md bg-transparent text-inherit cursor-pointer hover:bg-[hsl(var(--ms-accent))] transition-colors"
|
||||
@click="toggleDark"
|
||||
>
|
||||
{{ isDark ? 'Light' : 'Dark' }}
|
||||
</button>
|
||||
<router-link to="/" class="text-xs text-[var(--link-color)] no-underline hover:underline">
|
||||
← Playground
|
||||
</router-link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="example-content mx-auto px-6 py-8">
|
||||
<div class="mb-4 text-xs text-[hsl(var(--ms-muted-foreground))]">
|
||||
<strong>{{ presets.find(p => p.id === activePreset)?.label }}</strong> — {{ presets.find(p => p.id === activePreset)?.desc }}
|
||||
</div>
|
||||
|
||||
<!-- Color Palette -->
|
||||
<section class="palette-section">
|
||||
<h2 class="palette-title">
|
||||
Color Palette
|
||||
</h2>
|
||||
<div v-for="group in palette" :key="group.title" class="palette-group">
|
||||
<h3 class="palette-group-title">
|
||||
{{ group.title }}
|
||||
</h3>
|
||||
<div class="palette-row">
|
||||
<div v-for="token in group.tokens" :key="token.name" class="palette-item">
|
||||
<div
|
||||
class="palette-swatch"
|
||||
:style="{
|
||||
backgroundColor: `hsl(var(${token.bg}))`,
|
||||
color: `hsl(var(${token.fg}))`,
|
||||
}"
|
||||
>
|
||||
Aa
|
||||
</div>
|
||||
<span class="palette-label">{{ token.name }}</span>
|
||||
<span class="palette-var">{{ token.bg.replace('--ms-', '') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr class="palette-divider">
|
||||
|
||||
<MarkdownRender
|
||||
:content="content"
|
||||
:is-dark="isDark"
|
||||
:loading="false"
|
||||
:stream="false"
|
||||
:data-theme="activeTheme || undefined"
|
||||
:code-block-props="{
|
||||
showHeader: true,
|
||||
showCopyButton: true,
|
||||
showCollapseButton: true,
|
||||
showExpandButton: true,
|
||||
}"
|
||||
/>
|
||||
|
||||
<!-- Shiki-rendered code block (lightweight, no Monaco) -->
|
||||
<h2 class="text-xl font-semibold mt-8 mb-4">
|
||||
Shiki Code Block
|
||||
</h2>
|
||||
<MarkdownCodeBlockNode
|
||||
:node="shikiNode"
|
||||
:is-dark="isDark"
|
||||
:loading="false"
|
||||
:stream="false"
|
||||
index-key="shiki-demo"
|
||||
:show-header="true"
|
||||
:show-copy-button="true"
|
||||
:show-collapse-button="true"
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/*
|
||||
* "default" uses the library's built-in token defaults (Polished Editorial).
|
||||
* "compact" overrides tokens for high-density AI chat / dashboard use.
|
||||
*/
|
||||
|
||||
/* ================================================================
|
||||
COMPACT — AI chat / dashboard / sidebar
|
||||
~60% of default spacing. Tighter typography and rhythm.
|
||||
================================================================ */
|
||||
.markstream-vue.compact,
|
||||
.compact .markstream-vue {
|
||||
/* Typography — smaller, tighter */
|
||||
--ms-text-body: 0.875rem;
|
||||
--ms-leading-body: 1.5;
|
||||
--ms-text-h1: 1.375rem;
|
||||
--ms-text-h2: 1.125rem;
|
||||
--ms-text-h3: 1rem;
|
||||
--ms-text-h4: 0.875rem;
|
||||
--ms-text-h5: 0.8125rem;
|
||||
--ms-text-h6: 0.8125rem;
|
||||
--ms-text-label: 0.6875rem;
|
||||
--ms-action-btn-padding: 0.25rem;
|
||||
--ms-action-btn-icon: 0.75rem;
|
||||
|
||||
/* Heading rhythm — compressed */
|
||||
--ms-flow-heading-1-mt: 0;
|
||||
--ms-flow-heading-1-mb: 0.3em;
|
||||
--ms-flow-heading-2-mt: 1.25em;
|
||||
--ms-flow-heading-2-mb: 0.25em;
|
||||
--ms-flow-heading-3-mt: 1em;
|
||||
--ms-flow-heading-3-mb: 0.2em;
|
||||
--ms-flow-heading-4-mt: 0.8em;
|
||||
--ms-flow-heading-4-mb: 0.15em;
|
||||
--ms-flow-heading-5-mt: 0.6em;
|
||||
--ms-flow-heading-5-mb: 0.1em;
|
||||
--ms-flow-heading-6-mt: 0.5em;
|
||||
--ms-flow-heading-6-mb: 0.1em;
|
||||
|
||||
/* Flow spacing — tighter */
|
||||
--ms-flow-paragraph-y: 0.625em;
|
||||
--ms-flow-list-y: 0.5em;
|
||||
--ms-flow-list-item-y: 0.125em;
|
||||
--ms-flow-list-indent: 1.25em;
|
||||
--ms-flow-table-y: 0.625em;
|
||||
--ms-flow-table-cell: 0.2em 0.4em;
|
||||
--ms-flow-blockquote-y: 0.625em;
|
||||
--ms-flow-blockquote-indent: 0.75em;
|
||||
--ms-flow-admonition-y: 0.5em;
|
||||
--ms-flow-footnote-y: 0.25em;
|
||||
--ms-flow-hr-y: 1.25em;
|
||||
--ms-flow-diagram-y: 0.625em;
|
||||
--ms-flow-codeblock-y: 0.625em;
|
||||
--ms-flow-definition-term-mt: 0.25em;
|
||||
--ms-flow-definition-desc-ml: 0.75em;
|
||||
--ms-flow-definition-desc-mb: 0.25em;
|
||||
|
||||
/* Panel insets — smaller */
|
||||
--ms-inset-panel-x: 0.5rem;
|
||||
--ms-inset-panel-y: 0.25rem;
|
||||
--ms-inset-panel-body-sm: 0.125rem;
|
||||
--ms-inset-panel-body: 0.5rem;
|
||||
--ms-inset-admonition-body-top: 0.25rem;
|
||||
--ms-inset-admonition-body-bottom: 0.375rem;
|
||||
|
||||
/* Gaps — tighter */
|
||||
--ms-gap-header: 0.5rem;
|
||||
--ms-gap-header-main: 0.375rem;
|
||||
--ms-gap-header-actions: 0.25rem;
|
||||
|
||||
/* Sizes — smaller */
|
||||
--ms-size-diagram-min-height: 280px;
|
||||
--ms-size-code-max-height: 420px;
|
||||
--ms-size-skeleton-min-height: 80px;
|
||||
|
||||
/* Animation — faster */
|
||||
--ms-duration-standard: 140ms;
|
||||
--ms-duration-overlay: 160ms;
|
||||
}
|
||||
|
||||
/* Compact content width: full width for demo */
|
||||
.compact .example-content { max-width: 100%; padding: 1rem; }
|
||||
|
||||
/* ── Color Palette ── */
|
||||
.palette-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.palette-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.25rem;
|
||||
color: hsl(var(--ms-foreground));
|
||||
}
|
||||
.palette-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.palette-group-title {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: hsl(var(--ms-muted-foreground));
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.palette-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.palette-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 4.5rem;
|
||||
}
|
||||
.palette-swatch {
|
||||
width: 4.5rem;
|
||||
height: 3.25rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid hsl(var(--ms-border));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
font-family: var(--ms-font-sans);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.palette-swatch:hover {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
.palette-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
margin-top: 0.375rem;
|
||||
color: hsl(var(--ms-foreground));
|
||||
}
|
||||
.palette-var {
|
||||
font-size: 0.5625rem;
|
||||
color: hsl(var(--ms-muted-foreground));
|
||||
font-family: var(--ms-font-mono);
|
||||
}
|
||||
.palette-divider {
|
||||
border: none;
|
||||
border-top: 1px solid hsl(var(--ms-border));
|
||||
margin: 2rem 0;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,902 @@
|
||||
<script setup lang="ts">
|
||||
import type { StreamSliceMode } from '../composables/createLocalTextStream'
|
||||
import type { StreamPresetId } from '../composables/streamPresets'
|
||||
import type { StreamTransportMode } from '../composables/useStreamSimulator'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getUseMonaco } from '../../../src/components/CodeBlockNode/monaco'
|
||||
import MarkdownCodeBlockNode from '../../../src/components/MarkdownCodeBlockNode'
|
||||
import MarkdownRender from '../../../src/components/NodeRenderer'
|
||||
import { setCustomComponents } from '../../../src/utils/nodeComponents'
|
||||
import KatexWorker from '../../../src/workers/katexRenderer.worker?worker&inline'
|
||||
import { setKaTeXWorker } from '../../../src/workers/katexWorkerClient'
|
||||
import MermaidWorker from '../../../src/workers/mermaidParser.worker?worker&inline'
|
||||
import { setMermaidWorker } from '../../../src/workers/mermaidWorkerClient'
|
||||
import ThinkingNode from '../components/ThinkingNode.vue'
|
||||
import { CUSTOM_STREAM_PRESET_ID, findMatchingStreamPreset, getStreamPreset, STREAM_PRESETS } from '../composables/streamPresets'
|
||||
import { clampStreamControl, normalizeStreamRange, useStreamSimulator } from '../composables/useStreamSimulator'
|
||||
import { streamContent } from '../const/markdown'
|
||||
import 'katex/dist/katex.min.css'
|
||||
|
||||
const streamChunkDelayMin = useLocalStorage<number>('vmr-settings-stream-delay-min', 14)
|
||||
const streamChunkDelayMax = useLocalStorage<number>('vmr-settings-stream-delay-max', 34)
|
||||
const streamChunkSizeMin = useLocalStorage<number>('vmr-settings-stream-chunk-size-min', 2)
|
||||
const streamChunkSizeMax = useLocalStorage<number>('vmr-settings-stream-chunk-size-max', 7)
|
||||
const streamBurstiness = useLocalStorage<number>('vmr-settings-stream-burstiness', 35)
|
||||
const streamTransportMode = useLocalStorage<StreamTransportMode>('vmr-settings-stream-transport-mode', 'readable-stream')
|
||||
const streamSliceMode = useLocalStorage<StreamSliceMode>('vmr-settings-stream-slice-mode', 'pure-random')
|
||||
const normalizedChunkDelayRange = computed(() => normalizeStreamRange(
|
||||
Number(streamChunkDelayMin.value),
|
||||
Number(streamChunkDelayMax.value),
|
||||
8,
|
||||
240,
|
||||
14,
|
||||
34,
|
||||
))
|
||||
const normalizedChunkSizeRange = computed(() => normalizeStreamRange(
|
||||
Number(streamChunkSizeMin.value),
|
||||
Number(streamChunkSizeMax.value),
|
||||
1,
|
||||
24,
|
||||
2,
|
||||
7,
|
||||
))
|
||||
const normalizedBurstiness = computed(() => Math.round(clampStreamControl(Number(streamBurstiness.value), 0, 100, 35)))
|
||||
const activeStreamPreset = computed(() => findMatchingStreamPreset({
|
||||
chunkDelayMin: normalizedChunkDelayRange.value.min,
|
||||
chunkDelayMax: normalizedChunkDelayRange.value.max,
|
||||
chunkSizeMin: normalizedChunkSizeRange.value.min,
|
||||
chunkSizeMax: normalizedChunkSizeRange.value.max,
|
||||
burstiness: normalizedBurstiness.value,
|
||||
}))
|
||||
const selectedStreamPresetId = computed<StreamPresetId>({
|
||||
get: () => activeStreamPreset.value?.id ?? CUSTOM_STREAM_PRESET_ID,
|
||||
set: (presetId) => {
|
||||
if (presetId === CUSTOM_STREAM_PRESET_ID)
|
||||
return
|
||||
|
||||
const preset = getStreamPreset(presetId)
|
||||
if (!preset)
|
||||
return
|
||||
|
||||
streamChunkDelayMin.value = preset.chunkDelayMin
|
||||
streamChunkDelayMax.value = preset.chunkDelayMax
|
||||
streamChunkSizeMin.value = preset.chunkSizeMin
|
||||
streamChunkSizeMax.value = preset.chunkSizeMax
|
||||
streamBurstiness.value = preset.burstiness
|
||||
},
|
||||
})
|
||||
const streamPresetDescription = computed(() => activeStreamPreset.value?.description ?? 'Custom min/max window with your own burst profile.')
|
||||
const streamChunkRangeLabel = computed(() => `${normalizedChunkSizeRange.value.min}-${normalizedChunkSizeRange.value.max}`)
|
||||
const streamDelayRangeLabel = computed(() => `${normalizedChunkDelayRange.value.min}-${normalizedChunkDelayRange.value.max}ms`)
|
||||
const {
|
||||
content,
|
||||
isPaused,
|
||||
isStreaming,
|
||||
start: startStreamSimulation,
|
||||
stop: stopStreamSimulation,
|
||||
togglePause: toggleStreamPause,
|
||||
} = useStreamSimulator({
|
||||
source: streamContent,
|
||||
chunkSizeMin: computed(() => normalizedChunkSizeRange.value.min),
|
||||
chunkSizeMax: computed(() => normalizedChunkSizeRange.value.max),
|
||||
chunkDelayMin: computed(() => normalizedChunkDelayRange.value.min),
|
||||
chunkDelayMax: computed(() => normalizedChunkDelayRange.value.max),
|
||||
burstiness: computed(() => normalizedBurstiness.value / 100),
|
||||
sliceMode: streamSliceMode,
|
||||
transportMode: streamTransportMode,
|
||||
})
|
||||
|
||||
// 预加载 Monaco 编辑器
|
||||
getUseMonaco()
|
||||
setKaTeXWorker(new KatexWorker())
|
||||
setMermaidWorker(new MermaidWorker())
|
||||
const router = useRouter()
|
||||
|
||||
function goToTest() {
|
||||
// Prefer router navigation, fallback to full redirect if it fails.
|
||||
router.push('/test').catch(() => {
|
||||
window.location.href = '/test'
|
||||
})
|
||||
}
|
||||
|
||||
function goToCdnPeers() {
|
||||
router.push('/cdn-peers').catch(() => {
|
||||
window.location.href = '/cdn-peers'
|
||||
})
|
||||
}
|
||||
|
||||
// Keep persisted values within reasonable bounds on hydration.
|
||||
watchEffect(() => {
|
||||
if (streamChunkDelayMin.value !== normalizedChunkDelayRange.value.min)
|
||||
streamChunkDelayMin.value = normalizedChunkDelayRange.value.min
|
||||
if (streamChunkDelayMax.value !== normalizedChunkDelayRange.value.max)
|
||||
streamChunkDelayMax.value = normalizedChunkDelayRange.value.max
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
if (streamChunkSizeMin.value !== normalizedChunkSizeRange.value.min)
|
||||
streamChunkSizeMin.value = normalizedChunkSizeRange.value.min
|
||||
if (streamChunkSizeMax.value !== normalizedChunkSizeRange.value.max)
|
||||
streamChunkSizeMax.value = normalizedChunkSizeRange.value.max
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
const parsedBurstiness = Number(streamBurstiness.value)
|
||||
const fallbackBurstiness = Number.isFinite(parsedBurstiness) ? parsedBurstiness : 35
|
||||
const boundedBurstiness = Math.round(clampStreamControl(fallbackBurstiness, 0, 100, 35))
|
||||
if (streamBurstiness.value !== boundedBurstiness)
|
||||
streamBurstiness.value = boundedBurstiness
|
||||
})
|
||||
|
||||
setCustomComponents('playground-demo', { thinking: ThinkingNode, code_block: MarkdownCodeBlockNode })
|
||||
const parseOptions = {
|
||||
preTransformTokens: (tokens: any[]) => {
|
||||
// Example: Log tokens during parsing
|
||||
// console.log('Pre-transform tokens:', tokens)
|
||||
return tokens.map((token) => {
|
||||
if (token.type === 'inline' && token.content.includes('<thinking')) {
|
||||
token.children = token.children.map((t: any) => {
|
||||
if (t.type === 'html_block' && t.tag === 'thinking') {
|
||||
const m = t.content.match(/<thinking([^>]*)>/)
|
||||
const attrs = []
|
||||
if (m) {
|
||||
const attrString = m[1]
|
||||
const attrRegex = /([^\s=]+)(?:="([^"]*)")?/g
|
||||
let match
|
||||
while ((match = attrRegex.exec(attrString)) !== null) {
|
||||
const attrName = match[1]
|
||||
const attrValue = match[2] || true
|
||||
attrs.push({ name: attrName, value: attrValue })
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line regexp/no-super-linear-backtracking
|
||||
const content = t.content.replace(/<thinking[^>]*>/, '').replace(/<\/*t*h*i*n*k*i*n*g*>*\n*$/, '')
|
||||
return {
|
||||
type: 'thinking',
|
||||
loading: t.loading,
|
||||
attrs,
|
||||
content,
|
||||
}
|
||||
}
|
||||
return t
|
||||
})
|
||||
}
|
||||
return token
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// 主题切换
|
||||
const isDark = useDark()
|
||||
const toggleTheme = useToggle(isDark)
|
||||
// Code block theme selector (single dropdown)
|
||||
const themes = [
|
||||
'andromeeda',
|
||||
'aurora-x',
|
||||
'ayu-dark',
|
||||
'catppuccin-frappe',
|
||||
'catppuccin-latte',
|
||||
'catppuccin-macchiato',
|
||||
'catppuccin-mocha',
|
||||
'dark-plus',
|
||||
'dracula',
|
||||
'dracula-soft',
|
||||
'everforest-dark',
|
||||
'everforest-light',
|
||||
'github-dark',
|
||||
'github-dark-default',
|
||||
'github-dark-dimmed',
|
||||
'github-dark-high-contrast',
|
||||
'github-light',
|
||||
'github-light-default',
|
||||
'github-light-high-contrast',
|
||||
'gruvbox-dark-hard',
|
||||
'gruvbox-dark-medium',
|
||||
'gruvbox-dark-soft',
|
||||
'gruvbox-light-hard',
|
||||
'gruvbox-light-medium',
|
||||
'gruvbox-light-soft',
|
||||
'houston',
|
||||
'kanagawa-dragon',
|
||||
'kanagawa-lotus',
|
||||
'kanagawa-wave',
|
||||
'laserwave',
|
||||
'light-plus',
|
||||
'material-theme',
|
||||
'material-theme-darker',
|
||||
'material-theme-lighter',
|
||||
'material-theme-ocean',
|
||||
'material-theme-palenight',
|
||||
'min-dark',
|
||||
'min-light',
|
||||
'monokai',
|
||||
'night-owl',
|
||||
'nord',
|
||||
'one-dark-pro',
|
||||
'one-light',
|
||||
'plastic',
|
||||
'poimandres',
|
||||
'red',
|
||||
'rose-pine',
|
||||
'rose-pine-dawn',
|
||||
'rose-pine-moon',
|
||||
'slack-dark',
|
||||
'slack-ochin',
|
||||
'snazzy-light',
|
||||
'solarized-dark',
|
||||
'solarized-light',
|
||||
'synthwave-84',
|
||||
'tokyo-night',
|
||||
'vesper',
|
||||
'vitesse-black',
|
||||
'vitesse-dark',
|
||||
'vitesse-light',
|
||||
]
|
||||
const selectedTheme = useLocalStorage<string>('vmr-settings-selected-theme', 'vitesse-dark')
|
||||
|
||||
// 格式化主题名称显示
|
||||
function formatThemeName(themeName: string) {
|
||||
return themeName
|
||||
.split('-')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
// 设置面板显示状态
|
||||
const showSettings = ref(false)
|
||||
|
||||
// Use reversed column layout and let the browser handle native scrolling.
|
||||
// Removed custom JS scroll management (observers, programmatic scroll, and
|
||||
// pointer/wheel/touch heuristics) to rely on CSS `flex-direction: column-reverse`.
|
||||
const messagesContainer = ref<HTMLElement | null>(null)
|
||||
|
||||
// 性能友好的监听:使用 ResizeObserver 监听容器和渲染内容变化,
|
||||
// 当内容高度超过容器可见高度时,在滚动容器上添加 `disable-min-height` 类以移除渲染器的 min-height。
|
||||
// 注意:不要直接对 `.markdown-renderer` 做 `classList.add()`,因为它的 class 由 Vue patch,
|
||||
// 在切换模式/主题等触发更新时会被覆盖,导致 `disable-min-height` 丢失。
|
||||
let __roContainer: ResizeObserver | null = null
|
||||
let __roContent: ResizeObserver | null = null
|
||||
let __mo: MutationObserver | null = null
|
||||
let __scheduled = false
|
||||
// Observers and scheduler
|
||||
|
||||
function scheduleCheckMinHeight() {
|
||||
if (__scheduled)
|
||||
return
|
||||
__scheduled = true
|
||||
requestAnimationFrame(() => {
|
||||
__scheduled = false
|
||||
const container = messagesContainer.value
|
||||
if (!container)
|
||||
return
|
||||
container.classList.add('disable-min-height')
|
||||
const containerDelta = container.scrollHeight - container.clientHeight
|
||||
const shouldRemove = containerDelta > 1
|
||||
if (shouldRemove) {
|
||||
// 内容已超出:不再需要继续监听,断开所有 observer 以节省开销
|
||||
try {
|
||||
__roContainer?.disconnect()
|
||||
__roContent?.disconnect()
|
||||
__mo?.disconnect()
|
||||
}
|
||||
finally {
|
||||
__roContainer = null
|
||||
__roContent = null
|
||||
__mo = null
|
||||
}
|
||||
}
|
||||
else {
|
||||
container.classList.remove('disable-min-height')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
startStreamSimulation()
|
||||
// 初始检查和观察
|
||||
const container = messagesContainer.value
|
||||
if (!container)
|
||||
return
|
||||
// 初次判断(确保组件渲染完)
|
||||
requestAnimationFrame(scheduleCheckMinHeight)
|
||||
|
||||
// 观察容器尺寸变化(窗口大小、面板大小)
|
||||
__roContainer = new ResizeObserver(scheduleCheckMinHeight)
|
||||
__roContainer.observe(container)
|
||||
|
||||
// 观察渲染内容尺寸变化(markdown 内容动态变化)
|
||||
const tryObserveContent = () => {
|
||||
const el = container.querySelector('.markdown-renderer') as HTMLElement | null
|
||||
if (el) {
|
||||
if (__roContent)
|
||||
__roContent.disconnect()
|
||||
__roContent = new ResizeObserver(scheduleCheckMinHeight)
|
||||
__roContent.observe(el)
|
||||
}
|
||||
}
|
||||
tryObserveContent()
|
||||
|
||||
// 如果 MarkdownRender 在后续替换了子节点,使用 MutationObserver 重新 attach
|
||||
__mo = new MutationObserver(() => {
|
||||
tryObserveContent()
|
||||
scheduleCheckMinHeight()
|
||||
})
|
||||
__mo.observe(container, { childList: true, subtree: true })
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopStreamSimulation()
|
||||
__roContainer?.disconnect()
|
||||
__roContent?.disconnect()
|
||||
__mo?.disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-center p-4 app-container h-full bg-gray-50 dark:bg-gray-900">
|
||||
<!-- 设置按钮和面板 -->
|
||||
<div class="fixed top-4 right-4 z-10">
|
||||
<!-- 设置按钮 -->
|
||||
<button
|
||||
class="
|
||||
settings-toggle w-10 h-10 rounded-full
|
||||
bg-white/95 dark:bg-gray-800/95
|
||||
backdrop-blur-md border border-gray-200/50 dark:border-gray-700/50
|
||||
hover:bg-gray-50 dark:hover:bg-gray-700/50
|
||||
shadow-lg dark:shadow-gray-900/20
|
||||
transition-all duration-200 flex items-center justify-center
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500/50
|
||||
"
|
||||
:class="{ 'ring-2 ring-blue-500/50': showSettings }"
|
||||
@click="showSettings = !showSettings"
|
||||
>
|
||||
<Icon
|
||||
icon="carbon:settings"
|
||||
class="w-5 h-5 text-gray-600 dark:text-gray-400 transition-transform duration-200"
|
||||
:class="{ 'rotate-90': showSettings }"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- 设置面板 -->
|
||||
<Transition
|
||||
enter-active-class="transition ease-out duration-300"
|
||||
enter-from-class="opacity-0 scale-95 translate-y-2"
|
||||
enter-to-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-active-class="transition ease-in duration-200"
|
||||
leave-from-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-to-class="opacity-0 scale-95 translate-y-2"
|
||||
>
|
||||
<div
|
||||
v-if="showSettings"
|
||||
class="
|
||||
absolute top-12 right-0 mt-2
|
||||
bg-white/95 dark:bg-gray-800/95
|
||||
backdrop-blur-md border border-gray-200/50 dark:border-gray-700/50
|
||||
rounded-xl shadow-xl dark:shadow-gray-900/30
|
||||
p-4 space-y-4 min-w-[220px]
|
||||
origin-top-right
|
||||
"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 主题选择器 -->
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">
|
||||
Code Theme
|
||||
</label>
|
||||
<div class="relative">
|
||||
<select
|
||||
v-model="selectedTheme"
|
||||
class="
|
||||
w-full appearance-none px-3 py-2 pr-8
|
||||
bg-gray-50 dark:bg-gray-700/50
|
||||
border border-gray-200 dark:border-gray-600
|
||||
rounded-lg text-sm font-medium
|
||||
text-gray-900 dark:text-gray-100
|
||||
hover:bg-gray-100 dark:hover:bg-gray-700
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500
|
||||
transition-all duration-200 cursor-pointer
|
||||
"
|
||||
aria-label="Code block theme"
|
||||
@click.stop
|
||||
@change.stop
|
||||
>
|
||||
<option v-for="t in themes" :key="t" :value="t">
|
||||
{{ formatThemeName(t) }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<Icon
|
||||
icon="carbon:chevron-down"
|
||||
class="w-4 h-4 text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 流式速度控制 -->
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">
|
||||
Stream Profile
|
||||
</label>
|
||||
<div class="relative">
|
||||
<select
|
||||
v-model="selectedStreamPresetId"
|
||||
class="
|
||||
w-full appearance-none px-3 py-2 pr-8
|
||||
bg-gray-50 dark:bg-gray-700/50
|
||||
border border-gray-200 dark:border-gray-600
|
||||
rounded-lg text-sm font-medium
|
||||
text-gray-900 dark:text-gray-100
|
||||
hover:bg-gray-100 dark:hover:bg-gray-700
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500
|
||||
transition-all duration-200 cursor-pointer
|
||||
"
|
||||
>
|
||||
<option v-for="preset in STREAM_PRESETS" :key="preset.id" :value="preset.id">
|
||||
{{ preset.label }}
|
||||
</option>
|
||||
<option :value="CUSTOM_STREAM_PRESET_ID">
|
||||
Custom
|
||||
</option>
|
||||
</select>
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<Icon
|
||||
icon="carbon:chevron-down"
|
||||
class="w-4 h-4 text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[11px] leading-5 text-gray-500 dark:text-gray-400">
|
||||
{{ streamPresetDescription }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">
|
||||
Transport
|
||||
</label>
|
||||
<div class="relative">
|
||||
<select
|
||||
v-model="streamTransportMode"
|
||||
class="
|
||||
w-full appearance-none px-3 py-2 pr-8
|
||||
bg-gray-50 dark:bg-gray-700/50
|
||||
border border-gray-200 dark:border-gray-600
|
||||
rounded-lg text-sm font-medium
|
||||
text-gray-900 dark:text-gray-100
|
||||
hover:bg-gray-100 dark:hover:bg-gray-700
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500
|
||||
transition-all duration-200 cursor-pointer
|
||||
"
|
||||
>
|
||||
<option value="readable-stream">
|
||||
ReadableStream
|
||||
</option>
|
||||
<option value="scheduler">
|
||||
Scheduler
|
||||
</option>
|
||||
</select>
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<Icon
|
||||
icon="carbon:chevron-down"
|
||||
class="w-4 h-4 text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">
|
||||
Slice Mode
|
||||
</label>
|
||||
<div class="relative">
|
||||
<select
|
||||
v-model="streamSliceMode"
|
||||
class="
|
||||
w-full appearance-none px-3 py-2 pr-8
|
||||
bg-gray-50 dark:bg-gray-700/50
|
||||
border border-gray-200 dark:border-gray-600
|
||||
rounded-lg text-sm font-medium
|
||||
text-gray-900 dark:text-gray-100
|
||||
hover:bg-gray-100 dark:hover:bg-gray-700
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500
|
||||
transition-all duration-200 cursor-pointer
|
||||
"
|
||||
>
|
||||
<option value="pure-random">
|
||||
Pure Random
|
||||
</option>
|
||||
<option value="boundary-aware">
|
||||
Boundary Aware
|
||||
</option>
|
||||
</select>
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<Icon
|
||||
icon="carbon:chevron-down"
|
||||
class="w-4 h-4 text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">
|
||||
chunkDelayMin
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
v-model.number="streamChunkDelayMin"
|
||||
type="range"
|
||||
min="8"
|
||||
max="240"
|
||||
step="4"
|
||||
class="flex-1 cursor-pointer"
|
||||
>
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 w-14 text-right">
|
||||
{{ normalizedChunkDelayRange.min }}ms
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">
|
||||
chunkDelayMax
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
v-model.number="streamChunkDelayMax"
|
||||
type="range"
|
||||
min="8"
|
||||
max="240"
|
||||
step="4"
|
||||
class="flex-1 cursor-pointer"
|
||||
>
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 w-14 text-right">
|
||||
{{ normalizedChunkDelayRange.max }}ms
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">
|
||||
chunkSizeMin
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
v-model.number="streamChunkSizeMin"
|
||||
type="range"
|
||||
min="1"
|
||||
max="24"
|
||||
step="1"
|
||||
class="flex-1 cursor-pointer"
|
||||
>
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 w-14 text-right">
|
||||
{{ normalizedChunkSizeRange.min }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">
|
||||
chunkSizeMax
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
v-model.number="streamChunkSizeMax"
|
||||
type="range"
|
||||
min="1"
|
||||
max="24"
|
||||
step="1"
|
||||
class="flex-1 cursor-pointer"
|
||||
>
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 w-14 text-right">
|
||||
{{ normalizedChunkSizeRange.max }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">
|
||||
Burstiness
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
v-model.number="streamBurstiness"
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="1"
|
||||
class="flex-1 cursor-pointer"
|
||||
>
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 w-12 text-right">
|
||||
{{ normalizedBurstiness }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-[11px] leading-5 text-gray-500 dark:text-gray-400">
|
||||
Active window: {{ streamChunkRangeLabel }} chars and {{ streamDelayRangeLabel }}. When min=max, the cadence becomes fixed.
|
||||
</p>
|
||||
|
||||
<p class="text-[11px] leading-5 text-gray-500 dark:text-gray-400">
|
||||
`Pure Random` uses raw random `slice`; `Boundary Aware` snaps toward word and punctuation boundaries. `ReadableStream` is closest to the real reader path.
|
||||
</p>
|
||||
|
||||
<!-- 分割线 -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700" />
|
||||
|
||||
<!-- 主题切换 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">
|
||||
Dark Mode
|
||||
</label>
|
||||
<button
|
||||
class="
|
||||
relative w-12 h-6 rounded-full
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500/50
|
||||
hover:shadow-lg active:scale-95
|
||||
transition-all duration-200 ease-out
|
||||
"
|
||||
:style="{
|
||||
backgroundColor: isDark ? '#3b82f6' : '#e5e7eb',
|
||||
transition: 'background-color 0.35s ease-out, box-shadow 0.2s ease, transform 0.1s ease',
|
||||
}"
|
||||
@click.stop="toggleTheme()"
|
||||
>
|
||||
<!-- 滑动圆点 -->
|
||||
<div
|
||||
class="
|
||||
absolute top-0.5 w-5 h-5 bg-white rounded-full
|
||||
flex items-center justify-center
|
||||
shadow-md hover:shadow-lg
|
||||
"
|
||||
:style="{
|
||||
left: isDark ? '26px' : '2px',
|
||||
transform: `scale(${isDark ? 1.02 : 1})`,
|
||||
transition: 'left 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94), transform 0.2s ease-out, box-shadow 0.2s ease',
|
||||
}"
|
||||
>
|
||||
<!-- 图标根据状态显示 -->
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
enter-from-class="opacity-0 scale-0 rotate-90"
|
||||
enter-to-class="opacity-100 scale-100 rotate-0"
|
||||
leave-from-class="opacity-100 scale-100 rotate-0"
|
||||
leave-to-class="opacity-0 scale-0 rotate-90"
|
||||
mode="out-in"
|
||||
>
|
||||
<Icon
|
||||
v-if="isDark"
|
||||
key="moon"
|
||||
icon="carbon:moon"
|
||||
class="w-3 h-3 text-blue-600 drop-shadow-sm"
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
key="sun"
|
||||
icon="carbon:sun"
|
||||
class="w-3 h-3 text-yellow-500 drop-shadow-sm"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Chatbot-style container -->
|
||||
<div class="chatbot-container max-w-5xl w-full bg-white dark:bg-gray-800 rounded-2xl shadow-2xl dark:shadow-gray-900/50 flex flex-col overflow-hidden border border-gray-200 dark:border-gray-700">
|
||||
<!-- Header -->
|
||||
<div class="chatbot-header px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gradient-to-r from-blue-50 to-purple-50 dark:from-gray-800 dark:to-gray-800">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center shadow-lg">
|
||||
<Icon icon="carbon:chat" class="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold text-gray-800 dark:text-gray-100">
|
||||
markstream-vue
|
||||
</h1>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Streaming markdown demo
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<!-- GitHub Star Button -->
|
||||
<a
|
||||
href="https://github.com/Simon-He95/markstream-vue"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="
|
||||
github-star-btn flex items-center gap-2 px-3 py-1.5
|
||||
bg-gray-800 dark:bg-gray-700 hover:bg-gray-700 dark:hover:bg-gray-600
|
||||
text-white text-sm font-medium rounded-lg
|
||||
transition-all duration-200
|
||||
shadow-md hover:shadow-lg
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500/50
|
||||
"
|
||||
>
|
||||
<Icon icon="carbon:star" class="w-4 h-4" />
|
||||
<span>Star</span>
|
||||
</a>
|
||||
|
||||
<!-- Test Page Button -->
|
||||
<button
|
||||
class="ml-2 flex items-center gap-2 px-3 py-1.5 bg-amber-500 hover:bg-amber-400 disabled:bg-gray-200 dark:disabled:bg-gray-700 text-white disabled:text-gray-500 dark:disabled:text-gray-400 text-sm font-medium rounded-lg transition-all duration-200 shadow-md disabled:shadow-none focus:outline-none focus:ring-2 focus:ring-blue-500/50 disabled:cursor-not-allowed"
|
||||
:disabled="!isStreaming"
|
||||
:title="isPaused ? 'Resume streaming' : 'Pause streaming'"
|
||||
@click="toggleStreamPause"
|
||||
>
|
||||
<Icon :icon="isPaused ? 'carbon:play-filled-alt' : 'carbon:pause-filled'" class="w-4 h-4" />
|
||||
<span>{{ isPaused ? 'Resume' : 'Pause' }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="ml-2 test-page-btn flex items-center gap-2 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium rounded-lg transition-all duration-200 shadow-md focus:outline-none focus:ring-2 focus:ring-blue-500/50"
|
||||
title="Go to Test page"
|
||||
@click="goToTest"
|
||||
>
|
||||
<Icon icon="carbon:rocket" class="w-4 h-4" />
|
||||
<span>Test</span>
|
||||
</button>
|
||||
|
||||
<!-- CDN peers demo -->
|
||||
<button
|
||||
class="ml-2 flex items-center gap-2 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition-all duration-200 shadow-md focus:outline-none focus:ring-2 focus:ring-blue-500/50"
|
||||
title="Go to CDN peers demo"
|
||||
@click="goToCdnPeers"
|
||||
>
|
||||
<Icon icon="carbon:cloud" class="w-4 h-4" />
|
||||
<span>CDN</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages area with scroll (use column-reverse on the scroll container) -->
|
||||
<main ref="messagesContainer" class="chatbot-messages flex-1 overflow-y-auto mr-[1px] mb-4 flex flex-col-reverse">
|
||||
<MarkdownRender
|
||||
:content="content"
|
||||
:code-block-dark-theme="selectedTheme || undefined"
|
||||
:code-block-light-theme="selectedTheme || undefined"
|
||||
:themes="themes"
|
||||
:custom-html-tags="['thinking']"
|
||||
:is-dark="isDark"
|
||||
:parse-options="parseOptions"
|
||||
custom-id="playground-demo"
|
||||
class="p-6"
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-container {
|
||||
transition: background-color 0.3s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chatbot-container {
|
||||
transition: all 0.3s ease;
|
||||
overscroll-behavior: contain;
|
||||
height: calc(var(--app-viewport-vh, 1vh) * 100 - 2rem);
|
||||
max-height: calc(var(--app-viewport-vh, 1vh) * 100 - 2rem);
|
||||
}
|
||||
|
||||
.github-star-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.chatbot-messages {
|
||||
scroll-behavior: smooth;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.chatbot-messages > .markdown-renderer {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* 当真实内容高度超出容器时,移除默认 min-height(由 JS 切换类名) */
|
||||
.chatbot-messages.disable-min-height > .markdown-renderer {
|
||||
min-height: unset !important;
|
||||
}
|
||||
|
||||
.chatbot-messages::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.chatbot-messages::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chatbot-messages::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.dark .chatbot-messages::-webkit-scrollbar-thumb {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
.chatbot-messages::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
.dark .chatbot-messages::-webkit-scrollbar-thumb:hover {
|
||||
background: #64748b;
|
||||
}
|
||||
|
||||
.settings-toggle {
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.settings-toggle:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* 主题选择器自定义样式 */
|
||||
.theme-selector select:focus {
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.theme-selector select option {
|
||||
padding: 8px 12px;
|
||||
background-color: white;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.dark .theme-selector select option {
|
||||
background-color: #1f2937;
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
/* 设置面板动画 */
|
||||
.settings-panel {
|
||||
transform-origin: top right;
|
||||
}
|
||||
|
||||
/* 代码块加载时的流光闪烁效果 */
|
||||
:deep(.code-block-container.is-rendering) {
|
||||
position: relative;
|
||||
animation: renderingGlow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes renderingGlow {
|
||||
0% {
|
||||
box-shadow:
|
||||
0 0 10px rgba(59, 130, 246, 0.4),
|
||||
0 0 20px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
25% {
|
||||
box-shadow:
|
||||
0 0 15px rgba(139, 92, 246, 0.5),
|
||||
0 0 30px rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 0 20px rgba(236, 72, 153, 0.5),
|
||||
0 0 40px rgba(236, 72, 153, 0.3);
|
||||
}
|
||||
75% {
|
||||
box-shadow:
|
||||
0 0 15px rgba(16, 185, 129, 0.5),
|
||||
0 0 30px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
100% {
|
||||
box-shadow:
|
||||
0 0 10px rgba(59, 130, 246, 0.4),
|
||||
0 0 20px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Mermaid 块加载时的流光闪烁效果 */
|
||||
:deep(.is-rendering) {
|
||||
position: relative;
|
||||
animation: renderingGlow 2s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import { h, ref } from 'vue'
|
||||
import MermaidBlockNode from '../../../src/components/MermaidBlockNode'
|
||||
import MarkdownRender from '../../../src/components/NodeRenderer'
|
||||
import { setCustomComponents } from '../../../src/utils/nodeComponents'
|
||||
import MermaidWorker from '../../../src/workers/mermaidParser.worker?worker&inline'
|
||||
import { setMermaidWorker } from '../../../src/workers/mermaidWorkerClient'
|
||||
|
||||
// Ensure mermaid worker is registered (playground environment)
|
||||
setMermaidWorker(new MermaidWorker())
|
||||
|
||||
const uploaded = ref<string | null>(null)
|
||||
const uploading = ref(false)
|
||||
|
||||
function fakeUpload(_svg: string) {
|
||||
uploading.value = true
|
||||
// Simulate an async upload and return a fake URL
|
||||
return new Promise<string>((resolve) => {
|
||||
setTimeout(() => {
|
||||
uploading.value = false
|
||||
resolve(`https://example.com/uploads/mermaid-${Date.now()}.svg`)
|
||||
}, 700)
|
||||
})
|
||||
}
|
||||
|
||||
async function handleExport(ev: any) {
|
||||
// Prevent default export (component would otherwise download)
|
||||
ev.preventDefault()
|
||||
const svgString = ev.svgString ?? (ev.svgElement ? new XMLSerializer().serializeToString(ev.svgElement) : null)
|
||||
if (!svgString) {
|
||||
console.warn('No svg payload available on export event')
|
||||
return
|
||||
}
|
||||
const url = await fakeUpload(svgString)
|
||||
uploaded.value = url
|
||||
}
|
||||
|
||||
// Register a scoped override so MarkdownRender will use our custom onExport handler
|
||||
setCustomComponents('playground-demo-export', {
|
||||
mermaid: (props: any) => h(MermaidBlockNode, {
|
||||
...props,
|
||||
onExport: handleExport,
|
||||
}),
|
||||
})
|
||||
|
||||
// small mermaid block content (note: use a normal JS string here)
|
||||
const md = ref('```mermaid\ngraph LR\n A[User] --> B[Server]\n B --> C[Storage]\n```')
|
||||
|
||||
// reference MarkdownRender to satisfy linters that may require script usage
|
||||
void MarkdownRender
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 max-w-3xl mx-auto">
|
||||
<h2 class="text-xl font-semibold mb-4">
|
||||
Mermaid export override demo
|
||||
</h2>
|
||||
|
||||
<p class="mb-3 text-sm text-gray-600">
|
||||
This demo overrides the internal Mermaid renderer inside MarkdownRender and intercepts the <code>export</code> event. The example uses <code>ev.svgString</code> (provided by the component) and simulates uploading it to a server.
|
||||
</p>
|
||||
|
||||
<div class="mb-4 border rounded p-4 bg-white">
|
||||
<MarkdownRender custom-id="playground-demo-export" :content="md" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-sm">
|
||||
<div v-if="uploading" class="text-blue-700">
|
||||
Uploading SVG…
|
||||
</div>
|
||||
<div v-else-if="uploaded">
|
||||
Uploaded → <a :href="uploaded" target="_blank" class="text-indigo-600 hover:underline">{{ uploaded }}</a>
|
||||
</div>
|
||||
<div v-else class="text-gray-500">
|
||||
Try the 'Export' button in the block's header to upload (intercepted).
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
pre { white-space: pre-wrap }
|
||||
</style>
|
||||
@@ -0,0 +1,543 @@
|
||||
<script setup lang="ts">
|
||||
import type { SandboxSelection } from '../../../playground-shared/versionSandbox'
|
||||
import { computed, createApp as createVue3RuntimeApp, onBeforeUnmount, onMounted, ref, h as vue3H } from 'vue'
|
||||
import { decodeMarkdownHash } from '../../../playground-shared/testPageState'
|
||||
import { parseSandboxSelection } from '../../../playground-shared/versionSandbox'
|
||||
import {
|
||||
TEST_SANDBOX_KATEX_VERSION,
|
||||
TEST_SANDBOX_MERMAID_VERSION,
|
||||
testSandboxFrameworks,
|
||||
} from '../testSandboxConfig'
|
||||
import '../../../packages/markstream-angular/src/index.css'
|
||||
|
||||
type SandboxStatus = 'idle' | 'loading' | 'ready' | 'error'
|
||||
|
||||
const canvasRef = ref<HTMLElement | null>(null)
|
||||
const status = ref<SandboxStatus>('idle')
|
||||
const selection = ref<SandboxSelection>(parseSandboxSelection(window.location.search, testSandboxFrameworks))
|
||||
const markdown = ref(decodeMarkdownHash(window.location.hash || '') ?? '')
|
||||
const errorMessage = ref('')
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
if (status.value === 'loading')
|
||||
return '加载中'
|
||||
if (status.value === 'error')
|
||||
return '加载失败'
|
||||
if (status.value === 'ready')
|
||||
return 'Ready'
|
||||
return '待命'
|
||||
})
|
||||
|
||||
const selectionSummary = computed(() => {
|
||||
const current = selection.value
|
||||
return `${current.framework.label} / ${current.source === 'workspace' ? 'workspace' : 'npm'} / ${current.version}`
|
||||
})
|
||||
|
||||
let cleanupMountedRuntime: (() => void) | null = null
|
||||
let renderToken = 0
|
||||
|
||||
function setHeadLink(id: string, href: string | null) {
|
||||
const existing = document.head.querySelector<HTMLLinkElement>(`link[data-sandbox-style="${id}"]`)
|
||||
|
||||
if (!href) {
|
||||
existing?.remove()
|
||||
return
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
if (existing.href !== href)
|
||||
existing.href = href
|
||||
return
|
||||
}
|
||||
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.href = href
|
||||
link.dataset.sandboxStyle = id
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
|
||||
function encodeNpmVersion(version: string) {
|
||||
return encodeURIComponent(version.trim() || 'latest')
|
||||
}
|
||||
|
||||
function createEsmPackageUrl(packageName: string, version: string, deps: string[] = []) {
|
||||
const query = new URLSearchParams()
|
||||
if (deps.length)
|
||||
query.set('deps', deps.join(','))
|
||||
const suffix = query.toString()
|
||||
return `https://esm.sh/${packageName}@${encodeNpmVersion(version)}${suffix ? `?${suffix}` : ''}`
|
||||
}
|
||||
|
||||
function createEsmPackageSubpathUrl(
|
||||
packageName: string,
|
||||
version: string,
|
||||
subpath: string,
|
||||
deps: string[] = [],
|
||||
) {
|
||||
const base = createEsmPackageUrl(packageName, version, deps)
|
||||
const [prefix, query] = base.split('?')
|
||||
return `${prefix}/${subpath}${query ? `?${query}` : ''}`
|
||||
}
|
||||
|
||||
function createJsDelivrCssUrl(packageName: string, version: string) {
|
||||
return `https://cdn.jsdelivr.net/npm/${packageName}@${encodeNpmVersion(version)}/dist/index.css`
|
||||
}
|
||||
|
||||
function applySandboxStyles(current: SandboxSelection) {
|
||||
setHeadLink(
|
||||
'renderer',
|
||||
current.source === 'npm'
|
||||
? createJsDelivrCssUrl(current.framework.packageName, current.version)
|
||||
: null,
|
||||
)
|
||||
setHeadLink(
|
||||
'katex',
|
||||
`https://cdn.jsdelivr.net/npm/katex@${TEST_SANDBOX_KATEX_VERSION}/dist/katex.min.css`,
|
||||
)
|
||||
}
|
||||
|
||||
async function importRemote<T = any>(url: string): Promise<T> {
|
||||
return await import(/* @vite-ignore */ url) as T
|
||||
}
|
||||
|
||||
function cleanupRuntime() {
|
||||
cleanupMountedRuntime?.()
|
||||
cleanupMountedRuntime = null
|
||||
if (canvasRef.value)
|
||||
canvasRef.value.innerHTML = ''
|
||||
}
|
||||
|
||||
function setupCommonRendererRuntime(rendererModule: Record<string, any>) {
|
||||
rendererModule.enableKatex?.()
|
||||
rendererModule.enableMermaid?.()
|
||||
|
||||
const katexHandle = rendererModule.createKaTeXWorkerFromCDN?.({
|
||||
mode: 'classic',
|
||||
katexUrl: `https://cdn.jsdelivr.net/npm/katex@${TEST_SANDBOX_KATEX_VERSION}/dist/katex.min.js`,
|
||||
mhchemUrl: `https://cdn.jsdelivr.net/npm/katex@${TEST_SANDBOX_KATEX_VERSION}/dist/contrib/mhchem.min.js`,
|
||||
})
|
||||
if (katexHandle?.worker)
|
||||
rendererModule.setKaTeXWorker?.(katexHandle.worker)
|
||||
|
||||
const mermaidHandle = rendererModule.createMermaidWorkerFromCDN?.({
|
||||
mode: 'module',
|
||||
workerOptions: { type: 'module' },
|
||||
mermaidUrl: `https://cdn.jsdelivr.net/npm/mermaid@${TEST_SANDBOX_MERMAID_VERSION}/dist/mermaid.esm.min.mjs`,
|
||||
})
|
||||
if (mermaidHandle?.worker)
|
||||
rendererModule.setMermaidWorker?.(mermaidHandle.worker)
|
||||
|
||||
return () => {
|
||||
rendererModule.clearKaTeXWorker?.()
|
||||
rendererModule.clearMermaidWorker?.()
|
||||
katexHandle?.dispose?.()
|
||||
mermaidHandle?.dispose?.()
|
||||
}
|
||||
}
|
||||
|
||||
async function mountVue3Sandbox(current: SandboxSelection, content: string, mountPoint: HTMLElement) {
|
||||
const rendererModule = current.source === 'workspace'
|
||||
? await import('markstream-vue')
|
||||
: await importRemote(createEsmPackageUrl(
|
||||
current.framework.packageName,
|
||||
current.version,
|
||||
[`vue@${current.framework.runtimeVersion}`],
|
||||
))
|
||||
const vueModule = current.source === 'workspace'
|
||||
? null
|
||||
: await importRemote<any>(createEsmPackageUrl('vue', current.framework.runtimeVersion))
|
||||
|
||||
const teardownRuntime = setupCommonRendererRuntime(rendererModule)
|
||||
const MarkdownRender = rendererModule.default || rendererModule.MarkdownRender
|
||||
const createApp = current.source === 'workspace'
|
||||
? createVue3RuntimeApp
|
||||
: (vueModule?.createApp || vueModule?.default?.createApp)
|
||||
const h = current.source === 'workspace'
|
||||
? vue3H
|
||||
: (vueModule?.h || vueModule?.default?.h)
|
||||
|
||||
if (!MarkdownRender || !createApp || !h)
|
||||
throw new Error('无法找到 Vue 3 渲染器入口。')
|
||||
|
||||
const host = document.createElement('div')
|
||||
host.className = 'sandbox-canvas__host'
|
||||
mountPoint.replaceChildren(host)
|
||||
|
||||
const app = createApp({
|
||||
render() {
|
||||
return h(MarkdownRender, {
|
||||
content,
|
||||
batchRendering: false,
|
||||
typewriter: false,
|
||||
viewportPriority: false,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
app.mount(host)
|
||||
|
||||
return () => {
|
||||
app.unmount()
|
||||
teardownRuntime()
|
||||
}
|
||||
}
|
||||
|
||||
async function mountVue2Sandbox(current: SandboxSelection, content: string, mountPoint: HTMLElement) {
|
||||
const [vueModule, rendererModule] = await Promise.all([
|
||||
importRemote<any>(createEsmPackageUrl('vue', current.framework.runtimeVersion)),
|
||||
importRemote<any>(createEsmPackageUrl(
|
||||
current.framework.packageName,
|
||||
current.version,
|
||||
[
|
||||
`vue@${current.framework.runtimeVersion}`,
|
||||
'@vue/composition-api@1.7.2',
|
||||
],
|
||||
)),
|
||||
])
|
||||
|
||||
const Vue2 = vueModule.default || vueModule
|
||||
const MarkdownRender = rendererModule.default || rendererModule.MarkdownRender
|
||||
const teardownRuntime = setupCommonRendererRuntime(rendererModule)
|
||||
|
||||
if (!Vue2 || !MarkdownRender)
|
||||
throw new Error('无法找到 Vue 2 渲染器入口。')
|
||||
|
||||
const host = document.createElement('div')
|
||||
host.className = 'sandbox-canvas__host'
|
||||
mountPoint.replaceChildren(host)
|
||||
|
||||
const app = new Vue2({
|
||||
render(createElement: (component: any, data?: any) => any) {
|
||||
return createElement(MarkdownRender, {
|
||||
props: {
|
||||
content,
|
||||
batchRendering: false,
|
||||
typewriter: false,
|
||||
viewportPriority: false,
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
app.$mount(host)
|
||||
|
||||
return () => {
|
||||
app.$destroy()
|
||||
teardownRuntime()
|
||||
}
|
||||
}
|
||||
|
||||
async function mountReactSandbox(current: SandboxSelection, content: string, mountPoint: HTMLElement) {
|
||||
const runtimeVersion = current.framework.runtimeVersion
|
||||
const [reactModule, reactDomModule, rendererModule] = await Promise.all([
|
||||
importRemote<any>(createEsmPackageUrl('react', runtimeVersion)),
|
||||
importRemote<any>(createEsmPackageSubpathUrl(
|
||||
'react-dom',
|
||||
runtimeVersion,
|
||||
'client',
|
||||
[`react@${runtimeVersion}`],
|
||||
)),
|
||||
importRemote<any>(createEsmPackageUrl(
|
||||
current.framework.packageName,
|
||||
current.version,
|
||||
[`react@${runtimeVersion}`, `react-dom@${runtimeVersion}`],
|
||||
)),
|
||||
])
|
||||
|
||||
const React = reactModule.default || reactModule
|
||||
const ReactDOMClient = reactDomModule.default || reactDomModule
|
||||
const MarkdownRender = rendererModule.default || rendererModule.NodeRenderer
|
||||
|
||||
if (!React?.createElement || !ReactDOMClient?.createRoot || !MarkdownRender)
|
||||
throw new Error('无法找到 React 渲染器入口。')
|
||||
|
||||
const host = document.createElement('div')
|
||||
host.className = 'sandbox-canvas__host'
|
||||
mountPoint.replaceChildren(host)
|
||||
|
||||
const root = ReactDOMClient.createRoot(host)
|
||||
root.render(
|
||||
React.createElement(MarkdownRender, {
|
||||
content,
|
||||
batchRendering: false,
|
||||
typewriter: false,
|
||||
viewportPriority: false,
|
||||
}),
|
||||
)
|
||||
|
||||
return () => {
|
||||
root.unmount()
|
||||
}
|
||||
}
|
||||
|
||||
async function mountAngularSandbox(current: SandboxSelection, content: string, mountPoint: HTMLElement) {
|
||||
const runtimeVersion = current.framework.runtimeVersion
|
||||
const rendererModule = current.source === 'workspace'
|
||||
? await import('markstream-angular')
|
||||
: await importRemote<any>(createEsmPackageUrl(
|
||||
current.framework.packageName,
|
||||
current.version,
|
||||
[`@angular/common@${runtimeVersion}`, `@angular/core@${runtimeVersion}`],
|
||||
))
|
||||
|
||||
const renderMarkdownToHtml = rendererModule.renderMarkdownToHtml
|
||||
const enhanceRenderedHtml = rendererModule.enhanceRenderedHtml
|
||||
if (typeof renderMarkdownToHtml !== 'function')
|
||||
throw new Error('无法找到 Angular 渲染器入口。')
|
||||
|
||||
const host = document.createElement('div')
|
||||
host.className = 'sandbox-canvas__host'
|
||||
|
||||
const shell = document.createElement('div')
|
||||
shell.className = 'markstream-angular markdown-renderer'
|
||||
shell.innerHTML = renderMarkdownToHtml({
|
||||
content,
|
||||
final: true,
|
||||
allowHtml: true,
|
||||
})
|
||||
const enhancementHandle = await enhanceRenderedHtml?.(shell, {
|
||||
final: true,
|
||||
})
|
||||
|
||||
host.replaceChildren(shell)
|
||||
mountPoint.replaceChildren(host)
|
||||
|
||||
return () => {
|
||||
enhancementHandle?.dispose?.()
|
||||
mountPoint.replaceChildren()
|
||||
}
|
||||
}
|
||||
|
||||
async function mountSandbox(current: SandboxSelection, content: string, mountPoint: HTMLElement) {
|
||||
if (current.frameworkId === 'vue3')
|
||||
return await mountVue3Sandbox(current, content, mountPoint)
|
||||
|
||||
if (current.frameworkId === 'vue2')
|
||||
return await mountVue2Sandbox(current, content, mountPoint)
|
||||
|
||||
if (current.frameworkId === 'angular')
|
||||
return await mountAngularSandbox(current, content, mountPoint)
|
||||
|
||||
return await mountReactSandbox(current, content, mountPoint)
|
||||
}
|
||||
|
||||
async function renderSandbox() {
|
||||
const mountPoint = canvasRef.value
|
||||
if (!mountPoint)
|
||||
return
|
||||
|
||||
renderToken += 1
|
||||
const activeToken = renderToken
|
||||
|
||||
cleanupRuntime()
|
||||
selection.value = parseSandboxSelection(window.location.search, testSandboxFrameworks)
|
||||
markdown.value = decodeMarkdownHash(window.location.hash || '') ?? ''
|
||||
errorMessage.value = ''
|
||||
status.value = 'loading'
|
||||
applySandboxStyles(selection.value)
|
||||
|
||||
try {
|
||||
const teardown = await mountSandbox(selection.value, markdown.value, mountPoint)
|
||||
if (activeToken !== renderToken) {
|
||||
teardown()
|
||||
return
|
||||
}
|
||||
cleanupMountedRuntime = teardown
|
||||
status.value = 'ready'
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[test-sandbox] failed to render sandbox', error)
|
||||
cleanupRuntime()
|
||||
status.value = 'error'
|
||||
errorMessage.value = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
|
||||
function handleHashChange() {
|
||||
void renderSandbox()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void renderSandbox()
|
||||
window.addEventListener('hashchange', handleHashChange)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('hashchange', handleHashChange)
|
||||
cleanupRuntime()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sandbox-page">
|
||||
<header class="sandbox-hero">
|
||||
<div>
|
||||
<span class="sandbox-eyebrow">Version sandbox</span>
|
||||
<h1>Isolated Renderer</h1>
|
||||
<p>当前 iframe 正在独立加载对应 framework 与包版本,用于和左侧 workspace 预览做对照。</p>
|
||||
</div>
|
||||
|
||||
<div class="sandbox-summary">
|
||||
<div class="sandbox-summary__item">
|
||||
<span>选择</span>
|
||||
<strong>{{ selectionSummary }}</strong>
|
||||
</div>
|
||||
<div class="sandbox-summary__item">
|
||||
<span>状态</span>
|
||||
<strong>{{ statusLabel }}</strong>
|
||||
</div>
|
||||
<div class="sandbox-summary__item">
|
||||
<span>字符数</span>
|
||||
<strong>{{ markdown.length }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="status === 'error'" class="sandbox-banner sandbox-banner--error">
|
||||
<strong>沙箱加载失败。</strong>
|
||||
<span>{{ errorMessage || '请检查版本号是否存在,或稍后重试。' }}</span>
|
||||
</div>
|
||||
<div v-else-if="selection.source === 'workspace' && !selection.framework.supportsWorkspace" class="sandbox-banner sandbox-banner--warning">
|
||||
当前框架不支持在这个 iframe 中直接跑 workspace 源码,已自动回退到 npm 包模式。
|
||||
</div>
|
||||
<div v-else-if="status === 'loading'" class="sandbox-banner sandbox-banner--info">
|
||||
正在加载隔离渲染器与对应版本资源...
|
||||
</div>
|
||||
|
||||
<section class="sandbox-canvas">
|
||||
<div ref="canvasRef" class="sandbox-canvas__mount" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sandbox-page {
|
||||
min-height: 100vh;
|
||||
padding: 18px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(14, 165, 233, 0.12), transparent 28%),
|
||||
linear-gradient(180deg, #f8fbff 0%, #eef4fb 100%);
|
||||
color: #10203a;
|
||||
}
|
||||
|
||||
.sandbox-hero,
|
||||
.sandbox-canvas,
|
||||
.sandbox-banner {
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.08);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.sandbox-hero {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 20px 22px;
|
||||
}
|
||||
|
||||
.sandbox-eyebrow {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
padding: 6px 11px;
|
||||
border-radius: 999px;
|
||||
background: rgba(29, 78, 216, 0.1);
|
||||
color: #1d4ed8;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sandbox-hero h1 {
|
||||
margin: 10px 0 8px;
|
||||
font-size: clamp(1.8rem, 4vw, 2.8rem);
|
||||
line-height: 0.95;
|
||||
}
|
||||
|
||||
.sandbox-hero p {
|
||||
margin: 0;
|
||||
color: #5d7390;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.sandbox-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sandbox-summary__item {
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
background: rgba(248, 250, 252, 0.9);
|
||||
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.sandbox-summary__item span {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: #5d7390;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.sandbox-summary__item strong {
|
||||
display: block;
|
||||
line-break: anywhere;
|
||||
}
|
||||
|
||||
.sandbox-banner {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-top: 14px;
|
||||
padding: 14px 16px;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.sandbox-banner--info {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.sandbox-banner--warning {
|
||||
color: #b45309;
|
||||
background: rgba(255, 251, 235, 0.92);
|
||||
}
|
||||
|
||||
.sandbox-banner--error {
|
||||
color: #b91c1c;
|
||||
background: rgba(254, 242, 242, 0.94);
|
||||
}
|
||||
|
||||
.sandbox-canvas {
|
||||
margin-top: 14px;
|
||||
min-height: calc(100vh - 220px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sandbox-canvas__mount {
|
||||
min-height: inherit;
|
||||
padding: 22px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(245, 248, 252, 0.92));
|
||||
}
|
||||
|
||||
.sandbox-canvas__mount :deep(.markstream-vue),
|
||||
.sandbox-canvas__mount :deep(.markstream-react) {
|
||||
min-height: calc(100vh - 320px);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.sandbox-page {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.sandbox-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sandbox-canvas__mount {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user