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:
boos
2026-06-22 11:32:57 +08:00
parent d9c78ecc73
commit e0a5f57793
2305 changed files with 449355 additions and 0 deletions
@@ -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
![Vue Logo](https://vuejs.org/images/logo.png)
---
## 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">
&larr; 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
@@ -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