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.
@@ -0,0 +1,54 @@
|
||||
# Vue3 ↔ React 逐组件对照表(markstream)
|
||||
|
||||
说明:
|
||||
- Vue 源码在 `src/components/*`,React 源码在 `packages/markstream-react/src/components/*`。
|
||||
- React 组件统一从 `packages/markstream-react/src/index.ts` 导出;样式来自 `markstream-react/index.css`(选择器前缀 `.markstream-react`)。
|
||||
- 绝大多数“节点组件”在两端都是同名组件:输入 `node`(以及 renderer 透传的 `ctx` / `indexKey` / `customId` / `isDark` / `typewriter`),无额外 API 差异。
|
||||
|
||||
## 组件映射
|
||||
|
||||
| Vue 组件 | React 组件 | 备注(props / events / 样式) |
|
||||
|---|---|---|
|
||||
| `src/components/AdmonitionNode/AdmonitionNode.vue` | `packages/markstream-react/src/components/AdmonitionNode/AdmonitionNode.tsx` | 节点组件(`node` + renderer 透传字段) |
|
||||
| `src/components/BlockquoteNode/BlockquoteNode.vue` | `packages/markstream-react/src/components/BlockquoteNode/BlockquoteNode.tsx` | 节点组件 |
|
||||
| `src/components/CheckboxNode/CheckboxNode.vue` | `packages/markstream-react/src/components/CheckboxNode/CheckboxNode.tsx` | 节点组件 |
|
||||
| `src/components/CodeBlockNode/CodeBlockNode.vue` | `packages/markstream-react/src/components/CodeBlockNode/CodeBlockNode.tsx` | `CodeBlockNodeProps` 两端一致(见 `src/types/component-props.ts` 与 `packages/markstream-react/src/types/component-props.ts`);Vue emits `copy`/`previewCode` ↔ React `onCopy`/`onPreviewCode` |
|
||||
| `src/components/DefinitionListNode/DefinitionListNode.vue` | `packages/markstream-react/src/components/DefinitionListNode/DefinitionListNode.tsx` | 节点组件 |
|
||||
| `src/components/EmojiNode/EmojiNode.vue` | `packages/markstream-react/src/components/EmojiNode/EmojiNode.tsx` | 节点组件 |
|
||||
| `src/components/EmphasisNode/EmphasisNode.vue` | `packages/markstream-react/src/components/EmphasisNode/EmphasisNode.tsx` | 节点组件 |
|
||||
| `src/components/FootnoteAnchorNode/FootnoteAnchorNode.vue` | `packages/markstream-react/src/components/FootnoteAnchorNode/FootnoteAnchorNode.tsx` | 节点组件 |
|
||||
| `src/components/FootnoteNode/FootnoteNode.vue` | `packages/markstream-react/src/components/FootnoteNode/FootnoteNode.tsx` | 节点组件 |
|
||||
| `src/components/FootnoteReferenceNode/FootnoteReferenceNode.vue` | `packages/markstream-react/src/components/FootnoteReferenceNode/FootnoteReferenceNode.tsx` | 节点组件 |
|
||||
| `src/components/HardBreakNode/HardBreakNode.vue` | `packages/markstream-react/src/components/HardBreakNode/HardBreakNode.tsx` | 节点组件 |
|
||||
| `src/components/HeadingNode/HeadingNode.vue` | `packages/markstream-react/src/components/HeadingNode/HeadingNode.tsx` | 节点组件 |
|
||||
| `src/components/HighlightNode/HighlightNode.vue` | `packages/markstream-react/src/components/HighlightNode/HighlightNode.tsx` | 节点组件 |
|
||||
| `src/components/HtmlBlockNode/HtmlBlockNode.vue` | `packages/markstream-react/src/components/HtmlBlockNode/HtmlBlockNode.tsx` | 节点组件 |
|
||||
| `src/components/HtmlInlineNode/HtmlInlineNode.vue` | `packages/markstream-react/src/components/HtmlInlineNode/HtmlInlineNode.tsx` | 节点组件 |
|
||||
| `src/components/ImageNode/ImageNode.vue` | `packages/markstream-react/src/components/ImageNode/ImageNode.tsx` | `ImageNodeProps` 两端一致;React 额外事件:`onLoad`/`onError`/`onClick`(不影响基础 API) |
|
||||
| `src/components/InlineCodeNode/InlineCodeNode.vue` | `packages/markstream-react/src/components/InlineCodeNode/InlineCodeNode.tsx` | 节点组件 |
|
||||
| `src/components/InsertNode/InsertNode.vue` | `packages/markstream-react/src/components/InsertNode/InsertNode.tsx` | 节点组件 |
|
||||
| `src/components/LinkNode/LinkNode.vue` | `packages/markstream-react/src/components/LinkNode/LinkNode.tsx` | `LinkNodeProps` 两端一致;tooltip 使用单例实现(Vue `useSingletonTooltip` ↔ React `tooltip/singletonTooltip`) |
|
||||
| `src/components/ListItemNode/ListItemNode.vue` | `packages/markstream-react/src/components/ListItemNode/ListItemNode.tsx` | 节点组件 |
|
||||
| `src/components/ListNode/ListNode.vue` | `packages/markstream-react/src/components/ListNode/ListNode.tsx` | 节点组件 |
|
||||
| `src/components/MarkdownCodeBlockNode/MarkdownCodeBlockNode.vue` | `packages/markstream-react/src/components/MarkdownCodeBlockNode/MarkdownCodeBlockNode.tsx` | 节点组件(markdown fenced code 的轻量渲染) |
|
||||
| `src/components/MathBlockNode/MathBlockNode.vue` | `packages/markstream-react/src/components/MathBlockNode/MathBlockNode.tsx` | `MathBlockNodeProps` 两端一致 |
|
||||
| `src/components/MathInlineNode/MathInlineNode.vue` | `packages/markstream-react/src/components/MathInlineNode/MathInlineNode.tsx` | `MathInlineNodeProps` 两端一致 |
|
||||
| `src/components/MermaidBlockNode/MermaidBlockNode.vue` | `packages/markstream-react/src/components/MermaidBlockNode/MermaidBlockNode.tsx` | `MermaidBlockNodeProps` 两端一致;Vue emits `copy`/`export`/`open-modal`/`toggle-mode` ↔ React `onCopy`/`onExport`/`onOpenModal`/`onToggleMode`;全屏为独立 Portal 弹层(样式在 `packages/markstream-react/src/index.css` 的 `.mermaid-modal-*`) |
|
||||
| `src/components/NodeRenderer/NodeRenderer.vue` | `packages/markstream-react/src/components/NodeRenderer.tsx` | 渲染器本体:增量渲染/虚拟列表/viewport priority;React 额外文件在 `packages/markstream-react/src/components/NodeRenderer/*` |
|
||||
| `src/components/NodeRenderer/FallbackComponent.vue` | `packages/markstream-react/src/components/NodeRenderer/FallbackComponent.tsx` | 未识别节点兜底组件 |
|
||||
| `src/components/ParagraphNode/ParagraphNode.vue` | `packages/markstream-react/src/components/ParagraphNode/ParagraphNode.tsx` | 节点组件 |
|
||||
| `src/components/PreCodeNode/PreCodeNode.vue` | `packages/markstream-react/src/components/PreCodeNode/PreCodeNode.tsx` | React 实际实现位于 `packages/markstream-react/src/components/CodeBlockNode/PreCodeNode.tsx` 并在此处 re-export |
|
||||
| `src/components/ReferenceNode/ReferenceNode.vue` | `packages/markstream-react/src/components/ReferenceNode/ReferenceNode.tsx` | 节点组件 |
|
||||
| `src/components/StrikethroughNode/StrikethroughNode.vue` | `packages/markstream-react/src/components/StrikethroughNode/StrikethroughNode.tsx` | 节点组件 |
|
||||
| `src/components/StrongNode/StrongNode.vue` | `packages/markstream-react/src/components/StrongNode/StrongNode.tsx` | 节点组件 |
|
||||
| `src/components/SubscriptNode/SubscriptNode.vue` | `packages/markstream-react/src/components/SubscriptNode/SubscriptNode.tsx` | 节点组件 |
|
||||
| `src/components/SuperscriptNode/SuperscriptNode.vue` | `packages/markstream-react/src/components/SuperscriptNode/SuperscriptNode.tsx` | 节点组件 |
|
||||
| `src/components/TableNode/TableNode.vue` | `packages/markstream-react/src/components/TableNode/TableNode.tsx` | 节点组件(注意 table 内部 wrapper/`display: contents` 规则由 `markstream-react/index.css` 提供) |
|
||||
| `src/components/TextNode/TextNode.vue` | `packages/markstream-react/src/components/TextNode/TextNode.tsx` | 节点组件 |
|
||||
| `src/components/ThematicBreakNode/ThematicBreakNode.vue` | `packages/markstream-react/src/components/ThematicBreakNode/ThematicBreakNode.tsx` | 节点组件 |
|
||||
| `src/components/Tooltip/Tooltip.vue` | `packages/markstream-react/src/components/Tooltip/Tooltip.tsx` | Tooltip 组件本体;常用调用方式是单例 tooltip API(两端一致思路) |
|
||||
| `src/components/VmrContainerNode/VmrContainerNode.vue` | `packages/markstream-react/src/components/VmrContainerNode/VmrContainerNode.tsx` | 节点组件 |
|
||||
|
||||
## React 侧额外内部目录(无 Vue 对应)
|
||||
|
||||
- `packages/markstream-react/src/components/Math/*`:Math 渲染内部拆分实现(对外仍由 `MathInlineNode` / `MathBlockNode` 对齐 Vue API)。
|
||||
@@ -0,0 +1,166 @@
|
||||
# markstream-react
|
||||
|
||||
React renderer that consumes the structured AST output from `stream-markdown-parser` and renders it with lightweight semantic HTML components. This is the React counter-part to the Vue renderer that powers `markstream-vue`.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm --filter markstream-react dev
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
pnpm --filter markstream-react build
|
||||
pnpm --filter markstream-react build:analyze
|
||||
pnpm --filter markstream-react size:check
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import NodeRenderer from 'markstream-react'
|
||||
import 'markstream-react/index.css'
|
||||
|
||||
export default function Article({ markdown }: { markdown: string }) {
|
||||
return (
|
||||
<NodeRenderer content={markdown} />
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
If your app scales root font size on mobile (`html` / `body`), use `markstream-react/index.px.css` to prevent `rem`-based global scaling side effects.
|
||||
|
||||
You can also pass a pre-parsed `nodes` array if you already have AST data.
|
||||
|
||||
## Streaming best practices
|
||||
|
||||
- For high-frequency SSE / token streaming, prefer parsing outside the component and pass `nodes` instead of reparsing the full `content` string every chunk.
|
||||
- Keep `viewportPriority` enabled unless you explicitly want eager rendering. Mermaid / Monaco / D2 blocks now stay idle while offscreen and resume when they approach the viewport.
|
||||
|
||||
```tsx
|
||||
import NodeRenderer from 'markstream-react'
|
||||
|
||||
export default function StreamView({ nodes, final }: { nodes: any[], final: boolean }) {
|
||||
return (
|
||||
<NodeRenderer
|
||||
nodes={nodes}
|
||||
final={final}
|
||||
viewportPriority
|
||||
deferNodesUntilVisible
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Heavy-node prop forwarding
|
||||
|
||||
`NodeRenderer` can forward renderer-level props directly into Mermaid / D2 / Infographic blocks:
|
||||
|
||||
```tsx
|
||||
<NodeRenderer
|
||||
content={markdown}
|
||||
mermaidProps={{
|
||||
showHeader: false,
|
||||
renderDebounceMs: 180,
|
||||
previewPollDelayMs: 500,
|
||||
}}
|
||||
d2Props={{ progressiveIntervalMs: 500 }}
|
||||
infographicProps={{ showHeader: false }}
|
||||
/>
|
||||
```
|
||||
|
||||
Notes:
|
||||
- These props are forwarded to the built-in Mermaid / D2 / Infographic blocks and to custom `mermaid` / `d2` / `infographic` overrides registered with `setCustomComponents(...)`.
|
||||
- `viewportPriority` applies to those heavy nodes too, so offscreen graphs will not keep doing background work while the text stream is still updating.
|
||||
|
||||
## Language-specific code block overrides
|
||||
|
||||
You can register a custom component under a fenced language key without wrapping the generic `code_block` renderer:
|
||||
|
||||
```tsx
|
||||
import type { NodeComponentProps } from 'markstream-react'
|
||||
import { setCustomComponents } from 'markstream-react'
|
||||
|
||||
function EChartsBlockNode(props: NodeComponentProps<any>) {
|
||||
return <div data-language={String(props.node?.language)}>{String(props.node?.code || '')}</div>
|
||||
}
|
||||
|
||||
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`.
|
||||
- Custom `mermaid` / `d2` / `infographic` overrides keep their specialized top-level props; other custom language keys use the normal custom component contract (`node`, `ctx`, `renderNode`, and friends).
|
||||
|
||||
## Mermaid tuning
|
||||
|
||||
Common `mermaidProps` keys for streaming scenarios:
|
||||
|
||||
- `renderDebounceMs`: delay 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.
|
||||
|
||||
## Bundle size notes
|
||||
|
||||
- Optional peers are not bundled; install only what you use (`stream-monaco`, `stream-markdown`, `mermaid`, `katex`, etc.).
|
||||
- Infrequent language icons are split into an async chunk and loaded on demand.
|
||||
- To avoid first-hit fallback icons, preload once when the app is idle:
|
||||
|
||||
```tsx
|
||||
import { preloadExtendedLanguageIcons } from 'markstream-react'
|
||||
|
||||
if (typeof window !== 'undefined')
|
||||
void preloadExtendedLanguageIcons()
|
||||
```
|
||||
|
||||
## Tailwind
|
||||
|
||||
- Non-Tailwind projects: keep importing `markstream-react/index.css` (includes precompiled utilities for the renderer).
|
||||
- Tailwind projects (avoid duplicate utilities): import `markstream-react/index.tailwind.css` and add `require('markstream-react/tailwind')` to your `tailwind.config.js` `content`.
|
||||
|
||||
## Custom components (e.g. `<thinking>`)
|
||||
|
||||
Custom tag-like blocks are exposed as nodes with `type: 'thinking'` (the tag name, no angle brackets) when you register the tag in `customHtmlTags` or register a custom component mapping for it.
|
||||
|
||||
```tsx
|
||||
import type { NodeComponentProps } from 'markstream-react'
|
||||
import NodeRenderer, { setCustomComponents } from 'markstream-react'
|
||||
|
||||
function ThinkingNode(props: NodeComponentProps<{ type: 'thinking', content: string }>) {
|
||||
const { node, ctx } = props
|
||||
return (
|
||||
<div className="thinking-node">
|
||||
<div className="thinking-title">Thinking</div>
|
||||
<NodeRenderer
|
||||
content={node.content}
|
||||
customId={ctx?.customId}
|
||||
isDark={ctx?.isDark}
|
||||
typewriter={false}
|
||||
batchRendering={false}
|
||||
deferNodesUntilVisible={false}
|
||||
viewportPriority={false}
|
||||
maxLiveNodes={0}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
setCustomComponents('chat', { thinking: ThinkingNode })
|
||||
```
|
||||
|
||||
## Type exports
|
||||
|
||||
`markstream-react` now exposes the core public types directly from the package root, including:
|
||||
|
||||
- `NodeRendererProps`
|
||||
- `NodeComponentProps`
|
||||
- `RenderContext`
|
||||
- `RenderNodeFn`
|
||||
- `CustomComponentMap`
|
||||
- `CodeBlockMonacoOptions`
|
||||
@@ -0,0 +1,144 @@
|
||||
{
|
||||
"name": "markstream-react",
|
||||
"type": "module",
|
||||
"version": "0.0.45",
|
||||
"description": "React Markdown renderer optimized for large documents with progressive Mermaid rendering, streaming diff code blocks, and fast real-time preview. Built on stream-markdown AST for consistent rendering across frameworks. 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-react"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/Simon-He95/markstream-vue/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"react",
|
||||
"reactjs",
|
||||
"markdown",
|
||||
"markdown-to-html",
|
||||
"markdown-renderer",
|
||||
"react-markdown",
|
||||
"react-markdown-renderer",
|
||||
"react-markdown-component",
|
||||
"markdown-preview",
|
||||
"markdown-viewer",
|
||||
"react-component",
|
||||
"nextjs",
|
||||
"next.js",
|
||||
"remix",
|
||||
"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-react",
|
||||
"markstream",
|
||||
"documentation",
|
||||
"docs",
|
||||
"content-management",
|
||||
"cms",
|
||||
"markdown-parser",
|
||||
"md-to-html",
|
||||
"react-markdown-component"
|
||||
],
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./next": {
|
||||
"types": "./dist/next.d.ts",
|
||||
"import": "./dist/next.js"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./dist/server.d.ts",
|
||||
"import": "./dist/server.js"
|
||||
},
|
||||
"./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.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build --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 && node ./scripts/ensure-next-client-directive.mjs",
|
||||
"build:analyze": "ANALYZE=true pnpm build",
|
||||
"build:dts": "rollup -c ./scripts/rollup.dts.config.mjs && node ./scripts/clean-dts.cjs",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"size:check": "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",
|
||||
"katex": ">=0.16.22",
|
||||
"mermaid": ">=11",
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18",
|
||||
"stream-markdown": ">=0.0.15",
|
||||
"stream-monaco": ">=0.0.40"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@antv/infographic": {
|
||||
"optional": true
|
||||
},
|
||||
"@terrastruct/d2": {
|
||||
"optional": true
|
||||
},
|
||||
"katex": {
|
||||
"optional": true
|
||||
},
|
||||
"mermaid": {
|
||||
"optional": true
|
||||
},
|
||||
"stream-markdown": {
|
||||
"optional": true
|
||||
},
|
||||
"stream-monaco": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.7.6",
|
||||
"clsx": "^2.1.1",
|
||||
"stream-markdown-parser": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.28",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@vitejs/plugin-react-swc": "^3.11.0",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"rollup": "^3.30.0",
|
||||
"rollup-plugin-dts": "^5.3.1",
|
||||
"rollup-plugin-visualizer": "^6.0.11",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-dts": "^4.5.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: { config: './tailwind.config.js' },
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const typesDir = path.resolve(__dirname, '..', 'dist', 'types')
|
||||
const distDir = path.resolve(__dirname, '..', 'dist')
|
||||
|
||||
function rewriteTypesEntry(content) {
|
||||
return content
|
||||
.replace(/from\s+(['"])\.\//g, 'from $1./types/')
|
||||
.replace(/import\((['"])\.\//g, 'import($1./types/')
|
||||
}
|
||||
|
||||
function copyEntry(entryName) {
|
||||
const sourcePath = path.join(typesDir, `${entryName}.d.ts`)
|
||||
const targetPath = path.join(distDir, `${entryName}.d.ts`)
|
||||
if (!fs.existsSync(sourcePath))
|
||||
return
|
||||
|
||||
const content = rewriteTypesEntry(fs.readFileSync(sourcePath, 'utf8'))
|
||||
fs.writeFileSync(targetPath, content)
|
||||
console.log('Rewrote', targetPath, 'from', sourcePath)
|
||||
}
|
||||
|
||||
for (const entryName of ['index']) {
|
||||
copyEntry(entryName)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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')
|
||||
const primaryCss = path.join(toDir, 'index.css')
|
||||
const fallbackPrimaryCss = path.join(toDir, 'markstream-react.css')
|
||||
|
||||
function stripTailwindDirectives(css) {
|
||||
return css
|
||||
.replace(/@tailwind\s+base\s*;?\s*/g, '')
|
||||
.replace(/@tailwind\s+components\s*;?\s*/g, '')
|
||||
.replace(/@tailwind\s+utilities\s*;?\s*/g, '')
|
||||
.trimStart()
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await fs.mkdir(toDir, { recursive: true })
|
||||
|
||||
try {
|
||||
await fs.access(primaryCss)
|
||||
}
|
||||
catch {
|
||||
try {
|
||||
const css = await fs.readFile(fallbackPrimaryCss, 'utf8')
|
||||
await fs.writeFile(primaryCss, css)
|
||||
}
|
||||
catch (err) {
|
||||
const e = err
|
||||
throw new Error(`[copy-tailwind-css] Missing ${primaryCss} and ${fallbackPrimaryCss}. Did the main npm build run? (${e?.message || e})`)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const css = await fs.readFile(fromCss, 'utf8')
|
||||
await fs.writeFile(toCss, stripTailwindDirectives(css))
|
||||
}
|
||||
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,11 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const nextEntryPath = path.resolve(__dirname, '../dist/next.js')
|
||||
const source = readFileSync(nextEntryPath, 'utf8')
|
||||
|
||||
if (!source.startsWith('\'use client\';')) {
|
||||
writeFileSync(nextEntryPath, `'use client';\n${source}`)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import dts from 'rollup-plugin-dts'
|
||||
|
||||
const entryNames = ['index', 'next', 'server']
|
||||
|
||||
function resolveInputPath(entryName) {
|
||||
const preferred = [
|
||||
`./dist/types/${entryName}.d.ts`,
|
||||
entryName === 'index' ? './dist/types/exports.d.ts' : null,
|
||||
`./dist/${entryName}.d.ts`,
|
||||
].filter(Boolean)
|
||||
|
||||
return preferred.find(p => fs.existsSync(path.resolve(p))) ?? null
|
||||
}
|
||||
|
||||
const configs = entryNames.map((entryName) => {
|
||||
const inputPath = resolveInputPath(entryName)
|
||||
if (!inputPath) {
|
||||
throw new Error(
|
||||
`No declaration entry found for "${entryName}". Run \`pnpm run build\` before running \`build:dts\`.`,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
input: inputPath,
|
||||
plugins: [
|
||||
dts({
|
||||
respectExternal: false,
|
||||
}),
|
||||
],
|
||||
external: [
|
||||
/^node:.*$/,
|
||||
/^react(?:\/.*)?$/,
|
||||
/^react-dom(?:\/.*)?$/,
|
||||
/^(?:katex|mermaid|stream-monaco|stream-markdown)(?:\/.*)?$/,
|
||||
/^stream-markdown-parser(?:\/.*)?$/,
|
||||
/^@antv\/infographic(?:\/.*)?$/,
|
||||
/^@floating-ui\/dom(?:\/.*)?$/,
|
||||
],
|
||||
output: [
|
||||
{
|
||||
file: `dist/${entryName}.d.ts`,
|
||||
format: 'es',
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
export default configs
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { ParsedNode } from 'stream-markdown-parser'
|
||||
import type { NodeComponentProps } from '../../types/node-component'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { renderNodeChildren } from '../../renderers/renderChildren'
|
||||
|
||||
const ICON_MAP: Record<string, string> = {
|
||||
note: 'ℹ️',
|
||||
info: 'ℹ️',
|
||||
tip: '💡',
|
||||
warning: '⚠️',
|
||||
danger: '❗',
|
||||
error: '⛔',
|
||||
caution: '⚠️',
|
||||
}
|
||||
|
||||
export function AdmonitionNode(props: NodeComponentProps<{
|
||||
type: 'admonition'
|
||||
kind?: string
|
||||
title?: string
|
||||
children?: ParsedNode[]
|
||||
collapsible?: boolean
|
||||
open?: boolean
|
||||
}>) {
|
||||
const { node, ctx, renderNode, indexKey, isDark } = props
|
||||
const kind = String(node.kind || 'note')
|
||||
const displayTitle = useMemo(() => {
|
||||
if (node.title && String(node.title).trim().length)
|
||||
return String(node.title)
|
||||
return kind.charAt(0).toUpperCase() + kind.slice(1)
|
||||
}, [kind, node.title])
|
||||
|
||||
const [collapsed, setCollapsed] = useState(() => node.collapsible ? !(node.open ?? true) : false)
|
||||
const headerId = useMemo(() => `admonition-${Math.random().toString(36).slice(2, 9)}`, [])
|
||||
|
||||
return (
|
||||
<div className={`admonition admonition-${kind}${isDark ? ' is-dark' : ''}`} data-index-key={indexKey}>
|
||||
<div id={headerId} className="admonition-header">
|
||||
{ICON_MAP[kind] && <span className="admonition-icon">{ICON_MAP[kind]}</span>}
|
||||
<span className="admonition-title">{displayTitle}</span>
|
||||
{node.collapsible && (
|
||||
<button
|
||||
type="button"
|
||||
className="admonition-toggle"
|
||||
aria-expanded={!collapsed}
|
||||
aria-controls={`${headerId}-content`}
|
||||
title={collapsed ? 'Expand' : 'Collapse'}
|
||||
onClick={() => setCollapsed(v => !v)}
|
||||
>
|
||||
<span>{collapsed ? '▶' : '▼'}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div id={`${headerId}-content`} className="admonition-content" aria-labelledby={headerId}>
|
||||
{ctx && renderNode ? renderNodeChildren(node.children, ctx, String(indexKey ?? headerId), renderNode) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdmonitionNode
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ParsedNode } from 'stream-markdown-parser'
|
||||
import type { NodeComponentProps } from '../../types/node-component'
|
||||
import React from 'react'
|
||||
import { NodeRenderer } from '../NodeRenderer'
|
||||
|
||||
export function BlockquoteNode(props: NodeComponentProps<{ type: 'blockquote', children?: ParsedNode[] }>) {
|
||||
const { node, ctx, renderNode, indexKey, children } = props
|
||||
return (
|
||||
<blockquote className="blockquote-node" dir="auto" cite={(node as any).cite}>
|
||||
{children ?? (ctx && renderNode
|
||||
? (
|
||||
<NodeRenderer
|
||||
nodes={node.children || []}
|
||||
indexKey={`blockquote-${String(indexKey ?? 'blockquote')}`}
|
||||
customId={ctx.customId}
|
||||
isDark={ctx.isDark}
|
||||
typewriter={ctx.typewriter}
|
||||
viewportPriority
|
||||
codeBlockStream={ctx.codeBlockStream}
|
||||
renderCodeBlocksAsPre={ctx.renderCodeBlocksAsPre}
|
||||
codeBlockProps={ctx.codeBlockProps}
|
||||
themes={ctx.codeBlockThemes?.themes}
|
||||
codeBlockDarkTheme={ctx.codeBlockThemes?.darkTheme}
|
||||
codeBlockLightTheme={ctx.codeBlockThemes?.lightTheme}
|
||||
codeBlockMonacoOptions={ctx.codeBlockThemes?.monacoOptions}
|
||||
codeBlockMinWidth={ctx.codeBlockThemes?.minWidth}
|
||||
codeBlockMaxWidth={ctx.codeBlockThemes?.maxWidth}
|
||||
showTooltips={ctx.showTooltips}
|
||||
onCopy={ctx.events.onCopy}
|
||||
onHandleArtifactClick={ctx.events.onHandleArtifactClick}
|
||||
/>
|
||||
)
|
||||
: null)}
|
||||
</blockquote>
|
||||
)
|
||||
}
|
||||
|
||||
export default BlockquoteNode
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { NodeComponentProps } from '../../types/node-component'
|
||||
import React from 'react'
|
||||
|
||||
export function CheckboxNode(props: NodeComponentProps<{ type: 'checkbox' | 'checkbox_input', checked?: boolean }>) {
|
||||
const { node } = props
|
||||
return (
|
||||
<span className="checkbox-node">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(node.checked)}
|
||||
disabled
|
||||
className="checkbox-input"
|
||||
readOnly
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default CheckboxNode
|
||||
@@ -0,0 +1,90 @@
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useSafeI18n } from '../../i18n/useSafeI18n'
|
||||
|
||||
export interface HtmlPreviewFrameProps {
|
||||
code: string
|
||||
isDark?: boolean
|
||||
onClose?: () => void
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function HtmlPreviewFrame(props: HtmlPreviewFrameProps) {
|
||||
const { t } = useSafeI18n()
|
||||
const srcdoc = useMemo(() => {
|
||||
const base = props.code || ''
|
||||
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>`
|
||||
}, [props.code, props.isDark])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' || event.key === 'Esc')
|
||||
props.onClose?.()
|
||||
}
|
||||
if (typeof window !== 'undefined')
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
return () => {
|
||||
if (typeof window !== 'undefined')
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
}
|
||||
}, [props])
|
||||
|
||||
if (typeof document === 'undefined')
|
||||
return null
|
||||
|
||||
return createPortal(
|
||||
<div className={`html-preview-frame__backdrop${props.isDark ? ' html-preview-frame__backdrop--dark' : ''}`} onClick={() => props.onClose?.()}>
|
||||
<div className={`html-preview-frame${props.isDark ? ' html-preview-frame--dark' : ''}`} onClick={e => e.stopPropagation()}>
|
||||
<div className="html-preview-frame__header">
|
||||
<div className="html-preview-frame__title">
|
||||
<span className="html-preview-frame__dot" />
|
||||
<span className="html-preview-frame__label">{props.title || `HTML ${t('common.preview')}`}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`html-preview-frame__close${props.isDark ? ' html-preview-frame__close--dark' : ''}`}
|
||||
onClick={() => props.onClose?.()}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<iframe
|
||||
className="html-preview-frame__iframe"
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
src="about:blank"
|
||||
srcDoc={srcdoc}
|
||||
title={props.title || 'Preview'}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
export default HtmlPreviewFrame
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { PreCodeNodeProps } from '../../types/component-props'
|
||||
import React, { useMemo } from 'react'
|
||||
|
||||
function normalizeLanguage(raw: unknown) {
|
||||
const head = String(String(raw ?? '').split(/\s+/g)[0] ?? '').toLowerCase()
|
||||
const safe = head.replace(/[^\w-]/g, '')
|
||||
return safe || 'plaintext'
|
||||
}
|
||||
|
||||
export function PreCodeNode({ node }: PreCodeNodeProps) {
|
||||
const normalizedLanguage = useMemo(() => normalizeLanguage((node as any)?.language), [node])
|
||||
const languageClass = `language-${normalizedLanguage}`
|
||||
const ariaLabel = normalizedLanguage ? `Code block: ${normalizedLanguage}` : 'Code block'
|
||||
|
||||
return (
|
||||
<pre
|
||||
className={languageClass}
|
||||
aria-busy={(node as any)?.loading === true}
|
||||
aria-label={ariaLabel}
|
||||
data-language={normalizedLanguage}
|
||||
tabIndex={0}
|
||||
>
|
||||
<code translate="no">{String((node as any)?.code ?? '')}</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { preload } from '../NodeRenderer/preloadMonaco'
|
||||
|
||||
let mod: any = null
|
||||
let importAttempted = false
|
||||
|
||||
export async function getUseMonaco() {
|
||||
if (mod)
|
||||
return mod
|
||||
if (importAttempted)
|
||||
return null
|
||||
try {
|
||||
mod = await import('stream-monaco')
|
||||
await preload(mod)
|
||||
return mod
|
||||
}
|
||||
catch {
|
||||
importAttempted = true
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { resetLastAppliedThemeKey, scheduleMonacoThemeUpdate } from './monacoThemeScheduler'
|
||||
|
||||
type MonacoTheme = any
|
||||
type SetThemeFn = (theme: MonacoTheme, force?: boolean) => Promise<void> | void
|
||||
|
||||
let desiredTheme: MonacoTheme | null = null
|
||||
let desiredKey: string | null = null
|
||||
let setThemeImpl: SetThemeFn | null = null
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
const themeKeyCache = new WeakMap<object, string>()
|
||||
let themeKeySeq = 0
|
||||
|
||||
function themeKey(theme: MonacoTheme): 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)
|
||||
}
|
||||
|
||||
function notify() {
|
||||
for (const listener of Array.from(listeners)) {
|
||||
try {
|
||||
listener()
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeMonacoThemeApplied(listener: () => void) {
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
export function registerMonacoThemeSetter(setTheme: SetThemeFn | null | undefined) {
|
||||
if (typeof setTheme !== 'function')
|
||||
return
|
||||
const isNewSetter = setThemeImpl !== setTheme
|
||||
setThemeImpl = setTheme
|
||||
if (isNewSetter) {
|
||||
resetLastAppliedThemeKey()
|
||||
}
|
||||
console.log('[AQBot Theme Debug] registry: new setter registered, desiredKey:', desiredKey, 'isNew:', isNewSetter)
|
||||
if (desiredTheme != null && desiredKey != null) {
|
||||
void scheduleMonacoThemeUpdate(desiredTheme, setThemeImpl)
|
||||
.then(() => notify())
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
export function setDesiredMonacoTheme(theme: MonacoTheme | null | undefined) {
|
||||
if (theme == null)
|
||||
return
|
||||
const key = themeKey(theme)
|
||||
if (!key)
|
||||
return
|
||||
if (desiredKey === key) {
|
||||
console.log('[AQBot Theme Debug] registry: desiredKey === key, skipping:', key)
|
||||
return
|
||||
}
|
||||
desiredTheme = theme
|
||||
desiredKey = key
|
||||
console.log('[AQBot Theme Debug] registry: setDesiredMonacoTheme:', key, 'hasImpl:', !!setThemeImpl)
|
||||
if (!setThemeImpl)
|
||||
return
|
||||
void scheduleMonacoThemeUpdate(desiredTheme, setThemeImpl)
|
||||
.then(() => notify())
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
export function getDesiredMonacoTheme(): MonacoTheme | null {
|
||||
return desiredTheme
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
type MonacoTheme = any
|
||||
type SetThemeFn = (theme: MonacoTheme, force?: boolean) => Promise<void> | void
|
||||
|
||||
let setThemeImpl: SetThemeFn | null = null
|
||||
let applying = false
|
||||
let inFlight: Promise<void> | null = null
|
||||
let inFlightKey: string | null = null
|
||||
let pendingTheme: MonacoTheme | null = null
|
||||
let pendingKey: string | null = null
|
||||
let lastAppliedKey: string | null = null
|
||||
|
||||
const themeKeyCache = new WeakMap<object, string>()
|
||||
let themeKeySeq = 0
|
||||
|
||||
function themeKey(theme: MonacoTheme): 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 resetLastAppliedThemeKey() {
|
||||
lastAppliedKey = null
|
||||
}
|
||||
|
||||
export function scheduleMonacoThemeUpdate(theme: MonacoTheme, setTheme: SetThemeFn): Promise<void> {
|
||||
const key = themeKey(theme)
|
||||
if (!key)
|
||||
return Promise.resolve()
|
||||
|
||||
if (setThemeImpl !== setTheme)
|
||||
setThemeImpl = setTheme
|
||||
|
||||
if (!applying && lastAppliedKey === key) {
|
||||
console.log('[AQBot Theme Debug] scheduler: skipping, lastAppliedKey === key:', key)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
if (inFlight && (pendingKey === key || inFlightKey === key))
|
||||
return inFlight
|
||||
|
||||
pendingTheme = theme
|
||||
pendingKey = key
|
||||
|
||||
if (inFlight)
|
||||
return inFlight
|
||||
|
||||
applying = true
|
||||
inFlight = (async () => {
|
||||
while (pendingTheme != null && pendingKey != null) {
|
||||
const nextTheme = pendingTheme
|
||||
const nextKey = pendingKey
|
||||
pendingTheme = null
|
||||
pendingKey = null
|
||||
if (lastAppliedKey === nextKey)
|
||||
continue
|
||||
const impl = setThemeImpl
|
||||
if (!impl)
|
||||
break
|
||||
try {
|
||||
inFlightKey = nextKey
|
||||
console.log('[AQBot Theme Debug] scheduler: applying theme:', nextKey)
|
||||
await Promise.resolve(impl(nextTheme))
|
||||
lastAppliedKey = nextKey
|
||||
console.log('[AQBot Theme Debug] scheduler: applied successfully:', nextKey)
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[AQBot Theme Debug] scheduler: apply failed:', nextKey, err)
|
||||
}
|
||||
}
|
||||
})().finally(() => {
|
||||
applying = false
|
||||
inFlight = null
|
||||
inFlightKey = null
|
||||
})
|
||||
|
||||
return inFlight
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
import type { VisibilityHandle } from '../../context/viewportPriority'
|
||||
import type { D2BlockNodeProps } from '../../types/component-props'
|
||||
import clsx from 'clsx'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useViewportPriority } from '../../context/viewportPriority'
|
||||
import { useSafeI18n } from '../../i18n/useSafeI18n'
|
||||
import { hideTooltip, showTooltipForAnchor } from '../../tooltip/singletonTooltip'
|
||||
import { getD2 } from './d2'
|
||||
|
||||
const DEFAULTS = {
|
||||
maxHeight: '500px',
|
||||
loading: true,
|
||||
progressiveRender: true,
|
||||
progressiveIntervalMs: 700,
|
||||
showHeader: true,
|
||||
showModeToggle: true,
|
||||
showCopyButton: true,
|
||||
showExportButton: true,
|
||||
showCollapseButton: true,
|
||||
}
|
||||
|
||||
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',
|
||||
}
|
||||
|
||||
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 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 ''
|
||||
}
|
||||
|
||||
function shouldSkipEventTarget(target: EventTarget | null) {
|
||||
const btn = target as HTMLButtonElement | null
|
||||
return !btn || btn.disabled
|
||||
}
|
||||
|
||||
export function D2BlockNode(rawProps: D2BlockNodeProps) {
|
||||
const props = { ...DEFAULTS, ...rawProps }
|
||||
const { t } = useSafeI18n()
|
||||
const [copying, setCopying] = useState(false)
|
||||
const [isCollapsed, setIsCollapsed] = useState(false)
|
||||
const [showSource, setShowSource] = useState(false)
|
||||
const [d2Available, setD2Available] = useState(false)
|
||||
const [renderError, setRenderError] = useState<string | null>(null)
|
||||
const [svgMarkup, setSvgMarkup] = useState('')
|
||||
const [bodyMinHeight, setBodyMinHeight] = useState<number | null>(null)
|
||||
const [isRendering, setIsRendering] = useState(false)
|
||||
const [viewportReady, setViewportReady] = useState(() => typeof window === 'undefined')
|
||||
|
||||
const renderTokenRef = useRef(0)
|
||||
const d2InstanceRef = useRef<any | null>(null)
|
||||
const scheduledRef = useRef(false)
|
||||
const unmountedRef = useRef(false)
|
||||
const lastRenderAtRef = useRef(0)
|
||||
const throttleTimerRef = useRef<number | null>(null)
|
||||
const pendingRenderRef = useRef(false)
|
||||
const viewportTargetRef = useRef<HTMLDivElement | null>(null)
|
||||
const viewportHandleRef = useRef<VisibilityHandle | null>(null)
|
||||
const bodyRef = useRef<HTMLDivElement | null>(null)
|
||||
const bodyObserverRef = useRef<ResizeObserver | null>(null)
|
||||
const isRenderingRef = useRef(false)
|
||||
const renderDiagramRef = useRef<() => void>(() => {})
|
||||
const registerViewport = useViewportPriority()
|
||||
|
||||
const baseCode = props.node?.code ?? ''
|
||||
const showSourceFallback = showSource || !d2Available || !svgMarkup
|
||||
const hasPreview = !!svgMarkup
|
||||
|
||||
const renderStyle = useMemo(() => {
|
||||
if (!props.maxHeight)
|
||||
return undefined
|
||||
const max = typeof props.maxHeight === 'number' ? `${props.maxHeight}px` : String(props.maxHeight)
|
||||
return { maxHeight: max }
|
||||
}, [props.maxHeight])
|
||||
|
||||
const bodyStyle = useMemo(() => {
|
||||
if (!showSourceFallback || !bodyMinHeight)
|
||||
return undefined
|
||||
return { minHeight: `${bodyMinHeight}px` }
|
||||
}, [bodyMinHeight, showSourceFallback])
|
||||
|
||||
useEffect(() => {
|
||||
isRenderingRef.current = isRendering
|
||||
}, [isRendering])
|
||||
|
||||
useEffect(() => {
|
||||
const el = viewportTargetRef.current
|
||||
if (!el)
|
||||
return
|
||||
const handle = registerViewport(el, { rootMargin: '160px' })
|
||||
viewportHandleRef.current = handle
|
||||
if (handle.isVisible())
|
||||
setViewportReady(true)
|
||||
handle.whenVisible.then(() => setViewportReady(true))
|
||||
return () => {
|
||||
handle.destroy()
|
||||
viewportHandleRef.current = null
|
||||
}
|
||||
}, [registerViewport])
|
||||
|
||||
const updateBodyMinHeight = useCallback(() => {
|
||||
const el = bodyRef.current
|
||||
if (!el)
|
||||
return
|
||||
const height = el.getBoundingClientRect().height
|
||||
if (height > 0) {
|
||||
setBodyMinHeight(prev => (prev !== height ? height : prev))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const scheduleRender = useCallback((force = false) => {
|
||||
if (scheduledRef.current || typeof window === 'undefined')
|
||||
return
|
||||
if (unmountedRef.current)
|
||||
return
|
||||
if (!viewportReady)
|
||||
return
|
||||
if (isRenderingRef.current) {
|
||||
pendingRenderRef.current = true
|
||||
return
|
||||
}
|
||||
const interval = Math.max(120, Number(props.progressiveIntervalMs) || 0)
|
||||
const now = Date.now()
|
||||
const elapsed = now - lastRenderAtRef.current
|
||||
if (!force && elapsed < interval) {
|
||||
pendingRenderRef.current = true
|
||||
if (throttleTimerRef.current == null) {
|
||||
throttleTimerRef.current = window.setTimeout(() => {
|
||||
throttleTimerRef.current = null
|
||||
if (pendingRenderRef.current) {
|
||||
pendingRenderRef.current = false
|
||||
scheduleRender(true)
|
||||
}
|
||||
}, Math.max(0, interval - elapsed))
|
||||
}
|
||||
return
|
||||
}
|
||||
scheduledRef.current = true
|
||||
const runner = () => {
|
||||
scheduledRef.current = false
|
||||
lastRenderAtRef.current = Date.now()
|
||||
renderDiagramRef.current()
|
||||
}
|
||||
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function')
|
||||
window.requestAnimationFrame(runner)
|
||||
else
|
||||
setTimeout(runner, 0)
|
||||
}, [props.progressiveIntervalMs, viewportReady])
|
||||
|
||||
const ensureD2Instance = useCallback(async () => {
|
||||
if (d2InstanceRef.current)
|
||||
return d2InstanceRef.current
|
||||
const D2Ctor = await getD2()
|
||||
if (!D2Ctor)
|
||||
return null
|
||||
if (typeof D2Ctor === 'function') {
|
||||
const inst = new D2Ctor()
|
||||
if (inst && typeof inst.compile === 'function')
|
||||
d2InstanceRef.current = inst
|
||||
else if (typeof (D2Ctor as any).compile === 'function')
|
||||
d2InstanceRef.current = D2Ctor
|
||||
return d2InstanceRef.current
|
||||
}
|
||||
if (D2Ctor?.D2 && typeof D2Ctor.D2 === 'function') {
|
||||
d2InstanceRef.current = new D2Ctor.D2()
|
||||
return d2InstanceRef.current
|
||||
}
|
||||
if (typeof D2Ctor.compile === 'function')
|
||||
d2InstanceRef.current = D2Ctor
|
||||
return d2InstanceRef.current
|
||||
}, [])
|
||||
|
||||
const renderDiagram = useCallback(async () => {
|
||||
if (typeof window === 'undefined' || unmountedRef.current)
|
||||
return
|
||||
if (!viewportReady)
|
||||
return
|
||||
if (props.loading && !props.progressiveRender)
|
||||
return
|
||||
if (!baseCode) {
|
||||
setSvgMarkup('')
|
||||
setRenderError(null)
|
||||
return
|
||||
}
|
||||
|
||||
const token = ++renderTokenRef.current
|
||||
setIsRendering(true)
|
||||
setRenderError(null)
|
||||
|
||||
try {
|
||||
const instance = await ensureD2Instance()
|
||||
if (!instance) {
|
||||
setD2Available(false)
|
||||
setShowSource(true)
|
||||
setSvgMarkup('')
|
||||
setRenderError('D2 is not available.')
|
||||
return
|
||||
}
|
||||
if (typeof instance.compile !== 'function' || typeof instance.render !== 'function')
|
||||
throw new Error('D2 instance is missing compile/render methods.')
|
||||
|
||||
setD2Available(true)
|
||||
|
||||
const compileResult = await instance.compile(baseCode)
|
||||
if (token !== renderTokenRef.current)
|
||||
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
|
||||
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 !== renderTokenRef.current)
|
||||
return
|
||||
|
||||
const svg = extractSvg(renderResult)
|
||||
if (!svg)
|
||||
throw new Error('D2 render returned empty output.')
|
||||
const safe = toSafeSvgMarkup(svg)
|
||||
setSvgMarkup(safe || '')
|
||||
if (props.loading)
|
||||
setShowSource(false)
|
||||
setRenderError(null)
|
||||
}
|
||||
catch (err: any) {
|
||||
if (token !== renderTokenRef.current)
|
||||
return
|
||||
const message = err?.message ? String(err.message) : 'D2 render failed.'
|
||||
if (!props.loading)
|
||||
setRenderError(message)
|
||||
if (message.includes('@terrastruct/d2')) {
|
||||
setD2Available(false)
|
||||
setShowSource(true)
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if (token === renderTokenRef.current) {
|
||||
setIsRendering(false)
|
||||
if (pendingRenderRef.current) {
|
||||
pendingRenderRef.current = false
|
||||
scheduleRender()
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [baseCode, ensureD2Instance, props.darkThemeId, props.isDark, props.loading, props.progressiveRender, props.themeId, scheduleRender, viewportReady])
|
||||
|
||||
useEffect(() => {
|
||||
renderDiagramRef.current = renderDiagram
|
||||
}, [renderDiagram])
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
if (navigator?.clipboard?.writeText)
|
||||
await navigator.clipboard.writeText(baseCode)
|
||||
setCopying(true)
|
||||
setTimeout(() => setCopying(false), 1000)
|
||||
}
|
||||
catch {}
|
||||
}, [baseCode])
|
||||
|
||||
const handleExport = useCallback(() => {
|
||||
if (!svgMarkup)
|
||||
return
|
||||
try {
|
||||
const blob = new Blob([svgMarkup], { type: 'image/svg+xml;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
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)
|
||||
}
|
||||
}, [svgMarkup])
|
||||
|
||||
const onBtnHover = useCallback((e: React.MouseEvent, text: string) => {
|
||||
if (shouldSkipEventTarget(e.currentTarget))
|
||||
return
|
||||
showTooltipForAnchor(e.currentTarget as HTMLElement, text, 'top', false, { x: e.clientX, y: e.clientY }, props.isDark)
|
||||
}, [props.isDark])
|
||||
|
||||
const onBtnLeave = useCallback(() => {
|
||||
hideTooltip()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
scheduleRender()
|
||||
}, [baseCode, props.loading, props.isDark, scheduleRender])
|
||||
|
||||
useEffect(() => {
|
||||
if (props.loading)
|
||||
return
|
||||
scheduleRender(true)
|
||||
}, [props.loading, scheduleRender])
|
||||
|
||||
useEffect(() => {
|
||||
const raf = requestAnimationFrame(() => updateBodyMinHeight())
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [showSourceFallback, svgMarkup, baseCode, updateBodyMinHeight])
|
||||
|
||||
useEffect(() => {
|
||||
unmountedRef.current = false
|
||||
scheduleRender()
|
||||
const raf = requestAnimationFrame(() => updateBodyMinHeight())
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const observer = new ResizeObserver(() => {
|
||||
updateBodyMinHeight()
|
||||
})
|
||||
bodyObserverRef.current = observer
|
||||
if (bodyRef.current)
|
||||
observer.observe(bodyRef.current)
|
||||
}
|
||||
return () => {
|
||||
unmountedRef.current = true
|
||||
if (throttleTimerRef.current != null) {
|
||||
clearTimeout(throttleTimerRef.current)
|
||||
throttleTimerRef.current = null
|
||||
}
|
||||
bodyObserverRef.current?.disconnect()
|
||||
bodyObserverRef.current = null
|
||||
cancelAnimationFrame(raf)
|
||||
}
|
||||
}, [scheduleRender, updateBodyMinHeight])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={viewportTargetRef}
|
||||
className={clsx(
|
||||
'd2-block my-4 rounded-lg border overflow-hidden shadow-sm',
|
||||
props.isDark ? 'border-gray-700/30 bg-gray-900 text-gray-100' : 'border-gray-200 bg-white text-gray-900',
|
||||
)}
|
||||
>
|
||||
{props.showHeader && (
|
||||
<div
|
||||
className="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)', backgroundColor: 'var(--vscode-editor-background)' }}
|
||||
>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span className="text-sm font-medium font-mono">D2</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
{props.showModeToggle && (
|
||||
<div className={clsx('flex items-center gap-x-1 rounded-md p-0.5', props.isDark ? 'bg-gray-700' : 'bg-gray-100')}>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx('mode-btn px-2 py-1 text-xs rounded', !showSource && 'is-active')}
|
||||
onClick={() => setShowSource(false)}
|
||||
onMouseEnter={e => onBtnHover(e, t('common.preview') || 'Preview')}
|
||||
onFocus={e => onBtnHover(e as any, t('common.preview') || 'Preview')}
|
||||
onMouseLeave={onBtnLeave}
|
||||
onBlur={onBtnLeave}
|
||||
>
|
||||
{t('common.preview') || 'Preview'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx('mode-btn px-2 py-1 text-xs rounded', showSource && 'is-active')}
|
||||
onClick={() => setShowSource(true)}
|
||||
onMouseEnter={e => onBtnHover(e, t('common.source') || 'Source')}
|
||||
onFocus={e => onBtnHover(e as any, t('common.source') || 'Source')}
|
||||
onMouseLeave={onBtnLeave}
|
||||
onBlur={onBtnLeave}
|
||||
>
|
||||
{t('common.source') || 'Source'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.showCopyButton && (
|
||||
<button
|
||||
type="button"
|
||||
className="d2-action-btn p-2 text-xs rounded-md transition-colors hover:bg-[var(--vscode-editor-selectionBackground)]"
|
||||
aria-label={copying ? (t('common.copied') || 'Copied') : (t('common.copy') || 'Copy')}
|
||||
onClick={handleCopy}
|
||||
onMouseEnter={e => onBtnHover(e, copying ? (t('common.copied') || 'Copied') : (t('common.copy') || 'Copy'))}
|
||||
onFocus={e => onBtnHover(e as any, copying ? (t('common.copied') || 'Copied') : (t('common.copy') || 'Copy'))}
|
||||
onMouseLeave={onBtnLeave}
|
||||
onBlur={onBtnLeave}
|
||||
>
|
||||
{!copying
|
||||
? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" className="w-3 h-3">
|
||||
<g fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="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 xmlns="http://www.w3.org/2000/svg" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" className="w-3 h-3"><path fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M20 6L9 17l-5-5" /></svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{props.showExportButton && svgMarkup && (
|
||||
<button
|
||||
type="button"
|
||||
className="d2-action-btn p-2 text-xs rounded-md transition-colors hover:bg-[var(--vscode-editor-selectionBackground)]"
|
||||
aria-label={t('common.export') || 'Export'}
|
||||
onClick={handleExport}
|
||||
onMouseEnter={e => onBtnHover(e, t('common.export') || 'Export')}
|
||||
onFocus={e => onBtnHover(e as any, t('common.export') || 'Export')}
|
||||
onMouseLeave={onBtnLeave}
|
||||
onBlur={onBtnLeave}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" className="w-3 h-3"><path fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="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>
|
||||
)}
|
||||
|
||||
{props.showCollapseButton && (
|
||||
<button
|
||||
type="button"
|
||||
className="d2-action-btn p-2 text-xs rounded-md transition-colors hover:bg-[var(--vscode-editor-selectionBackground)]"
|
||||
aria-pressed={isCollapsed}
|
||||
onClick={() => setIsCollapsed(prev => !prev)}
|
||||
onMouseEnter={e => onBtnHover(e, isCollapsed ? (t('common.expand') || 'Expand') : (t('common.collapse') || 'Collapse'))}
|
||||
onFocus={e => onBtnHover(e as any, isCollapsed ? (t('common.expand') || 'Expand') : (t('common.collapse') || 'Collapse'))}
|
||||
onMouseLeave={onBtnLeave}
|
||||
onBlur={onBtnLeave}
|
||||
>
|
||||
<svg style={{ transform: `rotate(${isCollapsed ? 0 : 90}deg)` }} xmlns="http://www.w3.org/2000/svg" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" className="w-3 h-3"><path fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="m9 18l6-6l-6-6" /></svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCollapsed && (
|
||||
<div ref={bodyRef} className="d2-block-body" style={bodyStyle}>
|
||||
{props.loading && !hasPreview
|
||||
? (
|
||||
<div className="d2-source px-4 py-4">
|
||||
<pre className="d2-code"><code>{baseCode}</code></pre>
|
||||
{renderError && <p className="d2-error mt-2 text-xs">{renderError}</p>}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{showSourceFallback
|
||||
? (
|
||||
<div className="d2-source px-4 py-4">
|
||||
<pre className="d2-code"><code>{baseCode}</code></pre>
|
||||
{renderError && <p className="d2-error mt-2 text-xs">{renderError}</p>}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="d2-render" style={renderStyle}>
|
||||
<div className="d2-svg" dangerouslySetInnerHTML={{ __html: svgMarkup }} />
|
||||
{renderError && <p className="d2-error px-4 pb-3 text-xs">{renderError}</p>}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
export type D2Loader = () => Promise<any> | any
|
||||
|
||||
const defaultD2Loader: D2Loader = () => import('@terrastruct/d2')
|
||||
|
||||
let cachedD2: any = null
|
||||
let d2Loader: D2Loader | null = defaultD2Loader
|
||||
|
||||
function resetCachedD2() {
|
||||
cachedD2 = null
|
||||
}
|
||||
|
||||
function normalizeD2Module(mod: any) {
|
||||
if (!mod)
|
||||
return mod
|
||||
if (mod.D2 && typeof mod.D2 === 'function')
|
||||
return mod.D2
|
||||
if (mod.default && mod.default.D2 && typeof mod.default.D2 === 'function')
|
||||
return mod.default.D2
|
||||
const candidate = mod.default ?? mod
|
||||
if (typeof candidate === 'function')
|
||||
return candidate
|
||||
if (candidate?.D2 && typeof candidate.D2 === 'function')
|
||||
return candidate.D2
|
||||
return candidate
|
||||
}
|
||||
|
||||
export function setD2Loader(loader: D2Loader | null) {
|
||||
d2Loader = loader
|
||||
resetCachedD2()
|
||||
}
|
||||
|
||||
export function enableD2(loader?: D2Loader) {
|
||||
setD2Loader(loader ?? defaultD2Loader)
|
||||
}
|
||||
|
||||
export function disableD2() {
|
||||
setD2Loader(null)
|
||||
}
|
||||
|
||||
export function isD2Enabled() {
|
||||
return typeof d2Loader === 'function'
|
||||
}
|
||||
|
||||
export async function getD2() {
|
||||
if (cachedD2)
|
||||
return cachedD2
|
||||
|
||||
const loader = d2Loader
|
||||
if (!loader)
|
||||
return null
|
||||
|
||||
let mod: any
|
||||
try {
|
||||
mod = await loader()
|
||||
}
|
||||
catch (err) {
|
||||
if (loader === defaultD2Loader) {
|
||||
throw new Error('Optional dependency "@terrastruct/d2" is not installed. Please install it to enable D2 diagrams.')
|
||||
}
|
||||
throw err
|
||||
}
|
||||
if (!mod)
|
||||
return null
|
||||
|
||||
cachedD2 = normalizeD2Module(mod)
|
||||
return cachedD2
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { D2BlockNode } from './D2BlockNode'
|
||||
export { D2BlockNode as default } from './D2BlockNode'
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { NodeComponentProps } from '../../types/node-component'
|
||||
import React from 'react'
|
||||
import { renderInline } from '../../renderers/renderChildren'
|
||||
|
||||
export function DefinitionListNode(props: NodeComponentProps<{ type: 'definition_list', items?: any[] }>) {
|
||||
const { node, ctx, renderNode, indexKey } = props
|
||||
const items = Array.isArray(node.items) ? node.items : []
|
||||
return (
|
||||
<dl className="definition-list" data-index-key={indexKey}>
|
||||
{items.map((item: any, idx: number) => (
|
||||
<div key={`${String(indexKey ?? 'definition')}-${idx}`} className="mb-4">
|
||||
<dt className="definition-term font-semibold">
|
||||
{ctx && renderNode ? renderInline(item.term, ctx, `${String(indexKey ?? 'definition')}-term-${idx}`, renderNode) : null}
|
||||
</dt>
|
||||
<dd className="definition-desc ml-4">
|
||||
{ctx && renderNode ? renderInline(item.definition, ctx, `${String(indexKey ?? 'definition')}-desc-${idx}`, renderNode) : null}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
|
||||
export default DefinitionListNode
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { NodeComponentProps } from '../../types/node-component'
|
||||
import React from 'react'
|
||||
|
||||
export function EmojiNode(props: NodeComponentProps<{ type: 'emoji', name: string, markup?: string }>) {
|
||||
const { node } = props
|
||||
return (
|
||||
<span className="emoji-node">
|
||||
{node.name ?? node.markup}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmojiNode
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { NodeComponentProps } from '../../types/node-component'
|
||||
import React from 'react'
|
||||
|
||||
export function EmphasisNode(props: NodeComponentProps<{ type: 'emphasis' }>) {
|
||||
return <em className="emphasis-node">{props.children}</em>
|
||||
}
|
||||
|
||||
export default EmphasisNode
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { NodeComponentProps } from '../../types/node-component'
|
||||
import React, { useCallback } from 'react'
|
||||
|
||||
export function FootnoteAnchorNode(props: NodeComponentProps<{ type: 'footnote_anchor', id: string }>) {
|
||||
const { node } = props
|
||||
const handleScroll = useCallback((event: React.MouseEvent) => {
|
||||
event.preventDefault()
|
||||
if (typeof document === 'undefined')
|
||||
return
|
||||
const target = document.getElementById(`fnref-${String(node.id ?? '')}`)
|
||||
if (target)
|
||||
target.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [node.id])
|
||||
|
||||
return (
|
||||
<a
|
||||
className="footnote-anchor text-sm text-[#0366d6] hover:underline cursor-pointer"
|
||||
href={`#fnref-${node.id}`}
|
||||
title={`Back to reference ${node.id}`}
|
||||
onClick={handleScroll}
|
||||
>
|
||||
↩︎
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export default FootnoteAnchorNode
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ParsedNode } from 'stream-markdown-parser'
|
||||
import type { NodeComponentProps } from '../../types/node-component'
|
||||
import React from 'react'
|
||||
import { renderNodeChildren } from '../../renderers/renderChildren'
|
||||
|
||||
export function FootnoteNode(props: NodeComponentProps<{ type: 'footnote', id: string, children?: ParsedNode[] }>) {
|
||||
const { node, ctx, renderNode, indexKey } = props
|
||||
return (
|
||||
<div id={`footnote-${node.id}`} className="footnote-node flex mt-2 mb-2 text-sm leading-relaxed border-t border-[#eaecef] pt-2">
|
||||
<div className="flex-1">
|
||||
{ctx && renderNode ? renderNodeChildren(node.children, ctx, String(indexKey ?? `footnote-${node.id}`), renderNode) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FootnoteNode
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { NodeComponentProps } from '../../types/node-component'
|
||||
import React, { useCallback, useMemo } from 'react'
|
||||
|
||||
export function FootnoteReferenceNode(props: NodeComponentProps<{ type: 'footnote_reference', id: string }>) {
|
||||
const { node } = props
|
||||
const href = useMemo(() => `#footnote-${node.id}`, [node.id])
|
||||
const handleScroll = useCallback((event: React.MouseEvent) => {
|
||||
event.preventDefault()
|
||||
if (typeof document === 'undefined')
|
||||
return
|
||||
const target = document.querySelector(href)
|
||||
if (target)
|
||||
target.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [href])
|
||||
|
||||
return (
|
||||
<sup id={`fnref-${node.id}`} className="footnote-reference" onClick={handleScroll}>
|
||||
<a href={href} title={`View footnote ${node.id}`} className="footnote-link cursor-pointer">
|
||||
[
|
||||
{node.id}
|
||||
]
|
||||
</a>
|
||||
</sup>
|
||||
)
|
||||
}
|
||||
|
||||
export default FootnoteReferenceNode
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { NodeComponentProps } from '../../types/node-component'
|
||||
import React from 'react'
|
||||
|
||||
export function HardBreakNode(_props: NodeComponentProps<{ type: 'hardbreak' }>) {
|
||||
return <br className="hard-break" />
|
||||
}
|
||||
|
||||
export default HardBreakNode
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ParsedNode } from 'stream-markdown-parser'
|
||||
import type { NodeComponentProps } from '../../types/node-component'
|
||||
import clsx from 'clsx'
|
||||
import React from 'react'
|
||||
import { renderNodeChildren } from '../../renderers/renderChildren'
|
||||
|
||||
export function HeadingNode(props: NodeComponentProps<{ type: 'heading', level?: number, children?: ParsedNode[], attrs?: Record<string, string | boolean> }>) {
|
||||
const { node, ctx, renderNode, indexKey, children } = props
|
||||
const level = Math.min(6, Math.max(1, Number(node.level) || 1))
|
||||
type HeadingTag = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
|
||||
const Tag = (`h${level}`) as HeadingTag
|
||||
const attrs = (node as any)?.attrs as Record<string, unknown> | undefined
|
||||
const { class: classAttr, className: classNameAttr, style: styleAttr, ...restAttrs } = attrs ?? {}
|
||||
|
||||
return (
|
||||
<Tag
|
||||
dir="auto"
|
||||
className={clsx('heading-node font-semibold', `heading-${level}`, classAttr as any, classNameAttr as any)}
|
||||
{...(typeof styleAttr === 'object' && styleAttr != null ? { style: styleAttr as any } : {})}
|
||||
{...(restAttrs as React.HTMLAttributes<HTMLHeadingElement>)}
|
||||
>
|
||||
{children ?? (ctx && renderNode
|
||||
? renderNodeChildren(node.children, ctx, String(indexKey ?? `heading-${level}`), renderNode)
|
||||
: null)}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
|
||||
export default HeadingNode
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { NodeComponentProps } from '../../types/node-component'
|
||||
import React from 'react'
|
||||
|
||||
export function HighlightNode(props: NodeComponentProps<{ type: 'highlight' }>) {
|
||||
return <mark className="highlight-node">{props.children}</mark>
|
||||
}
|
||||
|
||||
export default HighlightNode
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { NodeComponentProps } from '../../types/node-component'
|
||||
import React, { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import { NON_STRUCTURING_HTML_TAGS, sanitizeHtmlContent, sanitizeHtmlTokenAttrs } from 'stream-markdown-parser'
|
||||
import { useViewportPriority } from '../../context/viewportPriority'
|
||||
import { getCustomComponentsRevision, getCustomNodeComponents, subscribeCustomComponents } from '../../customComponents'
|
||||
import { renderNodeChildren, tokenAttrsToProps } from '../../renderers/renderChildren'
|
||||
import { hasCustomHtmlComponents, normalizeDomAttrs, parseHtmlToReactNodes } from '../../utils/htmlToReact'
|
||||
|
||||
function mergeHtmlBlockClassName(attrs?: Record<string, any>) {
|
||||
const next = { ...(attrs || {}) }
|
||||
const existing = typeof next.className === 'string' ? next.className.trim() : ''
|
||||
next.className = existing ? `html-block-node ${existing}` : 'html-block-node'
|
||||
return next
|
||||
}
|
||||
|
||||
export function HtmlBlockNode(props: NodeComponentProps<{
|
||||
type: 'html_block'
|
||||
content: string
|
||||
raw?: string
|
||||
tag?: string
|
||||
attrs?: [string, string | null][] | null
|
||||
children?: any[]
|
||||
loading?: boolean
|
||||
}> & {
|
||||
customComponents?: Record<string, React.ComponentType<any>>
|
||||
placeholder?: React.ReactNode
|
||||
}) {
|
||||
const { node, placeholder, customId } = props
|
||||
const registerViewport = useViewportPriority()
|
||||
const [hostEl, setHostEl] = useState<HTMLElement | null>(null)
|
||||
const handleRef = useRef<ReturnType<typeof registerViewport> | null>(null)
|
||||
const [shouldRender, setShouldRender] = useState(() => typeof window === 'undefined')
|
||||
const [renderContent, setRenderContent] = useState(node.content)
|
||||
const isDeferred = Boolean(node.loading)
|
||||
|
||||
const customComponentsRevision = useSyncExternalStore(
|
||||
subscribeCustomComponents,
|
||||
getCustomComponentsRevision,
|
||||
getCustomComponentsRevision,
|
||||
)
|
||||
|
||||
const effectiveCustomComponents = useMemo(() => {
|
||||
// Allow explicit injection (primarily for tests), otherwise fall back to global store.
|
||||
return props.customComponents ?? getCustomNodeComponents(customId)
|
||||
}, [customId, props.customComponents, customComponentsRevision])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
setShouldRender(true)
|
||||
return
|
||||
}
|
||||
handleRef.current?.destroy()
|
||||
handleRef.current = null
|
||||
if (!isDeferred) {
|
||||
setShouldRender(true)
|
||||
setRenderContent(node.content)
|
||||
return
|
||||
}
|
||||
if (!hostEl) {
|
||||
setShouldRender(false)
|
||||
return
|
||||
}
|
||||
const handle = registerViewport(hostEl, { rootMargin: '400px' })
|
||||
handleRef.current = handle
|
||||
if (handle.isVisible())
|
||||
setShouldRender(true)
|
||||
handle.whenVisible.then(() => setShouldRender(true)).catch(() => {})
|
||||
return () => {
|
||||
handle.destroy()
|
||||
handleRef.current = null
|
||||
}
|
||||
}, [hostEl, isDeferred, node.content, registerViewport])
|
||||
|
||||
useEffect(() => () => {
|
||||
handleRef.current?.destroy()
|
||||
handleRef.current = null
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDeferred || shouldRender)
|
||||
setRenderContent(node.content)
|
||||
}, [isDeferred, node.content, shouldRender])
|
||||
|
||||
const boundAttrs = useMemo(() => {
|
||||
const rawAttrs = tokenAttrsToProps(sanitizeHtmlTokenAttrs(node.attrs ?? undefined))
|
||||
return rawAttrs ? normalizeDomAttrs(rawAttrs as Record<string, string>) : undefined
|
||||
}, [node.attrs])
|
||||
const structuredTag = useMemo(() => String(node.tag ?? '').trim(), [node.tag])
|
||||
const structuredChildren = useMemo(() => Array.isArray(node.children) ? node.children : [], [node.children])
|
||||
const isStructured = structuredChildren.length > 0
|
||||
&& !!structuredTag
|
||||
&& !NON_STRUCTURING_HTML_TAGS.has(structuredTag.toLowerCase())
|
||||
&& !!props.ctx
|
||||
&& !!props.renderNode
|
||||
const structuredWrapperProps = useMemo(
|
||||
() => mergeHtmlBlockClassName(boundAttrs as Record<string, any> | undefined),
|
||||
[boundAttrs],
|
||||
)
|
||||
|
||||
// Check if we should use dynamic rendering
|
||||
const useDynamic = useMemo(() => {
|
||||
return hasCustomHtmlComponents(node.content ?? '', effectiveCustomComponents)
|
||||
}, [effectiveCustomComponents, node.content])
|
||||
|
||||
const reactNodes = useMemo(() => {
|
||||
if (!useDynamic || !node.content)
|
||||
return null
|
||||
return parseHtmlToReactNodes(node.content, effectiveCustomComponents)
|
||||
}, [effectiveCustomComponents, node.content, useDynamic])
|
||||
const safeHtmlContent = useMemo(() => sanitizeHtmlContent(renderContent ?? ''), [renderContent])
|
||||
const structuredContent = useMemo(() => {
|
||||
if (!isStructured || !props.ctx || !props.renderNode)
|
||||
return null
|
||||
return renderNodeChildren(
|
||||
structuredChildren,
|
||||
props.ctx,
|
||||
`${String(props.indexKey ?? 'html-block')}-structured`,
|
||||
props.renderNode,
|
||||
)
|
||||
}, [isStructured, props.ctx, props.indexKey, props.renderNode, structuredChildren])
|
||||
|
||||
const placeholderNode = (
|
||||
<div className="html-block-node__placeholder">
|
||||
{placeholder ?? (
|
||||
<>
|
||||
<span className="html-block-node__placeholder-bar" />
|
||||
<span className="html-block-node__placeholder-bar w-4/5" />
|
||||
<span className="html-block-node__placeholder-bar w-2/3" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (isStructured) {
|
||||
return React.createElement(
|
||||
structuredTag,
|
||||
{
|
||||
...(structuredWrapperProps as any),
|
||||
ref: setHostEl,
|
||||
},
|
||||
shouldRender ? structuredContent : placeholderNode,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={setHostEl} className="html-block-node" {...(boundAttrs as any)}>
|
||||
{shouldRender
|
||||
? (
|
||||
useDynamic && reactNodes
|
||||
? (
|
||||
<>{reactNodes}</>
|
||||
)
|
||||
: (
|
||||
<div dangerouslySetInnerHTML={{ __html: safeHtmlContent }} />
|
||||
)
|
||||
)
|
||||
: placeholderNode}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HtmlBlockNode
|
||||
@@ -0,0 +1,237 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import type { NodeComponentProps } from '../../types/node-component'
|
||||
import type { HtmlToken } from '../../utils/htmlToReact'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { BLOCKED_HTML_TAGS as BLOCKED_TAGS, convertHtmlAttrsToProps, sanitizeHtmlContent } from 'stream-markdown-parser'
|
||||
import { getCustomNodeComponents } from '../../customComponents'
|
||||
import {
|
||||
hasCustomHtmlComponents,
|
||||
isCustomHtmlComponent,
|
||||
normalizeDomAttrs,
|
||||
sanitizeHtmlAttrs,
|
||||
tokenizeHtml,
|
||||
} from '../../utils/htmlToReact'
|
||||
|
||||
const SHOULD_LOG = (() => {
|
||||
try {
|
||||
return Boolean((import.meta as any).env?.DEV)
|
||||
}
|
||||
catch {}
|
||||
return false
|
||||
})()
|
||||
|
||||
function warn(message: string) {
|
||||
if (SHOULD_LOG)
|
||||
console.warn(message)
|
||||
}
|
||||
|
||||
function logError(message: string, err: unknown) {
|
||||
if (SHOULD_LOG)
|
||||
console.error(message, err)
|
||||
}
|
||||
|
||||
function convertAttrsToProps(attrs: Record<string, string>): Record<string, any> {
|
||||
return convertHtmlAttrsToProps(attrs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build React element tree from tokens
|
||||
*/
|
||||
function buildReactElementTree(
|
||||
tokens: HtmlToken[],
|
||||
customComponents: Record<string, ComponentType<any>>,
|
||||
): React.ReactNode[] {
|
||||
let autoKeySeed = 0
|
||||
const stack: Array<{ tagName: string, children: React.ReactNode[], attrs?: Record<string, string> }> = []
|
||||
const rootNodes: React.ReactNode[] = []
|
||||
|
||||
for (const token of tokens) {
|
||||
if (token.type === 'text') {
|
||||
const target = stack.length > 0 ? stack[stack.length - 1].children : rootNodes
|
||||
target.push(token.content!)
|
||||
}
|
||||
else if (token.type === 'self_closing') {
|
||||
const element = createReactElement(token.tagName!, token.attrs || {}, [], customComponents, `ms-html-${autoKeySeed++}`)
|
||||
const target = stack.length > 0 ? stack[stack.length - 1].children : rootNodes
|
||||
element != null && target.push(element)
|
||||
}
|
||||
else if (token.type === 'tag_open') {
|
||||
stack.push({ tagName: token.tagName!, children: [], attrs: token.attrs })
|
||||
}
|
||||
else if (token.type === 'tag_close') {
|
||||
const closingTag = token.tagName!.toLowerCase()
|
||||
|
||||
// Find matching opening tag (handle nested same tags)
|
||||
let matchedIndex = -1
|
||||
for (let i = stack.length - 1; i >= 0; i--) {
|
||||
if (stack[i].tagName.toLowerCase() === closingTag) {
|
||||
matchedIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedIndex !== -1) {
|
||||
// Pop all tags until the matched one (auto-closing intermediate tags)
|
||||
while (stack.length > matchedIndex) {
|
||||
const opening = stack.pop()!
|
||||
const element = createReactElement(opening.tagName, opening.attrs || {}, opening.children, customComponents, `ms-html-${autoKeySeed++}`)
|
||||
|
||||
if (stack.length > 0)
|
||||
element != null && stack[stack.length - 1].children.push(element)
|
||||
else
|
||||
element != null && rootNodes.push(element)
|
||||
|
||||
// Warn if auto-closing tags
|
||||
if (opening.tagName.toLowerCase() !== closingTag && stack.length > matchedIndex) {
|
||||
warn(`Auto-closing unclosed tag: <${opening.tagName}>`)
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
// No matching opening tag, warn and ignore
|
||||
warn(`Ignoring closing tag with no matching opening tag: </${token.tagName}>`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle any remaining unclosed tags
|
||||
while (stack.length > 0) {
|
||||
const unclosed = stack.pop()!
|
||||
const element = createReactElement(unclosed.tagName, unclosed.attrs || {}, unclosed.children, customComponents, `ms-html-${autoKeySeed++}`)
|
||||
element != null && rootNodes.push(element)
|
||||
warn(`Auto-closing unclosed tag: <${unclosed.tagName}>`)
|
||||
}
|
||||
|
||||
return rootNodes
|
||||
}
|
||||
|
||||
/**
|
||||
* Create React element for a tag
|
||||
*/
|
||||
function createReactElement(
|
||||
tagName: string,
|
||||
attrs: Record<string, string>,
|
||||
children: React.ReactNode[],
|
||||
customComponents: Record<string, ComponentType<any>>,
|
||||
autoKey: string,
|
||||
): React.ReactNode {
|
||||
if (BLOCKED_TAGS.has(tagName.toLowerCase()))
|
||||
return null
|
||||
|
||||
const sanitizedAttrs = sanitizeHtmlAttrs(attrs)
|
||||
const explicitKey = (sanitizedAttrs as any).key
|
||||
const elementKey = explicitKey != null && explicitKey !== '' ? explicitKey : autoKey
|
||||
|
||||
if (isCustomHtmlComponent(tagName, customComponents)) {
|
||||
// It's a custom React component
|
||||
const component = customComponents[tagName] || customComponents[tagName.toLowerCase()]
|
||||
const convertedAttrs = convertAttrsToProps(sanitizedAttrs)
|
||||
return React.createElement(component as ComponentType<any>, { ...convertedAttrs, key: elementKey }, ...children)
|
||||
}
|
||||
else {
|
||||
// It's a standard HTML element
|
||||
return React.createElement(tagName, { ...normalizeDomAttrs(sanitizedAttrs), key: elementKey }, ...children)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse HTML content to React elements
|
||||
*/
|
||||
function parseHtmlToReactNodes(
|
||||
content: string,
|
||||
customComponents: Record<string, ComponentType<any>>,
|
||||
): React.ReactNode[] | null {
|
||||
if (!content)
|
||||
return []
|
||||
|
||||
try {
|
||||
const tokens = tokenizeHtml(content)
|
||||
const nodes = buildReactElementTree(tokens, customComponents)
|
||||
return nodes
|
||||
}
|
||||
catch (error) {
|
||||
logError('Failed to parse HTML to React nodes:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function HtmlInlineNode(props: NodeComponentProps<{
|
||||
type: 'html_inline'
|
||||
content: string
|
||||
loading?: boolean
|
||||
autoClosed?: boolean
|
||||
}>) {
|
||||
const { node, customId } = props
|
||||
const containerRef = useRef<HTMLSpanElement>(null)
|
||||
const [isClient, setIsClient] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setIsClient(true)
|
||||
}, [])
|
||||
|
||||
// Get custom components from global registry
|
||||
const customComponents = getCustomNodeComponents(customId)
|
||||
const safeHtmlContent = React.useMemo(() => sanitizeHtmlContent(node.content ?? ''), [node.content])
|
||||
|
||||
// Computed property to determine render mode and content
|
||||
const renderMode = React.useMemo(() => {
|
||||
const content = node.content
|
||||
if (!content)
|
||||
return { mode: 'html', content: '' }
|
||||
|
||||
// Check if content contains custom components
|
||||
if (!hasCustomHtmlComponents(content, customComponents))
|
||||
return { mode: 'html', content }
|
||||
|
||||
// Parse and build React element tree
|
||||
const nodes = parseHtmlToReactNodes(content, customComponents)
|
||||
if (nodes === null)
|
||||
return { mode: 'html', content } // Fallback to dangerouslySetInnerHTML if parsing fails
|
||||
|
||||
return { mode: 'dynamic', nodes }
|
||||
}, [node.content, customComponents])
|
||||
|
||||
// Use DOM manipulation for pure HTML (mode: 'html')
|
||||
useEffect(() => {
|
||||
if (!isClient || !containerRef.current || renderMode.mode !== 'html')
|
||||
return
|
||||
|
||||
const host = containerRef.current
|
||||
host.innerHTML = ''
|
||||
const template = document.createElement('template')
|
||||
template.innerHTML = safeHtmlContent
|
||||
host.appendChild(template.content.cloneNode(true))
|
||||
}, [renderMode.mode, isClient, safeHtmlContent])
|
||||
|
||||
// Loading state handling
|
||||
if (node.loading && !node.autoClosed) {
|
||||
return (
|
||||
<span className="html-inline-node html-inline-node--loading">
|
||||
{node.content}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Dynamic rendering for custom components
|
||||
if (renderMode.mode === 'dynamic') {
|
||||
return (
|
||||
<span
|
||||
className="html-inline-node"
|
||||
style={{ display: 'inline' }}
|
||||
>
|
||||
{renderMode.nodes}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback to DOM rendering for standard HTML
|
||||
return (
|
||||
<span
|
||||
ref={containerRef}
|
||||
className="html-inline-node"
|
||||
style={{ display: 'inline' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default HtmlInlineNode
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { ImageNodeProps } from '../../types/component-props'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
const DEFAULT_PROPS = {
|
||||
fallbackSrc: '',
|
||||
lazy: true,
|
||||
usePlaceholder: true,
|
||||
}
|
||||
|
||||
export interface ImageNodeReactEvents {
|
||||
onLoad?: (src: string) => void
|
||||
onError?: (src: string) => void
|
||||
onClick?: (payload: [event: React.MouseEvent<HTMLImageElement>, src: string]) => void
|
||||
}
|
||||
|
||||
export function ImageNode(rawProps: ImageNodeProps & ImageNodeReactEvents) {
|
||||
const props = { ...DEFAULT_PROPS, ...rawProps }
|
||||
const [imageLoaded, setImageLoaded] = useState(false)
|
||||
const [hasError, setHasError] = useState(false)
|
||||
const [fallbackTried, setFallbackTried] = useState(false)
|
||||
|
||||
const displaySrc = hasError && props.fallbackSrc
|
||||
? props.fallbackSrc
|
||||
: props.node.src
|
||||
|
||||
useEffect(() => {
|
||||
setImageLoaded(false)
|
||||
setHasError(false)
|
||||
}, [displaySrc])
|
||||
|
||||
useEffect(() => {
|
||||
setFallbackTried(false)
|
||||
}, [props.node.src])
|
||||
|
||||
const handleImageError = () => {
|
||||
if (props.fallbackSrc && !fallbackTried) {
|
||||
setFallbackTried(true)
|
||||
setHasError(true)
|
||||
}
|
||||
else {
|
||||
setHasError(true)
|
||||
props.onError?.(props.node.src)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImageLoad = () => {
|
||||
setImageLoaded(true)
|
||||
setHasError(false)
|
||||
props.onLoad?.(displaySrc)
|
||||
}
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLImageElement>) => {
|
||||
event.preventDefault()
|
||||
if (!imageLoaded || hasError) {
|
||||
return
|
||||
}
|
||||
props.onClick?.([event, displaySrc])
|
||||
}
|
||||
|
||||
if (!props.node.loading && !hasError) {
|
||||
return (
|
||||
<img
|
||||
key="image"
|
||||
src={displaySrc}
|
||||
alt={String(props.node.alt ?? props.node.title ?? '')}
|
||||
title={String(props.node.title ?? props.node.alt ?? '')}
|
||||
className={`image-node__img${imageLoaded ? ' is-loaded' : ''}`}
|
||||
loading={props.lazy ? 'lazy' : 'eager'}
|
||||
decoding="async"
|
||||
tabIndex={imageLoaded ? 0 : -1}
|
||||
aria-label={props.node.alt ?? 'Preview image'}
|
||||
onError={handleImageError}
|
||||
onLoad={handleImageLoad}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!hasError) {
|
||||
return (
|
||||
<span key="placeholder" className="image-node__placeholder">
|
||||
{props.usePlaceholder
|
||||
? (
|
||||
<>
|
||||
<span className="image-node__spinner" aria-hidden="true" />
|
||||
<span className="image-node__placeholder-text">Loading image...</span>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<span className="image-node__placeholder-text">{props.node.raw ?? 'Loading image…'}</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (!props.node.loading && !props.fallbackSrc) {
|
||||
return (
|
||||
<span className="image-node__error">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<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 className="image-node__placeholder-text">Image failed to load</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default ImageNode
|
||||
@@ -0,0 +1,553 @@
|
||||
import type { VisibilityHandle } from '../../context/viewportPriority'
|
||||
import type { InfographicBlockNodeProps, InfographicBlockActionContext, MermaidBlockEvent } from '../../types/component-props'
|
||||
import clsx from 'clsx'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useViewportPriority } from '../../context/viewportPriority'
|
||||
import { useSafeI18n } from '../../i18n/useSafeI18n'
|
||||
import { hideTooltip, showTooltipForAnchor } from '../../tooltip/singletonTooltip'
|
||||
import { clampInfographicPreviewHeight, estimateInfographicPreviewHeight, parsePositiveNumber } from './height'
|
||||
import { getInfographic } from './infographic'
|
||||
|
||||
// Inline Icon
|
||||
const INFOGRAPHIC_ICON = (
|
||||
<svg width="1em" height="1em" viewBox="0 0 291 300" fill="none" className="w-4 h-4 flex-shrink-0" xmlns="http://www.w3.org/2000/svg">
|
||||
<g>
|
||||
<path d="M140.904 239.376C128.83 239.683 119.675 239.299 115.448 243.843C110.902 248.07 111.288 257.227 110.979 269.302C111.118 274.675 111.118 279.478 111.472 283.52C111.662 285.638 111.95 287.547 112.406 289.224C112.411 289.243 112.416 289.259 112.422 289.28C112.462 289.419 112.496 289.558 112.539 289.691C113.168 291.787 114.088 293.491 115.446 294.758C116.662 296.064 118.283 296.963 120.264 297.69C120.36 297.614 120.464 297.646 120.555 297.675C120.56 297.68 120.56 297.68 120.566 297.68C120.848 297.768 121.142 297.846 121.443 297.923C121.454 297.923 121.464 297.928 121.478 297.934C122.875 298.272 124.424 298.507 126.11 298.678C126.326 298.696 126.542 298.718 126.763 298.739C130.79 299.086 135.558 299.088 140.904 299.222C152.974 298.912 162.128 299.302 166.36 294.758C170.904 290.526 170.515 281.371 170.824 269.302C170.515 257.227 170.907 248.07 166.36 243.843C162.131 239.299 152.974 239.683 140.904 239.376Z" fill="#FF6376"></path>
|
||||
<path d="M21.2155 128.398C12.6555 128.616 6.16484 128.339 3.16751 131.56C-0.0538222 134.56 0.218178 141.054 -0.000488281 149.608C0.218178 158.168 -0.0538222 164.659 3.16751 167.656C6.16484 170.878 12.6555 170.606 21.2155 170.824C25.0262 170.726 28.4288 170.726 31.2955 170.475C32.7968 170.342 34.1488 170.136 35.3382 169.814C35.3542 169.811 35.3648 169.806 35.3782 169.803C35.4768 169.774 35.5755 169.747 35.6688 169.718C37.1568 169.272 38.3648 168.622 39.2635 167.656C40.1915 166.795 40.8262 165.646 41.2715 164.243C41.2875 164.174 41.3115 164.102 41.3328 164.035C41.3328 164.035 41.3355 164.032 41.3355 164.027C41.3968 163.827 41.4529 163.622 41.5062 163.406C41.5062 163.398 41.5115 163.392 41.5142 163.382C41.7542 162.392 41.9222 161.294 42.0422 160.096C42.0555 159.944 42.0715 159.792 42.0848 159.635C42.3328 156.779 42.3328 153.398 42.4262 149.608C42.2075 141.054 42.4848 134.56 39.2635 131.56C36.2635 128.339 29.7728 128.616 21.2155 128.398Z" fill="#FFCCCC"></path>
|
||||
<path d="M81.0595 184.171C70.8568 184.433 63.1208 184.102 59.5475 187.942C55.7075 191.518 56.0328 199.254 55.7742 209.454C56.0328 219.657 55.7075 227.393 59.5475 230.963C63.1208 234.803 70.8568 234.478 81.0595 234.739C85.6008 234.622 89.6595 234.622 93.0728 234.323C94.8648 234.163 96.4755 233.921 97.8942 233.534C97.9102 233.529 97.9235 233.526 97.9422 233.521C98.0568 233.486 98.1742 233.457 98.2888 233.422C100.06 232.889 101.5 232.113 102.569 230.963C103.676 229.937 104.433 228.566 104.964 226.894C104.985 226.811 105.012 226.726 105.036 226.646C105.041 226.643 105.041 226.643 105.041 226.638C105.116 226.401 105.18 226.153 105.244 225.897C105.244 225.889 105.249 225.881 105.254 225.867C105.54 224.689 105.74 223.379 105.881 221.953C105.9 221.771 105.916 221.59 105.934 221.403C106.228 218.001 106.228 213.969 106.342 209.454C106.081 199.254 106.412 191.518 102.572 187.942C98.9955 184.102 91.2568 184.433 81.0595 184.171Z" fill="#FF939F"></path>
|
||||
<path d="M260.591 151.87C215.652 151.87 203.02 164.523 203.02 209.462H198.476C198.476 164.523 185.836 151.881 140.895 151.881V147.337C185.836 147.337 198.487 134.705 198.487 89.7659H203.02C203.02 134.705 215.652 147.337 260.591 147.337V151.87ZM286.052 124.158C281.82 119.614 272.66 120.001 260.591 119.689C248.521 119.385 239.361 119.771 235.129 115.227C230.585 110.995 230.983 101.846 230.671 89.7659C230.513 83.7312 230.535 78.4272 230.023 74.1019C229.513 69.7659 228.481 66.4219 226.209 64.3046C221.967 59.7606 212.817 60.1472 200.748 59.8459C188.681 60.1472 179.519 59.7606 175.287 64.3046C170.753 68.5366 171.129 77.6966 170.828 89.7659C170.516 101.835 170.9 110.995 166.356 115.227C162.124 119.771 152.985 119.374 140.905 119.689C138.873 119.739 136.924 119.771 135.071 119.811C119.313 118.697 106.337 112.318 106.337 89.7659C106.212 84.6699 106.233 80.1792 105.807 76.5206C105.367 72.8726 104.492 70.0379 102.575 68.2566C99.0013 64.4112 91.2573 64.7446 81.0653 64.4832C70.86 64.7446 63.1186 64.4112 59.5533 68.2566C55.708 71.8299 56.0306 79.5632 55.7693 89.7659C56.0306 99.9686 55.708 107.702 59.5533 111.278C63.1186 115.113 70.86 114.79 81.0653 115.049C103.617 115.049 109.996 128.035 111.1 143.803C111.068 145.659 111.028 147.587 110.975 149.619C111.121 154.987 111.121 159.79 111.476 163.835C111.663 165.95 111.945 167.857 112.404 169.534C112.412 169.555 112.412 169.566 112.423 169.598C112.465 169.734 112.497 169.867 112.537 170.003C113.164 172.099 114.092 173.809 115.447 175.07C116.665 176.371 118.281 177.278 120.271 177.905C120.364 177.934 120.46 177.955 120.564 177.987C120.855 178.081 121.145 178.153 121.439 178.238C121.46 178.238 121.471 178.238 121.479 178.249C122.876 178.582 124.42 178.822 126.108 178.987C126.327 179.009 126.545 179.03 126.764 179.051C130.788 179.395 135.559 179.395 140.905 179.529C152.975 179.843 162.124 179.457 166.356 184.001C170.9 188.233 170.516 197.371 170.828 209.451C171.129 221.529 170.743 230.681 175.287 234.91C179.519 239.454 188.681 239.07 200.748 239.371C206.127 239.235 210.921 239.235 214.975 238.881C217.079 238.694 218.985 238.403 220.676 237.955C220.695 237.945 220.705 237.934 220.727 237.934C220.873 237.891 220.999 237.859 221.135 237.819C223.228 237.193 224.937 236.265 226.209 234.91C227.511 233.691 228.409 232.065 229.044 230.097C229.065 230.003 229.095 229.899 229.127 229.803V229.793C229.22 229.513 229.295 229.222 229.367 228.918C229.367 228.897 229.377 228.897 229.377 228.878C229.721 227.481 229.951 225.937 230.127 224.249C230.137 224.03 230.169 223.811 230.191 223.593C230.535 219.571 230.535 214.798 230.671 209.451C230.972 197.371 230.585 188.233 235.129 184.001C239.361 179.457 248.511 179.843 260.591 179.529C272.66 179.227 281.82 179.614 286.052 175.07C290.596 170.838 290.209 161.689 290.511 149.619C290.209 137.539 290.596 128.379 286.052 124.158Z" fill="#FF356A"></path>
|
||||
<path d="M112.405 49.848C112.411 49.8694 112.416 49.8827 112.421 49.904C112.461 50.0427 112.499 50.1814 112.539 50.3147C113.171 52.4134 114.088 54.1147 115.448 55.384C116.661 56.6907 118.283 57.5894 120.264 58.2134C120.36 58.24 120.464 58.2694 120.555 58.3014C120.56 58.3067 120.56 58.3067 120.565 58.3067C120.848 58.3947 121.141 58.4694 121.443 58.5467C121.453 58.5467 121.464 58.552 121.48 58.5574C122.875 58.896 124.424 59.1334 126.112 59.3014C126.325 59.3227 126.541 59.3414 126.763 59.3627C130.789 59.712 135.56 59.712 140.904 59.8454C152.973 59.5387 162.128 59.928 166.36 55.384C170.907 51.152 170.515 41.9947 170.824 29.9254C170.517 17.8507 170.907 8.69602 166.363 4.46935C162.131 -0.0746511 152.973 0.309349 140.904 1.52588e-05C128.829 0.309349 119.675 -0.0746511 115.448 4.46935C110.904 8.69602 111.288 17.8507 110.979 29.9254C111.117 35.3014 111.117 40.1014 111.472 44.144C111.661 46.2614 111.949 48.1707 112.405 49.848Z" fill="#FF6376"></path>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export interface InfographicBlockNodeReactEvents {
|
||||
onCopy?: (code: string) => void
|
||||
onExport?: (event: MermaidBlockEvent<{ type: 'export' }>) => void
|
||||
onOpenModal?: (event: MermaidBlockEvent<{ type: 'open-modal' }>) => void
|
||||
onToggleMode?: (
|
||||
target: 'preview' | 'source',
|
||||
event: MermaidBlockEvent<{ type: 'toggle-mode', target: 'preview' | 'source' }>,
|
||||
) => void
|
||||
}
|
||||
|
||||
const DEFAULTS = {
|
||||
maxHeight: '500px',
|
||||
loading: true,
|
||||
showHeader: true,
|
||||
showCopyButton: true,
|
||||
showCollapseButton: true,
|
||||
showModeToggle: true,
|
||||
showExportButton: true,
|
||||
showFullscreenButton: true,
|
||||
showZoomControls: true,
|
||||
}
|
||||
|
||||
export function InfographicBlockNode(rawProps: InfographicBlockNodeProps & InfographicBlockNodeReactEvents) {
|
||||
const props = { ...DEFAULTS, ...rawProps }
|
||||
const { t } = useSafeI18n()
|
||||
const [copying, setCopying] = useState(false)
|
||||
const [isCollapsed, setIsCollapsed] = useState(false)
|
||||
const [showSource, setShowSource] = useState(false)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [viewportReady, setViewportReady] = useState(() => typeof window === 'undefined')
|
||||
const baseCode = props.node.code
|
||||
const maxPreviewHeight = useMemo(() => {
|
||||
if (!props.maxHeight || props.maxHeight === 'none')
|
||||
return null
|
||||
return parsePositiveNumber(props.maxHeight)
|
||||
}, [props.maxHeight])
|
||||
const estimatedPreviewHeight = useMemo(() => {
|
||||
return clampInfographicPreviewHeight(
|
||||
parsePositiveNumber(props.estimatedPreviewHeightPx) ?? estimateInfographicPreviewHeight(baseCode),
|
||||
undefined,
|
||||
maxPreviewHeight,
|
||||
)
|
||||
}, [baseCode, maxPreviewHeight, props.estimatedPreviewHeightPx])
|
||||
const [containerHeight, setContainerHeight] = useState(`${estimatedPreviewHeight}px`)
|
||||
const [hasPreview, setHasPreview] = useState(false)
|
||||
|
||||
// Zoom
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [translate, setTranslate] = useState({ x: 0, y: 0 })
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const viewportTargetRef = useRef<HTMLDivElement>(null)
|
||||
const viewportHandleRef = useRef<VisibilityHandle | null>(null)
|
||||
const modalContentRef = useRef<HTMLDivElement>(null)
|
||||
const modalCloneWrapperRef = useRef<HTMLElement | null>(null)
|
||||
const dragStartRef = useRef({ x: 0, y: 0 })
|
||||
const instanceRef = useRef<any>(null)
|
||||
const registerViewport = useViewportPriority()
|
||||
|
||||
const resolveContainerHeight = useCallback((actualHeight: number) => {
|
||||
if (!props.maxHeight || props.maxHeight === 'none')
|
||||
return `${Math.max(actualHeight, estimatedPreviewHeight)}px`
|
||||
|
||||
const maxHeight = Number.parseFloat(String(props.maxHeight))
|
||||
if (!Number.isFinite(maxHeight))
|
||||
return `${Math.max(actualHeight, estimatedPreviewHeight)}px`
|
||||
|
||||
return `${Math.max(Math.min(actualHeight, maxHeight), estimatedPreviewHeight)}px`
|
||||
}, [estimatedPreviewHeight, props.maxHeight])
|
||||
|
||||
const updateContainerHeight = useCallback(() => {
|
||||
const el = containerRef.current
|
||||
if (!el)
|
||||
return
|
||||
|
||||
const actualHeight = el.scrollHeight
|
||||
if (actualHeight > 0)
|
||||
setContainerHeight(resolveContainerHeight(actualHeight))
|
||||
}, [resolveContainerHeight])
|
||||
|
||||
useEffect(() => {
|
||||
if (showSource || isCollapsed)
|
||||
return
|
||||
setContainerHeight((current) => {
|
||||
const currentHeight = parsePositiveNumber(current)
|
||||
if (currentHeight != null && currentHeight >= estimatedPreviewHeight)
|
||||
return current
|
||||
return `${estimatedPreviewHeight}px`
|
||||
})
|
||||
}, [estimatedPreviewHeight, isCollapsed, showSource])
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard?.writeText(baseCode)
|
||||
setCopying(true)
|
||||
props.onCopy?.(baseCode)
|
||||
setTimeout(() => setCopying(false), 1000)
|
||||
}
|
||||
catch {}
|
||||
}, [baseCode, props])
|
||||
|
||||
const renderInfographic = useCallback(async () => {
|
||||
if (!viewportReady)
|
||||
return
|
||||
if (!containerRef.current)
|
||||
return
|
||||
const el = containerRef.current
|
||||
|
||||
try {
|
||||
const InfographicClass = await getInfographic()
|
||||
if (!InfographicClass) {
|
||||
console.warn('Infographic library failed to load.')
|
||||
return
|
||||
}
|
||||
|
||||
if (instanceRef.current) {
|
||||
instanceRef.current.destroy?.()
|
||||
instanceRef.current = null
|
||||
}
|
||||
|
||||
el.innerHTML = ''
|
||||
|
||||
const instance = new InfographicClass({
|
||||
container: el,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
})
|
||||
instanceRef.current = instance
|
||||
instance.render(baseCode)
|
||||
setHasPreview(true)
|
||||
|
||||
// Update height
|
||||
setTimeout(() => {
|
||||
updateContainerHeight()
|
||||
}, 0)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to render infographic:', error)
|
||||
setHasPreview(false)
|
||||
el.innerHTML = `<div class="text-red-500 p-4">Failed to render infographic: ${error instanceof Error ? error.message : 'Unknown error'}</div>`
|
||||
}
|
||||
}, [baseCode, updateContainerHeight, viewportReady])
|
||||
|
||||
useEffect(() => {
|
||||
const el = viewportTargetRef.current
|
||||
if (!el)
|
||||
return
|
||||
const handle = registerViewport(el, { rootMargin: '160px' })
|
||||
viewportHandleRef.current = handle
|
||||
if (handle.isVisible())
|
||||
setViewportReady(true)
|
||||
handle.whenVisible.then(() => setViewportReady(true))
|
||||
return () => {
|
||||
handle.destroy()
|
||||
viewportHandleRef.current = null
|
||||
}
|
||||
}, [registerViewport])
|
||||
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
if (viewportReady && !showSource && !isCollapsed) {
|
||||
renderInfographic()
|
||||
}
|
||||
}, [baseCode, isCollapsed, renderInfographic, showSource, viewportReady])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (instanceRef.current) {
|
||||
instanceRef.current.destroy?.()
|
||||
instanceRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Modal logic matching Vue implementation
|
||||
const closeModal = useCallback(() => {
|
||||
setModalOpen(false)
|
||||
if (modalContentRef.current)
|
||||
modalContentRef.current.innerHTML = ''
|
||||
modalCloneWrapperRef.current = null
|
||||
document.body.style.overflow = ''
|
||||
}, [])
|
||||
|
||||
const handleOpenModal = useCallback(() => {
|
||||
setModalOpen(true)
|
||||
document.body.style.overflow = 'hidden'
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!modalOpen)
|
||||
return
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape')
|
||||
closeModal()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [modalOpen, closeModal])
|
||||
|
||||
useEffect(() => {
|
||||
if (!modalOpen)
|
||||
return
|
||||
|
||||
// Defer to allow portal to mount
|
||||
setTimeout(() => {
|
||||
if (containerRef.current && modalContentRef.current) {
|
||||
modalContentRef.current.innerHTML = ''
|
||||
const wrapper = document.createElement('div')
|
||||
Object.assign(wrapper.style, {
|
||||
transition: 'transform 0.1s ease',
|
||||
transformOrigin: 'center center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
})
|
||||
|
||||
const clone = containerRef.current.cloneNode(true) as HTMLElement
|
||||
clone.classList.add('fullscreen')
|
||||
clone.style.height = 'auto'
|
||||
|
||||
wrapper.appendChild(clone)
|
||||
modalContentRef.current.appendChild(wrapper)
|
||||
modalCloneWrapperRef.current = wrapper
|
||||
|
||||
wrapper.style.transform = `translate(${translate.x}px, ${translate.y}px) scale(${zoom})`
|
||||
}
|
||||
}, 0)
|
||||
}, [modalOpen]) // Intentionally not dependent on zoom/translate to avoid re-cloning
|
||||
|
||||
// Sync transform
|
||||
useEffect(() => {
|
||||
if (modalOpen && modalCloneWrapperRef.current) {
|
||||
modalCloneWrapperRef.current.style.transform = `translate(${translate.x}px, ${translate.y}px) scale(${zoom})`
|
||||
}
|
||||
}, [modalOpen, translate, zoom])
|
||||
|
||||
const handleExport = useCallback(() => {
|
||||
const svg = containerRef.current?.querySelector('svg')
|
||||
if (!svg)
|
||||
return
|
||||
const data = new XMLSerializer().serializeToString(svg)
|
||||
const blob = new Blob([data], { type: 'image/svg+xml;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `infographic-${Date.now()}.svg`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}, [])
|
||||
|
||||
// Drag logic
|
||||
const onMouseDown = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
setIsDragging(true)
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : (e as React.MouseEvent).clientX
|
||||
const clientY = 'touches' in e ? e.touches[0].clientY : (e as React.MouseEvent).clientY
|
||||
dragStartRef.current = { x: clientX - translate.x, y: clientY - translate.y }
|
||||
}
|
||||
|
||||
const onMouseMove = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
if (!isDragging)
|
||||
return
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : (e as React.MouseEvent).clientX
|
||||
const clientY = 'touches' in e ? e.touches[0].clientY : (e as React.MouseEvent).clientY
|
||||
setTranslate({
|
||||
x: clientX - dragStartRef.current.x,
|
||||
y: clientY - dragStartRef.current.y,
|
||||
})
|
||||
}
|
||||
|
||||
const stopDrag = () => setIsDragging(false)
|
||||
|
||||
const handleSwitchMode = useCallback((target: 'preview' | 'source') => {
|
||||
setShowSource(target === 'source')
|
||||
}, [])
|
||||
|
||||
const isFullscreenDisabled = showSource || isCollapsed
|
||||
|
||||
const actionContext: InfographicBlockActionContext = useMemo(() => ({
|
||||
collapsed: isCollapsed,
|
||||
copied: copying,
|
||||
showSource,
|
||||
modalOpen,
|
||||
isDark: !!props.isDark,
|
||||
code: baseCode,
|
||||
isExportDisabled: isFullscreenDisabled,
|
||||
zoom,
|
||||
toggleCollapse: () => setIsCollapsed(v => !v),
|
||||
copy: handleCopy,
|
||||
exportSvg: handleExport,
|
||||
toggleFullscreen: () => modalOpen ? closeModal() : handleOpenModal(),
|
||||
switchMode: handleSwitchMode,
|
||||
zoomIn: () => setZoom(v => Math.min(v + 0.1, 3)),
|
||||
zoomOut: () => setZoom(v => Math.max(v - 0.1, 0.5)),
|
||||
resetZoom: () => { setZoom(1); setTranslate({ x: 0, y: 0 }) },
|
||||
}), [isCollapsed, copying, showSource, modalOpen, props.isDark, baseCode, isFullscreenDisabled, zoom, handleCopy, handleExport, closeModal, handleOpenModal, handleSwitchMode])
|
||||
|
||||
const computedButtonClass = 'infographic-action-btn p-2 text-xs rounded'
|
||||
|
||||
// JSX Structure mirroring Vue template
|
||||
|
||||
const defaultModeToggle = props.showModeToggle ? (
|
||||
<div className="infographic-mode-toggle flex items-center gap-x-1 rounded-md p-0.5">
|
||||
<button
|
||||
className={clsx('infographic-mode-btn px-2.5 py-1 text-xs rounded transition-colors', { active: !showSource })}
|
||||
onClick={() => setShowSource(false)}
|
||||
onMouseEnter={e => showTooltipForAnchor(e.currentTarget, t('common.preview') || 'Preview', 'top', false, undefined, props.isDark)}
|
||||
onMouseLeave={() => hideTooltip()}
|
||||
>
|
||||
<div className="flex items-center gap-x-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" className="w-3 h-3">
|
||||
<g fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="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
|
||||
className={clsx('infographic-mode-btn px-2.5 py-1 text-xs rounded transition-colors', { active: showSource })}
|
||||
onClick={() => setShowSource(true)}
|
||||
onMouseEnter={e => showTooltipForAnchor(e.currentTarget, t('common.source') || 'Source', 'top', false, undefined, props.isDark)}
|
||||
onMouseLeave={() => hideTooltip()}
|
||||
>
|
||||
<div className="flex items-center gap-x-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" className="w-3 h-3"><path fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="m16 18l6-6l-6-6M8 6l-6 6l6 6" /></svg>
|
||||
<span>{t('common.source') || 'Source'}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
) : null
|
||||
|
||||
const defaultActions = (
|
||||
<div className="flex items-center gap-x-1">
|
||||
{props.showCollapseButton && (
|
||||
<button
|
||||
className={computedButtonClass}
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
onMouseEnter={e => showTooltipForAnchor(e.currentTarget, isCollapsed ? (t('common.expand') || 'Expand') : (t('common.collapse') || 'Collapse'), 'top', false, undefined, props.isDark)}
|
||||
onMouseLeave={() => hideTooltip()}
|
||||
>
|
||||
<svg style={{ rotate: isCollapsed ? '0deg' : '90deg' }} xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" className="w-3 h-3"><path fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="m9 18l6-6l-6-6" /></svg>
|
||||
</button>
|
||||
)}
|
||||
{props.showCopyButton && (
|
||||
<button
|
||||
className={computedButtonClass}
|
||||
onClick={handleCopy}
|
||||
onMouseEnter={e => showTooltipForAnchor(e.currentTarget, copying ? (t('common.copied') || 'Copied') : (t('common.copy') || 'Copy'), 'top', false, undefined, props.isDark)}
|
||||
onMouseLeave={() => hideTooltip()}
|
||||
>
|
||||
{!copying
|
||||
? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" className="w-3 h-3">
|
||||
<g fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="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 xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" className="w-3 h-3"><path fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M20 6L9 17l-5-5" /></svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{props.showExportButton && (
|
||||
<button
|
||||
className={clsx(computedButtonClass, isFullscreenDisabled && 'opacity-50 cursor-not-allowed')}
|
||||
disabled={isFullscreenDisabled}
|
||||
onClick={handleExport}
|
||||
onMouseEnter={e => showTooltipForAnchor(e.currentTarget, t('common.export') || 'Export', 'top', false, undefined, props.isDark)}
|
||||
onMouseLeave={() => hideTooltip()}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" className="w-3 h-3">
|
||||
<g fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="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>
|
||||
)}
|
||||
{props.showFullscreenButton && (
|
||||
<button
|
||||
className={clsx(computedButtonClass, isFullscreenDisabled && 'opacity-50 cursor-not-allowed')}
|
||||
disabled={isFullscreenDisabled}
|
||||
onClick={handleOpenModal}
|
||||
onMouseEnter={e => showTooltipForAnchor(e.currentTarget, modalOpen ? (t('common.minimize') || 'Minimize') : (t('common.open') || 'Open'), 'top', false, undefined, props.isDark)}
|
||||
onMouseLeave={() => hideTooltip()}
|
||||
>
|
||||
{!modalOpen
|
||||
? <svg xmlns="http://www.w3.org/2000/svg" width="0.75rem" height="0.75rem" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 3h6v6m0-6l-7 7M3 21l7-7m-1 7H3v-6" /></svg>
|
||||
: <svg xmlns="http://www.w3.org/2000/svg" width="0.75rem" height="0.75rem" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="m14 10l7-7m-1 7h-6V4M3 21l7-7m-6 0h6v6" /></svg>}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const defaultZoomControls = (
|
||||
<div className="absolute top-2 right-2 z-10 rounded-lg">
|
||||
<div className="flex items-center gap-2 backdrop-blur rounded-lg">
|
||||
<button
|
||||
className="infographic-zoom-btn p-2 text-xs rounded transition-colors"
|
||||
onClick={() => setZoom(Math.min(zoom + 0.1, 3))}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
className="infographic-zoom-btn p-2 text-xs rounded transition-colors"
|
||||
onClick={() => setZoom(Math.max(zoom - 0.1, 0.5))}
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<button
|
||||
className="infographic-zoom-btn p-2 text-xs rounded transition-colors"
|
||||
onClick={() => {
|
||||
setZoom(1)
|
||||
setTranslate({ x: 0, y: 0 })
|
||||
}}
|
||||
>
|
||||
{Math.round(zoom * 100)}
|
||||
%
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const header = props.showHeader && (
|
||||
props.renderHeader ? props.renderHeader(actionContext) : (
|
||||
<div className="infographic-block-header flex justify-between items-center px-4 py-2.5 border-b">
|
||||
<div className="flex items-center gap-x-2 overflow-hidden">
|
||||
{INFOGRAPHIC_ICON}
|
||||
<span className="infographic-block-title text-sm font-medium font-mono truncate">Infographic</span>
|
||||
</div>
|
||||
{props.renderModeToggle ? props.renderModeToggle(actionContext) : defaultModeToggle}
|
||||
{props.renderHeaderActions ? props.renderHeaderActions(actionContext) : defaultActions}
|
||||
</div>
|
||||
)
|
||||
)
|
||||
|
||||
const body = (
|
||||
<div>
|
||||
{showSource
|
||||
? (
|
||||
<div className="infographic-block-source p-4">
|
||||
<pre className="text-sm font-mono whitespace-pre-wrap">{baseCode}</pre>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="relative">
|
||||
{props.showZoomControls && (
|
||||
props.renderZoomControls ? props.renderZoomControls(actionContext) : defaultZoomControls
|
||||
)}
|
||||
<div
|
||||
className="infographic-block-body min-h-[360px] relative transition-all duration-100 overflow-hidden block"
|
||||
style={{ height: containerHeight, maxHeight: props.maxHeight ?? undefined }}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseUp={stopDrag}
|
||||
onMouseLeave={stopDrag}
|
||||
onTouchStart={onMouseDown}
|
||||
onTouchMove={onMouseMove}
|
||||
onTouchEnd={stopDrag}
|
||||
>
|
||||
<div className={clsx('absolute inset-0 cursor-grab', isDragging && 'cursor-grabbing')} style={{ transform: `translate(${translate.x}px, ${translate.y}px) scale(${zoom})` }}>
|
||||
<div ref={containerRef} className="w-full text-center flex items-center justify-center min-h-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={viewportTargetRef}
|
||||
data-markstream-infographic="1"
|
||||
data-markstream-mode={showSource ? 'source' : hasPreview ? 'preview' : 'pending'}
|
||||
className={clsx(
|
||||
'my-4 rounded-lg border overflow-hidden shadow-sm infographic-block',
|
||||
{ 'is-dark': props.isDark, 'is-rendering': props.loading },
|
||||
)}
|
||||
>
|
||||
{header}
|
||||
{!isCollapsed && body}
|
||||
</div>
|
||||
|
||||
{modalOpen && typeof document !== 'undefined' && createPortal(
|
||||
<div className="markstream-react infographic-modal-overlay fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4" onClick={closeModal} role="dialog" aria-modal="true">
|
||||
<div className={clsx('infographic-modal-panel dialog-panel relative w-full h-full max-w-full max-h-full rounded shadow-lg overflow-hidden', { 'is-dark': props.isDark })} onClick={e => e.stopPropagation()}>
|
||||
<div className="absolute top-6 right-6 z-50 flex items-center gap-2">
|
||||
<button className="infographic-zoom-btn p-2 text-xs rounded transition-colors" onClick={() => setZoom(Math.min(zoom + 0.1, 3))}>+</button>
|
||||
<button className="infographic-zoom-btn p-2 text-xs rounded transition-colors" onClick={() => setZoom(Math.max(zoom - 0.1, 0.5))}>-</button>
|
||||
<button
|
||||
className="infographic-zoom-btn p-2 text-xs rounded transition-colors"
|
||||
onClick={() => {
|
||||
setZoom(1)
|
||||
setTranslate({ x: 0, y: 0 })
|
||||
}}
|
||||
>
|
||||
{Math.round(zoom * 100)}
|
||||
%
|
||||
</button>
|
||||
<button className="infographic-action-btn inline-flex items-center justify-center p-2 rounded transition-colors" onClick={closeModal}>X</button>
|
||||
</div>
|
||||
<div
|
||||
ref={modalContentRef}
|
||||
className={clsx('w-full h-full flex items-center justify-center p-4 overflow-hidden', !isDragging && 'cursor-grab', isDragging && 'cursor-grabbing')}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseUp={stopDrag}
|
||||
onMouseLeave={stopDrag}
|
||||
onTouchStart={onMouseDown}
|
||||
onTouchMove={onMouseMove}
|
||||
onTouchEnd={stopDrag}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export const INFOGRAPHIC_PREVIEW_MIN_HEIGHT = 360
|
||||
export const INFOGRAPHIC_PREVIEW_MAX_HEIGHT = 500
|
||||
|
||||
export function parsePositiveNumber(value: unknown) {
|
||||
const numeric = typeof value === 'number' ? value : Number.parseFloat(String(value ?? ''))
|
||||
return Number.isFinite(numeric) && numeric > 0 ? numeric : null
|
||||
}
|
||||
|
||||
export function estimateInfographicPreviewHeight(code: string) {
|
||||
const itemCount = code
|
||||
.split(/\r?\n/)
|
||||
.filter(line => /^\s*-\s+/.test(line))
|
||||
.length
|
||||
|
||||
if (itemCount >= 3)
|
||||
return INFOGRAPHIC_PREVIEW_MAX_HEIGHT
|
||||
if (itemCount > 0)
|
||||
return 280 + itemCount * 60
|
||||
|
||||
return INFOGRAPHIC_PREVIEW_MIN_HEIGHT
|
||||
}
|
||||
|
||||
export function clampInfographicPreviewHeight(
|
||||
height: number,
|
||||
minHeight = INFOGRAPHIC_PREVIEW_MIN_HEIGHT,
|
||||
maxHeight: number | null = INFOGRAPHIC_PREVIEW_MAX_HEIGHT,
|
||||
) {
|
||||
return maxHeight == null
|
||||
? Math.max(minHeight, height)
|
||||
: Math.min(Math.max(minHeight, height), maxHeight)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { InfographicBlockNode } from './InfographicBlockNode'
|
||||
export { InfographicBlockNode as default } from './InfographicBlockNode'
|
||||
@@ -0,0 +1,35 @@
|
||||
let infographicPromise: Promise<any> | null = null
|
||||
let infographicInstance: any = null
|
||||
|
||||
export async function getInfographic() {
|
||||
if (infographicInstance)
|
||||
return infographicInstance
|
||||
if (infographicPromise)
|
||||
return infographicPromise
|
||||
|
||||
infographicPromise = import('@antv/infographic')
|
||||
.then((mod) => {
|
||||
// Normalize module
|
||||
const defaultExport = (mod && (mod as any).default) ? (mod as any).default : mod
|
||||
|
||||
let resolved = defaultExport
|
||||
|
||||
if (typeof defaultExport === 'function' && defaultExport.prototype && defaultExport.prototype.render) {
|
||||
resolved = defaultExport
|
||||
}
|
||||
else if (mod.Infographic) {
|
||||
resolved = mod.Infographic
|
||||
}
|
||||
else if (defaultExport && defaultExport.Infographic) {
|
||||
resolved = defaultExport.Infographic
|
||||
}
|
||||
|
||||
infographicInstance = resolved
|
||||
return resolved
|
||||
})
|
||||
.catch((e) => {
|
||||
console.warn('Failed to load @antv/infographic', e)
|
||||
return null
|
||||
})
|
||||
return infographicPromise
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { NodeComponentProps } from '../../types/node-component'
|
||||
import clsx from 'clsx'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { resolveStreamingTextUpdate } from '../../utils/streamingTextState'
|
||||
|
||||
export function InlineCodeNode(props: NodeComponentProps<{ type: 'inline_code', code: string }>) {
|
||||
const { node, children, ctx, indexKey, typewriter } = props
|
||||
const content = String(node.code ?? '')
|
||||
const typewriterEnabled = typewriter ?? ctx?.typewriter ?? true
|
||||
const streamStateKey = indexKey == null || indexKey === ''
|
||||
? ''
|
||||
: String(indexKey)
|
||||
const [settledContent, setSettledContent] = useState(content)
|
||||
const [streamedDelta, setStreamedDelta] = useState('')
|
||||
const [streamFadeVersion, setStreamFadeVersion] = useState(0)
|
||||
const renderedContentRef = useRef({
|
||||
settledContent: content,
|
||||
streamedDelta: '',
|
||||
})
|
||||
|
||||
const setRenderedContent = (nextSettledContent: string, nextStreamedDelta: string) => {
|
||||
renderedContentRef.current = {
|
||||
settledContent: nextSettledContent,
|
||||
streamedDelta: nextStreamedDelta,
|
||||
}
|
||||
setSettledContent(nextSettledContent)
|
||||
setStreamedDelta(nextStreamedDelta)
|
||||
}
|
||||
|
||||
const getRenderedContent = () => {
|
||||
const { settledContent, streamedDelta } = renderedContentRef.current
|
||||
return settledContent + streamedDelta
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (children != null) {
|
||||
setRenderedContent('', '')
|
||||
return
|
||||
}
|
||||
|
||||
const textStreamState = ctx?.textStreamState
|
||||
const currentState = renderedContentRef.current
|
||||
const persistedContent = streamStateKey
|
||||
? textStreamState?.get(streamStateKey)
|
||||
: undefined
|
||||
const nextState = resolveStreamingTextUpdate({
|
||||
nextContent: content,
|
||||
persistedContent,
|
||||
currentState,
|
||||
typewriterEnabled,
|
||||
})
|
||||
|
||||
setRenderedContent(nextState.settledContent, nextState.streamedDelta)
|
||||
if (nextState.appended)
|
||||
setStreamFadeVersion(version => version + 1)
|
||||
if (streamStateKey)
|
||||
textStreamState?.set(streamStateKey, content)
|
||||
}, [children, content, ctx?.textStreamState, streamStateKey, typewriterEnabled])
|
||||
|
||||
const handleStreamedDeltaAnimationEnd = () => {
|
||||
if (!renderedContentRef.current.streamedDelta)
|
||||
return
|
||||
setRenderedContent(getRenderedContent(), '')
|
||||
}
|
||||
|
||||
return (
|
||||
<code className="inline-code inline text-[85%] px-1 py-0.5 rounded font-mono bg-[hsl(var(--secondary))] whitespace-normal break-words max-w-full">
|
||||
{children || (
|
||||
<>
|
||||
{settledContent ? <span>{settledContent}</span> : null}
|
||||
{streamedDelta
|
||||
? (
|
||||
<span
|
||||
className={clsx(
|
||||
'text-node-stream-delta',
|
||||
streamFadeVersion % 2 === 0
|
||||
? 'text-node-stream-delta--a'
|
||||
: 'text-node-stream-delta--b',
|
||||
)}
|
||||
onAnimationEnd={handleStreamedDeltaAnimationEnd}
|
||||
>
|
||||
{streamedDelta}
|
||||
</span>
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
)}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
|
||||
export default InlineCodeNode
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { NodeComponentProps } from '../../types/node-component'
|
||||
import React from 'react'
|
||||
|
||||
export function InsertNode(props: NodeComponentProps<{ type: 'insert' }>) {
|
||||
return <ins className="insert-node">{props.children}</ins>
|
||||
}
|
||||
|
||||
export default InsertNode
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { ParsedNode } from 'stream-markdown-parser'
|
||||
import type { NodeComponentProps } from '../../types/node-component'
|
||||
import React, { useCallback, useMemo } from 'react'
|
||||
import { renderNodeChildren } from '../../renderers/renderChildren'
|
||||
import { hideTooltip, showTooltipForAnchor } from '../../tooltip/singletonTooltip'
|
||||
import { TextNode } from '../TextNode/TextNode'
|
||||
|
||||
export interface LinkNodeStyleProps {
|
||||
showTooltip?: boolean
|
||||
color?: string
|
||||
underlineHeight?: number
|
||||
underlineBottom?: number | string
|
||||
animationDuration?: number
|
||||
animationOpacity?: number
|
||||
animationTiming?: string
|
||||
animationIteration?: string | number
|
||||
}
|
||||
|
||||
export function LinkNode(props: NodeComponentProps<{
|
||||
type: 'link'
|
||||
href: string
|
||||
title: string | null
|
||||
text: string
|
||||
children?: ParsedNode[]
|
||||
loading?: boolean
|
||||
}> & LinkNodeStyleProps) {
|
||||
const { node, ctx, renderNode, indexKey } = props
|
||||
const showTip = props.showTooltip !== false
|
||||
const isDark = props.isDark ?? ctx?.isDark
|
||||
|
||||
const cssVars = useMemo(() => {
|
||||
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' as any]: props.color ?? '#0366d6',
|
||||
['--underline-height' as any]: `${props.underlineHeight ?? 2}px`,
|
||||
['--underline-bottom' as any]: bottom,
|
||||
['--underline-opacity' as any]: String(activeOpacity),
|
||||
['--underline-rest-opacity' as any]: String(restingOpacity),
|
||||
['--underline-duration' as any]: `${props.animationDuration ?? 1.6}s`,
|
||||
['--underline-timing' as any]: props.animationTiming ?? 'ease-in-out',
|
||||
['--underline-iteration' as any]: typeof props.animationIteration === 'number'
|
||||
? String(props.animationIteration)
|
||||
: (props.animationIteration ?? 'infinite'),
|
||||
} as React.CSSProperties
|
||||
}, [
|
||||
props.animationDuration,
|
||||
props.animationIteration,
|
||||
props.animationOpacity,
|
||||
props.animationTiming,
|
||||
props.color,
|
||||
props.underlineBottom,
|
||||
props.underlineHeight,
|
||||
])
|
||||
|
||||
const title = typeof node.title === 'string' && node.title.trim().length > 0
|
||||
? node.title
|
||||
: String(node.href ?? '')
|
||||
|
||||
const onAnchorEnter = useCallback((event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (!showTip)
|
||||
return
|
||||
const target = event.currentTarget as HTMLElement | null
|
||||
const txt = String(node.title || node.href || node.text || '')
|
||||
showTooltipForAnchor(target, txt, 'top', false, { x: event.clientX, y: event.clientY }, isDark)
|
||||
}, [isDark, node.href, node.text, node.title, showTip])
|
||||
|
||||
const onAnchorLeave = useCallback(() => {
|
||||
if (!showTip)
|
||||
return
|
||||
hideTooltip()
|
||||
}, [showTip])
|
||||
|
||||
if (node.loading) {
|
||||
return (
|
||||
<span
|
||||
className="link-loading inline-flex items-baseline gap-1.5"
|
||||
aria-hidden="false"
|
||||
style={cssVars}
|
||||
>
|
||||
<span className="link-text-wrapper relative inline-flex">
|
||||
<span className="leading-[normal] link-text">
|
||||
<TextNode
|
||||
node={{ type: 'text', content: String(node.text ?? '') }}
|
||||
ctx={ctx}
|
||||
indexKey={`${String(indexKey ?? 'link')}-loading`}
|
||||
typewriter={props.typewriter}
|
||||
/>
|
||||
</span>
|
||||
<span className="link-loading-indicator" aria-hidden="true" />
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
className="link-node"
|
||||
href={node.href}
|
||||
title={showTip ? '' : title}
|
||||
aria-label={`Link: ${title}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={cssVars}
|
||||
onMouseEnter={onAnchorEnter}
|
||||
onMouseLeave={onAnchorLeave}
|
||||
>
|
||||
{ctx && renderNode
|
||||
? renderNodeChildren(node.children, ctx, String(indexKey ?? 'link'), renderNode)
|
||||
: (
|
||||
<TextNode
|
||||
node={{ type: 'text', content: String(node.text ?? '') }}
|
||||
ctx={ctx}
|
||||
indexKey={`${String(indexKey ?? 'link')}-fallback`}
|
||||
typewriter={props.typewriter}
|
||||
/>
|
||||
)}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export default LinkNode
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ParsedNode } from 'stream-markdown-parser'
|
||||
import type { NodeComponentProps } from '../../types/node-component'
|
||||
import React from 'react'
|
||||
import { renderNodeChildren } from '../../renderers/renderChildren'
|
||||
|
||||
export interface ListItemNodeData {
|
||||
type: 'list_item'
|
||||
children?: ParsedNode[]
|
||||
raw?: string
|
||||
}
|
||||
|
||||
export interface ListItemNodeProps extends NodeComponentProps<ListItemNodeData> {
|
||||
value?: number
|
||||
}
|
||||
|
||||
export function ListItemNode(props: ListItemNodeProps) {
|
||||
const { node, ctx, renderNode, value, indexKey, children } = props
|
||||
const valueAttr = value == null ? undefined : value
|
||||
return (
|
||||
<li className="list-item pl-1.5 my-2" dir="auto" value={valueAttr}>
|
||||
{children ?? (ctx && renderNode
|
||||
? renderNodeChildren(node.children, ctx, String(indexKey ?? 'list-item'), renderNode)
|
||||
: null)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default ListItemNode
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { NodeComponentProps } from '../../types/node-component'
|
||||
import clsx from 'clsx'
|
||||
import React from 'react'
|
||||
import { getCustomNodeComponents } from '../../customComponents'
|
||||
import { ListItemNode } from '../ListItemNode/ListItemNode'
|
||||
|
||||
export function ListNode(props: NodeComponentProps<{ type: 'list', ordered?: boolean, start?: number, items?: any[] }>) {
|
||||
const { node, ctx, renderNode, indexKey } = props
|
||||
const Tag = node.ordered ? 'ol' : 'ul'
|
||||
const startAttr = node.ordered && node.start ? node.start : undefined
|
||||
const ListItemComponent = ((ctx && getCustomNodeComponents(ctx.customId).list_item) || ListItemNode) as any
|
||||
return (
|
||||
<Tag
|
||||
className={clsx(
|
||||
'list-node my-5 pl-[calc(13/8*1em)]',
|
||||
node.ordered ? 'list-decimal' : 'list-disc max-lg:my-[calc(4/3*1em)] max-lg:pl-[calc(14/9*1em)]',
|
||||
)}
|
||||
start={startAttr}
|
||||
>
|
||||
{node.items?.map((item: any, idx: number) => (
|
||||
<ListItemComponent
|
||||
key={`${String(indexKey ?? 'list')}-${idx}`}
|
||||
node={item}
|
||||
value={node.ordered ? (node.start ?? 1) + idx : undefined}
|
||||
ctx={ctx}
|
||||
renderNode={renderNode}
|
||||
indexKey={`${String(indexKey ?? 'list')}-${idx}`}
|
||||
customId={ctx?.customId}
|
||||
isDark={ctx?.isDark}
|
||||
typewriter={ctx?.typewriter}
|
||||
/>
|
||||
))}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
|
||||
export default ListNode
|
||||
@@ -0,0 +1,620 @@
|
||||
import type { VisibilityHandle } from '../../context/viewportPriority'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useViewportPriority } from '../../context/viewportPriority'
|
||||
import { useSafeI18n } from '../../i18n/useSafeI18n'
|
||||
import { hideTooltip, showTooltipForAnchor } from '../../tooltip/singletonTooltip'
|
||||
import { getLanguageIcon, languageMap, normalizeLanguageIdentifier, subscribeLanguageIconsRevision } from '../../utils/languageIcon'
|
||||
|
||||
export interface MarkdownCodeBlockNodeProps {
|
||||
node: {
|
||||
type: 'code_block'
|
||||
language: string
|
||||
code: string
|
||||
raw: string
|
||||
diff?: boolean
|
||||
originalCode?: string
|
||||
updatedCode?: string
|
||||
}
|
||||
loading?: boolean
|
||||
stream?: boolean
|
||||
darkTheme?: string
|
||||
lightTheme?: string
|
||||
isDark?: boolean
|
||||
isShowPreview?: boolean
|
||||
enableFontSizeControl?: boolean
|
||||
minWidth?: string | number
|
||||
maxWidth?: string | number
|
||||
themes?: string[]
|
||||
showHeader?: boolean
|
||||
showCopyButton?: boolean
|
||||
showExpandButton?: boolean
|
||||
showPreviewButton?: boolean
|
||||
showCollapseButton?: boolean
|
||||
showFontSizeButtons?: boolean
|
||||
showTooltips?: boolean
|
||||
onCopy?: (code: string) => void
|
||||
onPreviewCode?: (payload: { type: string, content: string, title: string }) => void
|
||||
}
|
||||
|
||||
interface ShikiRenderer {
|
||||
updateCode: (code: string, lang?: string) => void | Promise<void>
|
||||
setTheme: (theme?: string) => void | Promise<void>
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
function escapeHtml(str: string) {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function normalizeRendererLanguage(rawLang?: string | null) {
|
||||
const [baseToken] = String(rawLang ?? '').split(':')
|
||||
const normalized = baseToken?.trim().toLowerCase() ?? ''
|
||||
return normalized || 'plaintext'
|
||||
}
|
||||
|
||||
export function MarkdownCodeBlockNode(rawProps: MarkdownCodeBlockNodeProps) {
|
||||
const props: Required<Pick<
|
||||
MarkdownCodeBlockNodeProps,
|
||||
| 'loading'
|
||||
| 'stream'
|
||||
| 'darkTheme'
|
||||
| 'lightTheme'
|
||||
| 'isDark'
|
||||
| 'isShowPreview'
|
||||
| 'enableFontSizeControl'
|
||||
| 'showHeader'
|
||||
| 'showCopyButton'
|
||||
| 'showExpandButton'
|
||||
| 'showPreviewButton'
|
||||
| 'showCollapseButton'
|
||||
| 'showFontSizeButtons'
|
||||
>> & MarkdownCodeBlockNodeProps = {
|
||||
loading: true,
|
||||
stream: true,
|
||||
darkTheme: 'vitesse-dark',
|
||||
lightTheme: 'vitesse-light',
|
||||
isDark: false,
|
||||
isShowPreview: true,
|
||||
enableFontSizeControl: true,
|
||||
showHeader: true,
|
||||
showCopyButton: true,
|
||||
showExpandButton: true,
|
||||
showPreviewButton: true,
|
||||
showCollapseButton: true,
|
||||
showFontSizeButtons: true,
|
||||
...rawProps,
|
||||
}
|
||||
|
||||
const codeLanguage = useMemo(() => String(props.node.language ?? ''), [props.node.language])
|
||||
const normalizedLanguage = useMemo(() => normalizeRendererLanguage(codeLanguage), [codeLanguage])
|
||||
const canonicalLanguage = useMemo(() => normalizeLanguageIdentifier(codeLanguage), [codeLanguage])
|
||||
const [languageIconsRevision, setLanguageIconsRevision] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeLanguageIconsRevision(() => {
|
||||
setLanguageIconsRevision(v => v + 1)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const displayLanguage = useMemo(() => {
|
||||
const label = languageMap[canonicalLanguage] || canonicalLanguage
|
||||
if (!label)
|
||||
return 'Text'
|
||||
return label.charAt(0).toUpperCase() + label.slice(1)
|
||||
}, [canonicalLanguage])
|
||||
const languageIcon = useMemo(
|
||||
() => getLanguageIcon(canonicalLanguage),
|
||||
[canonicalLanguage, languageIconsRevision],
|
||||
)
|
||||
|
||||
const { t } = useSafeI18n()
|
||||
|
||||
const isPreviewable = useMemo(() => {
|
||||
if (!props.isShowPreview)
|
||||
return false
|
||||
return canonicalLanguage === 'html' || canonicalLanguage === 'svg'
|
||||
}, [canonicalLanguage, props.isShowPreview])
|
||||
|
||||
const containerStyle = useMemo(() => {
|
||||
const style: 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)
|
||||
style.minWidth = min
|
||||
if (max)
|
||||
style.maxWidth = max
|
||||
return style
|
||||
}, [props.maxWidth, props.minWidth])
|
||||
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [isCollapsed, setIsCollapsed] = useState(false)
|
||||
const [fallbackHtml, setFallbackHtml] = useState('')
|
||||
const [rendererReady, setRendererReady] = useState(false)
|
||||
|
||||
const [defaultFontSize, setDefaultFontSize] = useState<number>(14)
|
||||
const [fontSize, setFontSize] = useState<number>(defaultFontSize)
|
||||
const tooltipsEnabled = useMemo(() => props.showTooltips !== false, [props.showTooltips])
|
||||
|
||||
const viewportTargetRef = useRef<HTMLDivElement | null>(null)
|
||||
const codeBlockContentRef = useRef<HTMLDivElement | null>(null)
|
||||
const rendererTargetRef = useRef<HTMLDivElement | null>(null)
|
||||
const rendererRef = useRef<ShikiRenderer | null>(null)
|
||||
const createRendererRef = useRef<null | ((el: HTMLElement, opts: { theme?: string | undefined, themes?: string[] | undefined }) => ShikiRenderer)>(null)
|
||||
const importAttemptedRef = useRef(false)
|
||||
const viewportHandleRef = useRef<VisibilityHandle | null>(null)
|
||||
const registerViewport = useViewportPriority()
|
||||
const [viewportReady, setViewportReady] = useState(() => typeof window === 'undefined')
|
||||
|
||||
const getPreferredColorScheme = useCallback(() => {
|
||||
return props.isDark ? props.darkTheme : props.lightTheme
|
||||
}, [props.darkTheme, props.isDark, props.lightTheme])
|
||||
|
||||
const renderFallback = useCallback((code: string) => {
|
||||
if (!code) {
|
||||
setFallbackHtml('')
|
||||
setRendererReady(false)
|
||||
return
|
||||
}
|
||||
setFallbackHtml(`<pre class="shiki shiki-fallback"><code>${escapeHtml(code)}</code></pre>`)
|
||||
setRendererReady(false)
|
||||
}, [])
|
||||
|
||||
const clearFallback = useCallback(() => {
|
||||
setFallbackHtml('')
|
||||
setRendererReady(true)
|
||||
}, [])
|
||||
|
||||
const ensureStreamMarkdownLoaded = useCallback(async () => {
|
||||
if (createRendererRef.current || importAttemptedRef.current)
|
||||
return
|
||||
importAttemptedRef.current = true
|
||||
try {
|
||||
const mod: any = await import('stream-markdown')
|
||||
createRendererRef.current = mod.createShikiStreamRenderer
|
||||
}
|
||||
catch {
|
||||
// optional peer
|
||||
}
|
||||
}, [])
|
||||
|
||||
const initRenderer = useCallback(async () => {
|
||||
if (!viewportReady) {
|
||||
renderFallback(props.node.code)
|
||||
return
|
||||
}
|
||||
|
||||
await ensureStreamMarkdownLoaded()
|
||||
|
||||
if (!codeBlockContentRef.current || !rendererTargetRef.current) {
|
||||
renderFallback(props.node.code)
|
||||
return
|
||||
}
|
||||
|
||||
if (!rendererRef.current && createRendererRef.current) {
|
||||
rendererRef.current = createRendererRef.current(rendererTargetRef.current, {
|
||||
theme: getPreferredColorScheme(),
|
||||
themes: props.themes,
|
||||
})
|
||||
setRendererReady(true)
|
||||
}
|
||||
|
||||
if (!rendererRef.current) {
|
||||
renderFallback(props.node.code)
|
||||
return
|
||||
}
|
||||
|
||||
if (props.stream === false && props.loading) {
|
||||
renderFallback(props.node.code)
|
||||
return
|
||||
}
|
||||
|
||||
renderFallback(props.node.code)
|
||||
try {
|
||||
await rendererRef.current.updateCode(props.node.code, normalizedLanguage)
|
||||
clearFallback()
|
||||
}
|
||||
catch {
|
||||
// keep fallback
|
||||
}
|
||||
}, [clearFallback, ensureStreamMarkdownLoaded, getPreferredColorScheme, normalizedLanguage, props.loading, props.node.code, props.stream, props.themes, renderFallback, viewportReady])
|
||||
|
||||
useEffect(() => {
|
||||
const el = viewportTargetRef.current
|
||||
if (!el)
|
||||
return
|
||||
const handle = registerViewport(el, { rootMargin: '400px' })
|
||||
viewportHandleRef.current = handle
|
||||
if (handle.isVisible())
|
||||
setViewportReady(true)
|
||||
handle.whenVisible.then(() => setViewportReady(true))
|
||||
return () => {
|
||||
handle.destroy()
|
||||
viewportHandleRef.current = null
|
||||
}
|
||||
}, [registerViewport])
|
||||
|
||||
useEffect(() => {
|
||||
void initRenderer()
|
||||
return () => {
|
||||
rendererRef.current?.dispose()
|
||||
rendererRef.current = null
|
||||
}
|
||||
}, [initRenderer])
|
||||
|
||||
useEffect(() => {
|
||||
if (!rendererRef.current)
|
||||
return
|
||||
if (!viewportReady)
|
||||
return
|
||||
void rendererRef.current.setTheme(getPreferredColorScheme())
|
||||
}, [getPreferredColorScheme, viewportReady])
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewportReady) {
|
||||
renderFallback(props.node.code)
|
||||
return
|
||||
}
|
||||
if (!rendererRef.current)
|
||||
return
|
||||
if (props.stream === false && props.loading)
|
||||
return
|
||||
renderFallback(props.node.code)
|
||||
Promise.resolve(rendererRef.current.updateCode(props.node.code, normalizedLanguage))
|
||||
.then(() => clearFallback())
|
||||
.catch(() => {})
|
||||
}, [clearFallback, normalizedLanguage, props.loading, props.node.code, props.stream, renderFallback, viewportReady])
|
||||
|
||||
useEffect(() => {
|
||||
if (!tooltipsEnabled)
|
||||
hideTooltip(true)
|
||||
}, [tooltipsEnabled])
|
||||
|
||||
const onBtnHover = useCallback((event: React.MouseEvent<HTMLElement> | React.FocusEvent<HTMLElement>, text: string) => {
|
||||
if (!tooltipsEnabled)
|
||||
return
|
||||
const btn = event.currentTarget as unknown as HTMLElement
|
||||
if (!btn || (btn as HTMLButtonElement).disabled)
|
||||
return
|
||||
const origin = 'clientX' in event
|
||||
? { x: event.clientX, y: event.clientY }
|
||||
: undefined
|
||||
showTooltipForAnchor(btn, text, 'top', false, origin, props.isDark)
|
||||
}, [props.isDark, tooltipsEnabled])
|
||||
|
||||
const onBtnLeave = useCallback(() => {
|
||||
if (!tooltipsEnabled)
|
||||
return
|
||||
hideTooltip()
|
||||
}, [tooltipsEnabled])
|
||||
|
||||
const copy = useCallback(async () => {
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function')
|
||||
await navigator.clipboard.writeText(props.node.code)
|
||||
setCopied(true)
|
||||
props.onCopy?.(props.node.code)
|
||||
setTimeout(() => setCopied(false), 1000)
|
||||
}
|
||||
catch {}
|
||||
}, [props])
|
||||
|
||||
const previewCode = useCallback(() => {
|
||||
if (!isPreviewable)
|
||||
return
|
||||
const lowerLang = normalizedLanguage
|
||||
const artifactType = lowerLang === 'html' ? 'text/html' : 'image/svg+xml'
|
||||
const artifactTitle = lowerLang === 'html' ? 'HTML Preview' : 'SVG Preview'
|
||||
props.onPreviewCode?.({
|
||||
type: artifactType,
|
||||
content: props.node.code,
|
||||
title: artifactTitle,
|
||||
})
|
||||
}, [isPreviewable, normalizedLanguage, props])
|
||||
|
||||
const contentStyle = useMemo(() => ({ fontSize: `${fontSize}px` }), [fontSize])
|
||||
|
||||
const decreaseCodeFont = () => setFontSize(s => Math.max(10, s - 1))
|
||||
const increaseCodeFont = () => setFontSize(s => Math.min(36, s + 1))
|
||||
const resetCodeFont = () => setFontSize(defaultFontSize)
|
||||
|
||||
useEffect(() => {
|
||||
const initial = 14
|
||||
setDefaultFontSize(initial)
|
||||
setFontSize(initial)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={viewportTargetRef}
|
||||
className={[
|
||||
'code-block-container my-4 rounded-lg border overflow-hidden shadow-sm',
|
||||
props.isDark ? 'border-gray-700/30 bg-gray-900 is-dark' : 'border-gray-200 bg-white',
|
||||
].join(' ')}
|
||||
style={containerStyle}
|
||||
>
|
||||
{props.showHeader && (
|
||||
<div
|
||||
className="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)', backgroundColor: 'var(--vscode-editor-background)' }}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span
|
||||
className="icon-slot h-4 w-4 flex-shrink-0"
|
||||
// language icons are trusted internal assets or user-supplied via resolver
|
||||
dangerouslySetInnerHTML={{ __html: languageIcon }}
|
||||
/>
|
||||
<span className="text-sm font-medium font-mono">{displayLanguage}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{props.showCollapseButton && (
|
||||
<button
|
||||
type="button"
|
||||
className="code-action-btn p-2 text-xs rounded-md transition-colors hover:bg-[var(--vscode-editor-selectionBackground)]"
|
||||
aria-pressed={isCollapsed}
|
||||
onClick={() => setIsCollapsed(v => !v)}
|
||||
onMouseEnter={e => onBtnHover(e, isCollapsed ? (t('common.expand') || 'Expand') : (t('common.collapse') || 'Collapse'))}
|
||||
onFocus={e => onBtnHover(e as any, isCollapsed ? (t('common.expand') || 'Expand') : (t('common.collapse') || 'Collapse'))}
|
||||
onMouseLeave={onBtnLeave}
|
||||
onBlur={onBtnLeave}
|
||||
>
|
||||
<svg
|
||||
style={{ rotate: isCollapsed ? '0deg' : '90deg' }}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
className="w-3 h-3"
|
||||
>
|
||||
<path fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="m9 18l6-6l-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{props.showFontSizeButtons && props.enableFontSizeControl && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="code-action-btn p-2 text-xs rounded-md transition-colors hover:bg-[var(--vscode-editor-selectionBackground)]"
|
||||
disabled={fontSize <= 10}
|
||||
onClick={decreaseCodeFont}
|
||||
onMouseEnter={e => onBtnHover(e, t('common.decrease') || 'Decrease')}
|
||||
onFocus={e => onBtnHover(e as any, t('common.decrease') || 'Decrease')}
|
||||
onMouseLeave={onBtnLeave}
|
||||
onBlur={onBtnLeave}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
className="w-3 h-3"
|
||||
>
|
||||
<path fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 12h14" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="code-action-btn p-2 text-xs rounded-md transition-colors hover:bg-[var(--vscode-editor-selectionBackground)]"
|
||||
disabled={fontSize === defaultFontSize}
|
||||
onClick={resetCodeFont}
|
||||
onMouseEnter={e => onBtnHover(e, t('common.reset') || 'Reset')}
|
||||
onFocus={e => onBtnHover(e as any, t('common.reset') || 'Reset')}
|
||||
onMouseLeave={onBtnLeave}
|
||||
onBlur={onBtnLeave}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
className="w-3 h-3"
|
||||
>
|
||||
<g fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="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"
|
||||
className="code-action-btn p-2 text-xs rounded-md transition-colors hover:bg-[var(--vscode-editor-selectionBackground)]"
|
||||
disabled={fontSize >= 36}
|
||||
onClick={increaseCodeFont}
|
||||
onMouseEnter={e => onBtnHover(e, t('common.increase') || 'Increase')}
|
||||
onFocus={e => onBtnHover(e as any, t('common.increase') || 'Increase')}
|
||||
onMouseLeave={onBtnLeave}
|
||||
onBlur={onBtnLeave}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
className="w-3 h-3"
|
||||
>
|
||||
<path fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 12h14m-7-7v14" />
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{props.showCopyButton && (
|
||||
<button
|
||||
type="button"
|
||||
className="code-action-btn p-2 text-xs rounded-md transition-colors hover:bg-[var(--vscode-editor-selectionBackground)]"
|
||||
aria-label={copied ? (t('common.copied') || 'Copied') : (t('common.copy') || 'Copy')}
|
||||
onClick={copy}
|
||||
onMouseEnter={e => onBtnHover(e, copied ? (t('common.copied') || 'Copied') : (t('common.copy') || 'Copy'))}
|
||||
onFocus={e => onBtnHover(e as any, copied ? (t('common.copied') || 'Copied') : (t('common.copy') || 'Copy'))}
|
||||
onMouseLeave={onBtnLeave}
|
||||
onBlur={onBtnLeave}
|
||||
>
|
||||
{!copied
|
||||
? (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
className="w-3 h-3"
|
||||
>
|
||||
<g fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="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
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
className="w-3 h-3"
|
||||
>
|
||||
<path fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M20 6L9 17l-5-5" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{props.showExpandButton && (
|
||||
<button
|
||||
type="button"
|
||||
className="code-action-btn p-2 text-xs rounded-md transition-colors hover:bg-[var(--vscode-editor-selectionBackground)]"
|
||||
aria-pressed={isExpanded}
|
||||
onClick={(e) => {
|
||||
setIsExpanded(v => !v)
|
||||
onBtnHover(e, !isExpanded ? (t('common.collapse') || 'Collapse') : (t('common.expand') || 'Expand'))
|
||||
}}
|
||||
onMouseEnter={e => onBtnHover(e, isExpanded ? (t('common.collapse') || 'Collapse') : (t('common.expand') || 'Expand'))}
|
||||
onFocus={e => onBtnHover(e as any, isExpanded ? (t('common.collapse') || 'Collapse') : (t('common.expand') || 'Expand'))}
|
||||
onMouseLeave={onBtnLeave}
|
||||
onBlur={onBtnLeave}
|
||||
>
|
||||
{isExpanded
|
||||
? (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
className="w-3 h-3"
|
||||
>
|
||||
<path fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="m14 10l7-7m-1 7h-6V4M3 21l7-7m-6 0h6v6" />
|
||||
</svg>
|
||||
)
|
||||
: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
className="w-3 h-3"
|
||||
>
|
||||
<path fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 3h6v6m0-6l-7 7M3 21l7-7m-1 7H3v-6" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isPreviewable && props.showPreviewButton && (
|
||||
<button
|
||||
type="button"
|
||||
className="code-action-btn p-2 text-xs rounded-md transition-colors hover:bg-[var(--vscode-editor-selectionBackground)]"
|
||||
aria-label={t('common.preview') || 'Preview'}
|
||||
onClick={previewCode}
|
||||
onMouseEnter={e => onBtnHover(e, t('common.preview') || 'Preview')}
|
||||
onFocus={e => onBtnHover(e as any, t('common.preview') || 'Preview')}
|
||||
onMouseLeave={onBtnLeave}
|
||||
onBlur={onBtnLeave}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
className="w-3 h-3"
|
||||
>
|
||||
<g fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCollapsed && (props.stream ? true : !props.loading) && (
|
||||
<div
|
||||
ref={codeBlockContentRef}
|
||||
className="code-block-content"
|
||||
style={{
|
||||
...contentStyle,
|
||||
maxHeight: isExpanded ? 'none' : '500px',
|
||||
overflowY: isExpanded ? 'visible' : 'auto',
|
||||
overflowX: 'auto',
|
||||
}}
|
||||
>
|
||||
<div ref={rendererTargetRef} className="code-block-render" />
|
||||
{!rendererReady && fallbackHtml && (
|
||||
<div className="code-fallback-plain" dangerouslySetInnerHTML={{ __html: fallbackHtml }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!props.stream && props.loading && (
|
||||
<div className="code-loading-placeholder">
|
||||
<div className="loading-skeleton">
|
||||
<div className="skeleton-line" />
|
||||
<div className="skeleton-line" />
|
||||
<div className="skeleton-line short" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<span className="sr-only" aria-live="polite" role="status">{copied ? (t('common.copied') || 'Copied') : ''}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MarkdownCodeBlockNode
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { VisibilityHandle } from '../../context/viewportPriority'
|
||||
import type { MathBlockNodeProps } from '../../types/component-props'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useViewportPriority } from '../../context/viewportPriority'
|
||||
import { normalizeKaTeXRenderInput } from '../../utils/normalizeKaTeXRenderInput'
|
||||
import { renderKaTeXWithBackpressure, setKaTeXCache, WORKER_BUSY_CODE } from '../../workers/katexWorkerClient'
|
||||
import { getKatex } from './katex'
|
||||
|
||||
export function MathBlockNode({ node }: MathBlockNodeProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const mathRef = useRef<HTMLDivElement | null>(null)
|
||||
const viewportHandleRef = useRef<VisibilityHandle | null>(null)
|
||||
const registerViewport = useViewportPriority()
|
||||
const [rendering, setRendering] = useState(true)
|
||||
const [viewportReady, setViewportReady] = useState(() => typeof window === 'undefined')
|
||||
const renderIdRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el)
|
||||
return
|
||||
const handle = registerViewport(el, { rootMargin: '400px' })
|
||||
viewportHandleRef.current = handle
|
||||
if (handle.isVisible())
|
||||
setViewportReady(true)
|
||||
handle.whenVisible.then(() => setViewportReady(true))
|
||||
return () => {
|
||||
handle.destroy()
|
||||
viewportHandleRef.current = null
|
||||
}
|
||||
}, [registerViewport])
|
||||
|
||||
useEffect(() => {
|
||||
const content = normalizeKaTeXRenderInput(node.content ?? '')
|
||||
if (!content) {
|
||||
if (mathRef.current)
|
||||
mathRef.current.innerHTML = ''
|
||||
setRendering(false)
|
||||
return
|
||||
}
|
||||
if (!viewportReady) {
|
||||
setRendering(true)
|
||||
return
|
||||
}
|
||||
|
||||
let aborted = false
|
||||
const controller = new AbortController()
|
||||
const renderId = ++renderIdRef.current
|
||||
|
||||
renderKaTeXWithBackpressure(content, true, {
|
||||
timeout: 3000,
|
||||
waitTimeout: 2000,
|
||||
maxRetries: 1,
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((html) => {
|
||||
if (aborted || renderId !== renderIdRef.current)
|
||||
return
|
||||
if (mathRef.current)
|
||||
mathRef.current.innerHTML = html
|
||||
setRendering(false)
|
||||
})
|
||||
.catch(async (err: any) => {
|
||||
if (aborted || renderId !== renderIdRef.current)
|
||||
return
|
||||
if (!mathRef.current)
|
||||
return
|
||||
const code = err?.code || err?.name
|
||||
const isWorkerInitFailure = code === 'WORKER_INIT_ERROR' || err?.fallbackToRenderer
|
||||
const isBusyOrTimeout = code === WORKER_BUSY_CODE || code === 'WORKER_TIMEOUT'
|
||||
if (isWorkerInitFailure || isBusyOrTimeout) {
|
||||
const katex = await getKatex()
|
||||
if (katex) {
|
||||
try {
|
||||
const html = katex.renderToString(content, {
|
||||
throwOnError: node.loading,
|
||||
displayMode: true,
|
||||
})
|
||||
if (!aborted && renderId === renderIdRef.current && mathRef.current) {
|
||||
mathRef.current.innerHTML = html
|
||||
setRendering(false)
|
||||
setKaTeXCache(content, true, html)
|
||||
}
|
||||
return
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
}
|
||||
if (!node.loading) {
|
||||
mathRef.current.textContent = node.raw
|
||||
setRendering(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
aborted = true
|
||||
controller.abort()
|
||||
}
|
||||
}, [node.content, node.loading, node.raw, viewportReady])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="math-block text-center overflow-x-auto relative min-h-[40px]">
|
||||
<div ref={mathRef} className={rendering ? 'math-rendering' : undefined} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { VisibilityHandle } from '../../context/viewportPriority'
|
||||
import type { MathInlineNodeProps } from '../../types/component-props'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useViewportPriority } from '../../context/viewportPriority'
|
||||
import { normalizeKaTeXRenderInput } from '../../utils/normalizeKaTeXRenderInput'
|
||||
import { renderKaTeXWithBackpressure, setKaTeXCache, WORKER_BUSY_CODE } from '../../workers/katexWorkerClient'
|
||||
import { getKatex } from './katex'
|
||||
|
||||
export function MathInlineNode({ node }: MathInlineNodeProps) {
|
||||
const containerRef = useRef<HTMLSpanElement | null>(null)
|
||||
const mathRef = useRef<HTMLSpanElement | null>(null)
|
||||
const viewportHandleRef = useRef<VisibilityHandle | null>(null)
|
||||
const registerViewport = useViewportPriority()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [viewportReady, setViewportReady] = useState(() => typeof window === 'undefined')
|
||||
const renderIdRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el)
|
||||
return
|
||||
const handle = registerViewport(el, { rootMargin: '400px' })
|
||||
viewportHandleRef.current = handle
|
||||
if (handle.isVisible())
|
||||
setViewportReady(true)
|
||||
handle.whenVisible.then(() => setViewportReady(true))
|
||||
return () => {
|
||||
handle.destroy()
|
||||
viewportHandleRef.current = null
|
||||
}
|
||||
}, [registerViewport])
|
||||
|
||||
useEffect(() => {
|
||||
const content = normalizeKaTeXRenderInput(node.content ?? '')
|
||||
if (!content) {
|
||||
if (mathRef.current)
|
||||
mathRef.current.innerHTML = ''
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
if (!viewportReady) {
|
||||
setLoading(true)
|
||||
return
|
||||
}
|
||||
|
||||
let aborted = false
|
||||
const controller = new AbortController()
|
||||
const renderId = ++renderIdRef.current
|
||||
const displayMode = node.markup === '$$'
|
||||
renderKaTeXWithBackpressure(content, displayMode, {
|
||||
timeout: 1500,
|
||||
waitTimeout: 0,
|
||||
maxRetries: 0,
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((html) => {
|
||||
if (aborted || renderId !== renderIdRef.current)
|
||||
return
|
||||
if (mathRef.current) {
|
||||
mathRef.current.innerHTML = html
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
.catch(async (err: any) => {
|
||||
if (aborted || renderId !== renderIdRef.current)
|
||||
return
|
||||
if (!mathRef.current)
|
||||
return
|
||||
const code = err?.code || err?.name
|
||||
const isWorkerInitFailure = code === 'WORKER_INIT_ERROR' || err?.fallbackToRenderer
|
||||
const isBusyOrTimeout = code === WORKER_BUSY_CODE || code === 'WORKER_TIMEOUT'
|
||||
if (isWorkerInitFailure || isBusyOrTimeout) {
|
||||
const katex = await getKatex()
|
||||
if (katex) {
|
||||
try {
|
||||
const html = katex.renderToString(content, {
|
||||
throwOnError: node.loading,
|
||||
displayMode,
|
||||
})
|
||||
if (!aborted && renderId === renderIdRef.current && mathRef.current) {
|
||||
mathRef.current.innerHTML = html
|
||||
setLoading(false)
|
||||
setKaTeXCache(content, displayMode, html)
|
||||
}
|
||||
return
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
}
|
||||
if (!node.loading) {
|
||||
mathRef.current.textContent = node.raw
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
aborted = true
|
||||
controller.abort()
|
||||
}
|
||||
}, [node.content, node.loading, node.raw, node.markup, viewportReady])
|
||||
|
||||
return (
|
||||
<span ref={containerRef} className="math-inline-wrapper">
|
||||
<span ref={mathRef} className={`math-inline${loading ? ' math-inline--hidden' : ''}`} />
|
||||
{loading && (
|
||||
<span className="math-inline__loading" role="status" aria-live="polite">
|
||||
<span className="math-inline__spinner" aria-hidden="true" />
|
||||
<span className="sr-only">Loading</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
let katex: any = null
|
||||
let importAttempted = false
|
||||
|
||||
export async function getKatex() {
|
||||
if (katex)
|
||||
return katex
|
||||
if (importAttempted)
|
||||
return null
|
||||
try {
|
||||
katex = await import('katex')
|
||||
await import('katex/contrib/mhchem')
|
||||
return katex
|
||||
}
|
||||
catch {
|
||||
importAttempted = true
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { MathBlockNode } from '../Math/MathBlockNode'
|
||||
export { MathBlockNode as default } from '../Math/MathBlockNode'
|
||||
@@ -0,0 +1,2 @@
|
||||
export { MathInlineNode } from '../Math/MathInlineNode'
|
||||
export { MathInlineNode as default } from '../Math/MathInlineNode'
|
||||
@@ -0,0 +1,47 @@
|
||||
export const MERMAID_PREVIEW_MIN_HEIGHT = 360
|
||||
export const MERMAID_PREVIEW_MAX_HEIGHT = 500
|
||||
|
||||
export function parsePositiveNumber(value: unknown) {
|
||||
const numeric = typeof value === 'number' ? value : Number.parseFloat(String(value ?? ''))
|
||||
return Number.isFinite(numeric) && numeric > 0 ? numeric : null
|
||||
}
|
||||
|
||||
export function getMermaidDiagramKind(code: string) {
|
||||
for (const rawLine of code.split(/\r?\n/)) {
|
||||
const line = rawLine.trim()
|
||||
if (!line || line.startsWith('%%'))
|
||||
continue
|
||||
const match = line.match(/^([A-Z][\w-]*)\b/i)
|
||||
return match?.[1]?.toLowerCase() || ''
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function estimateMermaidPreviewHeight(code: string) {
|
||||
const meaningfulLines = code
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && !line.startsWith('%%'))
|
||||
const lineCount = Math.max(1, meaningfulLines.length)
|
||||
const kind = getMermaidDiagramKind(code)
|
||||
|
||||
if (kind === 'gantt')
|
||||
return 220 + lineCount * 28
|
||||
if (kind === 'sequencediagram')
|
||||
return 180 + lineCount * 26
|
||||
if (kind === 'classdiagram' || kind === 'statediagram' || kind === 'erdiagram')
|
||||
return 180 + lineCount * 24
|
||||
if (kind === 'flowchart' || kind === 'graph')
|
||||
return 170 + lineCount * 28
|
||||
return 200 + lineCount * 22
|
||||
}
|
||||
|
||||
export function clampMermaidPreviewHeight(
|
||||
height: number,
|
||||
minHeight = MERMAID_PREVIEW_MIN_HEIGHT,
|
||||
maxHeight: number | null = MERMAID_PREVIEW_MAX_HEIGHT,
|
||||
) {
|
||||
return maxHeight == null
|
||||
? Math.max(minHeight, height)
|
||||
: Math.min(Math.max(minHeight, height), maxHeight)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { MermaidBlockNode } from './MermaidBlockNode'
|
||||
export { MermaidBlockNode as default } from './MermaidBlockNode'
|
||||
@@ -0,0 +1,78 @@
|
||||
let cachedMermaid: any = null
|
||||
let importAttempted = false
|
||||
let lastInitKey: string | null = null
|
||||
let pendingImport: Promise<any | null> | null = null
|
||||
|
||||
function computeInitKey(config: Record<string, any>) {
|
||||
const securityLevel = String(config?.securityLevel ?? 'loose')
|
||||
const htmlLabels = config?.flowchart?.htmlLabels
|
||||
return `${securityLevel}|htmlLabels:${htmlLabels === false ? '0' : '1'}`
|
||||
}
|
||||
|
||||
function ensureInitialized(instance: any, config?: Record<string, any>) {
|
||||
if (!instance || !config)
|
||||
return
|
||||
const key = computeInitKey(config)
|
||||
if (lastInitKey === key)
|
||||
return
|
||||
try {
|
||||
if (typeof instance.initialize === 'function')
|
||||
instance.initialize(config)
|
||||
else if (instance.mermaidAPI?.initialize)
|
||||
instance.mermaidAPI.initialize(config)
|
||||
lastInitKey = key
|
||||
}
|
||||
catch {
|
||||
// ignore init failures; mermaid may already be initialized in some environments
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMermaid(initConfig?: Record<string, any>) {
|
||||
if (cachedMermaid) {
|
||||
ensureInitialized(cachedMermaid, initConfig)
|
||||
return cachedMermaid
|
||||
}
|
||||
if (pendingImport) {
|
||||
const instance = await pendingImport
|
||||
if (instance)
|
||||
ensureInitialized(instance, initConfig)
|
||||
return instance
|
||||
}
|
||||
if (importAttempted)
|
||||
return null
|
||||
try {
|
||||
importAttempted = true
|
||||
pendingImport = (async () => {
|
||||
try {
|
||||
const mod: any = await import('mermaid')
|
||||
const candidate: any = mod?.default || mod
|
||||
if (candidate?.default)
|
||||
cachedMermaid = candidate.default
|
||||
else if (candidate?.mermaidAPI)
|
||||
cachedMermaid = candidate
|
||||
else if (candidate?.mermaid)
|
||||
cachedMermaid = candidate.mermaid
|
||||
else
|
||||
cachedMermaid = candidate
|
||||
if (!cachedMermaid)
|
||||
throw new Error('Mermaid module did not export expected API')
|
||||
ensureInitialized(cachedMermaid, initConfig ?? { startOnLoad: false, securityLevel: 'loose' })
|
||||
return cachedMermaid
|
||||
}
|
||||
catch (err) {
|
||||
console.warn('[markstream-react] Failed to load mermaid:', err)
|
||||
return null
|
||||
}
|
||||
finally {
|
||||
pendingImport = null
|
||||
}
|
||||
})()
|
||||
const instance = await pendingImport
|
||||
if (instance)
|
||||
ensureInitialized(instance, initConfig)
|
||||
return instance
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,931 @@
|
||||
import type { ParsedNode } from 'stream-markdown-parser'
|
||||
import type { VisibilityHandle } from '../context/viewportPriority'
|
||||
import type { NodeRendererProps, RenderContext } from '../types'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import {
|
||||
getMarkdown,
|
||||
mergeCustomHtmlTags,
|
||||
parseMarkdownToStructure,
|
||||
} from 'stream-markdown-parser'
|
||||
import { useViewportPriority, ViewportPriorityProvider } from '../context/viewportPriority'
|
||||
import { getCustomComponentsRevision, getCustomNodeComponents, subscribeCustomComponents } from '../customComponents'
|
||||
import { renderNode } from '../renderers/renderNode'
|
||||
import { normalizeLanguageIdentifier } from '../utils/languageIcon'
|
||||
import { getUseMonaco } from './CodeBlockNode/monaco'
|
||||
import { setDesiredMonacoTheme } from './CodeBlockNode/monacoThemeRegistry'
|
||||
|
||||
const DEFAULT_PROPS: Required<Pick<NodeRendererProps, 'codeBlockStream'
|
||||
| 'typewriter'
|
||||
| 'batchRendering'
|
||||
| 'initialRenderBatchSize'
|
||||
| 'renderBatchSize'
|
||||
| 'renderBatchDelay'
|
||||
| 'renderBatchBudgetMs'
|
||||
| 'renderBatchIdleTimeoutMs'
|
||||
| 'deferNodesUntilVisible'
|
||||
| 'maxLiveNodes'
|
||||
| 'liveNodeBuffer'>> = {
|
||||
codeBlockStream: true,
|
||||
typewriter: true,
|
||||
batchRendering: true,
|
||||
initialRenderBatchSize: 40,
|
||||
renderBatchSize: 80,
|
||||
renderBatchDelay: 16,
|
||||
renderBatchBudgetMs: 6,
|
||||
renderBatchIdleTimeoutMs: 120,
|
||||
deferNodesUntilVisible: true,
|
||||
maxLiveNodes: 320,
|
||||
liveNodeBuffer: 60,
|
||||
}
|
||||
|
||||
const fallbackMarkdown = getMarkdown()
|
||||
|
||||
type ResolvedProps = NodeRendererProps & typeof DEFAULT_PROPS
|
||||
|
||||
interface IdleDeadlineLike {
|
||||
timeRemaining?: () => number
|
||||
}
|
||||
|
||||
interface NodeRendererInnerProps {
|
||||
props: ResolvedProps
|
||||
parsedNodes: ParsedNode[]
|
||||
renderCtx: RenderContext
|
||||
indexPrefix: string
|
||||
containerRef: React.RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
const DEFAULT_NODE_HEIGHT = 32
|
||||
const MAX_DEFERRED_NODE_COUNT = 900
|
||||
|
||||
const NodeRendererInner: React.FC<NodeRendererInnerProps> = ({
|
||||
props,
|
||||
parsedNodes,
|
||||
renderCtx,
|
||||
indexPrefix,
|
||||
containerRef,
|
||||
}) => {
|
||||
const registerNodeVisibility = useViewportPriority()
|
||||
const isClient = typeof window !== 'undefined'
|
||||
const hasIdleCallback = typeof window !== 'undefined' && typeof (window as any).requestIdleCallback === 'function'
|
||||
const resolvedBatchSize = Math.max(0, Math.trunc(props.renderBatchSize ?? 80))
|
||||
const resolvedInitialBatch = Math.max(
|
||||
0,
|
||||
Math.trunc(props.initialRenderBatchSize ?? (resolvedBatchSize || parsedNodes.length)),
|
||||
)
|
||||
const batchingEnabled = props.batchRendering !== false && resolvedBatchSize > 0 && isClient
|
||||
const liveNodeBufferResolved = Math.max(0, props.liveNodeBuffer ?? 60)
|
||||
const maxLiveNodesResolved = Math.max(1, props.maxLiveNodes ?? 320)
|
||||
const virtualizationEnabled = (props.maxLiveNodes ?? 0) > 0 && parsedNodes.length > maxLiveNodesResolved
|
||||
const viewportPriorityEnabled = props.viewportPriority !== false
|
||||
const incrementalRenderingActive = batchingEnabled && (props.maxLiveNodes ?? 0) <= 0
|
||||
const deferNodes = useMemo(() => {
|
||||
if (props.deferNodesUntilVisible === false)
|
||||
return false
|
||||
if ((props.maxLiveNodes ?? 0) <= 0)
|
||||
return false
|
||||
if (virtualizationEnabled)
|
||||
return false
|
||||
if (parsedNodes.length > MAX_DEFERRED_NODE_COUNT)
|
||||
return false
|
||||
return viewportPriorityEnabled
|
||||
}, [
|
||||
parsedNodes.length,
|
||||
props.deferNodesUntilVisible,
|
||||
props.maxLiveNodes,
|
||||
viewportPriorityEnabled,
|
||||
virtualizationEnabled,
|
||||
])
|
||||
|
||||
const [renderedCount, setRenderedCount] = useState(() => {
|
||||
if (!incrementalRenderingActive)
|
||||
return parsedNodes.length
|
||||
return Math.min(parsedNodes.length, resolvedInitialBatch)
|
||||
})
|
||||
const renderedCountRef = useRef(renderedCount)
|
||||
useEffect(() => {
|
||||
renderedCountRef.current = renderedCount
|
||||
}, [renderedCount])
|
||||
const cleanupLimit = incrementalRenderingActive ? renderedCount : parsedNodes.length
|
||||
|
||||
const [focusIndex, setFocusIndex] = useState(0)
|
||||
const [liveRange, setLiveRange] = useState({ start: 0, end: parsedNodes.length })
|
||||
const nodeHeightsRef = useRef(new Map<number, number>())
|
||||
const [heightsVersion, setHeightsVersion] = useState(0)
|
||||
const nodeVisibilityStateRef = useRef<Record<number, boolean>>({})
|
||||
const nodeVisibilityHandlesRef = useRef(new Map<number, VisibilityHandle>())
|
||||
const nodeSlotElementsRef = useRef(new Map<number, HTMLElement | null>())
|
||||
const nodeSlotRefCallbacksRef = useRef(new Map<number, (el: HTMLElement | null) => void>())
|
||||
const nodeContentRefCallbacksRef = useRef(new Map<number, (el: HTMLDivElement | null) => void>())
|
||||
const nodeSeenRef = useRef(new Set<number>())
|
||||
const prevRenderedRef = useRef(renderedCount)
|
||||
const batchRafRef = useRef<number | null>(null)
|
||||
const batchTimeoutRef = useRef<number | null>(null)
|
||||
const batchIdleRef = useRef<number | null>(null)
|
||||
const batchPendingRef = useRef(false)
|
||||
const pendingIncrementRef = useRef<number | null>(null)
|
||||
const adaptiveBatchSizeRef = useRef(Math.max(1, resolvedBatchSize || 1))
|
||||
const desiredRenderedCountRef = useRef(parsedNodes.length)
|
||||
const previousDatasetRef = useRef<{ key: typeof props.indexKey, total: number }>({
|
||||
key: props.indexKey,
|
||||
total: parsedNodes.length,
|
||||
})
|
||||
const previousBatchConfigRef = useRef({
|
||||
batchSize: resolvedBatchSize,
|
||||
initial: resolvedInitialBatch,
|
||||
delay: props.renderBatchDelay ?? 16,
|
||||
enabled: incrementalRenderingActive,
|
||||
})
|
||||
|
||||
const shouldObserveSlots = deferNodes || virtualizationEnabled
|
||||
|
||||
const averageNodeHeight = useMemo(() => {
|
||||
const map = nodeHeightsRef.current
|
||||
if (!map.size)
|
||||
return DEFAULT_NODE_HEIGHT
|
||||
let total = 0
|
||||
for (const height of map.values())
|
||||
total += height
|
||||
return Math.max(16, total / map.size)
|
||||
}, [heightsVersion])
|
||||
|
||||
const visibleNodes = useMemo(() => {
|
||||
if (!virtualizationEnabled)
|
||||
return parsedNodes.map((node, index) => ({ node, index }))
|
||||
const total = parsedNodes.length
|
||||
const start = Math.max(0, Math.min(liveRange.start, total))
|
||||
const end = Math.max(start, Math.min(liveRange.end, total))
|
||||
return parsedNodes.slice(start, end).map((node, idx) => ({
|
||||
node,
|
||||
index: start + idx,
|
||||
}))
|
||||
}, [parsedNodes, liveRange, virtualizationEnabled])
|
||||
|
||||
const desiredRenderedCount = useMemo(() => {
|
||||
if (!virtualizationEnabled)
|
||||
return parsedNodes.length
|
||||
const overscanEnd = Math.max(liveRange.end + liveNodeBufferResolved, resolvedInitialBatch)
|
||||
const target = Math.min(parsedNodes.length, overscanEnd)
|
||||
return Math.max(renderedCount, target)
|
||||
}, [
|
||||
parsedNodes.length,
|
||||
virtualizationEnabled,
|
||||
liveRange.end,
|
||||
liveNodeBufferResolved,
|
||||
resolvedInitialBatch,
|
||||
renderedCount,
|
||||
])
|
||||
desiredRenderedCountRef.current = desiredRenderedCount
|
||||
|
||||
const estimateHeightRange = useCallback((start: number, end: number) => {
|
||||
if (start >= end)
|
||||
return 0
|
||||
const map = nodeHeightsRef.current
|
||||
let total = 0
|
||||
for (let i = start; i < end; i++)
|
||||
total += map.get(i) ?? averageNodeHeight
|
||||
return total
|
||||
}, [averageNodeHeight])
|
||||
|
||||
const topSpacerHeight = virtualizationEnabled
|
||||
? estimateHeightRange(0, Math.min(liveRange.start, parsedNodes.length))
|
||||
: 0
|
||||
const bottomSpacerHeight = virtualizationEnabled
|
||||
? estimateHeightRange(Math.min(liveRange.end, parsedNodes.length), parsedNodes.length)
|
||||
: 0
|
||||
|
||||
const cancelBatchTimers = useCallback(() => {
|
||||
if (batchRafRef.current != null && typeof cancelAnimationFrame === 'function') {
|
||||
cancelAnimationFrame(batchRafRef.current)
|
||||
batchRafRef.current = null
|
||||
}
|
||||
if (batchTimeoutRef.current != null) {
|
||||
window.clearTimeout(batchTimeoutRef.current)
|
||||
batchTimeoutRef.current = null
|
||||
}
|
||||
if (batchIdleRef.current != null && typeof (window as any).cancelIdleCallback === 'function') {
|
||||
;(window as any).cancelIdleCallback(batchIdleRef.current)
|
||||
batchIdleRef.current = null
|
||||
}
|
||||
batchPendingRef.current = false
|
||||
pendingIncrementRef.current = null
|
||||
}, [])
|
||||
|
||||
const adjustAdaptiveBatchSize = useCallback((elapsed: number) => {
|
||||
if (!incrementalRenderingActive)
|
||||
return
|
||||
const budget = Math.max(2, props.renderBatchBudgetMs ?? 6)
|
||||
const maxSize = Math.max(1, resolvedBatchSize || 1)
|
||||
const minSize = Math.max(1, Math.floor(maxSize / 4))
|
||||
if (elapsed > budget * 1.2)
|
||||
adaptiveBatchSizeRef.current = Math.max(minSize, Math.floor(adaptiveBatchSizeRef.current * 0.7))
|
||||
else if (elapsed < budget * 0.5 && adaptiveBatchSizeRef.current < maxSize)
|
||||
adaptiveBatchSizeRef.current = Math.min(maxSize, Math.ceil(adaptiveBatchSizeRef.current * 1.2))
|
||||
}, [incrementalRenderingActive, props.renderBatchBudgetMs, resolvedBatchSize])
|
||||
|
||||
const scheduleBatch = useCallback((increment: number, opts: { immediate?: boolean } = {}) => {
|
||||
if (!incrementalRenderingActive)
|
||||
return
|
||||
const target = desiredRenderedCountRef.current
|
||||
if (renderedCountRef.current >= target)
|
||||
return
|
||||
const amount = Math.max(1, increment)
|
||||
const applyIncrement = (size: number) => {
|
||||
const start = typeof performance !== 'undefined' ? performance.now() : Date.now()
|
||||
setRenderedCount((prev) => {
|
||||
const next = Math.min(desiredRenderedCountRef.current, prev + Math.max(1, size))
|
||||
renderedCountRef.current = next
|
||||
return next
|
||||
})
|
||||
const end = typeof performance !== 'undefined' ? performance.now() : Date.now()
|
||||
adjustAdaptiveBatchSize(end - start)
|
||||
}
|
||||
const queueNextBatch = () => {
|
||||
const dynamicSize = Math.max(1, Math.round(adaptiveBatchSizeRef.current))
|
||||
scheduleBatch(dynamicSize)
|
||||
}
|
||||
const run = (deadline?: IdleDeadlineLike) => {
|
||||
batchRafRef.current = null
|
||||
batchTimeoutRef.current = null
|
||||
batchIdleRef.current = null
|
||||
batchPendingRef.current = false
|
||||
const pending = pendingIncrementRef.current
|
||||
pendingIncrementRef.current = null
|
||||
const initialSize = pending != null ? pending : amount
|
||||
applyIncrement(initialSize)
|
||||
if (renderedCountRef.current >= desiredRenderedCountRef.current)
|
||||
return
|
||||
if (!deadline) {
|
||||
queueNextBatch()
|
||||
return
|
||||
}
|
||||
const budget = Math.max(2, props.renderBatchBudgetMs ?? 6)
|
||||
while (renderedCountRef.current < desiredRenderedCountRef.current) {
|
||||
const remaining = typeof deadline.timeRemaining === 'function' ? deadline.timeRemaining() : 0
|
||||
if (remaining <= budget * 0.5)
|
||||
break
|
||||
applyIncrement(Math.max(1, Math.round(adaptiveBatchSizeRef.current)))
|
||||
}
|
||||
if (renderedCountRef.current < desiredRenderedCountRef.current)
|
||||
queueNextBatch()
|
||||
}
|
||||
|
||||
if (!isClient || opts.immediate) {
|
||||
run()
|
||||
return
|
||||
}
|
||||
const delay = Math.max(0, props.renderBatchDelay ?? 16)
|
||||
pendingIncrementRef.current = pendingIncrementRef.current != null
|
||||
? Math.max(pendingIncrementRef.current, amount)
|
||||
: amount
|
||||
if (batchPendingRef.current)
|
||||
return
|
||||
batchPendingRef.current = true
|
||||
if (hasIdleCallback) {
|
||||
const timeout = Math.max(0, props.renderBatchIdleTimeoutMs ?? 120)
|
||||
batchIdleRef.current = (window as any).requestIdleCallback((deadline: IdleDeadlineLike) => run(deadline), { timeout })
|
||||
return
|
||||
}
|
||||
if (typeof requestAnimationFrame !== 'function') {
|
||||
batchTimeoutRef.current = window.setTimeout(() => run(), delay)
|
||||
return
|
||||
}
|
||||
batchRafRef.current = requestAnimationFrame(() => {
|
||||
if (delay === 0) {
|
||||
run()
|
||||
return
|
||||
}
|
||||
batchTimeoutRef.current = window.setTimeout(() => run(), delay)
|
||||
})
|
||||
}, [
|
||||
adjustAdaptiveBatchSize,
|
||||
hasIdleCallback,
|
||||
incrementalRenderingActive,
|
||||
isClient,
|
||||
props.renderBatchDelay,
|
||||
props.renderBatchBudgetMs,
|
||||
props.renderBatchIdleTimeoutMs,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
const datasetKey = props.indexKey
|
||||
const total = parsedNodes.length
|
||||
const prevCtx = previousDatasetRef.current
|
||||
const datasetChanged = datasetKey !== undefined
|
||||
? datasetKey !== prevCtx.key
|
||||
: total !== prevCtx.total
|
||||
previousDatasetRef.current = { key: datasetKey, total }
|
||||
const prevBatch = previousBatchConfigRef.current
|
||||
const currentDelay = props.renderBatchDelay ?? 16
|
||||
const batchConfigChanged
|
||||
= prevBatch.batchSize !== resolvedBatchSize
|
||||
|| prevBatch.initial !== resolvedInitialBatch
|
||||
|| prevBatch.delay !== currentDelay
|
||||
|| prevBatch.enabled !== incrementalRenderingActive
|
||||
previousBatchConfigRef.current = {
|
||||
batchSize: resolvedBatchSize,
|
||||
initial: resolvedInitialBatch,
|
||||
delay: currentDelay,
|
||||
enabled: incrementalRenderingActive,
|
||||
}
|
||||
|
||||
if (datasetChanged || batchConfigChanged || !incrementalRenderingActive)
|
||||
cancelBatchTimers()
|
||||
if (datasetChanged || batchConfigChanged) {
|
||||
adaptiveBatchSizeRef.current = Math.max(1, resolvedBatchSize || 1)
|
||||
nodeSeenRef.current.clear()
|
||||
}
|
||||
|
||||
if (!total) {
|
||||
renderedCountRef.current = 0
|
||||
setRenderedCount(0)
|
||||
return
|
||||
}
|
||||
|
||||
const target = desiredRenderedCountRef.current
|
||||
|
||||
if (!incrementalRenderingActive) {
|
||||
renderedCountRef.current = target
|
||||
setRenderedCount(target)
|
||||
return
|
||||
}
|
||||
|
||||
if (datasetChanged || batchConfigChanged) {
|
||||
const initial = Math.min(target, resolvedInitialBatch)
|
||||
renderedCountRef.current = initial
|
||||
setRenderedCount(initial)
|
||||
if (initial < target)
|
||||
scheduleBatch(Math.max(1, resolvedInitialBatch), { immediate: !isClient })
|
||||
return
|
||||
}
|
||||
|
||||
const capped = Math.min(renderedCountRef.current, target)
|
||||
if (capped !== renderedCountRef.current) {
|
||||
renderedCountRef.current = capped
|
||||
setRenderedCount(capped)
|
||||
}
|
||||
if (renderedCountRef.current < target)
|
||||
scheduleBatch(Math.max(1, resolvedBatchSize || 1))
|
||||
}, [
|
||||
cancelBatchTimers,
|
||||
incrementalRenderingActive,
|
||||
isClient,
|
||||
parsedNodes.length,
|
||||
props.indexKey,
|
||||
props.renderBatchDelay,
|
||||
resolvedBatchSize,
|
||||
resolvedInitialBatch,
|
||||
scheduleBatch,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!virtualizationEnabled) {
|
||||
setLiveRange({ start: 0, end: parsedNodes.length })
|
||||
return
|
||||
}
|
||||
const total = parsedNodes.length
|
||||
if (!total) {
|
||||
setLiveRange({ start: 0, end: 0 })
|
||||
return
|
||||
}
|
||||
const focus = Math.max(0, Math.min(focusIndex, total - 1))
|
||||
let start = Math.max(0, focus - liveNodeBufferResolved)
|
||||
let end = Math.min(total, focus + liveNodeBufferResolved + 1)
|
||||
const size = end - start
|
||||
if (size > maxLiveNodesResolved) {
|
||||
const excess = size - maxLiveNodesResolved
|
||||
start += Math.ceil(excess / 2)
|
||||
end -= Math.floor(excess / 2)
|
||||
}
|
||||
else if (size < maxLiveNodesResolved) {
|
||||
const missing = maxLiveNodesResolved - size
|
||||
start = Math.max(0, start - Math.ceil(missing / 2))
|
||||
end = Math.min(total, end + Math.floor(missing / 2))
|
||||
}
|
||||
setLiveRange({ start, end })
|
||||
}, [focusIndex, liveNodeBufferResolved, maxLiveNodesResolved, parsedNodes.length, virtualizationEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancelBatchTimers()
|
||||
for (const handle of nodeVisibilityHandlesRef.current.values())
|
||||
handle.destroy()
|
||||
nodeVisibilityHandlesRef.current.clear()
|
||||
}
|
||||
}, [cancelBatchTimers])
|
||||
|
||||
const cleanupAfterTruncate = useCallback((limit: number) => {
|
||||
for (const [index, handle] of nodeVisibilityHandlesRef.current.entries()) {
|
||||
if (index >= limit) {
|
||||
handle.destroy()
|
||||
nodeVisibilityHandlesRef.current.delete(index)
|
||||
delete nodeVisibilityStateRef.current[index]
|
||||
nodeSlotElementsRef.current.delete(index)
|
||||
nodeSlotRefCallbacksRef.current.delete(index)
|
||||
nodeContentRefCallbacksRef.current.delete(index)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
cleanupAfterTruncate(cleanupLimit)
|
||||
}, [cleanupAfterTruncate, cleanupLimit])
|
||||
|
||||
useEffect(() => {
|
||||
const total = parsedNodes.length
|
||||
let changed = false
|
||||
for (const key of Array.from(nodeHeightsRef.current.keys())) {
|
||||
if (key >= total) {
|
||||
nodeHeightsRef.current.delete(key)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed)
|
||||
setHeightsVersion(v => v + 1)
|
||||
for (const key of Object.keys(nodeVisibilityStateRef.current)) {
|
||||
if (Number(key) >= total)
|
||||
delete nodeVisibilityStateRef.current[key]
|
||||
}
|
||||
for (const index of Array.from(nodeSeenRef.current)) {
|
||||
if (index >= total)
|
||||
nodeSeenRef.current.delete(index)
|
||||
}
|
||||
}, [parsedNodes.length])
|
||||
|
||||
const markNodeVisible = useCallback((index: number, visible: boolean) => {
|
||||
if (deferNodes && visible)
|
||||
nodeVisibilityStateRef.current[index] = true
|
||||
if (visible && virtualizationEnabled) {
|
||||
setFocusIndex(prev => (index > prev ? index : prev))
|
||||
}
|
||||
}, [deferNodes, virtualizationEnabled])
|
||||
|
||||
const destroyNodeHandle = useCallback((index: number) => {
|
||||
const handle = nodeVisibilityHandlesRef.current.get(index)
|
||||
if (handle) {
|
||||
handle.destroy()
|
||||
nodeVisibilityHandlesRef.current.delete(index)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setNodeSlotElement = useCallback((index: number, el: HTMLElement | null) => {
|
||||
const slots = nodeSlotElementsRef.current
|
||||
if (el)
|
||||
slots.set(index, el)
|
||||
else
|
||||
slots.delete(index)
|
||||
|
||||
if (!shouldObserveSlots || !el) {
|
||||
destroyNodeHandle(index)
|
||||
if (deferNodes && !el)
|
||||
delete nodeVisibilityStateRef.current[index]
|
||||
if (el)
|
||||
markNodeVisible(index, true)
|
||||
return
|
||||
}
|
||||
|
||||
if (index < resolvedInitialBatch && !virtualizationEnabled) {
|
||||
destroyNodeHandle(index)
|
||||
markNodeVisible(index, true)
|
||||
return
|
||||
}
|
||||
|
||||
destroyNodeHandle(index)
|
||||
const handle = registerNodeVisibility(el, { rootMargin: '400px' })
|
||||
nodeVisibilityHandlesRef.current.set(index, handle)
|
||||
if (handle.isVisible())
|
||||
markNodeVisible(index, true)
|
||||
handle.whenVisible.then(() => markNodeVisible(index, true)).catch(() => {})
|
||||
}, [
|
||||
deferNodes,
|
||||
destroyNodeHandle,
|
||||
markNodeVisible,
|
||||
registerNodeVisibility,
|
||||
resolvedInitialBatch,
|
||||
shouldObserveSlots,
|
||||
virtualizationEnabled,
|
||||
])
|
||||
|
||||
const getNodeSlotRef = useCallback((index: number) => {
|
||||
const existing = nodeSlotRefCallbacksRef.current.get(index)
|
||||
if (existing)
|
||||
return existing
|
||||
const cb = (el: HTMLElement | null) => setNodeSlotElement(index, el)
|
||||
nodeSlotRefCallbacksRef.current.set(index, cb)
|
||||
return cb
|
||||
}, [setNodeSlotElement])
|
||||
|
||||
const setNodeContentRef = useCallback((index: number, el: HTMLDivElement | null) => {
|
||||
if (!el) {
|
||||
if (nodeHeightsRef.current.has(index)) {
|
||||
nodeHeightsRef.current.delete(index)
|
||||
setHeightsVersion(v => v + 1)
|
||||
}
|
||||
return
|
||||
}
|
||||
const measure = () => {
|
||||
const height = el.offsetHeight
|
||||
if (!height)
|
||||
return
|
||||
const prev = nodeHeightsRef.current.get(index)
|
||||
if (prev !== height) {
|
||||
nodeHeightsRef.current.set(index, height)
|
||||
setHeightsVersion(v => v + 1)
|
||||
}
|
||||
}
|
||||
if (typeof queueMicrotask === 'function')
|
||||
queueMicrotask(measure)
|
||||
else
|
||||
Promise.resolve().then(measure)
|
||||
}, [])
|
||||
|
||||
const getNodeContentRef = useCallback((index: number) => {
|
||||
const existing = nodeContentRefCallbacksRef.current.get(index)
|
||||
if (existing)
|
||||
return existing
|
||||
const cb = (el: HTMLDivElement | null) => setNodeContentRef(index, el)
|
||||
nodeContentRefCallbacksRef.current.set(index, cb)
|
||||
return cb
|
||||
}, [setNodeContentRef])
|
||||
|
||||
const shouldRenderNode = useCallback((index: number) => {
|
||||
if (incrementalRenderingActive && index >= renderedCountRef.current)
|
||||
return false
|
||||
if (!deferNodes)
|
||||
return true
|
||||
if (index < resolvedInitialBatch)
|
||||
return true
|
||||
return nodeVisibilityStateRef.current[index] === true
|
||||
}, [deferNodes, incrementalRenderingActive, resolvedInitialBatch])
|
||||
|
||||
const handleMouseEvent = useCallback((cb?: (event: React.MouseEvent<HTMLElement>) => void) => {
|
||||
return (event: React.MouseEvent<HTMLElement>) => {
|
||||
if (!cb)
|
||||
return
|
||||
const target = event.target as HTMLElement | null
|
||||
if (!target?.closest('[data-node-index]'))
|
||||
return
|
||||
cb(event)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
for (const [index, el] of nodeSlotElementsRef.current.entries()) {
|
||||
if (el)
|
||||
setNodeSlotElement(index, el)
|
||||
}
|
||||
}, [setNodeSlotElement])
|
||||
|
||||
useEffect(() => {
|
||||
if (virtualizationEnabled && renderedCount > prevRenderedRef.current)
|
||||
setFocusIndex(renderedCount - 1)
|
||||
prevRenderedRef.current = renderedCount
|
||||
}, [renderedCount, virtualizationEnabled])
|
||||
|
||||
const topSpacer = virtualizationEnabled
|
||||
? <div className="node-spacer" style={{ height: `${topSpacerHeight}px` }} aria-hidden="true" />
|
||||
: null
|
||||
const bottomSpacer = virtualizationEnabled
|
||||
? <div className="node-spacer" style={{ height: `${bottomSpacerHeight}px` }} aria-hidden="true" />
|
||||
: null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`markstream-react markdown-renderer${props.isDark ? ' dark' : ''}${virtualizationEnabled ? ' virtualized' : ''}`}
|
||||
data-custom-id={props.customId}
|
||||
onClick={props.onClick}
|
||||
onMouseOver={handleMouseEvent(props.onMouseOver)}
|
||||
onMouseOut={handleMouseEvent(props.onMouseOut)}
|
||||
>
|
||||
{topSpacer}
|
||||
{visibleNodes.map(({ node, index }) => {
|
||||
const canRender = shouldRenderNode(index)
|
||||
const placeholderHeight = nodeHeightsRef.current.get(index) ?? averageNodeHeight
|
||||
const shouldAnimate = props.typewriter !== false
|
||||
&& node.type !== 'code_block'
|
||||
&& !nodeSeenRef.current.has(index)
|
||||
&& canRender
|
||||
if (shouldAnimate)
|
||||
nodeSeenRef.current.add(index)
|
||||
return (
|
||||
<div
|
||||
key={`${indexPrefix}-${index}`}
|
||||
ref={getNodeSlotRef(index)}
|
||||
className="node-slot"
|
||||
data-node-index={index}
|
||||
data-node-type={node.type}
|
||||
>
|
||||
{canRender
|
||||
? (
|
||||
<div
|
||||
ref={getNodeContentRef(index)}
|
||||
className={`node-content${shouldAnimate ? ' typewriter-node' : ''}`}
|
||||
>
|
||||
{renderNode(node, `${indexPrefix}-${index}`, renderCtx)}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="node-placeholder" style={{ height: `${placeholderHeight}px` }} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{bottomSpacer}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function areNodeRendererInnerPropsEqual(prev: NodeRendererInnerProps, next: NodeRendererInnerProps) {
|
||||
if (prev.parsedNodes !== next.parsedNodes)
|
||||
return false
|
||||
if (prev.renderCtx !== next.renderCtx)
|
||||
return false
|
||||
if (prev.indexPrefix !== next.indexPrefix)
|
||||
return false
|
||||
if (prev.containerRef !== next.containerRef)
|
||||
return false
|
||||
|
||||
const a = prev.props
|
||||
const b = next.props
|
||||
return a.isDark === b.isDark
|
||||
&& a.typewriter === b.typewriter
|
||||
&& a.batchRendering === b.batchRendering
|
||||
&& a.initialRenderBatchSize === b.initialRenderBatchSize
|
||||
&& a.renderBatchSize === b.renderBatchSize
|
||||
&& a.renderBatchDelay === b.renderBatchDelay
|
||||
&& a.renderBatchBudgetMs === b.renderBatchBudgetMs
|
||||
&& a.renderBatchIdleTimeoutMs === b.renderBatchIdleTimeoutMs
|
||||
&& a.deferNodesUntilVisible === b.deferNodesUntilVisible
|
||||
&& a.maxLiveNodes === b.maxLiveNodes
|
||||
&& a.liveNodeBuffer === b.liveNodeBuffer
|
||||
&& a.viewportPriority === b.viewportPriority
|
||||
&& a.indexKey === b.indexKey
|
||||
&& a.onClick === b.onClick
|
||||
&& a.onMouseOver === b.onMouseOver
|
||||
&& a.onMouseOut === b.onMouseOut
|
||||
}
|
||||
|
||||
const MemoNodeRendererInner = React.memo(NodeRendererInner, areNodeRendererInnerPropsEqual)
|
||||
|
||||
export const NodeRenderer: React.FC<NodeRendererProps> = (rawProps) => {
|
||||
const props = { ...DEFAULT_PROPS, ...rawProps } as ResolvedProps
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const desiredThemeKeyRef = useRef<string | null>(null)
|
||||
const textStreamStateRef = useRef(new Map<string, string>())
|
||||
const previousStreamInputsRef = useRef<{
|
||||
content?: NodeRendererProps['content']
|
||||
nodes?: NodeRendererProps['nodes']
|
||||
} | null>(null)
|
||||
const streamRenderVersionRef = useRef(0)
|
||||
|
||||
if (
|
||||
previousStreamInputsRef.current?.content !== props.content
|
||||
|| previousStreamInputsRef.current?.nodes !== props.nodes
|
||||
) {
|
||||
streamRenderVersionRef.current += 1
|
||||
previousStreamInputsRef.current = {
|
||||
content: props.content,
|
||||
nodes: props.nodes,
|
||||
}
|
||||
}
|
||||
|
||||
const customComponentsRevision = useSyncExternalStore(
|
||||
subscribeCustomComponents,
|
||||
getCustomComponentsRevision,
|
||||
getCustomComponentsRevision,
|
||||
)
|
||||
const customComponents = useMemo(() => {
|
||||
return getCustomNodeComponents(props.customId)
|
||||
}, [props.customId, customComponentsRevision])
|
||||
|
||||
const instanceMsgId = useMemo(() => {
|
||||
return props.customId
|
||||
? `renderer-${props.customId}`
|
||||
: `renderer-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
}, [props.customId])
|
||||
|
||||
const effectiveCustomHtmlTags = useMemo(() => {
|
||||
const base = props.parseOptions ?? {}
|
||||
const optionTags = (base as any).customHtmlTags ?? []
|
||||
return mergeCustomHtmlTags(
|
||||
props.customHtmlTags,
|
||||
Array.isArray(optionTags) ? optionTags : [],
|
||||
)
|
||||
}, [
|
||||
props.customId,
|
||||
props.customHtmlTags,
|
||||
props.parseOptions,
|
||||
customComponentsRevision,
|
||||
])
|
||||
|
||||
const mdBase = useMemo(() => {
|
||||
const tags = effectiveCustomHtmlTags
|
||||
if (!tags || tags.length === 0)
|
||||
return getMarkdown(instanceMsgId)
|
||||
return getMarkdown(instanceMsgId, { customHtmlTags: tags })
|
||||
}, [instanceMsgId, effectiveCustomHtmlTags])
|
||||
|
||||
const mdInstance = useMemo(() => {
|
||||
const base = mdBase
|
||||
return props.customMarkdownIt ? props.customMarkdownIt(base) : base
|
||||
}, [mdBase, props.customMarkdownIt])
|
||||
|
||||
const mergedParseOptions = useMemo(() => {
|
||||
const base = props.parseOptions ?? {}
|
||||
const resolvedFinal = props.final ?? (base as any).final
|
||||
const hasFinal = resolvedFinal != null
|
||||
const hasCustom = effectiveCustomHtmlTags.length > 0
|
||||
|
||||
if (!hasFinal && !hasCustom)
|
||||
return base
|
||||
|
||||
return {
|
||||
...(base as any),
|
||||
...(hasFinal ? { final: resolvedFinal } : {}),
|
||||
...(hasCustom ? { customHtmlTags: effectiveCustomHtmlTags } : {}),
|
||||
} as any
|
||||
}, [effectiveCustomHtmlTags, props.final, props.parseOptions])
|
||||
|
||||
const parsedNodes = useMemo<ParsedNode[]>(() => {
|
||||
const debugEnabled = Boolean(props.debugPerformance)
|
||||
&& typeof console !== 'undefined'
|
||||
&& typeof performance !== 'undefined'
|
||||
const parseStart = debugEnabled ? performance.now() : 0
|
||||
|
||||
let result: ParsedNode[] = []
|
||||
if (Array.isArray(props.nodes) && props.nodes.length) {
|
||||
result = (props.nodes as ParsedNode[]).map(node => ({ ...node }))
|
||||
}
|
||||
else if (props.content) {
|
||||
result = parseMarkdownToStructure(props.content, mdInstance ?? fallbackMarkdown, mergedParseOptions)
|
||||
}
|
||||
|
||||
if (debugEnabled) {
|
||||
console.info('[markstream-react][perf] parse(sync)', {
|
||||
ms: Math.round(performance.now() - parseStart),
|
||||
nodes: result.length,
|
||||
contentLength: props.content?.length ?? 0,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}, [
|
||||
props.content,
|
||||
props.debugPerformance,
|
||||
props.nodes,
|
||||
mergedParseOptions,
|
||||
mdInstance,
|
||||
props.customId,
|
||||
customComponentsRevision,
|
||||
effectiveCustomHtmlTags,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined')
|
||||
return
|
||||
if (props.renderCodeBlocksAsPre)
|
||||
return
|
||||
// React parity improvement: prefetch Monaco (and preload workers) in idle time
|
||||
// so the first visible code block doesn't pay the full dynamic import cost.
|
||||
const hasCodeBlock = parsedNodes.some((node) => {
|
||||
if ((node as any)?.type !== 'code_block')
|
||||
return false
|
||||
const lang = normalizeLanguageIdentifier(String((node as any)?.language ?? ''))
|
||||
return lang !== 'mermaid'
|
||||
})
|
||||
if (!hasCodeBlock)
|
||||
return
|
||||
|
||||
const requestIdle = (window as any).requestIdleCallback
|
||||
?? ((cb: (deadline: { didTimeout?: boolean, timeRemaining?: () => number }) => void, opts?: { timeout?: number }) => {
|
||||
return window.setTimeout(() => cb({ didTimeout: true, timeRemaining: () => 0 }), opts?.timeout ?? 600)
|
||||
})
|
||||
const cancelIdle = (window as any).cancelIdleCallback
|
||||
?? ((id: number) => window.clearTimeout(id))
|
||||
|
||||
const id = requestIdle(() => {
|
||||
void getUseMonaco()
|
||||
}, { timeout: 900 })
|
||||
|
||||
return () => cancelIdle(id)
|
||||
}, [parsedNodes, props.renderCodeBlocksAsPre])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined')
|
||||
return
|
||||
if (props.renderCodeBlocksAsPre)
|
||||
return
|
||||
const theme = props.isDark ? props.codeBlockDarkTheme : props.codeBlockLightTheme
|
||||
const nextKey = typeof theme === 'string'
|
||||
? theme
|
||||
: (typeof theme === 'object' && theme && 'name' in (theme as any) ? String((theme as any).name) : null)
|
||||
console.log('[AQBot Theme Debug] NodeRenderer useEffect:', { theme, nextKey, prevKey: desiredThemeKeyRef.current, isDark: props.isDark, darkTheme: props.codeBlockDarkTheme, lightTheme: props.codeBlockLightTheme })
|
||||
if (nextKey && desiredThemeKeyRef.current !== nextKey) {
|
||||
desiredThemeKeyRef.current = nextKey
|
||||
setDesiredMonacoTheme(theme)
|
||||
console.log('[AQBot Theme Debug] setDesiredMonacoTheme called with:', theme)
|
||||
}
|
||||
else {
|
||||
console.log('[AQBot Theme Debug] skipped setDesiredMonacoTheme — same key or null')
|
||||
}
|
||||
}, [props.codeBlockDarkTheme, props.codeBlockLightTheme, props.isDark, props.renderCodeBlocksAsPre])
|
||||
|
||||
const indexPrefix = useMemo(() => {
|
||||
return props.indexKey != null ? String(props.indexKey) : 'markdown-renderer'
|
||||
}, [props.indexKey])
|
||||
|
||||
const mergedCodeBlockProps = useMemo(() => {
|
||||
return {
|
||||
...(typeof props.showTooltips === 'boolean' ? { showTooltips: props.showTooltips } : {}),
|
||||
...(props.codeBlockProps || {}),
|
||||
}
|
||||
}, [props.codeBlockProps, props.showTooltips])
|
||||
|
||||
const mergedMermaidProps = useMemo(() => {
|
||||
return {
|
||||
...(props.mermaidProps || {}),
|
||||
}
|
||||
}, [props.mermaidProps])
|
||||
|
||||
const mergedD2Props = useMemo(() => {
|
||||
return {
|
||||
...(props.d2Props || {}),
|
||||
}
|
||||
}, [props.d2Props])
|
||||
|
||||
const mergedInfographicProps = useMemo(() => {
|
||||
return {
|
||||
...(props.infographicProps || {}),
|
||||
}
|
||||
}, [props.infographicProps])
|
||||
|
||||
const renderCtx = useMemo<RenderContext>(() => ({
|
||||
customId: props.customId,
|
||||
isDark: props.isDark,
|
||||
indexKey: indexPrefix,
|
||||
typewriter: props.typewriter,
|
||||
textStreamState: textStreamStateRef.current,
|
||||
streamRenderVersion: streamRenderVersionRef.current,
|
||||
customComponents,
|
||||
customHtmlTags: effectiveCustomHtmlTags,
|
||||
showTooltips: props.showTooltips,
|
||||
renderCodeBlocksAsPre: props.renderCodeBlocksAsPre,
|
||||
codeBlockStream: props.codeBlockStream,
|
||||
codeBlockProps: mergedCodeBlockProps,
|
||||
mermaidProps: mergedMermaidProps,
|
||||
d2Props: mergedD2Props,
|
||||
infographicProps: mergedInfographicProps,
|
||||
codeBlockThemes: {
|
||||
themes: props.themes,
|
||||
darkTheme: props.codeBlockDarkTheme,
|
||||
lightTheme: props.codeBlockLightTheme,
|
||||
monacoOptions: props.codeBlockMonacoOptions,
|
||||
minWidth: props.codeBlockMinWidth,
|
||||
maxWidth: props.codeBlockMaxWidth,
|
||||
},
|
||||
events: {
|
||||
onCopy: props.onCopy,
|
||||
onHandleArtifactClick: props.onHandleArtifactClick,
|
||||
},
|
||||
}), [
|
||||
props.customId,
|
||||
props.isDark,
|
||||
indexPrefix,
|
||||
props.typewriter,
|
||||
props.showTooltips,
|
||||
props.renderCodeBlocksAsPre,
|
||||
props.codeBlockStream,
|
||||
effectiveCustomHtmlTags,
|
||||
mergedCodeBlockProps,
|
||||
mergedMermaidProps,
|
||||
mergedD2Props,
|
||||
mergedInfographicProps,
|
||||
props.themes,
|
||||
props.codeBlockDarkTheme,
|
||||
props.codeBlockLightTheme,
|
||||
props.codeBlockMonacoOptions,
|
||||
props.codeBlockMinWidth,
|
||||
props.codeBlockMaxWidth,
|
||||
streamRenderVersionRef.current,
|
||||
props.onCopy,
|
||||
props.onHandleArtifactClick,
|
||||
customComponents,
|
||||
])
|
||||
|
||||
return (
|
||||
<ViewportPriorityProvider
|
||||
getRoot={() => containerRef.current}
|
||||
enabled={props.viewportPriority !== false}
|
||||
>
|
||||
<MemoNodeRendererInner
|
||||
props={props}
|
||||
parsedNodes={parsedNodes}
|
||||
renderCtx={renderCtx}
|
||||
indexPrefix={indexPrefix}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
</ViewportPriorityProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default NodeRenderer
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { NodeComponentProps } from '../../types/node-component'
|
||||
import React from 'react'
|
||||
|
||||
export function FallbackComponent(props: NodeComponentProps<{ type: string }>) {
|
||||
const { node } = props
|
||||
return (
|
||||
<div className="unknown-node text-sm text-gray-500 italic">
|
||||
Unsupported node type:
|
||||
{' '}
|
||||
{String((node as any)?.type)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FallbackComponent
|
||||
@@ -0,0 +1,12 @@
|
||||
let isPreloaded = false
|
||||
|
||||
export async function preload(mod: any) {
|
||||
if (isPreloaded)
|
||||
return
|
||||
isPreloaded = true
|
||||
const existingEnv = (globalThis as any)?.MonacoEnvironment
|
||||
if (existingEnv && (typeof existingEnv.getWorker === 'function' || typeof existingEnv.getWorkerUrl === 'function'))
|
||||
return
|
||||
if (mod?.preloadMonacoWorkers)
|
||||
await mod.preloadMonacoWorkers()
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { ParsedNode } from 'stream-markdown-parser'
|
||||
import type { NodeComponentProps } from '../../types/node-component'
|
||||
import React from 'react'
|
||||
import { getCustomNodeComponents } from '../../customComponents'
|
||||
import { BLOCK_LEVEL_TYPES, renderNodeChildren } from '../../renderers/renderChildren'
|
||||
import { isParagraphBreakingCustomHtmlNode } from '../../utils/customHtmlTag'
|
||||
|
||||
function isWhitespaceTextNode(node: ParsedNode | null | undefined) {
|
||||
return node?.type === 'text' && String((node as any)?.content ?? '').trim() === ''
|
||||
}
|
||||
|
||||
function getMeaningfulLinkChildren(node: ParsedNode | null | undefined) {
|
||||
if (node?.type !== 'link' || !Array.isArray((node as any)?.children))
|
||||
return []
|
||||
|
||||
return ((node as any).children as ParsedNode[]).filter(child => !isWhitespaceTextNode(child))
|
||||
}
|
||||
|
||||
function isImageOnlyLinkNode(node: ParsedNode | null | undefined) {
|
||||
const linkChildren = getMeaningfulLinkChildren(node)
|
||||
return linkChildren.length === 1 && linkChildren[0]?.type === 'image'
|
||||
}
|
||||
|
||||
function renderParagraphInlineNodes(
|
||||
nodes: ParsedNode[],
|
||||
renderNode: NonNullable<NodeComponentProps<{ type: 'paragraph', children?: ParsedNode[] }>['renderNode']>,
|
||||
ctx: NonNullable<NodeComponentProps<{ type: 'paragraph', children?: ParsedNode[] }>['ctx']>,
|
||||
prefix: string,
|
||||
) {
|
||||
const meaningfulChildren = nodes.filter(child => !isWhitespaceTextNode(child))
|
||||
const mediaOnly = meaningfulChildren.length > 0
|
||||
&& meaningfulChildren.every(child => child.type === 'image' || isImageOnlyLinkNode(child))
|
||||
|
||||
if (!mediaOnly || meaningfulChildren.length <= 1)
|
||||
return renderNodeChildren(nodes, ctx, prefix, renderNode)
|
||||
|
||||
const normalizedNodes: ParsedNode[] = []
|
||||
for (let index = 0; index < nodes.length; index++) {
|
||||
const child = nodes[index]
|
||||
if (!isWhitespaceTextNode(child)) {
|
||||
normalizedNodes.push(child)
|
||||
continue
|
||||
}
|
||||
|
||||
const hasPrevious = normalizedNodes.length > 0
|
||||
const hasNext = nodes.slice(index + 1).some(nextChild => !isWhitespaceTextNode(nextChild))
|
||||
if (!hasPrevious || !hasNext)
|
||||
continue
|
||||
|
||||
normalizedNodes.push({
|
||||
...(child as any),
|
||||
content: ' ',
|
||||
raw: ' ',
|
||||
})
|
||||
}
|
||||
|
||||
return normalizedNodes.map((child, index) => (
|
||||
mediaOnly && isWhitespaceTextNode(child)
|
||||
? <React.Fragment key={`${prefix}-${index}`}>{String((child as any)?.content ?? '')}</React.Fragment>
|
||||
: renderNode(child, `${prefix}-${index}`, ctx)
|
||||
))
|
||||
}
|
||||
|
||||
export function ParagraphNode(props: NodeComponentProps<{ type: 'paragraph', children?: ParsedNode[] }>) {
|
||||
const { node, ctx, renderNode, indexKey, children } = props
|
||||
if (!ctx || !renderNode) {
|
||||
return (
|
||||
<p dir="auto" className="paragraph-node">
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
const nodeChildren = node.children ?? []
|
||||
const customComponents = ctx.customComponents ?? getCustomNodeComponents(ctx.customId)
|
||||
const parts: React.ReactNode[] = []
|
||||
const inlineBuffer: ParsedNode[] = []
|
||||
|
||||
const flushInline = () => {
|
||||
if (!inlineBuffer.length)
|
||||
return
|
||||
const chunkIndex = parts.length
|
||||
parts.push(
|
||||
<p key={`${String(indexKey ?? 'paragraph')}-inline-${chunkIndex}`} dir="auto" className="paragraph-node">
|
||||
{renderParagraphInlineNodes(inlineBuffer.slice(), renderNode, ctx, `${String(indexKey ?? 'paragraph')}-${chunkIndex}`)}
|
||||
</p>,
|
||||
)
|
||||
inlineBuffer.length = 0
|
||||
}
|
||||
|
||||
nodeChildren.forEach((child, childIndex) => {
|
||||
if (BLOCK_LEVEL_TYPES.has(child.type) || isParagraphBreakingCustomHtmlNode(child, customComponents, ctx.customHtmlTags)) {
|
||||
flushInline()
|
||||
parts.push(
|
||||
<React.Fragment key={`${String(indexKey ?? 'paragraph')}-block-${childIndex}`}>
|
||||
{renderNode(child, `${String(indexKey ?? 'paragraph')}-block-${childIndex}`, ctx)}
|
||||
</React.Fragment>,
|
||||
)
|
||||
}
|
||||
else {
|
||||
inlineBuffer.push(child)
|
||||
}
|
||||
})
|
||||
flushInline()
|
||||
|
||||
if (!parts.length) {
|
||||
return (
|
||||
<p dir="auto" className="paragraph-node">
|
||||
{renderParagraphInlineNodes(nodeChildren, renderNode, ctx, String(indexKey ?? 'paragraph'))}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return <>{parts}</>
|
||||
}
|
||||
|
||||
export default ParagraphNode
|
||||
@@ -0,0 +1,2 @@
|
||||
export { PreCodeNode } from '../CodeBlockNode/PreCodeNode'
|
||||
export { PreCodeNode as default } from '../CodeBlockNode/PreCodeNode'
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { NodeComponentProps } from '../../types/node-component'
|
||||
import React, { useCallback } from 'react'
|
||||
|
||||
export interface ReferenceNodeHandlers {
|
||||
onClick?: (event: React.MouseEvent, id: string, messageId?: string, threadId?: string) => void
|
||||
onMouseEnter?: (event: React.MouseEvent, id: string, messageId?: string, threadId?: string) => void
|
||||
onMouseLeave?: (event: React.MouseEvent, id: string, messageId?: string, threadId?: string) => void
|
||||
}
|
||||
|
||||
export function ReferenceNode(
|
||||
props: NodeComponentProps<{ type: 'reference', id: string }>
|
||||
& { messageId?: string, threadId?: string }
|
||||
& ReferenceNodeHandlers,
|
||||
) {
|
||||
const { node } = props
|
||||
const handleClick = useCallback((event: React.MouseEvent) => {
|
||||
props.onClick?.(event, node.id, props.messageId, props.threadId)
|
||||
}, [node.id, props])
|
||||
|
||||
return (
|
||||
<span
|
||||
className="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}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={(event) => {
|
||||
props.onMouseEnter?.(event, node.id, props.messageId, props.threadId)
|
||||
}}
|
||||
onMouseLeave={(event) => {
|
||||
props.onMouseLeave?.(event, node.id, props.messageId, props.threadId)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ')
|
||||
handleClick(event as any)
|
||||
}}
|
||||
>
|
||||
{node.id}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReferenceNode
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { NodeComponentProps } from '../../types/node-component'
|
||||
import React from 'react'
|
||||
|
||||
export function StrikethroughNode(props: NodeComponentProps<{ type: 'strikethrough' }>) {
|
||||
return <s className="strikethrough-node">{props.children}</s>
|
||||
}
|
||||
|
||||
export default StrikethroughNode
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { NodeComponentProps } from '../../types/node-component'
|
||||
import React from 'react'
|
||||
|
||||
export function StrongNode(props: NodeComponentProps<{ type: 'strong' }>) {
|
||||
return <strong className="strong-node">{props.children}</strong>
|
||||
}
|
||||
|
||||
export default StrongNode
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { NodeComponentProps } from '../../types/node-component'
|
||||
import React from 'react'
|
||||
|
||||
export function SubscriptNode(props: NodeComponentProps<{ type: 'subscript' }>) {
|
||||
return <sub className="subscript-node">{props.children}</sub>
|
||||
}
|
||||
|
||||
export default SubscriptNode
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { NodeComponentProps } from '../../types/node-component'
|
||||
import React from 'react'
|
||||
|
||||
export function SuperscriptNode(props: NodeComponentProps<{ type: 'superscript' }>) {
|
||||
return <sup className="superscript-node">{props.children}</sup>
|
||||
}
|
||||
|
||||
export default SuperscriptNode
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { NodeComponentProps } from '../../types/node-component'
|
||||
import clsx from 'clsx'
|
||||
import React from 'react'
|
||||
import { renderInline } from '../../renderers/renderChildren'
|
||||
|
||||
export function TableNode(props: NodeComponentProps<{ type: 'table', header?: any, rows?: any[], loading?: boolean }>) {
|
||||
const { node, ctx, renderNode, indexKey } = props
|
||||
const headerCells = Array.isArray(node?.header?.cells) ? node.header.cells : []
|
||||
const isLoading = Boolean(node?.loading)
|
||||
const bodyRows = Array.isArray(node?.rows) ? node.rows : []
|
||||
const tableRef = React.useRef<HTMLTableElement | null>(null)
|
||||
const resizeStateRef = React.useRef<{
|
||||
index: number
|
||||
startX: number
|
||||
startWidth: number
|
||||
nextStartWidth: number
|
||||
widths: number[]
|
||||
} | null>(null)
|
||||
const resizeListenersRef = React.useRef<{
|
||||
move: (event: PointerEvent) => void
|
||||
stop: () => void
|
||||
} | null>(null)
|
||||
const [columnWidths, setColumnWidths] = React.useState<number[]>([])
|
||||
|
||||
const minColumnWidth = 48
|
||||
|
||||
const getAlignClass = (align?: string) => {
|
||||
if (align === 'right')
|
||||
return 'text-right'
|
||||
if (align === 'center')
|
||||
return 'text-center'
|
||||
return 'text-left'
|
||||
}
|
||||
|
||||
const measureHeaderWidths = () => {
|
||||
const cells = tableRef.current?.querySelectorAll('thead th')
|
||||
return Array.from(cells ?? [], cell => Math.round(cell.getBoundingClientRect().width))
|
||||
}
|
||||
|
||||
const stopColumnResize = () => {
|
||||
const listeners = resizeListenersRef.current
|
||||
if (listeners) {
|
||||
window.removeEventListener('pointermove', listeners.move)
|
||||
window.removeEventListener('pointerup', listeners.stop)
|
||||
window.removeEventListener('pointercancel', listeners.stop)
|
||||
resizeListenersRef.current = null
|
||||
}
|
||||
resizeStateRef.current = null
|
||||
}
|
||||
|
||||
const startColumnResize = (index: number, event: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (event.button !== 0)
|
||||
return
|
||||
|
||||
const widths = measureHeaderWidths()
|
||||
const startWidth = widths[index]
|
||||
const nextStartWidth = widths[index + 1]
|
||||
if (!startWidth || !nextStartWidth)
|
||||
return
|
||||
|
||||
event.preventDefault()
|
||||
stopColumnResize()
|
||||
|
||||
resizeStateRef.current = {
|
||||
index,
|
||||
startX: event.clientX,
|
||||
startWidth,
|
||||
nextStartWidth,
|
||||
widths,
|
||||
}
|
||||
setColumnWidths(widths)
|
||||
|
||||
const move = (moveEvent: PointerEvent) => {
|
||||
const resizeState = resizeStateRef.current
|
||||
if (!resizeState)
|
||||
return
|
||||
|
||||
moveEvent.preventDefault()
|
||||
|
||||
const pairWidth = resizeState.startWidth + resizeState.nextStartWidth
|
||||
const minWidth = Math.min(minColumnWidth, Math.floor(pairWidth / 2))
|
||||
const width = Math.max(
|
||||
minWidth,
|
||||
Math.min(pairWidth - minWidth, Math.round(resizeState.startWidth + moveEvent.clientX - resizeState.startX)),
|
||||
)
|
||||
const nextWidths = [...resizeState.widths]
|
||||
nextWidths[resizeState.index] = width
|
||||
nextWidths[resizeState.index + 1] = pairWidth - width
|
||||
setColumnWidths(nextWidths)
|
||||
}
|
||||
const stop = () => {
|
||||
stopColumnResize()
|
||||
}
|
||||
|
||||
resizeListenersRef.current = { move, stop }
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', stop)
|
||||
window.addEventListener('pointercancel', stop)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
stopColumnResize()
|
||||
setColumnWidths([])
|
||||
|
||||
return stopColumnResize
|
||||
}, [headerCells.length])
|
||||
|
||||
return (
|
||||
<div className="table-node-wrapper" data-index-key={indexKey}>
|
||||
<table
|
||||
ref={tableRef}
|
||||
className={clsx(
|
||||
'my-8 text-sm table-node',
|
||||
isLoading && 'table-node--loading',
|
||||
)}
|
||||
aria-busy={isLoading}
|
||||
>
|
||||
{columnWidths.length > 0 && (
|
||||
<colgroup>
|
||||
{headerCells.map((_: any, idx: number) => (
|
||||
<col key={`col-${idx}`} style={columnWidths[idx] > 0 ? { width: `${columnWidths[idx]}px` } : undefined} />
|
||||
))}
|
||||
</colgroup>
|
||||
)}
|
||||
<thead className="border-[var(--table-border,#cbd5e1)]">
|
||||
<tr className="border-b">
|
||||
{headerCells.map((cell: any, idx: number) => (
|
||||
<th
|
||||
key={`header-${idx}`}
|
||||
className={clsx('font-semibold p-[calc(4/7*1em)]', getAlignClass(cell.align))}
|
||||
dir="auto"
|
||||
>
|
||||
{ctx && renderNode ? renderInline(cell.children, ctx, `${String(indexKey ?? 'table')}-th-${idx}`, renderNode) : null}
|
||||
{idx < headerCells.length - 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="table-node__resize-handle"
|
||||
aria-label={`Resize columns ${idx + 1} and ${idx + 2}`}
|
||||
onPointerDown={event => startColumnResize(idx, event)}
|
||||
/>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bodyRows.map((row: any, rowIdx: number) => (
|
||||
<tr
|
||||
key={`row-${rowIdx}`}
|
||||
className={clsx(
|
||||
'border-[var(--table-border,#cbd5e1)]',
|
||||
rowIdx < bodyRows.length - 1 && 'border-b',
|
||||
)}
|
||||
>
|
||||
{row.cells?.map((cell: any, cellIdx: number) => (
|
||||
<td
|
||||
key={`cell-${rowIdx}-${cellIdx}`}
|
||||
className={clsx('p-[calc(4/7*1em)]', getAlignClass(cell.align))}
|
||||
dir="auto"
|
||||
>
|
||||
{ctx && renderNode ? renderInline(cell.children, ctx, `${String(indexKey ?? 'table')}-row-${rowIdx}-${cellIdx}`, renderNode) : null}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{isLoading && (
|
||||
<div className="table-node__loading" role="status" aria-live="polite">
|
||||
<span className="table-node__spinner" aria-hidden="true" />
|
||||
<span className="sr-only">Loading</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TableNode
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { NodeComponentProps } from '../../types/node-component'
|
||||
import clsx from 'clsx'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { resolveStreamingTextUpdate } from '../../utils/streamingTextState'
|
||||
|
||||
export function TextNode(props: NodeComponentProps<{ type: 'text', content: string, center?: boolean }>) {
|
||||
const { node, children, ctx, indexKey, typewriter } = props
|
||||
const content = String(node.content ?? '')
|
||||
const typewriterEnabled = typewriter ?? ctx?.typewriter ?? true
|
||||
const streamStateKey = indexKey == null || indexKey === ''
|
||||
? ''
|
||||
: String(indexKey)
|
||||
const [settledContent, setSettledContent] = useState(content)
|
||||
const [streamedDelta, setStreamedDelta] = useState('')
|
||||
const [streamFadeVersion, setStreamFadeVersion] = useState(0)
|
||||
const lastStreamRenderVersionRef = useRef(ctx?.streamRenderVersion)
|
||||
const renderedContentRef = useRef({
|
||||
settledContent: content,
|
||||
streamedDelta: '',
|
||||
})
|
||||
|
||||
const setRenderedContent = (nextSettledContent: string, nextStreamedDelta: string) => {
|
||||
renderedContentRef.current = {
|
||||
settledContent: nextSettledContent,
|
||||
streamedDelta: nextStreamedDelta,
|
||||
}
|
||||
setSettledContent(nextSettledContent)
|
||||
setStreamedDelta(nextStreamedDelta)
|
||||
}
|
||||
|
||||
const getRenderedContent = () => {
|
||||
const { settledContent, streamedDelta } = renderedContentRef.current
|
||||
return settledContent + streamedDelta
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const streamRenderVersion = ctx?.streamRenderVersion
|
||||
const streamRenderVersionChanged = streamRenderVersion !== lastStreamRenderVersionRef.current
|
||||
|
||||
if (children != null) {
|
||||
setRenderedContent('', '')
|
||||
lastStreamRenderVersionRef.current = streamRenderVersion
|
||||
return
|
||||
}
|
||||
|
||||
const textStreamState = ctx?.textStreamState
|
||||
const currentState = renderedContentRef.current
|
||||
const persistedContent = streamStateKey
|
||||
? textStreamState?.get(streamStateKey)
|
||||
: undefined
|
||||
const nextState = resolveStreamingTextUpdate({
|
||||
nextContent: content,
|
||||
persistedContent,
|
||||
currentState,
|
||||
typewriterEnabled,
|
||||
streamRenderVersionChanged,
|
||||
})
|
||||
|
||||
setRenderedContent(nextState.settledContent, nextState.streamedDelta)
|
||||
if (nextState.appended)
|
||||
setStreamFadeVersion(version => version + 1)
|
||||
if (streamStateKey)
|
||||
textStreamState?.set(streamStateKey, content)
|
||||
lastStreamRenderVersionRef.current = streamRenderVersion
|
||||
}, [children, content, ctx?.textStreamState, ctx?.streamRenderVersion, streamStateKey, typewriterEnabled])
|
||||
|
||||
const handleStreamedDeltaAnimationEnd = () => {
|
||||
if (!renderedContentRef.current.streamedDelta)
|
||||
return
|
||||
setRenderedContent(getRenderedContent(), '')
|
||||
}
|
||||
|
||||
if (children != null) {
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
'text-node whitespace-pre-wrap break-words',
|
||||
node.center && 'text-node-center',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
'text-node whitespace-pre-wrap break-words',
|
||||
node.center && 'text-node-center',
|
||||
)}
|
||||
>
|
||||
{settledContent ? <span>{settledContent}</span> : null}
|
||||
{streamedDelta
|
||||
? (
|
||||
<span
|
||||
className={clsx(
|
||||
'text-node-stream-delta',
|
||||
streamFadeVersion % 2 === 0
|
||||
? 'text-node-stream-delta--a'
|
||||
: 'text-node-stream-delta--b',
|
||||
)}
|
||||
onAnimationEnd={handleStreamedDeltaAnimationEnd}
|
||||
>
|
||||
{streamedDelta}
|
||||
</span>
|
||||
)
|
||||
: null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default TextNode
|
||||
@@ -0,0 +1,7 @@
|
||||
import React from 'react'
|
||||
|
||||
export function ThematicBreakNode() {
|
||||
return <hr className="hr-node thematic-break" />
|
||||
}
|
||||
|
||||
export default ThematicBreakNode
|
||||
@@ -0,0 +1,110 @@
|
||||
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom'
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
export type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right'
|
||||
|
||||
export interface TooltipProps {
|
||||
visible: boolean
|
||||
anchorEl: HTMLElement | null
|
||||
content: string
|
||||
placement?: TooltipPlacement
|
||||
offset?: number
|
||||
originX?: number | null
|
||||
originY?: number | null
|
||||
id?: string | null
|
||||
isDark?: boolean | null
|
||||
}
|
||||
|
||||
function detectDarkModeHint(hint?: boolean | null) {
|
||||
if (typeof hint === 'boolean')
|
||||
return hint
|
||||
if (typeof document !== 'undefined') {
|
||||
try {
|
||||
if (document.documentElement.classList.contains('dark'))
|
||||
return true
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
|
||||
try {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function Tooltip(props: TooltipProps) {
|
||||
const tooltipRef = useRef<HTMLDivElement | null>(null)
|
||||
const [ready, setReady] = useState(false)
|
||||
const [style, setStyle] = useState<React.CSSProperties>({ transform: 'translate3d(0px, 0px, 0px)', left: '0px', top: '0px' })
|
||||
|
||||
const isDarkEffective = useMemo(() => detectDarkModeHint(props.isDark), [props.isDark])
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.visible) {
|
||||
setReady(false)
|
||||
return
|
||||
}
|
||||
if (!props.anchorEl || !tooltipRef.current) {
|
||||
setReady(true)
|
||||
return
|
||||
}
|
||||
let cleanup: (() => void) | null = null
|
||||
let cancelled = false
|
||||
|
||||
const update = async () => {
|
||||
if (!props.anchorEl || !tooltipRef.current)
|
||||
return
|
||||
const { x, y } = await computePosition(props.anchorEl, tooltipRef.current, {
|
||||
placement: props.placement ?? 'top',
|
||||
middleware: [offset(props.offset ?? 8), flip(), shift({ padding: 8 })],
|
||||
strategy: 'fixed',
|
||||
})
|
||||
if (cancelled)
|
||||
return
|
||||
setStyle({
|
||||
position: 'fixed',
|
||||
left: '0px',
|
||||
top: '0px',
|
||||
transform: `translate3d(${Math.round(x)}px, ${Math.round(y)}px, 0)`,
|
||||
})
|
||||
}
|
||||
|
||||
void update().then(() => {
|
||||
if (!cancelled)
|
||||
setReady(true)
|
||||
})
|
||||
|
||||
cleanup = autoUpdate(props.anchorEl, tooltipRef.current, update)
|
||||
return () => {
|
||||
cancelled = true
|
||||
cleanup?.()
|
||||
}
|
||||
}, [props.anchorEl, props.offset, props.placement, props.visible, ready])
|
||||
|
||||
if (typeof document === 'undefined')
|
||||
return null
|
||||
|
||||
if (!props.visible || !ready)
|
||||
return null
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
id={props.id ?? undefined}
|
||||
ref={tooltipRef}
|
||||
style={style}
|
||||
className={[
|
||||
'ms-tooltip z-[9999] inline-block text-base py-2 px-3 rounded-md shadow-md whitespace-nowrap pointer-events-none border tooltip-element',
|
||||
isDarkEffective ? 'bg-gray-900 text-white border-gray-700 is-dark' : 'bg-white text-gray-900 border-gray-200',
|
||||
].join(' ')}
|
||||
role="tooltip"
|
||||
>
|
||||
{props.content}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
export default Tooltip
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ParsedNode } from 'stream-markdown-parser'
|
||||
import type { NodeComponentProps } from '../../types/node-component'
|
||||
import React, { useMemo } from 'react'
|
||||
import { tokenAttrsToProps } from '../../renderers/renderChildren'
|
||||
|
||||
export function VmrContainerNode(props: NodeComponentProps<{
|
||||
type: 'vmr_container'
|
||||
name: string
|
||||
attrs?: Record<string, string> | [string, string | null][] | null
|
||||
children?: ParsedNode[]
|
||||
}>) {
|
||||
const { node, ctx, renderNode, indexKey } = props
|
||||
const containerClass = `vmr-container vmr-container-${node.name}`
|
||||
const boundAttrs = useMemo(() => {
|
||||
if (!node.attrs)
|
||||
return undefined
|
||||
if (Array.isArray(node.attrs))
|
||||
return tokenAttrsToProps(node.attrs as any)
|
||||
return node.attrs as any
|
||||
}, [node.attrs])
|
||||
|
||||
return (
|
||||
<div className={containerClass} {...(boundAttrs as any)}>
|
||||
{(ctx && renderNode && Array.isArray(node.children))
|
||||
? node.children.map((child, idx) => (
|
||||
<React.Fragment key={`${String(indexKey ?? 'vmr-container')}-${idx}`}>
|
||||
{renderNode(child, `${String(indexKey ?? 'vmr-container')}-${idx}`, ctx)}
|
||||
</React.Fragment>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VmrContainerNode
|
||||
@@ -0,0 +1,236 @@
|
||||
import React, { createContext, useContext, useMemo, useRef } from 'react'
|
||||
|
||||
export interface VisibilityHandle {
|
||||
isVisible: () => boolean
|
||||
whenVisible: Promise<void>
|
||||
destroy: () => void
|
||||
}
|
||||
|
||||
export type RegisterViewportFn = (el: HTMLElement, opts?: { rootMargin?: string, threshold?: number }) => VisibilityHandle
|
||||
|
||||
type GetRootFn = () => HTMLElement | null
|
||||
type EnabledFn = () => boolean
|
||||
interface IdleDeadlineLike {
|
||||
didTimeout: boolean
|
||||
timeRemaining: () => number
|
||||
}
|
||||
|
||||
const ViewportPriorityContext = createContext<RegisterViewportFn | null>(null)
|
||||
|
||||
function createViewportRegistrar(getRoot: GetRootFn, enabled: EnabledFn): RegisterViewportFn {
|
||||
const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined'
|
||||
const requestIdle = isBrowser
|
||||
? ((window as any).requestIdleCallback as ((cb: (deadline: IdleDeadlineLike) => void, opts?: { timeout?: number }) => number) | undefined)
|
||||
?? ((cb: (deadline: IdleDeadlineLike) => void) => window.setTimeout(() => cb({ didTimeout: true, timeRemaining: () => 0 }), 16))
|
||||
: null
|
||||
const cancelIdle = isBrowser
|
||||
? ((window as any).cancelIdleCallback as ((id: number) => void) | undefined)
|
||||
?? ((id: number) => window.clearTimeout(id))
|
||||
: null
|
||||
interface ObserverConfig {
|
||||
root: Element | null
|
||||
rootMargin: string
|
||||
threshold: number
|
||||
}
|
||||
|
||||
let observer: IntersectionObserver | null = null
|
||||
let currentConfig: ObserverConfig | null = null
|
||||
const targets = new Map<Element, { resolve: () => void, state: { current: boolean } }>()
|
||||
const idleQueue = new Set<Element>()
|
||||
let idleJob: number | null = null
|
||||
|
||||
const normalizeConfig = (rootMargin: string, threshold: number): ObserverConfig => ({
|
||||
root: getRoot() ?? null,
|
||||
rootMargin,
|
||||
threshold,
|
||||
})
|
||||
|
||||
const sameConfig = (a: ObserverConfig | null, b: ObserverConfig) => {
|
||||
return !!a
|
||||
&& a.root === b.root
|
||||
&& a.rootMargin === b.rootMargin
|
||||
&& a.threshold === b.threshold
|
||||
}
|
||||
|
||||
const clearIdleJob = () => {
|
||||
if (idleJob == null)
|
||||
return
|
||||
try {
|
||||
cancelIdle?.(idleJob)
|
||||
}
|
||||
catch {}
|
||||
idleJob = null
|
||||
}
|
||||
|
||||
const cleanupObserver = () => {
|
||||
if (targets.size)
|
||||
return
|
||||
try {
|
||||
observer?.disconnect()
|
||||
}
|
||||
catch {}
|
||||
observer = null
|
||||
currentConfig = null
|
||||
if (!idleQueue.size)
|
||||
clearIdleJob()
|
||||
}
|
||||
|
||||
const settleTarget = (target: Element) => {
|
||||
const data = targets.get(target)
|
||||
if (!data)
|
||||
return
|
||||
data.state.current = true
|
||||
try {
|
||||
data.resolve()
|
||||
}
|
||||
catch {}
|
||||
try {
|
||||
observer?.unobserve(target)
|
||||
}
|
||||
catch {}
|
||||
targets.delete(target)
|
||||
idleQueue.delete(target)
|
||||
cleanupObserver()
|
||||
}
|
||||
|
||||
const scheduleIdleDrain = () => {
|
||||
if (!requestIdle || idleJob != null || !idleQueue.size)
|
||||
return
|
||||
idleJob = requestIdle(() => {
|
||||
idleJob = null
|
||||
const next = idleQueue.values().next().value as Element | undefined
|
||||
if (!next)
|
||||
return
|
||||
idleQueue.delete(next)
|
||||
settleTarget(next)
|
||||
if (idleQueue.size)
|
||||
scheduleIdleDrain()
|
||||
}, { timeout: 1200 })
|
||||
}
|
||||
|
||||
const ensureObserver = (rootMargin: string, threshold: number) => {
|
||||
if (!isBrowser)
|
||||
return null
|
||||
if (typeof IntersectionObserver === 'undefined')
|
||||
return null
|
||||
|
||||
const nextConfig = normalizeConfig(rootMargin, threshold)
|
||||
if (observer && sameConfig(currentConfig, nextConfig))
|
||||
return observer
|
||||
|
||||
if (observer) {
|
||||
try {
|
||||
observer.disconnect()
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const isVisible = entry.isIntersecting || entry.intersectionRatio > 0
|
||||
if (isVisible)
|
||||
settleTarget(entry.target)
|
||||
}
|
||||
}, {
|
||||
root: nextConfig.root,
|
||||
rootMargin: nextConfig.rootMargin,
|
||||
threshold: nextConfig.threshold,
|
||||
})
|
||||
|
||||
currentConfig = nextConfig
|
||||
|
||||
for (const element of targets.keys())
|
||||
observer.observe(element)
|
||||
|
||||
return observer
|
||||
}
|
||||
|
||||
const register: RegisterViewportFn = (el, opts) => {
|
||||
const state = { current: false }
|
||||
let settled = false
|
||||
let resolve!: () => void
|
||||
const whenVisible = new Promise<void>((res) => {
|
||||
resolve = () => {
|
||||
if (settled)
|
||||
return
|
||||
settled = true
|
||||
res()
|
||||
}
|
||||
})
|
||||
const destroy = () => {
|
||||
if (targets.has(el)) {
|
||||
targets.delete(el)
|
||||
try {
|
||||
observer?.unobserve(el)
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
idleQueue.delete(el)
|
||||
cleanupObserver()
|
||||
}
|
||||
|
||||
if (!isBrowser || !enabled()) {
|
||||
state.current = true
|
||||
resolve()
|
||||
return {
|
||||
isVisible: () => true,
|
||||
whenVisible,
|
||||
destroy,
|
||||
}
|
||||
}
|
||||
|
||||
const rootMargin = opts?.rootMargin ?? '300px'
|
||||
const threshold = opts?.threshold ?? 0
|
||||
const nextObserver = ensureObserver(rootMargin, threshold)
|
||||
if (!nextObserver) {
|
||||
state.current = true
|
||||
resolve()
|
||||
return {
|
||||
isVisible: () => true,
|
||||
whenVisible,
|
||||
destroy,
|
||||
}
|
||||
}
|
||||
|
||||
targets.set(el, { resolve, state })
|
||||
nextObserver.observe(el)
|
||||
idleQueue.add(el)
|
||||
scheduleIdleDrain()
|
||||
return {
|
||||
isVisible: () => state.current,
|
||||
whenVisible,
|
||||
destroy,
|
||||
}
|
||||
}
|
||||
|
||||
return register
|
||||
}
|
||||
|
||||
const fallbackRegister = createViewportRegistrar(() => null, () => true)
|
||||
|
||||
export interface ViewportPriorityProviderProps {
|
||||
getRoot: () => HTMLElement | null
|
||||
enabled?: boolean
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function ViewportPriorityProvider({ getRoot, enabled = true, children }: ViewportPriorityProviderProps) {
|
||||
const enabledRef = useRef(enabled)
|
||||
enabledRef.current = enabled
|
||||
const getRootRef = useRef(getRoot)
|
||||
getRootRef.current = getRoot
|
||||
|
||||
const registrar = useMemo(() => {
|
||||
return createViewportRegistrar(() => getRootRef.current?.() ?? null, () => enabledRef.current)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ViewportPriorityContext.Provider value={registrar}>
|
||||
{children}
|
||||
</ViewportPriorityContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useViewportPriority(): RegisterViewportFn {
|
||||
return useContext(ViewportPriorityContext) ?? fallbackRegister
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { ComponentType } from 'react'
|
||||
|
||||
export type CustomComponentDisplayMode = 'inline' | 'block'
|
||||
export type MarkstreamCustomComponent<P = any> = ComponentType<P> & {
|
||||
markstreamDisplay?: CustomComponentDisplayMode
|
||||
}
|
||||
export type CustomComponentMap = Record<string, MarkstreamCustomComponent<any>>
|
||||
|
||||
const GLOBAL_KEY = '__global__'
|
||||
|
||||
interface Store {
|
||||
scopedComponents: Record<string, CustomComponentMap>
|
||||
revision: number
|
||||
listeners: Set<() => void>
|
||||
}
|
||||
|
||||
const STORE_KEY = '__MARKSTREAM_REACT_CUSTOM_COMPONENTS_STORE__'
|
||||
const store: Store = (() => {
|
||||
const g = globalThis as any
|
||||
if (g[STORE_KEY])
|
||||
return g[STORE_KEY] as Store
|
||||
const next: Store = {
|
||||
scopedComponents: {},
|
||||
revision: 0,
|
||||
listeners: new Set(),
|
||||
}
|
||||
g[STORE_KEY] = next
|
||||
return next
|
||||
})()
|
||||
|
||||
function bumpRevision() {
|
||||
store.revision++
|
||||
for (const listener of Array.from(store.listeners)) {
|
||||
try {
|
||||
listener()
|
||||
}
|
||||
catch {
|
||||
// ignore subscriber errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeCustomComponents(listener: () => void) {
|
||||
store.listeners.add(listener)
|
||||
return () => {
|
||||
store.listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
export function getCustomComponentsRevision() {
|
||||
return store.revision
|
||||
}
|
||||
|
||||
export function setCustomComponents(id: string, mapping: CustomComponentMap): void
|
||||
export function setCustomComponents(mapping: CustomComponentMap): void
|
||||
export function setCustomComponents(idOrMapping: string | CustomComponentMap, maybeMapping?: CustomComponentMap) {
|
||||
if (typeof idOrMapping === 'string')
|
||||
store.scopedComponents[idOrMapping] = { ...(maybeMapping || {}) }
|
||||
else
|
||||
store.scopedComponents[GLOBAL_KEY] = { ...idOrMapping }
|
||||
bumpRevision()
|
||||
}
|
||||
|
||||
export function getCustomNodeComponents(customId?: string): CustomComponentMap {
|
||||
const globalMapping = store.scopedComponents[GLOBAL_KEY] || {}
|
||||
if (!customId)
|
||||
return globalMapping
|
||||
|
||||
const scopedMapping = store.scopedComponents[customId] || {}
|
||||
if (!globalMapping || Object.keys(globalMapping).length === 0)
|
||||
return scopedMapping
|
||||
if (!scopedMapping || Object.keys(scopedMapping).length === 0)
|
||||
return globalMapping
|
||||
return {
|
||||
...globalMapping,
|
||||
...scopedMapping,
|
||||
}
|
||||
}
|
||||
|
||||
export function removeCustomComponents(id: string) {
|
||||
if (id === GLOBAL_KEY)
|
||||
throw new Error('removeCustomComponents: cannot delete global mapping; call clearGlobalCustomComponents instead.')
|
||||
delete store.scopedComponents[id]
|
||||
bumpRevision()
|
||||
}
|
||||
|
||||
export function clearGlobalCustomComponents() {
|
||||
delete store.scopedComponents[GLOBAL_KEY]
|
||||
bumpRevision()
|
||||
}
|
||||
|
||||
export function getCustomComponentDisplay(component: ComponentType<any> | null | undefined): CustomComponentDisplayMode | undefined {
|
||||
return (component as MarkstreamCustomComponent<any> | null | undefined)?.markstreamDisplay
|
||||
}
|
||||
|
||||
export function withMarkstreamComponentDisplay<T extends ComponentType<any>>(
|
||||
component: T,
|
||||
display: CustomComponentDisplayMode,
|
||||
) {
|
||||
;(component as MarkstreamCustomComponent<any>).markstreamDisplay = display
|
||||
return component as T & { markstreamDisplay: CustomComponentDisplayMode }
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
function humanizeKey(key: string) {
|
||||
const s = key.split('.').pop() || key
|
||||
return s
|
||||
.replace(/[_-]/g, ' ')
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/\b\w/g, c => c.toUpperCase())
|
||||
.trim()
|
||||
}
|
||||
|
||||
const defaultMap: Record<string, string> = {
|
||||
'common.copy': 'Copy',
|
||||
'common.copied': 'Copied',
|
||||
'common.decrease': 'Decrease',
|
||||
'common.reset': 'Reset',
|
||||
'common.increase': 'Increase',
|
||||
'common.expand': 'Expand',
|
||||
'common.collapse': 'Collapse',
|
||||
'common.preview': 'Preview',
|
||||
'common.source': 'Source',
|
||||
'common.export': 'Export',
|
||||
'common.open': 'Open',
|
||||
'common.close': 'Close',
|
||||
'common.zoomIn': 'Zoom in',
|
||||
'common.zoomOut': 'Zoom out',
|
||||
'common.resetZoom': 'Reset zoom',
|
||||
'image.loadError': 'Image failed to load',
|
||||
'image.loading': 'Loading image...',
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the entire default translation map.
|
||||
* Consumers can call this to provide their own fallback translations (e.g. Chinese).
|
||||
*/
|
||||
export function setDefaultI18nMap(map: Record<keyof typeof defaultMap, string>) {
|
||||
Object.assign(defaultMap, map)
|
||||
}
|
||||
|
||||
export function useSafeI18n() {
|
||||
const t = (key: string) => defaultMap[key] ?? humanizeKey(key)
|
||||
return { t }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
||||
<path fill="none" stroke="#8caaee" stroke-linecap="round" stroke-linejoin="round" d="m 2.5559121,12.951629 c 2.7459832,2.734744 7.1981158,2.734744 9.9441189,0 l -1.789955,-1.782586 c -1.7574201,1.750224 -4.606788,1.750224 -6.3642295,0 -1.7574416,-1.7502236 -1.7574416,-4.587893 0,-6.338097 1.7574415,-1.750224 4.6068094,-1.750224 6.3642295,0 l 0.894977,-0.8912929 0.894978,-0.891293 c -2.7460031,-2.73472867 -7.198136,-2.73472867 -9.9441191,0 -2.74598585,2.7347089 -2.74598585,7.1685599 2e-7,9.9032689 z" clip-rule="evenodd" />
|
||||
<path fill="none" stroke="#8caaee" stroke-linecap="round" stroke-linejoin="round" d="M7.5 6v4M5.513524 7.9999996H9.51304M13.486476 5.9999996v4M11.5 7.9999992h3.999516" />
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 795 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
||||
<g fill="none" stroke="#ca9ee6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m4 1.5h8c1.38 0 2.5 1.12 2.5 2.5v8c0 1.38-1.12 2.5-2.5 2.5h-8c-1.38 0-2.5-1.12-2.5-2.5v-8c0-1.38 1.12-2.5 2.5-2.5z" />
|
||||
<path stroke-width=".814" d="m 10.240861,11.529149 c 0,0.58011 0.437448,1.039154 0.96002,1.035371 l 0.451635,-0.0032 c 0.522572,-0.0036 0.949379,-0.451477 0.949379,-1.032848 0,-0.581372 -0.426807,-1.065638 -0.949379,-1.065638 l -0.451635,3.4e-5 c -0.522572,3.9e-5 -0.949379,-0.4855273 -0.949379,-1.0656374 0,-0.5801104 0.426807,-1.0378931 0.949379,-1.0378931 l 0.451635,2.825e-4 c 0.522572,3.267e-4 0.951743,0.4577827 0.951743,1.0378931 M 6.8003972,11.529149 c 0,0.58011 0.4374474,1.039154 0.9600196,1.035371 l 0.46464,-0.0032 c 0.5225722,-0.0035 0.9363738,-0.451477 0.9363738,-1.031587 0,-0.580111 -0.4090724,-1.065638 -0.9316446,-1.065638 l -0.4693692,3.4e-5 c -0.5225722,3.8e-5 -0.949379,-0.4855272 -0.949379,-1.0656373 0,-0.5801104 0.4268068,-1.0378931 0.949379,-1.0378931 h 0.4516348 c 0.5225722,0 0.9635665,0.4577827 0.9635665,1.0378931 M 3.4072246,11.529149 c 0,0.58011 0.4374474,1.051765 0.9600196,1.051765 H 4.818879 c 0.5225722,0 0.949379,-0.456521 0.949379,-1.037893 m 0.01129,-2.1312747 c 0,-0.5801103 -0.4374474,-1.037893 -0.9600196,-1.037893 L 4.3678939,8.3741358 C 3.8453217,8.3744624 3.4078743,8.8420074 3.4078743,9.4233788 v 2.1186642" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
||||
<g fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="#ef9f76" d="M1.5 1.5h13L13 13l-5 2-5-2z" />
|
||||
<path stroke="#c6d0f5" d="M11 4.5H5l.25 3h5.5l-.25 3-2.5 1-2.5-1-.08-1" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 305 B |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
||||
<g fill="none" stroke="#99d1db" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M8 10.8c4.14 0 7.5-1.25 7.5-2.8S12.14 5.2 8 5.2.5 6.45.5 8s3.36 2.8 7.5 2.8" />
|
||||
<path d="M5.52 9.4c2.07 3.5 4.86 5.72 6.23 4.95 1.37-.78.8-4.24-1.27-7.75C8.41 3.1 5.62.88 4.25 1.65c-1.37.78-.8 4.24 1.27 7.75" />
|
||||
<path d="M5.52 6.6c-2.07 3.5-2.64 6.97-1.27 7.75 1.37.77 4.16-1.45 6.23-4.95s2.64-6.97 1.27-7.75C10.38.88 7.59 3.1 5.52 6.6" />
|
||||
<path d="M8.5 8a.5.5 0 01-.5.5.5.5 0 01-.5-.5.5.5 0 01.5-.5.5.5 0 01.5.5" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 623 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
||||
<g fill="none" stroke="#e5c890" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4.5 11c0 .828427.6715729 1.5 1.5 1.5.8284271 0 1.5-.671573 1.5-1.5V7.5M12.5 8.75C12.5 8.05964406 11.9627417 7.5 11.3 7.5L10.7 7.5C10.0372583 7.5 9.5 8.05964406 9.5 8.75 9.5 9.44035594 10.0372583 10 10.7 10L11.3 10C11.9627417 10 12.5 10.5596441 12.5 11.25 12.5 11.9403559 11.9627417 12.5 11.3 12.5L10.7 12.5C10.0372583 12.5 9.5 11.9403559 9.5 11.25" />
|
||||
<path d="m 4,1.5 h 8 c 1.385,0 2.5,1.115 2.5,2.5 v 8 c 0,1.385 -1.115,2.5 -2.5,2.5 H 4 C 2.615,14.5 1.5,13.385 1.5,12 V 4 C 1.5,2.615 2.615,1.5 4,1.5 Z" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 707 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
||||
<path fill="none" stroke="#e5c890" stroke-linecap="round" stroke-linejoin="round" d="M4.5 2.5H4c-.75 0-1.5.75-1.5 1.5v2c0 1.1-1 2-1.83 2 .83 0 1.83.9 1.83 2v2c0 .75.75 1.5 1.5 1.5h.5m7-11h.5c.75 0 1.5.75 1.5 1.5v2c0 1.1 1 2 1.83 2-.83 0-1.83.9-1.83 2v2c0 .74-.75 1.5-1.5 1.5h-.5m-6.5-3a.5.5 0 100-1 .5.5 0 000 1m3 0a.5.5 0 100-1 .5.5 0 000 1m3 0a.5.5 0 100-1 .5.5 0 000 1" />
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 470 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
||||
<path fill="none" stroke="#85c1dc" stroke-linecap="round" stroke-linejoin="round" d="m9.25 8.25 2.25 2.25 2.25-2.25M3.5 11V5.5l2.04 3 1.96-3V11m4-.5V5M1.65 2.5h12.7c.59 0 1.15.49 1.15 1v9c0 .51-.56 1-1.15 1H1.65c-.59 0-1.15-.49-1.15-1V3.58c0-.5.56-1.08 1.15-1.08" />
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 361 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
||||
<path fill="none" stroke="#ca9ee6" stroke-linecap="round" stroke-linejoin="round" d="M1.5 2.5c0 6 2.25 5.75 4 7 .83.67 1.17 2 1 4h3c-.17-2 .17-3.33 1-4 1.75-1.25 4-1 4-7C12 2.5 10 3 8 7 6 3 4 2.5 1.5 2.5" />
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 302 B |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
||||
<g fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="#8caaee" d="M8.5 5.5h-3m6 0V3c0-.8-.7-1.5-1.5-1.5H7c-.8 0-1.5.7-1.5 1.5v2.5H3c-.8 0-1.5.7-1.5 1.5v2c0 .8.7 1.5 1.48 1.5" />
|
||||
<path stroke="#e5c890" d="M10.5 10.5h-3m-3 0V13c0 .8.7 1.5 1.5 1.5h3c.8 0 1.5-.7 1.5-1.5v-2.5H13c.8 0 1.5-.7 1.5-1.5V7c0-.8-.7-1.5-1.48-1.5H11.5c0 1.5 0 2-1 2h-2" />
|
||||
<path stroke="#8caaee" d="M2.98 10.5H4.5c0-1.5 0-2 1-2h2M7.5 3.5v0" />
|
||||
<path stroke="#e5c890" d="m 8.5,12.5 v 0" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 600 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
||||
<g fill="none" stroke="#8caaee" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 15.5c-.7 0-1.5-.8-1.5-1.5V5c0-.7.8-1.5 1.5-1.5h9c.7 0 1.5.8 1.5 1.5v9c0 .7-.8 1.5-1.5 1.5z" />
|
||||
<path d="m1.2 3.8 3.04-2.5S5.17.5 5.7.5h8.4c.66 0 1.4.73 1.4 1.4v7.73a2.7 2.7 0 01-.7 1.75l-2.68 3.51M3 8.5l3 2-3 2m4 0h2" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 422 B |
@@ -0,0 +1,2 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16"><!-- Icon from Lucide by Lucide Contributors - https://github.com/lucide-icons/lucide/blob/main/LICENSE --><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m10 9l-3 3l3 3m4 0l3-3l-3-3"/><rect width="18" height="18" x="3" y="3" rx="2"/></g></svg>
|
||||
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
||||
<path fill="none" stroke="#ef9f76" stroke-linecap="round" stroke-linejoin="round" d="m4.54 10 6.92-4m-6.92 4a1.5 1.5 0 10-2.6 1.5 1.5 1.5 0 002.6-1.5M8 4v8m0-8a1.5 1.5 0 100-3 1.5 1.5 0 000 3M4.54 6l6.92 4M4.54 6a1.5 1.5 0 10-2.6-1.5A1.5 1.5 0 004.54 6M8 12a1.5 1.5 0 100 3 1.5 1.5 0 000-3m3.46-2a1.5 1.5 0 102.6 1.5 1.5 1.5 0 00-2.6-1.5m0-4a1.5 1.5 0 102.6-1.5 1.5 1.5 0 00-2.6 1.5" />
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 481 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
||||
<g fill="none" stroke="#c6d0f5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M13.5 6.5v6a2 2 0 01-2 2h-7a2 2 0 01-2-2v-9c0-1.1.9-2 2-2h4.01" />
|
||||
<path d="m8.5 1.5 5 5h-4a1 1 0 01-1-1zm-3 10h5m-5-3h5m-5-3h1" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 330 B |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
||||
<g fill="none" stroke="#8caaee" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M8 11.3c4.14 0 7.5-1.28 7.5-2.86S12.14 5.58 8 5.58.5 6.86.5 8.44s3.36 2.87 7.5 2.87Z" />
|
||||
<path d="M5.52 9.87c2.07 3.6 4.86 5.86 6.23 5.07 1.37-.8.8-4.34-1.27-7.93S5.62 1.16 4.25 1.95s-.8 4.34 1.27 7.92" />
|
||||
<path d="M5.52 7.01c-2.07 3.59-2.64 7.14-1.27 7.93s4.16-1.48 6.23-5.07c2.07-3.58 2.64-7.13 1.27-7.92-1.37-.8-4.16 1.47-6.23 5.06" />
|
||||
<path d="M8.5 8.44a.5.5 0 01-.5.5.5.5 0 01-.5-.5.5.5 0 01.5-.5.5.5 0 01.5.5" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 625 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
||||
<g fill="none" stroke="#8caaee" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 1.5h8A2.5 2.5 0 0114.5 4v8a2.5 2.5 0 01-2.5 2.5H4A2.5 2.5 0 011.5 12V4A2.5 2.5 0 014 1.5" />
|
||||
<path d="M12.5 8.75c0-.69-.54-1.25-1.2-1.25h-.6c-.66 0-1.2.56-1.2 1.25S10.04 10 10.7 10h.6c.66 0 1.2.56 1.2 1.25s-.54 1.25-1.2 1.25h-.6c-.66 0-1.2-.56-1.2-1.25m-3-3.75v5M5 7.5h3" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 476 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
||||
<g fill="none" stroke="#a6d189" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M1 1.5h5.44L8 4.56 9.56 1.5H15l-6.99 13z" />
|
||||
<path d="M12.05 1.73 8 9.28 3.95 1.73" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 285 B |
@@ -0,0 +1,109 @@
|
||||
import type { CustomComponentMap as MarkstreamCustomComponentMap } from './customComponents'
|
||||
import type { RenderContext as MarkstreamRenderContext, RenderNodeFn as MarkstreamRenderNodeFn } from './types'
|
||||
import './index.css'
|
||||
import './workers/katexRenderer.worker?worker'
|
||||
import './workers/mermaidParser.worker?worker'
|
||||
|
||||
export { AdmonitionNode } from './components/AdmonitionNode/AdmonitionNode'
|
||||
export { BlockquoteNode } from './components/BlockquoteNode/BlockquoteNode'
|
||||
export { CheckboxNode } from './components/CheckboxNode/CheckboxNode'
|
||||
export { CodeBlockNode } from './components/CodeBlockNode/CodeBlockNode'
|
||||
export { CodeBlockNode as ReactCodeBlockNode } from './components/CodeBlockNode/CodeBlockNode'
|
||||
export type { CodeBlockPreviewPayload, CodeBlockNodeReactEvents } from './components/CodeBlockNode/CodeBlockNode'
|
||||
export { HtmlPreviewFrame } from './components/CodeBlockNode/HtmlPreviewFrame'
|
||||
export type { HtmlPreviewFrameProps } from './components/CodeBlockNode/HtmlPreviewFrame'
|
||||
export type { D2Loader } from './components/D2BlockNode/d2'
|
||||
export { disableD2, enableD2, isD2Enabled, setD2Loader } from './components/D2BlockNode/d2'
|
||||
export { D2BlockNode } from './components/D2BlockNode/D2BlockNode'
|
||||
export { DefinitionListNode } from './components/DefinitionListNode/DefinitionListNode'
|
||||
export { EmojiNode } from './components/EmojiNode/EmojiNode'
|
||||
export { EmphasisNode } from './components/EmphasisNode/EmphasisNode'
|
||||
export { FootnoteAnchorNode } from './components/FootnoteAnchorNode/FootnoteAnchorNode'
|
||||
export { FootnoteNode } from './components/FootnoteNode/FootnoteNode'
|
||||
export { FootnoteReferenceNode } from './components/FootnoteReferenceNode/FootnoteReferenceNode'
|
||||
export { HardBreakNode } from './components/HardBreakNode/HardBreakNode'
|
||||
export { HeadingNode } from './components/HeadingNode/HeadingNode'
|
||||
export { HighlightNode } from './components/HighlightNode/HighlightNode'
|
||||
export { HtmlBlockNode } from './components/HtmlBlockNode/HtmlBlockNode'
|
||||
export { HtmlInlineNode } from './components/HtmlInlineNode/HtmlInlineNode'
|
||||
export { ImageNode } from './components/ImageNode/ImageNode'
|
||||
export { InfographicBlockNode } from './components/InfographicBlockNode/InfographicBlockNode'
|
||||
export { InlineCodeNode } from './components/InlineCodeNode/InlineCodeNode'
|
||||
export { InsertNode } from './components/InsertNode/InsertNode'
|
||||
export { LinkNode } from './components/LinkNode/LinkNode'
|
||||
export type { LinkNodeStyleProps } from './components/LinkNode/LinkNode'
|
||||
export { ListItemNode } from './components/ListItemNode/ListItemNode'
|
||||
export type { ListItemNodeProps } from './components/ListItemNode/ListItemNode'
|
||||
export { ListNode } from './components/ListNode/ListNode'
|
||||
export { MarkdownCodeBlockNode } from './components/MarkdownCodeBlockNode/MarkdownCodeBlockNode'
|
||||
export type { MarkdownCodeBlockNodeProps } from './components/MarkdownCodeBlockNode/MarkdownCodeBlockNode'
|
||||
export { MathBlockNode } from './components/MathBlockNode/MathBlockNode'
|
||||
export { MathInlineNode } from './components/MathInlineNode/MathInlineNode'
|
||||
export { MermaidBlockNode } from './components/MermaidBlockNode/MermaidBlockNode'
|
||||
export { NodeRenderer } from './components/NodeRenderer'
|
||||
export { default } from './components/NodeRenderer'
|
||||
export { FallbackComponent } from './components/NodeRenderer/FallbackComponent'
|
||||
export { ParagraphNode } from './components/ParagraphNode/ParagraphNode'
|
||||
export { PreCodeNode } from './components/PreCodeNode/PreCodeNode'
|
||||
export { ReferenceNode } from './components/ReferenceNode/ReferenceNode'
|
||||
export { StrikethroughNode } from './components/StrikethroughNode/StrikethroughNode'
|
||||
export { StrongNode } from './components/StrongNode/StrongNode'
|
||||
export { SubscriptNode } from './components/SubscriptNode/SubscriptNode'
|
||||
export { SuperscriptNode } from './components/SuperscriptNode/SuperscriptNode'
|
||||
export { TableNode } from './components/TableNode/TableNode'
|
||||
export { TextNode } from './components/TextNode/TextNode'
|
||||
export { ThematicBreakNode } from './components/ThematicBreakNode/ThematicBreakNode'
|
||||
export { Tooltip } from './components/Tooltip/Tooltip'
|
||||
export type { TooltipPlacement, TooltipProps } from './components/Tooltip/Tooltip'
|
||||
export { VmrContainerNode } from './components/VmrContainerNode/VmrContainerNode'
|
||||
export {
|
||||
clearGlobalCustomComponents,
|
||||
getCustomComponentDisplay,
|
||||
getCustomNodeComponents,
|
||||
removeCustomComponents,
|
||||
setCustomComponents,
|
||||
withMarkstreamComponentDisplay,
|
||||
} from './customComponents'
|
||||
export type {
|
||||
CustomComponentDisplayMode,
|
||||
MarkstreamCustomComponent,
|
||||
} from './customComponents'
|
||||
export * from './i18n/useSafeI18n'
|
||||
export * from './renderers/renderNode'
|
||||
export type { NodeRendererCodeBlockProps, NodeRendererProps } from './types'
|
||||
export type {
|
||||
CodeBlockActionContext,
|
||||
CodeBlockDiffAppearance,
|
||||
CodeBlockDiffHideUnchangedRegions,
|
||||
CodeBlockDiffHideUnchangedRegionsOptions,
|
||||
CodeBlockDiffHunkActionContext,
|
||||
CodeBlockDiffHunkActionKind,
|
||||
CodeBlockDiffHunkSide,
|
||||
CodeBlockDiffLineStyle,
|
||||
CodeBlockDiffUnchangedRegionStyle,
|
||||
CodeBlockMonacoLanguage,
|
||||
CodeBlockMonacoOptions,
|
||||
CodeBlockMonacoTheme,
|
||||
CodeBlockMonacoThemeObject,
|
||||
CodeBlockNodeProps,
|
||||
D2BlockNodeProps,
|
||||
ImageNodeProps,
|
||||
InfographicBlockActionContext,
|
||||
InfographicBlockNodeProps,
|
||||
LinkNodeProps,
|
||||
MathBlockNodeProps,
|
||||
MathInlineNodeProps,
|
||||
MermaidBlockActionContext,
|
||||
MermaidBlockEvent,
|
||||
MermaidBlockNodeProps,
|
||||
PreCodeNodeProps,
|
||||
} from './types/component-props'
|
||||
export type { NodeComponentProps } from './types/node-component'
|
||||
export * from './utils/languageIcon'
|
||||
export * from './workers/katexWorkerClient'
|
||||
|
||||
export * from './workers/mermaidWorkerClient'
|
||||
|
||||
export type CustomComponentMap = MarkstreamCustomComponentMap
|
||||
export type RenderContext = MarkstreamRenderContext
|
||||
export type RenderNodeFn = MarkstreamRenderNodeFn
|
||||
@@ -0,0 +1,161 @@
|
||||
'use client'
|
||||
|
||||
import type { D2Loader } from './components/D2BlockNode/d2'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { AdmonitionNode as ClientAdmonitionNode } from './components/AdmonitionNode/AdmonitionNode'
|
||||
import { BlockquoteNode as ClientBlockquoteNode } from './components/BlockquoteNode/BlockquoteNode'
|
||||
import { CheckboxNode as ClientCheckboxNode } from './components/CheckboxNode/CheckboxNode'
|
||||
import { CodeBlockNode as ClientCodeBlockNode } from './components/CodeBlockNode/CodeBlockNode'
|
||||
import { HtmlPreviewFrame as ClientHtmlPreviewFrame } from './components/CodeBlockNode/HtmlPreviewFrame'
|
||||
import { disableD2, enableD2, isD2Enabled, setD2Loader } from './components/D2BlockNode/d2'
|
||||
import { D2BlockNode as ClientD2BlockNode } from './components/D2BlockNode/D2BlockNode'
|
||||
import { DefinitionListNode as ClientDefinitionListNode } from './components/DefinitionListNode/DefinitionListNode'
|
||||
import { EmojiNode as ClientEmojiNode } from './components/EmojiNode/EmojiNode'
|
||||
import { EmphasisNode as ClientEmphasisNode } from './components/EmphasisNode/EmphasisNode'
|
||||
import { FootnoteAnchorNode as ClientFootnoteAnchorNode } from './components/FootnoteAnchorNode/FootnoteAnchorNode'
|
||||
import { FootnoteNode as ClientFootnoteNode } from './components/FootnoteNode/FootnoteNode'
|
||||
import { FootnoteReferenceNode as ClientFootnoteReferenceNode } from './components/FootnoteReferenceNode/FootnoteReferenceNode'
|
||||
import { HardBreakNode as ClientHardBreakNode } from './components/HardBreakNode/HardBreakNode'
|
||||
import { HeadingNode as ClientHeadingNode } from './components/HeadingNode/HeadingNode'
|
||||
import { HighlightNode as ClientHighlightNode } from './components/HighlightNode/HighlightNode'
|
||||
import { HtmlBlockNode as ClientHtmlBlockNode } from './components/HtmlBlockNode/HtmlBlockNode'
|
||||
import { HtmlInlineNode as ClientHtmlInlineNode } from './components/HtmlInlineNode/HtmlInlineNode'
|
||||
import { ImageNode as ClientImageNode } from './components/ImageNode/ImageNode'
|
||||
import { InfographicBlockNode as ClientInfographicBlockNode } from './components/InfographicBlockNode/InfographicBlockNode'
|
||||
import { InlineCodeNode as ClientInlineCodeNode } from './components/InlineCodeNode/InlineCodeNode'
|
||||
import { InsertNode as ClientInsertNode } from './components/InsertNode/InsertNode'
|
||||
import { LinkNode as ClientLinkNode } from './components/LinkNode/LinkNode'
|
||||
import { ListItemNode as ClientListItemNode } from './components/ListItemNode/ListItemNode'
|
||||
import { ListNode as ClientListNode } from './components/ListNode/ListNode'
|
||||
import { MarkdownCodeBlockNode as ClientMarkdownCodeBlockNode } from './components/MarkdownCodeBlockNode/MarkdownCodeBlockNode'
|
||||
import { MathBlockNode as ClientMathBlockNode } from './components/MathBlockNode/MathBlockNode'
|
||||
import { MathInlineNode as ClientMathInlineNode } from './components/MathInlineNode/MathInlineNode'
|
||||
import { MermaidBlockNode as ClientMermaidBlockNode } from './components/MermaidBlockNode/MermaidBlockNode'
|
||||
import ClientNodeRenderer from './components/NodeRenderer'
|
||||
import { FallbackComponent as ClientFallbackComponent } from './components/NodeRenderer/FallbackComponent'
|
||||
import { ParagraphNode as ClientParagraphNode } from './components/ParagraphNode/ParagraphNode'
|
||||
import { PreCodeNode as ClientPreCodeNode } from './components/PreCodeNode/PreCodeNode'
|
||||
import { ReferenceNode as ClientReferenceNode } from './components/ReferenceNode/ReferenceNode'
|
||||
import { StrikethroughNode as ClientStrikethroughNode } from './components/StrikethroughNode/StrikethroughNode'
|
||||
import { StrongNode as ClientStrongNode } from './components/StrongNode/StrongNode'
|
||||
import { SubscriptNode as ClientSubscriptNode } from './components/SubscriptNode/SubscriptNode'
|
||||
import { SuperscriptNode as ClientSuperscriptNode } from './components/SuperscriptNode/SuperscriptNode'
|
||||
import { TableNode as ClientTableNode } from './components/TableNode/TableNode'
|
||||
import { TextNode as ClientTextNode } from './components/TextNode/TextNode'
|
||||
import { ThematicBreakNode as ClientThematicBreakNode } from './components/ThematicBreakNode/ThematicBreakNode'
|
||||
import { Tooltip as ClientTooltip } from './components/Tooltip/Tooltip'
|
||||
import { VmrContainerNode as ClientVmrContainerNode } from './components/VmrContainerNode/VmrContainerNode'
|
||||
import {
|
||||
clearGlobalCustomComponents,
|
||||
getCustomNodeComponents,
|
||||
removeCustomComponents,
|
||||
setCustomComponents,
|
||||
} from './customComponents'
|
||||
import { AdmonitionNode as ServerAdmonitionNode, BlockquoteNode as ServerBlockquoteNode, CheckboxNode as ServerCheckboxNode, CodeBlockNode as ServerCodeBlockNode, D2BlockNode as ServerD2BlockNode, DefinitionListNode as ServerDefinitionListNode, EmojiNode as ServerEmojiNode, EmphasisNode as ServerEmphasisNode, FallbackComponent as ServerFallbackComponent, FootnoteAnchorNode as ServerFootnoteAnchorNode, FootnoteNode as ServerFootnoteNode, FootnoteReferenceNode as ServerFootnoteReferenceNode, HardBreakNode as ServerHardBreakNode, HeadingNode as ServerHeadingNode, HighlightNode as ServerHighlightNode, HtmlBlockNode as ServerHtmlBlockNode, HtmlInlineNode as ServerHtmlInlineNode, HtmlPreviewFrame as ServerHtmlPreviewFrame, ImageNode as ServerImageNode, InfographicBlockNode as ServerInfographicBlockNode, InlineCodeNode as ServerInlineCodeNode, InsertNode as ServerInsertNode, LinkNode as ServerLinkNode, ListItemNode as ServerListItemNode, ListNode as ServerListNode, MarkdownCodeBlockNode as ServerMarkdownCodeBlockNode, MathBlockNode as ServerMathBlockNode, MathInlineNode as ServerMathInlineNode, MermaidBlockNode as ServerMermaidBlockNode, NodeRenderer as ServerNodeRenderer, ParagraphNode as ServerParagraphNode, PreCodeNode as ServerPreCodeNode, ReferenceNode as ServerReferenceNode, StrikethroughNode as ServerStrikethroughNode, StrongNode as ServerStrongNode, SubscriptNode as ServerSubscriptNode, SuperscriptNode as ServerSuperscriptNode, TableNode as ServerTableNode, TextNode as ServerTextNode, ThematicBreakNode as ServerThematicBreakNode, Tooltip as ServerTooltip, VmrContainerNode as ServerVmrContainerNode } from './server-renderer'
|
||||
|
||||
function createNextEntryComponent<P>(
|
||||
ClientComponent: React.ComponentType<P>,
|
||||
ServerComponent: React.ComponentType<P>,
|
||||
) {
|
||||
function renderServerComponentWithDisabledSyncKatex(props: P) {
|
||||
const globalKey = '__MARKSTREAM_REACT_DISABLE_SYNC_KATEX__'
|
||||
const globalState = globalThis as Record<string, unknown>
|
||||
const previousValue = globalState[globalKey]
|
||||
globalState[globalKey] = true
|
||||
try {
|
||||
return (ServerComponent as unknown as (props: P) => React.ReactElement | null)(props)
|
||||
}
|
||||
finally {
|
||||
if (previousValue === undefined)
|
||||
delete globalState[globalKey]
|
||||
else
|
||||
globalState[globalKey] = previousValue
|
||||
}
|
||||
}
|
||||
|
||||
function MarkstreamNextEntryComponent(props: P) {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
if (!mounted)
|
||||
return renderServerComponentWithDisabledSyncKatex(props)
|
||||
|
||||
return React.createElement(ClientComponent, props)
|
||||
}
|
||||
|
||||
MarkstreamNextEntryComponent.displayName = `MarkstreamNextEntry(${ClientComponent.displayName || ClientComponent.name || 'Component'})`
|
||||
|
||||
return MarkstreamNextEntryComponent
|
||||
}
|
||||
|
||||
export const AdmonitionNode = createNextEntryComponent(ClientAdmonitionNode, ServerAdmonitionNode)
|
||||
export const BlockquoteNode = createNextEntryComponent(ClientBlockquoteNode, ServerBlockquoteNode)
|
||||
export const CheckboxNode = createNextEntryComponent(ClientCheckboxNode, ServerCheckboxNode)
|
||||
export const CodeBlockNode = createNextEntryComponent(ClientCodeBlockNode, ServerCodeBlockNode)
|
||||
export const ReactCodeBlockNode = CodeBlockNode
|
||||
export const HtmlPreviewFrame = createNextEntryComponent(ClientHtmlPreviewFrame, ServerHtmlPreviewFrame)
|
||||
export const D2BlockNode = createNextEntryComponent(ClientD2BlockNode, ServerD2BlockNode)
|
||||
export const DefinitionListNode = createNextEntryComponent(ClientDefinitionListNode, ServerDefinitionListNode)
|
||||
export const EmojiNode = createNextEntryComponent(ClientEmojiNode, ServerEmojiNode)
|
||||
export const EmphasisNode = createNextEntryComponent(ClientEmphasisNode, ServerEmphasisNode)
|
||||
export const FootnoteAnchorNode = createNextEntryComponent(ClientFootnoteAnchorNode, ServerFootnoteAnchorNode)
|
||||
export const FootnoteNode = createNextEntryComponent(ClientFootnoteNode, ServerFootnoteNode)
|
||||
export const FootnoteReferenceNode = createNextEntryComponent(ClientFootnoteReferenceNode, ServerFootnoteReferenceNode)
|
||||
export const HardBreakNode = createNextEntryComponent(ClientHardBreakNode, ServerHardBreakNode)
|
||||
export const HeadingNode = createNextEntryComponent(ClientHeadingNode, ServerHeadingNode)
|
||||
export const HighlightNode = createNextEntryComponent(ClientHighlightNode, ServerHighlightNode)
|
||||
export const HtmlBlockNode = createNextEntryComponent(ClientHtmlBlockNode, ServerHtmlBlockNode)
|
||||
export const HtmlInlineNode = createNextEntryComponent(ClientHtmlInlineNode, ServerHtmlInlineNode)
|
||||
export const ImageNode = createNextEntryComponent(ClientImageNode, ServerImageNode)
|
||||
export const InfographicBlockNode = createNextEntryComponent(ClientInfographicBlockNode, ServerInfographicBlockNode)
|
||||
export const InlineCodeNode = createNextEntryComponent(ClientInlineCodeNode, ServerInlineCodeNode)
|
||||
export const InsertNode = createNextEntryComponent(ClientInsertNode, ServerInsertNode)
|
||||
export const LinkNode = createNextEntryComponent(ClientLinkNode, ServerLinkNode)
|
||||
export const ListItemNode = createNextEntryComponent(ClientListItemNode, ServerListItemNode)
|
||||
export const ListNode = createNextEntryComponent(ClientListNode, ServerListNode)
|
||||
export const MarkdownCodeBlockNode = createNextEntryComponent(ClientMarkdownCodeBlockNode, ServerMarkdownCodeBlockNode)
|
||||
export const MathBlockNode = createNextEntryComponent(ClientMathBlockNode, ServerMathBlockNode)
|
||||
export const MathInlineNode = createNextEntryComponent(ClientMathInlineNode, ServerMathInlineNode)
|
||||
export const MermaidBlockNode = createNextEntryComponent(ClientMermaidBlockNode, ServerMermaidBlockNode)
|
||||
export const NodeRenderer = createNextEntryComponent(ClientNodeRenderer, ServerNodeRenderer)
|
||||
export const FallbackComponent = createNextEntryComponent(ClientFallbackComponent, ServerFallbackComponent)
|
||||
export const ParagraphNode = createNextEntryComponent(ClientParagraphNode, ServerParagraphNode)
|
||||
export const PreCodeNode = createNextEntryComponent(ClientPreCodeNode, ServerPreCodeNode)
|
||||
export const ReferenceNode = createNextEntryComponent(ClientReferenceNode, ServerReferenceNode)
|
||||
export const StrikethroughNode = createNextEntryComponent(ClientStrikethroughNode, ServerStrikethroughNode)
|
||||
export const StrongNode = createNextEntryComponent(ClientStrongNode, ServerStrongNode)
|
||||
export const SubscriptNode = createNextEntryComponent(ClientSubscriptNode, ServerSubscriptNode)
|
||||
export const SuperscriptNode = createNextEntryComponent(ClientSuperscriptNode, ServerSuperscriptNode)
|
||||
export const TableNode = createNextEntryComponent(ClientTableNode, ServerTableNode)
|
||||
export const TextNode = createNextEntryComponent(ClientTextNode, ServerTextNode)
|
||||
export const ThematicBreakNode = createNextEntryComponent(ClientThematicBreakNode, ServerThematicBreakNode)
|
||||
export const Tooltip = createNextEntryComponent(ClientTooltip, ServerTooltip)
|
||||
export const VmrContainerNode = createNextEntryComponent(ClientVmrContainerNode, ServerVmrContainerNode)
|
||||
|
||||
export default NodeRenderer
|
||||
|
||||
export type { D2Loader }
|
||||
export { disableD2, enableD2, isD2Enabled, setD2Loader }
|
||||
export {
|
||||
clearGlobalCustomComponents,
|
||||
getCustomNodeComponents,
|
||||
removeCustomComponents,
|
||||
setCustomComponents,
|
||||
}
|
||||
export type { HtmlPreviewFrameProps } from './components/CodeBlockNode/HtmlPreviewFrame'
|
||||
export * from './components/D2BlockNode/d2'
|
||||
export type { LinkNodeStyleProps } from './components/LinkNode/LinkNode'
|
||||
export type { ListItemNodeProps } from './components/ListItemNode/ListItemNode'
|
||||
export type { MarkdownCodeBlockNodeProps } from './components/MarkdownCodeBlockNode/MarkdownCodeBlockNode'
|
||||
export type { TooltipPlacement, TooltipProps } from './components/Tooltip/Tooltip'
|
||||
export * from './i18n/useSafeI18n'
|
||||
export * from './renderers/renderNode'
|
||||
export type { NodeRendererCodeBlockProps, NodeRendererProps } from './types'
|
||||
export * from './types/component-props'
|
||||
export type { NodeComponentProps } from './types/node-component'
|
||||
export * from './utils/languageIcon'
|
||||
export * from './workers/katexWorkerClient'
|
||||
export * from './workers/mermaidWorkerClient'
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ParsedNode } from 'stream-markdown-parser'
|
||||
import type { RenderContext, RenderNodeFn } from '../types'
|
||||
|
||||
export const BLOCK_LEVEL_TYPES = new Set([
|
||||
'table',
|
||||
'code_block',
|
||||
'html_block',
|
||||
'blockquote',
|
||||
'list',
|
||||
'list_item',
|
||||
'definition_list',
|
||||
'footnote',
|
||||
'footnote_reference',
|
||||
'footnote_anchor',
|
||||
'admonition',
|
||||
'thematic_break',
|
||||
'math_block',
|
||||
'thinking',
|
||||
'vmr_container',
|
||||
])
|
||||
|
||||
export function tokenAttrsToProps(attrs?: [string, string | null][]) {
|
||||
if (!Array.isArray(attrs) || attrs.length === 0)
|
||||
return undefined
|
||||
return attrs.reduce<Record<string, string | true>>((acc, [name, value]) => {
|
||||
if (!name)
|
||||
return acc
|
||||
const attrName = name === 'for'
|
||||
? 'htmlFor'
|
||||
: name === 'class'
|
||||
? 'className'
|
||||
: name
|
||||
acc[attrName] = value ?? true
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
export function renderNodeChildren(
|
||||
children: ParsedNode[] | undefined,
|
||||
ctx: RenderContext,
|
||||
prefix: string,
|
||||
renderNode: RenderNodeFn,
|
||||
) {
|
||||
if (!Array.isArray(children) || children.length === 0)
|
||||
return null
|
||||
|
||||
const result: ReactNode[] = []
|
||||
for (let idx = 0; idx < children.length; idx++) {
|
||||
const child = children[idx] as ParsedNode & { attrs?: [string, string | null][] }
|
||||
if (!child)
|
||||
continue
|
||||
|
||||
if (child.type === 'label_open') {
|
||||
const labelChildren: ParsedNode[] = []
|
||||
idx++
|
||||
while (idx < children.length) {
|
||||
const segment = children[idx]
|
||||
if (segment?.type === 'label_close')
|
||||
break
|
||||
if (segment)
|
||||
labelChildren.push(segment)
|
||||
idx++
|
||||
}
|
||||
const key = `${prefix}-label-${idx}`
|
||||
result.push(
|
||||
<label key={key} {...tokenAttrsToProps(child.attrs)}>
|
||||
{renderNodeChildren(labelChildren, ctx, `${key}-child`, renderNode)}
|
||||
</label>,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if (child.type === 'label_close')
|
||||
continue
|
||||
|
||||
result.push(renderNode(child, `${prefix}-${idx}`, ctx))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function renderInline(
|
||||
children: ParsedNode[] | undefined,
|
||||
ctx: RenderContext,
|
||||
prefix: string,
|
||||
renderNode: RenderNodeFn,
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
{renderNodeChildren(children, ctx, `${prefix}-inline`, renderNode)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import type { ParsedNode } from 'stream-markdown-parser'
|
||||
import type { RenderContext } from '../types'
|
||||
import React from 'react'
|
||||
import { getHtmlTagFromContent, shouldRenderUnknownHtmlTagAsText, stripCustomHtmlWrapper } from 'stream-markdown-parser'
|
||||
import { AdmonitionNode } from '../components/AdmonitionNode/AdmonitionNode'
|
||||
import { BlockquoteNode } from '../components/BlockquoteNode/BlockquoteNode'
|
||||
import { CheckboxNode } from '../components/CheckboxNode/CheckboxNode'
|
||||
import { CodeBlockNode as MonacoCodeBlockNode } from '../components/CodeBlockNode/CodeBlockNode'
|
||||
import { PreCodeNode } from '../components/CodeBlockNode/PreCodeNode'
|
||||
import { D2BlockNode } from '../components/D2BlockNode/D2BlockNode'
|
||||
import { DefinitionListNode } from '../components/DefinitionListNode/DefinitionListNode'
|
||||
import { EmojiNode } from '../components/EmojiNode/EmojiNode'
|
||||
import { EmphasisNode } from '../components/EmphasisNode/EmphasisNode'
|
||||
import { FootnoteAnchorNode } from '../components/FootnoteAnchorNode/FootnoteAnchorNode'
|
||||
import { FootnoteNode } from '../components/FootnoteNode/FootnoteNode'
|
||||
import { FootnoteReferenceNode } from '../components/FootnoteReferenceNode/FootnoteReferenceNode'
|
||||
import { HardBreakNode } from '../components/HardBreakNode/HardBreakNode'
|
||||
import { HeadingNode } from '../components/HeadingNode/HeadingNode'
|
||||
import { HighlightNode } from '../components/HighlightNode/HighlightNode'
|
||||
import { HtmlBlockNode } from '../components/HtmlBlockNode/HtmlBlockNode'
|
||||
import { HtmlInlineNode } from '../components/HtmlInlineNode/HtmlInlineNode'
|
||||
import { ImageNode } from '../components/ImageNode/ImageNode'
|
||||
import { clampInfographicPreviewHeight, estimateInfographicPreviewHeight, parsePositiveNumber as parsePositiveInfographicNumber } from '../components/InfographicBlockNode/height'
|
||||
import { InfographicBlockNode } from '../components/InfographicBlockNode/InfographicBlockNode'
|
||||
import { InlineCodeNode } from '../components/InlineCodeNode/InlineCodeNode'
|
||||
import { InsertNode } from '../components/InsertNode/InsertNode'
|
||||
import { LinkNode } from '../components/LinkNode/LinkNode'
|
||||
import { ListItemNode } from '../components/ListItemNode/ListItemNode'
|
||||
import { ListNode } from '../components/ListNode/ListNode'
|
||||
import { MathBlockNode } from '../components/MathBlockNode/MathBlockNode'
|
||||
import { MathInlineNode } from '../components/MathInlineNode/MathInlineNode'
|
||||
import { clampMermaidPreviewHeight, estimateMermaidPreviewHeight, parsePositiveNumber as parsePositiveMermaidNumber } from '../components/MermaidBlockNode/height'
|
||||
import { MermaidBlockNode } from '../components/MermaidBlockNode/MermaidBlockNode'
|
||||
import { FallbackComponent } from '../components/NodeRenderer/FallbackComponent'
|
||||
import { ParagraphNode } from '../components/ParagraphNode/ParagraphNode'
|
||||
import { ReferenceNode } from '../components/ReferenceNode/ReferenceNode'
|
||||
import { StrikethroughNode } from '../components/StrikethroughNode/StrikethroughNode'
|
||||
import { StrongNode } from '../components/StrongNode/StrongNode'
|
||||
import { SubscriptNode } from '../components/SubscriptNode/SubscriptNode'
|
||||
import { SuperscriptNode } from '../components/SuperscriptNode/SuperscriptNode'
|
||||
import { TableNode } from '../components/TableNode/TableNode'
|
||||
import { TextNode } from '../components/TextNode/TextNode'
|
||||
import { ThematicBreakNode } from '../components/ThematicBreakNode/ThematicBreakNode'
|
||||
import { VmrContainerNode } from '../components/VmrContainerNode/VmrContainerNode'
|
||||
import { getCustomNodeComponents } from '../customComponents'
|
||||
import { resolveCustomHtmlTag } from '../utils/customHtmlTag'
|
||||
import { normalizeLanguageIdentifier } from '../utils/languageIcon'
|
||||
import { renderNodeChildren } from './renderChildren'
|
||||
|
||||
function getRawCodeBlockLanguage(node: any) {
|
||||
const trimmed = String(node?.language || '').trim()
|
||||
if (!trimmed)
|
||||
return ''
|
||||
const [firstToken] = trimmed.split(/\s+/)
|
||||
const [base] = firstToken.split(':')
|
||||
return base.toLowerCase()
|
||||
}
|
||||
|
||||
function renderCustomCodeBlockComponent(
|
||||
component: any,
|
||||
node: any,
|
||||
key: React.Key,
|
||||
ctx: RenderContext,
|
||||
) {
|
||||
return React.createElement(component, {
|
||||
key,
|
||||
node,
|
||||
customId: ctx.customId,
|
||||
isDark: ctx.isDark,
|
||||
ctx,
|
||||
renderNode,
|
||||
indexKey: key,
|
||||
typewriter: ctx.typewriter,
|
||||
})
|
||||
}
|
||||
|
||||
function getMermaidRenderProps(node: any, ctx: RenderContext) {
|
||||
const next = { ...(ctx.mermaidProps || {}) } as Record<string, any>
|
||||
if (parsePositiveMermaidNumber(next.estimatedPreviewHeightPx) == null) {
|
||||
next.estimatedPreviewHeightPx = clampMermaidPreviewHeight(
|
||||
estimateMermaidPreviewHeight(String(node?.code ?? '')),
|
||||
undefined,
|
||||
next.maxHeight === 'none' ? null : (parsePositiveMermaidNumber(next.maxHeight) ?? undefined),
|
||||
)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function getInfographicRenderProps(node: any, ctx: RenderContext) {
|
||||
const next = { ...(ctx.infographicProps || {}) } as Record<string, any>
|
||||
if (parsePositiveInfographicNumber(next.estimatedPreviewHeightPx) == null) {
|
||||
next.estimatedPreviewHeightPx = clampInfographicPreviewHeight(
|
||||
estimateInfographicPreviewHeight(String(node?.code ?? '')),
|
||||
undefined,
|
||||
next.maxHeight === 'none' ? null : (parsePositiveInfographicNumber(next.maxHeight) ?? undefined),
|
||||
)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function renderCodeBlock(
|
||||
node: any,
|
||||
key: React.Key,
|
||||
ctx: RenderContext,
|
||||
customComponents: Record<string, any>,
|
||||
) {
|
||||
const rawLanguage = getRawCodeBlockLanguage(node)
|
||||
const language = normalizeLanguageIdentifier(rawLanguage)
|
||||
const customForLanguage = rawLanguage ? customComponents[rawLanguage] : null
|
||||
if (language === 'mermaid') {
|
||||
const mermaidProps = getMermaidRenderProps(node, ctx)
|
||||
const customMermaid = customForLanguage || customComponents.mermaid
|
||||
if (customMermaid)
|
||||
return React.createElement(customMermaid as any, { key, node, isDark: ctx.isDark, ...mermaidProps })
|
||||
if (!ctx.renderCodeBlocksAsPre) {
|
||||
return (
|
||||
<MermaidBlockNode
|
||||
key={key}
|
||||
node={node as any}
|
||||
isDark={ctx.isDark}
|
||||
loading={Boolean(node.loading)}
|
||||
{...mermaidProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (language === 'infographic') {
|
||||
const infographicProps = getInfographicRenderProps(node, ctx)
|
||||
const customInfographic = customForLanguage || customComponents.infographic
|
||||
if (customInfographic)
|
||||
return React.createElement(customInfographic as any, { key, node, isDark: ctx.isDark, ...infographicProps })
|
||||
|
||||
return (
|
||||
<InfographicBlockNode
|
||||
key={key}
|
||||
node={node as any}
|
||||
isDark={ctx.isDark}
|
||||
loading={Boolean(node.loading)}
|
||||
{...infographicProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (language === 'd2' || language === 'd2lang') {
|
||||
const customD2 = customForLanguage || customComponents.d2
|
||||
if (customD2)
|
||||
return React.createElement(customD2 as any, { key, node, isDark: ctx.isDark, ...(ctx.d2Props || {}) })
|
||||
|
||||
return (
|
||||
<D2BlockNode
|
||||
key={key}
|
||||
node={node as any}
|
||||
isDark={ctx.isDark}
|
||||
loading={Boolean(node.loading)}
|
||||
{...(ctx.d2Props || {})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (customForLanguage)
|
||||
return renderCustomCodeBlockComponent(customForLanguage, node, key, ctx)
|
||||
|
||||
const customCodeBlock = customComponents.code_block
|
||||
if (customCodeBlock)
|
||||
return renderCustomCodeBlockComponent(customCodeBlock, node, key, ctx)
|
||||
|
||||
if (ctx.renderCodeBlocksAsPre || language === 'mermaid') {
|
||||
return <PreCodeNode key={key} node={node} />
|
||||
}
|
||||
|
||||
return (
|
||||
<MonacoCodeBlockNode
|
||||
key={key}
|
||||
node={node}
|
||||
loading={Boolean(node.loading)}
|
||||
stream={ctx.codeBlockStream}
|
||||
monacoOptions={ctx.codeBlockThemes?.monacoOptions}
|
||||
themes={ctx.codeBlockThemes?.themes}
|
||||
darkTheme={ctx.codeBlockThemes?.darkTheme}
|
||||
lightTheme={ctx.codeBlockThemes?.lightTheme}
|
||||
minWidth={ctx.codeBlockThemes?.minWidth}
|
||||
maxWidth={ctx.codeBlockThemes?.maxWidth}
|
||||
isDark={ctx.isDark}
|
||||
onCopy={ctx.events.onCopy}
|
||||
{...(ctx.codeBlockProps || {})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function renderNode(node: ParsedNode, key: React.Key, ctx: RenderContext) {
|
||||
const customComponents = ctx.customComponents ?? getCustomNodeComponents(ctx.customId)
|
||||
const custom = node.type === 'code_block'
|
||||
? null
|
||||
: (customComponents as Record<string, any>)[node.type]
|
||||
if (custom) {
|
||||
return React.createElement(custom, {
|
||||
key,
|
||||
node,
|
||||
customId: ctx.customId,
|
||||
isDark: ctx.isDark,
|
||||
ctx,
|
||||
renderNode,
|
||||
indexKey: key,
|
||||
typewriter: ctx.typewriter,
|
||||
})
|
||||
}
|
||||
|
||||
if (node.type === 'html_block' || node.type === 'html_inline') {
|
||||
const resolvedCustomTag = resolveCustomHtmlTag(node as any, customComponents as any, ctx.customHtmlTags)
|
||||
const fallbackTag = String((node as any).tag ?? '').trim().toLowerCase() || getHtmlTagFromContent((node as any).content)
|
||||
const tag = resolvedCustomTag?.tag ?? fallbackTag
|
||||
if (tag) {
|
||||
const customForTag = resolvedCustomTag?.component ?? (customComponents as Record<string, any>)[tag]
|
||||
const isWhitelisted = resolvedCustomTag?.isWhitelisted ?? (ctx.customHtmlTags ?? []).some((t: string) => t.toLowerCase() === tag)
|
||||
if (isWhitelisted && customForTag) {
|
||||
const coerced = {
|
||||
...(node as any),
|
||||
type: tag,
|
||||
tag,
|
||||
content: stripCustomHtmlWrapper((node as any).content, tag),
|
||||
}
|
||||
return React.createElement(customForTag as any, {
|
||||
key,
|
||||
node: coerced,
|
||||
customId: ctx.customId,
|
||||
isDark: ctx.isDark,
|
||||
ctx,
|
||||
renderNode,
|
||||
indexKey: key,
|
||||
typewriter: ctx.typewriter,
|
||||
})
|
||||
}
|
||||
const rawContent = String((node as any).content ?? (node as any).raw ?? '')
|
||||
if (!isWhitelisted && shouldRenderUnknownHtmlTagAsText(rawContent, tag)) {
|
||||
if (node.type === 'html_inline') {
|
||||
return <TextNode key={key} node={{ type: 'text', content: rawContent, raw: rawContent } as any} ctx={ctx} indexKey={key} typewriter={ctx.typewriter} />
|
||||
}
|
||||
else {
|
||||
return <ParagraphNode key={key} node={{ type: 'paragraph', children: [{ type: 'text', content: rawContent, raw: rawContent }], raw: rawContent } as any} ctx={ctx} renderNode={renderNode} indexKey={key} typewriter={ctx.typewriter} />
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch (node.type) {
|
||||
case 'text':
|
||||
return <TextNode key={key} node={node as any} ctx={ctx} indexKey={key} typewriter={ctx.typewriter} />
|
||||
case 'text_special':
|
||||
return <TextNode key={key} node={{ type: 'text', content: (node as any).content ?? '', center: (node as any).center } as any} ctx={ctx} indexKey={key} typewriter={ctx.typewriter} />
|
||||
case 'paragraph':
|
||||
return <ParagraphNode key={key} node={node as any} ctx={ctx} renderNode={renderNode} indexKey={key} typewriter={ctx.typewriter} />
|
||||
case 'heading':
|
||||
return <HeadingNode key={key} node={node as any} ctx={ctx} renderNode={renderNode} indexKey={key} typewriter={ctx.typewriter} />
|
||||
case 'blockquote':
|
||||
return <BlockquoteNode key={key} node={node as any} ctx={ctx} renderNode={renderNode} indexKey={key} typewriter={ctx.typewriter} />
|
||||
case 'list':
|
||||
return <ListNode key={key} node={node as any} ctx={ctx} renderNode={renderNode} indexKey={key} typewriter={ctx.typewriter} />
|
||||
case 'list_item':
|
||||
return <ListItemNode key={key} node={node as any} ctx={ctx} renderNode={renderNode} indexKey={key} typewriter={ctx.typewriter} />
|
||||
case 'table':
|
||||
return <TableNode key={key} node={node as any} ctx={ctx} renderNode={renderNode} indexKey={key} typewriter={ctx.typewriter} />
|
||||
case 'definition_list':
|
||||
return <DefinitionListNode key={key} node={node as any} ctx={ctx} renderNode={renderNode} indexKey={key} typewriter={ctx.typewriter} />
|
||||
case 'footnote':
|
||||
return <FootnoteNode key={key} node={node as any} ctx={ctx} renderNode={renderNode} indexKey={key} typewriter={ctx.typewriter} />
|
||||
case 'footnote_reference':
|
||||
return <FootnoteReferenceNode key={key} node={node as any} />
|
||||
case 'footnote_anchor':
|
||||
return <FootnoteAnchorNode key={key} node={node as any} />
|
||||
case 'admonition':
|
||||
return <AdmonitionNode key={key} node={node as any} ctx={ctx} renderNode={renderNode} indexKey={key} isDark={ctx.isDark} typewriter={ctx.typewriter} />
|
||||
case 'hardbreak':
|
||||
return <HardBreakNode key={key} node={node as any} typewriter={ctx.typewriter} />
|
||||
case 'link':
|
||||
return (
|
||||
<LinkNode
|
||||
key={key}
|
||||
node={node as any}
|
||||
ctx={ctx}
|
||||
renderNode={renderNode}
|
||||
indexKey={key}
|
||||
isDark={ctx.isDark}
|
||||
showTooltip={typeof ctx.showTooltips === 'boolean' ? ctx.showTooltips : undefined}
|
||||
typewriter={ctx.typewriter}
|
||||
/>
|
||||
)
|
||||
case 'image':
|
||||
return (
|
||||
<ImageNode
|
||||
key={key}
|
||||
node={node as any}
|
||||
/>
|
||||
)
|
||||
case 'inline_code':
|
||||
return <InlineCodeNode key={key} node={node as any} ctx={ctx} indexKey={key} typewriter={ctx.typewriter} />
|
||||
case 'code_block':
|
||||
return renderCodeBlock(node, key, ctx, customComponents)
|
||||
case 'strong':
|
||||
return (
|
||||
<StrongNode
|
||||
key={key}
|
||||
node={node as any}
|
||||
>
|
||||
{renderNodeChildren((node as any).children, ctx, `${String(key)}-strong`, renderNode)}
|
||||
</StrongNode>
|
||||
)
|
||||
case 'emphasis':
|
||||
return (
|
||||
<EmphasisNode key={key} node={node as any}>
|
||||
{renderNodeChildren((node as any).children, ctx, `${String(key)}-em`, renderNode)}
|
||||
</EmphasisNode>
|
||||
)
|
||||
case 'strikethrough':
|
||||
return (
|
||||
<StrikethroughNode key={key} node={node as any}>
|
||||
{renderNodeChildren((node as any).children, ctx, `${String(key)}-strike`, renderNode)}
|
||||
</StrikethroughNode>
|
||||
)
|
||||
case 'highlight':
|
||||
return (
|
||||
<HighlightNode key={key} node={node as any}>
|
||||
{renderNodeChildren((node as any).children, ctx, `${String(key)}-highlight`, renderNode)}
|
||||
</HighlightNode>
|
||||
)
|
||||
case 'insert':
|
||||
return (
|
||||
<InsertNode key={key} node={node as any}>
|
||||
{renderNodeChildren((node as any).children, ctx, `${String(key)}-insert`, renderNode)}
|
||||
</InsertNode>
|
||||
)
|
||||
case 'subscript':
|
||||
return (
|
||||
<SubscriptNode key={key} node={node as any}>
|
||||
{renderNodeChildren((node as any).children, ctx, `${String(key)}-sub`, renderNode)}
|
||||
</SubscriptNode>
|
||||
)
|
||||
case 'superscript':
|
||||
return (
|
||||
<SuperscriptNode key={key} node={node as any}>
|
||||
{renderNodeChildren((node as any).children, ctx, `${String(key)}-sup`, renderNode)}
|
||||
</SuperscriptNode>
|
||||
)
|
||||
case 'checkbox':
|
||||
case 'checkbox_input':
|
||||
return <CheckboxNode key={key} node={node as any} typewriter={ctx.typewriter} />
|
||||
case 'emoji':
|
||||
return <EmojiNode key={key} node={node as any} typewriter={ctx.typewriter} />
|
||||
case 'thematic_break':
|
||||
return <ThematicBreakNode key={key} />
|
||||
case 'math_inline':
|
||||
return <MathInlineNode key={key} node={node as any} />
|
||||
case 'math_block':
|
||||
return <MathBlockNode key={key} node={node as any} />
|
||||
case 'reference':
|
||||
return <ReferenceNode key={key} node={node as any} ctx={ctx} typewriter={ctx.typewriter} />
|
||||
case 'html_block':
|
||||
case 'html_inline':
|
||||
return node.type === 'html_block'
|
||||
? <HtmlBlockNode key={key} node={node as any} ctx={ctx} renderNode={renderNode} indexKey={key} typewriter={ctx.typewriter} customId={ctx.customId} />
|
||||
: <HtmlInlineNode key={key} node={node as any} typewriter={ctx.typewriter} customId={ctx.customId} />
|
||||
case 'vmr_container':
|
||||
return <VmrContainerNode key={key} node={node as any} ctx={ctx} renderNode={renderNode} indexKey={key} typewriter={ctx.typewriter} />
|
||||
case 'label_open':
|
||||
case 'label_close':
|
||||
return null
|
||||
default:
|
||||
return <FallbackComponent key={key} node={node as any} />
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { parseHtmlToReactNodes } from '../utils/htmlToReact'
|
||||
@@ -0,0 +1,74 @@
|
||||
import { normalizeKaTeXRenderInput } from '../utils/normalizeKaTeXRenderInput'
|
||||
|
||||
let katexModule: any | undefined
|
||||
let katexLoadAttempted = false
|
||||
|
||||
function getRequire() {
|
||||
try {
|
||||
const processWithBuiltins = Reflect.get(globalThis, 'process') as {
|
||||
getBuiltinModule?: (specifier: string) => any
|
||||
} | undefined
|
||||
const moduleApi = typeof processWithBuiltins?.getBuiltinModule === 'function'
|
||||
? processWithBuiltins.getBuiltinModule('module')
|
||||
: null
|
||||
return typeof moduleApi?.createRequire === 'function'
|
||||
? moduleApi.createRequire(import.meta.url)
|
||||
: null
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function loadKatex() {
|
||||
if (katexModule)
|
||||
return katexModule
|
||||
if (katexLoadAttempted)
|
||||
return null
|
||||
|
||||
katexLoadAttempted = true
|
||||
|
||||
try {
|
||||
const require = getRequire()
|
||||
if (!require)
|
||||
return null
|
||||
const katex = require('katex')
|
||||
try {
|
||||
require('katex/contrib/mhchem')
|
||||
}
|
||||
catch {
|
||||
// optional extension
|
||||
}
|
||||
katexModule = katex
|
||||
return katexModule
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function renderKatexToHtml(
|
||||
content: string,
|
||||
displayMode: boolean,
|
||||
throwOnError: boolean,
|
||||
) {
|
||||
const normalizedContent = normalizeKaTeXRenderInput(content)
|
||||
if (!normalizedContent)
|
||||
return null
|
||||
if ((globalThis as any).__MARKSTREAM_REACT_DISABLE_SYNC_KATEX__)
|
||||
return null
|
||||
|
||||
const katex = loadKatex()
|
||||
if (!katex)
|
||||
return null
|
||||
|
||||
try {
|
||||
return katex.renderToString(normalizedContent, {
|
||||
displayMode,
|
||||
throwOnError,
|
||||
})
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
export type { HtmlPreviewFrameProps } from './components/CodeBlockNode/HtmlPreviewFrame'
|
||||
export type { LinkNodeStyleProps } from './components/LinkNode/LinkNode'
|
||||
export type { ListItemNodeProps } from './components/ListItemNode/ListItemNode'
|
||||
export type { MarkdownCodeBlockNodeProps } from './components/MarkdownCodeBlockNode/MarkdownCodeBlockNode'
|
||||
export type { TooltipPlacement, TooltipProps } from './components/Tooltip/Tooltip'
|
||||
export {
|
||||
clearGlobalCustomComponents,
|
||||
getCustomComponentDisplay,
|
||||
getCustomNodeComponents,
|
||||
removeCustomComponents,
|
||||
setCustomComponents,
|
||||
withMarkstreamComponentDisplay,
|
||||
} from './customComponents'
|
||||
export type { CustomComponentDisplayMode, MarkstreamCustomComponent } from './customComponents'
|
||||
export { AdmonitionNode } from './server-renderer'
|
||||
export { BlockquoteNode } from './server-renderer'
|
||||
export { CheckboxNode } from './server-renderer'
|
||||
export { CodeBlockNode } from './server-renderer'
|
||||
export { CodeBlockNode as ReactCodeBlockNode } from './server-renderer'
|
||||
export { HtmlPreviewFrame } from './server-renderer'
|
||||
export { D2BlockNode } from './server-renderer'
|
||||
export { DefinitionListNode } from './server-renderer'
|
||||
export { EmojiNode } from './server-renderer'
|
||||
export { EmphasisNode } from './server-renderer'
|
||||
export { FootnoteAnchorNode } from './server-renderer'
|
||||
export { FootnoteNode } from './server-renderer'
|
||||
export { FootnoteReferenceNode } from './server-renderer'
|
||||
export { HardBreakNode } from './server-renderer'
|
||||
export { HeadingNode } from './server-renderer'
|
||||
export { HighlightNode } from './server-renderer'
|
||||
export { HtmlBlockNode } from './server-renderer'
|
||||
export { HtmlInlineNode } from './server-renderer'
|
||||
export { ImageNode } from './server-renderer'
|
||||
export { InfographicBlockNode } from './server-renderer'
|
||||
export { InlineCodeNode } from './server-renderer'
|
||||
export { InsertNode } from './server-renderer'
|
||||
export { LinkNode } from './server-renderer'
|
||||
export { ListItemNode } from './server-renderer'
|
||||
export { ListNode } from './server-renderer'
|
||||
export { MarkdownCodeBlockNode } from './server-renderer'
|
||||
export { MathBlockNode } from './server-renderer'
|
||||
export { MathInlineNode } from './server-renderer'
|
||||
export { MermaidBlockNode } from './server-renderer'
|
||||
export { NodeRenderer } from './server-renderer'
|
||||
export { default } from './server-renderer'
|
||||
export { FallbackComponent } from './server-renderer'
|
||||
export { ParagraphNode } from './server-renderer'
|
||||
export { PreCodeNode } from './server-renderer'
|
||||
export { ReferenceNode } from './server-renderer'
|
||||
export { StrikethroughNode } from './server-renderer'
|
||||
export { StrongNode } from './server-renderer'
|
||||
export { SubscriptNode } from './server-renderer'
|
||||
export { SuperscriptNode } from './server-renderer'
|
||||
export { TableNode } from './server-renderer'
|
||||
export { TextNode } from './server-renderer'
|
||||
export { ThematicBreakNode } from './server-renderer'
|
||||
export { Tooltip } from './server-renderer'
|
||||
export { VmrContainerNode } from './server-renderer'
|
||||
export { renderNode } from './server-renderer'
|
||||
export type { NodeRendererCodeBlockProps, NodeRendererProps } from './types'
|
||||
export * from './types/component-props'
|
||||
export type { NodeComponentProps } from './types/node-component'
|
||||
@@ -0,0 +1,6 @@
|
||||
declare module 'katex/contrib/mhchem'
|
||||
|
||||
declare module '*.svg?raw' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import './index.css'
|
||||
|
||||
export {}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom'
|
||||
|
||||
export type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right'
|
||||
|
||||
let tooltipEl: HTMLDivElement | null = null
|
||||
let currentAnchor: HTMLElement | null = null
|
||||
let cleanupAutoUpdate: (() => void) | null = null
|
||||
let showTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let hideTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let currentId: string | null = null
|
||||
let visible = false
|
||||
|
||||
function clearTimers() {
|
||||
if (showTimer) {
|
||||
clearTimeout(showTimer)
|
||||
showTimer = null
|
||||
}
|
||||
if (hideTimer) {
|
||||
clearTimeout(hideTimer)
|
||||
hideTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function ensureTooltipEl() {
|
||||
if (tooltipEl || typeof document === 'undefined')
|
||||
return tooltipEl
|
||||
tooltipEl = document.createElement('div')
|
||||
tooltipEl.className = 'ms-tooltip'
|
||||
tooltipEl.setAttribute('role', 'tooltip')
|
||||
tooltipEl.dataset.visible = 'false'
|
||||
tooltipEl.style.position = 'fixed'
|
||||
tooltipEl.style.left = '0px'
|
||||
tooltipEl.style.top = '0px'
|
||||
tooltipEl.style.transform = 'translate3d(0,0,0)'
|
||||
document.body.appendChild(tooltipEl)
|
||||
return tooltipEl
|
||||
}
|
||||
|
||||
function detectDarkModeHint(hint?: boolean | null) {
|
||||
if (typeof hint === 'boolean')
|
||||
return hint
|
||||
if (typeof document !== 'undefined') {
|
||||
try {
|
||||
if (document.documentElement.classList.contains('dark'))
|
||||
return true
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
|
||||
try {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function updatePosition(placement: TooltipPlacement) {
|
||||
if (!tooltipEl || !currentAnchor)
|
||||
return
|
||||
const { x, y } = await computePosition(currentAnchor, tooltipEl, {
|
||||
placement,
|
||||
middleware: [offset(8), flip(), shift({ padding: 8 })],
|
||||
strategy: 'fixed',
|
||||
})
|
||||
tooltipEl.style.transform = `translate3d(${Math.round(x)}px, ${Math.round(y)}px, 0)`
|
||||
}
|
||||
|
||||
export function showTooltipForAnchor(
|
||||
anchor: HTMLElement | null,
|
||||
content: string,
|
||||
placement: TooltipPlacement = 'top',
|
||||
immediate = false,
|
||||
_origin?: { x: number, y: number } | undefined,
|
||||
isDark?: boolean | null,
|
||||
) {
|
||||
if (!anchor || typeof document === 'undefined')
|
||||
return
|
||||
ensureTooltipEl()
|
||||
if (!tooltipEl)
|
||||
return
|
||||
clearTimers()
|
||||
const doShow = async () => {
|
||||
if (!tooltipEl)
|
||||
return
|
||||
currentAnchor = anchor
|
||||
tooltipEl.textContent = content
|
||||
tooltipEl.dataset.placement = placement
|
||||
tooltipEl.dataset.dark = detectDarkModeHint(isDark) ? 'true' : 'false'
|
||||
tooltipEl.dataset.visible = 'false'
|
||||
currentId = `tooltip-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
tooltipEl.id = currentId
|
||||
try {
|
||||
anchor.setAttribute('aria-describedby', currentId)
|
||||
}
|
||||
catch {}
|
||||
await updatePosition(placement)
|
||||
tooltipEl.dataset.visible = 'true'
|
||||
visible = true
|
||||
cleanupAutoUpdate?.()
|
||||
cleanupAutoUpdate = autoUpdate(anchor, tooltipEl!, () => {
|
||||
updatePosition(placement)
|
||||
})
|
||||
}
|
||||
if (immediate)
|
||||
void doShow()
|
||||
else
|
||||
showTimer = setTimeout(doShow, 80)
|
||||
}
|
||||
|
||||
export function hideTooltip(immediate = false) {
|
||||
if (!tooltipEl)
|
||||
return
|
||||
clearTimers()
|
||||
const doHide = () => {
|
||||
if (!tooltipEl)
|
||||
return
|
||||
tooltipEl.dataset.visible = 'false'
|
||||
visible = false
|
||||
if (currentAnchor && currentId) {
|
||||
try {
|
||||
currentAnchor.removeAttribute('aria-describedby')
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
currentAnchor = null
|
||||
currentId = null
|
||||
if (cleanupAutoUpdate) {
|
||||
cleanupAutoUpdate()
|
||||
cleanupAutoUpdate = null
|
||||
}
|
||||
}
|
||||
if (immediate)
|
||||
doHide()
|
||||
else
|
||||
hideTimer = setTimeout(doHide, 120)
|
||||
}
|
||||
|
||||
export function isTooltipVisible() {
|
||||
return visible
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type React from 'react'
|
||||
import type { BaseNode, MarkdownIt, ParsedNode, ParseOptions } from 'stream-markdown-parser'
|
||||
import type {
|
||||
CodeBlockMonacoOptions,
|
||||
CodeBlockMonacoTheme,
|
||||
CodeBlockNodeProps,
|
||||
D2BlockNodeProps,
|
||||
InfographicBlockNodeProps,
|
||||
MermaidBlockNodeProps,
|
||||
} from './types/component-props'
|
||||
|
||||
export type NodeRendererCodeBlockProps = Partial<Omit<CodeBlockNodeProps, 'node'>> & Record<string, unknown>
|
||||
|
||||
export interface NodeRendererProps {
|
||||
content?: string
|
||||
nodes?: readonly BaseNode[] | null
|
||||
/**
|
||||
* Whether the input stream is complete (end-of-stream). When true, the parser
|
||||
* can stop emitting streaming "loading" nodes for unfinished constructs.
|
||||
*/
|
||||
final?: boolean
|
||||
parseOptions?: ParseOptions
|
||||
customMarkdownIt?: (md: MarkdownIt) => MarkdownIt
|
||||
/** Log parse/render timing stats (dev only). */
|
||||
debugPerformance?: boolean
|
||||
/**
|
||||
* Custom HTML-like tags that should be emitted as custom nodes (e.g. ['thinking']).
|
||||
* Forwarded to `getMarkdown()` and merged into parseOptions.
|
||||
*/
|
||||
customHtmlTags?: readonly string[]
|
||||
viewportPriority?: boolean
|
||||
codeBlockStream?: boolean
|
||||
codeBlockDarkTheme?: CodeBlockMonacoTheme
|
||||
codeBlockLightTheme?: CodeBlockMonacoTheme
|
||||
codeBlockMonacoOptions?: CodeBlockMonacoOptions
|
||||
renderCodeBlocksAsPre?: boolean
|
||||
codeBlockMinWidth?: string | number
|
||||
codeBlockMaxWidth?: string | number
|
||||
codeBlockProps?: NodeRendererCodeBlockProps
|
||||
mermaidProps?: Partial<Omit<MermaidBlockNodeProps, 'node' | 'loading' | 'isDark'>>
|
||||
d2Props?: Partial<Omit<D2BlockNodeProps, 'node' | 'loading' | 'isDark'>>
|
||||
infographicProps?: Partial<Omit<InfographicBlockNodeProps, 'node' | 'loading' | 'isDark'>>
|
||||
showTooltips?: boolean
|
||||
themes?: CodeBlockMonacoTheme[]
|
||||
isDark?: boolean
|
||||
customId?: string
|
||||
indexKey?: number | string
|
||||
typewriter?: boolean
|
||||
batchRendering?: boolean
|
||||
initialRenderBatchSize?: number
|
||||
renderBatchSize?: number
|
||||
renderBatchDelay?: number
|
||||
renderBatchBudgetMs?: number
|
||||
renderBatchIdleTimeoutMs?: number
|
||||
deferNodesUntilVisible?: boolean
|
||||
maxLiveNodes?: number
|
||||
liveNodeBuffer?: number
|
||||
onCopy?: (code: string) => void
|
||||
onHandleArtifactClick?: (payload: any) => void
|
||||
onClick?: (event: React.MouseEvent<HTMLDivElement>) => void
|
||||
onMouseOver?: (event: React.MouseEvent<HTMLElement>) => void
|
||||
onMouseOut?: (event: React.MouseEvent<HTMLElement>) => void
|
||||
}
|
||||
|
||||
export interface RenderContext {
|
||||
customId?: string
|
||||
isDark?: boolean
|
||||
indexKey?: string
|
||||
typewriter?: boolean
|
||||
textStreamState?: Map<string, string>
|
||||
streamRenderVersion?: number
|
||||
customComponents?: Record<string, React.ComponentType<any>>
|
||||
customHtmlTags?: readonly string[]
|
||||
codeBlockProps?: NodeRendererCodeBlockProps
|
||||
mermaidProps?: Partial<Omit<MermaidBlockNodeProps, 'node' | 'loading' | 'isDark'>>
|
||||
d2Props?: Partial<Omit<D2BlockNodeProps, 'node' | 'loading' | 'isDark'>>
|
||||
infographicProps?: Partial<Omit<InfographicBlockNodeProps, 'node' | 'loading' | 'isDark'>>
|
||||
showTooltips?: boolean
|
||||
codeBlockStream?: boolean
|
||||
renderCodeBlocksAsPre?: boolean
|
||||
codeBlockThemes?: {
|
||||
themes?: CodeBlockMonacoTheme[]
|
||||
darkTheme?: CodeBlockMonacoTheme
|
||||
lightTheme?: CodeBlockMonacoTheme
|
||||
monacoOptions?: CodeBlockMonacoOptions
|
||||
minWidth?: string | number
|
||||
maxWidth?: string | number
|
||||
}
|
||||
events: {
|
||||
onCopy?: (code: string) => void
|
||||
onHandleArtifactClick?: (payload: any) => void
|
||||
}
|
||||
}
|
||||
|
||||
export type RenderNodeFn = (node: ParsedNode, key: React.Key, ctx: RenderContext) => React.ReactNode
|
||||
@@ -0,0 +1,302 @@
|
||||
import type React from 'react'
|
||||
import type { CodeBlockNode } from 'stream-markdown-parser'
|
||||
|
||||
export interface CodeBlockMonacoThemeObject {
|
||||
name: string
|
||||
base?: string
|
||||
inherit?: boolean
|
||||
colors?: Record<string, string>
|
||||
rules?: Array<Record<string, unknown>>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type CodeBlockMonacoTheme = string | CodeBlockMonacoThemeObject
|
||||
|
||||
export type CodeBlockMonacoLanguage = string | ((...args: any[]) => unknown)
|
||||
|
||||
export interface CodeBlockDiffHideUnchangedRegionsOptions {
|
||||
enabled?: boolean
|
||||
contextLineCount?: number
|
||||
minimumLineCount?: number
|
||||
revealLineCount?: number
|
||||
}
|
||||
|
||||
export type CodeBlockDiffHideUnchangedRegions
|
||||
= | boolean
|
||||
| CodeBlockDiffHideUnchangedRegionsOptions
|
||||
|
||||
export type CodeBlockDiffLineStyle = 'background' | 'bar'
|
||||
|
||||
export type CodeBlockDiffAppearance = 'auto' | 'light' | 'dark'
|
||||
|
||||
export type CodeBlockDiffUnchangedRegionStyle = 'line-info' | 'line-info-basic' | 'metadata' | 'simple'
|
||||
|
||||
export type CodeBlockDiffHunkActionKind = 'revert' | 'stage'
|
||||
|
||||
export type CodeBlockDiffHunkSide = 'upper' | 'lower'
|
||||
|
||||
export interface CodeBlockDiffHunkActionContext {
|
||||
action: CodeBlockDiffHunkActionKind
|
||||
side: CodeBlockDiffHunkSide
|
||||
lineChange: unknown
|
||||
originalModel: unknown
|
||||
modifiedModel: unknown
|
||||
}
|
||||
|
||||
export interface CodeBlockMonacoOptions {
|
||||
MAX_HEIGHT?: number | string
|
||||
fontSize?: number
|
||||
lineHeight?: number
|
||||
fontFamily?: string
|
||||
tabSize?: number
|
||||
readOnly?: boolean
|
||||
wordWrap?: 'off' | 'on' | 'wordWrapColumn' | 'bounded' | string
|
||||
wrappingIndent?: 'none' | 'same' | 'indent' | 'deepIndent' | string
|
||||
theme?: string
|
||||
themes?: CodeBlockMonacoTheme[]
|
||||
languages?: CodeBlockMonacoLanguage[]
|
||||
renderSideBySide?: boolean
|
||||
useInlineViewWhenSpaceIsLimited?: boolean
|
||||
enableSplitViewResizing?: boolean
|
||||
ignoreTrimWhitespace?: boolean
|
||||
maxComputationTime?: number
|
||||
diffAlgorithm?: string
|
||||
renderIndicators?: boolean
|
||||
originalEditable?: boolean
|
||||
revealDebounceMs?: number
|
||||
revealStrategy?: 'bottom' | 'centerIfOutside' | 'center'
|
||||
revealBatchOnIdleMs?: number
|
||||
updateThrottleMs?: number
|
||||
diffUpdateThrottleMs?: number
|
||||
diffAutoScroll?: boolean
|
||||
diffHideUnchangedRegions?: CodeBlockDiffHideUnchangedRegions
|
||||
diffLineStyle?: CodeBlockDiffLineStyle
|
||||
diffAppearance?: CodeBlockDiffAppearance
|
||||
diffUnchangedRegionStyle?: CodeBlockDiffUnchangedRegionStyle
|
||||
diffHunkActionsOnHover?: boolean
|
||||
diffHunkHoverHideDelayMs?: number
|
||||
onDiffHunkAction?: (context: CodeBlockDiffHunkActionContext) => void | boolean | Promise<void | boolean>
|
||||
scrollbar?: Record<string, any>
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface CodeBlockActionContext {
|
||||
collapsed: boolean
|
||||
copied: boolean
|
||||
expanded: boolean
|
||||
fontSize: number
|
||||
defaultFontSize: number
|
||||
isDark: boolean
|
||||
language: string
|
||||
displayLanguage: string
|
||||
languageIcon: string
|
||||
isPreviewable: boolean
|
||||
code: string
|
||||
toggleCollapse: () => void
|
||||
copy: () => Promise<void>
|
||||
toggleExpand: () => void
|
||||
setFontSize: (size: number) => void
|
||||
resetFontSize: () => void
|
||||
decreaseFontSize: () => void
|
||||
increaseFontSize: () => void
|
||||
previewCode: () => void
|
||||
}
|
||||
|
||||
export interface CodeBlockNodeProps {
|
||||
node: CodeBlockNode
|
||||
isDark?: boolean
|
||||
loading?: boolean
|
||||
stream?: boolean
|
||||
darkTheme?: CodeBlockMonacoTheme
|
||||
lightTheme?: CodeBlockMonacoTheme
|
||||
isShowPreview?: boolean
|
||||
monacoOptions?: CodeBlockMonacoOptions
|
||||
enableFontSizeControl?: boolean
|
||||
minWidth?: string | number
|
||||
maxWidth?: string | number
|
||||
themes?: CodeBlockMonacoTheme[]
|
||||
showHeader?: boolean
|
||||
showCopyButton?: boolean
|
||||
showExpandButton?: boolean
|
||||
showPreviewButton?: boolean
|
||||
showCollapseButton?: boolean
|
||||
showFontSizeButtons?: boolean
|
||||
showTooltips?: boolean
|
||||
customId?: string
|
||||
renderHeaderActions?: (context: CodeBlockActionContext) => React.ReactNode
|
||||
renderHeader?: (context: CodeBlockActionContext) => React.ReactNode
|
||||
}
|
||||
|
||||
export interface ImageNodeProps {
|
||||
node: {
|
||||
type: 'image'
|
||||
src: string
|
||||
alt: string
|
||||
title: string | null
|
||||
raw: string
|
||||
loading?: boolean
|
||||
}
|
||||
fallbackSrc?: string
|
||||
lazy?: boolean
|
||||
usePlaceholder?: boolean
|
||||
}
|
||||
|
||||
export interface LinkNodeProps {
|
||||
node: {
|
||||
type: 'link'
|
||||
href: string
|
||||
title: string | null
|
||||
text: string
|
||||
attrs?: [string, 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
|
||||
}
|
||||
|
||||
export interface PreCodeNodeProps {
|
||||
node: CodeBlockNode
|
||||
}
|
||||
|
||||
export interface MermaidBlockActionContext {
|
||||
collapsed: boolean
|
||||
copied: boolean
|
||||
showSource: boolean
|
||||
modalOpen: boolean
|
||||
isDark: boolean
|
||||
code: string
|
||||
mermaidAvailable: boolean
|
||||
isExportDisabled: boolean
|
||||
zoom: number
|
||||
toggleCollapse: () => void
|
||||
copy: () => void
|
||||
exportSvg: () => void
|
||||
toggleFullscreen: () => void
|
||||
switchMode: (mode: 'preview' | 'source') => void
|
||||
zoomIn: () => void
|
||||
zoomOut: () => void
|
||||
resetZoom: () => void
|
||||
}
|
||||
|
||||
export interface MermaidBlockNodeProps {
|
||||
node: CodeBlockNode
|
||||
maxHeight?: string | null
|
||||
estimatedPreviewHeightPx?: number
|
||||
loading?: boolean
|
||||
isDark?: boolean
|
||||
workerTimeoutMs?: number
|
||||
parseTimeoutMs?: number
|
||||
renderTimeoutMs?: number
|
||||
fullRenderTimeoutMs?: number
|
||||
renderDebounceMs?: number
|
||||
contentStableDelayMs?: number
|
||||
previewPollDelayMs?: number
|
||||
previewPollMaxDelayMs?: number
|
||||
previewPollMaxAttempts?: number
|
||||
showHeader?: boolean
|
||||
showModeToggle?: boolean
|
||||
showCopyButton?: boolean
|
||||
showExportButton?: boolean
|
||||
showFullscreenButton?: boolean
|
||||
showCollapseButton?: boolean
|
||||
showZoomControls?: boolean
|
||||
enableWheelZoom?: boolean
|
||||
isStrict?: boolean
|
||||
showTooltips?: boolean
|
||||
onRenderError?: (error: unknown, code: string, container: HTMLElement) => boolean | void
|
||||
renderHeader?: (ctx: MermaidBlockActionContext) => React.ReactNode
|
||||
renderHeaderActions?: (ctx: MermaidBlockActionContext) => React.ReactNode
|
||||
renderModeToggle?: (ctx: MermaidBlockActionContext) => React.ReactNode
|
||||
renderZoomControls?: (ctx: MermaidBlockActionContext) => React.ReactNode
|
||||
}
|
||||
|
||||
export interface MermaidBlockEvent<TPayload = any> {
|
||||
payload?: TPayload
|
||||
defaultPrevented: boolean
|
||||
preventDefault: () => void
|
||||
svgElement?: SVGElement | null
|
||||
svgString?: string | null
|
||||
}
|
||||
|
||||
export interface D2BlockNodeProps {
|
||||
node: CodeBlockNode
|
||||
maxHeight?: string | 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
|
||||
}
|
||||
|
||||
export interface MathBlockNodeProps {
|
||||
node: {
|
||||
type: 'math_block'
|
||||
content: string
|
||||
raw: string
|
||||
loading?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface MathInlineNodeProps {
|
||||
node: {
|
||||
type: 'math_inline'
|
||||
content: string
|
||||
raw: string
|
||||
loading?: boolean
|
||||
markup?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface InfographicBlockActionContext {
|
||||
collapsed: boolean
|
||||
copied: boolean
|
||||
showSource: boolean
|
||||
modalOpen: boolean
|
||||
isDark: boolean
|
||||
code: string
|
||||
isExportDisabled: boolean
|
||||
zoom: number
|
||||
toggleCollapse: () => void
|
||||
copy: () => void
|
||||
exportSvg: () => void
|
||||
toggleFullscreen: () => void
|
||||
switchMode: (mode: 'preview' | 'source') => void
|
||||
zoomIn: () => void
|
||||
zoomOut: () => void
|
||||
resetZoom: () => void
|
||||
}
|
||||
|
||||
export interface InfographicBlockNodeProps {
|
||||
node: CodeBlockNode
|
||||
maxHeight?: string | null
|
||||
estimatedPreviewHeightPx?: number
|
||||
loading?: boolean
|
||||
isDark?: boolean
|
||||
showHeader?: boolean
|
||||
showModeToggle?: boolean
|
||||
showCopyButton?: boolean
|
||||
showCollapseButton?: boolean
|
||||
showExportButton?: boolean
|
||||
showFullscreenButton?: boolean
|
||||
showZoomControls?: boolean
|
||||
renderHeader?: (ctx: InfographicBlockActionContext) => React.ReactNode
|
||||
renderHeaderActions?: (ctx: InfographicBlockActionContext) => React.ReactNode
|
||||
renderModeToggle?: (ctx: InfographicBlockActionContext) => React.ReactNode
|
||||
renderZoomControls?: (ctx: InfographicBlockActionContext) => React.ReactNode
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type React from 'react'
|
||||
import type { RenderContext, RenderNodeFn } from '../types'
|
||||
|
||||
export interface NodeComponentProps<TNode = unknown> {
|
||||
node: TNode
|
||||
ctx?: RenderContext
|
||||
renderNode?: RenderNodeFn
|
||||
indexKey?: React.Key
|
||||
customId?: string
|
||||
isDark?: boolean
|
||||
typewriter?: boolean
|
||||
children?: React.ReactNode
|
||||
}
|
||||