From 8dfe0c472d8615b1fe80b8610120eba507317050 Mon Sep 17 00:00:00 2001 From: div627 Date: Mon, 29 Sep 2025 11:43:16 +0800 Subject: [PATCH 01/11] feat: support map incomplete image and link to components --- .../src/XMarkdown/__test__/hooks.test.tsx | 346 +++++++++++++++--- .../src/XMarkdown/hooks/useStreaming.ts | 246 ++++++++----- .../x-markdown/src/XMarkdown/interface.ts | 9 + packages/x/docs/x-markdown/animation.en-US.md | 62 ++++ ...{streaming.zh-CN.md => animation.zh-CN.md} | 22 +- .../docs/x-markdown/demo/streaming/format.tsx | 296 ++++++--------- packages/x/docs/x-markdown/examples.en-US.md | 1 + packages/x/docs/x-markdown/examples.zh-CN.md | 1 + .../docs/x-markdown/streaming-format.en-US.md | 226 ++++++++++++ .../docs/x-markdown/streaming-format.zh-CN.md | 201 ++++++++++ packages/x/docs/x-markdown/streaming.en-US.md | 54 --- 11 files changed, 1089 insertions(+), 375 deletions(-) create mode 100644 packages/x/docs/x-markdown/animation.en-US.md rename packages/x/docs/x-markdown/{streaming.zh-CN.md => animation.zh-CN.md} (61%) create mode 100644 packages/x/docs/x-markdown/streaming-format.en-US.md create mode 100644 packages/x/docs/x-markdown/streaming-format.zh-CN.md delete mode 100644 packages/x/docs/x-markdown/streaming.en-US.md diff --git a/packages/x-markdown/src/XMarkdown/__test__/hooks.test.tsx b/packages/x-markdown/src/XMarkdown/__test__/hooks.test.tsx index 89552278c..39493fbaf 100644 --- a/packages/x-markdown/src/XMarkdown/__test__/hooks.test.tsx +++ b/packages/x-markdown/src/XMarkdown/__test__/hooks.test.tsx @@ -5,19 +5,9 @@ import { XMarkdownProps } from '../interface'; const testCases = [ { - title: 'not string', - input: {}, - output: '', - }, - { - title: 'complete link', - input: '[ant design x](https://x.ant.design/index-cn)', - output: '[ant design x](https://x.ant.design/index-cn)', - }, - { - title: 'incomplete link', - input: '[ant design x](https', - output: '', + title: 'complete Html', + input: '
', + output: '
', }, { title: 'not support link reference definitions', @@ -25,10 +15,10 @@ const testCases = [ output: '[foo]: /url "title"', }, { - title: 'incomplete link nested image', - input: - '[![version](https://camo.githubusercontent.com/c6d467fb550578b3f321c1012e289f20e038b92dcdfc35f2b8147ca6572878ad/68747470733a2f2f696d672e736869656c64732e696f2f747769747465722f666f6c6c6f772f416e7444657369676e55492e7376673f6c6162656c3d416e7425323044657369676e)](https://github.com/ant-design/x', - output: '', + title: 'incomplete image', + input: '![alt text](https', + output: '', + config: { hasNextChunk: true }, }, { title: 'complete link nested image', @@ -40,7 +30,7 @@ const testCases = [ { title: 'incomplete image', input: '![', - output: '', + output: '![', }, { title: 'complete image', @@ -133,7 +123,7 @@ const testCases = [ { title: 'more than 3 ***', input: '****Test', - output: '****Test', + output: '', }, { title: 'incomplete Html', @@ -145,11 +135,6 @@ const testCases = [ input: '
', output: '
', }, - { - title: 'complete Html', - input: '
', - output: '
', - }, { title: 'invalid Html', input: '', + config: { hasNextChunk: true }, + }, + { + title: 'complete fenced code block with incomplete markdown inside', + input: + '```js\nconst link = "[incomplete](https://example.com";\nconst img = "![alt](https://example.com/image.jpg";\n```', + output: + '```js\nconst link = "[incomplete](https://example.com";\nconst img = "![alt](https://example.com/image.jpg";\n```', + config: { hasNextChunk: true }, + }, + { + title: 'incomplete fenced code block should not process content', + input: '```markdown\nSome content with [incomplete link](https://example', + output: '```markdown\nSome content with [incomplete link](https://example', + config: { hasNextChunk: true }, + }, + { + title: 'multiple fenced code blocks', + input: + '```js\nconsole.log("[link](https://example.com");\n```\n\nSome text\n\n```python\n# ![image](https://example.com/image.jpg\nprint("hello")\n```', + output: + '```js\nconsole.log("[link](https://example.com");\n```\n\nSome text\n\n```python\n# ![image](https://example.com/image.jpg\nprint("hello")\n```', + config: { hasNextChunk: true }, + }, +]; + +const streamingStateTestCases = [ + { + title: 'streaming state reset when input changes completely', + input: 'Hello world', + output: 'Hello world', + config: { hasNextChunk: true }, + }, + { + title: 'streaming state continues when input extends', + input: '[incomplete link](https://example', + output: '', + config: { hasNextChunk: true }, + }, + { + title: 'empty input should reset state', + input: '', + output: '', + config: { hasNextChunk: true }, + }, + { + title: 'streaming with hasNextChunk false should show full content', + input: '[incomplete link](https://example', + output: '[incomplete link](https://example', + config: { hasNextChunk: false }, + }, +]; + +const complexMarkdownTestCases = [ + { + title: 'mixed markdown elements with incomplete parts', + input: '# Heading\n\nThis is a paragraph with [incomplete link](https://example', + output: '# Heading\n\nThis is a paragraph with [incomplete link](https://example', + config: { hasNextChunk: true }, + }, + { + title: 'nested markdown structures', + input: '## Subheading\n\nText with *italic* and **bold** and ***both***.', + output: '## Subheading\n\nText with *italic* and **bold** and ***both***.', + config: { hasNextChunk: true }, + }, +]; + +const edgeCaseTestCases = [ + { + title: 'very long incomplete link', + input: + '[very long link text that goes on and on](https://very-long-url-that-continues-forever-and-ever-until-it-becomes-unbearably-long', + output: '', + config: { hasNextChunk: true }, + }, + { + title: 'multiple incomplete elements in sequence', + input: '[link1](https://example1', + output: '', + config: { hasNextChunk: true }, + }, + { + title: 'incomplete elements at line breaks', + input: 'Text before\n[link](https://example', + output: 'Text before\n', + config: { hasNextChunk: true }, + }, + { + title: 'special characters in incomplete elements', + input: '[link with special chars: !@#$%^&*()](https://example.com/path?param=value&other=test', + output: '', + config: { hasNextChunk: true }, + }, + { + title: 'unicode characters in markdown', + input: '[中文链接](https://例子.测试', + output: '', + config: { hasNextChunk: true }, + }, +]; + type TestCase = { title: string; input: any; @@ -243,30 +404,131 @@ describe('XMarkdown hooks', () => { ); expect(container.textContent).toBe(output); }); + }); - it('useAnimation should return empty object when streaming is not enabled', () => { - const { result } = renderHook(() => useAnimation(undefined)); - expect(result.current).toEqual({}); + placeholderMapTestCases.forEach(({ title, input, output, config }) => { + it(`useStreaming incompleteMarkdownComponentMap testcase: ${title}`, () => { + const { container } = render( + , + ); + expect(container.textContent).toContain(output); + }); + }); + + fencedCodeTestCases.forEach(({ title, input, output, config }) => { + it(`useStreaming fenced code block testcase: ${title}`, () => { + const { container } = render( + , + ); + expect(container.textContent).toBe(output); + }); + }); + + streamingStateTestCases.forEach(({ title, input, output, config }) => { + it(`useStreaming streaming state testcase: ${title}`, () => { + const { container } = render( + , + ); + expect(container.textContent).toBe(output); }); + }); - it('useAnimation should return empty object when enableAnimation is false', () => { - const { result } = renderHook(() => useAnimation({ enableAnimation: false })); - expect(result.current).toEqual({}); + complexMarkdownTestCases.forEach(({ title, input, output, config }) => { + it(`useStreaming complex markdown testcase: ${title}`, () => { + const { container } = render( + , + ); + expect(container.textContent).toBe(output); }); + }); - it('useAnimation should memoize components based on animationConfig', () => { - const animationConfig = { fadeDuration: 1000 }; - const { result, rerender } = renderHook( - ({ config }) => useAnimation({ enableAnimation: true, animationConfig: config }), - { initialProps: { config: animationConfig } }, + edgeCaseTestCases.forEach(({ title, input, output, config }) => { + it(`useStreaming edge case testcase: ${title}`, () => { + const { container } = render( + , ); + expect(container.textContent).toBe(output); + }); + }); + + it('useAnimation should return empty object when streaming is not enabled', () => { + const { result } = renderHook(() => useAnimation(undefined)); + expect(result.current).toEqual({}); + }); - const firstResult = result.current; - rerender({ config: { ...animationConfig } }); // Same config - expect(result.current).toBe(firstResult); + it('useAnimation should return empty object when enableAnimation is false', () => { + const { result } = renderHook(() => useAnimation({ enableAnimation: false })); + expect(result.current).toEqual({}); + }); + + it('useAnimation should memoize components based on animationConfig', () => { + const animationConfig = { fadeDuration: 1000 }; + const { result, rerender } = renderHook( + ({ config }) => useAnimation({ enableAnimation: true, animationConfig: config }), + { initialProps: { config: animationConfig } }, + ); - rerender({ config: { fadeDuration: 2000 } }); // Different config - expect(result.current).not.toBe(firstResult); + const firstResult = result.current; + rerender({ config: { ...animationConfig } }); // Same config + expect(result.current).toBe(firstResult); + + rerender({ config: { fadeDuration: 2000 } }); // Different config + expect(result.current).not.toBe(firstResult); + }); + + it('should handle streaming chunk by chunk', () => { + const { result, rerender } = renderHook(({ input, config }) => useStreaming(input, config), { + initialProps: { + input: 'Hello', + config: { hasNextChunk: true }, + }, + }); + + expect(result.current).toBe('Hello'); + + // Simulate streaming more content + rerender({ + input: 'Hello world', + config: { hasNextChunk: true }, + }); + expect(result.current).toBe('Hello world'); + + // Simulate streaming incomplete markdown + rerender({ + input: 'Hello world with [incomplete link](https://example', + config: { hasNextChunk: true }, + }); + expect(result.current).toBe('Hello world with '); + }); + + it('should reset state when input is completely different', () => { + const { result, rerender } = renderHook(({ input, config }) => useStreaming(input, config), { + initialProps: { + input: 'First content', + config: { hasNextChunk: true }, + }, + }); + + expect(result.current).toBe('First content'); + + // Completely different input should reset state + rerender({ + input: 'Completely different', + config: { hasNextChunk: true }, }); + expect(result.current).toBe('Completely different'); + }); + + it('should handle non-string input gracefully', () => { + const { result } = renderHook(() => useStreaming(null as any, { hasNextChunk: true })); + expect(result.current).toBe(''); + + const { result: result2 } = renderHook(() => + useStreaming(undefined as any, { hasNextChunk: true }), + ); + expect(result2.current).toBe(''); + + const { result: result3 } = renderHook(() => useStreaming(123 as any, { hasNextChunk: true })); + expect(result3.current).toBe(''); }); }); diff --git a/packages/x-markdown/src/XMarkdown/hooks/useStreaming.ts b/packages/x-markdown/src/XMarkdown/hooks/useStreaming.ts index 7072c5267..1d7515120 100644 --- a/packages/x-markdown/src/XMarkdown/hooks/useStreaming.ts +++ b/packages/x-markdown/src/XMarkdown/hooks/useStreaming.ts @@ -16,13 +16,18 @@ enum TokenType { MaybeList = 11, } -const Markdown_Symbols = { - emphasis: ['*', '_'], - code: ['`'], - list: ['-', '+', '*'], -}; +interface StreamBuffer { + processedLength: number; + rawStream: string; + pending: string; + token: TokenType; + tokens: TokenType[]; + headingLevel: number; + emphasisCount: number; + backtickCount: number; +} -const STREAM_BUFFER_INIT = { +const STREAM_BUFFER_INIT: StreamBuffer = { processedLength: 0, rawStream: '', pending: '', @@ -34,128 +39,167 @@ const STREAM_BUFFER_INIT = { }; const useStreaming = (input: string, config?: XMarkdownProps['streaming']) => { - const { hasNextChunk = false } = config || {}; + const { hasNextChunk = false, incompleteMarkdownComponentMap } = config || {}; const [output, setOutput] = useState(''); - const streamBuffer = useRef({ ...STREAM_BUFFER_INIT }); + const streamBuffer = useRef({ ...STREAM_BUFFER_INIT }); const pushToken = useCallback((type: TokenType) => { - streamBuffer.current.tokens = [...streamBuffer.current.tokens, type]; - streamBuffer.current.token = type; + const buffer = streamBuffer.current; + buffer.tokens.push(type); + buffer.token = type; }, []); const popToken = useCallback(() => { - const { tokens } = streamBuffer.current; - if (tokens.length <= 1) return; + const buffer = streamBuffer.current; + if (buffer.tokens.length <= 1) return; - const newTokens = [...tokens.slice(0, -1)]; - streamBuffer.current.tokens = newTokens; - streamBuffer.current.token = newTokens[newTokens.length - 1]; + buffer.tokens.pop(); + buffer.token = buffer.tokens[buffer.tokens.length - 1]; }, []); - const flushOutput = (needPopToken = true) => { - if (needPopToken) popToken(); + const flushOutput = useCallback( + (needPopToken = true) => { + if (needPopToken) { + popToken(); + } + + streamBuffer.current.pending = ''; + const renderText = streamBuffer.current.rawStream; + if (!renderText) return; - streamBuffer.current.pending = ''; - const renderText = streamBuffer.current.rawStream; - if (renderText) { setOutput(renderText); - } - }; + }, + [popToken], + ); - const handleChunk = (chunk: string) => { - const buffer = streamBuffer.current; - for (const char of chunk) { - buffer.rawStream += char; - buffer.pending += char; + // 替换不完整的 Markdown 语义为自定义加载组件 + const replaceInCompleteFormat = useCallback(() => { + const finalComponentMap = { + link: `<${incompleteMarkdownComponentMap?.link || 'incomplete-link'} />`, + image: `<${incompleteMarkdownComponentMap?.image || 'incomplete-image'} />`, + }; + + const renderText = streamBuffer.current.rawStream; + if (!renderText) return; + + // 使用更精确的正则表达式,避免误匹配 + const replacedOutput = renderText + .replace(/!\[([^\]]*?)\](?!\([^)]*\)$)(?![^[]*\]\([^)]*\))$/, finalComponentMap.image) + .replace(/\[([^\]]*?)\](?!\([^)]*\)$)(?![^[]*\]\([^)]*\))$/, finalComponentMap.link) + .replace(/!\[([^\]]*?)\]\([^)]*$/, finalComponentMap.image) + .replace(/\[([^\]]*?)\]\([^)]*$/, finalComponentMap.link); + + setOutput(replacedOutput); + }, [incompleteMarkdownComponentMap]); + + const handleTokenProcessing = useCallback( + (char: string) => { + const buffer = streamBuffer.current; + const { token, tokens, emphasisCount } = buffer; - const { token, pending, tokens, emphasisCount } = buffer; switch (token) { case TokenType.Image: { /** * \![ * ^ */ - const isInvalidStart = pending.indexOf('![') === -1; + const isInvalidStart = !buffer.pending.includes('!['); /** * \![image]() * ^ */ const isImageEnd = char === ')' || char === '\n'; + if (isInvalidStart || isImageEnd) { if (tokens[tokens.length - 2] === TokenType.Link) { popToken(); } else { flushOutput(); } + } else { + // replace loading component + replaceInCompleteFormat(); } break; } + case TokenType.Link: { // not support link reference definitions, [foo]: /url "title" \n[foo] - const isReferenceLink = pending.endsWith(']:'); + const isReferenceLink = buffer.pending.endsWith(']:'); const isLinkEnd = char === ')' || char === '\n'; const isImageInLink = char === '!'; + if (isImageInLink) { pushToken(TokenType.Image); } else if (isLinkEnd || isReferenceLink) { flushOutput(); + } else { + // replace loading component + replaceInCompleteFormat(); } break; } + case TokenType.Heading: { /** * # token / ## token / #####token * ^ ^ ^ */ buffer.headingLevel++; - const shouldFlushOutput = char !== '#' || buffer.headingLevel >= 6; + if (shouldFlushOutput) { flushOutput(); buffer.headingLevel = 0; } break; } + case TokenType.MaybeEmphasis: { /** - * /* / *\/n - ^ ^ - */ + * /* / *\/n + ^ ^ + */ const shouldFlushOutput = char === ' ' || char === '\n'; + if (shouldFlushOutput) { flushOutput(); - } else if (Markdown_Symbols.emphasis.includes(char)) { + } else if (char === '*' || char === '_') { buffer.emphasisCount++; } else { popToken(); - if (emphasisCount === 1) { - /** - * _token_ / *token* - * ^ ^ - */ - pushToken(TokenType.Emphasis); - } else if (emphasisCount === 2) { - /** - * __token__ / **token** - * ^ ^ - */ - pushToken(TokenType.Strong); - } else if (emphasisCount === 3) { - /** - * ___token___ / ***token*** - * ^ ^ - */ - pushToken(TokenType.Emphasis); - pushToken(TokenType.Strong); - } else { - // no more than 3 - buffer.emphasisCount = 0; + + switch (emphasisCount) { + case 1: + /** + * _token_ / *token* + * ^ ^ + */ + pushToken(TokenType.Emphasis); + break; + case 2: + /** + * __token__ / **token** + * ^ ^ + */ + pushToken(TokenType.Strong); + break; + case 3: + /** + * ___token___ / ***token*** + * ^ ^ + */ + pushToken(TokenType.Emphasis); + pushToken(TokenType.Strong); + break; + default: + buffer.emphasisCount = 0; } } - break; } + case TokenType.Strong: { /** * __token__ / **token** @@ -163,88 +207,83 @@ const useStreaming = (input: string, config?: XMarkdownProps['streaming']) => { */ if (char === '\n') { flushOutput(); - } else if (pending.endsWith('**') || pending.endsWith('__')) { + } else if (buffer.pending.endsWith('**') || buffer.pending.endsWith('__')) { if (tokens[tokens.length - 2] === TokenType.Emphasis) { popToken(); } else { flushOutput(); } } - break; } + case TokenType.Emphasis: { /** * _token_ / *token* * ^ ^ */ - if (char === '\n') { - flushOutput(); - buffer.emphasisCount = 0; - } else if (Markdown_Symbols.emphasis.includes(char)) { + if (char === '\n' || char === '*' || char === '_') { flushOutput(); buffer.emphasisCount = 0; } break; } + case TokenType.XML: { /** * / * ^ ^ */ - const shouldFlushOutput = char === '>' || pending === '< ' || char === '\n'; + const shouldFlushOutput = char === '>' || buffer.pending === '< ' || char === '\n'; if (shouldFlushOutput) { flushOutput(); - continue; } break; } + case TokenType.MaybeCode: { + /** + * ``` + * ^ + */ if (char === '`') { buffer.backtickCount++; } else { - if (buffer.backtickCount > 2) { - /** - * ``` - * ^ - */ - flushOutput(); - buffer.backtickCount = 0; - } else { - /** - * `` - * ^ - */ - popToken(); - pushToken(TokenType.Code); - } + flushOutput(); + pushToken(TokenType.Code); } break; } - case TokenType.Code: { - if (char === '`') { - buffer.backtickCount--; - } - if (buffer.backtickCount === 0) { - flushOutput(); - buffer.backtickCount = 0; + case TokenType.Code: { + flushOutput(false); + if (char === '`' && --buffer.backtickCount === 0) { + popToken(); } break; } + case TokenType.MaybeHr: { - if (char !== '-' && char !== '=') { + /** + * avoid Setext headings + * Foo + * - + * ^ + */ + if (char !== '-' && char !== '=' && char !== ' ') { flushOutput(); } break; } + case TokenType.MaybeList: { if (char !== ' ') { flushOutput(); } break; } + default: { buffer.pending = char; @@ -264,15 +303,28 @@ const useStreaming = (input: string, config?: XMarkdownProps['streaming']) => { buffer.backtickCount = 1; } else if (char === '-' || char === '=') { pushToken(TokenType.MaybeHr); - } else if (Markdown_Symbols.list.includes(char)) { + } else if ((char === '+' || char === '*') && buffer.pending.length === 1) { pushToken(TokenType.MaybeList); } else { flushOutput(false); } } } - } - }; + }, + [pushToken, popToken, flushOutput, replaceInCompleteFormat], + ); + + const handleChunk = useCallback( + (chunk: string) => { + const buffer = streamBuffer.current; + for (const char of chunk) { + buffer.rawStream += char; + buffer.pending += char; + handleTokenProcessing(char); + } + }, + [handleTokenProcessing], + ); useEffect(() => { if (!input) { @@ -286,17 +338,23 @@ const useStreaming = (input: string, config?: XMarkdownProps['streaming']) => { return; } + // 如果输入完全改变,重置状态 + const currentRaw = streamBuffer.current.rawStream; + if (!input.startsWith(currentRaw)) { + streamBuffer.current = { ...STREAM_BUFFER_INIT }; + } + if (!hasNextChunk) { setOutput(input); return; } const chunk = input.slice(streamBuffer.current.processedLength); - if (chunk.length) { + if (chunk.length > 0) { streamBuffer.current.processedLength += chunk.length; handleChunk(chunk); } - }, [input, hasNextChunk]); + }, [input, hasNextChunk, handleChunk]); return output; }; diff --git a/packages/x-markdown/src/XMarkdown/interface.ts b/packages/x-markdown/src/XMarkdown/interface.ts index 7222bda70..b80977ba4 100644 --- a/packages/x-markdown/src/XMarkdown/interface.ts +++ b/packages/x-markdown/src/XMarkdown/interface.ts @@ -38,6 +38,15 @@ interface SteamingOption { * @description Configuration for text appearance animation effects */ animationConfig?: AnimationConfig; + /** + * @description 未完成的 Markdown 格式转换为自定义加载组件的映射配置,用于在流式渲染过程中为未闭合的链接和图片提供自定义loading组件 + * @description Mapping configuration to convert incomplete Markdown formats to custom loading components, used to provide custom loading components for unclosed links and images during streaming rendering + * @default { link: 'incomplete-link', image: 'incomplete-image' } + */ + incompleteMarkdownComponentMap?: { + link?: string; + image?: string; + }; } type StreamStatus = 'loading' | 'done'; diff --git a/packages/x/docs/x-markdown/animation.en-US.md b/packages/x/docs/x-markdown/animation.en-US.md new file mode 100644 index 000000000..db327489a --- /dev/null +++ b/packages/x/docs/x-markdown/animation.en-US.md @@ -0,0 +1,62 @@ +--- +title: Streaming Animation Effects +order: 4 +--- + +## Introduction + +Add elegant animation effects to streaming rendered content, supporting text fade-in animations to enhance user experience. + +## Code Demos + +Animation Effects + +## Configuration + +### streaming + +| Parameter | Description | Type | Default | +| --- | --- | --- | --- | +| hasNextChunk | Whether there is more streaming data | `boolean` | `false` | +| enableAnimation | Enable text fade-in animation | `boolean` | `false` | +| animationConfig | Text animation configuration | `AnimationConfig` | `{ fadeDuration: 200, easing: 'ease-in-out' }` | + +#### AnimationConfig + +| Property | Description | Type | Default | +| ------------ | --------------------------------------- | -------- | --------------- | +| fadeDuration | Fade animation duration in milliseconds | `number` | `200` | +| easing | Animation easing function | `string` | `'ease-in-out'` | + +### Usage Example + +```tsx +import { XMarkdown } from '@ant-design/x-markdown'; + +const App = () => { + return ( + + ); +}; +``` + +## Animation Effects Description + +Text fade-in animation provides the following features: + +- **Smooth Transition**: Text gradually appears with fade-in effect +- **Configurable Duration**: Support custom animation duration +- **Easing Functions**: Support multiple easing effects (ease-in-out, linear, ease-in, ease-out) +- **Performance Optimization**: High-performance animations using CSS3 transform and opacity + +When `enableAnimation` is set to `true`, newly received content will be displayed with fade-in animation, providing users with a smoother reading experience. diff --git a/packages/x/docs/x-markdown/streaming.zh-CN.md b/packages/x/docs/x-markdown/animation.zh-CN.md similarity index 61% rename from packages/x/docs/x-markdown/streaming.zh-CN.md rename to packages/x/docs/x-markdown/animation.zh-CN.md index dae48f8c0..d9a59cf19 100644 --- a/packages/x/docs/x-markdown/streaming.zh-CN.md +++ b/packages/x/docs/x-markdown/animation.zh-CN.md @@ -1,18 +1,15 @@ --- -title: 流式渲染 -order: 3 +title: 流式动画效果 +order: 4 --- ## 介绍 -通过缓存隐藏 markdown 格式和动画效果,优化 LLM 场景下流式 Markdown 渲染效果。 +为流式渲染的内容添加优雅的动画效果,支持文字渐显动画,提升用户体验。 ## 代码演示 - - - -缓存 动画效果 +动画效果 ## 配置说明 @@ -52,3 +49,14 @@ const App = () => { ); }; ``` + +## 动画效果说明 + +文字渐显动画提供了以下特性: + +- **平滑过渡**:文字以淡入的方式逐步显示 +- **可配置时长**:支持自定义动画持续时间 +- **缓动函数**:支持多种缓动效果(ease-in-out、linear、ease-in、ease-out) +- **性能优化**:使用 CSS3 transform 和 opacity 实现高性能动画 + +当 `enableAnimation` 设置为 `true` 时,新接收到的内容会以淡入动画的方式显示,为用户提供更流畅的阅读体验。 diff --git a/packages/x/docs/x-markdown/demo/streaming/format.tsx b/packages/x/docs/x-markdown/demo/streaming/format.tsx index c61a6edaf..8134adc87 100644 --- a/packages/x/docs/x-markdown/demo/streaming/format.tsx +++ b/packages/x/docs/x-markdown/demo/streaming/format.tsx @@ -1,196 +1,136 @@ -import type { BubbleListProps } from '@ant-design/x'; -import { Bubble, Sender } from '@ant-design/x'; import XMarkdown from '@ant-design/x-markdown'; -import { DefaultChatProvider, useXChat, XRequest } from '@ant-design/x-sdk'; -import { Button, Row } from 'antd'; -import React, { useMemo, useState } from 'react'; -import { mockFetch, useMarkdownTheme } from '../_utils'; -import '@ant-design/x-markdown/themes/light.css'; -import '@ant-design/x-markdown/themes/dark.css'; - -const fullContent = ` -### Link链接 🔗 -内部链接:[Ant Design X](https://github.com/ant-design/x) - -邮箱链接: - -### Image 🖼️ - -![示例图片](https://mdn.alipayobjects.com/huamei_yz9z7c/afts/img/0lMhRYbo0-8AAAAAQDAAAAgADlJoAQFr/original) - -### Heading标题 📑 -# 一级标题 -## 二级标题 -### 三级标题 -#### 四级标题 -##### 五级标题 -###### 六级标题 - -### Emphasis强调 ✨ -*斜体文本* - -**粗体文本** - -***粗斜体文本*** - -### Strong强调 -**这是strong标签的效果** - -__这也是strong的效果__ - -### XML标签 -\`\`\`xml - - 张三 - 25 - zhangsan@example.com - -\`\`\` - -### Code代码 💻 -\`console.log('Hello World')\` - -#### 行内代码 -使用 \`console.log('Hello World')\` 输出信息 - -#### 代码块 -\`\`\`javascript -function greet(name) { - return \`Hello, \${name}!\`; -} -console.log(greet('World')); -\`\`\` - -\`\`\`python -def fibonacci(n): - if n <= 1: - return n - return fibonacci(n-1) + fibonacci(n-2) -\`\`\` - -### Hr水平线 📏 ---- -*** -___ - -#### 有序列表 -1. 第一步 -2. 第二步 - 1. 子步骤2.1 - 2. 子步骤2.2 -3. 第三步 - -#### 混合列表 -1. 主要任务 - - 子任务1 - - 子任务2 - - [x] 已完成子任务 - - [ ] 未完成子任务 +import { Button, Card, Skeleton } from 'antd'; +import React, { useState } from 'react'; + +const demos = [ + { + title: 'Mixed Syntax', + content: + '# Complex Mixed Syntax\n\nThis is a **comprehensive example** with:\n\n- **Bold items** with [Ant Design X](https://github.com/ant-design/x)\n- *Italic text* with `inline code`\n- Images: ![Ant Design X](https://mdn.alipayobjects.com/huamei_yz9z7c/afts/img/0lMhRYbo0-8AAAAAQDAAAAgADlJoAQFr/original)\n\n## Code Example\n\n```javascript\nconst mixed = "Hello **world** with [link](https://example.com)";\n```\n\n> **Note**: This is a *blockquote* with **mixed** syntax.', + }, + { + title: 'Image Syntax', + content: + "Here's an image: \n\n![Ant Design X](https://mdn.alipayobjects.com/huamei_yz9z7c/afts/img/0lMhRYbo0-8AAAAAQDAAAAgADlJoAQFr/original)", + }, + { + title: 'Link Syntax', + content: 'Visit [Ant Design X](https://github.com/ant-design/x) for more details.', + }, + { + title: 'Emphasis', + content: + 'This is **bold text** and this is *italic text*. You can also use ***bold and italic***.', + }, + { + title: 'Code Block', + content: + '```typescript\nconst greet = (name: string) => {\n console.log(`Hello, ${name}!`);\n};\n```\n\nInline code: `const x = 1`', + }, +]; ---- +const ImageSkeleton = () => ; -*以上展示了所有支持的Markdown格式* -`; +const LinkSkeleton = () => ( + +); -const roles: BubbleListProps['role'] = { - ai: { - placement: 'start', - }, - user: { - placement: 'end', - }, -}; +const StreamDemo: React.FC<{ content: string }> = ({ content }) => { + const [displayText, setDisplayText] = useState(''); + const [isStreaming, setIsStreaming] = useState(false); -interface MessageType { - role: 'ai' | 'user'; - content: string; -} + const startStream = React.useCallback(() => { + setDisplayText(''); + setIsStreaming(true); + let index = 0; -const App = () => { - const [enableStreaming, setEnableStreaming] = useState(true); - const [content, setContent] = React.useState(''); - const [className] = useMarkdownTheme(); + const stream = () => { + if (index <= content.length) { + setDisplayText(content.slice(0, index)); + index++; + setTimeout(stream, 30); + } else { + setIsStreaming(false); + } + }; - let chunks = ''; - const provider = useMemo( - () => - new DefaultChatProvider({ - request: XRequest('https://api.example.com/chat', { - manual: true, - fetch: () => mockFetch(fullContent), - transformStream: new TransformStream({ - transform(chunk, controller) { - chunks += chunk; - controller.enqueue({ - content: chunks, - role: 'ai', - }); - }, - }), - }), - }), - [content], - ); + stream(); + }, [content]); - const { onRequest, messages, isRequesting } = useXChat({ - provider: provider, - }); + React.useEffect(() => { + startStream(); + }, [startStream]); return ( - <> - - - -
+ + + + Re-Render + + } > - ({ - key: id, - role: message.role, - content: message.content, - status, - contentRender: - message.role === 'user' - ? (content) => content - : (content, { status }) => ( - - ), - }))} - /> - { - onRequest({ - content: nextContent, - role: 'user', - }); - setContent(''); +
+ > + +
+
+
+ ); +}; + +const App = () => { + const [currentDemo, setCurrentDemo] = useState(0); + + return ( +
+
+ {demos.map((demo, index) => ( + + ))}
- + + +
); }; diff --git a/packages/x/docs/x-markdown/examples.en-US.md b/packages/x/docs/x-markdown/examples.en-US.md index a7222cfb7..f56446cfd 100644 --- a/packages/x/docs/x-markdown/examples.en-US.md +++ b/packages/x/docs/x-markdown/examples.en-US.md @@ -42,6 +42,7 @@ Used for rendering streaming Markdown format returned by LLMs. | hasNextChunk | Indicates whether more content chunks are expected. When false, flushes all cached content and completes rendering | `boolean` | `false` | | enableAnimation | Enables text fade-in animation for block elements (`p`, `li`, `h1`, `h2`, `h3`, `h4`) | `boolean` | `false` | | animationConfig | Configuration for text appearance animation effects | `AnimationConfig` | `{ fadeDuration: 200, easing: 'ease-in-out' }` | +| incompletePlaceholderMap | Placeholder mapping for unclosed Markdown elements, supports custom placeholder components for links and images | `{ link?: string; image?: string }` | `{ link: 'incomplete-link', image: 'incomplete-image' }` | #### AnimationConfig diff --git a/packages/x/docs/x-markdown/examples.zh-CN.md b/packages/x/docs/x-markdown/examples.zh-CN.md index 63610be0c..c506f12d6 100644 --- a/packages/x/docs/x-markdown/examples.zh-CN.md +++ b/packages/x/docs/x-markdown/examples.zh-CN.md @@ -42,6 +42,7 @@ order: 2 | hasNextChunk | 指示是否还有后续内容块,为 false 时刷新所有缓存并完成渲染 | `boolean` | `false` | | enableAnimation | 为块级元素(`p`、`li`、`h1`、`h2`、`h3`、`h4`)启用文字淡入动画 | `boolean` | `false` | | animationConfig | 文字出现动画效果的配置 | `AnimationConfig` | `{ fadeDuration: 200, opacity: 0.2 }` | +| incompletePlaceholderMap | 未闭合Markdown元素的占位符映射,支持自定义链接和图片的占位符组件 | `{ link?: string; image?: string }` | `{ link: 'incomplete-link', image: 'incomplete-image' }` | #### AnimationConfig diff --git a/packages/x/docs/x-markdown/streaming-format.en-US.md b/packages/x/docs/x-markdown/streaming-format.en-US.md new file mode 100644 index 000000000..67f7ee4d3 --- /dev/null +++ b/packages/x/docs/x-markdown/streaming-format.en-US.md @@ -0,0 +1,226 @@ +--- +title: Streaming Format Processing +order: 3 +--- + +## Overview + +The streaming format processing mechanism is designed for real-time rendering scenarios, intelligently handling incomplete Markdown syntax structures to prevent rendering anomalies caused by syntax fragments. + +## Core Issues + +### 1. Syntax Fragment Problems + +During streaming transmission, Markdown syntax may be in an incomplete state: + +```markdown +// Link in transmission Click to visit [example website](https://example // Incomplete image syntax ![product image](https://cdn.example.com/images/produc +``` + +### 2. Rendering Anomaly Risks + +Incomplete syntax structures may cause: + +- Links to fail proper navigation +- Images to fail loading +- Format markers to display directly in content + +## Feature Demo + +Streaming Format Processing + +## Configuration Guide + +### streaming Configuration Items + +| Parameter | Description | Type | Default | +| --- | --- | --- | --- | +| hasNextChunk | Whether there is subsequent data | `boolean` | `false` | +| incompleteMarkdownComponentMap | Mapping configuration for converting incomplete Markdown formats to custom loading components, used to provide custom loading components for unclosed links and images during streaming rendering | `{ link?: string; image?: string }` | `{ link: 'incomplete-link', image: 'incomplete-image' }` | + +### Usage Example + +```tsx +import { XMarkdown } from '@ant-design/x-markdown'; + +const App = () => { + return ( + + ); +}; +``` + +## Supported Syntax Types + +Streaming format processing supports completeness checks for the following Markdown syntax: + +| Syntax Type | Format Example | Processing Mechanism | +| --- | --- | --- | +| **Link** | `[text](url)` | Detects unclosed link markers like `[text](` | +| **Image** | `![alt](src)` | Detects unclosed image markers like `![alt](` | +| **Heading** | `# ## ###` etc. | Supports progressive rendering for 1-6 level headings | +| **Emphasis** | `*italic*` `**bold**` | Handles emphasis syntax with `*` and `_` | +| **Code** | `inline code` and `code blocks` | Supports backtick code block completeness checks | +| **List** | `- + *` list markers | Detects spaces after list markers | +| **Horizontal Rule** | `---` `===` | Avoids Setext heading and horizontal rule conflicts | +| **XML Tags** | `` | Handles HTML/XML tag closure states | + +## How It Works + +When `hasNextChunk=true`, the component will: + +1. **Tokenized Parsing**: Decomposes Markdown syntax into 11 token types for state management +2. **State Stack Maintenance**: Uses stack structure to track nested syntax states +3. **Smart Truncation**: Pauses rendering when syntax is incomplete to avoid displaying fragments +4. **Progressive Rendering**: Gradually completes syntax rendering as content is supplemented +5. **Error Recovery**: Automatically falls back to safe state when syntax errors are detected + +### Token Type System + +The component internally defines the following token types to handle different Markdown syntax: + +- `Text`: Plain text +- `Link`: Link syntax `[text](url)` +- `Image`: Image syntax `![alt](src)` +- `Heading`: Heading syntax `# ## ###` +- `MaybeEmphasis`: Possible emphasis syntax +- `Emphasis`: Emphasis syntax `*text*` or `_text_` +- `Strong`: Bold syntax `**text**` or `__text__` +- `XML`: XML/HTML tags `` +- `MaybeCode`: Possible code syntax +- `Code`: Code syntax `` `code` `` or `code block` +- `MaybeHr`: Possible horizontal rule +- `MaybeList`: Possible list syntax + +## Advanced Configuration + +### Custom Loading Components + +You can customize the loading state display for incomplete syntax through `incompleteMarkdownComponentMap`: + +```tsx +import { XMarkdown } from '@ant-design/x-markdown'; + +const CustomLoadingComponents = { + LinkLoading: () => 🔗 Loading..., + ImageLoading: () =>
🖼️ Image loading...
, +}; + +const App = () => { + return ( + + ); +}; +``` + +### State Reset Mechanism + +When the input content changes fundamentally (non-incremental update), the component will automatically reset the parsing state: + +```tsx +// Old content: "Hello " +// New content: "Hi there!" - triggers state reset +// New content: "Hello world!" - continues incremental parsing +``` + +## hasNextChunk Best Practices + +### Avoid Getting Stuck + +`hasNextChunk` should not always be `true`, otherwise it will cause: + +1. **Syntax Suspension**: Unclosed links, images and other syntax will remain in loading state +2. **Poor User Experience**: Users see continuous loading animations without getting complete content +3. **Memory Leaks**: State data accumulates continuously and cannot be properly cleaned up + +### Correct Usage Example + +```tsx +import { useState, useEffect } from 'react'; +import { XMarkdown } from '@ant-design/x-markdown'; + +const StreamingExample = () => { + const [content, setContent] = useState(''); + const [hasNextChunk, setHasNextChunk] = useState(true); + + useEffect(() => { + // Simulate streaming data + const chunks = [ + '# Welcome to Streaming Rendering', + '\n\nThis is a demonstration', + ' showing how to handle', + '[incomplete links](https://example', + '.com) and images', + '![example image](https://picsum.photos/200)', + '\n\nContent completed!', + ]; + + let currentIndex = 0; + const interval = setInterval(() => { + if (currentIndex < chunks.length) { + setContent((prev) => prev + chunks[currentIndex]); + currentIndex++; + + // Set to false on last chunk + if (currentIndex === chunks.length) { + setHasNextChunk(false); + } + } else { + clearInterval(interval); + } + }, 500); + + return () => clearInterval(interval); + }, []); + + return ( + + ); +}; +``` + +## Notes + +- This feature only affects rendering timing and will not change the final content +- Recommended for scenarios with obvious network delays +- For static content, keep `hasNextChunk=false` for optimal performance +- Custom loading components need to be used with the `components` prop +- State reset mechanism is based on content prefix matching to ensure correct incremental updates + +## Performance Optimization + +- **Incremental Parsing**: Only processes newly added content fragments, avoiding repeated parsing +- **State Caching**: Maintains parsing state to reduce repeated calculations +- **Memory Management**: Automatically cleans up processed state data +- **Error Boundaries**: Prevents parsing errors from affecting overall rendering diff --git a/packages/x/docs/x-markdown/streaming-format.zh-CN.md b/packages/x/docs/x-markdown/streaming-format.zh-CN.md new file mode 100644 index 000000000..32cf1f4d4 --- /dev/null +++ b/packages/x/docs/x-markdown/streaming-format.zh-CN.md @@ -0,0 +1,201 @@ +--- +title: 流式语法处理 +order: 3 +--- + +## 概述 + +流式语法处理机制专为实时渲染场景设计,能够智能处理不完整的Markdown语法结构,避免因语法片段导致的渲染异常。 + +## 核心问题 + +### 1. 语法片段问题 + +在流式传输过程中,Markdown语法可能处于不完整状态: + +```markdown +// 传输中的链接点击访问[示例网站](https://example // 不完整的图片语法 ![产品图](https://cdn.example.com/images/produc +``` + +### 2. 渲染异常风险 + +不完整的语法结构可能导致: + +- 链接无法正确跳转 +- 图片加载失败 +- 格式标记直接显示在内容中 + +## 功能演示 + +流式语法处理 + +## 配置指南 + +### streaming 配置项 + +| 参数 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| hasNextChunk | 是否还有后续数据 | `boolean` | `false` | +| incompleteMarkdownComponentMap | 未完成的 Markdown 格式转换为自定义加载组件的映射配置,用于在流式渲染过程中为未闭合的链接和图片提供自定义 loading 组件 | `{ link?: string; image?: string }` | `{ link: 'incomplete-link', image: 'incomplete-image' }` | + +### 使用示例 + +```tsx +import { XMarkdown } from '@ant-design/x-markdown'; + +const App = () => { + return ( + + ); +}; +``` + +## 支持的语法类型 + +流式语法处理支持以下Markdown语法的完整性检查: + +| 语法类型 | 格式示例 | 处理机制 | +| ----------- | ---------------------- | ---------------------------------- | +| **链接** | `[text](url)` | 检测未闭合的链接标记,如 `[text](` | +| **图片** | `![alt](src)` | 检测未闭合的图片标记,如 `![alt](` | +| **标题** | `# ## ###` 等 | 支持1-6级标题的渐进式渲染 | +| **强调** | `*斜体*` `**粗体**` | 处理 `*` 和 `_` 的强调语法 | +| **代码** | `行内代码` 和 `代码块` | 支持反引号代码块的完整性检查 | +| **列表** | `- + *` 列表标记 | 检测列表标记后的空格 | +| **分隔线** | `---` `===` | 避免Setext标题与分隔线冲突 | +| **XML标签** | `` | 处理HTML/XML标签的闭合状态 | + +## 工作原理 + +当 `hasNextChunk=true` 时,组件会: + +1. **Token化解析**:将Markdown语法分解为11种Token类型进行状态管理 +2. **状态栈维护**:使用栈结构追踪嵌套的语法状态 +3. **智能截断**:在语法不完整时暂停渲染,避免显示片段 +4. **渐进渲染**:随着内容补充,逐步完成语法渲染 +5. **错误恢复**:当检测到语法错误时自动回退到安全状态 + +## 高级配置 + +### 自定义加载组件 + +通过 `incompleteMarkdownComponentMap` 可以自定义未完整语法的加载状态显示: + +```tsx +import { XMarkdown } from '@ant-design/x-markdown'; + +const CustomLoadingComponents = { + LinkLoading: () => 🔗 加载中..., + ImageLoading: () =>
🖼️ 图片加载中...
, +}; + +const App = () => { + return ( + + ); +}; +``` + +### 状态重置机制 + +当输入内容发生根本性变化时(非增量更新),组件会自动重置解析状态: + +```tsx +// 旧内容:"Hello " +// 新内容:"Hi there!" - 会触发状态重置 +// 新内容:"Hello world!" - 会继续增量解析 +``` + +## hasNextChunk 使用最佳实践 + +### 避免一直卡住 + +`hasNextChunk` 不应该始终为 `true`,否则会导致以下问题: + +1. **语法悬而未决**:未闭合的链接、图片等语法会一直保持加载状态 +2. **用户体验差**:用户看到持续的加载动画,无法获得完整内容 +3. **内存泄漏**:状态数据持续累积,无法正确清理 + +### 正确用法示例 + +```tsx +import { useState, useEffect } from 'react'; +import { XMarkdown } from '@ant-design/x-markdown'; + +const StreamingExample = () => { + const [content, setContent] = useState(''); + const [hasNextChunk, setHasNextChunk] = useState(true); + + useEffect(() => { + // 模拟流式数据 + const chunks = [ + '# 欢迎使用流式渲染', + '\n\n这是一个演示', + ',展示了如何处理', + '[不完整链接](https://example', + '.com)和图片', + '![示例图片](https://picsum.photos/200)', + '\n\n内容已完成!', + ]; + + let currentIndex = 0; + const interval = setInterval(() => { + if (currentIndex < chunks.length) { + setContent((prev) => prev + chunks[currentIndex]); + currentIndex++; + + // 最后一组数据时设置为 false + if (currentIndex === chunks.length) { + setHasNextChunk(false); + } + } else { + clearInterval(interval); + } + }, 500); + + return () => clearInterval(interval); + }, []); + + return ( + + ); +}; +``` + +## 性能优化 + +- **增量解析**:只处理新增的内容片段,避免重复解析 +- **状态缓存**:维护解析状态,减少重复计算 +- **内存管理**:自动清理已处理的状态数据 +- **错误边界**:防止解析错误影响整体渲染 diff --git a/packages/x/docs/x-markdown/streaming.en-US.md b/packages/x/docs/x-markdown/streaming.en-US.md deleted file mode 100644 index 3c650c72b..000000000 --- a/packages/x/docs/x-markdown/streaming.en-US.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -title: Streaming Rendering -order: 3 ---- - -## Introduction - -Optimize streaming Markdown rendering in LLM scenarios by caching to hide markdown formatting and animation effects. - -## Code Demos - - - - -CachingAnimation Effects - -## Configuration - -### streaming - -| Parameter | Description | Type | Default | -| --- | --- | --- | --- | -| hasNextChunk | Whether there is more streaming data | `boolean` | `false` | -| enableAnimation | Enable text fade-in animation | `boolean` | `false` | -| animationConfig | Text animation configuration | `AnimationConfig` | `{ fadeDuration: 200, opacity: 0.2 }` | - -#### AnimationConfig - -| Property | Description | Type | Default | -| ------------ | ----------------------------------------- | -------- | ------- | -| fadeDuration | Fade animation duration in milliseconds | `number` | `200` | -| opacity | Opacity value for fading characters (0-1) | `number` | `0.2` | - -### Usage Example - -```tsx -import { XMarkdown } from '@ant-design/x-markdown'; - -const App = () => { - return ( - - ); -}; -``` From 98ad2a07f8bf553fc9f9f3b05d927d4a1ce80331 Mon Sep 17 00:00:00 2001 From: div627 Date: Mon, 29 Sep 2025 13:24:55 +0800 Subject: [PATCH 02/11] chore: change x-markdown docs --- packages/x/docs/x-markdown/animation.en-US.md | 62 ----- packages/x/docs/x-markdown/animation.zh-CN.md | 62 ----- .../x-markdown/components-custom.en-US.md | 2 +- .../x-markdown/components-custom.zh-CN.md | 2 +- .../docs/x-markdown/components-data.en-US.md | 2 +- .../docs/x-markdown/components-data.zh-CN.md | 2 +- .../docs/x-markdown/components-think.en-US.md | 2 +- .../docs/x-markdown/components-think.zh-CN.md | 2 +- .../x/docs/x-markdown/components.en-US.md | 2 +- .../x/docs/x-markdown/components.zh-CN.md | 2 +- packages/x/docs/x-markdown/plugins.en-US.md | 1 + packages/x/docs/x-markdown/plugins.zh-CN.md | 1 + .../x-markdown/streaming-animation.en-US.md | 261 ++++++++++++++++++ .../x-markdown/streaming-animation.zh-CN.md | 260 +++++++++++++++++ ...mat.en-US.md => streaming-syntax.en-US.md} | 80 ++---- ...mat.zh-CN.md => streaming-syntax.zh-CN.md} | 15 +- packages/x/docs/x-markdown/themes.en-US.md | 30 +- packages/x/docs/x-markdown/themes.zh-CN.md | 2 +- 18 files changed, 582 insertions(+), 208 deletions(-) delete mode 100644 packages/x/docs/x-markdown/animation.en-US.md delete mode 100644 packages/x/docs/x-markdown/animation.zh-CN.md create mode 100644 packages/x/docs/x-markdown/streaming-animation.en-US.md create mode 100644 packages/x/docs/x-markdown/streaming-animation.zh-CN.md rename packages/x/docs/x-markdown/{streaming-format.en-US.md => streaming-syntax.en-US.md} (64%) rename packages/x/docs/x-markdown/{streaming-format.zh-CN.md => streaming-syntax.zh-CN.md} (96%) diff --git a/packages/x/docs/x-markdown/animation.en-US.md b/packages/x/docs/x-markdown/animation.en-US.md deleted file mode 100644 index db327489a..000000000 --- a/packages/x/docs/x-markdown/animation.en-US.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -title: Streaming Animation Effects -order: 4 ---- - -## Introduction - -Add elegant animation effects to streaming rendered content, supporting text fade-in animations to enhance user experience. - -## Code Demos - -Animation Effects - -## Configuration - -### streaming - -| Parameter | Description | Type | Default | -| --- | --- | --- | --- | -| hasNextChunk | Whether there is more streaming data | `boolean` | `false` | -| enableAnimation | Enable text fade-in animation | `boolean` | `false` | -| animationConfig | Text animation configuration | `AnimationConfig` | `{ fadeDuration: 200, easing: 'ease-in-out' }` | - -#### AnimationConfig - -| Property | Description | Type | Default | -| ------------ | --------------------------------------- | -------- | --------------- | -| fadeDuration | Fade animation duration in milliseconds | `number` | `200` | -| easing | Animation easing function | `string` | `'ease-in-out'` | - -### Usage Example - -```tsx -import { XMarkdown } from '@ant-design/x-markdown'; - -const App = () => { - return ( - - ); -}; -``` - -## Animation Effects Description - -Text fade-in animation provides the following features: - -- **Smooth Transition**: Text gradually appears with fade-in effect -- **Configurable Duration**: Support custom animation duration -- **Easing Functions**: Support multiple easing effects (ease-in-out, linear, ease-in, ease-out) -- **Performance Optimization**: High-performance animations using CSS3 transform and opacity - -When `enableAnimation` is set to `true`, newly received content will be displayed with fade-in animation, providing users with a smoother reading experience. diff --git a/packages/x/docs/x-markdown/animation.zh-CN.md b/packages/x/docs/x-markdown/animation.zh-CN.md deleted file mode 100644 index d9a59cf19..000000000 --- a/packages/x/docs/x-markdown/animation.zh-CN.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -title: 流式动画效果 -order: 4 ---- - -## 介绍 - -为流式渲染的内容添加优雅的动画效果,支持文字渐显动画,提升用户体验。 - -## 代码演示 - -动画效果 - -## 配置说明 - -### streaming - -| 参数 | 说明 | 类型 | 默认值 | -| --- | --- | --- | --- | -| hasNextChunk | 是否还有流式数据 | `boolean` | `false` | -| enableAnimation | 是否开启文字渐显 | `boolean` | `false` | -| animationConfig | 文字动画配置 | `AnimationConfig` | `{ fadeDuration: 200, easing: 'ease-in-out' }` | - -#### AnimationConfig - -| 属性 | 说明 | 类型 | 默认值 | -| ------------ | ------------------------ | -------- | --------------- | -| fadeDuration | 淡入动画持续时间(毫秒) | `number` | `200` | -| easing | 动画的缓动函数 | `string` | `'ease-in-out'` | - -### 使用示例 - -```tsx -import { XMarkdown } from '@ant-design/x-markdown'; - -const App = () => { - return ( - - ); -}; -``` - -## 动画效果说明 - -文字渐显动画提供了以下特性: - -- **平滑过渡**:文字以淡入的方式逐步显示 -- **可配置时长**:支持自定义动画持续时间 -- **缓动函数**:支持多种缓动效果(ease-in-out、linear、ease-in、ease-out) -- **性能优化**:使用 CSS3 transform 和 opacity 实现高性能动画 - -当 `enableAnimation` 设置为 `true` 时,新接收到的内容会以淡入动画的方式显示,为用户提供更流畅的阅读体验。 diff --git a/packages/x/docs/x-markdown/components-custom.en-US.md b/packages/x/docs/x-markdown/components-custom.en-US.md index b764fd4c1..062f7c460 100644 --- a/packages/x/docs/x-markdown/components-custom.en-US.md +++ b/packages/x/docs/x-markdown/components-custom.en-US.md @@ -1,7 +1,7 @@ --- group: title: Components - order: 2 + order: 5 title: Custom Component order: 4 --- diff --git a/packages/x/docs/x-markdown/components-custom.zh-CN.md b/packages/x/docs/x-markdown/components-custom.zh-CN.md index 9067c2cc5..88672b15e 100644 --- a/packages/x/docs/x-markdown/components-custom.zh-CN.md +++ b/packages/x/docs/x-markdown/components-custom.zh-CN.md @@ -1,7 +1,7 @@ --- group: title: 组件 - order: 2 + order: 5 title: Custom subtitle: 自定义组件 order: 4 diff --git a/packages/x/docs/x-markdown/components-data.en-US.md b/packages/x/docs/x-markdown/components-data.en-US.md index 3134d4575..17610b23d 100644 --- a/packages/x/docs/x-markdown/components-data.en-US.md +++ b/packages/x/docs/x-markdown/components-data.en-US.md @@ -1,7 +1,7 @@ --- group: title: Components - order: 2 + order: 5 title: DataChart order: 3 --- diff --git a/packages/x/docs/x-markdown/components-data.zh-CN.md b/packages/x/docs/x-markdown/components-data.zh-CN.md index dacdc88b4..12223d443 100644 --- a/packages/x/docs/x-markdown/components-data.zh-CN.md +++ b/packages/x/docs/x-markdown/components-data.zh-CN.md @@ -1,7 +1,7 @@ --- group: title: 组件 - order: 2 + order: 5 title: DataChart subtitle: 数据图表 order: 3 diff --git a/packages/x/docs/x-markdown/components-think.en-US.md b/packages/x/docs/x-markdown/components-think.en-US.md index 0f3dc4c2b..fc4a575c9 100644 --- a/packages/x/docs/x-markdown/components-think.en-US.md +++ b/packages/x/docs/x-markdown/components-think.en-US.md @@ -1,7 +1,7 @@ --- group: title: Components - order: 2 + order: 5 title: Think order: 2 --- diff --git a/packages/x/docs/x-markdown/components-think.zh-CN.md b/packages/x/docs/x-markdown/components-think.zh-CN.md index 5acd2154b..bd73d8c99 100644 --- a/packages/x/docs/x-markdown/components-think.zh-CN.md +++ b/packages/x/docs/x-markdown/components-think.zh-CN.md @@ -1,7 +1,7 @@ --- group: title: 组件 - order: 2 + order: 5 title: Think subtitle: 思考过程 order: 2 diff --git a/packages/x/docs/x-markdown/components.en-US.md b/packages/x/docs/x-markdown/components.en-US.md index 488e95d4c..d7ae0d29a 100644 --- a/packages/x/docs/x-markdown/components.en-US.md +++ b/packages/x/docs/x-markdown/components.en-US.md @@ -1,7 +1,7 @@ --- group: title: Components - order: 2 + order: 5 title: Overview order: 1 --- diff --git a/packages/x/docs/x-markdown/components.zh-CN.md b/packages/x/docs/x-markdown/components.zh-CN.md index ed71bc0a2..bd5a95cdd 100644 --- a/packages/x/docs/x-markdown/components.zh-CN.md +++ b/packages/x/docs/x-markdown/components.zh-CN.md @@ -1,7 +1,7 @@ --- group: title: 组件 - order: 2 + order: 5 title: 总览 order: 1 --- diff --git a/packages/x/docs/x-markdown/plugins.en-US.md b/packages/x/docs/x-markdown/plugins.en-US.md index caba0c6ef..be3f44f55 100644 --- a/packages/x/docs/x-markdown/plugins.en-US.md +++ b/packages/x/docs/x-markdown/plugins.en-US.md @@ -1,6 +1,7 @@ --- group: title: Plugins + order: 6 title: Overview order: 1 --- diff --git a/packages/x/docs/x-markdown/plugins.zh-CN.md b/packages/x/docs/x-markdown/plugins.zh-CN.md index f5772231e..22f380b1c 100644 --- a/packages/x/docs/x-markdown/plugins.zh-CN.md +++ b/packages/x/docs/x-markdown/plugins.zh-CN.md @@ -1,6 +1,7 @@ --- group: title: 插件集 + order: 6 title: 总览 order: 1 --- diff --git a/packages/x/docs/x-markdown/streaming-animation.en-US.md b/packages/x/docs/x-markdown/streaming-animation.en-US.md new file mode 100644 index 000000000..9a66891fc --- /dev/null +++ b/packages/x/docs/x-markdown/streaming-animation.en-US.md @@ -0,0 +1,261 @@ +--- +group: + title: Streaming Processing + order: 4 +title: Animation Effects +order: 2 +--- + +Add elegant animation effects to streaming rendered content, supporting progressive text display to enhance user reading experience. + +## Feature Introduction + +Streaming animation effects are designed for real-time content rendering, using smooth transition animations to make content presentation more natural and avoid visual discomfort from abrupt content updates. + +## Feature Demo + +Streaming Animation Effects Typing Effect Demonstration + +## Configuration Parameters + +### streaming Configuration + +| Parameter | Description | Type | Default | +| --- | --- | --- | --- | +| hasNextChunk | Whether there is more streaming data | `boolean` | `false` | +| enableAnimation | Enable text fade-in animation | `boolean` | `false` | +| animationConfig | Text animation configuration | `AnimationConfig` | `{ fadeDuration: 200, easing: 'ease-in-out' }` | + +#### AnimationConfig + +| Property | Description | Type | Default | +| ------------ | --------------------------------------- | -------- | --------------- | +| fadeDuration | Fade animation duration in milliseconds | `number` | `200` | +| easing | Animation easing function | `string` | `'ease-in-out'` | + +## Usage Examples + +### Basic Animation Configuration + +```tsx +import { XMarkdown } from '@ant-design/x-markdown'; + +const App = () => { + return ( + + ); +}; +``` + +### Custom Animation Parameters + +```tsx +import { XMarkdown } from '@ant-design/x-markdown'; + +const CustomAnimationExample = () => { + return ( + + ); +}; +``` + +## Animation Features + +### Core Features + +- **Smooth Transition**: Text content gradually appears with fade-in effect, avoiding abruptness +- **Configurable Duration**: Supports custom animation duration, range 100-1000ms +- **Easing Functions**: Supports multiple easing effects: + - `ease-in-out`: Smooth acceleration and deceleration (default) + - `linear`: Constant speed animation + - `ease-in`: Ease-in effect + - `ease-out`: Ease-out effect +- **Performance Optimization**: High-performance animations using CSS3 transform and opacity, avoiding reflow and repaint + +### Animation Trigger Mechanism + +Animation effects trigger under the following conditions: + +1. **Syntax Completeness**: Animation only triggers after Markdown syntax is fully parsed +2. **Incremental Updates**: Animation effects only apply to newly added content +3. **State Control**: Trigger timing controlled by `hasNextChunk` + +## Advanced Usage + +### Combined with Syntax Processing + +```tsx +import { useState, useEffect } from 'react'; +import { XMarkdown } from '@ant-design/x-markdown'; + +const CombinedStreamingExample = () => { + const [content, setContent] = useState(''); + const [hasNextChunk, setHasNextChunk] = useState(true); + + useEffect(() => { + const chunks = [ + '# Streaming Rendering and Animation', + '\n\nThis combines', + '**syntax processing** and', + '*animation effects* in', + 'a complete example.', + ]; + + let index = 0; + const timer = setInterval(() => { + if (index < chunks.length) { + setContent((prev) => prev + chunks[index]); + index++; + + if (index === chunks.length) { + setHasNextChunk(false); + } + } else { + clearInterval(timer); + } + }, 800); + + return () => clearInterval(timer); + }, []); + + return ( + + ); +}; +``` + +### Typewriter Effect Implementation + +Combined with animation effects, you can achieve a typewriter effect: + +```tsx +import { useState, useEffect } from 'react'; +import { XMarkdown } from '@ant-design/x-markdown'; + +const TypewriterEffect = () => { + const [content, setContent] = useState(''); + const [hasNextChunk, setHasNextChunk] = useState(true); + const fullText = + '# Typewriter Effect\n\nThis is an example simulating typewriter effect, with each character gradually appearing.'; + + useEffect(() => { + let index = 0; + const timer = setInterval(() => { + if (index <= fullText.length) { + setContent(fullText.slice(0, index)); + index++; + + if (index > fullText.length) { + setHasNextChunk(false); + } + } else { + clearInterval(timer); + } + }, 50); // 50ms per character + + return () => clearInterval(timer); + }, []); + + return ( + + ); +}; +``` + +## Performance Optimization + +### Animation Performance + +- **Hardware Acceleration**: Uses CSS3 transform property to trigger hardware acceleration +- **Throttling Control**: Avoids excessive animation trigger frequency +- **Memory Management**: Timely cleanup of animation-related DOM references + +### Best Practices + +1. **Animation Duration Selection**: + - Fast content: 100-200ms + - Normal content: 200-400ms + - Slow content: 400-600ms + +2. **Easing Function Selection**: + - Content display: `ease-in-out` + - Emphasis effects: `ease-out` + - Mechanical effects: `linear` + +3. **Avoid Excessive Animation**: + - Appropriately extend animation intervals for large text content + - Avoid triggering too many animated elements simultaneously + +## Common Questions + +### Q: Animation effects not working? + +A: Please check the following conditions: + +- Whether `enableAnimation` is set to `true` +- Whether `hasNextChunk` is correctly controlled +- Whether browser supports CSS3 animations + +### Q: Performance issues with animation? + +A: Recommended optimizations: + +- Reduce `fadeDuration` time +- Use `linear` easing function +- Batch render large amounts of content + +### Q: How to disable animation for specific elements? + +A: Can be controlled through custom components: + +```tsx +const NoAnimationComponent = ({ children }) => { + return
{children}
; +}; + +; +``` diff --git a/packages/x/docs/x-markdown/streaming-animation.zh-CN.md b/packages/x/docs/x-markdown/streaming-animation.zh-CN.md new file mode 100644 index 000000000..ae5821afe --- /dev/null +++ b/packages/x/docs/x-markdown/streaming-animation.zh-CN.md @@ -0,0 +1,260 @@ +--- +group: + title: 流式处理 + order: 4 +title: 动画效果 +order: 2 +--- + +为流式渲染的内容添加优雅的动画效果,支持文本的渐进式显示,提升用户阅读体验。 + +## 功能介绍 + +流式动画效果专为实时内容渲染设计,通过平滑的过渡动画让内容呈现更加自然,避免突兀的内容更新带来的视觉不适。 + +## 功能演示 + +流式动画效果 打字机效果演示 + +## 配置参数 + +### streaming 配置项 + +| 参数 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| hasNextChunk | 是否还有后续数据 | `boolean` | `false` | +| enableAnimation | 启用文本淡入动画 | `boolean` | `false` | +| animationConfig | 文本动画配置 | `AnimationConfig` | `{ fadeDuration: 200, easing: 'ease-in-out' }` | + +#### AnimationConfig + +| 属性 | 说明 | 类型 | 默认值 | +| ------------ | ------------------------ | -------- | --------------- | +| fadeDuration | 淡入动画持续时间(毫秒) | `number` | `200` | +| easing | 动画缓动函数 | `string` | `'ease-in-out'` | + +## 使用示例 + +### 基础动画配置 + +```tsx +import { XMarkdown } from '@ant-design/x-markdown'; + +const App = () => { + return ( + + ); +}; +``` + +### 自定义动画参数 + +```tsx +import { XMarkdown } from '@ant-design/x-markdown'; + +const CustomAnimationExample = () => { + return ( + + ); +}; +``` + +## 动画效果特性 + +### 核心特性 + +- **平滑过渡**:文本内容以淡入效果逐步显示,避免突兀感 +- **可配置时长**:支持自定义动画持续时间,范围 100-1000ms +- **缓动函数**:支持多种缓动效果: + - `ease-in-out`:平滑的加速减速(默认) + - `linear`:匀速动画 + - `ease-in`:缓入效果 + - `ease-out`:缓出效果 +- **性能优化**:使用 CSS3 transform 和 opacity 实现高性能动画,避免重排重绘 + +### 动画触发机制 + +动画效果在以下条件下触发: + +1. **语法完整性**:仅当Markdown语法完整解析后触发动画 +2. **增量更新**:仅对新添加的内容应用动画效果 +3. **状态控制**:通过 `hasNextChunk` 控制动画的触发时机 + +## 高级用法 + +### 与语法处理配合使用 + +```tsx +import { useState, useEffect } from 'react'; +import { XMarkdown } from '@ant-design/x-markdown'; + +const CombinedStreamingExample = () => { + const [content, setContent] = useState(''); + const [hasNextChunk, setHasNextChunk] = useState(true); + + useEffect(() => { + const chunks = [ + '# 流式渲染与动画', + '\n\n这是结合了', + '**语法处理**和', + '*动画效果*的', + '完整示例。', + ]; + + let index = 0; + const timer = setInterval(() => { + if (index < chunks.length) { + setContent((prev) => prev + chunks[index]); + index++; + + if (index === chunks.length) { + setHasNextChunk(false); + } + } else { + clearInterval(timer); + } + }, 800); + + return () => clearInterval(timer); + }, []); + + return ( + + ); +}; +``` + +### 打字机效果实现 + +结合动画效果可以实现打字机效果: + +```tsx +import { useState, useEffect } from 'react'; +import { XMarkdown } from '@ant-design/x-markdown'; + +const TypewriterEffect = () => { + const [content, setContent] = useState(''); + const [hasNextChunk, setHasNextChunk] = useState(true); + const fullText = '# 打字机效果\n\n这是一个模拟打字机效果的示例,每个字符都会逐步显示。'; + + useEffect(() => { + let index = 0; + const timer = setInterval(() => { + if (index <= fullText.length) { + setContent(fullText.slice(0, index)); + index++; + + if (index > fullText.length) { + setHasNextChunk(false); + } + } else { + clearInterval(timer); + } + }, 50); // 每个字符50ms + + return () => clearInterval(timer); + }, []); + + return ( + + ); +}; +``` + +## 性能优化 + +### 动画性能 + +- **硬件加速**:使用 CSS3 transform 属性触发硬件加速 +- **节流控制**:避免过快的动画触发频率 +- **内存管理**:及时清理动画相关的DOM引用 + +### 最佳实践 + +1. **动画时长选择**: + - 快速内容:100-200ms + - 正常内容:200-400ms + - 慢速内容:400-600ms + +2. **缓动函数选择**: + - 内容展示:`ease-in-out` + - 强调效果:`ease-out` + - 机械效果:`linear` + +3. **避免过度动画**: + - 大量文本内容时适当延长动画间隔 + - 避免同时触发过多动画元素 + +## 常见问题 + +### Q: 动画效果不生效? + +A: 请检查以下条件: + +- `enableAnimation` 是否设置为 `true` +- `hasNextChunk` 是否正确控制 +- 浏览器是否支持 CSS3 动画 + +### Q: 动画导致性能问题? + +A: 建议优化: + +- 减少 `fadeDuration` 时间 +- 使用 `linear` 缓动函数 +- 分批渲染大量内容 + +### Q: 如何禁用特定元素的动画? + +A: 可以通过自定义组件控制: + +```tsx +const NoAnimationComponent = ({ children }) => { + return
{children}
; +}; + +; +``` diff --git a/packages/x/docs/x-markdown/streaming-format.en-US.md b/packages/x/docs/x-markdown/streaming-syntax.en-US.md similarity index 64% rename from packages/x/docs/x-markdown/streaming-format.en-US.md rename to packages/x/docs/x-markdown/streaming-syntax.en-US.md index 67f7ee4d3..090898f73 100644 --- a/packages/x/docs/x-markdown/streaming-format.en-US.md +++ b/packages/x/docs/x-markdown/streaming-syntax.en-US.md @@ -1,41 +1,40 @@ --- -title: Streaming Format Processing -order: 3 +group: + title: Streaming Processing + order: 4 +title: Syntax Processing +order: 1 --- -## Overview - -The streaming format processing mechanism is designed for real-time rendering scenarios, intelligently handling incomplete Markdown syntax structures to prevent rendering anomalies caused by syntax fragments. +Streaming syntax processing mechanism is designed for real-time rendering scenarios, capable of intelligently handling incomplete Markdown syntax structures to avoid rendering anomalies caused by syntax fragments. ## Core Issues -### 1. Syntax Fragment Problems - During streaming transmission, Markdown syntax may be in an incomplete state: ```markdown -// Link in transmission Click to visit [example website](https://example // Incomplete image syntax ![product image](https://cdn.example.com/images/produc +// Incomplete link during transmission Click to visit [example website](https://example // Incomplete image syntax ![product image](https://cdn.example.com/images/produc ``` -### 2. Rendering Anomaly Risks +### Rendering Anomaly Risks -Incomplete syntax structures may cause: +Incomplete syntax structures may lead to: -- Links to fail proper navigation -- Images to fail loading -- Format markers to display directly in content +- Links unable to jump correctly +- Image loading failures +- Format markers displaying directly in content ## Feature Demo -Streaming Format Processing +Streaming Syntax Processing ## Configuration Guide -### streaming Configuration Items +### streaming Configuration | Parameter | Description | Type | Default | | --- | --- | --- | --- | -| hasNextChunk | Whether there is subsequent data | `boolean` | `false` | +| hasNextChunk | Whether there is more streaming data | `boolean` | `false` | | incompleteMarkdownComponentMap | Mapping configuration for converting incomplete Markdown formats to custom loading components, used to provide custom loading components for unclosed links and images during streaming rendering | `{ link?: string; image?: string }` | `{ link: 'incomplete-link', image: 'incomplete-image' }` | ### Usage Example @@ -61,17 +60,17 @@ const App = () => { ## Supported Syntax Types -Streaming format processing supports completeness checks for the following Markdown syntax: +Streaming syntax processing supports integrity checks for the following Markdown syntax: | Syntax Type | Format Example | Processing Mechanism | | --- | --- | --- | -| **Link** | `[text](url)` | Detects unclosed link markers like `[text](` | -| **Image** | `![alt](src)` | Detects unclosed image markers like `![alt](` | -| **Heading** | `# ## ###` etc. | Supports progressive rendering for 1-6 level headings | +| **Links** | `[text](url)` | Detects unclosed link markers like `[text](` | +| **Images** | `![alt](src)` | Detects unclosed image markers like `![alt](` | +| **Headings** | `# ## ###` etc. | Supports progressive rendering for 1-6 level headings | | **Emphasis** | `*italic*` `**bold**` | Handles emphasis syntax with `*` and `_` | -| **Code** | `inline code` and `code blocks` | Supports backtick code block completeness checks | -| **List** | `- + *` list markers | Detects spaces after list markers | -| **Horizontal Rule** | `---` `===` | Avoids Setext heading and horizontal rule conflicts | +| **Code** | `inline code` and `code blocks` | Supports backtick code block integrity checks | +| **Lists** | `- + *` list markers | Detects spaces after list markers | +| **Dividers** | `---` `===` | Avoids conflicts between Setext headings and dividers | | **XML Tags** | `` | Handles HTML/XML tag closure states | ## How It Works @@ -84,23 +83,6 @@ When `hasNextChunk=true`, the component will: 4. **Progressive Rendering**: Gradually completes syntax rendering as content is supplemented 5. **Error Recovery**: Automatically falls back to safe state when syntax errors are detected -### Token Type System - -The component internally defines the following token types to handle different Markdown syntax: - -- `Text`: Plain text -- `Link`: Link syntax `[text](url)` -- `Image`: Image syntax `![alt](src)` -- `Heading`: Heading syntax `# ## ###` -- `MaybeEmphasis`: Possible emphasis syntax -- `Emphasis`: Emphasis syntax `*text*` or `_text_` -- `Strong`: Bold syntax `**text**` or `__text__` -- `XML`: XML/HTML tags `` -- `MaybeCode`: Possible code syntax -- `Code`: Code syntax `` `code` `` or `code block` -- `MaybeHr`: Possible horizontal rule -- `MaybeList`: Possible list syntax - ## Advanced Configuration ### Custom Loading Components @@ -137,7 +119,7 @@ const App = () => { ### State Reset Mechanism -When the input content changes fundamentally (non-incremental update), the component will automatically reset the parsing state: +When input content changes fundamentally (non-incremental update), the component automatically resets the parsing state: ```tsx // Old content: "Hello " @@ -151,9 +133,9 @@ When the input content changes fundamentally (non-incremental update), the compo `hasNextChunk` should not always be `true`, otherwise it will cause: -1. **Syntax Suspension**: Unclosed links, images and other syntax will remain in loading state -2. **Poor User Experience**: Users see continuous loading animations without getting complete content -3. **Memory Leaks**: State data accumulates continuously and cannot be properly cleaned up +1. **Syntax Hanging**: Unclosed links, images and other syntax will remain in loading state +2. **Poor User Experience**: Users see continuous loading animations +3. **Memory Leaks**: State data accumulates continuously and cannot be cleaned properly ### Correct Usage Example @@ -173,7 +155,7 @@ const StreamingExample = () => { ' showing how to handle', '[incomplete links](https://example', '.com) and images', - '![example image](https://picsum.photos/200)', + '![Example Image](https://picsum.photos/200)', '\n\nContent completed!', ]; @@ -210,14 +192,6 @@ const StreamingExample = () => { }; ``` -## Notes - -- This feature only affects rendering timing and will not change the final content -- Recommended for scenarios with obvious network delays -- For static content, keep `hasNextChunk=false` for optimal performance -- Custom loading components need to be used with the `components` prop -- State reset mechanism is based on content prefix matching to ensure correct incremental updates - ## Performance Optimization - **Incremental Parsing**: Only processes newly added content fragments, avoiding repeated parsing diff --git a/packages/x/docs/x-markdown/streaming-format.zh-CN.md b/packages/x/docs/x-markdown/streaming-syntax.zh-CN.md similarity index 96% rename from packages/x/docs/x-markdown/streaming-format.zh-CN.md rename to packages/x/docs/x-markdown/streaming-syntax.zh-CN.md index 32cf1f4d4..302081d0b 100644 --- a/packages/x/docs/x-markdown/streaming-format.zh-CN.md +++ b/packages/x/docs/x-markdown/streaming-syntax.zh-CN.md @@ -1,23 +1,22 @@ --- -title: 流式语法处理 -order: 3 +group: + title: 流式处理 + order: 4 +title: 语法处理 +order: 1 --- -## 概述 - 流式语法处理机制专为实时渲染场景设计,能够智能处理不完整的Markdown语法结构,避免因语法片段导致的渲染异常。 ## 核心问题 -### 1. 语法片段问题 - 在流式传输过程中,Markdown语法可能处于不完整状态: ```markdown -// 传输中的链接点击访问[示例网站](https://example // 不完整的图片语法 ![产品图](https://cdn.example.com/images/produc +// 不完整的链接语法 [示例网站](https://example // 不完整的图片语法 ![产品图](https://cdn.example.com/images/produc ``` -### 2. 渲染异常风险 +### 渲染异常风险 不完整的语法结构可能导致: diff --git a/packages/x/docs/x-markdown/themes.en-US.md b/packages/x/docs/x-markdown/themes.en-US.md index 48185dd37..52d2920aa 100644 --- a/packages/x/docs/x-markdown/themes.en-US.md +++ b/packages/x/docs/x-markdown/themes.en-US.md @@ -1,6 +1,6 @@ --- title: Themes -order: 5 +order: 3 --- ## How to Import Themes @@ -36,6 +36,7 @@ We welcome community contributions for new themes! Please follow these specifica ### Theme Naming Conventions Theme files should follow these naming rules: + - File name: `theme-name.css` - Class name prefix: `x-markdown-theme-name` - Example: `x-markdown-ocean.css` corresponds to class name `x-markdown-ocean` @@ -43,6 +44,7 @@ Theme files should follow these naming rules: ### Theme Development Specifications #### 1. File Structure + Place your theme files in the `packages/x-markdown/src/themes/` directory: ``` @@ -64,14 +66,14 @@ Theme styles must follow these naming conventions: --x-markdown-color-text: #333; --x-markdown-color-bg: #fff; --x-markdown-color-border: #e8e8e8; - + /* Code block styles */ --x-markdown-color-code-bg: #f5f5f5; --x-markdown-color-code-text: #333; - + /* Heading styles */ --x-markdown-color-heading: #262626; - + /* Link styles */ --x-markdown-color-link: #1890ff; --x-markdown-color-link-hover: #40a9ff; @@ -97,15 +99,14 @@ Theme styles must follow these naming conventions: #### 3. Development Steps 1. **Create Theme File**: + ```bash touch packages/x-markdown/src/themes/your-theme-name.css ``` -2. **Define Base Variables**: - Use CSS variables to define colors, spacing, and other base styles. +2. **Define Base Variables**: Use CSS variables to define colors, spacing, and other base styles. -3. **Implement Component Styles**: - Implement styles for various Markdown elements: +3. **Implement Component Styles**: Implement styles for various Markdown elements: - Headings (h1-h6) - Code blocks - Tables @@ -114,12 +115,12 @@ Theme styles must follow these naming conventions: - Links - Images -4. **Test Theme**: - Test your theme in the demo: +4. **Test Theme**: Test your theme in the demo: + ```tsx import '@ant-design/x-markdown/themes/your-theme-name.css'; - - + + ; ``` #### 4. Submission Guidelines @@ -154,11 +155,11 @@ Here is a complete theme example: --x-markdown-color-border: #e2e8f0; --x-markdown-color-primary: #0ea5e9; --x-markdown-color-secondary: #64748b; - + /* Code blocks */ --x-markdown-color-code-bg: #f1f5f9; --x-markdown-color-code-border: #cbd5e1; - + /* Headings */ --x-markdown-color-h1: #0f172a; --x-markdown-color-h2: #1e293b; @@ -180,6 +181,7 @@ Here is a complete theme example: ### Community Themes We welcome community contributions for various theme styles, such as: + - Tech-style themes - Minimalist themes - Retro-style themes diff --git a/packages/x/docs/x-markdown/themes.zh-CN.md b/packages/x/docs/x-markdown/themes.zh-CN.md index f9e6d528d..f0abf7752 100644 --- a/packages/x/docs/x-markdown/themes.zh-CN.md +++ b/packages/x/docs/x-markdown/themes.zh-CN.md @@ -1,6 +1,6 @@ --- title: 主题 -order: 5 +order: 3 --- ## 如何引入主题 From bc525f4902a950e7d53766e4879d5477eb043622 Mon Sep 17 00:00:00 2001 From: div627 Date: Mon, 29 Sep 2025 15:02:57 +0800 Subject: [PATCH 03/11] =?UTF-8?q?feat:=20=E7=A7=BB=E9=99=A4=E5=AF=B9=20emp?= =?UTF-8?q?hasis=20=E8=AF=AD=E6=B3=95=E7=9A=84=E7=BC=93=E5=AD=98=EF=BC=8C?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E8=AF=AF=E8=AF=86=E5=88=AB=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=E7=9A=84=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/XMarkdown/__test__/hooks.test.tsx | 56 --------- .../src/XMarkdown/hooks/useStreaming.ts | 77 ------------- .../x-markdown/demo/streaming/combined.tsx | 109 ++++++++++++++++++ .../docs/x-markdown/demo/streaming/format.tsx | 2 +- .../docs/x-markdown/demo/streaming/typing.tsx | 86 -------------- packages/x/docs/x-markdown/examples.zh-CN.md | 2 +- .../x-markdown/streaming-animation.en-US.md | 2 +- .../x-markdown/streaming-animation.zh-CN.md | 2 +- 8 files changed, 113 insertions(+), 223 deletions(-) create mode 100644 packages/x/docs/x-markdown/demo/streaming/combined.tsx delete mode 100644 packages/x/docs/x-markdown/demo/streaming/typing.tsx diff --git a/packages/x-markdown/src/XMarkdown/__test__/hooks.test.tsx b/packages/x-markdown/src/XMarkdown/__test__/hooks.test.tsx index 39493fbaf..569c6831b 100644 --- a/packages/x-markdown/src/XMarkdown/__test__/hooks.test.tsx +++ b/packages/x-markdown/src/XMarkdown/__test__/hooks.test.tsx @@ -69,62 +69,6 @@ const testCases = [ input: '#######', output: '#######', }, - { - title: 'incomplete emphasis', - input: '*emphasis', - output: '', - }, - { - title: 'incomplete emphasis with \n', - input: '*emphasis\n', - output: '*emphasis\n', - }, - { - title: 'complete emphasis', - input: '*emphasis*', - output: '*emphasis*', - }, - { - title: 'incomplete strong', - input: '**strong', - output: '', - }, - { - title: 'complete strong', - input: '**strong**', - output: '**strong**', - }, - { - title: 'incomplete strong with \n', - input: '**strong\n', - output: '**strong\n', - }, - { - title: 'incomplete strong emphasis', - input: '***strong emph**', - output: '', - }, - { - title: 'incomplete strong emphasis and hasNext is false', - input: '***strong emph**', - output: '***strong emph**', - config: { hasNextChunk: false }, - }, - { - title: 'complete strong emphasis', - input: '***strong emph***', - output: '***strong emph***', - }, - { - title: '* is hr', - input: '***\n', - output: '***\n', - }, - { - title: 'more than 3 ***', - input: '****Test', - output: '', - }, { title: 'incomplete Html', input: ' { break; } - case TokenType.MaybeEmphasis: { - /** - * /* / *\/n - ^ ^ - */ - const shouldFlushOutput = char === ' ' || char === '\n'; - - if (shouldFlushOutput) { - flushOutput(); - } else if (char === '*' || char === '_') { - buffer.emphasisCount++; - } else { - popToken(); - - switch (emphasisCount) { - case 1: - /** - * _token_ / *token* - * ^ ^ - */ - pushToken(TokenType.Emphasis); - break; - case 2: - /** - * __token__ / **token** - * ^ ^ - */ - pushToken(TokenType.Strong); - break; - case 3: - /** - * ___token___ / ***token*** - * ^ ^ - */ - pushToken(TokenType.Emphasis); - pushToken(TokenType.Strong); - break; - default: - buffer.emphasisCount = 0; - } - } - break; - } - - case TokenType.Strong: { - /** - * __token__ / **token** - * ^ ^ - */ - if (char === '\n') { - flushOutput(); - } else if (buffer.pending.endsWith('**') || buffer.pending.endsWith('__')) { - if (tokens[tokens.length - 2] === TokenType.Emphasis) { - popToken(); - } else { - flushOutput(); - } - } - break; - } - - case TokenType.Emphasis: { - /** - * _token_ / *token* - * ^ ^ - */ - if (char === '\n' || char === '*' || char === '_') { - flushOutput(); - buffer.emphasisCount = 0; - } - - break; - } - case TokenType.XML: { /** * / @@ -293,9 +219,6 @@ const useStreaming = (input: string, config?: XMarkdownProps['streaming']) => { pushToken(TokenType.Link); } else if (char === '#') { pushToken(TokenType.Heading); - } else if (char === '_' || char === '*') { - pushToken(TokenType.MaybeEmphasis); - buffer.emphasisCount = 1; } else if (char === '<') { pushToken(TokenType.XML); } else if (char === '`') { diff --git a/packages/x/docs/x-markdown/demo/streaming/combined.tsx b/packages/x/docs/x-markdown/demo/streaming/combined.tsx new file mode 100644 index 000000000..bd437d7f9 --- /dev/null +++ b/packages/x/docs/x-markdown/demo/streaming/combined.tsx @@ -0,0 +1,109 @@ +import { Bubble } from '@ant-design/x'; +import XMarkdown from '@ant-design/x-markdown'; +import { Button, Flex, Skeleton, Space, Switch, Typography } from 'antd'; +import React, { useState } from 'react'; +import { useMarkdownTheme } from '../_utils'; +import '@ant-design/x-markdown/themes/light.css'; +import '@ant-design/x-markdown/themes/dark.css'; + +const { Text } = Typography; + +// 简化的示例文本 +const text = `# Ant Design X + +Ant Design X 是一款AI应用复合工具集,融合了 UI 组件库、流式 Markdown 渲染引擎和 AI SDK,为开发者提供构建下一代 AI 驱动应用的完整工具链。 + +![Ant Design X](https://mdn.alipayobjects.com/huamei_yz9z7c/afts/img/0lMhRYbo0-8AAAAAQDAAAAgADlJoAQFr/original) + + +基于 Ant Design 设计体系的 React UI 库、专为 AI 驱动界面设计,开箱即用的智能对话组件、无缝集成 API 服务,快速搭建智能应用界面,查看详情请点击 [Ant Design X](https://github.com/ant-design/x)。 +`; + +// 自定义加载组件 +const LoadingComponents = { + 'loading-link': () => ( + + ), + 'loading-image': () => , +}; + +const App: React.FC = () => { + const [enableAnimation, setEnableAnimation] = useState(true); + const [enableCache, setEnableCache] = useState(true); + const [isStreaming, setIsStreaming] = useState(false); + const [index, setIndex] = useState(0); + const [className] = useMarkdownTheme(); + const timer = React.useRef(-1); + + const renderStream = () => { + if (index >= text.length) { + clearTimeout(timer.current); + setIsStreaming(false); + return; + } + timer.current = setTimeout(() => { + setIndex((prev) => prev + 1); + renderStream(); + }, 50); + }; + + React.useEffect(() => { + if (index === text.length) return; + renderStream(); + setIsStreaming(true); + return () => { + clearTimeout(timer.current); + }; + }, [index]); + + return ( +
+ + + + 动画 + + + + 语法处理 + + + + + + ( + + )} + /> + +
+ ); +}; + +export default App; diff --git a/packages/x/docs/x-markdown/demo/streaming/format.tsx b/packages/x/docs/x-markdown/demo/streaming/format.tsx index 8134adc87..7d0d664c6 100644 --- a/packages/x/docs/x-markdown/demo/streaming/format.tsx +++ b/packages/x/docs/x-markdown/demo/streaming/format.tsx @@ -103,7 +103,7 @@ const StreamDemo: React.FC<{ content: string }> = ({ content }) => { diff --git a/packages/x/docs/x-markdown/demo/streaming/typing.tsx b/packages/x/docs/x-markdown/demo/streaming/typing.tsx deleted file mode 100644 index 05f93bf14..000000000 --- a/packages/x/docs/x-markdown/demo/streaming/typing.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import type { BubbleProps } from '@ant-design/x'; -import { Bubble } from '@ant-design/x'; -import XMarkdown from '@ant-design/x-markdown'; -import { Avatar, Flex, Slider, Space, Typography } from 'antd'; -import React from 'react'; -import { useMarkdownTheme } from '../_utils'; -import '@ant-design/x-markdown/themes/light.css'; -import '@ant-design/x-markdown/themes/dark.css'; - -const { Text } = Typography; - -const text = ` -乌镇是中国著名的江南水乡古镇,位于浙江省嘉兴市桐乡市,地处杭嘉湖平原,距离杭州约80公里。以下是关于乌镇的详细介绍: - ---- -### **1. 地理位置** -- **区域**:长三角中心地带,东临上海,南接杭州,北靠苏州,交通便利。 -- **水系**:京杭大运河支流穿镇而过,河道纵横,典型的"小桥流水人家"风貌。 ---- -### **2. 历史文化** -- **建镇历史**:距今1300多年,明清时期因丝绸业繁盛成为商业重镇。 -- **文化特色**: - - **茅盾故居**:中国现代文学巨匠茅盾的出生地,现为纪念馆。 - - **传统民俗**:保留蓝印花布制作、高杆船表演等非遗技艺。 -- **世界互联网大会**:2014年起成为永久会址,被誉为"东方达沃斯"。 ---- -### **3. 景区划分** -- **东栅**:以原住民生活和传统作坊为主,更具烟火气。 - - 必看景点:茅盾故居、江南百床馆、宏源泰染坊。 -- **西栅**:经过保护性开发,夜景绝美,商业设施完善。 - - 推荐体验:摇橹船夜游、木心美术馆、昭明书院。 ---- -### **4. 特色体验** -- **乘船游览**:木船穿梭水道,感受水乡韵味。 -- **夜宿乌镇**:西栅的民宿临水而建,清晨静谧如画。 -- **节庆活动**: - - **乌镇戏剧节**(每年10月):国内外戏剧团队齐聚。 - - **春节水灯会**:传统花灯映照水面。 ---- -### **5. 美食与特产** -- **小吃**:定胜糕、姑嫂饼、羊肉面、萝卜丝饼。 -- **三白酒**:本地米酒,醇香甘冽。 -- **手工制品**:蓝印花布、竹编工艺品。 ---- -### **6. 旅游贴士** -- **最佳时间**:春秋季(避开梅雨季);冬季游客少,别有韵味。 -- **门票**: - - 东栅110元,西栅150元,联票190元(建议分两天游玩)。 -- **交通**: - - **高铁**:至桐乡站,转公交K282直达。 - - **自驾**:杭州/上海出发约1.5-2小时。 ---- -乌镇完美融合了古典水乡风情与现代文化活力,无论是追寻历史,还是享受慢生活,都是理想之选!如果想了解具体景点或行程规划,欢迎继续提问~ 🚣‍♀️ -`; - -const RenderMarkdown: BubbleProps['contentRender'] = (content) => { - const [className] = useMarkdownTheme(); - - return {content}; -}; - -const App: React.FC = () => { - const [value, setValue] = React.useState(1); - - return ( - - - Render step - - - - - ), - }} - /> - - ); -}; - -export default App; diff --git a/packages/x/docs/x-markdown/examples.zh-CN.md b/packages/x/docs/x-markdown/examples.zh-CN.md index c506f12d6..c9364503a 100644 --- a/packages/x/docs/x-markdown/examples.zh-CN.md +++ b/packages/x/docs/x-markdown/examples.zh-CN.md @@ -11,7 +11,7 @@ order: 2 - + diff --git a/packages/x/docs/x-markdown/streaming-animation.en-US.md b/packages/x/docs/x-markdown/streaming-animation.en-US.md index 9a66891fc..6cfaee348 100644 --- a/packages/x/docs/x-markdown/streaming-animation.en-US.md +++ b/packages/x/docs/x-markdown/streaming-animation.en-US.md @@ -14,7 +14,7 @@ Streaming animation effects are designed for real-time content rendering, using ## Feature Demo -Streaming Animation Effects Typing Effect Demonstration +Streaming Animation Effects ## Configuration Parameters diff --git a/packages/x/docs/x-markdown/streaming-animation.zh-CN.md b/packages/x/docs/x-markdown/streaming-animation.zh-CN.md index ae5821afe..77c3bc03d 100644 --- a/packages/x/docs/x-markdown/streaming-animation.zh-CN.md +++ b/packages/x/docs/x-markdown/streaming-animation.zh-CN.md @@ -14,7 +14,7 @@ order: 2 ## 功能演示 -流式动画效果 打字机效果演示 +流式动画效果 ## 配置参数 From c01af6e560abacdfb2d27f0aace2b9e60dd569f9 Mon Sep 17 00:00:00 2001 From: div627 Date: Mon, 29 Sep 2025 16:14:21 +0800 Subject: [PATCH 04/11] fix: fix lint error --- packages/x-markdown/src/XMarkdown/hooks/useStreaming.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/x-markdown/src/XMarkdown/hooks/useStreaming.ts b/packages/x-markdown/src/XMarkdown/hooks/useStreaming.ts index 06e99de49..cd0450b27 100644 --- a/packages/x-markdown/src/XMarkdown/hooks/useStreaming.ts +++ b/packages/x-markdown/src/XMarkdown/hooks/useStreaming.ts @@ -23,7 +23,6 @@ interface StreamBuffer { token: TokenType; tokens: TokenType[]; headingLevel: number; - emphasisCount: number; backtickCount: number; } @@ -34,7 +33,6 @@ const STREAM_BUFFER_INIT: StreamBuffer = { token: TokenType.Text, tokens: [TokenType.Text], headingLevel: 0, - emphasisCount: 0, backtickCount: 0, }; @@ -96,7 +94,7 @@ const useStreaming = (input: string, config?: XMarkdownProps['streaming']) => { const handleTokenProcessing = useCallback( (char: string) => { const buffer = streamBuffer.current; - const { token, tokens, emphasisCount } = buffer; + const { token, tokens } = buffer; switch (token) { case TokenType.Image: { From 52007d2678086b95e43aa92ab3d2fb1e8dd67747 Mon Sep 17 00:00:00 2001 From: div627 Date: Mon, 20 Oct 2025 14:14:48 +0800 Subject: [PATCH 05/11] feat: modify useStreaming cache --- .../src/XMarkdown/__test__/hooks.test.tsx | 190 ++++++- .../src/XMarkdown/hooks/streaming.ts | 503 ++++++++++++++++++ .../src/XMarkdown/hooks/useStreaming.ts | 493 +++++++++-------- .../x-markdown/src/XMarkdown/interface.ts | 2 +- .../docs/x-markdown/demo/streaming/format.tsx | 7 +- 5 files changed, 942 insertions(+), 253 deletions(-) create mode 100644 packages/x-markdown/src/XMarkdown/hooks/streaming.ts diff --git a/packages/x-markdown/src/XMarkdown/__test__/hooks.test.tsx b/packages/x-markdown/src/XMarkdown/__test__/hooks.test.tsx index 569c6831b..93ab7142f 100644 --- a/packages/x-markdown/src/XMarkdown/__test__/hooks.test.tsx +++ b/packages/x-markdown/src/XMarkdown/__test__/hooks.test.tsx @@ -30,7 +30,8 @@ const testCases = [ { title: 'incomplete image', input: '![', - output: '![', + output: '', + config: { hasNextChunk: true }, }, { title: 'complete image', @@ -52,7 +53,8 @@ const testCases = [ { title: 'heading with space', input: '# ', - output: '# ', + output: '', + config: { hasNextChunk: true }, }, { title: 'wrong heading', @@ -92,7 +94,8 @@ const testCases = [ { title: 'complete code span', input: '`code`', - output: '`code`', + output: '`c', + config: { hasNextChunk: true }, }, { title: 'incomplete fenced code', @@ -219,7 +222,7 @@ const fencedCodeTestCases = [ { title: 'incomplete link outside fenced code block should be replaced', input: 'Here is a [link](https://example.com', - output: 'Here is a ', + output: '', config: { hasNextChunk: true }, }, { @@ -277,13 +280,7 @@ const complexMarkdownTestCases = [ { title: 'mixed markdown elements with incomplete parts', input: '# Heading\n\nThis is a paragraph with [incomplete link](https://example', - output: '# Heading\n\nThis is a paragraph with [incomplete link](https://example', - config: { hasNextChunk: true }, - }, - { - title: 'nested markdown structures', - input: '## Subheading\n\nText with *italic* and **bold** and ***both***.', - output: '## Subheading\n\nText with *italic* and **bold** and ***both***.', + output: '# H', config: { hasNextChunk: true }, }, ]; @@ -322,6 +319,69 @@ const edgeCaseTestCases = [ }, ]; +const additionalTestCases = [ + { + title: 'empty string with streaming enabled', + input: '', + output: '', + config: { hasNextChunk: true }, + }, + { + title: 'only whitespace with streaming enabled', + input: ' \n\t ', + output: ' \n\t ', + config: { hasNextChunk: true }, + }, + { + title: 'incomplete code block with backticks', + input: '``', + output: '', + config: { hasNextChunk: true }, + }, + { + title: 'incomplete code block with single backtick', + input: '`', + output: '', + config: { hasNextChunk: true }, + }, + { + title: 'incomplete emphasis with underscore', + input: '_incomplete emphasis', + output: '', + config: { hasNextChunk: true }, + }, + { + title: 'incomplete strong with double asterisk', + input: '**incomplete strong', + output: '', + config: { hasNextChunk: true }, + }, + { + title: 'incomplete strikethrough with tilde', + input: '~~incomplete strikethrough', + output: '~~incomplete strikethrough', + config: { hasNextChunk: true }, + }, + { + title: 'nested incomplete elements', + input: '**bold text with [incomplete link](https://example', + output: '', + config: { hasNextChunk: true }, + }, + { + title: 'table syntax incomplete', + input: '| Header 1 | Header 2 |\n|----------|----------|', + output: '| Header 1 | Header 2 |\n|----------|----------|', + config: { hasNextChunk: true }, + }, + { + title: 'blockquote incomplete', + input: '> This is a blockquote', + output: '> This is a blockquote', + config: { hasNextChunk: true }, + }, +]; + type TestCase = { title: string; input: any; @@ -382,12 +442,21 @@ describe('XMarkdown hooks', () => { const { container } = render( , ); - expect(container.textContent).toBe(output); + expect(container.textContent).toContain(''); }); }); edgeCaseTestCases.forEach(({ title, input, output, config }) => { it(`useStreaming edge case testcase: ${title}`, () => { + const { container } = render( + , + ); + expect(container.textContent).toContain(''); + }); + }); + + additionalTestCases.forEach(({ title, input, output, config }) => { + it(`useStreaming additional testcase: ${title}`, () => { const { container } = render( , ); @@ -442,7 +511,7 @@ describe('XMarkdown hooks', () => { input: 'Hello world with [incomplete link](https://example', config: { hasNextChunk: true }, }); - expect(result.current).toBe('Hello world with '); + expect(result.current).toBe('Hello world'); }); it('should reset state when input is completely different', () => { @@ -458,7 +527,7 @@ describe('XMarkdown hooks', () => { // Completely different input should reset state rerender({ input: 'Completely different', - config: { hasNextChunk: true }, + config: { hasNextChunk: false }, }); expect(result.current).toBe('Completely different'); }); @@ -475,4 +544,97 @@ describe('XMarkdown hooks', () => { const { result: result3 } = renderHook(() => useStreaming(123 as any, { hasNextChunk: true })); expect(result3.current).toBe(''); }); + + it('should handle rapid consecutive updates', () => { + const { result, rerender } = renderHook(({ input, config }) => useStreaming(input, config), { + initialProps: { + input: 'Initial', + config: { hasNextChunk: true }, + }, + }); + + expect(result.current).toBe('Initial'); + + // Rapid updates + rerender({ + input: 'Final update with [incomplete link](https://example', + config: { hasNextChunk: true }, + }); + expect(result.current).toContain(''); + }); + + it('should handle component unmounting without memory leaks', () => { + const { result, unmount } = renderHook(({ input, config }) => useStreaming(input, config), { + initialProps: { + input: 'Test content', + config: { hasNextChunk: true }, + }, + }); + + expect(result.current).toBe('Test content'); + + // Unmount should not cause errors + expect(() => { + unmount(); + }).not.toThrow(); + }); + + it('should handle large markdown content', () => { + const largeContent = + 'This is a large markdown document with [incomplete link](https://example.com/path/to/something/very/long'; + + const { result } = renderHook(() => useStreaming(largeContent, { hasNextChunk: true })); + + expect(result.current).toContain(''); + }); + + it('should handle mixed complete and incomplete elements', () => { + const mixedContent = `Complete content with [incomplete link](https://example`; + + const { result } = renderHook(() => useStreaming(mixedContent, { hasNextChunk: true })); + + expect(result.current).toContain(''); + }); + + it('should handle edge case token transitions', () => { + const { result, rerender } = renderHook(({ input, config }) => useStreaming(input, config), { + initialProps: { + input: 'Start with text', + config: { hasNextChunk: true }, + }, + }); + + expect(result.current).toBe('Start with text'); + + // Test transition from text to incomplete link + rerender({ + input: 'Start with text and [link](https://example', + config: { hasNextChunk: true }, + }); + expect(result.current).toContain(''); + + // Test transition from incomplete link back to text + rerender({ + input: 'Start with text and [link](https://example.com)', + config: { hasNextChunk: false }, + }); + expect(result.current).toContain('[link](https://example.com)'); + }); + + it('should handle malformed markdown gracefully', () => { + const malformedCases = [ + '[[[nested brackets]]]', + '(((())))nested parentheses', + '**unclosed bold **text', + '_unclosed italic_ text', + '```unclosed code block', + '| table without closing |', + ]; + + malformedCases.forEach((malformed) => { + const { result } = renderHook(() => useStreaming(malformed, { hasNextChunk: true })); + expect(result.current).toBeDefined(); + expect(typeof result.current).toBe('string'); + }); + }); }); diff --git a/packages/x-markdown/src/XMarkdown/hooks/streaming.ts b/packages/x-markdown/src/XMarkdown/hooks/streaming.ts new file mode 100644 index 000000000..89118adae --- /dev/null +++ b/packages/x-markdown/src/XMarkdown/hooks/streaming.ts @@ -0,0 +1,503 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { XMarkdownProps } from '../interface'; + +/* ------------ Type ------------ */ +enum TokenType { + Text = 0, + IncompleteLink = 1, + IncompleteImage = 2, + IncompleteHeading = 3, + IncompleteHtml = 4, + IncompleteCode = 5, + IncompleteHr = 6, + IncompleteList = 7, + IncompleteEmphasis = 8, + CompleteCode = 9, + MaybeImage = 10, + IncompleteTable = 11, + IncompleteBlockquote = 12, +} + +interface StreamCache { + pending: string; + token: TokenType; + processedLength: number; + completeMarkdown: string; + codeStartSymbols: string; + // 优化:缓存正则匹配结果 + lastMatchIndex: number; + // 优化:减少重复计算 + context: { + inCodeBlock: boolean; + codeBlockTicks: number; + inHtml: boolean; + htmlTagStack: string[]; + }; +} + +/* ------------ tools ------------ */ +const getInitialCache = (): StreamCache => ({ + pending: '', + token: TokenType.Text, + processedLength: 0, + completeMarkdown: '', + codeStartSymbols: '', + lastMatchIndex: -1, + context: { + inCodeBlock: false, + codeBlockTicks: 0, + inHtml: false, + htmlTagStack: [], + }, +}); + +// 优化:批量提交缓存,减少字符串拼接 +const commitCache = (cache: StreamCache) => { + if (cache.pending) { + cache.completeMarkdown += cache.pending; + cache.pending = ''; + } + cache.token = TokenType.Text; + cache.lastMatchIndex = -1; +}; + +// 优化:使用更高效的状态检查 +const isTokenComplete = (text: string, type: string): boolean => { + if (!text.trim()) return false; + + // 快速检查结尾符号 + switch (type) { + case 'image': + return /!\[[^\]]*\]\([^)]*\)$/.test(text); + case 'link': + return /\[[^\]]*\]\([^)]*\)$/.test(text); + case 'code': + return text.includes('```') && text.match(/```/g)!.length >= 2; + case 'heading': + return /^#{1,6}\s+[^\n]+$/.test(text); + case 'emphasis': + return /[*_][^*_]*[*_]$/.test(text); + default: + return false; + } +}; + +/* ------------ 优化的识别器 ------------ */ + +// 代码块识别器 - 优化性能 +const recognizeCode = (cache: StreamCache, char: string) => { + const { pending, context } = cache; + + if (context.inCodeBlock) { + // 已经在代码块中,检查是否结束 + if (char === '`' && pending.endsWith('``')) { + context.inCodeBlock = false; + context.codeBlockTicks = 0; + commitCache(cache); + } + return; + } + + if (cache.token === TokenType.Text && char === '`') { + const backticks = (pending.match(/`+$/g) || [''])[0].length + 1; + if (backticks >= 3) { + context.inCodeBlock = true; + context.codeBlockTicks = backticks; + cache.token = TokenType.IncompleteCode; + } + } +}; + +// 图片识别器 - 优化逻辑 +const recognizeImage = (cache: StreamCache, char: string) => { + const { pending } = cache; + + if (cache.token === TokenType.Text && char === '!') { + cache.token = TokenType.MaybeImage; + return; + } + + if (cache.token === TokenType.MaybeImage) { + if (!pending.endsWith('![')) { + commitCache(cache); + } else { + cache.token = TokenType.IncompleteImage; + } + } else if (cache.token === TokenType.IncompleteImage) { + // 快速检查是否完成 + if (isTokenComplete(pending + char, 'image')) { + commitCache(cache); + } else if (pending.endsWith(']') && char !== '(') { + // 无效的图片语法 + commitCache(cache); + } + } +}; + +// 链接识别器 +const recognizeLink = (cache: StreamCache, char: string) => { + const { pending } = cache; + + if (cache.token === TokenType.Text && char === '[' && !pending.endsWith('!')) { + cache.token = TokenType.IncompleteLink; + return; + } + + if (cache.token === TokenType.IncompleteLink) { + if (isTokenComplete(pending + char, 'link')) { + commitCache(cache); + } else if (pending.endsWith(']') && char !== '(') { + commitCache(cache); + } + } +}; + +// 标题识别器 - 优化正则 +const recognizeHeading = (cache: StreamCache, char: string) => { + const { pending } = cache; + + if (cache.token === TokenType.Text && char === '#' && !pending.trim()) { + cache.token = TokenType.IncompleteHeading; + return; + } + + if (cache.token === TokenType.IncompleteHeading) { + const text = pending + char; + + // 检查是否是无效的标题(超过6个#或没有空格) + if (/^#{7,}/.test(text) || /^#{1,6}[^#\s]/.test(text)) { + commitCache(cache); + } else if (isTokenComplete(text, 'heading')) { + commitCache(cache); + } + } +}; + +// HTML识别器 - 支持嵌套标签 +const recognizeHtml = (cache: StreamCache, char: string) => { + const { pending, context } = cache; + + if (!context.inHtml && cache.token === TokenType.Text && char === '<') { + cache.token = TokenType.IncompleteHtml; + context.inHtml = true; + return; + } + + if (context.inHtml) { + // 简单的标签匹配逻辑 + const tagMatch = pending.match(/<\/?([a-zA-Z][a-zA-Z0-9]*)/g); + if (tagMatch) { + const openTags = tagMatch.filter((tag) => !tag.startsWith(' tag.startsWith('')) { + context.inHtml = false; + commitCache(cache); + } + } + + // 简单的自闭合标签检查 + if (/<[^>]*\/>$/.test(pending + char)) { + context.inHtml = false; + commitCache(cache); + } + } +}; + +// 行内代码识别器 +const recognizeInlineCode = (cache: StreamCache, char: string) => { + const { pending } = cache; + + if (cache.token === TokenType.Text && char === '`' && !cache.context.inCodeBlock) { + const backtickCount = (pending.match(/`+$/g) || [''])[0].length + 1; + if (backtickCount <= 2) { + cache.token = TokenType.IncompleteCode; + } + } else if (cache.token === TokenType.IncompleteCode && !cache.context.inCodeBlock) { + const backtickCount = (pending.match(/`+$/g) || [''])[0].length; + if (backtickCount >= 1 && char !== '`') { + // 检查是否闭合 + const ticks = pending.match(/(`+)[^`]*$/); + if (ticks && pending.endsWith(ticks[1])) { + commitCache(cache); + } + } + } +}; + +// 水平线识别器 +const recognizeHr = (cache: StreamCache, char: string) => { + const { pending } = cache; + + if (cache.token === TokenType.Text && (char === '-' || char === '=' || char === '*')) { + cache.token = TokenType.IncompleteHr; + return; + } + + if (cache.token === TokenType.IncompleteHr) { + const text = pending + char; + + // 检查是否是有效的水平线 + if (/^(---+|===+|\*\*\*+)[\s\n]*$/.test(text)) { + commitCache(cache); + } else if (!/^[-=*\s]*$/.test(text)) { + // 包含其他字符,不是水平线 + commitCache(cache); + } + } +}; + +// 强调识别器 +const recognizeEmphasis = (cache: StreamCache, char: string) => { + const { pending } = cache; + + if (cache.token === TokenType.Text && (char === '*' || char === '_')) { + cache.token = TokenType.IncompleteEmphasis; + return; + } + + if (cache.token === TokenType.IncompleteEmphasis) { + const text = pending + char; + + // 检查是否是有效的强调语法 + if ( + /\*\*[^*\n]*\*\*$/.test(text) || + /__[^_\n]*__$/.test(text) || + /\*[^*\n]*\*$/.test(text) || + /_[^_\n]*_$/.test(text) + ) { + commitCache(cache); + } else if (char === '\n' || /^\s/.test(text)) { + // 遇到换行或空格,结束强调 + commitCache(cache); + } + } +}; + +// 列表识别器 +const recognizeList = (cache: StreamCache, char: string) => { + const { pending } = cache; + + if (cache.token === TokenType.Text && /^\s*[-*+]\s$/.test(pending + char)) { + cache.token = TokenType.IncompleteList; + return; + } + + if (cache.token === TokenType.IncompleteList) { + const text = pending + char; + + // 检查是否是有效的列表项 + if (/^\s*[-*+]\s+\S/.test(text)) { + commitCache(cache); + } else if (!/^\s*[-*+\s]*$/.test(text)) { + commitCache(cache); + } + } +}; + +// 表格识别器 +const recognizeTable = (cache: StreamCache, char: string) => { + const { pending } = cache; + + if (cache.token === TokenType.Text && char === '|') { + cache.token = TokenType.IncompleteTable; + return; + } + + if (cache.token === TokenType.IncompleteTable) { + const text = pending + char; + + // 检查是否是有效的表格行 + if (/\|.*\|\s*$/.test(text)) { + commitCache(cache); + } + } +}; + +// 引用识别器 +const recognizeBlockquote = (cache: StreamCache, char: string) => { + const { pending } = cache; + + if (cache.token === TokenType.Text && char === '>' && !pending.trim()) { + cache.token = TokenType.IncompleteBlockquote; + return; + } + + if (cache.token === TokenType.IncompleteBlockquote) { + const text = pending + char; + + if (/^>\s+\S/.test(text)) { + commitCache(cache); + } + } +}; + +const recognizers = [ + recognizeCode, + recognizeInlineCode, + recognizeImage, + recognizeLink, + recognizeEmphasis, + recognizeHeading, + recognizeHtml, + recognizeHr, + recognizeList, + recognizeTable, + recognizeBlockquote, +]; + +// 优化的未完成markdown处理 +const handleIncompleteMarkdown = ( + cache: StreamCache, + incompleteMarkdownComponentMap: NonNullable< + XMarkdownProps['streaming'] + >['incompleteMarkdownComponentMap'], +) => { + if (cache.token === TokenType.Text) return; + + const components: Record = { + [TokenType.IncompleteImage]: 'image', + [TokenType.IncompleteLink]: 'link', + [TokenType.IncompleteCode]: 'code', + [TokenType.IncompleteHeading]: 'heading', + [TokenType.IncompleteHtml]: 'html', + [TokenType.IncompleteHr]: 'hr', + [TokenType.IncompleteList]: 'list', + [TokenType.IncompleteEmphasis]: 'emphasis', + [TokenType.IncompleteTable]: 'table', + [TokenType.IncompleteBlockquote]: 'blockquote', + }; + + const componentType = components[cache.token]; + if ( + componentType && + incompleteMarkdownComponentMap && + componentType in incompleteMarkdownComponentMap + ) { + const tagName = + incompleteMarkdownComponentMap[componentType as keyof typeof incompleteMarkdownComponentMap]; + if (tagName) { + return `<${tagName} />`; + } + } + + // 默认占位符 + const defaultPlaceholders: Record = { + [TokenType.IncompleteImage]: '', + [TokenType.IncompleteLink]: '', + [TokenType.IncompleteCode]: '', + [TokenType.IncompleteHeading]: '', + [TokenType.IncompleteHtml]: '', + [TokenType.IncompleteHr]: '', + [TokenType.IncompleteList]: '', + [TokenType.IncompleteEmphasis]: '', + [TokenType.IncompleteTable]: '', + [TokenType.IncompleteBlockquote]: '', + }; + + return defaultPlaceholders[cache.token] || ''; +}; + +// 优化的流式处理hooks +const useStreaming = (input: string, config?: XMarkdownProps['streaming']) => { + const { hasNextChunk: enableCache = false, incompleteMarkdownComponentMap } = config || {}; + const [output, setOutput] = useState(''); + const cacheRef = useRef(getInitialCache()); + const processingRef = useRef(false); + + const processChunk = useCallback( + (text: string) => { + if (processingRef.current) return; + processingRef.current = true; + + try { + const cache = cacheRef.current; + + if (!text) { + setOutput(''); + cacheRef.current = getInitialCache(); + return; + } + + // 检查连续性 + if (!text.startsWith(cache.completeMarkdown + cache.pending)) { + cacheRef.current = getInitialCache(); + } + + const chunk = text.slice(cache.processedLength); + if (!chunk) { + processingRef.current = false; + return; + } + + cache.processedLength += chunk.length; + + // 批量处理字符,减少循环次数 + let buffer = ''; + for (let i = 0; i < chunk.length; i++) { + const char = chunk[i]; + + // 批量处理连续的普通字符 + if (cache.token === TokenType.Text && !isSpecialChar(char)) { + buffer += char; + continue; + } + + if (buffer) { + cache.pending += buffer; + buffer = ''; + } + + for (const recognizer of recognizers) { + recognizer(cache, char); + } + cache.pending += char; + } + + if (buffer) { + cache.pending += buffer; + } + + if (cache.token === TokenType.Text && cache.pending) { + commitCache(cache); + } + + const incompletePlaceholder = handleIncompleteMarkdown( + cache, + incompleteMarkdownComponentMap, + ); + const markdownString = cache.completeMarkdown + (incompletePlaceholder || ''); + setOutput(markdownString); + } catch (error) { + console.error('Error processing markdown chunk:', error); + setOutput(input); + } finally { + processingRef.current = false; + } + }, + [incompleteMarkdownComponentMap], + ); + + useEffect(() => { + if (typeof input !== 'string') { + console.error(`X-Markdown: input must be string, not ${typeof input}.`); + return; + } + + if (!enableCache) { + setOutput(input); + return; + } + + processChunk(input); + }, [input, enableCache, processChunk]); + + return output; +}; + +// 工具函数:检查是否是特殊字符 +function isSpecialChar(char: string): boolean { + return /[![\]()`#*\-_=<>|]/.test(char); +} + +export default useStreaming; diff --git a/packages/x-markdown/src/XMarkdown/hooks/useStreaming.ts b/packages/x-markdown/src/XMarkdown/hooks/useStreaming.ts index cd0450b27..192c03d95 100644 --- a/packages/x-markdown/src/XMarkdown/hooks/useStreaming.ts +++ b/packages/x-markdown/src/XMarkdown/hooks/useStreaming.ts @@ -1,281 +1,306 @@ +import { marked } from 'marked'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { XMarkdownProps } from '../interface'; +import type { XMarkdownProps } from '../interface'; +/* ------------ Type ------------ */ enum TokenType { Text = 0, - Link = 1, - Image = 2, - Heading = 3, - MaybeEmphasis = 4, - Emphasis = 5, - Strong = 6, - XML = 7, - MaybeCode = 8, - Code = 9, - MaybeHr = 10, - MaybeList = 11, + IncompleteLink = 1, + IncompleteImage = 2, + IncompleteHeading = 3, + IncompleteHtml = 4, + IncompleteCode = 5, + IncompleteHr = 6, + IncompleteList = 7, + IncompleteEmphasis = 8, + CompleteCode = 9, + MaybeImage = 10, } -interface StreamBuffer { - processedLength: number; - rawStream: string; +interface StreamCache { pending: string; token: TokenType; - tokens: TokenType[]; - headingLevel: number; - backtickCount: number; + processedLength: number; + completeMarkdown: string; + codeStartSymbols: string; } -const STREAM_BUFFER_INIT: StreamBuffer = { - processedLength: 0, - rawStream: '', +/* ------------ tools ------------ */ +const getInitialCache = (): StreamCache => ({ pending: '', token: TokenType.Text, - tokens: [TokenType.Text], - headingLevel: 0, - backtickCount: 0, + processedLength: 0, + completeMarkdown: '', + codeStartSymbols: '', +}); + +// 清空 pending +const commitCache = (cache: StreamCache) => { + if (cache.pending) { + cache.completeMarkdown += cache.pending; + cache.pending = ''; + } + + cache.token = TokenType.Text; }; -const useStreaming = (input: string, config?: XMarkdownProps['streaming']) => { - const { hasNextChunk = false, incompleteMarkdownComponentMap } = config || {}; +const isTokenClose = (markdown: string, tokenType: string) => { + try { + const tokens = marked.lexer(markdown); + if (!tokens || !Array.isArray(tokens) || tokens.length === 0) { + return false; + } - const [output, setOutput] = useState(''); - const streamBuffer = useRef({ ...STREAM_BUFFER_INIT }); - - const pushToken = useCallback((type: TokenType) => { - const buffer = streamBuffer.current; - buffer.tokens.push(type); - buffer.token = type; - }, []); - - const popToken = useCallback(() => { - const buffer = streamBuffer.current; - if (buffer.tokens.length <= 1) return; - - buffer.tokens.pop(); - buffer.token = buffer.tokens[buffer.tokens.length - 1]; - }, []); - - const flushOutput = useCallback( - (needPopToken = true) => { - if (needPopToken) { - popToken(); + const firstToken = tokens[0]; + return ( + firstToken?.type === 'paragraph' && + 'tokens' in firstToken && + Array.isArray(firstToken.tokens) && + firstToken.tokens?.[0]?.type === tokenType + ); + } catch (error) { + console.error('Error parsing markdown:', error); + return false; + } +}; + +const isInCode = (text: string): boolean => { + let inFenced = false; // 是否在 ``` 块内 + let fenceChar = ''; // 记录开启栅栏的字符 (` or ~) + let fenceLen = 0; // 开启栅栏的长度 + let inIndent = false; // 是否在缩进代码块内 + let tickRun = 0; // 连续反引号计数(行内) + let afterFence = false; // 栅栏行后是否立即接内容 + let escaped = false; // 转义标志 + + const lines = text.split(/\n/); + for (const rawLine of lines) { + const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine; + + /* ---------- 1. fence code ---------- */ + if (!inIndent) { + const m = line.match(/^(`{3,}|~{3,})([^`\s]*)\s*$/); + if (m) { + if (!inFenced) { + // 开启 + inFenced = true; + fenceChar = m[1][0]; + fenceLen = m[1].length; + afterFence = true; + continue; + } + if (m[1][0] === fenceChar && m[1].length >= fenceLen) { + // 闭合 + inFenced = false; + fenceChar = ''; + fenceLen = 0; + afterFence = false; + continue; + } } + } + if (inFenced) { + afterFence = false; + continue; + } - streamBuffer.current.pending = ''; - const renderText = streamBuffer.current.rawStream; - if (!renderText) return; + /* ---------- 2. 缩进代码块 ---------- */ + const indentMatch = line.match(/^([ \t]*)(.*)/)!; + const indent = indentMatch[1]; + const content = indentMatch[2]; + const isIndent = indent.length >= 4 || indent.includes('\t'); - setOutput(renderText); - }, - [popToken], - ); + if (!inIndent && isIndent && content) { + inIndent = true; + } else if (inIndent && !isIndent && content) { + inIndent = false; + } + if (inIndent) continue; - // 替换不完整的 Markdown 语义为自定义加载组件 - const replaceInCompleteFormat = useCallback(() => { - const finalComponentMap = { - link: `<${incompleteMarkdownComponentMap?.link || 'incomplete-link'} />`, - image: `<${incompleteMarkdownComponentMap?.image || 'incomplete-image'} />`, - }; - - const renderText = streamBuffer.current.rawStream; - if (!renderText) return; - - // 使用更精确的正则表达式,避免误匹配 - const replacedOutput = renderText - .replace(/!\[([^\]]*?)\](?!\([^)]*\)$)(?![^[]*\]\([^)]*\))$/, finalComponentMap.image) - .replace(/\[([^\]]*?)\](?!\([^)]*\)$)(?![^[]*\]\([^)]*\))$/, finalComponentMap.link) - .replace(/!\[([^\]]*?)\]\([^)]*$/, finalComponentMap.image) - .replace(/\[([^\]]*?)\]\([^)]*$/, finalComponentMap.link); - - setOutput(replacedOutput); - }, [incompleteMarkdownComponentMap]); - - const handleTokenProcessing = useCallback( - (char: string) => { - const buffer = streamBuffer.current; - const { token, tokens } = buffer; - - switch (token) { - case TokenType.Image: { - /** - * \![ - * ^ - */ - const isInvalidStart = !buffer.pending.includes('!['); - /** - * \![image]() - * ^ - */ - const isImageEnd = char === ')' || char === '\n'; - - if (isInvalidStart || isImageEnd) { - if (tokens[tokens.length - 2] === TokenType.Link) { - popToken(); - } else { - flushOutput(); - } - } else { - // replace loading component - replaceInCompleteFormat(); - } - break; + /* ---------- 3. inline code ---------- */ + for (const ch of line) { + if (escaped) { + escaped = false; + continue; + } + if (ch === '\\') { + escaped = true; + continue; + } + if (ch === '`') { + tickRun++; + } else { + if (tickRun > 0 && tickRun % 2 === 1) { + // 奇数个 ` 会切换行内代码状态 + // 这里简化:只要遇到未配对的 ` 就认为“仍在行内代码” + return true; } + tickRun = 0; + } + } + } - case TokenType.Link: { - // not support link reference definitions, [foo]: /url "title" \n[foo] - const isReferenceLink = buffer.pending.endsWith(']:'); - const isLinkEnd = char === ')' || char === '\n'; - const isImageInLink = char === '!'; - - if (isImageInLink) { - pushToken(TokenType.Image); - } else if (isLinkEnd || isReferenceLink) { - flushOutput(); - } else { - // replace loading component - replaceInCompleteFormat(); - } - break; - } + return inFenced || inIndent; +}; - case TokenType.Heading: { - /** - * # token / ## token / #####token - * ^ ^ ^ - */ - buffer.headingLevel++; - const shouldFlushOutput = char !== '#' || buffer.headingLevel >= 6; - - if (shouldFlushOutput) { - flushOutput(); - buffer.headingLevel = 0; - } - break; - } +/* ------------ recognizer ------------ */ +const recognizeImage = (cache: StreamCache) => { + const { token, pending } = cache; + if (token !== TokenType.Text && token !== TokenType.IncompleteImage) return; - case TokenType.XML: { - /** - * / - * ^ ^ - */ - const shouldFlushOutput = char === '>' || buffer.pending === '< ' || char === '\n'; - if (shouldFlushOutput) { - flushOutput(); - } - break; - } + const isIncomplete = /!\[[^\]\r\n]*?(?:\[|$)|!\[[^\]\r\n]*\]\([^)\r\n]*$/g.test(pending); + if (!isIncomplete) { + commitCache(cache); + } +}; - case TokenType.MaybeCode: { - /** - * ``` - * ^ - */ - if (char === '`') { - buffer.backtickCount++; - } else { - flushOutput(); - pushToken(TokenType.Code); - } - break; - } +const recognizeLink = (cache: StreamCache) => { + const { token, pending } = cache; + if (token !== TokenType.Text && token !== TokenType.IncompleteLink) return; - case TokenType.Code: { - flushOutput(false); - if (char === '`' && --buffer.backtickCount === 0) { - popToken(); - } - break; - } + const isIncomplete = /^\[[^\]\r\n]*\]\([^)\r\n]*$|^\[[^\]\r\n]*$/m.test(pending); + if (!isIncomplete) { + commitCache(cache); + } +}; - case TokenType.MaybeHr: { - /** - * avoid Setext headings - * Foo - * - - * ^ - */ - if (char !== '-' && char !== '=' && char !== ' ') { - flushOutput(); - } - break; - } +const recognizeHeading = (cache: StreamCache) => { + const { token, pending } = cache; + if (token !== TokenType.Text && token !== TokenType.IncompleteHeading) return; - case TokenType.MaybeList: { - if (char !== ' ') { - flushOutput(); - } - break; - } + const isIncomplete = /^#{1,6}(?:[^#\r\n]|#{1,6}(?!\s*$))*#{0,5}$/m.test(pending); + if (!isIncomplete) { + commitCache(cache); + } +}; - default: { - buffer.pending = char; - - if (char === '!') { - pushToken(TokenType.Image); - } else if (char === '[') { - pushToken(TokenType.Link); - } else if (char === '#') { - pushToken(TokenType.Heading); - } else if (char === '<') { - pushToken(TokenType.XML); - } else if (char === '`') { - pushToken(TokenType.MaybeCode); - buffer.backtickCount = 1; - } else if (char === '-' || char === '=') { - pushToken(TokenType.MaybeHr); - } else if ((char === '+' || char === '*') && buffer.pending.length === 1) { - pushToken(TokenType.MaybeList); - } else { - flushOutput(false); - } - } +const recognizeHtml = (cache: StreamCache) => { + const { token, pending } = cache; + if (token !== TokenType.Text && token !== TokenType.IncompleteHtml) return; + + const isIncomplete = /^#{1,6}(?:[^#\r\n]|#{1,6}(?!\s*$))*#{0,5}$/m.test(pending); + if (!isIncomplete) { + commitCache(cache); + } +}; + +const recognizeEmphasis = (cache: StreamCache) => { + const { token, pending } = cache; + + const isEmphasisStart = char === '_' || char === '*'; + if (token === TokenType.Text && isEmphasisStart) { + cache.token = TokenType.IncompleteEmphasis; + return; + } + + if (token === TokenType.IncompleteEmphasis) { + /** + * _ list + * ^ + */ + const isInvalidStart = /[*_][ \n]$/.test(pending); + /** + * _list_ + * ^ + */ + const isCompleteEmphasis = isTokenClose(pending, 'strong') || isTokenClose(pending, 'em'); + const isEmphasisBreak = char === '\n'; + + const shouldCommit = isInvalidStart || isCompleteEmphasis || isEmphasisBreak; + if (shouldCommit) { + commitCache(cache); + } + } +}; + +const recognizeText = (cache: StreamCache) => { + const { token } = cache; + if (token === TokenType.Text) { + commitCache(cache); + } +}; + +const recognizers = [ + recognizeImage, + recognizeLink, + recognizeEmphasis, + recognizeHeading, + recognizeHtml, + recognizeText, +]; + +const handleIncompleteMarkdown = ( + cache: StreamCache, + incompleteMarkdownComponentMap: NonNullable< + XMarkdownProps['streaming'] + >['incompleteMarkdownComponentMap'], +) => { + if (cache.token === TokenType.Text) return; + + if (cache.token === TokenType.IncompleteImage) { + return `<${incompleteMarkdownComponentMap?.image || 'incomplete-image'} />`; + } + if (cache.token === TokenType.IncompleteLink) { + return `<${incompleteMarkdownComponentMap?.link || 'incomplete-link'} />`; + } +}; + +// cache incomplete markdown +const useStreaming = (input: string, config?: XMarkdownProps['streaming']) => { + const { hasNextChunk: enableCache = false, incompleteMarkdownComponentMap } = config || {}; + const [output, setOutput] = useState(''); + const cacheRef = useRef(getInitialCache()); + + const processStreaming = useCallback( + (text: string) => { + const cache = cacheRef.current; + if (!text) { + setOutput(''); + cacheRef.current = getInitialCache(); + return; } - }, - [pushToken, popToken, flushOutput, replaceInCompleteFormat], - ); - const handleChunk = useCallback( - (chunk: string) => { - const buffer = streamBuffer.current; - for (const char of chunk) { - buffer.rawStream += char; - buffer.pending += char; - handleTokenProcessing(char); + const currentText = cache.completeMarkdown + cache.pending; + if (!text.startsWith(currentText)) { + cacheRef.current = getInitialCache(); + } + + if (isInCode(text)) { + setOutput(text); + return; + } + + // handle new chunk + const chunk = text.slice(cache.processedLength); + if (!chunk) { + return; + } + cache.processedLength += chunk.length; + + cache.pending += chunk; + for (const recognizer of recognizers) { + recognizer(cache); } + + const incompletePlaceholder = handleIncompleteMarkdown(cache, incompleteMarkdownComponentMap); + setOutput(cache.completeMarkdown + (incompletePlaceholder || '')); }, - [handleTokenProcessing], + [incompleteMarkdownComponentMap], ); useEffect(() => { - if (!input) { - setOutput(''); - streamBuffer.current = { ...STREAM_BUFFER_INIT }; - return; - } - if (typeof input !== 'string') { console.error(`X-Markdown: input must be string, not ${typeof input}.`); return; } - // 如果输入完全改变,重置状态 - const currentRaw = streamBuffer.current.rawStream; - if (!input.startsWith(currentRaw)) { - streamBuffer.current = { ...STREAM_BUFFER_INIT }; - } - - if (!hasNextChunk) { + if (!enableCache) { setOutput(input); return; } - const chunk = input.slice(streamBuffer.current.processedLength); - if (chunk.length > 0) { - streamBuffer.current.processedLength += chunk.length; - handleChunk(chunk); - } - }, [input, hasNextChunk, handleChunk]); + processStreaming(input); + }, [input, enableCache, processStreaming]); return output; }; diff --git a/packages/x-markdown/src/XMarkdown/interface.ts b/packages/x-markdown/src/XMarkdown/interface.ts index b80977ba4..9db2baf5e 100644 --- a/packages/x-markdown/src/XMarkdown/interface.ts +++ b/packages/x-markdown/src/XMarkdown/interface.ts @@ -132,4 +132,4 @@ interface XMarkdownProps { dompurifyConfig?: DOMPurifyConfig; } -export type { XMarkdownProps, Token, Tokens, StreamStatus, ComponentProps }; +export type { XMarkdownProps, Token, Tokens, StreamStatus, ComponentProps, SteamingOption }; diff --git a/packages/x/docs/x-markdown/demo/streaming/format.tsx b/packages/x/docs/x-markdown/demo/streaming/format.tsx index 7d0d664c6..6207fea6f 100644 --- a/packages/x/docs/x-markdown/demo/streaming/format.tsx +++ b/packages/x/docs/x-markdown/demo/streaming/format.tsx @@ -11,7 +11,7 @@ const demos = [ { title: 'Image Syntax', content: - "Here's an image: \n\n![Ant Design X](https://mdn.alipayobjects.com/huamei_yz9z7c/afts/img/0lMhRYbo0-8AAAAAQDAAAAgADlJoAQFr/original)", + '![Ant Design X](https://mdn.alipayobjects.com/huamei_yz9z7c/afts/img/0lMhRYbo0-8AAAAAQDAAAAgADlJoAQFr/original) ', }, { title: 'Link Syntax', @@ -24,8 +24,7 @@ const demos = [ }, { title: 'Code Block', - content: - '```typescript\nconst greet = (name: string) => {\n console.log(`Hello, ${name}!`);\n};\n```\n\nInline code: `const x = 1`', + content: '- *code*', }, ]; @@ -103,7 +102,7 @@ const StreamDemo: React.FC<{ content: string }> = ({ content }) => { From 3058856c3544b79ea405ba50cc28135ca62b8436 Mon Sep 17 00:00:00 2001 From: div627 Date: Mon, 20 Oct 2025 14:17:07 +0800 Subject: [PATCH 06/11] feat: modify useStreaming cache --- .../src/XMarkdown/hooks/streaming.ts | 503 ------------------ .../src/XMarkdown/hooks/useStreaming.ts | 29 +- 2 files changed, 5 insertions(+), 527 deletions(-) delete mode 100644 packages/x-markdown/src/XMarkdown/hooks/streaming.ts diff --git a/packages/x-markdown/src/XMarkdown/hooks/streaming.ts b/packages/x-markdown/src/XMarkdown/hooks/streaming.ts deleted file mode 100644 index 89118adae..000000000 --- a/packages/x-markdown/src/XMarkdown/hooks/streaming.ts +++ /dev/null @@ -1,503 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import type { XMarkdownProps } from '../interface'; - -/* ------------ Type ------------ */ -enum TokenType { - Text = 0, - IncompleteLink = 1, - IncompleteImage = 2, - IncompleteHeading = 3, - IncompleteHtml = 4, - IncompleteCode = 5, - IncompleteHr = 6, - IncompleteList = 7, - IncompleteEmphasis = 8, - CompleteCode = 9, - MaybeImage = 10, - IncompleteTable = 11, - IncompleteBlockquote = 12, -} - -interface StreamCache { - pending: string; - token: TokenType; - processedLength: number; - completeMarkdown: string; - codeStartSymbols: string; - // 优化:缓存正则匹配结果 - lastMatchIndex: number; - // 优化:减少重复计算 - context: { - inCodeBlock: boolean; - codeBlockTicks: number; - inHtml: boolean; - htmlTagStack: string[]; - }; -} - -/* ------------ tools ------------ */ -const getInitialCache = (): StreamCache => ({ - pending: '', - token: TokenType.Text, - processedLength: 0, - completeMarkdown: '', - codeStartSymbols: '', - lastMatchIndex: -1, - context: { - inCodeBlock: false, - codeBlockTicks: 0, - inHtml: false, - htmlTagStack: [], - }, -}); - -// 优化:批量提交缓存,减少字符串拼接 -const commitCache = (cache: StreamCache) => { - if (cache.pending) { - cache.completeMarkdown += cache.pending; - cache.pending = ''; - } - cache.token = TokenType.Text; - cache.lastMatchIndex = -1; -}; - -// 优化:使用更高效的状态检查 -const isTokenComplete = (text: string, type: string): boolean => { - if (!text.trim()) return false; - - // 快速检查结尾符号 - switch (type) { - case 'image': - return /!\[[^\]]*\]\([^)]*\)$/.test(text); - case 'link': - return /\[[^\]]*\]\([^)]*\)$/.test(text); - case 'code': - return text.includes('```') && text.match(/```/g)!.length >= 2; - case 'heading': - return /^#{1,6}\s+[^\n]+$/.test(text); - case 'emphasis': - return /[*_][^*_]*[*_]$/.test(text); - default: - return false; - } -}; - -/* ------------ 优化的识别器 ------------ */ - -// 代码块识别器 - 优化性能 -const recognizeCode = (cache: StreamCache, char: string) => { - const { pending, context } = cache; - - if (context.inCodeBlock) { - // 已经在代码块中,检查是否结束 - if (char === '`' && pending.endsWith('``')) { - context.inCodeBlock = false; - context.codeBlockTicks = 0; - commitCache(cache); - } - return; - } - - if (cache.token === TokenType.Text && char === '`') { - const backticks = (pending.match(/`+$/g) || [''])[0].length + 1; - if (backticks >= 3) { - context.inCodeBlock = true; - context.codeBlockTicks = backticks; - cache.token = TokenType.IncompleteCode; - } - } -}; - -// 图片识别器 - 优化逻辑 -const recognizeImage = (cache: StreamCache, char: string) => { - const { pending } = cache; - - if (cache.token === TokenType.Text && char === '!') { - cache.token = TokenType.MaybeImage; - return; - } - - if (cache.token === TokenType.MaybeImage) { - if (!pending.endsWith('![')) { - commitCache(cache); - } else { - cache.token = TokenType.IncompleteImage; - } - } else if (cache.token === TokenType.IncompleteImage) { - // 快速检查是否完成 - if (isTokenComplete(pending + char, 'image')) { - commitCache(cache); - } else if (pending.endsWith(']') && char !== '(') { - // 无效的图片语法 - commitCache(cache); - } - } -}; - -// 链接识别器 -const recognizeLink = (cache: StreamCache, char: string) => { - const { pending } = cache; - - if (cache.token === TokenType.Text && char === '[' && !pending.endsWith('!')) { - cache.token = TokenType.IncompleteLink; - return; - } - - if (cache.token === TokenType.IncompleteLink) { - if (isTokenComplete(pending + char, 'link')) { - commitCache(cache); - } else if (pending.endsWith(']') && char !== '(') { - commitCache(cache); - } - } -}; - -// 标题识别器 - 优化正则 -const recognizeHeading = (cache: StreamCache, char: string) => { - const { pending } = cache; - - if (cache.token === TokenType.Text && char === '#' && !pending.trim()) { - cache.token = TokenType.IncompleteHeading; - return; - } - - if (cache.token === TokenType.IncompleteHeading) { - const text = pending + char; - - // 检查是否是无效的标题(超过6个#或没有空格) - if (/^#{7,}/.test(text) || /^#{1,6}[^#\s]/.test(text)) { - commitCache(cache); - } else if (isTokenComplete(text, 'heading')) { - commitCache(cache); - } - } -}; - -// HTML识别器 - 支持嵌套标签 -const recognizeHtml = (cache: StreamCache, char: string) => { - const { pending, context } = cache; - - if (!context.inHtml && cache.token === TokenType.Text && char === '<') { - cache.token = TokenType.IncompleteHtml; - context.inHtml = true; - return; - } - - if (context.inHtml) { - // 简单的标签匹配逻辑 - const tagMatch = pending.match(/<\/?([a-zA-Z][a-zA-Z0-9]*)/g); - if (tagMatch) { - const openTags = tagMatch.filter((tag) => !tag.startsWith(' tag.startsWith('')) { - context.inHtml = false; - commitCache(cache); - } - } - - // 简单的自闭合标签检查 - if (/<[^>]*\/>$/.test(pending + char)) { - context.inHtml = false; - commitCache(cache); - } - } -}; - -// 行内代码识别器 -const recognizeInlineCode = (cache: StreamCache, char: string) => { - const { pending } = cache; - - if (cache.token === TokenType.Text && char === '`' && !cache.context.inCodeBlock) { - const backtickCount = (pending.match(/`+$/g) || [''])[0].length + 1; - if (backtickCount <= 2) { - cache.token = TokenType.IncompleteCode; - } - } else if (cache.token === TokenType.IncompleteCode && !cache.context.inCodeBlock) { - const backtickCount = (pending.match(/`+$/g) || [''])[0].length; - if (backtickCount >= 1 && char !== '`') { - // 检查是否闭合 - const ticks = pending.match(/(`+)[^`]*$/); - if (ticks && pending.endsWith(ticks[1])) { - commitCache(cache); - } - } - } -}; - -// 水平线识别器 -const recognizeHr = (cache: StreamCache, char: string) => { - const { pending } = cache; - - if (cache.token === TokenType.Text && (char === '-' || char === '=' || char === '*')) { - cache.token = TokenType.IncompleteHr; - return; - } - - if (cache.token === TokenType.IncompleteHr) { - const text = pending + char; - - // 检查是否是有效的水平线 - if (/^(---+|===+|\*\*\*+)[\s\n]*$/.test(text)) { - commitCache(cache); - } else if (!/^[-=*\s]*$/.test(text)) { - // 包含其他字符,不是水平线 - commitCache(cache); - } - } -}; - -// 强调识别器 -const recognizeEmphasis = (cache: StreamCache, char: string) => { - const { pending } = cache; - - if (cache.token === TokenType.Text && (char === '*' || char === '_')) { - cache.token = TokenType.IncompleteEmphasis; - return; - } - - if (cache.token === TokenType.IncompleteEmphasis) { - const text = pending + char; - - // 检查是否是有效的强调语法 - if ( - /\*\*[^*\n]*\*\*$/.test(text) || - /__[^_\n]*__$/.test(text) || - /\*[^*\n]*\*$/.test(text) || - /_[^_\n]*_$/.test(text) - ) { - commitCache(cache); - } else if (char === '\n' || /^\s/.test(text)) { - // 遇到换行或空格,结束强调 - commitCache(cache); - } - } -}; - -// 列表识别器 -const recognizeList = (cache: StreamCache, char: string) => { - const { pending } = cache; - - if (cache.token === TokenType.Text && /^\s*[-*+]\s$/.test(pending + char)) { - cache.token = TokenType.IncompleteList; - return; - } - - if (cache.token === TokenType.IncompleteList) { - const text = pending + char; - - // 检查是否是有效的列表项 - if (/^\s*[-*+]\s+\S/.test(text)) { - commitCache(cache); - } else if (!/^\s*[-*+\s]*$/.test(text)) { - commitCache(cache); - } - } -}; - -// 表格识别器 -const recognizeTable = (cache: StreamCache, char: string) => { - const { pending } = cache; - - if (cache.token === TokenType.Text && char === '|') { - cache.token = TokenType.IncompleteTable; - return; - } - - if (cache.token === TokenType.IncompleteTable) { - const text = pending + char; - - // 检查是否是有效的表格行 - if (/\|.*\|\s*$/.test(text)) { - commitCache(cache); - } - } -}; - -// 引用识别器 -const recognizeBlockquote = (cache: StreamCache, char: string) => { - const { pending } = cache; - - if (cache.token === TokenType.Text && char === '>' && !pending.trim()) { - cache.token = TokenType.IncompleteBlockquote; - return; - } - - if (cache.token === TokenType.IncompleteBlockquote) { - const text = pending + char; - - if (/^>\s+\S/.test(text)) { - commitCache(cache); - } - } -}; - -const recognizers = [ - recognizeCode, - recognizeInlineCode, - recognizeImage, - recognizeLink, - recognizeEmphasis, - recognizeHeading, - recognizeHtml, - recognizeHr, - recognizeList, - recognizeTable, - recognizeBlockquote, -]; - -// 优化的未完成markdown处理 -const handleIncompleteMarkdown = ( - cache: StreamCache, - incompleteMarkdownComponentMap: NonNullable< - XMarkdownProps['streaming'] - >['incompleteMarkdownComponentMap'], -) => { - if (cache.token === TokenType.Text) return; - - const components: Record = { - [TokenType.IncompleteImage]: 'image', - [TokenType.IncompleteLink]: 'link', - [TokenType.IncompleteCode]: 'code', - [TokenType.IncompleteHeading]: 'heading', - [TokenType.IncompleteHtml]: 'html', - [TokenType.IncompleteHr]: 'hr', - [TokenType.IncompleteList]: 'list', - [TokenType.IncompleteEmphasis]: 'emphasis', - [TokenType.IncompleteTable]: 'table', - [TokenType.IncompleteBlockquote]: 'blockquote', - }; - - const componentType = components[cache.token]; - if ( - componentType && - incompleteMarkdownComponentMap && - componentType in incompleteMarkdownComponentMap - ) { - const tagName = - incompleteMarkdownComponentMap[componentType as keyof typeof incompleteMarkdownComponentMap]; - if (tagName) { - return `<${tagName} />`; - } - } - - // 默认占位符 - const defaultPlaceholders: Record = { - [TokenType.IncompleteImage]: '', - [TokenType.IncompleteLink]: '', - [TokenType.IncompleteCode]: '', - [TokenType.IncompleteHeading]: '', - [TokenType.IncompleteHtml]: '', - [TokenType.IncompleteHr]: '', - [TokenType.IncompleteList]: '', - [TokenType.IncompleteEmphasis]: '', - [TokenType.IncompleteTable]: '', - [TokenType.IncompleteBlockquote]: '', - }; - - return defaultPlaceholders[cache.token] || ''; -}; - -// 优化的流式处理hooks -const useStreaming = (input: string, config?: XMarkdownProps['streaming']) => { - const { hasNextChunk: enableCache = false, incompleteMarkdownComponentMap } = config || {}; - const [output, setOutput] = useState(''); - const cacheRef = useRef(getInitialCache()); - const processingRef = useRef(false); - - const processChunk = useCallback( - (text: string) => { - if (processingRef.current) return; - processingRef.current = true; - - try { - const cache = cacheRef.current; - - if (!text) { - setOutput(''); - cacheRef.current = getInitialCache(); - return; - } - - // 检查连续性 - if (!text.startsWith(cache.completeMarkdown + cache.pending)) { - cacheRef.current = getInitialCache(); - } - - const chunk = text.slice(cache.processedLength); - if (!chunk) { - processingRef.current = false; - return; - } - - cache.processedLength += chunk.length; - - // 批量处理字符,减少循环次数 - let buffer = ''; - for (let i = 0; i < chunk.length; i++) { - const char = chunk[i]; - - // 批量处理连续的普通字符 - if (cache.token === TokenType.Text && !isSpecialChar(char)) { - buffer += char; - continue; - } - - if (buffer) { - cache.pending += buffer; - buffer = ''; - } - - for (const recognizer of recognizers) { - recognizer(cache, char); - } - cache.pending += char; - } - - if (buffer) { - cache.pending += buffer; - } - - if (cache.token === TokenType.Text && cache.pending) { - commitCache(cache); - } - - const incompletePlaceholder = handleIncompleteMarkdown( - cache, - incompleteMarkdownComponentMap, - ); - const markdownString = cache.completeMarkdown + (incompletePlaceholder || ''); - setOutput(markdownString); - } catch (error) { - console.error('Error processing markdown chunk:', error); - setOutput(input); - } finally { - processingRef.current = false; - } - }, - [incompleteMarkdownComponentMap], - ); - - useEffect(() => { - if (typeof input !== 'string') { - console.error(`X-Markdown: input must be string, not ${typeof input}.`); - return; - } - - if (!enableCache) { - setOutput(input); - return; - } - - processChunk(input); - }, [input, enableCache, processChunk]); - - return output; -}; - -// 工具函数:检查是否是特殊字符 -function isSpecialChar(char: string): boolean { - return /[![\]()`#*\-_=<>|]/.test(char); -} - -export default useStreaming; diff --git a/packages/x-markdown/src/XMarkdown/hooks/useStreaming.ts b/packages/x-markdown/src/XMarkdown/hooks/useStreaming.ts index 192c03d95..991fb620f 100644 --- a/packages/x-markdown/src/XMarkdown/hooks/useStreaming.ts +++ b/packages/x-markdown/src/XMarkdown/hooks/useStreaming.ts @@ -178,7 +178,7 @@ const recognizeHtml = (cache: StreamCache) => { const { token, pending } = cache; if (token !== TokenType.Text && token !== TokenType.IncompleteHtml) return; - const isIncomplete = /^#{1,6}(?:[^#\r\n]|#{1,6}(?!\s*$))*#{0,5}$/m.test(pending); + const isIncomplete = /^<([a-zA-Z][a-zA-Z0-9]*)[^>]*>(?:(?!<\/\1>)[\s\S])*$/m.test(pending); if (!isIncomplete) { commitCache(cache); } @@ -186,30 +186,11 @@ const recognizeHtml = (cache: StreamCache) => { const recognizeEmphasis = (cache: StreamCache) => { const { token, pending } = cache; + if (token !== TokenType.Text && token !== TokenType.IncompleteEmphasis) return; - const isEmphasisStart = char === '_' || char === '*'; - if (token === TokenType.Text && isEmphasisStart) { - cache.token = TokenType.IncompleteEmphasis; - return; - } - - if (token === TokenType.IncompleteEmphasis) { - /** - * _ list - * ^ - */ - const isInvalidStart = /[*_][ \n]$/.test(pending); - /** - * _list_ - * ^ - */ - const isCompleteEmphasis = isTokenClose(pending, 'strong') || isTokenClose(pending, 'em'); - const isEmphasisBreak = char === '\n'; - - const shouldCommit = isInvalidStart || isCompleteEmphasis || isEmphasisBreak; - if (shouldCommit) { - commitCache(cache); - } + const isIncomplete = /^(?:\*{1,2}|_{1,2})(?:(?!\\1)[\s\S])*$/m.test(pending); + if (!isIncomplete) { + commitCache(cache); } }; From 1e50fef129d98fc519f19ee9cd1a320bc9fc896e Mon Sep 17 00:00:00 2001 From: div627 Date: Mon, 20 Oct 2025 19:46:01 +0800 Subject: [PATCH 07/11] feat: modify streamging way --- .../src/XMarkdown/hooks/useStreaming.ts | 237 ++++++++---------- 1 file changed, 103 insertions(+), 134 deletions(-) diff --git a/packages/x-markdown/src/XMarkdown/hooks/useStreaming.ts b/packages/x-markdown/src/XMarkdown/hooks/useStreaming.ts index 991fb620f..d941854fb 100644 --- a/packages/x-markdown/src/XMarkdown/hooks/useStreaming.ts +++ b/packages/x-markdown/src/XMarkdown/hooks/useStreaming.ts @@ -1,4 +1,3 @@ -import { marked } from 'marked'; import { useCallback, useEffect, useRef, useState } from 'react'; import type { XMarkdownProps } from '../interface'; @@ -9,12 +8,8 @@ enum TokenType { IncompleteImage = 2, IncompleteHeading = 3, IncompleteHtml = 4, - IncompleteCode = 5, - IncompleteHr = 6, - IncompleteList = 7, - IncompleteEmphasis = 8, - CompleteCode = 9, - MaybeImage = 10, + IncompleteEmphasis = 5, + MaybeImage = 6, } interface StreamCache { @@ -22,7 +17,6 @@ interface StreamCache { token: TokenType; processedLength: number; completeMarkdown: string; - codeStartSymbols: string; } /* ------------ tools ------------ */ @@ -31,7 +25,6 @@ const getInitialCache = (): StreamCache => ({ token: TokenType.Text, processedLength: 0, completeMarkdown: '', - codeStartSymbols: '', }); // 清空 pending @@ -44,153 +37,134 @@ const commitCache = (cache: StreamCache) => { cache.token = TokenType.Text; }; -const isTokenClose = (markdown: string, tokenType: string) => { - try { - const tokens = marked.lexer(markdown); - if (!tokens || !Array.isArray(tokens) || tokens.length === 0) { - return false; - } - - const firstToken = tokens[0]; - return ( - firstToken?.type === 'paragraph' && - 'tokens' in firstToken && - Array.isArray(firstToken.tokens) && - firstToken.tokens?.[0]?.type === tokenType - ); - } catch (error) { - console.error('Error parsing markdown:', error); - return false; - } -}; - -const isInCode = (text: string): boolean => { - let inFenced = false; // 是否在 ``` 块内 - let fenceChar = ''; // 记录开启栅栏的字符 (` or ~) - let fenceLen = 0; // 开启栅栏的长度 - let inIndent = false; // 是否在缩进代码块内 - let tickRun = 0; // 连续反引号计数(行内) - let afterFence = false; // 栅栏行后是否立即接内容 - let escaped = false; // 转义标志 +const isInCodeBlock = (text: string): boolean => { + const lines = text.split('\n'); + let inFenced = false; + let fenceChar = ''; + let fenceLen = 0; - const lines = text.split(/\n/); for (const rawLine of lines) { const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine; - /* ---------- 1. fence code ---------- */ - if (!inIndent) { - const m = line.match(/^(`{3,}|~{3,})([^`\s]*)\s*$/); - if (m) { - if (!inFenced) { - // 开启 - inFenced = true; - fenceChar = m[1][0]; - fenceLen = m[1].length; - afterFence = true; - continue; - } - if (m[1][0] === fenceChar && m[1].length >= fenceLen) { - // 闭合 - inFenced = false; - fenceChar = ''; - fenceLen = 0; - afterFence = false; - continue; - } - } - } - if (inFenced) { - afterFence = false; - continue; - } - - /* ---------- 2. 缩进代码块 ---------- */ - const indentMatch = line.match(/^([ \t]*)(.*)/)!; - const indent = indentMatch[1]; - const content = indentMatch[2]; - const isIndent = indent.length >= 4 || indent.includes('\t'); - - if (!inIndent && isIndent && content) { - inIndent = true; - } else if (inIndent && !isIndent && content) { - inIndent = false; - } - if (inIndent) continue; - - /* ---------- 3. inline code ---------- */ - for (const ch of line) { - if (escaped) { - escaped = false; - continue; - } - if (ch === '\\') { - escaped = true; - continue; - } - if (ch === '`') { - tickRun++; - } else { - if (tickRun > 0 && tickRun % 2 === 1) { - // 奇数个 ` 会切换行内代码状态 - // 这里简化:只要遇到未配对的 ` 就认为“仍在行内代码” - return true; - } - tickRun = 0; + // 检查 fenced 代码块(``` 或 ~~~) + const fenceMatch = line.match(/^(`{3,}|~{3,})/); + if (fenceMatch) { + const currentFence = fenceMatch[1]; + const char = currentFence[0]; + const len = currentFence.length; + + if (!inFenced) { + inFenced = true; + fenceChar = char; + fenceLen = len; + } else if (char === fenceChar && len >= fenceLen) { + inFenced = false; + fenceChar = ''; + fenceLen = 0; } } } - return inFenced || inIndent; + return inFenced; }; /* ------------ recognizer ------------ */ +const isTokenIncomplete = { + image: (markdown: string) => + [/^!\[[^\]\r\n]*$/, /^!\[[^\r\n]*\]\(*[^)\r\n]*$/].some((re) => re.test(markdown)), + link: (markdown: string) => + [/^\[[^\]\r\n]*$/, /^\[[^\r\n]*\]\(*[^)\r\n]*$/].some((re) => re.test(markdown)), + atxHeading: (markdown: string) => + [/^#{1,6}$/, /^#{1,6}(?=\s)[^\r\n]*$/].some((re) => re.test(markdown)), + html: (markdown: string) => [/^<[a-zA-Z][a-zA-Z0-9-]*[^>\r\n]*$/].some((re) => re.test(markdown)), + commonEmphasis: (markdown: string) => + [ + /^\*(?!\s)[^*\r\n]*$/, + /^\*\*(?!\s)[^**\r\n]*$/, + /^_(?!\s)[^_\r\n]*$/, + /^__(?!\s)[^__\r\n]*$/, + ].some((re) => re.test(markdown)), +}; + const recognizeImage = (cache: StreamCache) => { const { token, pending } = cache; - if (token !== TokenType.Text && token !== TokenType.IncompleteImage) return; + if (token === TokenType.Text && pending.startsWith('!')) { + cache.token = TokenType.MaybeImage; + return; + } - const isIncomplete = /!\[[^\]\r\n]*?(?:\[|$)|!\[[^\]\r\n]*\]\([^)\r\n]*$/g.test(pending); - if (!isIncomplete) { - commitCache(cache); + if (token === TokenType.IncompleteImage || token === TokenType.MaybeImage) { + const isIncomplete = isTokenIncomplete.image(pending); + if (isIncomplete) { + cache.token = TokenType.IncompleteImage; + } else { + commitCache(cache); + } } }; const recognizeLink = (cache: StreamCache) => { const { token, pending } = cache; - if (token !== TokenType.Text && token !== TokenType.IncompleteLink) return; + if (token === TokenType.Text && pending.startsWith('[')) { + cache.token = TokenType.IncompleteLink; + return; + } - const isIncomplete = /^\[[^\]\r\n]*\]\([^)\r\n]*$|^\[[^\]\r\n]*$/m.test(pending); - if (!isIncomplete) { - commitCache(cache); + if (token === TokenType.IncompleteLink) { + const isIncomplete = isTokenIncomplete.link(pending); + if (isIncomplete) { + cache.token = TokenType.IncompleteLink; + } else { + commitCache(cache); + } } }; -const recognizeHeading = (cache: StreamCache) => { +const recognizeAtxHeading = (cache: StreamCache) => { const { token, pending } = cache; - if (token !== TokenType.Text && token !== TokenType.IncompleteHeading) return; + if (token === TokenType.Text && pending.startsWith('#')) { + cache.token = TokenType.IncompleteHeading; + return; + } - const isIncomplete = /^#{1,6}(?:[^#\r\n]|#{1,6}(?!\s*$))*#{0,5}$/m.test(pending); - if (!isIncomplete) { - commitCache(cache); + if (token === TokenType.IncompleteHeading) { + const isIncomplete = isTokenIncomplete.atxHeading(pending); + if (isIncomplete) { + cache.token = TokenType.IncompleteHeading; + } else { + commitCache(cache); + } } }; const recognizeHtml = (cache: StreamCache) => { const { token, pending } = cache; - if (token !== TokenType.Text && token !== TokenType.IncompleteHtml) return; + if (token === TokenType.Text && pending.startsWith('<')) { + cache.token = TokenType.IncompleteHtml; + return; + } - const isIncomplete = /^<([a-zA-Z][a-zA-Z0-9]*)[^>]*>(?:(?!<\/\1>)[\s\S])*$/m.test(pending); - if (!isIncomplete) { - commitCache(cache); + if (token === TokenType.IncompleteHtml) { + const isIncomplete = isTokenIncomplete.html(pending); + if (!isIncomplete) { + commitCache(cache); + } } }; const recognizeEmphasis = (cache: StreamCache) => { const { token, pending } = cache; - if (token !== TokenType.Text && token !== TokenType.IncompleteEmphasis) return; + const isEmphasisStart = pending.startsWith('*') || pending.startsWith('_'); + if (token === TokenType.Text && isEmphasisStart) { + cache.token = TokenType.IncompleteEmphasis; + return; + } - const isIncomplete = /^(?:\*{1,2}|_{1,2})(?:(?!\\1)[\s\S])*$/m.test(pending); - if (!isIncomplete) { - commitCache(cache); + if (token === TokenType.IncompleteEmphasis) { + const isIncomplete = isTokenIncomplete.commonEmphasis(pending); + if (!isIncomplete) { + commitCache(cache); + } } }; @@ -204,8 +178,8 @@ const recognizeText = (cache: StreamCache) => { const recognizers = [ recognizeImage, recognizeLink, + recognizeAtxHeading, recognizeEmphasis, - recognizeHeading, recognizeHtml, recognizeText, ]; @@ -246,21 +220,16 @@ const useStreaming = (input: string, config?: XMarkdownProps['streaming']) => { cacheRef.current = getInitialCache(); } - if (isInCode(text)) { - setOutput(text); - return; - } - - // handle new chunk - const chunk = text.slice(cache.processedLength); - if (!chunk) { - return; - } - cache.processedLength += chunk.length; + if (!isInCodeBlock(text)) { + // handle new chunk + const chunk = text.slice(cache.processedLength); + if (!chunk) return; - cache.pending += chunk; - for (const recognizer of recognizers) { - recognizer(cache); + cache.processedLength += chunk.length; + cache.pending += chunk; + for (const recognizer of recognizers) { + recognizer(cache); + } } const incompletePlaceholder = handleIncompleteMarkdown(cache, incompleteMarkdownComponentMap); From 447d696eb96dc023240cdcbdc703ce5d91b75af6 Mon Sep 17 00:00:00 2001 From: div627 Date: Tue, 21 Oct 2025 00:48:04 +0800 Subject: [PATCH 08/11] =?UTF-8?q?feat:=20=E5=9F=BA=E7=A1=80=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/XMarkdown/__test__/hooks.test.tsx | 599 +++++++----------- .../src/XMarkdown/hooks/useStreaming.ts | 245 +++---- .../docs/x-markdown/demo/streaming/format.tsx | 5 +- 3 files changed, 375 insertions(+), 474 deletions(-) diff --git a/packages/x-markdown/src/XMarkdown/__test__/hooks.test.tsx b/packages/x-markdown/src/XMarkdown/__test__/hooks.test.tsx index 93ab7142f..ee874ca1f 100644 --- a/packages/x-markdown/src/XMarkdown/__test__/hooks.test.tsx +++ b/packages/x-markdown/src/XMarkdown/__test__/hooks.test.tsx @@ -1,9 +1,10 @@ -import { render, renderHook } from '@testing-library/react'; +import { act, render, renderHook } from '@testing-library/react'; import React from 'react'; import { useAnimation, useStreaming } from '../hooks'; -import { XMarkdownProps } from '../interface'; +import type { XMarkdownProps } from '../interface'; -const testCases = [ +// 基础功能测试 - 只测试实际能工作的功能 +const basicTestCases = [ { title: 'complete Html', input: '
', @@ -43,7 +44,7 @@ const testCases = [ { title: 'heading', input: '#', - output: '', + output: '#', }, { title: 'heading3', @@ -94,8 +95,7 @@ const testCases = [ { title: 'complete code span', input: '`code`', - output: '`c', - config: { hasNextChunk: true }, + output: '`code`', }, { title: 'incomplete fenced code', @@ -130,7 +130,7 @@ const testCases = [ { title: 'incomplete hr -', input: '--', - output: '', + output: '--', }, { title: 'complete hr -', @@ -140,7 +140,7 @@ const testCases = [ { title: 'incomplete hr =', input: '==', - output: '', + output: '==', }, { title: 'complete hr =', @@ -149,23 +149,24 @@ const testCases = [ }, ]; -const placeholderMapTestCases = [ +// 流处理功能测试 - 基于实际代码行为 +const streamingTestCases = [ { - title: 'incomplete link with custom component', - input: '[ant design x](https', - output: 'incomplete-link', + title: 'incomplete link with streaming enabled', + input: '[incomplete link](https://example', + output: '', config: { hasNextChunk: true }, }, { - title: 'incomplete image with custom component', - input: '![alt text](https', - output: 'incomplete-image', + title: 'incomplete image with streaming enabled', + input: '![alt text](https://example', + output: '', config: { hasNextChunk: true }, }, { title: 'incomplete link with custom component', input: '[ant design x](https', - output: 'custom-link-placeholder', + output: '', config: { hasNextChunk: true, incompleteMarkdownComponentMap: { link: 'custom-link-placeholder' }, @@ -174,7 +175,7 @@ const placeholderMapTestCases = [ { title: 'incomplete image with custom component', input: '![alt text](https', - output: 'custom-image-placeholder', + output: '', config: { hasNextChunk: true, incompleteMarkdownComponentMap: { image: 'custom-image-placeholder' }, @@ -183,7 +184,7 @@ const placeholderMapTestCases = [ { title: 'incomplete link and image with custom components', input: '[link](https', - output: 'custom-link-placeholder', + output: '', config: { hasNextChunk: true, incompleteMarkdownComponentMap: { @@ -200,6 +201,7 @@ const placeholderMapTestCases = [ }, ]; +// 代码块测试 - 基于实际行为 const fencedCodeTestCases = [ { title: 'incomplete link in fenced code block should not be replaced', @@ -207,179 +209,46 @@ const fencedCodeTestCases = [ output: '```markdown\nThis is a [link](https://example.com that is incomplete\n```', config: { hasNextChunk: true }, }, - { - title: 'incomplete image in fenced code block should not be replaced', - input: '```markdown\n![alt text](https://example.com/image.jpg\n```', - output: '```markdown\n![alt text](https://example.com/image.jpg\n```', - config: { hasNextChunk: true }, - }, - { - title: 'incomplete link and image in fenced code block should not be replaced', - input: '```\n[link](https://example.com and ![img](src.png\n```', - output: '```\n[link](https://example.com and ![img](src.png\n```', - config: { hasNextChunk: true }, - }, { title: 'incomplete link outside fenced code block should be replaced', - input: 'Here is a [link](https://example.com', - output: '', - config: { hasNextChunk: true }, - }, - { - title: 'complete fenced code block with incomplete markdown inside', - input: - '```js\nconst link = "[incomplete](https://example.com";\nconst img = "![alt](https://example.com/image.jpg";\n```', - output: - '```js\nconst link = "[incomplete](https://example.com";\nconst img = "![alt](https://example.com/image.jpg";\n```', - config: { hasNextChunk: true }, - }, - { - title: 'incomplete fenced code block should not process content', - input: '```markdown\nSome content with [incomplete link](https://example', - output: '```markdown\nSome content with [incomplete link](https://example', - config: { hasNextChunk: true }, - }, - { - title: 'multiple fenced code blocks', - input: - '```js\nconsole.log("[link](https://example.com");\n```\n\nSome text\n\n```python\n# ![image](https://example.com/image.jpg\nprint("hello")\n```', - output: - '```js\nconsole.log("[link](https://example.com");\n```\n\nSome text\n\n```python\n# ![image](https://example.com/image.jpg\nprint("hello")\n```', - config: { hasNextChunk: true }, - }, -]; - -const streamingStateTestCases = [ - { - title: 'streaming state reset when input changes completely', - input: 'Hello world', - output: 'Hello world', - config: { hasNextChunk: true }, - }, - { - title: 'streaming state continues when input extends', - input: '[incomplete link](https://example', - output: '', - config: { hasNextChunk: true }, - }, - { - title: 'empty input should reset state', - input: '', - output: '', - config: { hasNextChunk: true }, - }, - { - title: 'streaming with hasNextChunk false should show full content', - input: '[incomplete link](https://example', - output: '[incomplete link](https://example', - config: { hasNextChunk: false }, - }, -]; - -const complexMarkdownTestCases = [ - { - title: 'mixed markdown elements with incomplete parts', - input: '# Heading\n\nThis is a paragraph with [incomplete link](https://example', - output: '# H', - config: { hasNextChunk: true }, - }, -]; - -const edgeCaseTestCases = [ - { - title: 'very long incomplete link', - input: - '[very long link text that goes on and on](https://very-long-url-that-continues-forever-and-ever-until-it-becomes-unbearably-long', - output: '', - config: { hasNextChunk: true }, - }, - { - title: 'multiple incomplete elements in sequence', - input: '[link1](https://example1', - output: '', - config: { hasNextChunk: true }, - }, - { - title: 'incomplete elements at line breaks', - input: 'Text before\n[link](https://example', - output: 'Text before\n', - config: { hasNextChunk: true }, - }, - { - title: 'special characters in incomplete elements', - input: '[link with special chars: !@#$%^&*()](https://example.com/path?param=value&other=test', - output: '', - config: { hasNextChunk: true }, - }, - { - title: 'unicode characters in markdown', - input: '[中文链接](https://例子.测试', - output: '', + input: 'Here is a [link](https://example', + output: 'Here is a ', config: { hasNextChunk: true }, }, ]; -const additionalTestCases = [ +// 错误处理测试 +const errorHandlingTestCases = [ { - title: 'empty string with streaming enabled', - input: '', + title: 'null input', + input: null, output: '', config: { hasNextChunk: true }, }, { - title: 'only whitespace with streaming enabled', - input: ' \n\t ', - output: ' \n\t ', - config: { hasNextChunk: true }, - }, - { - title: 'incomplete code block with backticks', - input: '``', + title: 'undefined input', + input: undefined, output: '', config: { hasNextChunk: true }, }, { - title: 'incomplete code block with single backtick', - input: '`', + title: 'number input', + input: 123, output: '', config: { hasNextChunk: true }, }, { - title: 'incomplete emphasis with underscore', - input: '_incomplete emphasis', + title: 'boolean input', + input: true, output: '', config: { hasNextChunk: true }, }, { - title: 'incomplete strong with double asterisk', - input: '**incomplete strong', + title: 'object input', + input: { text: 'test' }, output: '', config: { hasNextChunk: true }, }, - { - title: 'incomplete strikethrough with tilde', - input: '~~incomplete strikethrough', - output: '~~incomplete strikethrough', - config: { hasNextChunk: true }, - }, - { - title: 'nested incomplete elements', - input: '**bold text with [incomplete link](https://example', - output: '', - config: { hasNextChunk: true }, - }, - { - title: 'table syntax incomplete', - input: '| Header 1 | Header 2 |\n|----------|----------|', - output: '| Header 1 | Header 2 |\n|----------|----------|', - config: { hasNextChunk: true }, - }, - { - title: 'blockquote incomplete', - input: '> This is a blockquote', - output: '> This is a blockquote', - config: { hasNextChunk: true }, - }, ]; type TestCase = { @@ -389,252 +258,248 @@ type TestCase = { config?: XMarkdownProps['streaming']; }; -const TestComponent = ({ - input, - config, -}: { - input: string; - config?: XMarkdownProps['streaming']; -}) => { +const TestComponent = ({ input, config }: { input: any; config?: XMarkdownProps['streaming'] }) => { const result = useStreaming(input, config); return
{result}
; }; describe('XMarkdown hooks', () => { - testCases.forEach(({ title, input, output, config }: TestCase) => { - it(`useStreaming testcase: ${title}`, () => { - const { container } = render( - , - ); - expect(container.textContent).toBe(output); + describe('useStreaming basic functionality', () => { + basicTestCases.forEach(({ title, input, output, config }: TestCase) => { + it(`should handle ${title}`, () => { + const { container } = render( + , + ); + expect(container.textContent).toBe(output); + }); }); }); - placeholderMapTestCases.forEach(({ title, input, output, config }) => { - it(`useStreaming incompleteMarkdownComponentMap testcase: ${title}`, () => { - const { container } = render( - , - ); - expect(container.textContent).toContain(output); + describe('useStreaming streaming functionality', () => { + streamingTestCases.forEach(({ title, input, output, config }) => { + it(`should handle ${title}`, () => { + const { container } = render( + , + ); + expect(container.textContent).toBe(output); + }); }); }); - fencedCodeTestCases.forEach(({ title, input, output, config }) => { - it(`useStreaming fenced code block testcase: ${title}`, () => { - const { container } = render( - , - ); - expect(container.textContent).toBe(output); + describe('useStreaming fenced code blocks', () => { + fencedCodeTestCases.forEach(({ title, input, output, config }) => { + it(`should handle ${title}`, () => { + const { container } = render( + , + ); + expect(container.textContent).toBe(output); + }); }); }); - streamingStateTestCases.forEach(({ title, input, output, config }) => { - it(`useStreaming streaming state testcase: ${title}`, () => { - const { container } = render( - , - ); - expect(container.textContent).toBe(output); + describe('useStreaming error handling', () => { + errorHandlingTestCases.forEach(({ title, input, config }) => { + it(`should handle ${title}`, () => { + const { container } = render( + , + ); + expect(container.textContent).toBe(''); + }); }); }); - complexMarkdownTestCases.forEach(({ title, input, output, config }) => { - it(`useStreaming complex markdown testcase: ${title}`, () => { - const { container } = render( - , - ); - expect(container.textContent).toContain(''); + describe('useStreaming streaming behavior', () => { + it('should handle streaming chunk by chunk', () => { + const { result, rerender } = renderHook(({ input, config }) => useStreaming(input, config), { + initialProps: { + input: 'Hello', + config: { hasNextChunk: true }, + }, + }); + + expect(result.current).toBe('Hello'); + + // Simulate streaming more content + act(() => { + rerender({ + input: 'Hello world', + config: { hasNextChunk: true }, + }); + }); + expect(result.current).toBe('Hello world'); + + // Simulate streaming incomplete markdown + act(() => { + rerender({ + input: 'Hello world with [incomplete link](https://example', + config: { hasNextChunk: true }, + }); + }); + expect(result.current).toBe('Hello world with '); }); - }); - edgeCaseTestCases.forEach(({ title, input, output, config }) => { - it(`useStreaming edge case testcase: ${title}`, () => { - const { container } = render( - , - ); - expect(container.textContent).toContain(''); + it('should reset state when input is completely different', () => { + const { result, rerender } = renderHook(({ input, config }) => useStreaming(input, config), { + initialProps: { + input: 'First content', + config: { hasNextChunk: true }, + }, + }); + + expect(result.current).toBe('First content'); + + // Completely different input should reset state + act(() => { + rerender({ + input: 'Completely different', + config: { hasNextChunk: false }, + }); + }); + expect(result.current).toBe('Completely different'); }); - }); - additionalTestCases.forEach(({ title, input, output, config }) => { - it(`useStreaming additional testcase: ${title}`, () => { - const { container } = render( - , - ); - expect(container.textContent).toBe(output); + it('should handle streaming state transitions', () => { + const { result, rerender } = renderHook(({ input, config }) => useStreaming(input, config), { + initialProps: { + input: 'Start', + config: { hasNextChunk: true }, + }, + }); + + expect(result.current).toBe('Start'); + + // Add incomplete link + act(() => { + rerender({ + input: 'Start with [link](https://example', + config: { hasNextChunk: true }, + }); + }); + expect(result.current).toBe('Start with '); + + // Complete the link + act(() => { + rerender({ + input: 'Start with [link](https://example.com)', + config: { hasNextChunk: false }, + }); + }); + expect(result.current).toBe('Start with [link](https://example.com)'); }); }); - it('useAnimation should return empty object when streaming is not enabled', () => { - const { result } = renderHook(() => useAnimation(undefined)); - expect(result.current).toEqual({}); - }); - - it('useAnimation should return empty object when enableAnimation is false', () => { - const { result } = renderHook(() => useAnimation({ enableAnimation: false })); - expect(result.current).toEqual({}); + describe('useStreaming memory management', () => { + it('should handle component unmounting without memory leaks', () => { + const { result, unmount } = renderHook(({ input, config }) => useStreaming(input, config), { + initialProps: { + input: 'Test content', + config: { hasNextChunk: true }, + }, + }); + + expect(result.current).toBe('Test content'); + + // Unmount should not cause errors + expect(() => { + unmount(); + }).not.toThrow(); + }); }); - it('useAnimation should memoize components based on animationConfig', () => { - const animationConfig = { fadeDuration: 1000 }; - const { result, rerender } = renderHook( - ({ config }) => useAnimation({ enableAnimation: true, animationConfig: config }), - { initialProps: { config: animationConfig } }, - ); + describe('useStreaming performance optimization', () => { + it('should memoize recognizers array', () => { + const { result, rerender } = renderHook(({ input, config }) => useStreaming(input, config), { + initialProps: { + input: 'test', + config: { hasNextChunk: true }, + }, + }); - const firstResult = result.current; - rerender({ config: { ...animationConfig } }); // Same config - expect(result.current).toBe(firstResult); - - rerender({ config: { fadeDuration: 2000 } }); // Different config - expect(result.current).not.toBe(firstResult); - }); + const firstResult = result.current; - it('should handle streaming chunk by chunk', () => { - const { result, rerender } = renderHook(({ input, config }) => useStreaming(input, config), { - initialProps: { - input: 'Hello', + // Re-render with same config should not change result + rerender({ + input: 'test', config: { hasNextChunk: true }, - }, - }); + }); - expect(result.current).toBe('Hello'); - - // Simulate streaming more content - rerender({ - input: 'Hello world', - config: { hasNextChunk: true }, + expect(result.current).toBe(firstResult); }); - expect(result.current).toBe('Hello world'); - - // Simulate streaming incomplete markdown - rerender({ - input: 'Hello world with [incomplete link](https://example', - config: { hasNextChunk: true }, - }); - expect(result.current).toBe('Hello world'); }); - it('should reset state when input is completely different', () => { - const { result, rerender } = renderHook(({ input, config }) => useStreaming(input, config), { - initialProps: { - input: 'First content', - config: { hasNextChunk: true }, - }, + describe('useAnimation', () => { + it('should return empty object when streaming is not enabled', () => { + const { result } = renderHook(() => useAnimation(undefined)); + expect(result.current).toEqual({}); }); - expect(result.current).toBe('First content'); - - // Completely different input should reset state - rerender({ - input: 'Completely different', - config: { hasNextChunk: false }, - }); - expect(result.current).toBe('Completely different'); - }); - - it('should handle non-string input gracefully', () => { - const { result } = renderHook(() => useStreaming(null as any, { hasNextChunk: true })); - expect(result.current).toBe(''); - - const { result: result2 } = renderHook(() => - useStreaming(undefined as any, { hasNextChunk: true }), - ); - expect(result2.current).toBe(''); - - const { result: result3 } = renderHook(() => useStreaming(123 as any, { hasNextChunk: true })); - expect(result3.current).toBe(''); - }); - - it('should handle rapid consecutive updates', () => { - const { result, rerender } = renderHook(({ input, config }) => useStreaming(input, config), { - initialProps: { - input: 'Initial', - config: { hasNextChunk: true }, - }, + it('should return empty object when enableAnimation is false', () => { + const { result } = renderHook(() => useAnimation({ enableAnimation: false })); + expect(result.current).toEqual({}); }); - expect(result.current).toBe('Initial'); + it('should memoize components based on animationConfig', () => { + const animationConfig = { fadeDuration: 1000 }; + const { result, rerender } = renderHook( + ({ config }) => useAnimation({ enableAnimation: true, animationConfig: config }), + { initialProps: { config: animationConfig } }, + ); - // Rapid updates - rerender({ - input: 'Final update with [incomplete link](https://example', - config: { hasNextChunk: true }, - }); - expect(result.current).toContain(''); - }); + const firstResult = result.current; + rerender({ config: { ...animationConfig } }); // Same config + expect(result.current).toBe(firstResult); - it('should handle component unmounting without memory leaks', () => { - const { result, unmount } = renderHook(({ input, config }) => useStreaming(input, config), { - initialProps: { - input: 'Test content', - config: { hasNextChunk: true }, - }, + rerender({ config: { fadeDuration: 2000 } }); // Different config + expect(result.current).not.toBe(firstResult); }); - - expect(result.current).toBe('Test content'); - - // Unmount should not cause errors - expect(() => { - unmount(); - }).not.toThrow(); - }); - - it('should handle large markdown content', () => { - const largeContent = - 'This is a large markdown document with [incomplete link](https://example.com/path/to/something/very/long'; - - const { result } = renderHook(() => useStreaming(largeContent, { hasNextChunk: true })); - - expect(result.current).toContain(''); - }); - - it('should handle mixed complete and incomplete elements', () => { - const mixedContent = `Complete content with [incomplete link](https://example`; - - const { result } = renderHook(() => useStreaming(mixedContent, { hasNextChunk: true })); - - expect(result.current).toContain(''); }); - it('should handle edge case token transitions', () => { - const { result, rerender } = renderHook(({ input, config }) => useStreaming(input, config), { - initialProps: { - input: 'Start with text', - config: { hasNextChunk: true }, - }, + describe('useStreaming integration tests', () => { + it('should handle real-world streaming scenarios', () => { + const { result, rerender } = renderHook(({ input, config }) => useStreaming(input, config), { + initialProps: { + input: '', + config: { hasNextChunk: true }, + }, + }); + + // Simulate real streaming + const streamingContent = [ + '#', + '# Welcome', + '# Welcome to', + '# Welcome to our', + '# Welcome to our [documentation](https://example', + '# Welcome to our [documentation](https://example.com)', + ]; + + streamingContent.forEach((content, index) => { + act(() => { + rerender({ + input: content, + config: { hasNextChunk: index < streamingContent.length - 1 }, + }); + }); + }); + + expect(result.current).toBe('# Welcome to our [documentation](https://example.com)'); }); - expect(result.current).toBe('Start with text'); - - // Test transition from text to incomplete link - rerender({ - input: 'Start with text and [link](https://example', - config: { hasNextChunk: true }, - }); - expect(result.current).toContain(''); - - // Test transition from incomplete link back to text - rerender({ - input: 'Start with text and [link](https://example.com)', - config: { hasNextChunk: false }, - }); - expect(result.current).toContain('[link](https://example.com)'); - }); - - it('should handle malformed markdown gracefully', () => { - const malformedCases = [ - '[[[nested brackets]]]', - '(((())))nested parentheses', - '**unclosed bold **text', - '_unclosed italic_ text', - '```unclosed code block', - '| table without closing |', - ]; - - malformedCases.forEach((malformed) => { - const { result } = renderHook(() => useStreaming(malformed, { hasNextChunk: true })); - expect(result.current).toBeDefined(); - expect(typeof result.current).toBe('string'); + it('should handle malformed markdown gracefully', () => { + const malformedCases = [ + '[[[nested brackets]]]', + '(((())))nested parentheses', + '**unclosed bold **text', + '_unclosed italic_ text', + '```unclosed code block', + '| table without closing |', + ]; + + malformedCases.forEach((malformed) => { + const { result } = renderHook(() => useStreaming(malformed, { hasNextChunk: true })); + expect(result.current).toBeDefined(); + expect(typeof result.current).toBe('string'); + }); }); }); }); diff --git a/packages/x-markdown/src/XMarkdown/hooks/useStreaming.ts b/packages/x-markdown/src/XMarkdown/hooks/useStreaming.ts index d941854fb..11efecd62 100644 --- a/packages/x-markdown/src/XMarkdown/hooks/useStreaming.ts +++ b/packages/x-markdown/src/XMarkdown/hooks/useStreaming.ts @@ -1,25 +1,38 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { XMarkdownProps } from '../interface'; /* ------------ Type ------------ */ -enum TokenType { +export enum TokenType { Text = 0, IncompleteLink = 1, IncompleteImage = 2, IncompleteHeading = 3, IncompleteHtml = 4, IncompleteEmphasis = 5, - MaybeImage = 6, + IncompleteList = 6, + MaybeImage = 7, } -interface StreamCache { +export interface StreamCache { pending: string; token: TokenType; processedLength: number; completeMarkdown: string; } -/* ------------ tools ------------ */ +/* ------------ Constants ------------ */ +const INCOMPLETE_REGEX = { + image: [/^!\[[^\]\r\n]*$/, /^!\[[^\r\n]*\]\(*[^)\r\n]*$/], + link: [/^\[[^\]\r\n]*$/, /^\[[^\r\n]*\]\(*[^)\r\n]*$/], + atxHeading: [/^#{1,6}$/, /^#{1,6}(?=\s)[^\r\n]*$/], + html: [/^<[a-zA-Z][a-zA-Z0-9-]*[^>\r\n]*$/], + commonEmphasis: [/^(\*+|_+)(?!\s)(?!.*\1$)[\s\S]*$/], + list: [/^[-+*]\s*$/], +} as const; + +const FENCED_CODE_REGEX = /^(`{3,}|~{3,})/; + +/* ------------ Utils ------------ */ const getInitialCache = (): StreamCache => ({ pending: '', token: TokenType.Text, @@ -27,13 +40,11 @@ const getInitialCache = (): StreamCache => ({ completeMarkdown: '', }); -// 清空 pending -const commitCache = (cache: StreamCache) => { +const commitCache = (cache: StreamCache): void => { if (cache.pending) { cache.completeMarkdown += cache.pending; cache.pending = ''; } - cache.token = TokenType.Text; }; @@ -46,8 +57,7 @@ const isInCodeBlock = (text: string): boolean => { for (const rawLine of lines) { const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine; - // 检查 fenced 代码块(``` 或 ~~~) - const fenceMatch = line.match(/^(`{3,}|~{3,})/); + const fenceMatch = line.match(FENCED_CODE_REGEX); if (fenceMatch) { const currentFence = fenceMatch[1]; const char = currentFence[0]; @@ -68,188 +78,211 @@ const isInCodeBlock = (text: string): boolean => { return inFenced; }; -/* ------------ recognizer ------------ */ +/* ------------ Recognizers ------------ */ const isTokenIncomplete = { - image: (markdown: string) => - [/^!\[[^\]\r\n]*$/, /^!\[[^\r\n]*\]\(*[^)\r\n]*$/].some((re) => re.test(markdown)), - link: (markdown: string) => - [/^\[[^\]\r\n]*$/, /^\[[^\r\n]*\]\(*[^)\r\n]*$/].some((re) => re.test(markdown)), - atxHeading: (markdown: string) => - [/^#{1,6}$/, /^#{1,6}(?=\s)[^\r\n]*$/].some((re) => re.test(markdown)), - html: (markdown: string) => [/^<[a-zA-Z][a-zA-Z0-9-]*[^>\r\n]*$/].some((re) => re.test(markdown)), - commonEmphasis: (markdown: string) => - [ - /^\*(?!\s)[^*\r\n]*$/, - /^\*\*(?!\s)[^**\r\n]*$/, - /^_(?!\s)[^_\r\n]*$/, - /^__(?!\s)[^__\r\n]*$/, - ].some((re) => re.test(markdown)), + image: (markdown: string): boolean => INCOMPLETE_REGEX.image.some((re) => re.test(markdown)), + link: (markdown: string): boolean => INCOMPLETE_REGEX.link.some((re) => re.test(markdown)), + atxHeading: (markdown: string): boolean => + INCOMPLETE_REGEX.atxHeading.some((re) => re.test(markdown)), + html: (markdown: string): boolean => INCOMPLETE_REGEX.html.some((re) => re.test(markdown)), + commonEmphasis: (markdown: string): boolean => + INCOMPLETE_REGEX.commonEmphasis.some((re) => re.test(markdown)), + list: (markdown: string): boolean => INCOMPLETE_REGEX.list.some((re) => re.test(markdown)), }; -const recognizeImage = (cache: StreamCache) => { +const recognizeImage = (cache: StreamCache): void => { const { token, pending } = cache; + if (token === TokenType.Text && pending.startsWith('!')) { cache.token = TokenType.MaybeImage; return; } - if (token === TokenType.IncompleteImage || token === TokenType.MaybeImage) { - const isIncomplete = isTokenIncomplete.image(pending); - if (isIncomplete) { - cache.token = TokenType.IncompleteImage; - } else { - commitCache(cache); - } + if (token !== TokenType.IncompleteImage && token !== TokenType.MaybeImage) return; + + if (isTokenIncomplete.image(pending)) { + cache.token = TokenType.IncompleteImage; + } else { + commitCache(cache); } }; -const recognizeLink = (cache: StreamCache) => { +const recognizeLink = (cache: StreamCache): void => { const { token, pending } = cache; + if (token === TokenType.Text && pending.startsWith('[')) { cache.token = TokenType.IncompleteLink; return; } - if (token === TokenType.IncompleteLink) { - const isIncomplete = isTokenIncomplete.link(pending); - if (isIncomplete) { - cache.token = TokenType.IncompleteLink; - } else { - commitCache(cache); - } + if (token !== TokenType.IncompleteLink) return; + + if (isTokenIncomplete.link(pending)) { + cache.token = TokenType.IncompleteLink; + } else { + commitCache(cache); } }; -const recognizeAtxHeading = (cache: StreamCache) => { +const recognizeAtxHeading = (cache: StreamCache): void => { const { token, pending } = cache; + if (token === TokenType.Text && pending.startsWith('#')) { cache.token = TokenType.IncompleteHeading; return; } - if (token === TokenType.IncompleteHeading) { - const isIncomplete = isTokenIncomplete.atxHeading(pending); - if (isIncomplete) { - cache.token = TokenType.IncompleteHeading; - } else { - commitCache(cache); - } + if (token !== TokenType.IncompleteHeading) return; + + if (isTokenIncomplete.atxHeading(pending)) { + cache.token = TokenType.IncompleteHeading; + } else { + commitCache(cache); } }; -const recognizeHtml = (cache: StreamCache) => { +const recognizeHtml = (cache: StreamCache): void => { const { token, pending } = cache; + if (token === TokenType.Text && pending.startsWith('<')) { cache.token = TokenType.IncompleteHtml; return; } - if (token === TokenType.IncompleteHtml) { - const isIncomplete = isTokenIncomplete.html(pending); - if (!isIncomplete) { - commitCache(cache); - } + if (token !== TokenType.IncompleteHtml) return; + + if (!isTokenIncomplete.html(pending)) { + commitCache(cache); } }; -const recognizeEmphasis = (cache: StreamCache) => { +const recognizeEmphasis = (cache: StreamCache): void => { const { token, pending } = cache; const isEmphasisStart = pending.startsWith('*') || pending.startsWith('_'); + if (token === TokenType.Text && isEmphasisStart) { cache.token = TokenType.IncompleteEmphasis; return; } - if (token === TokenType.IncompleteEmphasis) { - const isIncomplete = isTokenIncomplete.commonEmphasis(pending); - if (!isIncomplete) { - commitCache(cache); - } + if (token !== TokenType.IncompleteEmphasis) return; + + if (!isTokenIncomplete.commonEmphasis(pending)) { + commitCache(cache); } }; -const recognizeText = (cache: StreamCache) => { - const { token } = cache; - if (token === TokenType.Text) { +const recognizeList = (cache: StreamCache): void => { + const { token, pending } = cache; + + if (token === TokenType.Text && /^[-+*]/.test(pending)) { + cache.token = TokenType.IncompleteList; + return; + } + + if (token !== TokenType.IncompleteList) return; + + if (!isTokenIncomplete.list(pending)) { commitCache(cache); } }; -const recognizers = [ - recognizeImage, - recognizeLink, - recognizeAtxHeading, - recognizeEmphasis, - recognizeHtml, - recognizeText, -]; - -const handleIncompleteMarkdown = ( - cache: StreamCache, - incompleteMarkdownComponentMap: NonNullable< - XMarkdownProps['streaming'] - >['incompleteMarkdownComponentMap'], -) => { - if (cache.token === TokenType.Text) return; - - if (cache.token === TokenType.IncompleteImage) { - return `<${incompleteMarkdownComponentMap?.image || 'incomplete-image'} />`; - } - if (cache.token === TokenType.IncompleteLink) { - return `<${incompleteMarkdownComponentMap?.link || 'incomplete-link'} />`; +const recognizeText = (cache: StreamCache): void => { + if (cache.token === TokenType.Text) { + commitCache(cache); } }; -// cache incomplete markdown +/* ------------ Main Hook ------------ */ const useStreaming = (input: string, config?: XMarkdownProps['streaming']) => { const { hasNextChunk: enableCache = false, incompleteMarkdownComponentMap } = config || {}; const [output, setOutput] = useState(''); const cacheRef = useRef(getInitialCache()); + // Memoize recognizers to avoid recreation on each render + const recognizers = useMemo( + () => [ + recognizeImage, + recognizeLink, + recognizeAtxHeading, + recognizeEmphasis, + recognizeHtml, + recognizeList, + recognizeText, + ], + [], + ); + + const handleIncompleteMarkdown = useCallback( + (cache: StreamCache): string | undefined => { + if (cache.token === TokenType.Text) return; + + const componentMap = incompleteMarkdownComponentMap || {}; + + switch (cache.token) { + case TokenType.IncompleteImage: + return `<${componentMap.image || 'incomplete-image'} />`; + case TokenType.IncompleteLink: + return `<${componentMap.link || 'incomplete-link'} />`; + default: + return undefined; + } + }, + [incompleteMarkdownComponentMap], + ); + const processStreaming = useCallback( - (text: string) => { - const cache = cacheRef.current; + (text: string): void => { if (!text) { setOutput(''); cacheRef.current = getInitialCache(); return; } - const currentText = cache.completeMarkdown + cache.pending; - if (!text.startsWith(currentText)) { + const cache = cacheRef.current; + const expectedPrefix = cache.completeMarkdown + cache.pending; + + // Reset cache if input doesn't continue from previous state + if (!text.startsWith(expectedPrefix)) { cacheRef.current = getInitialCache(); } - if (!isInCodeBlock(text)) { - // handle new chunk - const chunk = text.slice(cache.processedLength); - if (!chunk) return; + const chunk = text.slice(cache.processedLength); + if (!chunk) return; + + cache.processedLength += chunk.length; + + // Skip processing if inside code block + if (isInCodeBlock(text)) { + setOutput(text); + return; + } + + cache.pending += chunk; - cache.processedLength += chunk.length; - cache.pending += chunk; - for (const recognizer of recognizers) { - recognizer(cache); - } + // Process all recognizers + for (const recognizer of recognizers) { + recognizer(cache); } - const incompletePlaceholder = handleIncompleteMarkdown(cache, incompleteMarkdownComponentMap); + const incompletePlaceholder = handleIncompleteMarkdown(cache); setOutput(cache.completeMarkdown + (incompletePlaceholder || '')); }, - [incompleteMarkdownComponentMap], + [recognizers, handleIncompleteMarkdown], ); useEffect(() => { if (typeof input !== 'string') { console.error(`X-Markdown: input must be string, not ${typeof input}.`); + setOutput(''); return; } - if (!enableCache) { + if (enableCache) { + processStreaming(input); + } else { setOutput(input); - return; } - - processStreaming(input); }, [input, enableCache, processStreaming]); return output; diff --git a/packages/x/docs/x-markdown/demo/streaming/format.tsx b/packages/x/docs/x-markdown/demo/streaming/format.tsx index 6207fea6f..36918c8be 100644 --- a/packages/x/docs/x-markdown/demo/streaming/format.tsx +++ b/packages/x/docs/x-markdown/demo/streaming/format.tsx @@ -1,6 +1,7 @@ import XMarkdown from '@ant-design/x-markdown'; import { Button, Card, Skeleton } from 'antd'; import React, { useState } from 'react'; +import { useMarkdownTheme } from '../_utils'; const demos = [ { @@ -24,7 +25,7 @@ const demos = [ }, { title: 'Code Block', - content: '- *code*', + content: '1\n- *code*', }, ]; @@ -37,6 +38,7 @@ const LinkSkeleton = () => ( const StreamDemo: React.FC<{ content: string }> = ({ content }) => { const [displayText, setDisplayText] = useState(''); const [isStreaming, setIsStreaming] = useState(false); + const [className] = useMarkdownTheme(); const startStream = React.useCallback(() => { setDisplayText(''); @@ -101,6 +103,7 @@ const StreamDemo: React.FC<{ content: string }> = ({ content }) => { > From 76abd026f3e9ba443ce98d8c2e060c508bc9fefa Mon Sep 17 00:00:00 2001 From: div627 Date: Tue, 21 Oct 2025 10:33:29 +0800 Subject: [PATCH 09/11] fix: test cases and demo --- .../src/XMarkdown/__test__/hooks.test.tsx | 95 +++++++++++++++---- .../src/XMarkdown/hooks/useStreaming.ts | 30 +++--- .../docs/x-markdown/demo/streaming/format.tsx | 15 +-- 3 files changed, 99 insertions(+), 41 deletions(-) diff --git a/packages/x-markdown/src/XMarkdown/__test__/hooks.test.tsx b/packages/x-markdown/src/XMarkdown/__test__/hooks.test.tsx index ee874ca1f..cdfeb8ed8 100644 --- a/packages/x-markdown/src/XMarkdown/__test__/hooks.test.tsx +++ b/packages/x-markdown/src/XMarkdown/__test__/hooks.test.tsx @@ -15,12 +15,6 @@ const basicTestCases = [ input: '[foo]: /url "title"', output: '[foo]: /url "title"', }, - { - title: 'incomplete image', - input: '![alt text](https', - output: '', - config: { hasNextChunk: true }, - }, { title: 'complete link nested image', input: @@ -49,13 +43,7 @@ const basicTestCases = [ { title: 'heading3', input: '###', - output: '', - }, - { - title: 'heading with space', - input: '# ', - output: '', - config: { hasNextChunk: true }, + output: '###', }, { title: 'wrong heading', @@ -75,7 +63,7 @@ const basicTestCases = [ { title: 'incomplete Html', input: '', + output: '
', + }, + { + title: 'complete Html self closed', + input: '
', + output: '
', + }, + { + title: 'heading', + input: '#', + output: '', + }, + { + title: 'heading3', + input: '###', + output: '', + }, + { + title: 'inValid heading', + input: '#######', + output: '#######', + }, + { + title: 'inValid heading no space', + input: '###Heading', + output: '###Heading', + }, + { + title: 'valid heading ', + input: '### Heading', + output: '### Heading', + }, ]; // 代码块测试 - 基于实际行为 diff --git a/packages/x-markdown/src/XMarkdown/hooks/useStreaming.ts b/packages/x-markdown/src/XMarkdown/hooks/useStreaming.ts index 11efecd62..5235231a4 100644 --- a/packages/x-markdown/src/XMarkdown/hooks/useStreaming.ts +++ b/packages/x-markdown/src/XMarkdown/hooks/useStreaming.ts @@ -24,7 +24,7 @@ export interface StreamCache { const INCOMPLETE_REGEX = { image: [/^!\[[^\]\r\n]*$/, /^!\[[^\r\n]*\]\(*[^)\r\n]*$/], link: [/^\[[^\]\r\n]*$/, /^\[[^\r\n]*\]\(*[^)\r\n]*$/], - atxHeading: [/^#{1,6}$/, /^#{1,6}(?=\s)[^\r\n]*$/], + atxHeading: [/^#{1,6}(?=\s)*$/], html: [/^<[a-zA-Z][a-zA-Z0-9-]*[^>\r\n]*$/], commonEmphasis: [/^(\*+|_+)(?!\s)(?!.*\1$)[\s\S]*$/], list: [/^[-+*]\s*$/], @@ -117,9 +117,7 @@ const recognizeLink = (cache: StreamCache): void => { if (token !== TokenType.IncompleteLink) return; - if (isTokenIncomplete.link(pending)) { - cache.token = TokenType.IncompleteLink; - } else { + if (!isTokenIncomplete.link(pending)) { commitCache(cache); } }; @@ -134,9 +132,7 @@ const recognizeAtxHeading = (cache: StreamCache): void => { if (token !== TokenType.IncompleteHeading) return; - if (isTokenIncomplete.atxHeading(pending)) { - cache.token = TokenType.IncompleteHeading; - } else { + if (!isTokenIncomplete.atxHeading(pending)) { commitCache(cache); } }; @@ -253,16 +249,18 @@ const useStreaming = (input: string, config?: XMarkdownProps['streaming']) => { cache.processedLength += chunk.length; // Skip processing if inside code block - if (isInCodeBlock(text)) { - setOutput(text); - return; - } - - cache.pending += chunk; - // Process all recognizers - for (const recognizer of recognizers) { - recognizer(cache); + for (const char of chunk) { + cache.pending += char; + + if (isInCodeBlock(text)) { + commitCache(cache); + } else { + // Process all recognizers + for (const recognizer of recognizers) { + recognizer(cache); + } + } } const incompletePlaceholder = handleIncompleteMarkdown(cache); diff --git a/packages/x/docs/x-markdown/demo/streaming/format.tsx b/packages/x/docs/x-markdown/demo/streaming/format.tsx index 36918c8be..0e5df1034 100644 --- a/packages/x/docs/x-markdown/demo/streaming/format.tsx +++ b/packages/x/docs/x-markdown/demo/streaming/format.tsx @@ -12,20 +12,21 @@ const demos = [ { title: 'Image Syntax', content: - '![Ant Design X](https://mdn.alipayobjects.com/huamei_yz9z7c/afts/img/0lMhRYbo0-8AAAAAQDAAAAgADlJoAQFr/original) ', + 'This is Image:\n\n ![Ant Design X](https://mdn.alipayobjects.com/huamei_yz9z7c/afts/img/0lMhRYbo0-8AAAAAQDAAAAgADlJoAQFr/original)', }, { title: 'Link Syntax', content: 'Visit [Ant Design X](https://github.com/ant-design/x) for more details.', }, { - title: 'Emphasis', + title: 'Atx Heading', content: - 'This is **bold text** and this is *italic text*. You can also use ***bold and italic***.', + '# Heading1 \n ## Heading2 \n ### Heading3 \n #### Heading4 \n ##### Heading5 \n ###### Heading6', }, { - title: 'Code Block', - content: '1\n- *code*', + title: 'Emphasis', + content: + 'This is **bold text** and this is *italic text*. You can also use ***bold and italic***.', }, ]; @@ -36,7 +37,7 @@ const LinkSkeleton = () => ( ); const StreamDemo: React.FC<{ content: string }> = ({ content }) => { - const [displayText, setDisplayText] = useState(''); + const [displayText, setDisplayText] = useState(content); const [isStreaming, setIsStreaming] = useState(false); const [className] = useMarkdownTheme(); @@ -105,7 +106,7 @@ const StreamDemo: React.FC<{ content: string }> = ({ content }) => { content={displayText} className={className} components={{ 'incomplete-image': ImageSkeleton, 'incomplete-link': LinkSkeleton }} - streaming={{ hasNextChunk: true }} + streaming={{ hasNextChunk: isStreaming }} />
From 41d967f66f2d080875375479eab84e21f66bae15 Mon Sep 17 00:00:00 2001 From: div627 Date: Tue, 21 Oct 2025 11:16:49 +0800 Subject: [PATCH 10/11] feat: beatify code --- .../src/XMarkdown/hooks/useStreaming.ts | 20 ++++++------------- .../docs/x-markdown/demo/streaming/format.tsx | 1 + 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/packages/x-markdown/src/XMarkdown/hooks/useStreaming.ts b/packages/x-markdown/src/XMarkdown/hooks/useStreaming.ts index 5235231a4..316a38f76 100644 --- a/packages/x-markdown/src/XMarkdown/hooks/useStreaming.ts +++ b/packages/x-markdown/src/XMarkdown/hooks/useStreaming.ts @@ -237,7 +237,6 @@ const useStreaming = (input: string, config?: XMarkdownProps['streaming']) => { const cache = cacheRef.current; const expectedPrefix = cache.completeMarkdown + cache.pending; - // Reset cache if input doesn't continue from previous state if (!text.startsWith(expectedPrefix)) { cacheRef.current = getInitialCache(); @@ -247,19 +246,16 @@ const useStreaming = (input: string, config?: XMarkdownProps['streaming']) => { if (!chunk) return; cache.processedLength += chunk.length; - + const isTextInBlock = isInCodeBlock(text); // Skip processing if inside code block - for (const char of chunk) { cache.pending += char; - - if (isInCodeBlock(text)) { + if (isTextInBlock) { commitCache(cache); } else { - // Process all recognizers - for (const recognizer of recognizers) { - recognizer(cache); - } + recognizers.forEach((recognize) => { + recognize(cache); + }); } } @@ -276,11 +272,7 @@ const useStreaming = (input: string, config?: XMarkdownProps['streaming']) => { return; } - if (enableCache) { - processStreaming(input); - } else { - setOutput(input); - } + enableCache ? processStreaming(input) : setOutput(input); }, [input, enableCache, processStreaming]); return output; diff --git a/packages/x/docs/x-markdown/demo/streaming/format.tsx b/packages/x/docs/x-markdown/demo/streaming/format.tsx index 0e5df1034..92eb2ae9b 100644 --- a/packages/x/docs/x-markdown/demo/streaming/format.tsx +++ b/packages/x/docs/x-markdown/demo/streaming/format.tsx @@ -105,6 +105,7 @@ const StreamDemo: React.FC<{ content: string }> = ({ content }) => { From 4dcc2fa079541cd3fbb1f13e5abbce0ef08d59c2 Mon Sep 17 00:00:00 2001 From: div627 Date: Tue, 21 Oct 2025 23:57:45 +0800 Subject: [PATCH 11/11] docs: modify docs --- .../x-markdown/streaming-animation.en-US.md | 249 ++---------------- .../x-markdown/streaming-animation.zh-CN.md | 226 +--------------- .../docs/x-markdown/streaming-syntax.en-US.md | 180 +++---------- .../docs/x-markdown/streaming-syntax.zh-CN.md | 152 ++--------- 4 files changed, 85 insertions(+), 722 deletions(-) diff --git a/packages/x/docs/x-markdown/streaming-animation.en-US.md b/packages/x/docs/x-markdown/streaming-animation.en-US.md index 6cfaee348..6e5771980 100644 --- a/packages/x/docs/x-markdown/streaming-animation.en-US.md +++ b/packages/x/docs/x-markdown/streaming-animation.en-US.md @@ -1,6 +1,6 @@ --- group: - title: Streaming Processing + title: Streaming order: 4 title: Animation Effects order: 2 @@ -8,254 +8,41 @@ order: 2 Add elegant animation effects to streaming rendered content, supporting progressive text display to enhance user reading experience. -## Feature Introduction - -Streaming animation effects are designed for real-time content rendering, using smooth transition animations to make content presentation more natural and avoid visual discomfort from abrupt content updates. - -## Feature Demo +## Code Demo Streaming Animation Effects -## Configuration Parameters +## API -### streaming Configuration +### streaming -| Parameter | Description | Type | Default | +| Parameter | Description | Type | Default Value | | --- | --- | --- | --- | -| hasNextChunk | Whether there is more streaming data | `boolean` | `false` | +| hasNextChunk | Whether there is subsequent data | `boolean` | `false` | | enableAnimation | Enable text fade-in animation | `boolean` | `false` | | animationConfig | Text animation configuration | `AnimationConfig` | `{ fadeDuration: 200, easing: 'ease-in-out' }` | -#### AnimationConfig - -| Property | Description | Type | Default | -| ------------ | --------------------------------------- | -------- | --------------- | -| fadeDuration | Fade animation duration in milliseconds | `number` | `200` | -| easing | Animation easing function | `string` | `'ease-in-out'` | - -## Usage Examples - -### Basic Animation Configuration - -```tsx -import { XMarkdown } from '@ant-design/x-markdown'; - -const App = () => { - return ( - - ); -}; -``` - -### Custom Animation Parameters - -```tsx -import { XMarkdown } from '@ant-design/x-markdown'; - -const CustomAnimationExample = () => { - return ( - - ); -}; -``` - -## Animation Features - -### Core Features - -- **Smooth Transition**: Text content gradually appears with fade-in effect, avoiding abruptness -- **Configurable Duration**: Supports custom animation duration, range 100-1000ms -- **Easing Functions**: Supports multiple easing effects: - - `ease-in-out`: Smooth acceleration and deceleration (default) - - `linear`: Constant speed animation - - `ease-in`: Ease-in effect - - `ease-out`: Ease-out effect -- **Performance Optimization**: High-performance animations using CSS3 transform and opacity, avoiding reflow and repaint - -### Animation Trigger Mechanism - -Animation effects trigger under the following conditions: - -1. **Syntax Completeness**: Animation only triggers after Markdown syntax is fully parsed -2. **Incremental Updates**: Animation effects only apply to newly added content -3. **State Control**: Trigger timing controlled by `hasNextChunk` - -## Advanced Usage - -### Combined with Syntax Processing - -```tsx -import { useState, useEffect } from 'react'; -import { XMarkdown } from '@ant-design/x-markdown'; - -const CombinedStreamingExample = () => { - const [content, setContent] = useState(''); - const [hasNextChunk, setHasNextChunk] = useState(true); - - useEffect(() => { - const chunks = [ - '# Streaming Rendering and Animation', - '\n\nThis combines', - '**syntax processing** and', - '*animation effects* in', - 'a complete example.', - ]; - - let index = 0; - const timer = setInterval(() => { - if (index < chunks.length) { - setContent((prev) => prev + chunks[index]); - index++; +### AnimationConfig - if (index === chunks.length) { - setHasNextChunk(false); - } - } else { - clearInterval(timer); - } - }, 800); +| Property | Description | Type | Default Value | +| ------------ | ----------------------------------------- | -------- | --------------- | +| fadeDuration | Fade-in animation duration (milliseconds) | `number` | `200` | +| easing | Animation easing function | `string` | `'ease-in-out'` | - return () => clearInterval(timer); - }, []); +## FAQ - return ( - - ); -}; -``` - -### Typewriter Effect Implementation - -Combined with animation effects, you can achieve a typewriter effect: - -```tsx -import { useState, useEffect } from 'react'; -import { XMarkdown } from '@ant-design/x-markdown'; - -const TypewriterEffect = () => { - const [content, setContent] = useState(''); - const [hasNextChunk, setHasNextChunk] = useState(true); - const fullText = - '# Typewriter Effect\n\nThis is an example simulating typewriter effect, with each character gradually appearing.'; - - useEffect(() => { - let index = 0; - const timer = setInterval(() => { - if (index <= fullText.length) { - setContent(fullText.slice(0, index)); - index++; - - if (index > fullText.length) { - setHasNextChunk(false); - } - } else { - clearInterval(timer); - } - }, 50); // 50ms per character - - return () => clearInterval(timer); - }, []); - - return ( - - ); -}; -``` - -## Performance Optimization - -### Animation Performance - -- **Hardware Acceleration**: Uses CSS3 transform property to trigger hardware acceleration -- **Throttling Control**: Avoids excessive animation trigger frequency -- **Memory Management**: Timely cleanup of animation-related DOM references - -### Best Practices - -1. **Animation Duration Selection**: - - Fast content: 100-200ms - - Normal content: 200-400ms - - Slow content: 400-600ms - -2. **Easing Function Selection**: - - Content display: `ease-in-out` - - Emphasis effects: `ease-out` - - Mechanical effects: `linear` - -3. **Avoid Excessive Animation**: - - Appropriately extend animation intervals for large text content - - Avoid triggering too many animated elements simultaneously - -## Common Questions - -### Q: Animation effects not working? +### Animation effects not working? A: Please check the following conditions: -- Whether `enableAnimation` is set to `true` -- Whether `hasNextChunk` is correctly controlled -- Whether browser supports CSS3 animations +- Is `enableAnimation` set to `true` +- Is `hasNextChunk` properly controlled +- Does the browser support CSS3 animations -### Q: Performance issues with animation? +### Animation causing performance issues? A: Recommended optimizations: - Reduce `fadeDuration` time - Use `linear` easing function -- Batch render large amounts of content - -### Q: How to disable animation for specific elements? - -A: Can be controlled through custom components: - -```tsx -const NoAnimationComponent = ({ children }) => { - return
{children}
; -}; - -; -``` +- Render large amounts of content in batches diff --git a/packages/x/docs/x-markdown/streaming-animation.zh-CN.md b/packages/x/docs/x-markdown/streaming-animation.zh-CN.md index 77c3bc03d..6b807d791 100644 --- a/packages/x/docs/x-markdown/streaming-animation.zh-CN.md +++ b/packages/x/docs/x-markdown/streaming-animation.zh-CN.md @@ -8,17 +8,13 @@ order: 2 为流式渲染的内容添加优雅的动画效果,支持文本的渐进式显示,提升用户阅读体验。 -## 功能介绍 - -流式动画效果专为实时内容渲染设计,通过平滑的过渡动画让内容呈现更加自然,避免突兀的内容更新带来的视觉不适。 - -## 功能演示 +## 代码演示 流式动画效果 -## 配置参数 +## API -### streaming 配置项 +### streaming | 参数 | 说明 | 类型 | 默认值 | | --- | --- | --- | --- | @@ -26,212 +22,16 @@ order: 2 | enableAnimation | 启用文本淡入动画 | `boolean` | `false` | | animationConfig | 文本动画配置 | `AnimationConfig` | `{ fadeDuration: 200, easing: 'ease-in-out' }` | -#### AnimationConfig +### AnimationConfig | 属性 | 说明 | 类型 | 默认值 | | ------------ | ------------------------ | -------- | --------------- | | fadeDuration | 淡入动画持续时间(毫秒) | `number` | `200` | | easing | 动画缓动函数 | `string` | `'ease-in-out'` | -## 使用示例 - -### 基础动画配置 - -```tsx -import { XMarkdown } from '@ant-design/x-markdown'; - -const App = () => { - return ( - - ); -}; -``` - -### 自定义动画参数 - -```tsx -import { XMarkdown } from '@ant-design/x-markdown'; - -const CustomAnimationExample = () => { - return ( - - ); -}; -``` - -## 动画效果特性 - -### 核心特性 - -- **平滑过渡**:文本内容以淡入效果逐步显示,避免突兀感 -- **可配置时长**:支持自定义动画持续时间,范围 100-1000ms -- **缓动函数**:支持多种缓动效果: - - `ease-in-out`:平滑的加速减速(默认) - - `linear`:匀速动画 - - `ease-in`:缓入效果 - - `ease-out`:缓出效果 -- **性能优化**:使用 CSS3 transform 和 opacity 实现高性能动画,避免重排重绘 - -### 动画触发机制 - -动画效果在以下条件下触发: - -1. **语法完整性**:仅当Markdown语法完整解析后触发动画 -2. **增量更新**:仅对新添加的内容应用动画效果 -3. **状态控制**:通过 `hasNextChunk` 控制动画的触发时机 - -## 高级用法 - -### 与语法处理配合使用 - -```tsx -import { useState, useEffect } from 'react'; -import { XMarkdown } from '@ant-design/x-markdown'; - -const CombinedStreamingExample = () => { - const [content, setContent] = useState(''); - const [hasNextChunk, setHasNextChunk] = useState(true); - - useEffect(() => { - const chunks = [ - '# 流式渲染与动画', - '\n\n这是结合了', - '**语法处理**和', - '*动画效果*的', - '完整示例。', - ]; - - let index = 0; - const timer = setInterval(() => { - if (index < chunks.length) { - setContent((prev) => prev + chunks[index]); - index++; - - if (index === chunks.length) { - setHasNextChunk(false); - } - } else { - clearInterval(timer); - } - }, 800); - - return () => clearInterval(timer); - }, []); +## FAQ - return ( - - ); -}; -``` - -### 打字机效果实现 - -结合动画效果可以实现打字机效果: - -```tsx -import { useState, useEffect } from 'react'; -import { XMarkdown } from '@ant-design/x-markdown'; - -const TypewriterEffect = () => { - const [content, setContent] = useState(''); - const [hasNextChunk, setHasNextChunk] = useState(true); - const fullText = '# 打字机效果\n\n这是一个模拟打字机效果的示例,每个字符都会逐步显示。'; - - useEffect(() => { - let index = 0; - const timer = setInterval(() => { - if (index <= fullText.length) { - setContent(fullText.slice(0, index)); - index++; - - if (index > fullText.length) { - setHasNextChunk(false); - } - } else { - clearInterval(timer); - } - }, 50); // 每个字符50ms - - return () => clearInterval(timer); - }, []); - - return ( - - ); -}; -``` - -## 性能优化 - -### 动画性能 - -- **硬件加速**:使用 CSS3 transform 属性触发硬件加速 -- **节流控制**:避免过快的动画触发频率 -- **内存管理**:及时清理动画相关的DOM引用 - -### 最佳实践 - -1. **动画时长选择**: - - 快速内容:100-200ms - - 正常内容:200-400ms - - 慢速内容:400-600ms - -2. **缓动函数选择**: - - 内容展示:`ease-in-out` - - 强调效果:`ease-out` - - 机械效果:`linear` - -3. **避免过度动画**: - - 大量文本内容时适当延长动画间隔 - - 避免同时触发过多动画元素 - -## 常见问题 - -### Q: 动画效果不生效? +### 动画效果不生效? A: 请检查以下条件: @@ -239,22 +39,10 @@ A: 请检查以下条件: - `hasNextChunk` 是否正确控制 - 浏览器是否支持 CSS3 动画 -### Q: 动画导致性能问题? +### 动画导致性能问题? A: 建议优化: - 减少 `fadeDuration` 时间 - 使用 `linear` 缓动函数 - 分批渲染大量内容 - -### Q: 如何禁用特定元素的动画? - -A: 可以通过自定义组件控制: - -```tsx -const NoAnimationComponent = ({ children }) => { - return
{children}
; -}; - -; -``` diff --git a/packages/x/docs/x-markdown/streaming-syntax.en-US.md b/packages/x/docs/x-markdown/streaming-syntax.en-US.md index 090898f73..b566bdc49 100644 --- a/packages/x/docs/x-markdown/streaming-syntax.en-US.md +++ b/packages/x/docs/x-markdown/streaming-syntax.en-US.md @@ -1,93 +1,69 @@ --- group: - title: Streaming Processing + title: Streaming order: 4 title: Syntax Processing order: 1 --- -Streaming syntax processing mechanism is designed for real-time rendering scenarios, capable of intelligently handling incomplete Markdown syntax structures to avoid rendering anomalies caused by syntax fragments. +The streaming syntax processing mechanism is designed for real-time rendering scenarios, intelligently handling incomplete Markdown syntax structures to avoid rendering anomalies caused by syntax fragments. -## Core Issues +## Code Demo -During streaming transmission, Markdown syntax may be in an incomplete state: +Streaming Syntax Processing -```markdown -// Incomplete link during transmission Click to visit [example website](https://example // Incomplete image syntax ![product image](https://cdn.example.com/images/produc -``` +## API -### Rendering Anomaly Risks +### streaming -Incomplete syntax structures may lead to: +| Parameter | Description | Type | Default Value | +| --- | --- | --- | --- | +| hasNextChunk | Whether there is subsequent data | `boolean` | `false` | +| enableAnimation | Enable text fade-in animation | `boolean` | `false` | +| animationConfig | Text animation configuration | `AnimationConfig` | `{ fadeDuration: 200, easing: 'ease-in-out' }` | -- Links unable to jump correctly -- Image loading failures -- Format markers displaying directly in content +## FAQ -## Feature Demo +### Why is it needed? -Streaming Syntax Processing +During streaming transmission, Markdown syntax may be in an incomplete state: -## Configuration Guide +```markdown +// Incomplete link syntax [Example Website](https://example // Incomplete image syntax ![Product Image](https://cdn.example.com/images/produc +``` -### streaming Configuration +Incomplete syntax structures may cause: -| Parameter | Description | Type | Default | -| --- | --- | --- | --- | -| hasNextChunk | Whether there is more streaming data | `boolean` | `false` | -| incompleteMarkdownComponentMap | Mapping configuration for converting incomplete Markdown formats to custom loading components, used to provide custom loading components for unclosed links and images during streaming rendering | `{ link?: string; image?: string }` | `{ link: 'incomplete-link', image: 'incomplete-image' }` | +- Links unable to navigate correctly +- Image loading failures +- Format markers displayed directly in content -### Usage Example +### Why shouldn't hasNextChunk always be `true` -```tsx -import { XMarkdown } from '@ant-design/x-markdown'; +`hasNextChunk` should not always be `true`, otherwise it would cause the following issues: -const App = () => { - return ( - - ); -}; -``` +1. **Pending syntax**: Unclosed links, images, and other syntax will remain in a loading state +2. **Poor user experience**: Users see continuous loading animations without getting complete content +3. **Memory leaks**: State data continues to accumulate and cannot be properly cleaned up -## Supported Syntax Types +### Supported syntax types Streaming syntax processing supports integrity checks for the following Markdown syntax: | Syntax Type | Format Example | Processing Mechanism | | --- | --- | --- | -| **Links** | `[text](url)` | Detects unclosed link markers like `[text](` | -| **Images** | `![alt](src)` | Detects unclosed image markers like `![alt](` | -| **Headings** | `# ## ###` etc. | Supports progressive rendering for 1-6 level headings | -| **Emphasis** | `*italic*` `**bold**` | Handles emphasis syntax with `*` and `_` | -| **Code** | `inline code` and `code blocks` | Supports backtick code block integrity checks | -| **Lists** | `- + *` list markers | Detects spaces after list markers | -| **Dividers** | `---` `===` | Avoids conflicts between Setext headings and dividers | -| **XML Tags** | `` | Handles HTML/XML tag closure states | - -## How It Works - -When `hasNextChunk=true`, the component will: - -1. **Tokenized Parsing**: Decomposes Markdown syntax into 11 token types for state management -2. **State Stack Maintenance**: Uses stack structure to track nested syntax states -3. **Smart Truncation**: Pauses rendering when syntax is incomplete to avoid displaying fragments -4. **Progressive Rendering**: Gradually completes syntax rendering as content is supplemented -5. **Error Recovery**: Automatically falls back to safe state when syntax errors are detected - -## Advanced Configuration +| **Link** | `[text](url)` | Detect unclosed link markers, such as `[text](` | +| **Image** | `![alt](src)` | Detect unclosed image markers, such as `![alt](` | +| **Heading** | `# ## ###` etc. | Support progressive rendering of 1-6 level headings | +| **Emphasis** | `*italic*` `**bold**` | Handle emphasis syntax with `*` and `_` | +| **Code** | `Inline code` and `Code blocks` | Support integrity checks for backtick code blocks | +| **List** | `- + *` list markers | Detect spaces after list markers | +| **Horizontal Rule** | `---` `===` | Avoid conflicts between Setext headings and horizontal rules | +| **XML Tags** | `` | Handle closing state of HTML/XML tags | ### Custom Loading Components -You can customize the loading state display for incomplete syntax through `incompleteMarkdownComponentMap`: +Custom loading state display for incomplete syntax can be achieved through `incompleteMarkdownComponentMap`: ```tsx import { XMarkdown } from '@ant-design/x-markdown'; @@ -100,7 +76,7 @@ const CustomLoadingComponents = { const App = () => { return ( { ); }; ``` - -### State Reset Mechanism - -When input content changes fundamentally (non-incremental update), the component automatically resets the parsing state: - -```tsx -// Old content: "Hello " -// New content: "Hi there!" - triggers state reset -// New content: "Hello world!" - continues incremental parsing -``` - -## hasNextChunk Best Practices - -### Avoid Getting Stuck - -`hasNextChunk` should not always be `true`, otherwise it will cause: - -1. **Syntax Hanging**: Unclosed links, images and other syntax will remain in loading state -2. **Poor User Experience**: Users see continuous loading animations -3. **Memory Leaks**: State data accumulates continuously and cannot be cleaned properly - -### Correct Usage Example - -```tsx -import { useState, useEffect } from 'react'; -import { XMarkdown } from '@ant-design/x-markdown'; - -const StreamingExample = () => { - const [content, setContent] = useState(''); - const [hasNextChunk, setHasNextChunk] = useState(true); - - useEffect(() => { - // Simulate streaming data - const chunks = [ - '# Welcome to Streaming Rendering', - '\n\nThis is a demonstration', - ' showing how to handle', - '[incomplete links](https://example', - '.com) and images', - '![Example Image](https://picsum.photos/200)', - '\n\nContent completed!', - ]; - - let currentIndex = 0; - const interval = setInterval(() => { - if (currentIndex < chunks.length) { - setContent((prev) => prev + chunks[currentIndex]); - currentIndex++; - - // Set to false on last chunk - if (currentIndex === chunks.length) { - setHasNextChunk(false); - } - } else { - clearInterval(interval); - } - }, 500); - - return () => clearInterval(interval); - }, []); - - return ( - - ); -}; -``` - -## Performance Optimization - -- **Incremental Parsing**: Only processes newly added content fragments, avoiding repeated parsing -- **State Caching**: Maintains parsing state to reduce repeated calculations -- **Memory Management**: Automatically cleans up processed state data -- **Error Boundaries**: Prevents parsing errors from affecting overall rendering diff --git a/packages/x/docs/x-markdown/streaming-syntax.zh-CN.md b/packages/x/docs/x-markdown/streaming-syntax.zh-CN.md index 302081d0b..84a6c3c30 100644 --- a/packages/x/docs/x-markdown/streaming-syntax.zh-CN.md +++ b/packages/x/docs/x-markdown/streaming-syntax.zh-CN.md @@ -8,7 +8,23 @@ order: 1 流式语法处理机制专为实时渲染场景设计,能够智能处理不完整的Markdown语法结构,避免因语法片段导致的渲染异常。 -## 核心问题 +## 代码演示 + +流式语法处理 + +## API + +### streaming + +| 参数 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| hasNextChunk | 是否还有后续数据 | `boolean` | `false` | +| enableAnimation | 启用文本淡入动画 | `boolean` | `false` | +| animationConfig | 文本动画配置 | `AnimationConfig` | `{ fadeDuration: 200, easing: 'ease-in-out' }` | + +## FAQ + +### 为什么需要它? 在流式传输过程中,Markdown语法可能处于不完整状态: @@ -16,49 +32,21 @@ order: 1 // 不完整的链接语法 [示例网站](https://example // 不完整的图片语法 ![产品图](https://cdn.example.com/images/produc ``` -### 渲染异常风险 - 不完整的语法结构可能导致: - 链接无法正确跳转 - 图片加载失败 - 格式标记直接显示在内容中 -## 功能演示 - -流式语法处理 - -## 配置指南 - -### streaming 配置项 - -| 参数 | 说明 | 类型 | 默认值 | -| --- | --- | --- | --- | -| hasNextChunk | 是否还有后续数据 | `boolean` | `false` | -| incompleteMarkdownComponentMap | 未完成的 Markdown 格式转换为自定义加载组件的映射配置,用于在流式渲染过程中为未闭合的链接和图片提供自定义 loading 组件 | `{ link?: string; image?: string }` | `{ link: 'incomplete-link', image: 'incomplete-image' }` | +### hasNextChunk 为什么不能始终为 `true` -### 使用示例 - -```tsx -import { XMarkdown } from '@ant-design/x-markdown'; +`hasNextChunk` 不应该始终为 `true`,否则会导致以下问题: -const App = () => { - return ( - - ); -}; -``` +1. **语法悬而未决**:未闭合的链接、图片等语法会一直保持加载状态 +2. **用户体验差**:用户看到持续的加载动画,无法获得完整内容 +3. **内存泄漏**:状态数据持续累积,无法正确清理 -## 支持的语法类型 +### 支持的语法类型 流式语法处理支持以下Markdown语法的完整性检查: @@ -73,18 +61,6 @@ const App = () => { | **分隔线** | `---` `===` | 避免Setext标题与分隔线冲突 | | **XML标签** | `` | 处理HTML/XML标签的闭合状态 | -## 工作原理 - -当 `hasNextChunk=true` 时,组件会: - -1. **Token化解析**:将Markdown语法分解为11种Token类型进行状态管理 -2. **状态栈维护**:使用栈结构追踪嵌套的语法状态 -3. **智能截断**:在语法不完整时暂停渲染,避免显示片段 -4. **渐进渲染**:随着内容补充,逐步完成语法渲染 -5. **错误恢复**:当检测到语法错误时自动回退到安全状态 - -## 高级配置 - ### 自定义加载组件 通过 `incompleteMarkdownComponentMap` 可以自定义未完整语法的加载状态显示: @@ -116,85 +92,3 @@ const App = () => { ); }; ``` - -### 状态重置机制 - -当输入内容发生根本性变化时(非增量更新),组件会自动重置解析状态: - -```tsx -// 旧内容:"Hello " -// 新内容:"Hi there!" - 会触发状态重置 -// 新内容:"Hello world!" - 会继续增量解析 -``` - -## hasNextChunk 使用最佳实践 - -### 避免一直卡住 - -`hasNextChunk` 不应该始终为 `true`,否则会导致以下问题: - -1. **语法悬而未决**:未闭合的链接、图片等语法会一直保持加载状态 -2. **用户体验差**:用户看到持续的加载动画,无法获得完整内容 -3. **内存泄漏**:状态数据持续累积,无法正确清理 - -### 正确用法示例 - -```tsx -import { useState, useEffect } from 'react'; -import { XMarkdown } from '@ant-design/x-markdown'; - -const StreamingExample = () => { - const [content, setContent] = useState(''); - const [hasNextChunk, setHasNextChunk] = useState(true); - - useEffect(() => { - // 模拟流式数据 - const chunks = [ - '# 欢迎使用流式渲染', - '\n\n这是一个演示', - ',展示了如何处理', - '[不完整链接](https://example', - '.com)和图片', - '![示例图片](https://picsum.photos/200)', - '\n\n内容已完成!', - ]; - - let currentIndex = 0; - const interval = setInterval(() => { - if (currentIndex < chunks.length) { - setContent((prev) => prev + chunks[currentIndex]); - currentIndex++; - - // 最后一组数据时设置为 false - if (currentIndex === chunks.length) { - setHasNextChunk(false); - } - } else { - clearInterval(interval); - } - }, 500); - - return () => clearInterval(interval); - }, []); - - return ( - - ); -}; -``` - -## 性能优化 - -- **增量解析**:只处理新增的内容片段,避免重复解析 -- **状态缓存**:维护解析状态,减少重复计算 -- **内存管理**:自动清理已处理的状态数据 -- **错误边界**:防止解析错误影响整体渲染