Skip to content

Commit 260afcc

Browse files
💄 style: Support streaming Markdown animation
1 parent 2b4ae40 commit 260afcc

File tree

15 files changed

+800
-185
lines changed

15 files changed

+800
-185
lines changed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,9 @@
116116
"emoji-mart": "^5.6.0",
117117
"fast-deep-equal": "^3.1.3",
118118
"framer-motion": "^12.6.3",
119+
"hast": "^1.0.0",
119120
"immer": "^10.1.1",
121+
"katex": "^0.16.9",
120122
"leva": "^0.10.0",
121123
"lodash-es": "^4.17.21",
122124
"lucide-react": "^0.484.0",

src/Highlighter/SyntaxHighlighter/index.tsx

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
'use client';
22

33
import { cva } from 'class-variance-authority';
4-
import { memo, useMemo } from 'react';
4+
import { memo, useEffect, useMemo, useState } from 'react';
55

66
import { useHighlight } from '@/hooks/useHighlight';
77

88
import type { SyntaxHighlighterProps } from '../type';
99
import { useStyles } from './style';
1010

11+
const Line = memo<{ content: string }>(({ content }) => {
12+
return <div dangerouslySetInnerHTML={{ __html: content }} />;
13+
});
14+
Line.displayName = 'HighlighterLine';
15+
1116
const SyntaxHighlighter = memo<SyntaxHighlighterProps>(
1217
({ ref, children, language, className, style, enableTransformer, variant, theme }) => {
1318
const { styles, cx } = useStyles();
@@ -18,6 +23,41 @@ const SyntaxHighlighter = memo<SyntaxHighlighterProps>(
1823
language,
1924
theme: isDefaultTheme ? undefined : theme,
2025
});
26+
const [contentLines, setContentLines] = useState<string[]>([]);
27+
28+
useEffect(() => {
29+
if (data && typeof data === 'string') {
30+
// Extract all lines from the HTML content
31+
// We need to handle the structure from shiki which gives us HTML with a <pre><code> structure
32+
const parser = new DOMParser();
33+
const doc = parser.parseFromString(data, 'text/html');
34+
const codeElement = doc.querySelector('pre code');
35+
36+
if (codeElement) {
37+
const spanLines = codeElement.querySelectorAll('.line');
38+
const newLines = [...spanLines].map((line) => line.outerHTML);
39+
40+
// Only update if the lines have changed
41+
setContentLines((prevLines) => {
42+
if (prevLines.length !== newLines.length) return newLines;
43+
44+
let hasChanged = false;
45+
for (const [i, newLine] of newLines.entries()) {
46+
if (prevLines[i] !== newLine) {
47+
hasChanged = true;
48+
break;
49+
}
50+
}
51+
52+
return hasChanged ? newLines : prevLines;
53+
});
54+
} else {
55+
// Fallback if the structure is different
56+
const htmlLines = data.split('\n').map((line) => `<span class="line">${line}</span>`);
57+
setContentLines(htmlLines);
58+
}
59+
}
60+
}, [data]);
2161

2262
const variants = useMemo(
2363
() =>
@@ -48,7 +88,7 @@ const SyntaxHighlighter = memo<SyntaxHighlighterProps>(
4888
[styles],
4989
);
5090

51-
if (!data)
91+
if (contentLines.length === 0)
5292
return (
5393
<div
5494
className={cx(variants({ shiki: false, showBackground, variant }), className)}
@@ -65,13 +105,18 @@ const SyntaxHighlighter = memo<SyntaxHighlighterProps>(
65105
return (
66106
<div
67107
className={cx(variants({ shiki: true, showBackground, variant }), className)}
68-
dangerouslySetInnerHTML={{
69-
__html: data as string,
70-
}}
71108
dir="ltr"
72109
ref={ref}
73110
style={style}
74-
/>
111+
>
112+
<pre className="shiki">
113+
<code>
114+
{contentLines.map((line, index) => (
115+
<Line content={line} key={index} />
116+
))}
117+
</code>
118+
</pre>
119+
</div>
75120
);
76121
},
77122
);

src/Markdown/Markdown.tsx

Lines changed: 31 additions & 165 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,12 @@
11
'use client';
22

33
import { cva } from 'class-variance-authority';
4-
import { memo, useCallback, useMemo } from 'react';
5-
import ReactMarkdown from 'react-markdown';
6-
import type { Components } from 'react-markdown/lib';
4+
import { memo, useMemo } from 'react';
75

8-
import { PreviewGroup } from '@/Image';
9-
import Image from '@/mdx/mdxComponents/Image';
10-
import Link from '@/mdx/mdxComponents/Link';
11-
import Section from '@/mdx/mdxComponents/Section';
12-
import Video from '@/mdx/mdxComponents/Video';
13-
14-
import { CodeFullFeatured, CodeLite } from './CodeBlock';
6+
import SyntaxMarkdown from './SyntaxMarkdown';
157
import Typography from './Typography';
168
import { useStyles } from './style';
179
import type { MarkdownProps } from './type';
18-
import {
19-
addToCache,
20-
contentCache,
21-
createPlugins,
22-
escapeBrackets,
23-
escapeMhchem,
24-
fixMarkdownBold,
25-
transformCitations,
26-
} from './utils';
2710

2811
const Markdown = memo<MarkdownProps>(
2912
({
@@ -33,6 +16,7 @@ const Markdown = memo<MarkdownProps>(
3316
style,
3417
fullFeaturedCodeBlock,
3518
onDoubleClick,
19+
animated,
3620
enableLatex = true,
3721
enableMermaid = true,
3822
enableImageGallery = true,
@@ -55,12 +39,13 @@ const Markdown = memo<MarkdownProps>(
5539
...rest
5640
}) => {
5741
const { cx, styles } = useStyles();
58-
const isChatMode = variant === 'chat';
5942

43+
// Style variant handling
6044
const variants = useMemo(
6145
() =>
6246
cva(styles.root, {
6347
defaultVariants: {
48+
animated: false,
6449
enableLatex: true,
6550
variant: 'default',
6651
},
@@ -74,158 +59,19 @@ const Markdown = memo<MarkdownProps>(
7459
true: styles.latex,
7560
false: null,
7661
},
62+
animated: {
63+
true: styles.animated,
64+
false: null,
65+
},
7766
},
7867
/* eslint-enable sort-keys-fix/sort-keys-fix */
7968
}),
8069
[styles],
8170
);
8271

83-
// 计算缓存键
84-
const cacheKey = useMemo(() => {
85-
return `${children}-${enableLatex}-${enableCustomFootnotes}-${citations?.length || 0}`;
86-
}, [children, enableLatex, enableCustomFootnotes, citations?.length]);
87-
88-
// 处理内容并利用缓存避免重复计算
89-
const escapedContent = useMemo(() => {
90-
// 尝试从缓存获取
91-
if (contentCache.has(cacheKey)) {
92-
return contentCache.get(cacheKey);
93-
}
94-
95-
// 处理新内容
96-
let processedContent;
97-
if (enableLatex) {
98-
const baseContent = fixMarkdownBold(escapeMhchem(escapeBrackets(children)));
99-
processedContent = enableCustomFootnotes
100-
? transformCitations(baseContent, citations?.length)
101-
: baseContent;
102-
} else {
103-
processedContent = fixMarkdownBold(children);
104-
}
105-
106-
// 缓存处理结果
107-
addToCache(cacheKey, processedContent);
108-
return processedContent;
109-
}, [cacheKey, children, enableLatex, enableCustomFootnotes, citations?.length]);
110-
111-
// 创建插件
112-
const { rehypePluginsList, remarkPluginsList } = useMemo(
113-
() =>
114-
createPlugins({
115-
allowHtml,
116-
enableCustomFootnotes,
117-
enableLatex,
118-
isChatMode,
119-
rehypePlugins,
120-
remarkPlugins,
121-
remarkPluginsAhead,
122-
}),
123-
[
124-
allowHtml,
125-
enableLatex,
126-
enableCustomFootnotes,
127-
isChatMode,
128-
rehypePlugins,
129-
remarkPlugins,
130-
remarkPluginsAhead,
131-
],
132-
);
133-
134-
// 使用 useCallback 优化渲染子组件
135-
const renderLink = useCallback(
136-
(props: any) => <Link citations={citations} {...props} {...componentProps?.a} />,
137-
[citations, componentProps?.a],
138-
);
139-
140-
const renderImage = useCallback(
141-
(props: any) => <Image {...props} {...componentProps?.img} />,
142-
[isChatMode, componentProps?.img],
143-
);
144-
145-
const renderCodeBlock = useCallback(
146-
(props: any) =>
147-
fullFeaturedCodeBlock ? (
148-
<CodeFullFeatured
149-
enableMermaid={enableMermaid}
150-
highlight={componentProps?.highlight}
151-
mermaid={componentProps?.mermaid}
152-
{...props}
153-
{...componentProps?.pre}
154-
/>
155-
) : (
156-
<CodeLite
157-
enableMermaid={enableMermaid}
158-
highlight={componentProps?.highlight}
159-
mermaid={componentProps?.mermaid}
160-
{...props}
161-
{...componentProps?.pre}
162-
/>
163-
),
164-
[
165-
enableMermaid,
166-
fullFeaturedCodeBlock,
167-
componentProps?.highlight,
168-
componentProps?.mermaid,
169-
componentProps?.pre,
170-
],
171-
);
172-
173-
const renderSection = useCallback(
174-
(props: any) => <Section showCitations={showFootnotes} {...props} />,
175-
[showFootnotes],
176-
);
177-
178-
const renderVideo = useCallback(
179-
(props: any) => <Video {...props} {...componentProps?.video} />,
180-
[componentProps?.video],
181-
);
182-
183-
// 创建组件映射
184-
const memoComponents = useMemo(
185-
() => ({
186-
a: renderLink,
187-
img: enableImageGallery ? renderImage : undefined,
188-
pre: renderCodeBlock,
189-
section: renderSection,
190-
video: renderVideo,
191-
...components,
192-
}),
193-
[
194-
renderLink,
195-
renderImage,
196-
renderCodeBlock,
197-
renderSection,
198-
renderVideo,
199-
enableImageGallery,
200-
components,
201-
],
202-
) as Components;
203-
204-
// 渲染默认内容
205-
const defaultDOM = useMemo(
206-
() => (
207-
<PreviewGroup enable={enableImageGallery}>
208-
<ReactMarkdown
209-
{...reactMarkdownProps}
210-
components={memoComponents}
211-
rehypePlugins={rehypePluginsList}
212-
remarkPlugins={remarkPluginsList}
213-
>
214-
{escapedContent}
215-
</ReactMarkdown>
216-
</PreviewGroup>
217-
),
218-
[escapedContent, memoComponents, rehypePluginsList, remarkPluginsList, enableImageGallery],
219-
);
220-
221-
// 应用自定义渲染
222-
const markdownContent = customRender
223-
? customRender(defaultDOM, { text: escapedContent || '' })
224-
: defaultDOM;
225-
22672
return (
22773
<Typography
228-
className={cx(variants({ enableLatex, variant }), className)}
74+
className={cx(variants({ animated, enableLatex, variant }), className)}
22975
data-code-type="markdown"
23076
fontSize={fontSize}
23177
headerMultiple={headerMultiple}
@@ -236,7 +82,27 @@ const Markdown = memo<MarkdownProps>(
23682
style={style}
23783
{...rest}
23884
>
239-
{markdownContent}
85+
<SyntaxMarkdown
86+
allowHtml={allowHtml}
87+
animated={animated}
88+
citations={citations}
89+
componentProps={componentProps}
90+
components={components}
91+
customRender={customRender}
92+
enableCustomFootnotes={enableCustomFootnotes}
93+
enableImageGallery={enableImageGallery}
94+
enableLatex={enableLatex}
95+
enableMermaid={enableMermaid}
96+
fullFeaturedCodeBlock={fullFeaturedCodeBlock}
97+
reactMarkdownProps={reactMarkdownProps}
98+
rehypePlugins={rehypePlugins}
99+
remarkPlugins={remarkPlugins}
100+
remarkPluginsAhead={remarkPluginsAhead}
101+
showFootnotes={showFootnotes}
102+
variant={variant}
103+
>
104+
{children}
105+
</SyntaxMarkdown>
240106
</Typography>
241107
);
242108
},

0 commit comments

Comments
 (0)