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,332 @@
# markstream-vue2
Vue 2.6-compatible renderer for markstream-vue.
## Install
```bash
pnpm add markstream-vue2
# npm i markstream-vue2
# yarn add markstream-vue2
```
## Requirements
- Vue 2.6.14+ (Vue 2.7 recommended for better TS support)
- @vue/composition-api (required for Vue 2.6.x)
## Composition API compatibility
| Vue version | Composition API availability | What to install | How to import |
|-------------|------------------------------|-----------------|---------------|
| **2.6.x** | Not built-in | `@vue/composition-api` | `import { ref, computed, defineComponent } from '@vue/composition-api'` |
| **2.7.x** | Built-in | None | `import { ref, computed, defineComponent } from 'vue'` |
| **3.x** | Built-in | None | `import { ref, computed, defineComponent } from 'vue'` |
Notes:
- **Vue 2.6.x** must install and **Vue.use(@vue/composition-api)**.
- **Vue 2.7.x** should **not** install `@vue/composition-api`.
- **Vue 3.x** should use **markstream-vue** (not markstream-vue2).
## Quick start by version
### Vue 2.6.x
```bash
pnpm add markstream-vue2 vue@2.6.14 vue-template-compiler@2.6.14 @vue/composition-api
```
```ts
import VueCompositionAPI from '@vue/composition-api'
import MarkdownRender, { VueRendererMarkdown } from 'markstream-vue2'
import Vue from 'vue'
import 'markstream-vue2/index.css'
Vue.use(VueCompositionAPI)
Vue.use(VueRendererMarkdown)
```
If your app increases root font size on mobile (`html` / `body`), prefer `markstream-vue2/index.px.css` to avoid `rem`-driven scaling.
Repo example:
- `playground-vue2-cli` (Vue 2.6 + Vue CLI / Webpack 4)
Start:
```bash
pnpm -C playground-vue2-cli dev
```
From repo root:
```bash
pnpm play:vue2-cli
```
### Vue 2.7.x
```bash
pnpm add markstream-vue2 vue@2.7.16 vue-template-compiler@2.7.16
```
```ts
import MarkdownRender, { VueRendererMarkdown } from 'markstream-vue2'
import Vue from 'vue'
import 'markstream-vue2/index.css'
Vue.use(VueRendererMarkdown)
```
Repo example:
- `playground-vue2` (Vue 2.7 + Vite)
Start:
```bash
pnpm -C playground-vue2 dev
```
From repo root:
```bash
pnpm play:vue2
```
### Vue 3.x (use markstream-vue)
```bash
pnpm add markstream-vue vue@^3
```
If your workspace also installs Vue 3, ensure `vue-demi` targets Vue 2:
```bash
pnpm vue-demi-switch 2
```
If you cannot run `vue-demi-switch`, you can force the Vue 2 build via bundler alias (common in Vue CLI / Webpack 4):
```js
// vue.config.js / webpack config
module.exports = {
configureWebpack: {
resolve: {
alias: {
'vue-demi$': 'vue-demi/lib/v2/index.cjs',
},
},
},
}
```
## Usage (Vue 2.6)
```ts
import VueCompositionAPI from '@vue/composition-api'
import MarkdownRender, { VueRendererMarkdown } from 'markstream-vue2'
import Vue from 'vue'
import 'markstream-vue2/index.css'
Vue.use(VueCompositionAPI)
Vue.use(VueRendererMarkdown)
new Vue({
render: h => h(MarkdownRender, {
props: {
content: '# Hello from Vue 2',
},
}),
}).$mount('#app')
```
## Streaming best practices
- For high-frequency SSE / token streaming, prefer parsing outside the renderer and pass `nodes` instead of reparsing the whole `content` string every chunk.
- Keep `viewport-priority` enabled unless you explicitly want eager rendering. Mermaid / Monaco / D2 blocks now stay idle while offscreen and resume when they approach the viewport.
```vue
<template>
<MarkdownRender
:nodes="nodes"
:final="final"
:viewport-priority="true"
:defer-nodes-until-visible="true"
/>
</template>
```
## Heavy-node prop forwarding
`MarkdownRender` can forward framework-level props directly into specialized heavy renderers:
```vue
<template>
<MarkdownRender
:content="markdown"
:mermaid-props="{
showHeader: false,
renderDebounceMs: 180,
previewPollDelayMs: 500,
}"
:d2-props="{ progressiveIntervalMs: 500 }"
:infographic-props="{ showHeader: false }"
/>
</template>
```
Notes:
- Use kebab-case in templates: `mermaid-props`, `d2-props`, `infographic-props`.
- These props are forwarded to the built-in Mermaid / D2 / Infographic blocks and to custom `mermaid` / `d2` / `infographic` overrides registered with `setCustomComponents(...)`.
## Language-specific code block overrides
You can also register a renderer for one fenced language directly:
```ts
import { setCustomComponents } from 'markstream-vue2'
setCustomComponents('docs', {
echarts: EChartsBlockNode,
})
```
Notes:
- `echarts` only catches fences whose language is `echarts`.
- Code block routing priority is exact language key -> built-in `mermaid` / `d2` / `infographic` routes -> `code_block`.
- `mermaid`, `d2`, and `infographic` overrides keep their specialized prop forwarding; other language keys receive the normal code-block-level bindings.
## Mermaid tuning
Common `mermaid-props` keys for streaming scenarios:
- `renderDebounceMs`: delay partial/full progressive work during rapid token bursts.
- `contentStableDelayMs`: how long source mode waits before auto-switching back to preview when content stabilizes.
- `previewPollDelayMs`: initial delay before preview polling tries to upgrade a partial preview into a full render.
- `previewPollMaxDelayMs`: cap for preview polling backoff.
- `previewPollMaxAttempts`: maximum retry count while the Mermaid source is still incomplete.
## Build and size checks
```bash
pnpm --filter markstream-vue2 build
pnpm --filter markstream-vue2 build:analyze
pnpm --filter markstream-vue2 size:check
```
## Bundle size notes
- Optional peers are not bundled; install only the features you need.
- Infrequent language icons are split into an async chunk and loaded on demand.
- If you want to avoid first-hit fallback icons, preload once when the app is idle:
```ts
import { preloadExtendedLanguageIcons } from 'markstream-vue2'
if (typeof window !== 'undefined')
void preloadExtendedLanguageIcons()
```
## Troubleshooting
### `defineComponent is not a function`
Cause: `vue-demi` is in Vue 3 mode while the app runs Vue 2.x.
Fix: run `pnpm vue-demi-switch 2` or alias `vue-demi$` to `vue-demi/lib/v2/index.cjs`.
### `Vue packages version mismatch`
Cause: `vue` and `vue-template-compiler` versions differ.
Fix: align both to the same version (e.g. `2.6.14` or `2.7.16`).
### `Cannot read properties of undefined (reading 'props')`
Cause: Vue 2.6 + Composition API missing `_setupProxy` patch, or plugin not installed.
Fix: ensure `@vue/composition-api` is installed + `Vue.use(...)`, and update to the latest markstream-vue2 build.
### Custom node renders trigger `infinite update loop`
Cause: in Vue 2.6 / Vue CLI 4, recursively mounting another `MarkdownRender` inside a custom node component can create a render loop when the parent renderer is still streaming.
Fix: prefer `NestedRenderer` for nested streaming content. If you only need static HTML output, `renderNestedMarkdownToHtml(...)` is still available.
```ts
import { NestedRenderer } from 'markstream-vue2'
export default {
components: { NestedRenderer },
props: {
node: { type: Object, required: true },
},
}
```
```vue
<template>
<NestedRenderer
:node="node"
custom-id="chat-demo"
:custom-html-tags="['thinking']"
:typewriter="false"
:batch-rendering="false"
:viewport-priority="false"
:defer-nodes-until-visible="false"
/>
</template>
```
`NestedRenderer` keeps the inner content streamable, but switches the nested renderer to the `nodes` path so Vue 2.6 does not recurse through a second `content`-driven renderer.
If you cannot mount another renderer and just need a static nested body, use `renderNestedMarkdownToHtml(...)`:
```ts
import { renderNestedMarkdownToHtml } from 'markstream-vue2'
export default {
props: {
node: { type: Object, required: true },
},
computed: {
renderedHtml() {
return renderNestedMarkdownToHtml(
{ node: this.node },
{
customHtmlTags: ['thinking'],
customNodeClass(node) {
return node.type === 'thinking' ? 'thinking-node__nested' : ''
},
},
)
},
},
}
```
This keeps deeply nested custom content streamable without recursively mounting a second `content`-driven `MarkdownRender` / `NodeRenderer`.
## Tailwind
If your app uses Tailwind and you want to avoid shipping duplicated utility CSS, import the Tailwind-ready output instead:
```ts
import 'markstream-vue2/index.tailwind.css'
```
Then include the extracted class list in `tailwind.config.js`:
```js
module.exports = {
content: [
'./src/**/*.{js,ts,vue}',
require('markstream-vue2/tailwind'),
],
}
```
## Notes
- The Vue 2 package mirrors the Vue 3 renderer feature set where possible (virtualization, streaming code blocks, Monaco, Mermaid, KaTeX, tooltip singleton).
- Optional peers are still required for those features (`stream-monaco`, `stream-markdown`, `mermaid`, `katex`, etc.).
- Custom node components are supported via `setCustomComponents` from `markstream-vue2`.
## TypeScript
Parser node types are re-exported from `stream-markdown-parser`, and Vue 2 specific mapping / Monaco types are available from `markstream-vue2` itself:
```ts
import type { CodeBlockMonacoOptions, CustomComponents, ParsedNode } from 'markstream-vue2'
```
@@ -0,0 +1,142 @@
{
"name": "markstream-vue2",
"type": "module",
"version": "0.0.41",
"description": "Vue 2 Markdown renderer optimized for large documents with progressive Mermaid rendering, streaming diff code blocks, and fast real-time preview. Perfect for documentation sites, AI chat interfaces, and content management systems.",
"author": "Simon He",
"license": "MIT",
"homepage": "https://github.com/Simon-He95/markstream-vue#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/Simon-He95/markstream-vue.git",
"directory": "packages/markstream-vue2"
},
"bugs": {
"url": "https://github.com/Simon-He95/markstream-vue/issues"
},
"keywords": [
"vue",
"vue2",
"vue-2",
"markdown",
"markdown-to-html",
"markdown-renderer",
"vue-markdown",
"vue-markdown-renderer",
"vue-markdown-component",
"markdown-preview",
"markdown-viewer",
"vue-component",
"nuxt",
"nuxt2",
"nuxt-2",
"vite",
"webpack",
"sse",
"ai-chat",
"streaming-markdown",
"html",
"renderer",
"custom-component",
"mermaid",
"progressive-mermaid",
"streaming-diff",
"diff-codeblock",
"large-documents",
"performance",
"real-time-preview",
"markstream-vue2",
"markstream",
"documentation",
"docs",
"content-management",
"cms",
"markdown-parser",
"md-to-html",
"vue2-markdown",
"vue2-component"
],
"sideEffects": [
"**/*.css"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./index.css": "./dist/index.css",
"./index.px.css": "./dist/index.px.css",
"./index.tailwind.css": "./dist/index.tailwind.css",
"./tailwind": "./dist/tailwind.ts",
"./workers/katexRenderer.worker": "./dist/workers/katexRenderer.worker.js",
"./workers/mermaidParser.worker": "./dist/workers/mermaidParser.worker.js"
},
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "vite build -c vite.config.ts --mode npm && vite build -c vite.config.tailwind.ts --mode npm && node ./scripts/copy-tailwind-css.mjs && node ../../scripts/generate-px-css.mjs && pnpm run build:dts && pnpm run validate:dist",
"build:analyze": "ANALYZE=true pnpm build",
"build:dts": "rollup -c ./scripts/rollup.dts.config.mjs && node ./scripts/clean-dts.cjs",
"validate:dist": "node ./scripts/validate-dist-entries.mjs",
"size:check": "MAX_PACK_UNPACKED_BYTES=800000 node ../../scripts/check-package-size.mjs",
"release": "bumpp --commit --no-tag --no-push && pnpm publish --access public && node ../../scripts/tag-package.mjs --package-json package.json --push"
},
"peerDependencies": {
"@antv/infographic": "^0.2.3",
"@terrastruct/d2": ">=0.1.33",
"@vue/composition-api": ">=1.7.2",
"katex": ">=0.16.22",
"mermaid": ">=11",
"stream-markdown": ">=0.0.15",
"stream-monaco": ">=0.0.40",
"vue": ">=2.6.14 <3",
"vue-i18n": ">=9"
},
"peerDependenciesMeta": {
"@antv/infographic": {
"optional": true
},
"@terrastruct/d2": {
"optional": true
},
"@vue/composition-api": {
"optional": true
},
"katex": {
"optional": true
},
"mermaid": {
"optional": true
},
"stream-markdown": {
"optional": true
},
"stream-monaco": {
"optional": true
},
"vue-i18n": {
"optional": true
}
},
"dependencies": {
"@floating-ui/dom": "^1.7.6",
"stream-markdown-parser": "workspace:*",
"vue-demi": "^0.14.10"
},
"devDependencies": {
"@vitejs/plugin-vue2": "^2.3.4",
"@vue/compiler-sfc": "2.7.16",
"rollup": "^3.30.0",
"rollup-plugin-dts": "^5.3.1",
"rollup-plugin-visualizer": "^6.0.11",
"typescript": "^5.9.3",
"vite": "^4.5.14",
"vite-plugin-dts": "^4.5.4",
"vue-tsc": "^2.2.12"
}
}
@@ -0,0 +1,3 @@
module.exports = {
plugins: [require('tailwindcss'), require('autoprefixer')],
}
@@ -0,0 +1,20 @@
const fs = require('node:fs')
const path = require('node:path')
const distDir = path.resolve(__dirname, '..', 'dist')
const typesDir = path.join(distDir, 'types')
function rewriteTypesEntry(content) {
return content
.replace(/from\s+(['"])\.\//g, 'from $1./types/')
.replace(/import\((['"])\.\//g, 'import($1./types/')
}
const sourcePath = path.join(typesDir, 'exports.d.ts')
const targetPath = path.join(distDir, 'index.d.ts')
if (fs.existsSync(sourcePath)) {
const content = rewriteTypesEntry(fs.readFileSync(sourcePath, 'utf8'))
fs.writeFileSync(targetPath, content)
console.log('Rewrote', targetPath, 'from', sourcePath)
}
@@ -0,0 +1,28 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
const fromDir = path.resolve('dist-tw')
const toDir = path.resolve('dist')
const fromCss = path.join(fromDir, 'index.tailwind.css')
const toCss = path.join(toDir, 'index.tailwind.css')
async function main() {
await fs.mkdir(toDir, { recursive: true })
try {
await fs.copyFile(fromCss, toCss)
}
catch (err) {
const e = err
throw new Error(`[copy-tailwind-css] Missing ${fromCss}. Did the tailwind build run? (${e?.message || e})`)
}
await fs.rm(fromDir, { recursive: true, force: true })
}
main().catch((err) => {
console.error(err)
process.exitCode = 1
})
@@ -0,0 +1,51 @@
import fs from 'node:fs'
import path from 'node:path'
import dts from 'rollup-plugin-dts'
const fallbackInput = './dist/index.d.ts'
const preferredInput = './dist/types/exports.d.ts'
const preferredExists = fs.existsSync(path.resolve(preferredInput))
const fallbackExists = fs.existsSync(path.resolve(fallbackInput))
if (!preferredExists && !fallbackExists) {
throw new Error(
'No declaration entry found. Run the build first to emit declarations before running build:dts.',
)
}
const inputPath = preferredExists ? preferredInput : fallbackInput
export default [
{
input: inputPath,
plugins: [
{
name: 'normalize-vue-demi-imports',
transform(code, id) {
if (!id.endsWith('.d.ts'))
return null
const rewritten = code
.replace(/from\s+['"](?:\.\.\/)+vue-demi['"]/g, 'from \'vue-demi\'')
.replace(/import\(\s*['"](?:\.\.\/)+vue-demi['"]\s*\)/g, 'import(\'vue-demi\')')
if (rewritten === code)
return null
return { code: rewritten, map: null }
},
},
dts({
respectExternal: false,
}),
],
external: [
/^stream-markdown-parser(?:\/.*)?$/,
/^vue-demi(?:\/.*)?$/,
],
output: [
{
file: 'dist/index.d.ts',
format: 'es',
},
],
},
]
@@ -0,0 +1,65 @@
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const rootDir = resolve(__dirname, '..')
const distDir = resolve(rootDir, 'dist')
const esmEntry = resolve(distDir, 'index.js')
const cjsEntry = resolve(distDir, 'index.cjs')
function assert(condition, message) {
if (!condition)
throw new Error(message)
}
function read(file) {
return readFileSync(file, 'utf8')
}
assert(existsSync(esmEntry), `[validate-dist] Missing ESM entry: ${esmEntry}`)
assert(existsSync(cjsEntry), `[validate-dist] Missing CJS entry: ${cjsEntry}`)
const esmSource = read(esmEntry)
const cjsSource = read(cjsEntry)
function collectFiles(dir, files = []) {
for (const name of readdirSync(dir)) {
const fullPath = resolve(dir, name)
const stat = statSync(fullPath)
if (stat.isDirectory())
collectFiles(fullPath, files)
else
files.push(fullPath)
}
return files
}
assert(
!/\bObject\.defineProperty\(exports\b|\bexports\./.test(esmSource),
'[validate-dist] dist/index.js still contains CommonJS export markers.',
)
await import(pathToFileURL(esmEntry).href)
console.log('[validate-dist] ESM entry loaded successfully.')
const cjsFiles = collectFiles(distDir).filter(file => file.endsWith('.cjs'))
const localRequirePattern = /require\(\s*['"](\.{1,2}\/[^'"]+)['"]\s*\)/g
for (const file of cjsFiles) {
const source = file === cjsEntry ? cjsSource : read(file)
assert(
!/\brequire\(\s*['"]\.{1,2}\/[^'"]+\.js['"]\s*\)/.test(source),
`[validate-dist] ${file} still requires local .js chunks under a type=module package.`,
)
for (const match of source.matchAll(localRequirePattern)) {
const requiredPath = resolve(dirname(file), match[1])
assert(
existsSync(requiredPath),
`[validate-dist] Missing local CJS dependency ${match[1]} referenced from ${file}.`,
)
}
}
console.log('[validate-dist] CJS entries reference only local .cjs dependencies that exist.')
@@ -0,0 +1,283 @@
<script setup lang="ts">
import { computed, getCurrentInstance, ref } from 'vue-demi'
import { isLegacyVue26Vm } from '../../utils/vue26'
import NodeRenderer from '../NodeRenderer'
import LegacyNodesRenderer from '../NodeRenderer/LegacyNodesRenderer.vue'
// 定义警告块节点类型
export type AdmonitionKind = 'note' | 'info' | 'tip' | 'warning' | 'danger' | 'caution' | 'error'
interface AdmonitionNode {
type: 'admonition'
kind: AdmonitionKind
title?: string
children: { type: string, raw: string }[]
raw: string
// 可选:是否支持折叠
collapsible?: boolean
// 可选:初始是否展开,默认 true
open?: boolean
}
// 接收 props(并在 script 中使用)
const props = defineProps<{ node: AdmonitionNode, indexKey: number | string, isDark?: boolean, typewriter?: boolean, customId?: string }>()
// 定义事件
const emit = defineEmits(['copy'])
// 不同类型的警告块图标(显式类型以便编辑器提示)
const iconMap: Record<AdmonitionKind, string> = {
note: 'ℹ️',
info: 'ℹ️',
tip: '💡',
warning: '⚠️',
danger: '❗',
// 'error' is a common alias for 'danger' in some markdown flavors
error: '⛔',
caution: '⚠️',
}
// 当 title 为空时使用 kind 作为回退(首字母大写)
const displayTitle = computed(() => {
if (props.node.title && props.node.title.trim().length)
return props.node.title
const k = props.node.kind || 'note'
return k.charAt(0).toUpperCase() + k.slice(1)
})
// 支持折叠:如果 props.node.collapsible 为 true,则依据 props.node.open 初始化
const collapsed = ref<boolean>(props.node.collapsible ? !(props.node.open ?? true) : false)
function toggleCollapse() {
if (!props.node.collapsible)
return
collapsed.value = !collapsed.value
}
// 为无障碍生成 ID(用于 aria-labelledby)
const headerId = `admonition-${Math.random().toString(36).slice(2, 9)}`
const instance = getCurrentInstance()
const nestedRenderer = computed(() => {
const vm = instance?.proxy as any
return isLegacyVue26Vm(vm) ? LegacyNodesRenderer : NodeRenderer
})
</script>
<template>
<div class="admonition" :class="[`admonition-${props.node.kind}`, props.isDark ? 'is-dark' : '']">
<div :id="headerId" class="admonition-header">
<span v-if="iconMap[props.node.kind]" class="admonition-icon">{{ iconMap[props.node.kind] }}</span>
<span class="admonition-title">{{ displayTitle }}</span>
<!-- 可选的折叠控制(放在 header 末端) -->
<button
v-if="props.node.collapsible"
class="admonition-toggle"
:aria-expanded="!collapsed"
:aria-controls="`${headerId}-content`"
:title="collapsed ? 'Expand' : 'Collapse'"
@click="toggleCollapse"
>
<span v-if="collapsed">▶</span>
<span v-else>▼</span>
</button>
</div>
<div
v-show="!collapsed"
:id="`${headerId}-content`"
class="admonition-content"
:aria-labelledby="headerId"
>
<component
:is="nestedRenderer"
:index-key="`admonition-${indexKey}`"
:nodes="props.node.children"
:custom-id="props.customId"
:typewriter="props.typewriter"
@copy="emit('copy', $event)"
/>
</div>
</div>
</template>
<style scoped>
/* 变量默认(浅色主题)*/
.admonition {
--admonition-bg: #f8f8f8;
--admonition-border: #eaecef;
--admonition-header-bg: rgba(0, 0, 0, 0.03);
--admonition-text: #111827;
--admonition-muted: #374151;
--admonition-note-color: #448aff;
--admonition-tip-color: #00bfa5;
--admonition-warning-color: #ff9100;
--admonition-danger-color: #ff5252;
margin: 1rem 0;
padding: 0;
border-radius: 4px;
border-left: 4px solid var(--admonition-border);
background-color: var(--admonition-bg);
color: var(--admonition-text);
overflow: hidden;
}
.admonition-header {
padding: 0.5rem 1rem;
font-weight: 600;
display: flex;
align-items: center;
background-color: var(--admonition-header-bg);
color: var(--admonition-muted);
}
.admonition-icon {
margin-right: 0.5rem;
color: inherit;
}
.admonition-content {
padding: 0.5rem 1rem 1rem;
color: var(--admonition-text);
}
/* 各种类型只控制边框与 header 颜色(使用更轻的 header 背景,以免过于抢眼) */
.admonition-note {
border-left-color: var(--admonition-note-color);
}
.admonition-note .admonition-header {
background-color: rgba(68, 138, 255, 0.06);
color: var(--admonition-note-color);
}
.admonition-info {
border-left-color: var(--admonition-note-color);
}
.admonition-info .admonition-header {
background-color: rgba(68, 138, 255, 0.06);
color: var(--admonition-note-color);
}
.admonition-tip {
border-left-color: var(--admonition-tip-color);
}
.admonition-tip .admonition-header {
background-color: rgba(0, 191, 165, 0.06);
color: var(--admonition-tip-color);
}
.admonition-warning {
border-left-color: var(--admonition-warning-color);
}
.admonition-warning .admonition-header {
background-color: rgba(255, 145, 0, 0.06);
color: var(--admonition-warning-color);
}
.admonition-danger {
border-left-color: var(--admonition-danger-color);
}
.admonition-danger .admonition-header {
background-color: rgba(255, 82, 82, 0.06);
color: var(--admonition-danger-color);
}
.admonition-error {
border-left-color: var(--admonition-danger-color);
}
.admonition-error .admonition-header {
background-color: rgba(255, 82, 82, 0.06);
color: var(--admonition-danger-color);
}
.admonition-caution {
border-left-color: var(--admonition-warning-color);
}
.admonition-caution .admonition-header {
background-color: rgba(255, 145, 0, 0.06);
color: var(--admonition-warning-color);
}
/* 修复:当一次性渲染大量内容并滚动到 AdmonitionNode 时,
内部 NodeRenderer(.markdown-renderer)使用 content-visibility: auto
可能导致占位高度很高但未及时绘制。这里在告示块内部禁用该优化,
保证内容按时渲染,避免“空白但很高”的现象。*/
.admonition-content ::v-deep .markdown-renderer {
content-visibility: visible;
contain: content;
contain-intrinsic-size: 0px 0px;
}
/* 折叠按钮样式 */
.admonition-toggle {
margin-left: auto;
background: transparent;
border: none;
color: inherit;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.9rem;
}
.admonition-toggle:focus {
outline: 2px solid rgba(0,0,0,0.08);
outline-offset: 2px;
}
/* 深色模式支持:支持 props.isDark(组件级)与系统偏好(媒体查询) */
.admonition.is-dark {
--admonition-bg: #0b1220;
--admonition-border: rgba(255, 255, 255, 0.06);
--admonition-header-bg: rgba(255, 255, 255, 0.03);
--admonition-text: #e6eef8;
--admonition-muted: #cbd5e1;
}
/* 当组件通过 props.isDark 指定为暗色时,增强语义色块 */
.admonition.is-dark .admonition-note .admonition-header,
.admonition.is-dark .admonition-info .admonition-header {
background-color: rgba(68, 138, 255, 0.12);
color: var(--admonition-note-color);
}
.admonition.is-dark .admonition-tip .admonition-header {
background-color: rgba(0, 191, 165, 0.12);
color: var(--admonition-tip-color);
}
.admonition.is-dark .admonition-warning .admonition-header {
background-color: rgba(255, 145, 0, 0.12);
color: var(--admonition-warning-color);
}
.admonition.is-dark .admonition-danger .admonition-header {
background-color: rgba(255, 82, 82, 0.12);
color: var(--admonition-danger-color);
}
@media (prefers-color-scheme: dark) {
.admonition {
--admonition-bg: #0b1220;
--admonition-border: rgba(255, 255, 255, 0.06);
--admonition-header-bg: rgba(255, 255, 255, 0.03);
--admonition-text: #e6eef8;
--admonition-muted: #cbd5e1;
}
/* 在暗色里稍微增强 header 的语义色块 */
.admonition-note .admonition-header,
.admonition-info .admonition-header {
background-color: rgba(68, 138, 255, 0.12);
color: var(--admonition-note-color);
}
.admonition-tip .admonition-header {
background-color: rgba(0, 191, 165, 0.12);
color: var(--admonition-tip-color);
}
.admonition-warning .admonition-header {
background-color: rgba(255, 145, 0, 0.12);
color: var(--admonition-warning-color);
}
.admonition-danger .admonition-header {
background-color: rgba(255, 82, 82, 0.12);
color: var(--admonition-danger-color);
}
}
</style>
@@ -0,0 +1,3 @@
import AdmonitionNode from './AdmonitionNode.vue'
export default AdmonitionNode
@@ -0,0 +1,66 @@
<script setup lang="ts">
import { computed, getCurrentInstance } from 'vue-demi'
import { isLegacyVue26Vm } from '../../utils/vue26'
import NodeRenderer from '../NodeRenderer'
import LegacyNodesRenderer from '../NodeRenderer/LegacyNodesRenderer.vue'
interface NodeChild {
type: string
raw: string
[key: string]: unknown
}
interface BlockquoteNode {
type: 'blockquote'
children: NodeChild[]
raw: string
cite?: string
}
const props = defineProps<{
node: BlockquoteNode
indexKey: string | number
typewriter?: boolean
customId?: string
}>()
defineEmits<{
copy: [text: string]
}>()
const instance = getCurrentInstance()
const nestedRenderer = computed(() => {
const vm = instance?.proxy as any
return isLegacyVue26Vm(vm) ? LegacyNodesRenderer : NodeRenderer
})
</script>
<template>
<blockquote class="blockquote" dir="auto" :cite="node.cite">
<component
:is="nestedRenderer"
:index-key="`blockquote-${props.indexKey}`"
:nodes="props.node.children || []"
:custom-id="props.customId"
:typewriter="props.typewriter"
@copy="$emit('copy', $event)"
/>
</blockquote>
</template>
<style scoped>
.blockquote {
font-weight: 500;
font-style: italic;
border-left: 0.25rem solid var(--blockquote-border-color,#e2e8f0);
quotes: "\201C" "\201D" "\2018" "\2019";
margin: 1.6em 0;
padding-left: 1em;
}
.blockquote ::v-deep .markdown-renderer {
content-visibility: visible;
contain: content;
contain-intrinsic-size: 0px 0px;
}
</style>
@@ -0,0 +1,3 @@
import BlockquoteNode from './BlockquoteNode.vue'
export default BlockquoteNode
@@ -0,0 +1,32 @@
<script setup lang="ts">
defineProps<{
node: {
type: 'checkbox'
checked: boolean
raw: string
}
}>()
</script>
<template>
<span class="checkbox-node">
<input
type="checkbox"
:checked="node.checked"
disabled
class="checkbox-input"
>
</span>
</template>
<style scoped>
.checkbox-node {
margin-right: 0.5rem;
vertical-align: middle;
}
.checkbox-input {
margin: 0;
cursor: default;
}
</style>
@@ -0,0 +1,3 @@
import CheckboxNode from './CheckboxNode.vue'
export default CheckboxNode
@@ -0,0 +1,192 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue-demi'
import { useSafeI18n } from '../../composables/useSafeI18n'
import Portal from '../Portal'
const props = defineProps<{
code: string
isDark?: boolean
onClose?: () => void
title?: string
}>()
const { t } = useSafeI18n()
const srcdoc = computed(() => {
const base = props.code || ''
// 如果用户已经自己写了 <html>/<body>,直接原样输出,避免重复包裹
const lowered = base.trim().toLowerCase()
if (lowered.startsWith('<!doctype') || lowered.startsWith('<html') || lowered.startsWith('<body'))
return base
const bg = props.isDark ? '#020617' : '#ffffff'
const fg = props.isDark ? '#e5e7eb' : '#020617'
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
html, body {
margin: 0;
padding: 0;
height: 100%;
background-color: ${bg};
color: ${fg};
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'SF Pro Text', ui-sans-serif, sans-serif;
}
</style>
</head>
<body>
${base}
</body>
</html>`
})
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' || e.key === 'Esc')
props.onClose?.()
}
onMounted(() => {
if (typeof window !== 'undefined')
window.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
if (typeof window !== 'undefined')
window.removeEventListener('keydown', handleKeydown)
})
</script>
<template>
<Portal to="body">
<div class="markstream-vue2">
<div class="html-preview-frame__backdrop" :class="{ 'html-preview-frame__backdrop--dark': props.isDark }" @click="props.onClose?.()">
<div class="html-preview-frame" :class="{ 'html-preview-frame--dark': props.isDark }" @click.stop>
<div class="html-preview-frame__header">
<div class="html-preview-frame__title">
<span class="html-preview-frame__dot" />
<span class="html-preview-frame__label">{{ props.title || t('common.preview') || 'Preview' }}</span>
</div>
<button
type="button"
class="html-preview-frame__close"
:class="{ 'html-preview-frame__close--dark': props.isDark }"
@click="props.onClose?.()"
>
×
</button>
</div>
<iframe
class="html-preview-frame__iframe"
sandbox="allow-scripts allow-same-origin"
:srcdoc="srcdoc"
/>
</div>
</div>
</div>
</Portal>
</template>
<style scoped>
.html-preview-frame__backdrop {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
.html-preview-frame__backdrop--dark {
background-color: rgba(15, 23, 42, 0.8);
}
.html-preview-frame {
width: 80vw;
max-width: 960px;
height: 70vh;
background-color: #fff;
border-radius: 0.5rem;
overflow: hidden;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.25);
display: flex;
flex-direction: column;
}
.html-preview-frame--dark {
background-color: #020617;
color: #e5e7eb;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.6);
}
.html-preview-frame__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.4rem 0.75rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.html-preview-frame--dark .html-preview-frame__header {
border-bottom-color: rgba(148, 163, 184, 0.35);
}
.html-preview-frame__title {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.02em;
text-transform: uppercase;
opacity: 0.85;
}
.html-preview-frame__dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 999px;
background-color: #22c55e;
}
.html-preview-frame--dark .html-preview-frame__dot {
background-color: #4ade80;
}
.html-preview-frame__label {
white-space: nowrap;
}
.html-preview-frame__close {
border: none;
background: transparent;
font-size: 1.25rem;
line-height: 1;
cursor: pointer;
}
.html-preview-frame__close--dark {
color: #e5e7eb;
}
.html-preview-frame__iframe {
width: 100%;
height: 100%;
border: none;
display: block;
}
@media (max-width: 640px) {
.html-preview-frame {
width: 100vw;
height: 80vh;
border-radius: 0;
}
}
</style>
@@ -0,0 +1,26 @@
# CodeBlockNode (component)
`CodeBlockNode` renders rich interactive code blocks. It supports Monaco streaming integration (optional), Markdown mode, and a flexible header API with slots and events.
Quick example — inline usage (fallback to a simple rendering if Monaco not installed):
```vue
<CodeBlockNode :node="{ type: 'code_block', language: 'js', code: 'console.log(1)', raw: 'console.log(1)' }" />
```
Header override example:
```vue
<CodeBlockNode :node="node" :showCopyButton="false">
<template #header-left>
<div class="text-sm font-medium">My snippet</div>
</template>
<template #header-right>
<button @click="run">Run</button>
</template>
</CodeBlockNode>
```
Docs and usage examples:
- Docs: /guide/code-block-node
- Header API: /guide/codeblock-header
@@ -0,0 +1,3 @@
import CodeBlockNode from './CodeBlockNode.vue'
export default CodeBlockNode
@@ -0,0 +1,58 @@
import { preload } from '../NodeRenderer/preloadMonaco'
let mod: any = null
let importAttempted = false
async function warmupShikiTokenizer(m: any) {
const getOrCreateHighlighter = m?.getOrCreateHighlighter
if (typeof getOrCreateHighlighter !== 'function')
return true
try {
const highlighter = await getOrCreateHighlighter(
['vitesse-dark', 'vitesse-light'],
['plaintext', 'text', 'javascript'],
)
if (highlighter && typeof highlighter.codeToTokens === 'function') {
// Trigger a minimal tokenize pass to ensure the TextMate regex engine
// is initialized. In some bundlers (notably Webpack 4) this can
// otherwise fail later in Monaco's background tokenization with
// "Cannot read properties of null (reading 'compileAG')".
highlighter.codeToTokens('const a = 1', { lang: 'javascript', theme: 'vitesse-dark' })
}
return true
}
catch (err) {
// If the optional tokenizer stack fails to initialize, fall back to
// plain <pre><code> rendering instead of letting Monaco crash the app.
console.warn('[markstream-vue2] Failed to warm up Shiki tokenizer; disabling stream-monaco for this session.', err)
return false
}
}
export async function getUseMonaco() {
if (mod)
return mod
if (importAttempted)
return null
try {
const imported = await import('stream-monaco/legacy')
mod = (imported as any)?.default ?? imported
await preload(mod)
const ok = await warmupShikiTokenizer(mod)
if (!ok) {
mod = null
importAttempted = true
return null
}
return mod
}
catch {
importAttempted = true
// Return null to indicate the module is not available
// The caller should handle the fallback gracefully
return null
}
}
@@ -0,0 +1,75 @@
type SetTheme = (theme: any) => Promise<void>
let applyPromise: Promise<void> | null = null
let pendingTheme: any = null
let pendingKey: string | null = null
let lastAppliedKey: string | null = null
let currentSetTheme: SetTheme | null = null
const themeKeyCache = new WeakMap<object, string>()
let themeKeySeq = 0
function getThemeKey(theme: any): string | null {
if (theme == null)
return null
if (typeof theme === 'string')
return theme
if (typeof theme === 'object' && theme && 'name' in theme)
return String((theme as any).name)
if (typeof theme === 'object') {
const cached = themeKeyCache.get(theme)
if (cached)
return cached
try {
const str = JSON.stringify(theme)
if (str) {
themeKeyCache.set(theme, str)
return str
}
}
catch {}
const id = `__theme_${++themeKeySeq}`
themeKeyCache.set(theme, id)
return id
}
return String(theme)
}
export function scheduleGlobalMonacoTheme(setTheme: SetTheme, theme: any): Promise<void> {
const key = getThemeKey(theme)
if (!key)
return Promise.resolve()
currentSetTheme = setTheme
if (!applyPromise && lastAppliedKey === key)
return Promise.resolve()
pendingTheme = theme
pendingKey = key
if (applyPromise)
return applyPromise
applyPromise = (async () => {
while (pendingKey && pendingTheme != null) {
const nextTheme = pendingTheme
const nextKey = pendingKey
pendingTheme = null
pendingKey = null
if (lastAppliedKey === nextKey)
continue
try {
await (currentSetTheme ?? setTheme)(nextTheme)
lastAppliedKey = nextKey
}
catch {}
}
})().finally(() => {
applyPromise = null
})
return applyPromise
}
@@ -0,0 +1,642 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue-demi'
import { useSafeI18n } from '../../composables/useSafeI18n'
import { hideTooltip, showTooltipForAnchor } from '../../composables/useSingletonTooltip'
import { getD2 } from './d2'
interface D2BlockNodeProps {
node: {
code?: string
}
maxHeight?: string | number | null
loading?: boolean
isDark?: boolean
progressiveRender?: boolean
progressiveIntervalMs?: number
themeId?: number | null
darkThemeId?: number | null
showHeader?: boolean
showModeToggle?: boolean
showCopyButton?: boolean
showExportButton?: boolean
showCollapseButton?: boolean
}
const props = withDefaults(
defineProps<D2BlockNodeProps>(),
{
maxHeight: '500px',
loading: true,
progressiveRender: true,
progressiveIntervalMs: 700,
showHeader: true,
showModeToggle: true,
showCopyButton: true,
showExportButton: true,
showCollapseButton: true,
},
)
const { t } = useSafeI18n()
const copyText = ref(false)
const isCollapsed = ref(false)
const showSource = ref(false)
const d2Available = ref(false)
const renderError = ref<string | null>(null)
const isRendering = ref(false)
const svgMarkup = ref('')
const renderToken = ref(0)
const bodyRef = ref<HTMLElement | null>(null)
const bodyMinHeight = ref<number | null>(null)
const baseCode = computed(() => props.node.code ?? '')
const showSourceFallback = computed(() => {
return showSource.value || !d2Available.value || !svgMarkup.value
})
const hasPreview = computed(() => !!svgMarkup.value)
const bodyStyle = computed(() => {
if (!showSourceFallback.value || !bodyMinHeight.value)
return undefined
return { minHeight: `${bodyMinHeight.value}px` }
})
const renderStyle = computed(() => {
if (!props.maxHeight)
return undefined
const max = typeof props.maxHeight === 'number' ? `${props.maxHeight}px` : String(props.maxHeight)
return { maxHeight: max }
})
const isClient = typeof window !== 'undefined'
let d2Instance: any | null = null
let scheduled = false
let unmounted = false
let lastRenderAt = 0
let throttleTimer: number | null = null
let pendingRender = false
let bodyObserver: ResizeObserver | null = null
const DARK_THEME_OVERRIDES: Record<string, string> = {
N1: '#E5E7EB',
N2: '#CBD5E1',
N3: '#94A3B8',
N4: '#64748B',
N5: '#475569',
N6: '#334155',
N7: '#0B1220',
B1: '#60A5FA',
B2: '#3B82F6',
B3: '#2563EB',
B4: '#1D4ED8',
B5: '#1E40AF',
B6: '#111827',
AA2: '#22D3EE',
AA4: '#0EA5E9',
AA5: '#0284C7',
AB4: '#FBBF24',
AB5: '#F59E0B',
}
// Tooltip helpers
function shouldSkipEventTarget(el: EventTarget | null) {
const btn = el as HTMLButtonElement | null
return !btn || btn.disabled
}
type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right'
function onBtnHover(e: Event, text: string, place: TooltipPlacement = 'top') {
if (shouldSkipEventTarget(e.currentTarget))
return
const ev = e as MouseEvent
const origin = ev?.clientX != null && ev?.clientY != null ? { x: ev.clientX, y: ev.clientY } : undefined
showTooltipForAnchor(e.currentTarget as HTMLElement, text, place, false, origin, props.isDark)
}
function onBtnLeave() {
hideTooltip()
}
function onCopyHover(e: Event) {
if (shouldSkipEventTarget(e.currentTarget))
return
const txt = copyText.value ? (t('common.copied') || 'Copied') : (t('common.copy') || 'Copy')
const ev = e as MouseEvent
const origin = ev?.clientX != null && ev?.clientY != null ? { x: ev.clientX, y: ev.clientY } : undefined
showTooltipForAnchor(e.currentTarget as HTMLElement, txt, 'top', false, origin, props.isDark)
}
async function copy() {
try {
const text = baseCode.value
if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
await navigator.clipboard.writeText(text)
}
copyText.value = true
setTimeout(() => {
copyText.value = false
}, 1000)
}
catch (err) {
console.error('Copy failed:', err)
}
}
function toggleCollapse() {
isCollapsed.value = !isCollapsed.value
}
function handleSwitchMode(mode: 'preview' | 'source') {
showSource.value = mode === 'source'
}
const DISALLOWED_STYLE_PATTERNS = [/javascript:/i, /expression\s*\(/i, /url\s*\(\s*javascript:/i, /@import/i]
const SAFE_URL_PROTOCOLS = /^(?:https?:|mailto:|tel:|#|\/|data:image\/(?:png|gif|jpe?g|webp);)/i
function neutralizeScriptProtocols(raw: string) {
return raw
.replace(/["']\s*javascript:/gi, '#')
.replace(/\bjavascript:/gi, '#')
.replace(/["']\s*vbscript:/gi, '#')
.replace(/\bvbscript:/gi, '#')
.replace(/\bdata:text\/html/gi, '#')
}
function sanitizeUrl(value: string | null | undefined) {
if (!value)
return ''
const trimmed = value.trim()
if (SAFE_URL_PROTOCOLS.test(trimmed))
return trimmed
return ''
}
function scrubSvgElement(svgEl: SVGElement) {
const forbiddenTags = new Set(['script'])
const nodes = [svgEl, ...Array.from(svgEl.querySelectorAll<SVGElement>('*'))]
for (const node of nodes) {
if (forbiddenTags.has(node.tagName.toLowerCase())) {
node.remove()
continue
}
const attrs = Array.from(node.attributes)
for (const attr of attrs) {
const name = attr.name
if (/^on/i.test(name)) {
node.removeAttribute(name)
continue
}
if (name === 'style' && attr.value) {
const val = attr.value
if (DISALLOWED_STYLE_PATTERNS.some(re => re.test(val))) {
node.removeAttribute(name)
continue
}
}
if ((name === 'href' || name === 'xlink:href') && attr.value) {
const safe = sanitizeUrl(attr.value)
if (!safe) {
node.removeAttribute(name)
continue
}
if (safe !== attr.value)
node.setAttribute(name, safe)
}
}
}
}
function toSafeSvgMarkup(svg: string | null | undefined) {
if (typeof window === 'undefined' || typeof DOMParser === 'undefined')
return ''
if (!svg)
return ''
const neutralized = neutralizeScriptProtocols(svg)
const parsed = new DOMParser().parseFromString(neutralized, 'image/svg+xml')
const svgEl = parsed.documentElement
if (!svgEl || svgEl.nodeName.toLowerCase() !== 'svg')
return ''
const svgElement = svgEl as unknown as SVGElement
scrubSvgElement(svgElement)
svgElement.classList.add('markstream-d2-root-svg')
return svgElement.outerHTML
}
function setSvg(svg: string) {
const safe = toSafeSvgMarkup(svg)
svgMarkup.value = safe || ''
}
function clearSvg() {
svgMarkup.value = ''
}
function extractSvg(renderResult: any) {
if (!renderResult)
return ''
if (typeof renderResult === 'string')
return renderResult
if (typeof renderResult.svg === 'string')
return renderResult.svg
if (typeof renderResult.data === 'string')
return renderResult.data
return ''
}
async function ensureD2Instance() {
if (d2Instance)
return d2Instance
const D2Ctor = await getD2()
if (!D2Ctor)
return null
if (typeof D2Ctor === 'function') {
const inst = new D2Ctor()
if (inst && typeof inst.compile === 'function')
d2Instance = inst
else if (typeof (D2Ctor as any).compile === 'function')
d2Instance = D2Ctor
return d2Instance
}
if (D2Ctor?.D2 && typeof D2Ctor.D2 === 'function') {
d2Instance = new D2Ctor.D2()
return d2Instance
}
if (typeof D2Ctor.compile === 'function')
d2Instance = D2Ctor
return d2Instance
}
async function renderDiagram() {
if (!isClient || unmounted)
return
if (props.loading && !props.progressiveRender)
return
const code = baseCode.value
if (!code) {
clearSvg()
renderError.value = null
return
}
const token = ++renderToken.value
isRendering.value = true
renderError.value = null
try {
const instance = await ensureD2Instance()
if (!instance) {
d2Available.value = false
showSource.value = true
clearSvg()
renderError.value = 'D2 is not available.'
return
}
if (typeof instance.compile !== 'function' || typeof instance.render !== 'function') {
throw new TypeError('D2 instance is missing compile/render methods.')
}
d2Available.value = true
const compileResult = await instance.compile(code)
if (token !== renderToken.value)
return
const diagram = compileResult?.diagram ?? compileResult
const baseRenderOptions = compileResult?.renderOptions ?? compileResult?.options ?? {}
const resolvedThemeId = props.themeId ?? baseRenderOptions.themeID
const resolvedDarkThemeId = props.darkThemeId ?? baseRenderOptions.darkThemeID
const renderOptions: Record<string, any> = { ...baseRenderOptions }
renderOptions.themeID = props.isDark && resolvedDarkThemeId != null
? resolvedDarkThemeId
: resolvedThemeId
// Ensure app-controlled theme takes precedence over OS-level preference.
renderOptions.darkThemeID = null
renderOptions.darkThemeOverrides = null
if (props.isDark) {
const baseOverrides = baseRenderOptions.themeOverrides && typeof baseRenderOptions.themeOverrides === 'object'
? baseRenderOptions.themeOverrides
: null
renderOptions.themeOverrides = {
...DARK_THEME_OVERRIDES,
...(baseOverrides || {}),
}
}
const renderResult = await instance.render(diagram, renderOptions)
if (token !== renderToken.value)
return
const svg = extractSvg(renderResult)
if (!svg)
throw new Error('D2 render returned empty output.')
setSvg(svg)
if (props.loading)
showSource.value = false
renderError.value = null
}
catch (err: any) {
if (token !== renderToken.value)
return
const message = err?.message ? String(err.message) : 'D2 render failed.'
if (!props.loading)
renderError.value = message
if (message.includes('@terrastruct/d2')) {
d2Available.value = false
showSource.value = true
}
}
finally {
if (token === renderToken.value) {
isRendering.value = false
if (pendingRender) {
pendingRender = false
scheduleRender()
}
}
}
}
function scheduleRender(force = false) {
if (scheduled)
return
if (!isClient)
return
if (unmounted)
return
if (isRendering.value) {
pendingRender = true
return
}
const interval = Math.max(120, Number(props.progressiveIntervalMs) || 0)
const now = Date.now()
const elapsed = now - lastRenderAt
if (!force && elapsed < interval) {
pendingRender = true
if (throttleTimer == null) {
throttleTimer = window.setTimeout(() => {
throttleTimer = null
if (pendingRender) {
pendingRender = false
scheduleRender(true)
}
}, Math.max(0, interval - elapsed))
}
return
}
scheduled = true
const runner = () => {
scheduled = false
lastRenderAt = Date.now()
renderDiagram()
}
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function')
window.requestAnimationFrame(runner)
else
setTimeout(runner, 0)
}
function exportSvg() {
if (!svgMarkup.value)
return
try {
const blob = new Blob([svgMarkup.value], { type: 'image/svg+xml;charset=utf-8' })
const url = URL.createObjectURL(blob)
if (typeof document !== 'undefined') {
const link = document.createElement('a')
link.href = url
link.download = `d2-diagram-${Date.now()}.svg`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
URL.revokeObjectURL(url)
}
catch (error) {
console.error('Failed to export SVG:', error)
}
}
function updateBodyMinHeight() {
const el = bodyRef.value
if (!el)
return
const height = el.getBoundingClientRect().height
if (height > 0)
bodyMinHeight.value = height
}
watch(
() => [props.node.code, props.loading, props.isDark],
() => {
scheduleRender()
},
{ immediate: true },
)
watch(
() => props.loading,
(loading, prev) => {
if (prev && !loading)
scheduleRender(true)
},
)
watch(
() => [showSourceFallback.value, svgMarkup.value, baseCode.value],
() => {
nextTick(() => {
updateBodyMinHeight()
})
},
)
onMounted(() => {
scheduleRender()
nextTick(() => {
updateBodyMinHeight()
})
if (typeof ResizeObserver !== 'undefined') {
bodyObserver = new ResizeObserver(() => {
updateBodyMinHeight()
})
if (bodyRef.value)
bodyObserver.observe(bodyRef.value)
}
})
onBeforeUnmount(() => {
unmounted = true
if (throttleTimer != null) {
clearTimeout(throttleTimer)
throttleTimer = null
}
bodyObserver?.disconnect()
bodyObserver = null
})
</script>
<template>
<div
class="d2-block-container my-4 rounded-lg border overflow-hidden shadow-sm"
:class="props.isDark ? 'border-gray-700/30 bg-gray-900 text-gray-100' : 'border-gray-200 bg-white text-gray-900'"
>
<div
v-if="props.showHeader"
class="d2-block-header flex justify-between items-center px-4 py-2.5 border-b border-gray-400/5"
style="color: var(--vscode-editor-foreground);background-color: var(--vscode-editor-background);"
>
<div class="flex items-center gap-x-2">
<span class="text-sm font-medium font-mono">D2</span>
</div>
<div class="flex items-center gap-x-2">
<div
v-if="props.showModeToggle"
class="flex items-center gap-x-1 rounded-md p-0.5"
:class="props.isDark ? 'bg-gray-700' : 'bg-gray-100'"
>
<button
type="button"
class="mode-btn px-2 py-1 text-xs rounded"
:class="!showSource ? 'is-active' : ''"
@click="handleSwitchMode('preview')"
@mouseenter="onBtnHover($event, t('common.preview') || 'Preview')"
@focus="onBtnHover($event, t('common.preview') || 'Preview')"
@mouseleave="onBtnLeave"
@blur="onBtnLeave"
>
{{ t('common.preview') || 'Preview' }}
</button>
<button
type="button"
class="mode-btn px-2 py-1 text-xs rounded"
:class="showSource ? 'is-active' : ''"
@click="handleSwitchMode('source')"
@mouseenter="onBtnHover($event, t('common.source') || 'Source')"
@focus="onBtnHover($event, t('common.source') || 'Source')"
@mouseleave="onBtnLeave"
@blur="onBtnLeave"
>
{{ t('common.source') || 'Source' }}
</button>
</div>
<button
v-if="props.showCopyButton"
type="button"
class="d2-action-btn p-2 text-xs rounded-md transition-colors hover:bg-[var(--vscode-editor-selectionBackground)]"
:aria-label="copyText ? (t('common.copied') || 'Copied') : (t('common.copy') || 'Copy')"
@click="copy"
@mouseenter="onCopyHover($event)"
@focus="onCopyHover($event)"
@mouseleave="onBtnLeave"
@blur="onBtnLeave"
>
<svg v-if="!copyText" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="w-3 h-3"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><rect width="14" height="14" x="8" y="8" rx="2" ry="2" /><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" /></g></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="w-3 h-3"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 6L9 17l-5-5" /></svg>
</button>
<button
v-if="props.showExportButton && svgMarkup"
type="button"
class="d2-action-btn p-2 text-xs rounded-md transition-colors hover:bg-[var(--vscode-editor-selectionBackground)]"
:aria-label="t('common.export') || 'Export'"
@click="exportSvg"
@mouseenter="onBtnHover($event, t('common.export') || 'Export')"
@focus="onBtnHover($event, t('common.export') || 'Export')"
@mouseleave="onBtnLeave"
@blur="onBtnLeave"
>
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="w-3 h-3"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v12m0-12l-4 4m4-4l4 4M4 14v4a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-4" /></svg>
</button>
<button
v-if="props.showCollapseButton"
type="button"
class="d2-action-btn p-2 text-xs rounded-md transition-colors hover:bg-[var(--vscode-editor-selectionBackground)]"
:aria-pressed="isCollapsed"
@click="toggleCollapse"
@mouseenter="onBtnHover($event, isCollapsed ? (t('common.expand') || 'Expand') : (t('common.collapse') || 'Collapse'))"
@focus="onBtnHover($event, isCollapsed ? (t('common.expand') || 'Expand') : (t('common.collapse') || 'Collapse'))"
@mouseleave="onBtnLeave"
@blur="onBtnLeave"
>
<svg :style="{ rotate: isCollapsed ? '0deg' : '90deg' }" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="w-3 h-3"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m9 18l6-6l-6-6" /></svg>
</button>
</div>
</div>
<div v-show="!isCollapsed" ref="bodyRef" class="d2-block-body" :style="bodyStyle">
<div v-if="props.loading && !hasPreview" class="d2-source px-4 py-4">
<pre class="d2-code"><code>{{ baseCode }}</code></pre>
<p v-if="renderError" class="d2-error mt-2 text-xs">
{{ renderError }}
</p>
</div>
<div v-else>
<div v-if="showSourceFallback" class="d2-source px-4 py-4">
<pre class="d2-code"><code>{{ baseCode }}</code></pre>
<p v-if="renderError" class="d2-error mt-2 text-xs">
{{ renderError }}
</p>
</div>
<div v-else class="d2-render" :style="renderStyle">
<div class="d2-svg" v-html="svgMarkup" />
<p v-if="renderError" class="d2-error px-4 pb-3 text-xs">
{{ renderError }}
</p>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.d2-block-body {
position: relative;
}
.d2-source {
font-family: var(--vscode-editor-font-family, 'Fira Code', 'SFMono-Regular', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace);
}
.d2-code {
white-space: pre;
font-size: 0.875rem;
line-height: 1.5;
}
.d2-render {
overflow: auto;
}
.d2-svg :deep(svg.markstream-d2-root-svg) {
width: 100%;
max-width: 100%;
height: auto;
display: block;
}
.mode-btn {
opacity: 0.7;
transition: opacity 0.2s;
}
.mode-btn.is-active {
opacity: 1;
font-weight: 600;
}
.d2-action-btn {
opacity: 0.7;
transition: opacity 0.2s;
}
.d2-action-btn:hover {
opacity: 1;
}
.d2-action-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.d2-error {
color: #dc2626;
}
</style>
@@ -0,0 +1,67 @@
export type D2Loader = () => Promise<any> | any
const defaultD2Loader: D2Loader = () => import('@terrastruct/d2')
let cachedD2: any = null
let d2Loader: D2Loader | null = defaultD2Loader
function resetCachedD2() {
cachedD2 = null
}
function normalizeD2Module(mod: any) {
if (!mod)
return mod
if (mod.D2 && typeof mod.D2 === 'function')
return mod.D2
if (mod.default && mod.default.D2 && typeof mod.default.D2 === 'function')
return mod.default.D2
const candidate = mod.default ?? mod
if (typeof candidate === 'function')
return candidate
if (candidate?.D2 && typeof candidate.D2 === 'function')
return candidate.D2
return candidate
}
export function setD2Loader(loader: D2Loader | null) {
d2Loader = loader
resetCachedD2()
}
export function enableD2(loader?: D2Loader) {
setD2Loader(loader ?? defaultD2Loader)
}
export function disableD2() {
setD2Loader(null)
}
export function isD2Enabled() {
return typeof d2Loader === 'function'
}
export async function getD2() {
if (cachedD2)
return cachedD2
const loader = d2Loader
if (!loader)
return null
let mod: any
try {
mod = await loader()
}
catch (err) {
if (loader === defaultD2Loader) {
throw new Error('Optional dependency "@terrastruct/d2" is not installed. Please install it to enable D2 diagrams.')
}
throw err
}
if (!mod)
return null
cachedD2 = normalizeD2Module(mod)
return cachedD2
}
@@ -0,0 +1,11 @@
import D2BlockNode from './D2BlockNode.vue'
interface Vue2App {
component: (name: string, component: any) => void
}
D2BlockNode.install = (app: Vue2App) => {
app.component(D2BlockNode.__name as string, D2BlockNode)
}
export default D2BlockNode
@@ -0,0 +1,93 @@
<script setup lang="ts">
import { computed, getCurrentInstance } from 'vue-demi'
import { isLegacyVue26Vm } from '../../utils/vue26'
import NodeRenderer from '../NodeRenderer'
import LegacyNodesRenderer from '../NodeRenderer/LegacyNodesRenderer.vue'
interface DefinitionItemNode {
type: 'definition_item'
term: { type: string, raw: string }[]
definition: { type: string, raw: string }[]
raw: string
}
interface DefinitionListNode {
type: 'definition_list'
items: DefinitionItemNode[]
raw: string
}
const props = defineProps<{
node: DefinitionListNode
indexKey: string | number
typewriter?: boolean
customId?: string
}>()
defineEmits(['copy'])
const instance = getCurrentInstance()
const nestedRenderer = computed(() => {
const vm = instance?.proxy as any
return isLegacyVue26Vm(vm) ? LegacyNodesRenderer : NodeRenderer
})
const definitionEntries = computed(() => {
return props.node.items.flatMap((item, index) => ([
{
key: `definition-term-${props.indexKey}-${index}`,
tag: 'dt',
className: 'definition-term',
nodes: item.term,
},
{
key: `definition-desc-${props.indexKey}-${index}`,
tag: 'dd',
className: 'definition-desc',
nodes: item.definition,
},
]))
})
</script>
<template>
<dl class="definition-list">
<component
:is="entry.tag"
v-for="entry in definitionEntries"
:key="entry.key"
:class="entry.className"
>
<component
:is="nestedRenderer"
:index-key="entry.key"
:nodes="entry.nodes"
:custom-id="props.customId"
:typewriter="props.typewriter"
@copy="$emit('copy', $event)"
/>
</component>
</dl>
</template>
<style scoped>
.definition-list {
margin: 0 0 1rem;
}
.definition-term {
font-weight: 600;
margin-top: 0.5rem;
}
.definition-desc {
margin-left: 1rem;
margin-bottom: 0.5rem;
}
.definition-list ::v-deep .markdown-renderer {
content-visibility: visible;
contain: content;
contain-intrinsic-size: 0px 0px;
}
</style>
@@ -0,0 +1,3 @@
import DefinitionListNode from './DefinitionListNode.vue'
export default DefinitionListNode
@@ -0,0 +1,20 @@
<script setup lang="ts">
defineProps<{
node: {
type: 'emoji'
name: string
markup: string
raw: string
}
}>()
</script>
<template>
<span class="emoji-node">{{ node.name }}</span>
</template>
<style scoped>
.emoji-node {
display: inline-block;
}
</style>
@@ -0,0 +1,3 @@
import EmojiNode from './EmojiNode.vue'
export default EmojiNode
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { getCustomNodeComponents } from '../../utils/nodeComponents'
import EmojiNode from '../EmojiNode'
import FootnoteReferenceNode from '../FootnoteReferenceNode'
import HighlightNode from '../HighlightNode'
import HtmlInlineNode from '../HtmlInlineNode'
import InlineCodeNode from '../InlineCodeNode'
import InsertNode from '../InsertNode'
import LinkNode from '../LinkNode'
import { MathInlineNodeAsync } from '../NodeRenderer/asyncComponent'
import ReferenceNode from '../ReferenceNode'
import StrikethroughNode from '../StrikethroughNode'
import StrongNode from '../StrongNode'
import SubscriptNode from '../SubscriptNode'
import SuperscriptNode from '../SuperscriptNode'
import TextNode from '../TextNode'
interface NodeChild {
type: string
raw: string
[key: string]: unknown
}
const props = defineProps<{
node: {
type: 'emphasis'
children: NodeChild[]
raw: string
}
customId?: string
indexKey?: number | string
}>()
const overrides = getCustomNodeComponents(props.customId)
// Available node components for child rendering; prefer custom overrides
const nodeComponents = {
text: TextNode,
inline_code: InlineCodeNode,
link: LinkNode,
html_inline: HtmlInlineNode,
strong: StrongNode,
strikethrough: StrikethroughNode,
highlight: HighlightNode,
insert: InsertNode,
subscript: SubscriptNode,
superscript: SuperscriptNode,
emoji: EmojiNode,
footnote_reference: FootnoteReferenceNode,
math_inline: MathInlineNodeAsync,
reference: ReferenceNode,
...overrides,
}
</script>
<template>
<em class="emphasis-node">
<component
:is="nodeComponents[child.type]"
v-for="(child, index) in node.children"
:key="`${indexKey || 'emphasis'}-${index}`"
:node="child"
:custom-id="props.customId"
:index-key="`${indexKey || 'emphasis'}-${index}`"
/>
</em>
</template>
<style scoped>
.emphasis-node {
font-style: italic;
}
</style>
@@ -0,0 +1,3 @@
import EmphasisNode from './EmphasisNode.vue'
export default EmphasisNode
@@ -0,0 +1,33 @@
<script setup lang="ts">
const props = defineProps<{ node: { type: 'footnote_anchor', id: string, raw?: string } }>()
function scrollToReference(e) {
e.preventDefault()
if (typeof document === 'undefined')
return
const id = `fnref-${String(props.node.id ?? '')}`
// Try to find the reference element rendered by FootnoteReferenceNode.
// FootnoteReferenceNode renders a span. We search for .footnote-link text matching [id].
const anchors = document.getElementById(id)
if (anchors) {
anchors.scrollIntoView({ behavior: 'smooth' })
}
}
</script>
<template>
<a
class="footnote-anchor text-sm text-[#0366d6] hover:underline cursor-pointer"
:href="`#fnref-${node.id}`"
:title="`返回引用 ${node.id}`"
@click="scrollToReference"
>
↩︎
</a>
</template>
<style scoped>
.footnote-anchor {
margin-left: 0.5rem;
}
</style>
@@ -0,0 +1,3 @@
import FootnoteAnchorNode from './FootnoteAnchorNode.vue'
export default FootnoteAnchorNode
@@ -0,0 +1,59 @@
<script setup lang="ts">
import { computed, getCurrentInstance } from 'vue-demi'
import { isLegacyVue26Vm } from '../../utils/vue26'
import NodeRenderer from '../NodeRenderer'
import LegacyNodesRenderer from '../NodeRenderer/LegacyNodesRenderer.vue'
// 定义脚注节点
interface FootnoteNode {
type: 'footnote'
id: string
children: { type: string, raw: string }[]
raw: string
}
// 接收props
const props = defineProps<{
node: FootnoteNode
indexKey: string | number
typewriter?: boolean
customId?: string
}>()
// 定义事件
defineEmits(['copy'])
const instance = getCurrentInstance()
const nestedRenderer = computed(() => {
const vm = instance?.proxy as any
return isLegacyVue26Vm(vm) ? LegacyNodesRenderer : NodeRenderer
})
</script>
<template>
<div
:id="`fnref--${node.id}`"
class="flex mt-2 mb-2 text-sm leading-relaxed border-t border-[#eaecef] pt-2"
>
<!-- <span class="font-semibold mr-2 text-[#0366d6]">[{{ node.id }}]</span> -->
<div class="flex-1">
<component
:is="nestedRenderer"
:index-key="`footnote-${props.indexKey}`"
:nodes="props.node.children"
:custom-id="props.customId"
:typewriter="props.typewriter"
@copy="$emit('copy', $event)"
/>
</div>
</div>
</template>
<style>
/* 脚注中嵌套 NodeRenderer 关闭 content-visibility 占位,防止空白内容 */
.markstream-vue2 [class*="footnote-"] .markdown-renderer,
.markstream-vue2 .flex-1 .markdown-renderer {
content-visibility: visible;
contain: content;
contain-intrinsic-size: 0px 0px;
}
</style>
@@ -0,0 +1,3 @@
import FootnoteNode from './FootnoteNode.vue'
export default FootnoteNode
@@ -0,0 +1,53 @@
<script setup lang="ts">
// 定义脚注引用节点
interface FootnoteReferenceNode {
type: 'footnote_reference'
id: string
raw: string
}
// 接收props
const props = defineProps<{
node: FootnoteReferenceNode
}>()
const href = `#footnote-${props.node.id}`
function handleScroll() {
if (typeof document === 'undefined') {
// SSR: nothing to do
return
}
const element = document.querySelector(href)
if (element) {
element.scrollIntoView({ behavior: 'smooth' })
}
else {
console.warn(`Element with href: ${href} not found`)
}
}
</script>
<template>
<sup :id="`fnref-${node.id}`" class="footnote-reference" @click="handleScroll">
<span
:href="href"
:title="`查看脚注 ${node.id}`"
class="footnote-link cursor-pointer"
>[{{ node.id }}]</span>
</sup>
</template>
<style scoped>
.footnote-reference {
font-size: 0.75em;
line-height: 0;
}
.footnote-link {
color: #0366d6;
text-decoration: none;
}
.footnote-link:hover {
text-decoration: underline;
}
</style>
@@ -0,0 +1,3 @@
import FootnoteReferenceNode from './FootnoteReferenceNode.vue'
export default FootnoteReferenceNode
@@ -0,0 +1,22 @@
<script setup lang="ts">
// 定义硬换行节点
interface HardBreakNode {
type: 'hardbreak'
raw: string
}
// 接收props
defineProps<{
node: HardBreakNode
}>()
</script>
<template>
<br class="hard-break">
</template>
<style scoped>
.hard-break {
display: block;
}
</style>
@@ -0,0 +1,3 @@
import HardBreakNode from './HardBreakNode.vue'
export default HardBreakNode
@@ -0,0 +1,116 @@
<script setup lang="ts">
import { getCustomNodeComponents } from '../../utils/nodeComponents'
import CheckboxNode from '../CheckboxNode'
import EmojiNode from '../EmojiNode'
import EmphasisNode from '../EmphasisNode'
import FootnoteReferenceNode from '../FootnoteReferenceNode'
import HardBreakNode from '../HardBreakNode'
import HighlightNode from '../HighlightNode'
import ImageNode from '../ImageNode'
import InlineCodeNode from '../InlineCodeNode'
import InsertNode from '../InsertNode'
import LinkNode from '../LinkNode'
import { MathInlineNodeAsync } from '../NodeRenderer/asyncComponent'
import ReferenceNode from '../ReferenceNode'
import StrikethroughNode from '../StrikethroughNode'
import StrongNode from '../StrongNode'
import SubscriptNode from '../SubscriptNode'
import SuperscriptNode from '../SuperscriptNode'
import TextNode from '../TextNode'
// Define the type for the node children
interface NodeChild {
type: string
raw: string
[key: string]: unknown
}
const props = defineProps<{
node: {
type: 'heading'
level: number
text: string
attrs?: Record<string, string | boolean>
children: NodeChild[]
raw: string
}
customId?: string
indexKey?: number | string
}>()
const overrides = getCustomNodeComponents(props.customId)
const nodeComponents = {
text: TextNode,
inline_code: InlineCodeNode,
link: LinkNode,
image: ImageNode,
strong: StrongNode,
emphasis: EmphasisNode,
strikethrough: StrikethroughNode,
highlight: HighlightNode,
insert: InsertNode,
subscript: SubscriptNode,
superscript: SuperscriptNode,
emoji: EmojiNode,
checkbox: CheckboxNode,
checkbox_input: CheckboxNode,
footnote_reference: FootnoteReferenceNode,
hardbreak: HardBreakNode,
math_inline: MathInlineNodeAsync,
reference: ReferenceNode,
...overrides,
}
</script>
<template>
<component
:is="`h${node.level}`"
class="heading-node"
:class="[`heading-${node.level}`]"
dir="auto"
v-bind="node.attrs"
>
<component
:is="nodeComponents[child.type]"
v-for="(child, index) in node.children"
:key="`${indexKey || 'heading'}-${index}`"
:custom-id="props.customId"
:node="child"
:index-key="`${indexKey || 'heading'}-${index}`"
/>
</component>
</template>
<style scoped>
.heading-node {
@apply font-medium leading-tight;
}
hr + .heading-node {
@apply mt-0;
}
.heading-1 {
@apply mt-0 mb-[calc(8/9*1em)] text-4xl leading-[calc(10/9*1)] font-extrabold;
}
.heading-2 {
@apply mt-8 mb-4 text-2xl leading-[calc(4/3*1)] font-bold;
}
.heading-3 {
@apply mt-[calc(8/5*1em)] mb-[calc(3/5*1em)] text-xl font-semibold leading-[calc(5/3*1)];
}
.heading-4 {
@apply mt-6 mb-2 text-base font-semibold;
}
.heading-5 {
@apply m-0 text-base;
}
.heading-6 {
@apply m-0 text-base;
}
</style>
@@ -0,0 +1,3 @@
import HeadingNode from './HeadingNode.vue'
export default HeadingNode
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { getCustomNodeComponents } from '../../utils/nodeComponents'
import EmojiNode from '../EmojiNode'
import EmphasisNode from '../EmphasisNode'
import FootnoteReferenceNode from '../FootnoteReferenceNode'
import HtmlInlineNode from '../HtmlInlineNode'
import InlineCodeNode from '../InlineCodeNode'
import InsertNode from '../InsertNode'
import LinkNode from '../LinkNode'
import { MathInlineNodeAsync } from '../NodeRenderer/asyncComponent'
import ReferenceNode from '../ReferenceNode'
import StrikethroughNode from '../StrikethroughNode'
import StrongNode from '../StrongNode'
import SubscriptNode from '../SubscriptNode'
import SuperscriptNode from '../SuperscriptNode'
import TextNode from '../TextNode'
interface NodeChild {
type: string
raw: string
[key: string]: unknown
}
const props = defineProps<{
node: {
type: 'highlight'
children: NodeChild[]
raw: string
}
customId?: string
indexKey?: number | string
}>()
const overrides = getCustomNodeComponents(props.customId)
// Available node components for child rendering; prefer custom overrides
const nodeComponents = {
text: TextNode,
inline_code: InlineCodeNode,
link: LinkNode,
html_inline: HtmlInlineNode,
strong: StrongNode,
emphasis: EmphasisNode,
strikethrough: StrikethroughNode,
insert: InsertNode,
subscript: SubscriptNode,
superscript: SuperscriptNode,
emoji: EmojiNode,
footnote_reference: FootnoteReferenceNode,
math_inline: MathInlineNodeAsync,
reference: ReferenceNode,
...overrides,
}
</script>
<template>
<mark class="highlight-node">
<component
:is="nodeComponents[child.type]"
v-for="(child, index) in node.children"
:key="`${indexKey || 'highlight'}-${index}`"
:node="child"
:custom-id="props.customId"
:index-key="`${indexKey || 'highlight'}-${index}`"
/>
</mark>
</template>
<style scoped>
.highlight-node {
background-color: #ffff00;
padding: 0 0.2rem;
}
</style>
@@ -0,0 +1,3 @@
import HighlightNode from './HighlightNode.vue'
export default HighlightNode
@@ -0,0 +1,200 @@
<script setup lang="ts">
import { NON_STRUCTURING_HTML_TAGS, sanitizeHtmlContent, sanitizeHtmlTokenAttrs, tokenAttrsToRecord } from 'stream-markdown-parser'
import { computed, defineComponent, onBeforeUnmount, ref, watch } from 'vue-demi'
import { useViewportPriority } from '../../composables/viewportPriority'
import { hasCustomComponents, parseHtmlToVNodes } from '../../utils/htmlRenderer'
import { renderMarkdownNodesToHtml } from '../../utils/nestedHtml'
import { getCustomNodeComponents } from '../../utils/nodeComponents'
const props = defineProps<{
node: {
content: string
raw?: string
tag?: string
attrs?: [string, string][] | null
children?: any[]
loading?: boolean
}
customId?: string
}>()
const boundAttrs = computed(() => {
const sanitizedAttrs = sanitizeHtmlTokenAttrs(props.node.attrs)
if (!sanitizedAttrs)
return undefined
const record = tokenAttrsToRecord(sanitizedAttrs)
return Object.keys(record).length > 0 ? record : undefined
})
// Get custom components from global registry
const customComponents = computed(() => {
return getCustomNodeComponents(props.customId)
})
// Dynamic wrapper component for rendering VNodes
const DynamicRenderer = defineComponent({
name: 'DynamicRenderer',
props: {
content: {
type: String,
required: true,
},
customComponents: {
type: Object as () => Record<string, any>,
required: true,
},
},
render() {
const nodes = parseHtmlToVNodes(this.content, this.customComponents)
return (nodes || []) as any
},
})
const structuredChildren = computed(() => Array.isArray(props.node.children) ? props.node.children : [])
const structuredTag = computed(() => String(props.node.tag || 'div'))
const isBlockedStructuredTag = computed(() => NON_STRUCTURING_HTML_TAGS.has(structuredTag.value.trim().toLowerCase()))
const structuredHtml = computed(() => {
if (structuredChildren.value.length === 0 || !props.node.tag || isBlockedStructuredTag.value)
return ''
return renderMarkdownNodesToHtml(structuredChildren.value as any)
})
// Computed property to determine render mode and content
const renderMode = computed(() => {
if (structuredChildren.value.length > 0 && !!props.node.tag && !isBlockedStructuredTag.value)
return { mode: 'structured' as const }
const content = props.node.content
if (!content)
return { mode: 'html', content: '' }
// Check if content contains custom components
if (!hasCustomComponents(content, customComponents.value))
return { mode: 'html', content: sanitizeHtmlContent(content) }
return { mode: 'dynamic', content }
})
const htmlRef = ref<HTMLElement | null>(null)
const shouldRender = ref(typeof window === 'undefined')
const renderContent = ref(props.node.content)
const registerVisibility = useViewportPriority()
let visibilityHandle: ReturnType<typeof registerVisibility> | null = null
const isDeferred = !!props.node.loading
if (typeof window !== 'undefined') {
watch(
htmlRef,
(el) => {
visibilityHandle?.destroy?.()
visibilityHandle = null
if (!isDeferred) {
shouldRender.value = true
renderContent.value = props.node.content
return
}
if (!el) {
shouldRender.value = false
return
}
const handle = registerVisibility(el, { rootMargin: '400px' })
visibilityHandle = handle
shouldRender.value = handle.isVisible.value
handle.whenVisible.then(() => {
shouldRender.value = true
})
},
{ immediate: true },
)
watch(
() => props.node.content,
(val) => {
if (!isDeferred || shouldRender.value) {
renderContent.value = val
}
},
)
}
else {
shouldRender.value = true
}
onBeforeUnmount(() => {
visibilityHandle?.destroy?.()
visibilityHandle = null
})
</script>
<template>
<!-- eslint-disable vue/no-v-text-v-html-on-component -->
<component
:is="structuredTag"
v-if="renderMode.mode === 'structured' && shouldRender"
ref="htmlRef"
class="html-block-node"
v-bind="boundAttrs"
v-html="structuredHtml"
/>
<!-- eslint-enable vue/no-v-text-v-html-on-component -->
<component
:is="structuredTag"
v-else-if="renderMode.mode === 'structured'"
ref="htmlRef"
class="html-block-node"
v-bind="boundAttrs"
>
<div class="html-block-node__placeholder">
<slot name="placeholder" :node="node">
<span class="html-block-node__placeholder-bar" />
<span class="html-block-node__placeholder-bar w-4/5" />
<span class="html-block-node__placeholder-bar w-2/3" />
</slot>
</div>
</component>
<div
v-else
ref="htmlRef"
class="html-block-node"
>
<template v-if="shouldRender">
<!-- Use dynamic rendering for custom components -->
<DynamicRenderer v-if="renderMode.mode === 'dynamic'" :content="renderMode.content" :custom-components="customComponents" />
<!-- Fallback to v-html for standard HTML -->
<div v-else v-bind="boundAttrs" v-html="renderMode.content" />
</template>
<div v-else class="html-block-node__placeholder">
<slot name="placeholder" :node="node">
<span class="html-block-node__placeholder-bar" />
<span class="html-block-node__placeholder-bar w-4/5" />
<span class="html-block-node__placeholder-bar w-2/3" />
</slot>
</div>
</div>
</template>
<style scoped>
.html-block-node__placeholder {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.5rem 0;
}
.html-block-node__placeholder-bar {
display: block;
height: 0.8rem;
border-radius: 9999px;
background-image: linear-gradient(90deg, rgba(148, 163, 184, 0.35), rgba(148, 163, 184, 0.1), rgba(148, 163, 184, 0.35));
background-size: 200% 100%;
animation: html-block-node-shimmer 1.2s ease infinite;
}
@keyframes html-block-node-shimmer {
0% {
background-position: 0% 0%;
}
100% {
background-position: 200% 0%;
}
}
</style>
@@ -0,0 +1,3 @@
import HtmlBlockNode from './HtmlBlockNode.vue'
export default HtmlBlockNode
@@ -0,0 +1,88 @@
<script setup lang="ts">
import { sanitizeHtmlContent } from 'stream-markdown-parser'
import { computed, defineComponent } from 'vue-demi'
import { hasCustomComponents, parseHtmlToVNodes } from '../../utils/htmlRenderer'
import { getCustomNodeComponents } from '../../utils/nodeComponents'
const props = defineProps<{
node: {
type: 'html_inline'
tag?: string
content: string
loading?: boolean
autoClosed?: boolean
}
customId?: string
}>()
// Get custom components from global registry
const customComponents = computed(() => {
return getCustomNodeComponents(props.customId)
})
// Dynamic wrapper component for rendering VNodes
const DynamicRenderer = defineComponent({
name: 'DynamicRenderer',
props: {
content: {
type: String,
required: true,
},
customComponents: {
type: Object as () => Record<string, any>,
required: true,
},
},
render() {
const nodes = parseHtmlToVNodes(this.content, this.customComponents)
return (nodes || []) as any
},
})
// Computed property to determine render mode and content
const renderMode = computed(() => {
const content = props.node.content
if (!content)
return { mode: 'html', content: '' }
if (props.node.loading && !props.node.autoClosed)
return { mode: 'text', content }
// Check if content contains custom components
if (!hasCustomComponents(content, customComponents.value))
return { mode: 'html', content: sanitizeHtmlContent(content) }
return { mode: 'dynamic', content }
})
</script>
<template>
<span
v-if="renderMode.mode === 'dynamic'"
class="html-inline-node"
:class="{ 'html-inline-node--loading': props.node.loading }"
>
<DynamicRenderer :content="renderMode.content" :custom-components="customComponents" />
</span>
<span
v-else-if="renderMode.mode === 'text'"
class="html-inline-node"
:class="{ 'html-inline-node--loading': props.node.loading }"
>{{ renderMode.content }}</span>
<span
v-else
class="html-inline-node"
:class="{ 'html-inline-node--loading': props.node.loading }"
v-html="renderMode.content"
/>
</template>
<style scoped>
.html-inline-node {
display: inline;
}
.html-inline-node--loading {
opacity: 0.85;
}
</style>
@@ -0,0 +1,3 @@
import HtmlInlineNode from './HtmlInlineNode.vue'
export default HtmlInlineNode
@@ -0,0 +1,161 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue-demi'
import { useSafeI18n } from '../../composables/useSafeI18n'
interface ImageNodeProps {
node: {
type: 'image'
src: string
alt: string
title: string | null
raw: string
loading?: boolean
}
fallbackSrc?: string
lazy?: boolean
usePlaceholder?: boolean
}
const props = withDefaults(defineProps<ImageNodeProps>(), {
fallbackSrc: '',
lazy: true,
usePlaceholder: true,
})
const emit = defineEmits<{ (e: 'load', src: string): void, (e: 'error', src: string): void, (e: 'click', payload: [Event, string]): void }>()
const imageLoaded = ref(false)
const hasError = ref(false)
const fallbackTried = ref(false)
const displaySrc = computed(() => hasError.value && props.fallbackSrc ? props.fallbackSrc : props.node.src)
function handleImageError() {
if (props.fallbackSrc && !fallbackTried.value) {
fallbackTried.value = true
hasError.value = true
// leave imageLoaded false so placeholder/spinner can show while fallback loads
}
else {
hasError.value = true
emit('error', props.node.src)
}
}
function handleImageLoad() {
imageLoaded.value = true
hasError.value = false
emit('load', displaySrc.value)
}
function handleClick(e: Event) {
e.preventDefault()
if (!imageLoaded.value || hasError.value)
return
emit('click', [e, displaySrc.value])
}
const { t } = useSafeI18n()
watch(displaySrc, () => {
imageLoaded.value = false
hasError.value = false
})
</script>
<template>
<transition name="img-switch" mode="out-in">
<img
v-if="!node.loading && !hasError"
key="image"
:src="displaySrc"
:alt="String(props.node.alt ?? props.node.title ?? '')"
:title="String(props.node.title ?? props.node.alt ?? '')"
class="image-node__img h-auto rounded-lg transition-opacity duration-200 ease-in-out image-node__img--inline"
:class="{
'opacity-0': !imageLoaded,
'opacity-100': imageLoaded,
'cursor-pointer': imageLoaded,
}"
:loading="props.lazy ? 'lazy' : 'eager'"
decoding="async"
:tabindex="imageLoaded ? 0 : -1"
:aria-label="props.node.alt ?? t('image.preview')"
@error="handleImageError"
@load="handleImageLoad"
@click="handleClick"
>
<span
v-else-if="!hasError"
key="placeholder"
class="placeholder-layer placeholder-layer--inline inline-flex items-center justify-center gap-2"
>
<template v-if="props.usePlaceholder">
<slot name="placeholder" :node="props.node" :display-src="displaySrc" :image-loaded="imageLoaded" :has-error="hasError" :fallback-src="props.fallbackSrc" :lazy="props.lazy">
<div class="w-4 h-4 rounded-full border-2 border-solid border-current border-t-transparent animate-spin" aria-hidden="true" />
<span class="text-sm whitespace-nowrap">{{ t('image.loading') }}</span>
</slot>
</template>
<template v-else>
<span class="text-sm text-gray-500">{{ node.raw }}</span>
</template>
</span>
<span v-else-if="!node.loading && !props.fallbackSrc" key="error" class="image-node__error image-node__error--inline px-4 py-2 bg-gray-100 flex items-center justify-center rounded-lg gap-2 text-red-500">
<slot name="error" :node="props.node" :display-src="displaySrc" :image-loaded="imageLoaded" :has-error="hasError" :fallback-src="props.fallbackSrc" :lazy="props.lazy">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><!-- Icon from TDesign Icons by TDesign - https://github.com/Tencent/tdesign-icons/blob/main/LICENSE --><path fill="currentColor" d="M2 2h20v10h-2V4H4v9.586l5-5L14.414 14L13 15.414l-4-4l-5 5V20h8v2H2zm13.547 5a1 1 0 1 0 0 2a1 1 0 0 0 0-2m-3 1a3 3 0 1 1 6 0a3 3 0 0 1-6 0m3.625 6.757L19 17.586l2.828-2.829l1.415 1.415L20.414 19l2.829 2.828l-1.415 1.415L19 20.414l-2.828 2.829l-1.415-1.415L17.586 19l-2.829-2.828z" /></svg>
<span class="text-sm whitespace-nowrap">{{ t('image.loadError') }}</span>
</slot>
</span>
</transition>
</template>
<style scoped>
.image-node__img {
max-width: 24rem;
}
.placeholder-layer {
max-width: 24rem;
}
.image-node__img--inline {
min-height: 0 !important;
width: auto !important;
height: auto !important;
object-fit: initial !important;
display: inline-block;
vertical-align: middle;
}
.placeholder-layer--inline {
min-height: auto !important;
}
.image-node__error--inline {
display: inline-flex;
vertical-align: middle;
}
.img-switch-enter-active, .img-switch-leave-active {
transition: opacity 220ms ease, transform 220ms ease;
}
.img-switch-enter-from, .img-switch-leave-to {
opacity: 0;
transform: translateY(6px);
}
.img-switch-enter-to, .img-switch-leave-from {
opacity: 1;
transform: translateY(0);
}
.placeholder-layer {
will-change: transform, opacity;
}
@media (prefers-reduced-motion: reduce) {
.spinner { animation: none !important; }
.img-switch-enter-active, .img-switch-leave-active { transition: none !important; }
}
</style>
@@ -0,0 +1,3 @@
import ImageNode from './ImageNode.vue'
export default ImageNode
@@ -0,0 +1,750 @@
<script setup lang="ts">
// import type { InfographicBlockNodeProps } from '../../types/component-props'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue-demi'
import { useSafeI18n } from '../../composables/useSafeI18n'
import { hideTooltip, showTooltipForAnchor } from '../../composables/useSingletonTooltip'
import infographicIconUrl from '../../icon/infographic.svg?url'
import { clampInfographicPreviewHeight, estimateInfographicPreviewHeight, parsePositiveNumber } from '../../utils/diagramHeight'
import Portal from '../Portal'
import { getInfographic } from './infographic'
interface InfographicBlockNodeProps {
node: any
maxHeight?: string | null
estimatedPreviewHeightPx?: number
loading?: boolean
isDark?: boolean
showHeader?: boolean
showModeToggle?: boolean
showCopyButton?: boolean
showExportButton?: boolean
showFullscreenButton?: boolean
showCollapseButton?: boolean
showZoomControls?: boolean
}
const props = withDefaults(
defineProps<InfographicBlockNodeProps>(),
{
maxHeight: '500px',
loading: true,
showHeader: true,
showCopyButton: true,
showCollapseButton: true,
showModeToggle: true,
showExportButton: true,
showFullscreenButton: true,
showZoomControls: true,
},
)
const _emits = defineEmits(['copy', 'export', 'openModal'])
const { t } = useSafeI18n()
const copyText = ref(false)
const isCollapsed = ref(false)
const infographicContainer = ref<HTMLElement>()
const showSource = ref(false)
const isModalOpen = ref(false)
const modalContent = ref<HTMLElement>()
const modalCloneWrapper = ref<HTMLElement | null>(null)
const baseCode = computed(() => props.node.code)
const maxPreviewHeight = computed(() => {
if (!props.maxHeight || props.maxHeight === 'none')
return null
return parsePositiveNumber(props.maxHeight)
})
const estimatedPreviewHeight = computed(() => {
return clampInfographicPreviewHeight(
parsePositiveNumber(props.estimatedPreviewHeightPx) ?? estimateInfographicPreviewHeight(baseCode.value),
undefined,
maxPreviewHeight.value,
)
})
const containerHeight = ref<string>(`${estimatedPreviewHeight.value}px`)
function resolveContainerHeight(actualHeight: number) {
if (!props.maxHeight || props.maxHeight === 'none')
return `${Math.max(actualHeight, estimatedPreviewHeight.value)}px`
const maxHeight = maxPreviewHeight.value
if (maxHeight == null)
return `${Math.max(actualHeight, estimatedPreviewHeight.value)}px`
return `${Math.max(Math.min(actualHeight, maxHeight), estimatedPreviewHeight.value)}px`
}
function updateContainerHeight() {
if (!infographicContainer.value)
return
const actualHeight = infographicContainer.value.scrollHeight
if (actualHeight > 0)
containerHeight.value = resolveContainerHeight(actualHeight)
}
// Zoom state
const zoom = ref(1)
const translateX = ref(0)
const translateY = ref(0)
const isDragging = ref(false)
const dragStart = ref({ x: 0, y: 0 })
watch(
estimatedPreviewHeight,
(height) => {
if (showSource.value || isCollapsed.value)
return
const currentHeight = parsePositiveNumber(containerHeight.value)
if (currentHeight == null || currentHeight < height)
containerHeight.value = `${height}px`
},
)
// Tooltip helpers
type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right'
function shouldSkipEventTarget(el: EventTarget | null) {
const btn = el as HTMLButtonElement | null
return !btn || (btn as HTMLButtonElement).disabled
}
function onBtnHover(e: Event, text: string, place: TooltipPlacement = 'top') {
if (shouldSkipEventTarget(e.currentTarget))
return
const ev = e as MouseEvent
const origin = ev?.clientX != null && ev?.clientY != null ? { x: ev.clientX, y: ev.clientY } : undefined
showTooltipForAnchor(e.currentTarget as HTMLElement, text, place, false, origin, props.isDark)
}
function onBtnLeave() {
hideTooltip()
}
function onCopyHover(e: Event) {
if (shouldSkipEventTarget(e.currentTarget))
return
const txt = copyText.value ? (t('common.copied') || 'Copied') : (t('common.copy') || 'Copy')
const ev = e as MouseEvent
const origin = ev?.clientX != null && ev?.clientY != null ? { x: ev.clientX, y: ev.clientY } : undefined
showTooltipForAnchor(e.currentTarget as HTMLElement, txt, 'top', false, origin, props.isDark)
}
// Copy functionality
async function copy() {
try {
const text = baseCode.value
if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
await navigator.clipboard.writeText(text)
}
copyText.value = true
setTimeout(() => {
copyText.value = false
}, 1000)
}
catch (err) {
console.error('Failed to copy:', err)
}
}
function handleSwitchMode(mode: 'preview' | 'source') {
showSource.value = mode === 'source'
}
// Export SVG functionality
function handleExportClick() {
const svgElement = infographicContainer.value?.querySelector('svg')
if (!svgElement) {
console.error('SVG element not found')
return
}
exportSvg(svgElement)
}
async function exportSvg(svgElement: SVGElement) {
try {
const svgData = new XMLSerializer().serializeToString(svgElement)
const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' })
const url = URL.createObjectURL(blob)
if (typeof document !== 'undefined') {
const link = document.createElement('a')
link.href = url
link.download = `infographic-${Date.now()}.svg`
try {
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
catch {}
URL.revokeObjectURL(url)
}
}
catch (error) {
console.error('Failed to export SVG:', error)
}
}
// Modal/Fullscreen functionality
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && isModalOpen.value) {
closeModal()
}
}
function openModal() {
isModalOpen.value = true
if (typeof document !== 'undefined') {
try {
document.body.style.overflow = 'hidden'
}
catch {}
}
if (typeof window !== 'undefined') {
try {
window.addEventListener('keydown', handleKeydown)
}
catch {}
}
nextTick(() => {
if (infographicContainer.value && modalContent.value) {
// Clear previous
modalContent.value.innerHTML = ''
// Create a wrapper for transform
const wrapper = document.createElement('div')
wrapper.style.transition = 'transform 0.1s ease'
wrapper.style.transformOrigin = 'center center'
wrapper.style.width = '100%'
wrapper.style.height = '100%'
wrapper.style.display = 'flex'
wrapper.style.alignItems = 'center'
wrapper.style.justifyContent = 'center'
// Clone the content
const clone = infographicContainer.value.cloneNode(true) as HTMLElement
clone.classList.add('fullscreen')
// Remove any fixed height from the clone to allow it to scale properly in flex
clone.style.height = 'auto'
wrapper.appendChild(clone)
modalContent.value.appendChild(wrapper)
modalCloneWrapper.value = wrapper
// Sync initial transform
wrapper.style.transform = `translate(${translateX.value}px, ${translateY.value}px) scale(${zoom.value})`
}
})
}
function closeModal() {
isModalOpen.value = false
if (modalContent.value) {
modalContent.value.innerHTML = ''
}
modalCloneWrapper.value = null
if (typeof document !== 'undefined') {
try {
document.body.style.overflow = ''
}
catch {}
}
if (typeof window !== 'undefined') {
try {
window.removeEventListener('keydown', handleKeydown)
}
catch {}
}
}
// Keep modal clone in sync with transform changes
function handleOpenModalClick() {
openModal()
}
// Zoom controls
function zoomIn() {
if (zoom.value < 3) {
zoom.value += 0.1
}
}
function zoomOut() {
if (zoom.value > 0.5) {
zoom.value -= 0.1
}
}
function resetZoom() {
zoom.value = 1
translateX.value = 0
translateY.value = 0
}
// Drag functionality
function startDrag(e: MouseEvent | TouchEvent) {
isDragging.value = true
if (e instanceof MouseEvent) {
dragStart.value = {
x: e.clientX - translateX.value,
y: e.clientY - translateY.value,
}
}
else {
dragStart.value = {
x: e.touches[0].clientX - translateX.value,
y: e.touches[0].clientY - translateY.value,
}
}
}
function onDrag(e: MouseEvent | TouchEvent) {
if (!isDragging.value)
return
let clientX: number
let clientY: number
if (e instanceof MouseEvent) {
clientX = e.clientX
clientY = e.clientY
}
else {
clientX = e.touches[0].clientX
clientY = e.touches[0].clientY
}
translateX.value = clientX - dragStart.value.x
translateY.value = clientY - dragStart.value.y
}
function stopDrag() {
isDragging.value = false
}
let infographicInstance: any | null = null
async function renderInfographic() {
if (!infographicContainer.value)
return
try {
const InfographicClass = await getInfographic()
if (!InfographicClass) {
console.warn('Infographic library failed to load.')
return
}
// Clear previous instance
if (infographicInstance) {
infographicInstance.destroy?.()
infographicInstance = null
}
// Clear container
infographicContainer.value.innerHTML = ''
// Create new instance
infographicInstance = new InfographicClass({
container: infographicContainer.value,
width: '100%',
height: '100%',
})
// Render the syntax
infographicInstance.render(baseCode.value)
// Update container height after render
nextTick(() => {
updateContainerHeight()
})
}
catch (error) {
console.error('Failed to render infographic:', error)
if (infographicContainer.value) {
infographicContainer.value.innerHTML = `<div class="text-red-500 p-4">Failed to render infographic: ${error instanceof Error ? error.message : 'Unknown error'}</div>`
}
}
}
// Watch for code changes
watch(
() => baseCode.value,
() => {
if (!showSource.value && !isCollapsed.value) {
nextTick(() => {
renderInfographic()
})
}
},
)
// Watch for mode changes
watch(
() => showSource.value,
(isSource) => {
if (!isSource && !isCollapsed.value) {
nextTick(() => {
renderInfographic()
})
}
},
)
// Watch for collapse changes
watch(
() => isCollapsed.value,
(collapsed) => {
if (!collapsed && !showSource.value) {
nextTick(() => {
renderInfographic()
})
}
},
)
watch(
() => props.maxHeight,
() => {
if (!showSource.value && !isCollapsed.value) {
nextTick(() => {
renderInfographic()
})
}
},
)
onMounted(() => {
if (!showSource.value && !isCollapsed.value) {
nextTick(() => {
renderInfographic()
})
}
})
onBeforeUnmount(() => {
if (infographicInstance) {
infographicInstance.destroy?.()
infographicInstance = null
}
if (typeof window !== 'undefined') {
try {
window.removeEventListener('keydown', handleKeydown)
}
catch {}
}
})
const computedButtonStyle = computed(() => {
return props.isDark
? 'infographic-action-btn p-2 text-xs rounded text-gray-400 hover:bg-gray-700 hover:text-gray-200'
: 'infographic-action-btn p-2 text-xs rounded text-gray-600 hover:bg-gray-200 hover:text-gray-700'
})
const isFullscreenDisabled = computed(() => showSource.value || isCollapsed.value)
const transformStyle = computed(() => ({
transform: `translate(${translateX.value}px, ${translateY.value}px) scale(${zoom.value})`,
}))
// Keep modal clone in sync with transform changes
watch(
transformStyle,
(newStyle) => {
if (isModalOpen.value && modalCloneWrapper.value) {
modalCloneWrapper.value.style.transform = newStyle.transform
}
},
)
</script>
<template>
<div
data-markstream-infographic="1"
:data-markstream-mode="showSource ? 'source' : infographicInstance ? 'preview' : 'pending'"
class="my-4 rounded-lg border overflow-hidden shadow-sm"
:class="[
props.isDark ? 'border-gray-700/30' : 'border-gray-200',
{ 'is-rendering': props.loading },
]"
>
<!-- Header -->
<div
v-if="props.showHeader"
class="infographic-block-header flex justify-between items-center px-4 py-2.5 border-b"
:class="props.isDark ? 'bg-gray-800 border-gray-700/30' : 'bg-gray-50 border-gray-200'"
>
<!-- Left side -->
<div v-if="$slots['header-left']">
<slot name="header-left" />
</div>
<div v-else class="flex items-center gap-x-2 overflow-hidden">
<img :src="infographicIconUrl" class="w-4 h-4 my-0" alt="Infographic">
<span class="text-sm font-medium font-mono truncate" :class="props.isDark ? 'text-gray-400' : 'text-gray-600'">Infographic</span>
</div>
<!-- Center - Mode toggle -->
<div v-if="$slots['header-center']">
<slot name="header-center" />
</div>
<div v-else-if="props.showModeToggle" class="flex items-center gap-x-1 rounded-md p-0.5" :class="props.isDark ? 'bg-gray-700' : 'bg-gray-100'">
<button
class="px-2.5 py-1 text-xs rounded transition-colors"
:class="[
!showSource
? (props.isDark ? 'bg-gray-600 text-gray-200 shadow-sm' : 'bg-white text-gray-700 shadow-sm')
: (props.isDark ? 'text-gray-400 hover:text-gray-200' : 'text-gray-500 hover:text-gray-700'),
]"
@click="() => handleSwitchMode('preview')"
@mouseenter="onBtnHover($event, t('common.preview') || 'Preview')"
@focus="onBtnHover($event, t('common.preview') || 'Preview')"
@mouseleave="onBtnLeave"
@blur="onBtnLeave"
>
<div class="flex items-center gap-x-1">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="w-3 h-3"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M2.062 12.348a1 1 0 0 1 0-.696a10.75 10.75 0 0 1 19.876 0a1 1 0 0 1 0 .696a10.75 10.75 0 0 1-19.876 0" /><circle cx="12" cy="12" r="3" /></g></svg>
<span>{{ t('common.preview') || 'Preview' }}</span>
</div>
</button>
<button
class="px-2.5 py-1 text-xs rounded transition-colors"
:class="[
showSource
? (props.isDark ? 'bg-gray-600 text-gray-200 shadow-sm' : 'bg-white text-gray-700 shadow-sm')
: (props.isDark ? 'text-gray-400 hover:text-gray-200' : 'text-gray-500 hover:text-gray-700'),
]"
@click="() => handleSwitchMode('source')"
@mouseenter="onBtnHover($event, t('common.source') || 'Source')"
@focus="onBtnHover($event, t('common.source') || 'Source')"
@mouseleave="onBtnLeave"
@blur="onBtnLeave"
>
<div class="flex items-center gap-x-1">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="w-3 h-3"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m16 18l6-6l-6-6M8 6l-6 6l6 6" /></svg>
<span>{{ t('common.source') || 'Source' }}</span>
</div>
</button>
</div>
<!-- Right side - Action buttons -->
<div v-if="$slots['header-right']">
<slot name="header-right" />
</div>
<div v-else class="flex items-center gap-x-1">
<button
v-if="props.showCollapseButton"
:class="computedButtonStyle"
:aria-pressed="isCollapsed"
@click="isCollapsed = !isCollapsed"
@mouseenter="onBtnHover($event, isCollapsed ? (t('common.expand') || 'Expand') : (t('common.collapse') || 'Collapse'))"
@focus="onBtnHover($event, isCollapsed ? (t('common.expand') || 'Expand') : (t('common.collapse') || 'Collapse'))"
@mouseleave="onBtnLeave"
@blur="onBtnLeave"
>
<svg :style="{ rotate: isCollapsed ? '0deg' : '90deg' }" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="w-3 h-3"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m9 18l6-6l-6-6" /></svg>
</button>
<button
v-if="props.showCopyButton"
:class="computedButtonStyle"
@click="copy"
@mouseenter="onCopyHover($event)"
@focus="onCopyHover($event)"
@mouseleave="onBtnLeave"
@blur="onBtnLeave"
>
<svg v-if="!copyText" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="w-3 h-3"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><rect width="14" height="14" x="8" y="8" rx="2" ry="2" /><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" /></g></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="w-3 h-3"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 6L9 17l-5-5" /></svg>
</button>
<button
v-if="props.showExportButton"
:class="`${computedButtonStyle} ${isFullscreenDisabled ? 'opacity-50 cursor-not-allowed' : ''}`"
:disabled="isFullscreenDisabled"
@click="handleExportClick"
@mouseenter="onBtnHover($event, t('common.export') || 'Export')"
@focus="onBtnHover($event, t('common.export') || 'Export')"
@mouseleave="onBtnLeave"
@blur="onBtnLeave"
>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="w-3 h-3"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M12 15V3m9 12v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="m7 10l5 5l5-5" /></g></svg>
</button>
<button
v-if="props.showFullscreenButton"
:class="`${computedButtonStyle} ${isFullscreenDisabled ? 'opacity-50 cursor-not-allowed' : ''}`"
:disabled="isFullscreenDisabled"
@click="handleOpenModalClick"
@mouseenter="onBtnHover($event, isModalOpen ? (t('common.minimize') || 'Minimize') : (t('common.open') || 'Open'))"
@focus="onBtnHover($event, isModalOpen ? (t('common.minimize') || 'Minimize') : (t('common.open') || 'Open'))"
@mouseleave="onBtnLeave"
@blur="onBtnLeave"
>
<svg v-if="!isModalOpen" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="0.75rem" height="0.75rem" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 3h6v6m0-6l-7 7M3 21l7-7m-1 7H3v-6" /></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="0.75rem" height="0.75rem" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m14 10l7-7m-1 7h-6V4M3 21l7-7m-6 0h6v6" /></svg>
</button>
</div>
</div>
<!-- Content area -->
<div v-show="!isCollapsed">
<div v-if="showSource" class="p-4" :class="props.isDark ? 'bg-gray-900' : 'bg-gray-50'">
<pre class="text-sm font-mono whitespace-pre-wrap" :class="props.isDark ? 'text-gray-300' : 'text-gray-700'">{{ baseCode }}</pre>
</div>
<div v-else class="relative">
<!-- Zoom controls -->
<div v-if="props.showZoomControls" class="absolute top-2 right-2 z-10 rounded-lg">
<div class="flex items-center gap-2 backdrop-blur rounded-lg">
<button
class="p-2 text-xs rounded transition-colors" :class="[props.isDark ? 'text-gray-400 hover:bg-gray-700' : 'text-gray-600 hover:bg-gray-200']"
@click="zoomIn"
@mouseenter="onBtnHover($event, t('common.zoomIn') || 'Zoom in')"
@focus="onBtnHover($event, t('common.zoomIn') || 'Zoom in')"
@mouseleave="onBtnLeave"
@blur="onBtnLeave"
>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="w-3 h-3"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><circle cx="11" cy="11" r="8" /><path d="m21 21l-4.35-4.35M11 8v6m-3-3h6" /></g></svg>
</button>
<button
class="p-2 text-xs rounded transition-colors" :class="[props.isDark ? 'text-gray-400 hover:bg-gray-700' : 'text-gray-600 hover:bg-gray-200']"
@click="zoomOut"
@mouseenter="onBtnHover($event, t('common.zoomOut') || 'Zoom out')"
@focus="onBtnHover($event, t('common.zoomOut') || 'Zoom out')"
@mouseleave="onBtnLeave"
@blur="onBtnLeave"
>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="w-3 h-3"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><circle cx="11" cy="11" r="8" /><path d="m21 21l-4.35-4.35M8 11h6" /></g></svg>
</button>
<button
class="p-2 text-xs rounded transition-colors" :class="[props.isDark ? 'text-gray-400 hover:bg-gray-700' : 'text-gray-600 hover:bg-gray-200']"
@click="resetZoom"
@mouseenter="onBtnHover($event, t('common.resetZoom') || 'Reset zoom')"
@focus="onBtnHover($event, t('common.resetZoom') || 'Reset zoom')"
@mouseleave="onBtnLeave"
@blur="onBtnLeave"
>
{{ Math.round(zoom * 100) }}%
</button>
</div>
</div>
<div
class="infographic-preview min-h-[360px] relative transition-all duration-100 overflow-hidden block"
:class="props.isDark ? 'bg-gray-900' : 'bg-gray-50'"
:style="{ height: containerHeight, maxHeight: props.maxHeight ?? undefined }"
@mousedown="startDrag"
@mousemove="onDrag"
@mouseup="stopDrag"
@mouseleave="stopDrag"
@touchstart.passive="startDrag"
@touchmove.passive="onDrag"
@touchend.passive="stopDrag"
>
<div
class="absolute inset-0 cursor-grab"
:class="{ 'cursor-grabbing': isDragging }"
:style="transformStyle"
>
<div
ref="infographicContainer"
class="w-full text-center flex items-center justify-center min-h-full"
/>
</div>
</div>
</div>
</div>
<!-- Modal fullscreen overlay -->
<Portal to="body">
<div class="markstream-vue">
<transition name="infographic-dialog" appear>
<div
v-if="isModalOpen"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
@click.self="closeModal"
>
<div
class="dialog-panel relative w-full h-full max-w-full max-h-full rounded shadow-lg overflow-hidden"
:class="props.isDark ? 'bg-gray-900' : 'bg-white'"
>
<div class="absolute top-6 right-6 z-50 flex items-center gap-2">
<button
class="p-2 text-xs rounded transition-colors" :class="[props.isDark ? 'text-gray-400 hover:bg-gray-700' : 'text-gray-600 hover:bg-gray-200']"
@click="zoomIn"
>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="w-3 h-3"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><circle cx="11" cy="11" r="8" /><path d="m21 21l-4.35-4.35M11 8v6m-3-3h6" /></g></svg>
</button>
<button
class="p-2 text-xs rounded transition-colors" :class="[props.isDark ? 'text-gray-400 hover:bg-gray-700' : 'text-gray-600 hover:bg-gray-200']"
@click="zoomOut"
>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="w-3 h-3"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><circle cx="11" cy="11" r="8" /><path d="m21 21l-4.35-4.35M8 11h6" /></g></svg>
</button>
<button
class="p-2 text-xs rounded transition-colors" :class="[props.isDark ? 'text-gray-400 hover:bg-gray-700' : 'text-gray-600 hover:bg-gray-200']"
@click="resetZoom"
>
{{ Math.round(zoom * 100) }}%
</button>
<button
class="inline-flex items-center justify-center p-2 rounded transition-colors" :class="[props.isDark ? 'text-gray-400 hover:bg-gray-700' : 'text-gray-600 hover:bg-gray-200']"
@click="closeModal"
>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="w-3 h-3"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 6L6 18M6 6l12 12" /></svg>
</button>
</div>
<div
ref="modalContent"
class="w-full h-full flex items-center justify-center p-4 overflow-hidden"
:class="{ 'cursor-grab': !isDragging, 'cursor-grabbing': isDragging }"
@mousedown="startDrag"
@mousemove="onDrag"
@mouseup="stopDrag"
@mouseleave="stopDrag"
@touchstart.passive="startDrag"
@touchmove.passive="onDrag"
@touchend.passive="stopDrag"
/>
</div>
</div>
</transition>
</div>
</Portal>
</div>
</template>
<style scoped>
.infographic-action-btn {
font-family: inherit;
}
.infographic-action-btn:active {
transform: scale(0.98);
}
.fullscreen {
width: 100%;
max-height: 100% !important;
height: 100% !important;
}
/* Dialog transition */
.infographic-dialog-enter-from,
.infographic-dialog-leave-to {
opacity: 0;
}
.infographic-dialog-enter-active,
.infographic-dialog-leave-active {
transition: opacity 200ms ease;
}
.infographic-dialog-enter-from .dialog-panel,
.infographic-dialog-leave-to .dialog-panel {
transform: translateY(8px) scale(0.98);
opacity: 0.98;
}
.infographic-dialog-enter-to .dialog-panel,
.infographic-dialog-leave-from .dialog-panel {
transform: translateY(0) scale(1);
opacity: 1;
}
.infographic-dialog-enter-active .dialog-panel,
.infographic-dialog-leave-active .dialog-panel {
transition: transform 200ms ease, opacity 200ms ease;
}
</style>
@@ -0,0 +1,3 @@
import InfographicBlockNode from './InfographicBlockNode.vue'
export default InfographicBlockNode
@@ -0,0 +1,35 @@
let infographicPromise: Promise<any> | null = null
let infographicInstance: any = null
export async function getInfographic() {
if (infographicInstance)
return infographicInstance
if (infographicPromise)
return infographicPromise
infographicPromise = import('@antv/infographic')
.then((mod) => {
// Normalize module
const defaultExport = (mod && (mod as any).default) ? (mod as any).default : mod
let resolved = defaultExport
if (typeof defaultExport === 'function' && defaultExport.prototype && defaultExport.prototype.render) {
resolved = defaultExport
}
else if (mod.Infographic) {
resolved = mod.Infographic
}
else if (defaultExport && defaultExport.Infographic) {
resolved = defaultExport.Infographic
}
infographicInstance = resolved
return resolved
})
.catch((e) => {
console.warn('Failed to load @antv/infographic', e)
return null
})
return infographicPromise
}
@@ -0,0 +1,153 @@
<script setup lang="ts">
import { computed, getCurrentInstance, inject, ref, watch } from 'vue-demi'
import { resolveStreamingTextState } from '../TextNode/streamingTextState'
const props = defineProps<{
node: {
type: 'inline_code'
code: string
raw: string
}
}>()
const instance = getCurrentInstance()
const attrs = computed<Record<string, unknown>>(() => ((instance?.proxy as any)?.$attrs ?? {}) as Record<string, unknown>)
const inheritedTypewriter = inject<{ value?: boolean } | undefined>('markstreamTypewriter', undefined)
const inheritedTextStreamState = inject<Map<string, string> | undefined>('markstreamTextStreamState', undefined)
const explicitTypewriter = computed<boolean | undefined>(() => {
const raw = attrs.value.typewriter
if (raw === '' || raw === true || raw === 'true')
return true
if (raw === false || raw === 'false')
return false
return undefined
})
const typewriterEnabled = computed(() => {
if (typeof explicitTypewriter.value === 'boolean')
return explicitTypewriter.value
if (typeof inheritedTypewriter?.value === 'boolean')
return inheritedTypewriter.value
return true
})
const streamStateKey = computed(() => {
const raw = attrs.value['index-key'] ?? attrs.value.indexKey
if (raw == null || raw === '')
return ''
return String(raw)
})
const settledCode = ref(props.node.code)
const streamedDelta = ref('')
const streamFadeVersion = ref(0)
function getRenderedContent() {
return settledCode.value + streamedDelta.value
}
function setFullContent(next: string) {
settledCode.value = next
streamedDelta.value = ''
}
function settleStreamedDelta() {
if (!streamedDelta.value)
return
settledCode.value = getRenderedContent()
streamedDelta.value = ''
}
watch(
[() => props.node.code, streamStateKey, typewriterEnabled],
([next]) => {
const normalized = String(next ?? '')
const rendered = getRenderedContent()
const key = streamStateKey.value
const previousPersisted = key
? inheritedTextStreamState?.get(key)
: undefined
const previousContent = previousPersisted ?? rendered
const nextState = resolveStreamingTextState({
nextContent: normalized,
previousContent,
typewriterEnabled: typewriterEnabled.value,
})
settledCode.value = nextState.settledContent
streamedDelta.value = nextState.streamedDelta
if (nextState.appended)
streamFadeVersion.value += 1
if (key)
inheritedTextStreamState?.set(key, normalized)
},
{ immediate: true },
)
watch(
typewriterEnabled,
(enabled) => {
if (enabled)
return
setFullContent(getRenderedContent())
},
)
const streamedDeltaClass = computed(() => (
streamFadeVersion.value % 2 === 0
? 'inline-code-stream-delta--a'
: 'inline-code-stream-delta--b'
))
</script>
<template>
<code
class="inline text-[85%] px-1 py-0.5 rounded font-mono bg-[hsl(var(--secondary))] whitespace-normal break-words max-w-full before:content-[''] after:content-['']"
>
<span v-if="settledCode">{{ settledCode }}</span>
<span
v-if="streamedDelta"
class="inline-code-stream-delta" :class="[streamedDeltaClass]"
@animationend="settleStreamedDelta"
>
{{ streamedDelta }}
</span>
</code>
</template>
<style scoped>
.inline-code-stream-delta {
animation-duration: var(--stream-update-fade-duration, var(--typewriter-fade-duration, 900ms));
animation-timing-function: var(--stream-update-fade-ease, var(--typewriter-fade-ease, ease-out));
animation-fill-mode: both;
will-change: opacity;
}
.inline-code-stream-delta--a {
animation-name: inline-code-stream-update-fade-a;
}
.inline-code-stream-delta--b {
animation-name: inline-code-stream-update-fade-b;
}
@keyframes inline-code-stream-update-fade-a {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes inline-code-stream-update-fade-b {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.inline-code-stream-delta {
animation: none !important;
}
}
</style>
@@ -0,0 +1,3 @@
import InlineCodeNode from './InlineCodeNode.vue'
export default InlineCodeNode
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { getCustomNodeComponents } from '../../utils/nodeComponents'
import EmojiNode from '../EmojiNode'
import EmphasisNode from '../EmphasisNode'
import FootnoteReferenceNode from '../FootnoteReferenceNode'
import HighlightNode from '../HighlightNode'
import HtmlInlineNode from '../HtmlInlineNode'
import InlineCodeNode from '../InlineCodeNode'
import LinkNode from '../LinkNode'
import { MathInlineNodeAsync } from '../NodeRenderer/asyncComponent'
import ReferenceNode from '../ReferenceNode'
import StrikethroughNode from '../StrikethroughNode'
import StrongNode from '../StrongNode'
import SubscriptNode from '../SubscriptNode'
import SuperscriptNode from '../SuperscriptNode'
import TextNode from '../TextNode'
interface NodeChild {
type: string
raw: string
[key: string]: unknown
}
const props = defineProps<{
node: {
type: 'insert'
children: NodeChild[]
raw: string
}
customId?: string
indexKey?: number | string
}>()
const overrides = getCustomNodeComponents(props.customId)
// Available node components for child rendering; prefer custom overrides
const nodeComponents = {
text: TextNode,
inline_code: InlineCodeNode,
link: LinkNode,
html_inline: HtmlInlineNode,
strong: StrongNode,
emphasis: EmphasisNode,
strikethrough: StrikethroughNode,
highlight: HighlightNode,
subscript: SubscriptNode,
superscript: SuperscriptNode,
emoji: EmojiNode,
footnote_reference: FootnoteReferenceNode,
math_inline: MathInlineNodeAsync,
reference: ReferenceNode,
...overrides,
}
</script>
<template>
<ins class="insert-node">
<component
:is="nodeComponents[child.type]"
v-for="(child, index) in node.children"
:key="`${indexKey || 'insert'}-${index}`"
:node="child"
:custom-id="props.customId"
:index-key="`${indexKey || 'insert'}-${index}`"
/>
</ins>
</template>
<style scoped>
.insert-node {
text-decoration: underline;
}
</style>
@@ -0,0 +1,3 @@
import InsertNode from './InsertNode.vue'
export default InsertNode
@@ -0,0 +1,283 @@
<script setup lang="ts">
// 定义链接节点
import { computed, nextTick, onBeforeUnmount, onMounted, onUpdated, ref, useAttrs } from 'vue-demi'
import { ensureTooltipMounted, hideTooltip, showTooltipForAnchor } from '../../composables/useSingletonTooltip'
import EmphasisNode from '../EmphasisNode/EmphasisNode.vue'
import HtmlInlineNode from '../HtmlInlineNode'
import ImageNode from '../ImageNode'
import StrikethroughNode from '../StrikethroughNode'
import StrongNode from '../StrongNode'
import TextNode from '../TextNode'
// 接收props — 把动画/颜色相关配置暴露为props,并通过CSS变量注入样式
const props = withDefaults(defineProps<LinkNodeProps>(), {
showTooltip: true,
})
const lastPointerPosition = {
x: 0,
y: 0,
hasValue: false,
}
let pointerTrackerRefCount = 0
let pointerTrackerCleanup: (() => void) | null = null
function ensurePointerTracker() {
if (pointerTrackerCleanup || typeof window === 'undefined')
return
const updatePosition = (event: MouseEvent | PointerEvent) => {
if (typeof event.clientX !== 'number' || typeof event.clientY !== 'number')
return
lastPointerPosition.x = event.clientX
lastPointerPosition.y = event.clientY
lastPointerPosition.hasValue = true
}
window.addEventListener('pointermove', updatePosition, true)
window.addEventListener('mousemove', updatePosition, true)
pointerTrackerCleanup = () => {
window.removeEventListener('pointermove', updatePosition, true)
window.removeEventListener('mousemove', updatePosition, true)
}
}
function retainPointerTracker() {
ensurePointerTracker()
pointerTrackerRefCount += 1
}
function releasePointerTracker() {
pointerTrackerRefCount = Math.max(0, pointerTrackerRefCount - 1)
if (!pointerTrackerRefCount && pointerTrackerCleanup) {
pointerTrackerCleanup()
pointerTrackerCleanup = null
}
}
interface LinkNodeProps {
node: {
type: 'link'
href: string
title: string | null
text: string
children: { type: string, raw: string }[]
raw: string
loading?: boolean
}
indexKey: number | string
customId?: string
showTooltip?: boolean
color?: string
underlineHeight?: number
underlineBottom?: number | string
animationDuration?: number
animationOpacity?: number
animationTiming?: string
animationIteration?: string | number
}
const cssVars = computed(() => {
const bottom = props.underlineBottom !== undefined
? (typeof props.underlineBottom === 'number' ? `${props.underlineBottom}px` : String(props.underlineBottom))
: '-3px'
const activeOpacity = props.animationOpacity ?? 0.35
const restingOpacity = Math.max(0.12, Math.min(activeOpacity * 0.5, activeOpacity))
return {
'--link-color': props.color ?? '#0366d6',
'--underline-height': `${props.underlineHeight ?? 2}px`,
'--underline-bottom': bottom,
'--underline-opacity': String(activeOpacity),
'--underline-rest-opacity': String(restingOpacity),
'--underline-duration': `${props.animationDuration ?? 1.6}s`,
'--underline-timing': props.animationTiming ?? 'ease-in-out',
'--underline-iteration': typeof props.animationIteration === 'number' ? String(props.animationIteration) : (props.animationIteration ?? 'infinite'),
} as Record<string, string>
})
// Available node components for child rendering
const nodeComponents = {
text: TextNode,
strong: StrongNode,
strikethrough: StrikethroughNode,
emphasis: EmphasisNode,
image: ImageNode,
html_inline: HtmlInlineNode,
}
// forward any non-prop attributes (e.g. custom-id) to the rendered element
const attrs = useAttrs()
const anchorEl = ref<HTMLElement | null>(null)
const isHovering = ref(false)
const anchorAttrs = computed(() => {
const merged = { ...(attrs as Record<string, unknown>) }
// `title` is controlled by `showTooltip` behavior and should not be overridden.
delete (merged as Record<string, unknown>).title
return merged
})
function getTooltipText() {
return props.node?.title || props.node?.href || props.node?.text || ''
}
function isPointerInsideAnchor(el: HTMLElement | null) {
if (!el || !lastPointerPosition.hasValue)
return false
const rect = el.getBoundingClientRect()
return lastPointerPosition.x >= rect.left
&& lastPointerPosition.x <= rect.right
&& lastPointerPosition.y >= rect.top
&& lastPointerPosition.y <= rect.bottom
}
function syncTooltipForHoveredAnchor() {
if (!props.showTooltip || !anchorEl.value || !isPointerInsideAnchor(anchorEl.value))
return
isHovering.value = true
showTooltipForAnchor(
anchorEl.value,
getTooltipText(),
'top',
true,
{
x: lastPointerPosition.x,
y: lastPointerPosition.y,
},
)
}
// Tooltip handlers using singleton tooltip
function onAnchorEnter(e: Event) {
if (!props.showTooltip)
return
isHovering.value = true
const ev = e as MouseEvent
const origin = ev?.clientX != null && ev?.clientY != null ? { x: ev.clientX, y: ev.clientY } : undefined
showTooltipForAnchor(anchorEl.value || (e.currentTarget as HTMLElement), getTooltipText(), 'top', true, origin)
}
function onAnchorLeave() {
if (!props.showTooltip)
return
isHovering.value = false
hideTooltip()
}
const title = computed(() => {
const rawTitle = props.node?.title
if (typeof rawTitle === 'string' && rawTitle.trim().length > 0)
return rawTitle
return String(props.node?.href ?? '')
})
onMounted(() => {
retainPointerTracker()
if (props.showTooltip)
ensureTooltipMounted()
nextTick(() => {
syncTooltipForHoveredAnchor()
})
})
onUpdated(() => {
syncTooltipForHoveredAnchor()
})
onBeforeUnmount(() => {
releasePointerTracker()
if (!isHovering.value)
return
isHovering.value = false
hideTooltip(true)
})
</script>
<template>
<a
v-if="!node.loading"
ref="anchorEl"
class="link-node"
:href="node.href"
:title="showTooltip ? '' : title"
:aria-label="`Link: ${title}`"
:aria-hidden="node.loading ? 'true' : 'false'"
target="_blank"
rel="noopener noreferrer"
v-bind="anchorAttrs"
:style="cssVars"
@mouseenter="(e) => onAnchorEnter(e)"
@mouseleave="onAnchorLeave"
>
<component
:is="nodeComponents[child.type]"
v-for="(child, index) in node.children"
:key="`${indexKey || 'emphasis'}-${index}`"
:node="child"
:custom-id="props.customId"
:index-key="`${indexKey || 'link-text'}-${index}`"
/>
</a>
<span v-else class="link-loading inline-flex items-baseline gap-1.5" :aria-hidden="!node.loading ? 'true' : 'false'" v-bind="attrs" :style="cssVars">
<span class="link-text-wrapper relative inline-flex">
<span class="leading-[normal] link-text">
<TextNode
class="leading-[normal] link-text"
:node="{ type: 'text', content: String(node.text ?? ''), raw: String(node.text ?? '') }"
:index-key="`${indexKey || 'link-text'}-loading`"
/>
</span>
<span class="link-loading-indicator" aria-hidden="true" />
</span>
</span>
</template>
<style scoped>
.link-node {
color: var(--link-color, #0366d6);
text-decoration: none;
}
.link-node:hover {
text-decoration: underline;
text-underline-offset: .2rem
}
.link-loading .link-text-wrapper {
position: relative;
}
.link-loading {
color: var(--link-color, #0366d6);
}
.link-loading .link-text {
position: relative;
z-index: 2;
}
.link-loading-indicator {
position: absolute;
left: 0;
right: 0;
height: var(--underline-height, 2px);
bottom: var(--underline-bottom, -3px);
background: currentColor;
border-radius: 999px;
will-change: opacity;
opacity: var(--underline-rest-opacity, 0.18);
animation: underlinePulse var(--underline-duration, 1.6s) var(--underline-timing, ease-in-out) var(--underline-iteration, infinite);
}
@keyframes underlinePulse {
0%, 100% { opacity: var(--underline-rest-opacity, 0.18); }
50% { opacity: var(--underline-opacity, 0.35); }
}
@media (prefers-reduced-motion: reduce) {
.link-loading-indicator {
animation: none;
opacity: var(--underline-rest-opacity, 0.18);
}
}
</style>
@@ -0,0 +1,3 @@
import LinkNode from './LinkNode.vue'
export default LinkNode
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { computed, getCurrentInstance } from 'vue-demi'
import { isLegacyVue26Vm } from '../../utils/vue26'
import NodeRenderer from '../NodeRenderer'
import LegacyNodesRenderer from '../NodeRenderer/LegacyNodesRenderer.vue'
interface NodeChild {
type: string
raw: string
[key: string]: unknown
}
interface ListItem {
type: 'list_item'
children: NodeChild[]
raw: string
}
const props = defineProps<{
node?: ListItem
item?: ListItem
indexKey?: number | string
value?: number
customId?: string
typewriter?: boolean
showTooltips?: boolean
}>()
defineEmits<{
copy: [text: string]
}>()
const itemNode = computed(() => props.node ?? props.item)
const liValueAttr = computed(() => (props.value == null ? {} : { value: props.value }))
const instance = getCurrentInstance()
const nestedRenderer = computed(() => {
const vm = instance?.proxy as any
return isLegacyVue26Vm(vm) ? LegacyNodesRenderer : NodeRenderer
})
</script>
<template>
<li class="list-item pl-1.5 my-2" dir="auto" v-bind="liValueAttr">
<component
:is="nestedRenderer"
v-bind="{ showTooltips: props.showTooltips }"
:index-key="`list-item-${props.indexKey}`"
:nodes="itemNode?.children ?? []"
:custom-id="props.customId"
:typewriter="props.typewriter"
:batch-rendering="false"
@copy="$emit('copy', $event)"
/>
</li>
</template>
<style scoped>
ol > .list-item::marker{
color: var(--list-item-counter-marker,#64748b);
line-height: 1.6;
}
ul > .list-item::marker{
color: var(--list-item-marker,#cbd5e1)
}
.list-item ::v-deep .markdown-renderer {
content-visibility: visible;
contain-intrinsic-size: 0px 0px;
contain: none;
}
</style>
@@ -0,0 +1,3 @@
import ListItemNode from './ListItemNode.vue'
export default ListItemNode
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { computed } from 'vue-demi'
import { customComponentsRevision, getCustomNodeComponents } from '../../utils/nodeComponents'
import ListItemNode from '../ListItemNode'
interface NodeChild {
type: string
raw: string
[key: string]: unknown
}
interface ListItem {
type: 'list_item'
children: NodeChild[]
raw: string
}
const { node, customId, indexKey, typewriter, showTooltips } = defineProps<{
node: {
type: 'list'
ordered: boolean
start?: number
items: ListItem[]
raw: string
}
customId?: string
indexKey?: number | string
typewriter?: boolean
showTooltips?: boolean
}>()
defineEmits(['copy'])
const listItemComponent = computed(() => {
void customComponentsRevision.value
const customComponents = getCustomNodeComponents(customId)
return (customComponents as any).list_item || ListItemNode
})
</script>
<template>
<component
:is="node.ordered ? 'ol' : 'ul'"
class="list-node"
:class="{ 'list-decimal': node.ordered, 'list-disc': !node.ordered }"
>
<component
:is="listItemComponent"
v-for="(item, index) in node.items"
:key="`${indexKey || 'list'}-${index}`"
v-bind="{ showTooltips }"
:node="item"
:custom-id="customId"
:index-key="`${indexKey || 'list'}-${index}`"
:typewriter="typewriter"
:value="node.ordered ? (node.start ?? 1) + index : undefined"
@copy="$emit('copy', $event)"
/>
</component>
</template>
<style scoped>
.list-node {
@apply my-5 pl-[calc(13/8*1em)];
}
.list-decimal {
list-style-type: decimal;
}
.list-disc {
list-style-type: disc;
@apply max-lg:my-[calc(4/3*1em)] max-lg:pl-[calc(14/9*1em)];
}
</style>
@@ -0,0 +1,3 @@
import ListNode from './ListNode.vue'
export default ListNode
@@ -0,0 +1,884 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue-demi'
import { useSafeI18n } from '../../composables/useSafeI18n'
import { hideTooltip, showTooltipForAnchor } from '../../composables/useSingletonTooltip'
import { getLanguageIcon, languageIconsRevision, languageMap, normalizeLanguageIdentifier } from '../../utils'
const props = defineProps({
node: { type: Object, required: true },
loading: { type: Boolean, default: true },
stream: { type: Boolean, default: true },
darkTheme: { type: String, default: 'vitesse-dark' },
lightTheme: { type: String, default: 'vitesse-light' },
isDark: { type: Boolean, default: false },
isShowPreview: { type: Boolean, default: true },
enableFontSizeControl: { type: Boolean, default: true },
minWidth: { type: [String, Number], default: undefined },
maxWidth: { type: [String, Number], default: undefined },
themes: { type: Array, default: undefined },
showHeader: { type: Boolean, default: true },
showCopyButton: { type: Boolean, default: true },
showExpandButton: { type: Boolean, default: true },
showPreviewButton: { type: Boolean, default: true },
showCollapseButton: { type: Boolean, default: true },
showFontSizeButtons: { type: Boolean, default: true },
showTooltips: { type: Boolean, default: undefined },
})
const emits = defineEmits(['previewCode', 'copy'])
const { t } = useSafeI18n()
const codeLanguage = ref<string>(normalizeLanguageIdentifier(String(props.node.language ?? '')))
const copyText = ref(false)
const isExpanded = ref(false)
const isCollapsed = ref(false)
const container = ref<HTMLElement | null>(null)
const codeBlockContent = ref<HTMLElement | null>(null)
const rendererTarget = ref<HTMLElement | null>(null)
const fallbackHtml = ref('')
const rendererReady = ref(false)
const hasStableRender = ref(false)
let renderObserver: MutationObserver | undefined
// Auto-scroll state management
const autoScrollEnabled = ref(true) // Start with auto-scroll enabled
const lastScrollTop = ref(0) // Track last scroll position to detect scroll direction
// Font size control
const codeFontMin = 10
const codeFontMax = 36
const codeFontStep = 1
const defaultCodeFontSize = ref<number>(14)
const codeFontSize = ref<number>(defaultCodeFontSize.value)
const fontBaselineReady = computed(() => {
const a = defaultCodeFontSize.value
const b = codeFontSize.value
return typeof a === 'number' && Number.isFinite(a) && a > 0 && typeof b === 'number' && Number.isFinite(b) && b > 0
})
// 计算用于显示的语言名称
const displayLanguage = computed(() => {
const lang = codeLanguage.value.trim().toLowerCase()
if (!lang)
return languageMap[''] || 'Plain Text'
return languageMap[lang] || lang.charAt(0).toUpperCase() + lang.slice(1)
})
// Computed property for language icon
const languageIcon = computed(() => {
void languageIconsRevision.value
const lang = codeLanguage.value.trim().toLowerCase()
return getLanguageIcon(lang.split(':')[0])
})
// Check if the language is previewable (HTML or SVG)
const isPreviewable = computed(() => {
const lang = codeLanguage.value.trim().toLowerCase()
return props.isShowPreview && (lang === 'html' || lang === 'svg')
})
// Compute inline style for container to respect optional min/max width
const containerStyle = computed(() => {
const s: Record<string, string> = {}
const fmt = (v: string | number | undefined) => {
if (v == null)
return undefined
return typeof v === 'number' ? `${v}px` : String(v)
}
const min = fmt(props.minWidth)
const max = fmt(props.maxWidth)
if (min)
s.minWidth = min
if (max)
s.maxWidth = max
return s
})
// Computed style for code block content with font size
const contentStyle = computed(() => {
return {
fontSize: `${codeFontSize.value}px`,
}
})
const tooltipsEnabled = computed(() => props.showTooltips !== false)
function getPreferredColorScheme() {
return props.isDark ? props.darkTheme : props.lightTheme
}
function getResolvedThemes() {
return props.themes as string[] | undefined
}
function getColorChannels(color: string) {
const match = color.match(/\d+(\.\d+)?/g)
if (!match || match.length < 3)
return null
return match.slice(0, 3).map(Number) as [number, number, number]
}
function getRelativeLuminance(color: string) {
const channels = getColorChannels(color)
if (!channels)
return null
const normalized = channels.map((channel) => {
const value = channel / 255
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4
})
return 0.2126 * normalized[0] + 0.7152 * normalized[1] + 0.0722 * normalized[2]
}
function getContrastRatio(foreground: string, background: string) {
const fg = getRelativeLuminance(foreground)
const bg = getRelativeLuminance(background)
if (fg == null || bg == null)
return null
const lighter = Math.max(fg, bg)
const darker = Math.min(fg, bg)
return (lighter + 0.05) / (darker + 0.05)
}
function isDarkBackgroundColor(color: string) {
const channels = getColorChannels(color)
if (!channels)
return props.isDark
const [r, g, b] = channels
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b
return luminance < 140
}
function syncRenderedCssVars() {
if (typeof window === 'undefined' || typeof window.getComputedStyle !== 'function')
return
const rootEl = container.value
const target = rendererTarget.value
if (!rootEl || !target)
return
const preEl = (target.querySelector('.shiki') || target.querySelector('pre')) as HTMLElement | null
if (!preEl)
return
const preStyles = window.getComputedStyle(preEl)
const codeEl = (preEl.querySelector('code') || preEl) as HTMLElement
const codeStyles = window.getComputedStyle(codeEl)
const bg = String(preStyles.backgroundColor || '').trim()
const detectedFg = String(codeStyles.color || preStyles.color || '').trim()
if (bg)
rootEl.style.setProperty('--vscode-editor-background', bg)
const darkBg = bg ? isDarkBackgroundColor(bg) : props.isDark
const fallbackFg = darkBg ? 'rgb(229, 231, 235)' : 'rgb(17, 24, 39)'
const resolvedFg = bg && detectedFg && (getContrastRatio(detectedFg, bg) ?? 0) >= 3
? detectedFg
: fallbackFg
rootEl.style.setProperty('--vscode-editor-foreground', resolvedFg)
rootEl.style.setProperty('--markstream-code-fallback-selection-bg', darkBg ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.06)')
rootEl.style.setProperty('--vscode-editor-selectionBackground', `var(--markstream-code-fallback-selection-bg)`)
}
function escapeHtml(str: string) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function renderFallback(code: string, force = false) {
renderObserver?.disconnect()
renderObserver = undefined
if (!force && hasStableRender.value) {
fallbackHtml.value = ''
rendererReady.value = true
return
}
if (!code) {
fallbackHtml.value = ''
rendererReady.value = false
hasStableRender.value = false
return
}
fallbackHtml.value = `<pre class="shiki shiki-fallback"><code>${escapeHtml(code)}</code></pre>`
rendererReady.value = false
}
function clearFallback() {
fallbackHtml.value = ''
rendererReady.value = true
hasStableRender.value = true
void nextTick().then(() => {
syncRenderedCssVars()
})
}
function keepLastSuccessfulRender() {
renderObserver?.disconnect()
renderObserver = undefined
if (!hasStableRender.value)
return
fallbackHtml.value = ''
rendererReady.value = true
}
function hasRendererContent() {
const target = rendererTarget.value
if (!target)
return false
if (target.childNodes.length > 0)
return true
return Boolean(target.textContent?.trim().length)
}
async function clearFallbackWhenRendererReady() {
await nextTick()
if (hasRendererContent()) {
clearFallback()
return
}
const target = rendererTarget.value
if (!target)
return
renderObserver?.disconnect()
renderObserver = new MutationObserver(() => {
if (!hasRendererContent())
return
clearFallback()
renderObserver?.disconnect()
renderObserver = undefined
})
renderObserver.observe(target, { childList: true, subtree: true })
}
// Lazy-load stream-markdown (and thus shiki) only when needed
interface ShikiRenderer {
updateCode: (code: string, lang?: string) => void | Promise<void>
setTheme: (theme?: string) => void | Promise<void>
dispose: () => void
}
let renderer: ShikiRenderer | undefined
let createShikiRenderer:
| ((el: HTMLElement, opts: { theme?: string | undefined, themes?: string[] | undefined }) => ShikiRenderer)
| undefined
let registerHighlight
let registeredHighlightLanguages: Set<string> | undefined
const warnedMissingLanguages = new Set<string>()
const warnedRendererErrors = new Set<string>()
const isDevEnv = typeof import.meta !== 'undefined' && Boolean((import.meta as any).env?.DEV)
function normalizeRendererLanguage(rawLang?: string | null, hasContent = false) {
const normalized = normalizeLanguageIdentifier(String(rawLang ?? ''))
if (!normalized)
return 'plaintext'
if (!registeredHighlightLanguages || registeredHighlightLanguages.has(normalized))
return normalized
if (hasContent && isDevEnv && !warnedMissingLanguages.has(normalized)) {
warnedMissingLanguages.add(normalized)
console.warn(`[MarkdownCodeBlockNode] Language "${normalized}" not preloaded in stream-markdown; falling back to plaintext.`)
}
return 'plaintext'
}
async function updateRendererWithFallback(code: string, rawLang?: string | null) {
if (!renderer)
return false
const normalized = normalizeRendererLanguage(rawLang, Boolean(code && code.length))
try {
await renderer.updateCode(code, normalized)
return true
}
catch (err) {
if (normalized !== 'plaintext') {
if (isDevEnv && !warnedRendererErrors.has(normalized)) {
warnedRendererErrors.add(normalized)
console.warn(`[MarkdownCodeBlockNode] Failed to render language "${normalized}", retrying as plaintext.`, err)
}
try {
await renderer.updateCode(code, 'plaintext')
return true
}
catch (plainErr) {
if (isDevEnv)
console.warn('[MarkdownCodeBlockNode] Failed to render code block even as plaintext.', plainErr)
return false
}
}
else if (isDevEnv) {
console.warn('[MarkdownCodeBlockNode] Failed to render code block even as plaintext.', err)
}
return false
}
}
async function ensureStreamMarkdownLoaded() {
if (createShikiRenderer)
return
try {
const mod = await import('stream-markdown')
createShikiRenderer = mod.createShikiStreamRenderer
registerHighlight = mod.registerHighlight
const defaultLangs = Array.isArray((mod as any).defaultLanguages) ? (mod as any).defaultLanguages : undefined
registeredHighlightLanguages = defaultLangs ? new Set(defaultLangs.map((l: string) => l.toLowerCase())) : undefined
registerHighlight?.({ themes: getResolvedThemes() })
}
catch (e) {
// stream-markdown is an optional peer; if missing, silently skip highlighting
console.warn('[MarkdownCodeBlockNode] stream-markdown not available:', e)
}
}
async function initRenderer() {
await ensureStreamMarkdownLoaded()
if (!codeBlockContent.value || !rendererTarget.value) {
renderFallback(props.node.code, true)
return
}
registerHighlight?.({ themes: getResolvedThemes() })
if (!renderer && createShikiRenderer) {
renderer = createShikiRenderer(rendererTarget.value, {
theme: getPreferredColorScheme(),
themes: getResolvedThemes(),
})
rendererReady.value = true
}
if (!renderer) {
renderFallback(props.node.code, true)
return
}
if (props.stream === false && props.loading) {
renderFallback(props.node.code, !hasStableRender.value)
return
}
renderFallback(props.node.code, !hasStableRender.value)
const updated = await updateRendererWithFallback(props.node.code, codeLanguage.value)
if (!updated) {
keepLastSuccessfulRender()
return
}
if (hasStableRender.value)
keepLastSuccessfulRender()
else
await clearFallbackWhenRendererReady()
}
initRenderer()
onMounted(() => {
initRenderer()
})
onBeforeUnmount(() => {
renderObserver?.disconnect()
renderObserver = undefined
})
watch(() => props.themes, async () => {
if (registerHighlight)
registerHighlight({ themes: getResolvedThemes() })
})
watch(() => props.loading, (loading) => {
if (loading)
return
initRenderer()
})
watch(tooltipsEnabled, (enabled) => {
if (!enabled)
hideTooltip()
})
watch(() => [props.node.code, props.node.language], async ([code, lang]) => {
const normalizedLang = normalizeLanguageIdentifier(String(lang ?? ''))
if (normalizedLang !== codeLanguage.value)
codeLanguage.value = normalizedLang
if (!codeBlockContent.value || !rendererTarget.value) {
renderFallback(code, true)
return
}
if (!code) {
renderObserver?.disconnect()
renderObserver = undefined
rendererTarget.value.innerHTML = ''
fallbackHtml.value = ''
rendererReady.value = false
hasStableRender.value = false
return
}
if (!renderer) {
renderFallback(code, !hasStableRender.value)
await initRenderer()
}
if (!renderer || !code)
return
if (props.stream === false && props.loading)
return
renderFallback(code, !hasStableRender.value)
const updated = await updateRendererWithFallback(code, normalizedLang)
if (!updated) {
keepLastSuccessfulRender()
return
}
if (hasStableRender.value)
keepLastSuccessfulRender()
else
await clearFallbackWhenRendererReady()
})
watch(
() => [props.darkTheme, props.lightTheme],
async () => {
if (!codeBlockContent.value || !rendererTarget.value)
return
if (!renderer)
await initRenderer()
await renderer?.setTheme(getPreferredColorScheme())
syncRenderedCssVars()
},
)
// Auto-scroll to bottom when content changes (if not expanded and auto-scroll is enabled)
watch(() => props.node.code, async () => {
if (isExpanded.value || !autoScrollEnabled.value)
return
await nextTick()
const content = codeBlockContent.value
if (!content)
return
// Check if content has scrollbar (scrollHeight > clientHeight)
if (content.scrollHeight > content.clientHeight) {
// Scroll to bottom
content.scrollTop = content.scrollHeight
}
})
// Check if user is at the bottom of scroll area
// Increased threshold to 50px to better detect "near bottom" state
function isAtBottom(element: HTMLElement, threshold = 50): boolean {
return element.scrollHeight - element.scrollTop - element.clientHeight <= threshold
}
// Handle scroll event to detect user interaction
function handleScroll() {
const content = codeBlockContent.value
if (!content || isExpanded.value)
return
const currentScrollTop = content.scrollTop
// Detect scroll direction: if user scrolls up (scrollTop decreased), disable auto-scroll immediately
if (currentScrollTop < lastScrollTop.value) {
// User is scrolling up - disable auto-scroll
autoScrollEnabled.value = false
}
else if (isAtBottom(content)) {
// User is scrolling down and near bottom - re-enable auto-scroll
autoScrollEnabled.value = true
}
// Update last scroll position
lastScrollTop.value = currentScrollTop
}
// Copy code functionality
async function copy() {
try {
if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
await navigator.clipboard.writeText(props.node.code)
}
copyText.value = true
emits('copy', props.node.code)
setTimeout(() => {
copyText.value = false
}, 1000)
}
catch (err) {
console.error('Copy failed:', err)
}
}
// Tooltip helpers
function resolveTooltipTarget(e: Event) {
const btn = (e.currentTarget || e.target) as HTMLButtonElement | null
if (!btn || btn.disabled)
return null
return btn
}
type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right'
function onBtnHover(e: Event, text: string, place: TooltipPlacement = 'top') {
if (!tooltipsEnabled.value)
return
const target = resolveTooltipTarget(e)
if (!target)
return
const ev = e as MouseEvent
const origin = ev?.clientX != null && ev?.clientY != null ? { x: ev.clientX, y: ev.clientY } : undefined
showTooltipForAnchor(target, text, place, false, origin, props.isDark)
}
function onBtnLeave() {
if (!tooltipsEnabled.value)
return
hideTooltip()
}
function onCopyHover(e: Event) {
if (!tooltipsEnabled.value)
return
const target = resolveTooltipTarget(e)
if (!target)
return
const txt = copyText.value ? (t('common.copied') || 'Copied') : (t('common.copy') || 'Copy')
const ev = e as MouseEvent
const origin = ev?.clientX != null && ev?.clientY != null ? { x: ev.clientX, y: ev.clientY } : undefined
showTooltipForAnchor(target, txt, 'top', false, origin, props.isDark)
}
// Expand/collapse functionality
function toggleExpand(e?: Event) {
isExpanded.value = !isExpanded.value
if (e && tooltipsEnabled.value) {
const target = resolveTooltipTarget(e)
if (target) {
const txt = isExpanded.value ? (t('common.collapse') || 'Collapse') : (t('common.expand') || 'Expand')
showTooltipForAnchor(target, txt, 'top', false, undefined, props.isDark)
}
}
const content = codeBlockContent.value
if (!content)
return
if (isExpanded.value) {
content.style.maxHeight = 'none'
content.style['overflow-y'] = 'visible'
content.style['overflow-x'] = 'auto'
}
else {
content.style.maxHeight = '500px'
content.style.overflow = 'auto'
// When collapsing, re-enable auto-scroll and scroll to bottom
autoScrollEnabled.value = true
nextTick(() => {
if (content.scrollHeight > content.clientHeight) {
content.scrollTop = content.scrollHeight
}
})
}
}
// Header collapse/fold functionality
function toggleHeaderCollapse() {
isCollapsed.value = !isCollapsed.value
}
// Font size controls
function increaseCodeFont() {
const after = Math.min(codeFontMax, codeFontSize.value + codeFontStep)
codeFontSize.value = after
}
function decreaseCodeFont() {
const after = Math.max(codeFontMin, codeFontSize.value - codeFontStep)
codeFontSize.value = after
}
function resetCodeFont() {
codeFontSize.value = defaultCodeFontSize.value
}
// Preview functionality
function previewCode() {
if (!isPreviewable.value)
return
const lowerLang = normalizeLanguageIdentifier(String(codeLanguage.value || props.node.language)).toLowerCase()
const artifactType = lowerLang === 'html' ? 'text/html' : 'image/svg+xml'
const artifactTitle
= lowerLang === 'html'
? 'HTML Preview'
: 'SVG Preview'
emits('previewCode', {
type: artifactType,
content: props.node.code,
title: artifactTitle,
})
}
</script>
<template>
<div
ref="container"
:style="{
...containerStyle,
backgroundColor: 'var(--vscode-editor-background, var(--markstream-code-fallback-bg))',
color: 'var(--vscode-editor-foreground, var(--markstream-code-fallback-fg))',
}"
class="code-block-container my-4 rounded-lg border overflow-hidden shadow-sm"
:class="[props.isDark ? 'border-gray-700/30 bg-gray-900' : 'border-gray-200 bg-white', props.isDark ? 'is-dark' : '']"
>
<div
v-if="props.showHeader"
class="code-block-header flex justify-between items-center px-4 py-2.5 border-b border-gray-400/5"
style="color: var(--vscode-editor-foreground, var(--markstream-code-fallback-fg));background-color: var(--vscode-editor-background, var(--markstream-code-fallback-bg));"
>
<!-- left slot / fallback language label -->
<slot name="header-left">
<div class="flex items-center space-x-2 flex-1 overflow-hidden">
<span class="icon-slot h-4 w-4 flex-shrink-0" v-html="languageIcon" />
<span class="text-sm font-medium font-mono truncate">{{ displayLanguage }}</span>
</div>
</slot>
<!-- right slot / fallback action buttons -->
<slot name="header-right">
<div class="flex items-center space-x-2">
<button
v-if="props.showCollapseButton"
type="button"
class="code-action-btn p-2 text-xs rounded-md transition-colors hover:bg-[var(--vscode-editor-selectionBackground)]"
:aria-pressed="isCollapsed"
@click="toggleHeaderCollapse"
@mouseenter="onBtnHover($event, isCollapsed ? (t('common.expand') || 'Expand') : (t('common.collapse') || 'Collapse'))"
@focus="onBtnHover($event, isCollapsed ? (t('common.expand') || 'Expand') : (t('common.collapse') || 'Collapse'))"
@mouseleave="onBtnLeave"
@blur="onBtnLeave"
>
<svg :style="{ rotate: isCollapsed ? '0deg' : '90deg' }" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="w-3 h-3"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m9 18l6-6l-6-6" /></svg>
</button>
<template v-if="props.showFontSizeButtons && props.enableFontSizeControl">
<button
type="button"
class="code-action-btn p-2 text-xs rounded-md transition-colors hover:bg-[var(--vscode-editor-selectionBackground)]"
:disabled="Number.isFinite(codeFontSize) ? codeFontSize <= codeFontMin : false"
@click="decreaseCodeFont()"
@mouseenter="onBtnHover($event, t('common.decrease') || 'Decrease')"
@focus="onBtnHover($event, t('common.decrease') || 'Decrease')"
@mouseleave="onBtnLeave"
@blur="onBtnLeave"
>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="w-3 h-3"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14" /></svg>
</button>
<button
type="button"
class="code-action-btn p-2 text-xs rounded-md transition-colors hover:bg-[var(--vscode-editor-selectionBackground)]"
:disabled="!fontBaselineReady || codeFontSize === defaultCodeFontSize"
@click="resetCodeFont()"
@mouseenter="onBtnHover($event, t('common.reset') || 'Reset')"
@focus="onBtnHover($event, t('common.reset') || 'Reset')"
@mouseleave="onBtnLeave"
@blur="onBtnLeave"
>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="w-3 h-3"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3 12a9 9 0 1 0 9-9a9.75 9.75 0 0 0-6.74 2.74L3 8" /><path d="M3 3v5h5" /></g></svg>
</button>
<button
type="button"
class="code-action-btn p-2 text-xs rounded-md transition-colors hover:bg-[var(--vscode-editor-selectionBackground)]"
:disabled="Number.isFinite(codeFontSize) ? codeFontSize >= codeFontMax : false"
@click="increaseCodeFont()"
@mouseenter="onBtnHover($event, t('common.increase') || 'Increase')"
@focus="onBtnHover($event, t('common.increase') || 'Increase')"
@mouseleave="onBtnLeave"
@blur="onBtnLeave"
>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="w-3 h-3"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14m-7-7v14" /></svg>
</button>
</template>
<button
v-if="props.showCopyButton"
type="button"
class="code-action-btn p-2 text-xs rounded-md transition-colors hover:bg-[var(--vscode-editor-selectionBackground)]"
:aria-label="copyText ? (t('common.copied') || 'Copied') : (t('common.copy') || 'Copy')"
@click="copy"
@mouseenter="onCopyHover($event)"
@focus="onCopyHover($event)"
@mouseleave="onBtnLeave"
@blur="onBtnLeave"
>
<svg v-if="!copyText" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="w-3 h-3"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><rect width="14" height="14" x="8" y="8" rx="2" ry="2" /><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" /></g></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="w-3 h-3"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 6L9 17l-5-5" /></svg>
</button>
<button
v-if="props.showExpandButton"
type="button"
class="code-action-btn p-2 text-xs rounded-md transition-colors hover:bg-[var(--vscode-editor-selectionBackground)]"
:aria-pressed="isExpanded"
@click="toggleExpand($event)"
@mouseenter="onBtnHover($event, isExpanded ? (t('common.collapse') || 'Collapse') : (t('common.expand') || 'Expand'))"
@focus="onBtnHover($event, isExpanded ? (t('common.collapse') || 'Collapse') : (t('common.expand') || 'Expand'))"
@mouseleave="onBtnLeave"
@blur="onBtnLeave"
>
<svg v-if="isExpanded" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="0.75rem" height="0.75rem" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m14 10l7-7m-1 7h-6V4M3 21l7-7m-6 0h6v6" /></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="0.75rem" height="0.75rem" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 3h6v6m0-6l-7 7M3 21l7-7m-1 7H3v-6" /></svg>
</button>
<button
v-if="isPreviewable && props.showPreviewButton"
type="button"
class="code-action-btn p-2 text-xs rounded-md transition-colors hover:bg-[var(--vscode-editor-selectionBackground)]"
:aria-label="t('common.preview') || 'Preview'"
@click="previewCode"
@mouseenter="onBtnHover($event, t('common.preview') || 'Preview')"
@focus="onBtnHover($event, t('common.preview') || 'Preview')"
@mouseleave="onBtnLeave"
@blur="onBtnLeave"
>
<svg data-v-3d59cc65="" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="w-3 h-3"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M2.062 12.348a1 1 0 0 1 0-.696a10.75 10.75 0 0 1 19.876 0a1 1 0 0 1 0 .696a10.75 10.75 0 0 1-19.876 0" /><circle cx="12" cy="12" r="3" /></g></svg>
</button>
</div>
</slot>
</div>
<div
v-show="!isCollapsed && (stream ? true : !loading)"
ref="codeBlockContent"
class="code-block-content"
:style="contentStyle"
@scroll="handleScroll"
>
<div ref="rendererTarget" class="code-block-render" />
<div v-if="!rendererReady" class="code-fallback-plain" v-html="fallbackHtml" />
</div>
<!-- Loading placeholder can be overridden via slot -->
<div v-show="!stream && loading" class="code-loading-placeholder">
<slot name="loading" :loading="loading" :stream="stream">
<div class="loading-skeleton">
<div class="skeleton-line" />
<div class="skeleton-line" />
<div class="skeleton-line short" />
</div>
</slot>
</div>
</div>
</template>
<style scoped>
.code-block-container {
contain: content;
/* 新增:显著减少离屏 codeblock 的布局/绘制与样式计算 */
content-visibility: auto;
contain-intrinsic-size: 320px 180px;
--markstream-code-fallback-bg: #ffffff;
--markstream-code-fallback-fg: #111827;
--markstream-code-fallback-selection-bg: rgba(0, 0, 0, 0.06);
--vscode-editor-selectionBackground: var(--markstream-code-fallback-selection-bg);
}
.code-block-container.is-dark {
--markstream-code-fallback-bg: #111827;
--markstream-code-fallback-fg: #e5e7eb;
--markstream-code-fallback-selection-bg: rgba(255, 255, 255, 0.08);
}
.code-block-content {
max-height: min(70vh, 500px);
overflow: auto;
transition: max-height 0.3s ease;
font-family: var(--vscode-editor-font-family, 'Fira Code', 'SFMono-Regular', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace);
line-height: var(--vscode-editor-line-height, 1.5);
}
.code-block-render {
min-height: 1px;
}
.code-block-container ::v-deep .code-block-render pre,
.code-block-container ::v-deep .code-block-content .shiki {
font-family: inherit;
font-size: inherit;
line-height: inherit;
margin-top: 0;
padding-top:0;
}
.code-block-container ::v-deep .code-block-content .shiki-fallback {
padding: 1rem;
margin: 0;
background: transparent;
color: inherit;
white-space: pre;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
.code-fallback-plain {
white-space: pre;
overflow: auto;
background: transparent;
color: inherit;
font-size: inherit;
line-height: inherit;
font-family: inherit;
}
.code-block-container ::v-deep .code-block-content pre {
margin: 0;
padding: 1rem;
}
.code-action-btn {
cursor: pointer;
opacity: 0.7;
transition: opacity 0.2s;
}
.code-action-btn:hover {
opacity: 1;
}
.code-action-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* Loading placeholder styles */
.code-loading-placeholder {
padding: 1rem;
min-height: 120px;
}
.loading-skeleton {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.skeleton-line {
height: 1rem;
background: linear-gradient(90deg, rgba(0,0,0,0.06) 25%, rgba(0,0,0,0.12) 37%, rgba(0,0,0,0.06) 63%);
background-size: 400% 100%;
animation: code-skeleton-shimmer 1.2s ease-in-out infinite;
border-radius: 0.25rem;
}
.code-block-container.is-dark .skeleton-line {
background: linear-gradient(90deg, rgba(255,255,255,0.06) 25%, rgba(255,255,255,0.12) 37%, rgba(255,255,255,0.06) 63%);
background-size: 400% 100%;
}
.skeleton-line.short {
width: 60%;
}
@keyframes code-skeleton-shimmer {
0% { background-position: 100% 0; }
100% { background-position: 0 0; }
}
</style>
@@ -0,0 +1,3 @@
import MarkdownCodeBlockNode from './MarkdownCodeBlockNode.vue'
export default MarkdownCodeBlockNode
@@ -0,0 +1,157 @@
<script lang="ts">
import type { BaseNode, MarkdownIt, ParseOptions } from 'stream-markdown-parser'
import type { NodeRendererProps } from './NodeRenderer/NodeRenderer.vue'
import { getMarkdown, mergeCustomHtmlTags, parseMarkdownToStructure, resolveCustomHtmlTags } from 'stream-markdown-parser'
import { defineComponent } from 'vue-demi'
import { isLegacyVue26Vm, resolveVueListeners } from '../utils/vue26'
import NodeRenderer from './NodeRenderer'
import LegacyNodesRenderer from './NodeRenderer/LegacyNodesRenderer.vue'
export default defineComponent({
name: 'MarkdownRender',
components: {
LegacyNodesRenderer,
NodeRenderer,
},
props: {
content: String,
nodes: Array as unknown as () => BaseNode[] | undefined,
final: Boolean,
parseOptions: Object as unknown as () => ParseOptions | undefined,
customMarkdownIt: Function as unknown as () => ((md: MarkdownIt) => MarkdownIt) | undefined,
debugPerformance: Boolean,
customHtmlTags: Array as unknown as () => readonly string[] | undefined,
viewportPriority: Boolean,
codeBlockStream: {
type: Boolean,
default: true,
},
codeBlockDarkTheme: null,
codeBlockLightTheme: null,
codeBlockMonacoOptions: Object as unknown as () => Record<string, any> | undefined,
renderCodeBlocksAsPre: Boolean,
codeBlockMinWidth: [String, Number],
codeBlockMaxWidth: [String, Number],
codeBlockProps: Object as unknown as () => Record<string, any> | undefined,
showTooltips: Boolean,
themes: Array as unknown as () => string[] | undefined,
isDark: Boolean,
customId: [String, Number],
indexKey: [String, Number],
typewriter: Boolean,
batchRendering: Boolean,
initialRenderBatchSize: Number,
renderBatchSize: Number,
renderBatchDelay: Number,
renderBatchBudgetMs: Number,
renderBatchIdleTimeoutMs: Number,
deferNodesUntilVisible: Boolean,
maxLiveNodes: Number,
liveNodeBuffer: Number,
},
data() {
return {
instanceMsgId: this.customId
? `renderer-${this.customId}`
: `renderer-${Date.now()}-${Math.random().toString(36).slice(2)}`,
}
},
computed: {
legacyVue26(): boolean {
return isLegacyVue26Vm(this)
},
mergedParseOptions(): ParseOptions | undefined {
const base = this.parseOptions ?? {}
const resolvedFinal = this.final ?? (base as any).final
const merged = mergeCustomHtmlTags(this.customHtmlTags, (base as any).customHtmlTags)
const hasFinal = resolvedFinal != null
const hasCustom = merged.length > 0
if (!hasFinal && !hasCustom)
return base
return {
...(base as any),
...(hasFinal ? { final: resolvedFinal } : {}),
...(hasCustom ? { customHtmlTags: merged } : {}),
} as ParseOptions
},
parsedLegacyNodes(): BaseNode[] {
if (Array.isArray(this.nodes) && this.nodes.length > 0)
return this.nodes
if (!this.content)
return []
const { key, tags } = resolveCustomHtmlTags((this.mergedParseOptions as any)?.customHtmlTags)
const markdown = getMarkdown(key ? `${this.instanceMsgId}:${key}` : this.instanceMsgId, {
customHtmlTags: tags,
})
const md = this.customMarkdownIt ? this.customMarkdownIt(markdown) : markdown
return parseMarkdownToStructure(this.content, md, this.mergedParseOptions)
},
forwardedProps(): NodeRendererProps {
return {
content: this.content,
nodes: this.nodes,
final: this.final,
parseOptions: this.parseOptions,
customMarkdownIt: this.customMarkdownIt,
debugPerformance: this.debugPerformance,
customHtmlTags: this.customHtmlTags,
viewportPriority: this.viewportPriority,
codeBlockStream: this.codeBlockStream,
codeBlockDarkTheme: this.codeBlockDarkTheme,
codeBlockLightTheme: this.codeBlockLightTheme,
codeBlockMonacoOptions: this.codeBlockMonacoOptions,
renderCodeBlocksAsPre: this.renderCodeBlocksAsPre,
codeBlockMinWidth: this.codeBlockMinWidth,
codeBlockMaxWidth: this.codeBlockMaxWidth,
codeBlockProps: this.codeBlockProps,
showTooltips: this.showTooltips,
themes: this.themes,
isDark: this.isDark,
customId: this.customId as string | undefined,
indexKey: this.indexKey,
typewriter: this.typewriter,
batchRendering: this.batchRendering,
initialRenderBatchSize: this.initialRenderBatchSize,
renderBatchSize: this.renderBatchSize,
renderBatchDelay: this.renderBatchDelay,
renderBatchBudgetMs: this.renderBatchBudgetMs,
renderBatchIdleTimeoutMs: this.renderBatchIdleTimeoutMs,
deferNodesUntilVisible: this.deferNodesUntilVisible,
maxLiveNodes: this.maxLiveNodes,
liveNodeBuffer: this.liveNodeBuffer,
}
},
},
render(h) {
const listeners = resolveVueListeners(this)
if (this.legacyVue26) {
return h(LegacyNodesRenderer, {
props: {
nodes: this.parsedLegacyNodes,
customId: this.customId,
indexKey: this.indexKey,
typewriter: this.typewriter,
showTooltips: this.showTooltips,
codeBlockStream: this.codeBlockStream,
codeBlockDarkTheme: this.codeBlockDarkTheme,
codeBlockLightTheme: this.codeBlockLightTheme,
codeBlockMonacoOptions: this.codeBlockMonacoOptions,
renderCodeBlocksAsPre: this.renderCodeBlocksAsPre,
codeBlockMinWidth: this.codeBlockMinWidth,
codeBlockMaxWidth: this.codeBlockMaxWidth,
codeBlockProps: this.codeBlockProps,
themes: this.themes,
isDark: this.isDark,
},
...(Object.keys(listeners).length > 0 ? { on: listeners } : {}),
})
}
return h(NodeRenderer, {
props: this.forwardedProps,
...(Object.keys(listeners).length > 0 ? { on: listeners } : {}),
})
},
})
</script>
@@ -0,0 +1,219 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue-demi'
import { useViewportPriority } from '../../composables/viewportPriority'
import { getCachedMathRender, setCachedMathRender } from '../../utils/mathRenderCache'
import { normalizeKaTeXRenderInput } from '../../utils/normalizeKaTeXRenderInput'
import { renderKaTeXWithBackpressure, setKaTeXCache, WORKER_BUSY_CODE } from '../../workers/katexWorkerClient'
import { getKatex } from '../MathInlineNode/katex'
interface MathBlockNodeProps {
node: {
type: 'math_block'
content: string
raw: string
loading?: boolean
}
indexKey?: number | string
}
const props = defineProps<MathBlockNodeProps>()
const mathContent = computed(() => normalizeKaTeXRenderInput(props.node.content))
const containerEl = ref<HTMLElement | null>(null)
const registerVisibility = useViewportPriority()
let visibilityHandle: ReturnType<typeof registerVisibility> | null = null
let currentRenderId = 0
let isUnmounted = false
let currentAbortController: AbortController | null = null
const cacheKey = computed(() => {
if (props.indexKey == null)
return null
return `block:${String(props.indexKey)}`
})
const initialCached = getCachedMathRender(cacheKey.value)
const renderedHtml = ref(initialCached?.html || '')
const fallbackText = ref('')
const renderingLoading = ref(!initialCached?.html)
let hasRenderedOnce = Boolean(initialCached?.html)
async function renderWithMainThreadFallback() {
const katex = await getKatex()
if (!katex)
return false
try {
const html = katex.renderToString(mathContent.value, {
throwOnError: props.node.loading,
displayMode: true,
})
applySuccessfulRender(html)
setKaTeXCache(mathContent.value, true, html)
return true
}
catch {
return false
}
}
function applySuccessfulRender(html: string) {
renderedHtml.value = html
fallbackText.value = ''
renderingLoading.value = false
hasRenderedOnce = true
setCachedMathRender(cacheKey.value, html)
}
function applyRawFallback() {
renderedHtml.value = ''
fallbackText.value = props.node.raw
renderingLoading.value = false
}
async function renderMath() {
if (isUnmounted)
return
if (!props.node.content) {
applyRawFallback()
hasRenderedOnce = true
return
}
const cached = getCachedMathRender(cacheKey.value)
if (cached?.html && !renderedHtml.value) {
renderedHtml.value = cached.html
hasRenderedOnce = true
renderingLoading.value = false
}
if (!hasRenderedOnce) {
renderingLoading.value = true
try {
if (!visibilityHandle && containerEl.value)
visibilityHandle = registerVisibility(containerEl.value)
await visibilityHandle?.whenVisible
}
catch {}
}
if (currentAbortController) {
currentAbortController.abort()
currentAbortController = null
}
const renderId = ++currentRenderId
const abortController = new AbortController()
currentAbortController = abortController
try {
const html = await renderKaTeXWithBackpressure(mathContent.value, true, {
timeout: 3000,
waitTimeout: 2000,
maxRetries: 1,
signal: abortController.signal,
})
if (isUnmounted || renderId !== currentRenderId)
return
applySuccessfulRender(html)
}
catch (err: any) {
if (isUnmounted || renderId !== currentRenderId)
return
if (err?.name === 'AbortError')
return
const code = err?.code || err?.name
const isWorkerInitFailure = code === 'WORKER_INIT_ERROR' || err?.fallbackToRenderer
const isBusyOrTimeout = code === WORKER_BUSY_CODE || code === 'WORKER_TIMEOUT'
const isDisabled = code === 'KATEX_DISABLED'
if ((isWorkerInitFailure || isBusyOrTimeout) && await renderWithMainThreadFallback())
return
if (hasRenderedOnce || renderedHtml.value)
return
if (isDisabled || !props.node.loading)
applyRawFallback()
else
renderingLoading.value = true
}
}
watch(
() => [props.node.content, props.node.loading, props.node.raw, props.indexKey],
() => renderMath(),
)
onMounted(() => {
renderMath()
})
onBeforeUnmount(() => {
isUnmounted = true
if (currentAbortController) {
currentAbortController.abort()
currentAbortController = null
}
visibilityHandle?.destroy?.()
visibilityHandle = null
})
</script>
<template>
<div ref="containerEl" class="math-block text-center overflow-x-auto relative min-h-[40px]">
<div v-if="renderedHtml" class="math-block__content" v-html="renderedHtml" />
<div v-else-if="fallbackText" class="math-block__fallback">
{{ fallbackText }}
</div>
<div v-if="renderingLoading && !renderedHtml && !fallbackText" class="math-loading-overlay">
<div class="math-loading-spinner" />
</div>
</div>
</template>
<style scoped>
.math-loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(2px);
min-height: 40px;
}
.math-block__fallback {
white-space: pre-wrap;
}
.math-loading-spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(0, 0, 0, 0.1);
border-top-color: rgba(0, 0, 0, 0.6);
border-radius: 50%;
animation: math-spin 0.8s linear infinite;
}
@keyframes math-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-color-scheme: dark) {
.math-loading-overlay {
background-color: rgba(0, 0, 0, 0.6);
}
.math-loading-spinner {
border-color: rgba(255, 255, 255, 0.2);
border-top-color: rgba(255, 255, 255, 0.8);
}
}
</style>
@@ -0,0 +1,3 @@
import MathBlockNode from './MathBlockNode.vue'
export default MathBlockNode
@@ -0,0 +1,226 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue-demi'
import { useViewportPriority } from '../../composables/viewportPriority'
import { getCachedMathRender, setCachedMathRender } from '../../utils/mathRenderCache'
import { normalizeKaTeXRenderInput } from '../../utils/normalizeKaTeXRenderInput'
import { renderKaTeXWithBackpressure, setKaTeXCache, WORKER_BUSY_CODE } from '../../workers/katexWorkerClient'
import { getKatex } from './katex'
interface MathInlineNodeProps {
node: {
type: 'math_inline'
content: string
raw: string
loading?: boolean
markup?: string
}
indexKey?: number | string
}
const props = defineProps<MathInlineNodeProps>()
const containerEl = ref<HTMLElement | null>(null)
const registerVisibility = useViewportPriority()
let visibilityHandle: ReturnType<typeof registerVisibility> | null = null
let currentRenderId = 0
let isUnmounted = false
let currentAbortController: AbortController | null = null
const displayMode = computed(() => props.node.markup === '$$')
const mathContent = computed(() => normalizeKaTeXRenderInput(props.node.content))
const cacheKey = computed(() => {
if (props.indexKey == null)
return null
return `inline:${String(props.indexKey)}`
})
const initialCached = getCachedMathRender(cacheKey.value)
const renderedHtml = ref(initialCached?.html || '')
const fallbackText = ref('')
const renderingLoading = ref(!initialCached?.html)
let hasRenderedOnce = Boolean(initialCached?.html)
async function renderWithMainThreadFallback() {
const katex = await getKatex()
if (!katex)
return false
try {
const html = katex.renderToString(mathContent.value, {
throwOnError: props.node.loading,
displayMode: displayMode.value,
})
applySuccessfulRender(html)
setKaTeXCache(mathContent.value, displayMode.value, html)
return true
}
catch {
return false
}
}
function applySuccessfulRender(html: string) {
renderedHtml.value = html
fallbackText.value = ''
renderingLoading.value = false
hasRenderedOnce = true
setCachedMathRender(cacheKey.value, html)
}
function applyRawFallback() {
renderedHtml.value = ''
fallbackText.value = props.node.raw
renderingLoading.value = false
}
async function renderMath() {
if (isUnmounted)
return
if (!props.node.content) {
applyRawFallback()
hasRenderedOnce = true
return
}
const cached = getCachedMathRender(cacheKey.value)
if (cached?.html && !renderedHtml.value) {
renderedHtml.value = cached.html
hasRenderedOnce = true
renderingLoading.value = false
}
if (currentAbortController) {
currentAbortController.abort()
currentAbortController = null
}
const renderId = ++currentRenderId
const abortController = new AbortController()
currentAbortController = abortController
if (!hasRenderedOnce) {
renderingLoading.value = true
try {
if (!visibilityHandle && containerEl.value)
visibilityHandle = registerVisibility(containerEl.value)
await visibilityHandle?.whenVisible
}
catch {}
}
try {
const html = await renderKaTeXWithBackpressure(mathContent.value, displayMode.value, {
timeout: 1500,
waitTimeout: 0,
maxRetries: 0,
signal: abortController.signal,
})
if (isUnmounted || renderId !== currentRenderId)
return
applySuccessfulRender(html)
}
catch (err: any) {
if (isUnmounted || renderId !== currentRenderId)
return
if (err?.name === 'AbortError')
return
const code = err?.code || err?.name
const isWorkerInitFailure = code === 'WORKER_INIT_ERROR' || err?.fallbackToRenderer
const isBusyOrTimeout = code === WORKER_BUSY_CODE || code === 'WORKER_TIMEOUT'
const isDisabled = code === 'KATEX_DISABLED'
if ((isWorkerInitFailure || isBusyOrTimeout) && await renderWithMainThreadFallback())
return
if (hasRenderedOnce || renderedHtml.value)
return
if (isDisabled || !props.node.loading)
applyRawFallback()
else
renderingLoading.value = true
}
}
watch(
() => [props.node.content, props.node.loading, props.node.markup, props.node.raw, props.indexKey],
() => renderMath(),
)
onMounted(() => {
renderMath()
})
onBeforeUnmount(() => {
isUnmounted = true
if (currentAbortController) {
currentAbortController.abort()
currentAbortController = null
}
visibilityHandle?.destroy?.()
visibilityHandle = null
})
</script>
<template>
<span ref="containerEl" class="math-inline-wrapper">
<span v-if="renderedHtml" class="math-inline" v-html="renderedHtml" />
<span v-else-if="fallbackText" class="math-inline math-inline--fallback">{{ fallbackText }}</span>
<span
v-if="renderingLoading && !renderedHtml && !fallbackText"
class="math-inline__loading"
role="status"
aria-live="polite"
>
<slot name="loading" :is-loading="renderingLoading">
<span class="math-inline__spinner animate-spin" aria-hidden="true" />
<span class="sr-only">Loading</span>
</slot>
</span>
</span>
</template>
<style scoped>
.math-inline-wrapper {
position: relative;
display: inline-block;
}
.math-inline {
display: inline-block;
vertical-align: middle;
}
.math-inline--fallback {
white-space: pre-wrap;
}
.math-inline__loading {
display: inline-flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.math-inline__spinner {
width: 1rem;
height: 1rem;
border-radius: 9999px;
border: 2px solid rgba(94, 104, 121, 0.25);
border-top-color: rgba(94, 104, 121, 0.8);
will-change: transform;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>
@@ -0,0 +1,3 @@
import MathInlineNode from './MathInlineNode.vue'
export default MathInlineNode
@@ -0,0 +1,93 @@
export type KatexLoader = () => Promise<any> | any
let katex: any = null
let importAttempted = false
let katexLoader: KatexLoader | null = defaultKatexLoader
function normalizeKatexModule(mod: any) {
const resolved = mod?.default ?? mod
if (resolved && typeof resolved.renderToString === 'function')
return resolved
return null
}
function getGlobalKatex() {
try {
const g: any = globalThis as any
return normalizeKatexModule(g?.katex)
}
catch {
return null
}
}
function defaultKatexLoader() {
return (async () => {
const globalKatex = getGlobalKatex()
if (globalKatex)
return globalKatex
const mod = await import('katex')
try {
// Webpack 4 does not support `package.json#exports`, so `katex/contrib/mhchem`
// resolves to a directory and fails. Use a real file path instead.
await import('katex/dist/contrib/mhchem')
}
catch {
// ignore missing optional contrib bundle
}
return normalizeKatexModule(mod)
})()
}
function resetCache() {
katex = null
importAttempted = false
}
export function setKatexLoader(loader: KatexLoader | null) {
katexLoader = loader
resetCache()
}
export function enableKatex(loader?: KatexLoader) {
setKatexLoader(loader ?? defaultKatexLoader)
}
export function disableKatex() {
setKatexLoader(null)
}
export function isKatexEnabled() {
return typeof katexLoader === 'function'
}
export async function getKatex() {
const globalKatex = getGlobalKatex()
if (globalKatex) {
katex = globalKatex
return katex
}
if (katex)
return katex
if (importAttempted)
return null
const loader = katexLoader
if (!loader) {
importAttempted = true
return null
}
try {
const result = await loader()
if (result) {
katex = normalizeKatexModule(result) ?? result
return katex
}
}
catch {
// Swallow errors here; callers handle lack of KaTeX support gracefully.
}
importAttempted = true
return null
}
@@ -0,0 +1,3 @@
import MermaidBlockNode from './MermaidBlockNode.vue'
export default MermaidBlockNode
@@ -0,0 +1,129 @@
export type MermaidLoader = () => Promise<any> | any
const defaultMermaidLoader: MermaidLoader = () => import('mermaid')
let cachedMermaid: any = null
let mermaidLoader: MermaidLoader | null = defaultMermaidLoader
function resetCachedMermaid() {
cachedMermaid = null
}
function getGlobalMermaid() {
try {
const g: any = globalThis as any
return normalizeMermaidModule(g?.mermaid)
}
catch {
return null
}
}
export function setMermaidLoader(loader: MermaidLoader | null) {
mermaidLoader = loader
resetCachedMermaid()
}
export function enableMermaid(loader?: MermaidLoader) {
setMermaidLoader(loader ?? defaultMermaidLoader)
}
export function disableMermaid() {
setMermaidLoader(null)
}
export function isMermaidEnabled() {
return typeof mermaidLoader === 'function'
}
function normalizeMermaidModule(mod: any) {
if (!mod)
return mod
const candidate = (mod && (mod as any).default) ? (mod as any).default : mod
// Common shapes:
// - `candidate.render/parse/initialize` exist -> use candidate
// - `candidate.mermaidAPI` exists with render/parse -> bind those
// - `mod.mermaid` or `mod.default.mermaid` may contain the API
if (candidate && (typeof candidate.render === 'function' || typeof candidate.parse === 'function' || typeof candidate.initialize === 'function'))
return candidate
if (candidate && candidate.mermaidAPI && (typeof candidate.mermaidAPI.render === 'function' || typeof candidate.mermaidAPI.parse === 'function')) {
// expose a normalized facade that provides render/parse/initialize
const api = candidate.mermaidAPI
return {
...candidate,
render: api.render.bind(api),
parse: api.parse ? api.parse.bind(api) : undefined,
initialize: (opts: any) => {
// some builds expose initialize on root, prefer that
if (typeof candidate.initialize === 'function')
return candidate.initialize(opts)
// otherwise try mermaidAPI.initialize
return api.initialize ? api.initialize(opts) : undefined
},
}
}
if ((mod as any).mermaid && typeof (mod as any).mermaid.render === 'function')
return (mod as any).mermaid
// fallback: use candidate as-is; it may still work or we'll surface a runtime error later
return candidate
}
function patchInitialize(target: any) {
if (!target)
return
// Ensure initialize honors a safe default: suppressErrorRendering = true.
// This prevents mermaid from injecting verbose error diagrams into the DOM
// when parsing/rendering fails. If the consumer passes an explicit
// `suppressErrorRendering: false` it will be respected.
try {
const origInit = target?.initialize
target.initialize = (opts: any) => {
const merged = { suppressErrorRendering: true, ...(opts || {}) }
if (typeof origInit === 'function')
return origInit.call(target, merged)
// fallback to mermaidAPI.initialize if present
if (target?.mermaidAPI && typeof target.mermaidAPI.initialize === 'function')
return target.mermaidAPI.initialize(merged)
return undefined
}
}
catch {
// be defensive: if anything goes wrong wrapping initialize, ignore and
// return the mermaid instance as-is. Consumers will handle runtime errors.
}
}
export async function getMermaid() {
if (cachedMermaid)
return cachedMermaid
const globalMermaid = getGlobalMermaid()
if (globalMermaid) {
cachedMermaid = globalMermaid
patchInitialize(cachedMermaid)
return cachedMermaid
}
const loader = mermaidLoader
if (!loader)
return null
let mod: any
try {
mod = await loader()
}
catch (err) {
if (loader === defaultMermaidLoader) {
throw new Error('Optional dependency "mermaid" is not installed. Please install it to enable mermaid diagrams.')
}
throw err
}
if (!mod)
return null
cachedMermaid = normalizeMermaidModule(mod)
patchInitialize(cachedMermaid)
return cachedMermaid
}
@@ -0,0 +1,75 @@
<script lang="ts">
import type { BaseNode, MarkdownIt, ParseOptions } from 'stream-markdown-parser'
import { computed, defineComponent, getCurrentInstance } from 'vue-demi'
import { parseNestedMarkdownToNodes } from '../../utils/nestedNodes'
import { resolveVueListeners } from '../../utils/vue26'
import MarkdownRender from '../MarkdownRenderCompat.vue'
export default defineComponent({
name: 'NestedRenderer',
components: {
MarkdownRender,
},
inheritAttrs: false,
props: {
node: Object as unknown as () => (BaseNode & Record<string, any>) | undefined,
nodes: Array as unknown as () => BaseNode[] | undefined,
content: String,
loading: Boolean,
final: Boolean,
cacheKey: String,
parseOptions: Object as unknown as () => ParseOptions | undefined,
customHtmlTags: Array as unknown as () => readonly string[] | undefined,
customMarkdownIt: Function as unknown as () => ((md: MarkdownIt) => MarkdownIt) | undefined,
},
setup(props) {
const instance = getCurrentInstance()
const resolvedFinal = computed(() => {
if (props.final != null)
return props.final
if (typeof props.loading === 'boolean')
return !props.loading
if (typeof props.node?.loading === 'boolean')
return !props.node.loading
return undefined
})
const resolvedNodes = computed(() => {
return parseNestedMarkdownToNodes(
{
node: props.node,
nodes: props.nodes,
content: props.content,
},
{
cacheKey: props.cacheKey,
final: resolvedFinal.value,
parseOptions: props.parseOptions,
customHtmlTags: props.customHtmlTags,
customMarkdownIt: props.customMarkdownIt,
},
)
})
const forwardedListeners = computed(() => {
return resolveVueListeners(instance?.proxy)
})
return {
forwardedListeners,
resolvedFinal,
resolvedNodes,
}
},
})
</script>
<template>
<MarkdownRender
v-bind="$attrs"
:nodes="resolvedNodes"
:final="resolvedFinal"
:custom-html-tags="customHtmlTags"
v-on="forwardedListeners"
/>
</template>
@@ -0,0 +1,3 @@
import NestedRenderer from './NestedRenderer.vue'
export default NestedRenderer
@@ -0,0 +1,15 @@
<script setup lang="ts">
defineProps<{
node: {
raw: string
}
}>()
</script>
<template>
<div class="unknown-node">
{{ node.raw }}
</div>
</template>
<style scoped></style>
@@ -0,0 +1,18 @@
<script setup lang="ts">
defineProps<{
html?: string
}>()
</script>
<template>
<div
class="markstream-vue2-legacy-node-html"
v-html="html || ''"
/>
</template>
<style scoped>
.markstream-vue2-legacy-node-html {
display: block;
}
</style>
@@ -0,0 +1,282 @@
<script setup lang="ts">
import type { BaseNode, ParsedNode } from 'stream-markdown-parser'
import { normalizeCustomHtmlTags } from 'stream-markdown-parser'
import { computed } from 'vue-demi'
import AdmonitionNode from '../../components/AdmonitionNode'
import BlockquoteNode from '../../components/BlockquoteNode'
import CheckboxNode from '../../components/CheckboxNode'
import DefinitionListNode from '../../components/DefinitionListNode'
import EmojiNode from '../../components/EmojiNode'
import EmphasisNode from '../../components/EmphasisNode'
import FootnoteAnchorNode from '../../components/FootnoteAnchorNode'
import FootnoteNode from '../../components/FootnoteNode'
import FootnoteReferenceNode from '../../components/FootnoteReferenceNode'
import HardBreakNode from '../../components/HardBreakNode'
import HeadingNode from '../../components/HeadingNode'
import HighlightNode from '../../components/HighlightNode'
import ImageNode from '../../components/ImageNode'
import InlineCodeNode from '../../components/InlineCodeNode'
import InsertNode from '../../components/InsertNode'
import LinkNode from '../../components/LinkNode'
import ListItemNode from '../../components/ListItemNode'
import ListNode from '../../components/ListNode'
import ParagraphNode from '../../components/ParagraphNode'
import PreCodeNode from '../../components/PreCodeNode'
import ReferenceNode from '../../components/ReferenceNode'
import StrikethroughNode from '../../components/StrikethroughNode'
import StrongNode from '../../components/StrongNode'
import SubscriptNode from '../../components/SubscriptNode'
import SuperscriptNode from '../../components/SuperscriptNode'
import TableNode from '../../components/TableNode'
import TextNode from '../../components/TextNode'
import ThematicBreakNode from '../../components/ThematicBreakNode'
import VmrContainerNode from '../../components/VmrContainerNode'
import { getHtmlTagFromContent, shouldRenderUnknownHtmlTagAsText, stripCustomHtmlWrapper } from '../../utils/htmlRenderer'
import { customComponentsRevision, getCustomNodeComponents } from '../../utils/nodeComponents'
import HtmlBlockNode from '../HtmlBlockNode/HtmlBlockNode.vue'
import HtmlInlineNode from '../HtmlInlineNode/HtmlInlineNode.vue'
import { MathBlockNodeAsync, MathInlineNodeAsync } from './asyncComponent'
import FallbackComponent from './FallbackComponent.vue'
const props = withDefaults(defineProps<{
nodes?: BaseNode[]
customId?: string
indexKey?: number | string
typewriter?: boolean
showTooltips?: boolean
codeBlockStream?: boolean
codeBlockDarkTheme?: any
codeBlockLightTheme?: any
codeBlockMonacoOptions?: Record<string, any>
codeBlockMinWidth?: string | number
codeBlockMaxWidth?: string | number
codeBlockProps?: Record<string, any>
renderCodeBlocksAsPre?: boolean
themes?: string[]
isDark?: boolean
customHtmlTags?: readonly string[]
}>(), {
codeBlockStream: true,
showTooltips: true,
typewriter: true,
})
const emit = defineEmits(['copy', 'handleArtifactClick'])
const nodeComponents = {
text: TextNode,
paragraph: ParagraphNode,
heading: HeadingNode,
code_block: PreCodeNode,
list: ListNode,
list_item: ListItemNode,
blockquote: BlockquoteNode,
table: TableNode,
definition_list: DefinitionListNode,
footnote: FootnoteNode,
footnote_reference: FootnoteReferenceNode,
footnote_anchor: FootnoteAnchorNode,
admonition: AdmonitionNode,
vmr_container: VmrContainerNode,
hardbreak: HardBreakNode,
link: LinkNode,
image: ImageNode,
thematic_break: ThematicBreakNode,
math_inline: MathInlineNodeAsync,
math_block: MathBlockNodeAsync,
strong: StrongNode,
emphasis: EmphasisNode,
strikethrough: StrikethroughNode,
highlight: HighlightNode,
insert: InsertNode,
subscript: SubscriptNode,
superscript: SuperscriptNode,
emoji: EmojiNode,
checkbox: CheckboxNode,
checkbox_input: CheckboxNode,
inline_code: InlineCodeNode,
html_inline: HtmlInlineNode,
html_block: HtmlBlockNode,
reference: ReferenceNode,
}
const customComponentsMap = computed(() => {
void customComponentsRevision.value
return getCustomNodeComponents(props.customId)
})
const indexPrefix = computed(() => (props.indexKey != null ? String(props.indexKey) : 'legacy-renderer'))
const codeBlockBindings = computed(() => ({
stream: props.codeBlockStream,
darkTheme: props.codeBlockDarkTheme,
lightTheme: props.codeBlockLightTheme,
monacoOptions: props.codeBlockMonacoOptions,
themes: props.themes,
minWidth: props.codeBlockMinWidth,
maxWidth: props.codeBlockMaxWidth,
...(props.codeBlockProps || {}),
}))
const nonCodeBindings = computed(() => ({ typewriter: props.typewriter }))
const linkBindings = computed(() => ({
...nonCodeBindings.value,
...(typeof props.showTooltips === 'boolean' ? { showTooltip: props.showTooltips } : {}),
}))
const listBindings = computed(() => ({
...nonCodeBindings.value,
...(typeof props.showTooltips === 'boolean' ? { showTooltips: props.showTooltips } : {}),
}))
// Set of effective custom HTML tags (normalised to lowercase).
const effectiveCustomHtmlTagsSet = computed<Set<string>>(() => {
return new Set(normalizeCustomHtmlTags(props.customHtmlTags))
})
const renderedItems = computed(() => {
const nodes = Array.isArray(props.nodes) ? props.nodes : []
return nodes.map((rawNode, index) => {
let node = rawNode as ParsedNode
const language = getCodeBlockLanguage(node)
const type = String((node as any)?.type || 'unknown')
let component = getNodeComponent(node, language)
// Coerce html_block/html_inline nodes whose tag matches a registered
// custom component listed in customHtmlTags.
if (
(node.type === 'html_block' || node.type === 'html_inline')
&& component === (nodeComponents as any)[node.type]
) {
const tag = String((node as any).tag ?? '').trim().toLowerCase()
|| getHtmlTagFromContent((node as any).content)
if (tag) {
// Check if tag is whitelisted in customHtmlTags
if (effectiveCustomHtmlTagsSet.value.has(tag)) {
const customComponents = customComponentsMap.value
const customForTag = (customComponents as any)[tag]
if (customForTag) {
component = customForTag
node = {
...(node as any),
type: tag,
tag,
content: stripCustomHtmlWrapper((node as any).content, tag),
} as ParsedNode
}
}
else if (shouldRenderUnknownHtmlTagAsText((node as any).content ?? (node as any).raw, tag)) {
const rawContent = String((node as any).content ?? (node as any).raw ?? '')
if (node.type === 'html_inline') {
component = TextNode
node = {
type: 'text',
content: rawContent,
raw: rawContent,
} as ParsedNode
}
else {
component = ParagraphNode
node = {
type: 'paragraph',
children: [{ type: 'text', content: rawContent, raw: rawContent }],
raw: rawContent,
} as ParsedNode
}
}
}
}
return {
index,
indexKey: `${indexPrefix.value}-${index}`,
// Keep code blocks mounted during streaming so Shiki/Monaco renderers can
// preserve their last successful DOM instead of flashing back to <pre>.
renderKey: type === 'code_block'
? `${indexPrefix.value}-${index}-${type}`
: `${indexPrefix.value}-${index}-${type}-${String((rawNode as any)?.raw || '').length}`,
node,
isCodeBlock: node?.type === 'code_block',
component,
bindings: getBindingsFor(node, language),
}
})
})
function getCodeBlockLanguage(node: ParsedNode) {
return node?.type === 'code_block'
? String((node as any).language ?? '').trim().toLowerCase()
: ''
}
function getNodeComponent(node: ParsedNode, language?: string) {
if (!node)
return FallbackComponent
const customComponents = customComponentsMap.value
const customForType = (customComponents as any)[String((node as any).type)]
if (node.type === 'code_block') {
const lang = language ?? getCodeBlockLanguage(node)
const customForLanguage = lang ? (customComponents as any)[lang] : undefined
if (customForLanguage)
return customForLanguage
if (lang === 'mermaid') {
const customMermaid = (customComponents as any).mermaid
return customMermaid || PreCodeNode
}
if (lang === 'infographic') {
const customInfographic = (customComponents as any).infographic
return customInfographic || PreCodeNode
}
if (lang === 'd2' || lang === 'd2lang') {
const customD2 = (customComponents as any).d2
return customD2 || PreCodeNode
}
if (customForType)
return customForType
const customCodeBlock = (customComponents as any).code_block
return customCodeBlock || (props.renderCodeBlocksAsPre ? PreCodeNode : PreCodeNode)
}
if (customForType)
return customForType
return (nodeComponents as any)[String((node as any).type)] || FallbackComponent
}
function getBindingsFor(node: ParsedNode, language?: string) {
const lang = language ?? getCodeBlockLanguage(node)
if (lang === 'mermaid' || lang === 'infographic' || lang === 'd2' || lang === 'd2lang')
return {}
if (node.type === 'link')
return linkBindings.value
if (node.type === 'list')
return listBindings.value
return node.type === 'code_block'
? codeBlockBindings.value
: nonCodeBindings.value
}
function handleClick(event: MouseEvent) {
emit('handleArtifactClick', event)
}
</script>
<template>
<div class="markstream-vue2 markdown-renderer legacy-nodes-renderer" :class="{ dark: props.isDark }" @click="handleClick">
<div
v-for="item in renderedItems"
:key="item.renderKey"
class="node-slot"
:data-node-index="item.index"
:data-node-type="item.node.type"
>
<div class="node-content">
<component
:is="item.component"
:node="item.node"
:loading="item.node.loading"
:index-key="item.indexKey"
v-bind="item.bindings"
:custom-id="props.customId"
:is-dark="props.isDark"
@copy="emit('copy', $event)"
@handle-artifact-click="emit('handleArtifactClick', $event)"
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,61 @@
import { h } from 'vue-demi'
import { getKatex } from '../MathInlineNode/katex'
import TextNode from '../TextNode'
function createMathFallbackNode(wrapper: (content: string) => string) {
return (props: any) => {
return h(TextNode, {
...props,
node: {
...props.node,
content: props.node.raw ?? wrapper(String(props.node.content ?? '')),
},
})
}
}
export async function MathInlineNodeAsync() {
// In test environment prefer the simple text fallback to avoid
// race conditions with workers/KaTeX rendering.
const isTestEnv = typeof globalThis !== 'undefined'
// eslint-disable-next-line node/prefer-global/process
&& typeof (globalThis as any).process !== 'undefined'
// eslint-disable-next-line node/prefer-global/process
&& (globalThis as any).process?.env?.NODE_ENV === 'test'
if (isTestEnv) {
// test fallback should be deterministic and minimal
return createMathFallbackNode(content => `$${content}$`)
}
try {
const katex = await getKatex()
if (katex) {
const mod = await import('../../components/MathInlineNode')
return (mod as any).default ?? mod
}
}
catch (e) {
console.warn(
'[markstream-vue2] Optional peer dependencies for MathInlineNode are missing. Falling back to text rendering. To enable full math rendering features, please install "katex".',
e,
)
}
return createMathFallbackNode(content => `$${content}$`)
}
export async function MathBlockNodeAsync() {
try {
const katex = await getKatex()
if (katex) {
const mod = await import('../../components/MathBlockNode')
return (mod as any).default ?? mod
}
}
catch (e) {
console.warn(
'[markstream-vue2] Optional peer dependencies for MathBlockNode are missing. Falling back to text rendering. To enable full math rendering features, please install "katex".',
e,
)
}
return createMathFallbackNode(content => `$$${content}$$`)
}
@@ -0,0 +1,3 @@
import NodeRenderer from './NodeRenderer.vue'
export default NodeRenderer
@@ -0,0 +1,10 @@
let isPreload = false
export async function preload(m) {
if (isPreload)
return
isPreload = true
const existingEnv = (globalThis as any)?.MonacoEnvironment
if (existingEnv && (typeof existingEnv.getWorker === 'function' || typeof existingEnv.getWorkerUrl === 'function'))
return
return m.preloadMonacoWorkers()
}
@@ -0,0 +1,184 @@
<script setup lang="ts">
import { normalizeCustomHtmlTags } from 'stream-markdown-parser'
import { computed } from 'vue-demi'
import { getHtmlTagFromContent, shouldRenderUnknownHtmlTagAsText } from '../../utils/htmlRenderer'
import { getCustomNodeComponents } from '../../utils/nodeComponents'
import CheckboxNode from '../CheckboxNode'
import EmojiNode from '../EmojiNode'
import EmphasisNode from '../EmphasisNode'
import FootnoteAnchorNode from '../FootnoteAnchorNode'
import FootnoteReferenceNode from '../FootnoteReferenceNode'
import HardBreakNode from '../HardBreakNode'
import HighlightNode from '../HighlightNode'
import HtmlBlockNode from '../HtmlBlockNode'
import HtmlInlineNode from '../HtmlInlineNode'
import ImageNode from '../ImageNode'
import InlineCodeNode from '../InlineCodeNode'
import InsertNode from '../InsertNode'
import LinkNode from '../LinkNode'
import { MathInlineNodeAsync } from '../NodeRenderer/asyncComponent'
import ReferenceNode from '../ReferenceNode'
import StrikethroughNode from '../StrikethroughNode'
import StrongNode from '../StrongNode'
import SubscriptNode from '../SubscriptNode'
import SuperscriptNode from '../SuperscriptNode'
import TextNode from '../TextNode'
interface NodeChild {
type: string
raw: string
[key: string]: unknown
}
const props = defineProps<{
node: {
type: 'paragraph'
children: NodeChild[]
raw: string
}
customId?: string
indexKey?: number | string
customHtmlTags?: readonly string[]
}>()
const overrides = getCustomNodeComponents(props.customId)
function isWhitespaceText(child: NodeChild) {
return child.type === 'text' && String((child as any).content ?? '').trim() === ''
}
function getTextContent(child: NodeChild) {
return String((child as any).content ?? '')
}
function getMeaningfulLinkChildren(child: NodeChild) {
if (child.type !== 'link' || !Array.isArray((child as any).children))
return []
return (child as any).children.filter((linkChild: NodeChild) => !isWhitespaceText(linkChild))
}
function isImageOnlyLink(child: NodeChild) {
const linkChildren = getMeaningfulLinkChildren(child)
return linkChildren.length === 1 && linkChildren[0]?.type === 'image'
}
const meaningfulChildren = computed(() => props.node.children.filter(child => !isWhitespaceText(child)))
const isMediaOnlyParagraph = computed(() => (
meaningfulChildren.value.length > 0
&& meaningfulChildren.value.every(child => child.type === 'image' || isImageOnlyLink(child))
))
const customHtmlTagsSet = computed<Set<string>>(() => {
return new Set(normalizeCustomHtmlTags(props.customHtmlTags))
})
const renderedChildren = computed(() => {
if (!isMediaOnlyParagraph.value || meaningfulChildren.value.length <= 1)
return props.node.children
const children: NodeChild[] = []
for (let i = 0; i < props.node.children.length; i++) {
const child = props.node.children[i]
if (!isWhitespaceText(child)) {
children.push(child)
continue
}
const hasPrevious = children.length > 0
const hasNext = props.node.children.slice(i + 1).some(nextChild => !isWhitespaceText(nextChild))
if (!hasPrevious || !hasNext)
continue
children.push({
...child,
content: ' ',
raw: ' ',
} as NodeChild)
}
return children
})
function getChildProps(child: NodeChild, index: number) {
return {
'node': child,
'index-key': `${props.indexKey ?? 'paragraph'}-${index}`,
'custom-id': props.customId,
'custom-html-tags': props.customHtmlTags,
}
}
const nodeComponents = {
inline_code: InlineCodeNode,
image: ImageNode,
link: LinkNode,
hardbreak: HardBreakNode,
emphasis: EmphasisNode,
strong: StrongNode,
strikethrough: StrikethroughNode,
highlight: HighlightNode,
insert: InsertNode,
subscript: SubscriptNode,
superscript: SuperscriptNode,
html_inline: HtmlInlineNode,
html_block: HtmlBlockNode,
emoji: EmojiNode,
checkbox: CheckboxNode,
math_inline: MathInlineNodeAsync,
checkbox_input: CheckboxNode,
reference: ReferenceNode,
footnote_anchor: FootnoteAnchorNode,
footnote_reference: FootnoteReferenceNode,
text: TextNode,
...overrides,
}
function processChild(child: NodeChild): { child: NodeChild, component: any } {
if (child.type === 'html_block' || child.type === 'html_inline') {
const tag = String((child as any).tag ?? '').trim().toLowerCase()
|| getHtmlTagFromContent((child as any).content)
if (tag && !customHtmlTagsSet.value.has(tag) && shouldRenderUnknownHtmlTagAsText((child as any).content ?? (child as any).raw, tag)) {
const rawContent = String((child as any).content ?? (child as any).raw ?? '')
return {
child: {
type: 'text',
content: rawContent,
raw: rawContent,
} as NodeChild,
component: TextNode,
}
}
}
return { child, component: (nodeComponents as any)[child.type] }
}
</script>
<template>
<p dir="auto" class="paragraph-node">
<template v-for="(child, index) in renderedChildren">
<template v-if="isMediaOnlyParagraph && isWhitespaceText(child)">
{{ getTextContent(child) }}
</template>
<component
:is="processChild(child).component"
v-else
:key="`${indexKey || 'paragraph'}-${index}`"
v-bind="getChildProps(processChild(child).child, index)"
/>
</template>
</p>
</template>
<style scoped>
.paragraph-node{
margin: 1.25em 0;
}
li .paragraph-node{
margin: 0;
}
</style>
@@ -0,0 +1,3 @@
import ParagraphNode from './ParagraphNode.vue'
export default ParagraphNode
@@ -0,0 +1,50 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue-demi'
const props = defineProps<{ to?: string }>()
const rootRef = ref<HTMLElement | null>(null)
function resolveTarget() {
if (typeof document === 'undefined')
return null
if (!props.to || props.to === 'body')
return document.body
return document.querySelector(props.to)
}
function moveToTarget() {
const root = rootRef.value
if (!root)
return
const target = resolveTarget()
if (target && root.parentElement !== target)
target.appendChild(root)
}
onMounted(() => {
moveToTarget()
})
watch(
() => props.to,
() => {
moveToTarget()
},
)
onBeforeUnmount(() => {
// Vue will remove the element from its current parent automatically.
})
</script>
<template>
<div ref="rootRef" class="portal-root">
<slot />
</div>
</template>
<style scoped>
.portal-root {
display: contents;
}
</style>
@@ -0,0 +1,3 @@
import Portal from './Portal.vue'
export default Portal
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { computed } from 'vue-demi'
interface PreCodeNodeProps {
node: any
}
const props = defineProps<PreCodeNodeProps>()
// Normalize language to a safe, lowercase token (fallback to 'plaintext')
const normalizedLanguage = computed(() => {
const raw = String(props.node?.language ?? '')
const head = String(String(raw).split(/\s+/g)[0] ?? '').toLowerCase()
const safe = head.replace(/[^\w-]/g, '')
return safe || 'plaintext'
})
const languageClass = computed(() => `language-${normalizedLanguage.value}`)
const ariaLabel = computed(() => {
const lang = normalizedLanguage.value
return lang ? `Code block: ${lang}` : 'Code block'
})
</script>
<template>
<pre
:class="[languageClass]"
:aria-busy="node.loading === true"
:aria-label="ariaLabel"
:data-language="normalizedLanguage"
tabindex="0"
><code translate="no" v-text="node.code" /></pre>
</template>
<style>
/* Minimal, safe defaults to reduce flicker during frequent text updates */
.markstream-vue2 pre[class^='language-'],
.markstream-vue2 pre[class*=' language-'] {
/* Ensure code layout is stable */
white-space: pre;
overflow: auto;
tab-size: 2;
font-variant-ligatures: none;
/* Isolate painting/layout to this block to avoid ancestor reflow jank */
contain: content;
/* Hint GPU compositing on WebKit/Blink to reduce paint flashing */
backface-visibility: hidden;
transform: translateZ(0);
-webkit-font-smoothing: antialiased;
}
.markstream-vue2 pre[class^='language-'] > code,
.markstream-vue2 pre[class*=' language-'] > code {
display: block;
}
/* Keyboard accessibility: visible focus when scroll container is focused */
.markstream-vue2 pre[class^='language-']:focus,
.markstream-vue2 pre[class*=' language-']:focus {
outline: 2px solid var(--vmdr-focus, #3b82f6);
outline-offset: 2px;
}
</style>
@@ -0,0 +1,3 @@
import PreCodeNode from './PreCodeNode.vue'
export default PreCodeNode
@@ -0,0 +1,25 @@
<script setup lang="ts">
defineProps<{
node: {
type: 'reference'
id: string
raw: string
}
messageId?: string
threadId?: string
}>()
defineEmits(['click', 'mouseEnter', 'mouseLeave'])
</script>
<template>
<span
class="reference-node cursor-pointer bg-[hsl(var(--muted))] text-xs rounded-md px-1.5 mx-0.5 hover:bg-[hsl(var(--secondary))]"
role="button"
tabindex="0"
@click="$emit('click', $event, node.id, messageId, threadId)"
@mouseenter="$emit('mouseEnter', $event, node.id, messageId, threadId)"
@mouseleave="$emit('mouseLeave', $event, node.id, messageId, threadId)"
>
{{ node.id }}
</span>
</template>
@@ -0,0 +1,3 @@
import ReferenceNode from './ReferenceNode.vue'
export default ReferenceNode
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { getCustomNodeComponents } from '../../utils/nodeComponents'
import EmojiNode from '../EmojiNode'
import EmphasisNode from '../EmphasisNode'
import FootnoteReferenceNode from '../FootnoteReferenceNode'
import HighlightNode from '../HighlightNode'
import HtmlInlineNode from '../HtmlInlineNode'
import InlineCodeNode from '../InlineCodeNode'
import InsertNode from '../InsertNode'
import LinkNode from '../LinkNode'
import { MathInlineNodeAsync } from '../NodeRenderer/asyncComponent'
import ReferenceNode from '../ReferenceNode'
import StrongNode from '../StrongNode'
import SubscriptNode from '../SubscriptNode'
import SuperscriptNode from '../SuperscriptNode'
import TextNode from '../TextNode'
interface NodeChild {
type: string
raw: string
[key: string]: unknown
}
const props = defineProps<{
node: {
type: 'strikethrough'
children: NodeChild[]
raw: string
}
customId?: string
indexKey?: string | number
}>()
const overrides = getCustomNodeComponents(props.customId)
// Available node components for child rendering; prefer custom overrides
const nodeComponents = {
text: TextNode,
inline_code: InlineCodeNode,
link: LinkNode,
html_inline: HtmlInlineNode,
strong: StrongNode,
emphasis: EmphasisNode,
highlight: HighlightNode,
insert: InsertNode,
subscript: SubscriptNode,
superscript: SuperscriptNode,
emoji: EmojiNode,
footnote_reference: FootnoteReferenceNode,
math_inline: MathInlineNodeAsync,
reference: ReferenceNode,
...overrides,
}
</script>
<template>
<del class="strikethrough-node">
<component
:is="nodeComponents[child.type]"
v-for="(child, index) in node.children"
:key="`${indexKey || 'strikethrough'}-${index}`"
:node="child"
:custom-id="props.customId"
:index-key="`${indexKey || 'strikethrough'}-${index}`"
/>
</del>
</template>
<style scoped>
.strikethrough-node {
text-decoration: line-through;
}
</style>
@@ -0,0 +1,3 @@
import StrikethroughNode from './StrikethroughNode.vue'
export default StrikethroughNode
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { getCustomNodeComponents } from '../../utils/nodeComponents'
import EmojiNode from '../EmojiNode'
import EmphasisNode from '../EmphasisNode'
import FootnoteReferenceNode from '../FootnoteReferenceNode'
import HighlightNode from '../HighlightNode'
import HtmlInlineNode from '../HtmlInlineNode'
import InlineCodeNode from '../InlineCodeNode'
import InsertNode from '../InsertNode'
import LinkNode from '../LinkNode'
import { MathInlineNodeAsync } from '../NodeRenderer/asyncComponent'
import ReferenceNode from '../ReferenceNode'
import StrikethroughNode from '../StrikethroughNode'
import SubscriptNode from '../SubscriptNode'
import SuperscriptNode from '../SuperscriptNode'
import TextNode from '../TextNode'
interface NodeChild {
type: string
raw: string
[key: string]: unknown
}
const props = defineProps<{
node: {
type: 'strong'
children: NodeChild[]
raw: string
}
customId?: string
indexKey?: number | string
}>()
const overrides = getCustomNodeComponents(props.customId)
// Available node components for child rendering; prefer custom overrides
const nodeComponents = {
text: TextNode,
inline_code: InlineCodeNode,
link: LinkNode,
html_inline: HtmlInlineNode,
emphasis: EmphasisNode,
strikethrough: StrikethroughNode,
highlight: HighlightNode,
insert: InsertNode,
subscript: SubscriptNode,
superscript: SuperscriptNode,
emoji: EmojiNode,
footnote_reference: FootnoteReferenceNode,
math_inline: MathInlineNodeAsync,
reference: ReferenceNode,
...overrides,
}
</script>
<template>
<strong class="strong-node">
<component
:is="nodeComponents[child.type]"
v-for="(child, index) in node.children"
:key="`${indexKey || 'strong'}-${index}`"
:index-key="`${indexKey || 'strong'}-${index}`"
:node="child"
:custom-id="props.customId"
/>
</strong>
</template>
<style scoped>
.strong-node {
font-weight: bold;
}
</style>
@@ -0,0 +1,3 @@
import StrongNode from './StrongNode.vue'
export default StrongNode
@@ -0,0 +1,82 @@
<script setup lang="ts">
import { getCustomNodeComponents } from '../../utils/nodeComponents'
import EmojiNode from '../EmojiNode'
import EmphasisNode from '../EmphasisNode'
import FootnoteReferenceNode from '../FootnoteReferenceNode'
import HighlightNode from '../HighlightNode'
import HtmlInlineNode from '../HtmlInlineNode'
import InlineCodeNode from '../InlineCodeNode'
import InsertNode from '../InsertNode'
import LinkNode from '../LinkNode'
import { MathInlineNodeAsync } from '../NodeRenderer/asyncComponent'
import ReferenceNode from '../ReferenceNode'
import StrikethroughNode from '../StrikethroughNode'
import StrongNode from '../StrongNode'
import SuperscriptNode from '../SuperscriptNode'
import TextNode from '../TextNode'
interface NodeChild {
type: string
raw: string
[key: string]: unknown
}
const props = defineProps<{
node: {
type: 'subscript'
children: NodeChild[]
raw: string
}
customId?: string
indexKey?: number | string
}>()
const overrides = getCustomNodeComponents(props.customId)
const nodeComponents = {
text: TextNode,
inline_code: InlineCodeNode,
link: LinkNode,
html_inline: HtmlInlineNode,
strong: StrongNode,
emphasis: EmphasisNode,
footnote_reference: FootnoteReferenceNode,
strikethrough: StrikethroughNode,
highlight: HighlightNode,
insert: InsertNode,
superscript: SuperscriptNode,
emoji: EmojiNode,
math_inline: MathInlineNodeAsync,
reference: ReferenceNode,
...overrides,
}
</script>
<template>
<sub class="subscript-node">
<span
v-for="(child, index) in node.children"
:key="`${indexKey || 'subscript'}-${index}`"
class="subscript-child"
>
<component
:is="nodeComponents[child.type]"
v-if="nodeComponents[child.type]"
:node="child"
:custom-id="props.customId"
:index-key="`${indexKey || 'subscript'}-${index}`"
/>
<span v-else>{{ child.content || child.raw || '' }}</span>
</span>
</sub>
</template>
<style scoped>
.subscript-node {
font-size: 0.8em;
vertical-align: sub;
}
.subscript-child {
display: contents;
}
</style>
@@ -0,0 +1,3 @@
import SubscriptNode from './SubscriptNode.vue'
export default SubscriptNode
@@ -0,0 +1,82 @@
<script setup lang="ts">
import { getCustomNodeComponents } from '../../utils/nodeComponents'
import EmojiNode from '../EmojiNode'
import EmphasisNode from '../EmphasisNode'
import FootnoteReferenceNode from '../FootnoteReferenceNode'
import HighlightNode from '../HighlightNode'
import HtmlInlineNode from '../HtmlInlineNode'
import InlineCodeNode from '../InlineCodeNode'
import InsertNode from '../InsertNode'
import LinkNode from '../LinkNode'
import { MathInlineNodeAsync } from '../NodeRenderer/asyncComponent'
import ReferenceNode from '../ReferenceNode'
import StrikethroughNode from '../StrikethroughNode'
import StrongNode from '../StrongNode'
import SubscriptNode from '../SubscriptNode'
import TextNode from '../TextNode'
interface NodeChild {
type: string
raw: string
[key: string]: unknown
}
const props = defineProps<{
node: {
type: 'superscript'
children: NodeChild[]
raw: string
}
customId?: string
indexKey?: number | string
}>()
const overrides = getCustomNodeComponents(props.customId)
const nodeComponents = {
text: TextNode,
inline_code: InlineCodeNode,
link: LinkNode,
html_inline: HtmlInlineNode,
strong: StrongNode,
emphasis: EmphasisNode,
footnote_reference: FootnoteReferenceNode,
strikethrough: StrikethroughNode,
highlight: HighlightNode,
insert: InsertNode,
subscript: SubscriptNode,
emoji: EmojiNode,
math_inline: MathInlineNodeAsync,
reference: ReferenceNode,
...overrides,
}
</script>
<template>
<sup class="superscript-node">
<span
v-for="(child, index) in node.children"
:key="`${indexKey || 'superscript'}-${index}`"
class="superscript-child"
>
<component
:is="nodeComponents[child.type]"
v-if="nodeComponents[child.type]"
:node="child"
:custom-id="props.customId"
:index-key="`${indexKey || 'superscript'}-${index}`"
/>
<span v-else>{{ child.content || child.raw || '' }}</span>
</span>
</sup>
</template>
<style scoped>
.superscript-node {
font-size: 0.8em;
vertical-align: super;
}
.superscript-child {
display: contents;
}
</style>
@@ -0,0 +1,3 @@
import SuperscriptNode from './SuperscriptNode.vue'
export default SuperscriptNode
@@ -0,0 +1,406 @@
<script setup lang="ts">
import { computed, getCurrentInstance, onBeforeUnmount, ref, watch } from 'vue-demi'
import { isLegacyVue26Vm } from '../../utils/vue26'
import NodeRenderer from '../NodeRenderer'
import LegacyNodesRenderer from '../NodeRenderer/LegacyNodesRenderer.vue'
// 定义单元格节点
interface TableCellNode {
type: 'table_cell'
header: boolean
children: {
type: string
raw: string
}[]
raw: string
align?: 'left' | 'right' | 'center'
}
// 定义行节点
interface TableRowNode {
type: 'table_row'
cells: TableCellNode[]
raw: string
}
// 定义表格节点
interface TableNode {
type: 'table'
header: TableRowNode
rows: TableRowNode[]
raw: string
loading: boolean
}
// 接收props
const props = defineProps<{
node: TableNode
indexKey: string | number
isDark?: boolean
typewriter?: boolean
customId?: string
}>()
// 定义事件
defineEmits(['copy'])
const isLoading = computed(() => props.node.loading ?? false)
const bodyRows = computed(() => props.node.rows ?? [])
const tableRef = ref<HTMLTableElement | null>(null)
const columnWidths = ref<number[]>([])
const instance = getCurrentInstance()
const nestedRenderer = computed(() => {
const vm = instance?.proxy as any
return isLegacyVue26Vm(vm) ? LegacyNodesRenderer : NodeRenderer
})
const MIN_COLUMN_WIDTH = 48
let resizeState: {
index: number
startX: number
startWidth: number
nextStartWidth: number
widths: number[]
} | null = null
const columnStyles = computed(() =>
columnWidths.value.map(width => width > 0 ? { width: `${width}px` } : undefined),
)
const hasColumnWidths = computed(() => columnWidths.value.length > 0)
function measureHeaderWidths() {
const cells = tableRef.value?.querySelectorAll('thead th')
return Array.from(cells ?? [], cell => Math.round(cell.getBoundingClientRect().width))
}
function onColumnResizeMove(event: PointerEvent) {
if (!resizeState)
return
event.preventDefault()
const pairWidth = resizeState.startWidth + resizeState.nextStartWidth
const minWidth = Math.min(MIN_COLUMN_WIDTH, Math.floor(pairWidth / 2))
const width = Math.max(
minWidth,
Math.min(pairWidth - minWidth, Math.round(resizeState.startWidth + event.clientX - resizeState.startX)),
)
const nextWidths = [...resizeState.widths]
nextWidths[resizeState.index] = width
nextWidths[resizeState.index + 1] = pairWidth - width
columnWidths.value = nextWidths
}
function stopColumnResize() {
if (!resizeState)
return
window.removeEventListener('pointermove', onColumnResizeMove)
window.removeEventListener('pointerup', stopColumnResize)
window.removeEventListener('pointercancel', stopColumnResize)
resizeState = null
}
function startColumnResize(index: number, event: PointerEvent) {
if (event.button !== 0)
return
const widths = measureHeaderWidths()
const startWidth = widths[index]
const nextStartWidth = widths[index + 1]
if (!startWidth || !nextStartWidth)
return
event.preventDefault()
resizeState = {
index,
startX: event.clientX,
startWidth,
nextStartWidth,
widths,
}
columnWidths.value = widths
window.addEventListener('pointermove', onColumnResizeMove)
window.addEventListener('pointerup', stopColumnResize)
window.addEventListener('pointercancel', stopColumnResize)
}
watch(
() => props.node.header.cells.length,
() => {
stopColumnResize()
columnWidths.value = []
},
)
onBeforeUnmount(stopColumnResize)
</script>
<template>
<div class="table-node-wrapper">
<table
ref="tableRef"
class="my-8 text-sm table-node"
:class="{ 'table-node--loading': isLoading }"
:aria-busy="isLoading"
>
<colgroup v-if="hasColumnWidths">
<col
v-for="(_, index) in node.header.cells"
:key="`col-${index}`"
:style="columnStyles[index]"
>
</colgroup>
<thead class="border-[var(--table-border,#cbd5e1)] border-solid">
<tr class="border-b border-solid">
<th
v-for="(cell, index) in node.header.cells"
:key="`header-${index}`"
dir="auto"
class="font-semibold p-[calc(4/7*1em)]"
:class="[
cell.align === 'right'
? 'text-right'
: cell.align === 'center'
? 'text-center'
: 'text-left',
]"
>
<component
:is="nestedRenderer"
:nodes="cell.children"
:index-key="`table-th-${props.indexKey}`"
:custom-id="props.customId"
:typewriter="props.typewriter"
@copy="$emit('copy', $event)"
/>
<button
v-if="index < node.header.cells.length - 1"
type="button"
class="table-node__resize-handle"
:aria-label="`Resize columns ${index + 1} and ${index + 2}`"
@pointerdown="startColumnResize(index, $event)"
/>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, rowIndex) in bodyRows"
:key="`row-${rowIndex}`"
class="border-[var(--table-border,#cbd5e1)] border-solid"
:class="[rowIndex < bodyRows.length - 1 ? 'border-b' : '']"
>
<td
v-for="(cell, cellIndex) in row.cells"
:key="`cell-${rowIndex}-${cellIndex}`"
class="p-[calc(4/7*1em)]"
:class="[
cell.align === 'right'
? 'text-right'
: cell.align === 'center'
? 'text-center'
: 'text-left',
]"
dir="auto"
>
<component
:is="nestedRenderer"
:nodes="cell.children"
:index-key="`table-td-${props.indexKey}`"
:custom-id="props.customId"
:typewriter="props.typewriter"
@copy="$emit('copy', $event)"
/>
</td>
</tr>
</tbody>
</table>
<transition name="table-node-fade">
<div v-if="isLoading" class="table-node__loading" role="status" aria-live="polite">
<slot name="loading" :is-loading="isLoading">
<span class="table-node__spinner animate-spin" aria-hidden="true" />
<span class="sr-only">Loading</span>
</slot>
</div>
</transition>
</div>
</template>
<style scoped>
.table-node-wrapper {
position: relative;
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
overscroll-behavior-y: auto;
scrollbar-gutter: stable;
}
.table-node {
width: 100%;
border-collapse: collapse;
}
.table-node ::v-deep th,
.table-node ::v-deep td {
white-space: normal;
overflow-wrap: break-word;
word-break: normal;
}
.table-node ::v-deep thead th {
position: relative;
}
.table-node__resize-handle {
position: absolute;
top: 0;
right: -4px;
bottom: 0;
z-index: 1;
width: 8px;
padding: 0;
border: 0;
background: transparent;
cursor: col-resize;
touch-action: none;
}
.table-node__resize-handle::after {
content: '';
position: absolute;
top: 0.35em;
bottom: 0.35em;
left: 50%;
width: 2px;
border-radius: 9999px;
background: rgba(94, 104, 121, 0.55);
opacity: 0;
transform: translateX(-50%);
transition: opacity 0.12s ease;
}
.table-node__resize-handle:hover::after,
.table-node__resize-handle:focus-visible::after {
opacity: 1;
}
.table-node--loading tbody td {
position: relative;
overflow: hidden;
}
.table-node--loading tbody td > * {
visibility: hidden;
}
.table-node--loading tbody td::after {
content: '';
position: absolute;
inset: 0;
border-radius: 0.25rem;
background: linear-gradient(
90deg,
rgba(148, 163, 184, 0.16) 25%,
rgba(148, 163, 184, 0.28) 50%,
rgba(148, 163, 184, 0.16) 75%
);
background-size: 200% 100%;
animation: table-node-shimmer 1.2s linear infinite;
will-change: background-position;
}
.table-node__loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.table-node__spinner {
width: 2.5rem;
height: 2.5rem;
border-radius: 9999px;
border: 2px solid rgba(94, 104, 121, 0.25);
border-top-color: rgba(94, 104, 121, 0.8);
will-change: transform;
}
.table-node-fade-enter-active,
.table-node-fade-leave-active {
transition: opacity 0.18s ease;
}
.table-node-fade-enter-from,
.table-node-fade-leave-to {
opacity: 0;
}
/* 表格单元格内的 NodeRenderer 禁用 content-visibility 的占位行为,避免“高但空”的问题 */
.table-node ::v-deep .markdown-renderer {
/* Make the NodeRenderer wrapper behave as if it's not there so
table cells keep their expected inline/flow layout. */
display: contents;
content-visibility: visible;
contain: content;
contain-intrinsic-size: 0px 0px;
}
/* Also make internal NodeRenderer wrapper elements layout-transparent
so they don't introduce block-level boxes inside table cells. */
.table-node ::v-deep .markdown-renderer .node-slot,
.table-node ::v-deep .markdown-renderer .node-content,
.table-node ::v-deep .markdown-renderer .node-space
{
display: contents;
}
/* Override the default `break-words` / pre-wrap text styles inside tables so
dense tables don't turn into vertical glyph stacks. */
.table-node ::v-deep .text-node,
.table-node ::v-deep code {
white-space: inherit;
overflow-wrap: inherit;
word-break: inherit;
max-width: none;
}
@keyframes table-node-shimmer {
0% {
background-position: 0% 0%;
}
50% {
background-position: 100% 0%;
}
100% {
background-position: 200% 0%;
}
}
.hr + .table-node-wrapper {
margin-top: 0;
}
.hr + .table-node-wrapper .table-node {
margin-top: 0;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>
@@ -0,0 +1,3 @@
import TableNode from './TableNode.vue'
export default TableNode
@@ -0,0 +1,185 @@
<script setup lang="ts">
import { computed, getCurrentInstance, inject, ref, watch } from 'vue-demi'
import { useKatexReady } from '../../composables/useKatexReady'
import { resolveStreamingTextState } from './streamingTextState'
const props = defineProps<{
node: {
type: 'text'
content: string
raw: string
center?: boolean
}
}>()
defineEmits(['copy'])
const katexReady = useKatexReady()
const instance = getCurrentInstance()
const attrs = computed<Record<string, unknown>>(() => ((instance?.proxy as any)?.$attrs ?? {}) as Record<string, unknown>)
const inheritedTypewriter = inject<{ value?: boolean } | undefined>('markstreamTypewriter', undefined)
const inheritedTextStreamState = inject<Map<string, string> | undefined>('markstreamTextStreamState', undefined)
const inheritedStreamVersion = inject<{ value?: number } | undefined>('markstreamStreamVersion', undefined)
const explicitTypewriter = computed<boolean | undefined>(() => {
const raw = attrs.value.typewriter
if (raw === '' || raw === true || raw === 'true')
return true
if (raw === false || raw === 'false')
return false
return undefined
})
const typewriterEnabled = computed(() => {
if (typeof explicitTypewriter.value === 'boolean')
return explicitTypewriter.value
if (typeof inheritedTypewriter?.value === 'boolean')
return inheritedTypewriter.value
return true
})
const streamStateKey = computed(() => {
const raw = attrs.value['index-key'] ?? attrs.value.indexKey
if (raw == null || raw === '')
return ''
return String(raw)
})
const settledContent = ref(props.node.content)
const streamedDelta = ref('')
const streamFadeVersion = ref(0)
function getRenderedContent() {
return settledContent.value + streamedDelta.value
}
function setFullContent(next: string) {
settledContent.value = next
streamedDelta.value = ''
}
function settleStreamedDelta() {
if (!streamedDelta.value)
return
settledContent.value = getRenderedContent()
streamedDelta.value = ''
}
watch(
[() => props.node.content, streamStateKey, typewriterEnabled, () => inheritedStreamVersion?.value],
([next]) => {
const normalized = String(next ?? '')
const rendered = getRenderedContent()
const key = streamStateKey.value
const previousPersisted = key
? inheritedTextStreamState?.get(key)
: undefined
const previousContent = previousPersisted ?? rendered
if (!typewriterEnabled.value) {
setFullContent(normalized)
if (key)
inheritedTextStreamState?.set(key, normalized)
return
}
if (normalized === previousContent) {
if (streamedDelta.value)
settleStreamedDelta()
else if (rendered !== normalized)
setFullContent(normalized)
if (key)
inheritedTextStreamState?.set(key, normalized)
return
}
const nextState = resolveStreamingTextState({
nextContent: normalized,
previousContent,
typewriterEnabled: typewriterEnabled.value,
})
settledContent.value = nextState.settledContent
streamedDelta.value = nextState.streamedDelta
if (nextState.appended)
streamFadeVersion.value += 1
if (key)
inheritedTextStreamState?.set(key, normalized)
},
{ immediate: true },
)
watch(
typewriterEnabled,
(enabled) => {
if (enabled)
return
setFullContent(getRenderedContent())
},
)
const streamedDeltaClass = computed(() => (
streamFadeVersion.value % 2 === 0
? 'text-node-stream-delta--a'
: 'text-node-stream-delta--b'
))
</script>
<template>
<span
:class="[katexReady && node.center ? 'text-node-center' : '']"
class="whitespace-pre-wrap break-words text-node"
>
<span v-if="settledContent">{{ settledContent }}</span>
<span
v-if="streamedDelta"
class="text-node-stream-delta" :class="[streamedDeltaClass]"
@animationend="settleStreamedDelta"
>
{{ streamedDelta }}
</span>
</span>
</template>
<style scoped>
.text-node {
display: inline;
font-weight: inherit;
vertical-align: baseline;
}
.text-node-center {
display: inline-flex;
justify-content: center;
width: 100%;
}
.text-node-stream-delta {
animation-duration: var(--stream-update-fade-duration, var(--typewriter-fade-duration, 900ms));
animation-timing-function: var(--stream-update-fade-ease, var(--typewriter-fade-ease, ease-out));
animation-fill-mode: both;
will-change: opacity;
}
.text-node-stream-delta--a {
animation-name: text-node-stream-update-fade-a;
}
.text-node-stream-delta--b {
animation-name: text-node-stream-update-fade-b;
}
@keyframes text-node-stream-update-fade-a {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes text-node-stream-update-fade-b {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.text-node-stream-delta {
animation: none !important;
}
}
</style>
@@ -0,0 +1,3 @@
import TextNode from './TextNode.vue'
export default TextNode
@@ -0,0 +1,47 @@
export interface ResolveStreamingTextStateOptions {
nextContent: string
previousContent: string
typewriterEnabled: boolean
}
export interface StreamingTextStateResult {
settledContent: string
streamedDelta: string
appended: boolean
}
export function resolveStreamingTextState({
nextContent,
previousContent,
typewriterEnabled,
}: ResolveStreamingTextStateOptions): StreamingTextStateResult {
if (!typewriterEnabled) {
return {
settledContent: nextContent,
streamedDelta: '',
appended: false,
}
}
if (nextContent === previousContent) {
return {
settledContent: nextContent,
streamedDelta: '',
appended: false,
}
}
if (previousContent && nextContent.startsWith(previousContent) && nextContent.length > previousContent.length) {
return {
settledContent: previousContent,
streamedDelta: nextContent.slice(previousContent.length),
appended: true,
}
}
return {
settledContent: nextContent,
streamedDelta: '',
appended: false,
}
}

Some files were not shown because too many files have changed in this diff Show More