feat(desktop): embed BoosAPI Desktop client as a subproject
Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as a monorepo subdirectory under desktop/. Source only — node_modules, dist, Cargo target and the upstream .git history are excluded. desktop/ keeps its own independent Cargo workspace (desktop/src-tauri with crates/*); it is not part of the backend boosapi crate, so a `cargo check` at the metapi root still compiles only the backend. Build the desktop app from desktop/src-tauri per desktop/README.md. Note: app icons are still placeholder assets pending the final BoosAPI logo. The updater signing keys live outside the repo and are not included.
This commit is contained in:
@@ -0,0 +1,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',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
+65
@@ -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.')
|
||||
+283
@@ -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>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import AdmonitionNode from './AdmonitionNode.vue'
|
||||
|
||||
export default AdmonitionNode
|
||||
+66
@@ -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>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import BlockquoteNode from './BlockquoteNode.vue'
|
||||
|
||||
export default BlockquoteNode
|
||||
+32
@@ -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>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import CheckboxNode from './CheckboxNode.vue'
|
||||
|
||||
export default CheckboxNode
|
||||
+2190
File diff suppressed because it is too large
Load Diff
+192
@@ -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>
|
||||
+26
@@ -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
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import CodeBlockNode from './CodeBlockNode.vue'
|
||||
|
||||
export default CodeBlockNode
|
||||
+58
@@ -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
|
||||
}
|
||||
}
|
||||
+75
@@ -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
|
||||
}
|
||||
+642
@@ -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
|
||||
}
|
||||
+11
@@ -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
|
||||
+93
@@ -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>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import DefinitionListNode from './DefinitionListNode.vue'
|
||||
|
||||
export default DefinitionListNode
|
||||
+20
@@ -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
|
||||
+73
@@ -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>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import EmphasisNode from './EmphasisNode.vue'
|
||||
|
||||
export default EmphasisNode
|
||||
+33
@@ -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>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import FootnoteAnchorNode from './FootnoteAnchorNode.vue'
|
||||
|
||||
export default FootnoteAnchorNode
|
||||
+59
@@ -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>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import FootnoteNode from './FootnoteNode.vue'
|
||||
|
||||
export default FootnoteNode
|
||||
+53
@@ -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>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import FootnoteReferenceNode from './FootnoteReferenceNode.vue'
|
||||
|
||||
export default FootnoteReferenceNode
|
||||
+22
@@ -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>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import HardBreakNode from './HardBreakNode.vue'
|
||||
|
||||
export default HardBreakNode
|
||||
+116
@@ -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>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import HeadingNode from './HeadingNode.vue'
|
||||
|
||||
export default HeadingNode
|
||||
+74
@@ -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>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import HighlightNode from './HighlightNode.vue'
|
||||
|
||||
export default HighlightNode
|
||||
+200
@@ -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>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import HtmlBlockNode from './HtmlBlockNode.vue'
|
||||
|
||||
export default HtmlBlockNode
|
||||
+88
@@ -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>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import HtmlInlineNode from './HtmlInlineNode.vue'
|
||||
|
||||
export default HtmlInlineNode
|
||||
+161
@@ -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
|
||||
+750
@@ -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>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import InfographicBlockNode from './InfographicBlockNode.vue'
|
||||
|
||||
export default InfographicBlockNode
|
||||
+35
@@ -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
|
||||
}
|
||||
+153
@@ -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>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import InlineCodeNode from './InlineCodeNode.vue'
|
||||
|
||||
export default InlineCodeNode
|
||||
+73
@@ -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>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import InsertNode from './InsertNode.vue'
|
||||
|
||||
export default InsertNode
|
||||
+283
@@ -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
|
||||
+71
@@ -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>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import ListItemNode from './ListItemNode.vue'
|
||||
|
||||
export default ListItemNode
|
||||
+73
@@ -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
|
||||
+884
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
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>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import MarkdownCodeBlockNode from './MarkdownCodeBlockNode.vue'
|
||||
|
||||
export default MarkdownCodeBlockNode
|
||||
+157
@@ -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>
|
||||
+219
@@ -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>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import MathBlockNode from './MathBlockNode.vue'
|
||||
|
||||
export default MathBlockNode
|
||||
+226
@@ -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>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import MathInlineNode from './MathInlineNode.vue'
|
||||
|
||||
export default MathInlineNode
|
||||
+93
@@ -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
|
||||
}
|
||||
+2149
File diff suppressed because it is too large
Load Diff
+3
@@ -0,0 +1,3 @@
|
||||
import MermaidBlockNode from './MermaidBlockNode.vue'
|
||||
|
||||
export default MermaidBlockNode
|
||||
+129
@@ -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
|
||||
}
|
||||
+75
@@ -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>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import NestedRenderer from './NestedRenderer.vue'
|
||||
|
||||
export default NestedRenderer
|
||||
+15
@@ -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>
|
||||
+18
@@ -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>
|
||||
+282
@@ -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>
|
||||
+2280
File diff suppressed because it is too large
Load Diff
+61
@@ -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}$$`)
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import NodeRenderer from './NodeRenderer.vue'
|
||||
|
||||
export default NodeRenderer
|
||||
+10
@@ -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()
|
||||
}
|
||||
+184
@@ -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>
|
||||
+3
@@ -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
|
||||
+63
@@ -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>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import PreCodeNode from './PreCodeNode.vue'
|
||||
|
||||
export default PreCodeNode
|
||||
+25
@@ -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>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import ReferenceNode from './ReferenceNode.vue'
|
||||
|
||||
export default ReferenceNode
|
||||
+73
@@ -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>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import StrikethroughNode from './StrikethroughNode.vue'
|
||||
|
||||
export default StrikethroughNode
|
||||
+73
@@ -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>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import StrongNode from './StrongNode.vue'
|
||||
|
||||
export default StrongNode
|
||||
+82
@@ -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>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import SubscriptNode from './SubscriptNode.vue'
|
||||
|
||||
export default SubscriptNode
|
||||
+82
@@ -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>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import SuperscriptNode from './SuperscriptNode.vue'
|
||||
|
||||
export default SuperscriptNode
|
||||
+406
@@ -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
|
||||
+185
@@ -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
|
||||
+47
@@ -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
Reference in New Issue
Block a user