diff --git a/packages/x-markdown/src/XMarkdown/__test__/__snapshots__/index.test.tsx.snap b/packages/x-markdown/src/XMarkdown/__test__/__snapshots__/index.test.tsx.snap index c1efa6868..c9a17a29e 100644 --- a/packages/x-markdown/src/XMarkdown/__test__/__snapshots__/index.test.tsx.snap +++ b/packages/x-markdown/src/XMarkdown/__test__/__snapshots__/index.test.tsx.snap @@ -6,9 +6,13 @@ exports[`XMarkdown animation parent is custom components 1`] = ` class="ant-x-markdown x-markdown" style="direction: ltr;" > -

- This is Text. -

+
+

+ This is Text. +

+
`; @@ -19,13 +23,17 @@ exports[`XMarkdown animation parent is not custom components 1`] = ` class="ant-x-markdown x-markdown" style="direction: ltr;" > -

- - This is Text. - -

+
+

+ + This is Text. + +

+
`; @@ -36,16 +44,20 @@ exports[`XMarkdown support checkbox is checked 1`] = ` class="ant-x-markdown x-markdown" style="direction: ltr;" > - +
+ +
`; @@ -56,15 +68,19 @@ exports[`XMarkdown support checkbox not checked 1`] = ` class="ant-x-markdown x-markdown" style="direction: ltr;" > - +
+ +
`; diff --git a/packages/x-markdown/src/XMarkdown/__test__/footer.test.tsx b/packages/x-markdown/src/XMarkdown/__test__/footer.test.tsx new file mode 100644 index 000000000..9910e406e --- /dev/null +++ b/packages/x-markdown/src/XMarkdown/__test__/footer.test.tsx @@ -0,0 +1,140 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import XMarkdown from '../index'; + +describe('XMarkdown Footer', () => { + it('should render footer when provided', () => { + const footerContent =
Loading...
; + render(); + + expect(screen.getByTestId('custom-footer')).toBeInTheDocument(); + expect(screen.getByTestId('custom-footer')).toHaveTextContent('Loading...'); + }); + + it('should render footer at the end of content', () => { + const footerContent =
Footer Content
; + const { container } = render( + , + ); + + const markdownContainer = container.firstChild; + expect(markdownContainer).toHaveClass('x-markdown'); + + const content = container.querySelector('.x-markdown-content'); + const footer = container.querySelector('.x-markdown-footer'); + + expect(content).toBeInTheDocument(); + expect(footer).toBeInTheDocument(); + expect(footer).toContainElement(screen.getByTestId('footer')); + }); + + it('should not render footer when not provided', () => { + const { container } = render(); + + expect(container.querySelector('.x-markdown-footer')).not.toBeInTheDocument(); + }); + + it('should render footer with empty content', () => { + const footerContent =
Empty Content Footer
; + render(); + + expect(screen.getByTestId('empty-footer')).toBeInTheDocument(); + }); + + it('should render footer with null content', () => { + const footerContent =
Null Content Footer
; + render(); + + expect(screen.getByTestId('null-footer')).toBeInTheDocument(); + }); + + it('should render footer with undefined content', () => { + const footerContent =
Undefined Content Footer
; + render(); + + expect(screen.getByTestId('undefined-footer')).toBeInTheDocument(); + }); + + it('should render footer with streaming content', () => { + const footerContent =
Streaming...
; + const { rerender } = render( + , + ); + + expect(screen.getByTestId('streaming-footer')).toBeInTheDocument(); + expect(screen.getByTestId('streaming-footer')).toHaveTextContent('Streaming...'); + + // Update content to simulate streaming + rerender( + , + ); + + // Footer should still be visible + expect(screen.getByTestId('streaming-footer')).toBeInTheDocument(); + }); + + it('should render footer with complex React components', () => { + const ComplexFooter = () => ( +
+ Loading +
+
+ ); + + render(} />); + + expect(screen.getByText('Loading')).toBeInTheDocument(); + expect(screen.getByText('⚡')).toBeInTheDocument(); + }); + + it('should apply custom styles to footer container', () => { + const footerContent =
Styled Footer
; + const { container } = render(); + + const footer = container.querySelector('.x-markdown-footer'); + expect(footer).toBeInTheDocument(); + // Check CSS classes instead of computed styles + expect(footer).toHaveClass('x-markdown-footer'); + }); + + it('should work with custom prefixCls', () => { + const footerContent =
Custom Prefix
; + const { container } = render( + , + ); + + expect(container.querySelector('.custom .x-markdown-content')).toBeInTheDocument(); + expect(container.querySelector('.custom .x-markdown-footer')).toBeInTheDocument(); + }); + + it('should not render anything when both content and footer are empty', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('should render footer with string content', () => { + render(String Footer}>{'# String Content'}); + + expect(screen.getByText('String Footer')).toBeInTheDocument(); + expect(screen.getByText('String Content')).toBeInTheDocument(); + }); + + it('should memoize footer rendering for performance', () => { + const FooterComponent = jest.fn(() =>
Memoized Footer
); + const footerElement = ; + + const { rerender } = render(); + + expect(FooterComponent).toHaveBeenCalledTimes(1); + + // Re-render with same footer element reference + rerender(); + + // Footer component should not re-render due to memoization + expect(FooterComponent).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/x-markdown/src/XMarkdown/__test__/index.test.tsx b/packages/x-markdown/src/XMarkdown/__test__/index.test.tsx index 461807130..c9661ec14 100644 --- a/packages/x-markdown/src/XMarkdown/__test__/index.test.tsx +++ b/packages/x-markdown/src/XMarkdown/__test__/index.test.tsx @@ -144,7 +144,7 @@ describe('XMarkdown', () => { it(`common markdown case: ${title}`, () => { const { container } = render(); - expect((container.firstChild as HTMLElement)?.innerHTML).toBe(html); + expect((container.firstChild as HTMLElement)?.innerHTML).toContain(html); }); }); @@ -162,7 +162,7 @@ describe('XMarkdown', () => { />, ); - expect((container.firstChild as HTMLElement)?.innerHTML).toBe(html); + expect((container.firstChild as HTMLElement)?.innerHTML).toContain(html); }); it('walkToken', () => { @@ -195,7 +195,7 @@ describe('XMarkdown', () => { // XMarkdown wraps content in a div with class "ant-x-markdown" const wrapper = container.firstChild as HTMLElement; expect(wrapper).toHaveClass('ant-x-markdown'); - expect(wrapper.innerHTML).toBe('
This is a paragraph.
\n'); + expect(wrapper.innerHTML).toContain('
This is a paragraph.
\n'); }); it('support checkbox is checked', () => { diff --git a/packages/x-markdown/src/XMarkdown/index.css b/packages/x-markdown/src/XMarkdown/index.css index 8cb35621f..35a440b49 100644 --- a/packages/x-markdown/src/XMarkdown/index.css +++ b/packages/x-markdown/src/XMarkdown/index.css @@ -16,6 +16,8 @@ --text-color: inherit; --font-size: inherit; --margin-block: 0 0 1em 0; + --footer-top-margin: 0.85rem; + --footer-padding: 0.85em 0; --td-th-padding: 0.85em 1em; --pre-th-td-padding: 2px 0; --border-font-weight: 600; @@ -196,3 +198,21 @@ .x-markdown .inline-katex .katex-display > .katex > .katex-html > .tag { position: static; } + +/* ========================================== + XMarkdown – footer css + ========================================== */ + +.x-markdown .x-markdown-content { + width: 100%; +} + +.x-markdown .x-markdown-footer { + width: 100%; + padding: var(--footer-padding); + border-top: 1px solid var(--border-color, #f0f0f0); +} + +.x-markdown-content:not(:empty) + .x-markdown-footer { + margin-top: var(--footer-top-margin); +} diff --git a/packages/x-markdown/src/XMarkdown/index.tsx b/packages/x-markdown/src/XMarkdown/index.tsx index bc5e43c74..0a094328d 100644 --- a/packages/x-markdown/src/XMarkdown/index.tsx +++ b/packages/x-markdown/src/XMarkdown/index.tsx @@ -20,6 +20,9 @@ const XMarkdown: React.FC = React.memo((props) => { style, openLinksInNewTab, dompurifyConfig, + footer, + footerClassName, + footerStyle, } = props; // ============================ style ============================ @@ -37,6 +40,16 @@ const XMarkdown: React.FC = React.memo((props) => { [contextDirection, style], ); + const mergedFooterCls = classnames('x-markdown-footer', footerClassName); + + const mergedFooterStyle: React.CSSProperties = useMemo( + () => ({ + direction: contextDirection === 'rtl' ? 'rtl' : 'ltr', + ...footerStyle, + }), + [contextDirection, footerStyle], + ); + // ============================ Streaming ============================ const displayContent = useStreaming(content || children || '', streaming); @@ -66,11 +79,24 @@ const XMarkdown: React.FC = React.memo((props) => { return parser.parse(displayContent); }, [displayContent, parser]); - if (!displayContent) return null; + // Memoize footer rendering to prevent unnecessary re-renders + const footerElement = useMemo(() => { + if (!footer) return null; + return ( +
+ {footer} +
+ ); + }, [footer, prefixCls, footerClassName, mergedFooterStyle]); + + if (!displayContent && !footer) return null; return (
- {renderer.render(htmlString)} +
+ {displayContent ? renderer.render(htmlString) : null} +
+ {footerElement}
); }); diff --git a/packages/x-markdown/src/XMarkdown/interface.ts b/packages/x-markdown/src/XMarkdown/interface.ts index dc73e88a6..09c71f81b 100644 --- a/packages/x-markdown/src/XMarkdown/interface.ts +++ b/packages/x-markdown/src/XMarkdown/interface.ts @@ -142,6 +142,21 @@ interface XMarkdownProps { * @description DOMPurify configuration for HTML sanitization and XSS protection */ dompurifyConfig?: DOMPurifyConfig; + /** + * @description 显示在文档末尾的 React 节点 + * @description React node displayed at the end of document + */ + footer?: React.ReactNode; + /** + * @description 文档末尾的 React 节点的 CSS 类名 + * @description CSS class name for the footer React node + */ + footerClassName?: string; + /** + * @description 文档末尾的 React 节点的内联样式 + * @description Inline styles for the footer React node + */ + footerStyle?: React.CSSProperties; } export type { XMarkdownProps, Token, Tokens, StreamStatus, ComponentProps, StreamingOption }; diff --git a/packages/x-markdown/src/plugins/Latex/__test__/__snapshots__/index.test.tsx.snap b/packages/x-markdown/src/plugins/Latex/__test__/__snapshots__/index.test.tsx.snap index cadeda2cf..15ac1b92d 100644 --- a/packages/x-markdown/src/plugins/Latex/__test__/__snapshots__/index.test.tsx.snap +++ b/packages/x-markdown/src/plugins/Latex/__test__/__snapshots__/index.test.tsx.snap @@ -6,95 +6,99 @@ exports[`LaTeX Plugin should handle LaTeX with surrounding text 1`] = ` class="ant-x-markdown x-markdown" style="direction: ltr;" > -

- This is an equation: - +

+

+ This is an equation:

+ in text +

+
`; @@ -120,217 +124,221 @@ exports[`LaTeX Plugin should handle align* syntax replacement 1`] = ` class="ant-x-markdown x-markdown" style="direction: ltr;" > -

- +

+

+

+
`; @@ -353,165 +361,107 @@ exports[`LaTeX Plugin should handle complex LaTeX expressions 1`] = ` class="ant-x-markdown x-markdown" style="direction: ltr;" > -

- +

+

-
- -`; - -exports[`LaTeX Plugin should handle content without LaTeX 1`] = ` -
-
-

- Just plain text -

-
-
-`; - -exports[`LaTeX Plugin should handle empty content 1`] = `
`; - -exports[`LaTeX Plugin should handle mixed LaTeX syntaxes 1`] = ` -
-
-

- Inline: - - - -

+
+
+
+`; + +exports[`LaTeX Plugin should handle content without LaTeX 1`] = ` +
+
+
+

+ Just plain text +

+
+
+
+`; + +exports[`LaTeX Plugin should handle empty content 1`] = `
`; + +exports[`LaTeX Plugin should handle mixed LaTeX syntaxes 1`] = ` +
+
+
+

+ Inline:

-
-
-`; - -exports[`LaTeX Plugin should handle multiple LaTeX formulas 1`] = ` -
-
-

- - - -

+

+
`; -exports[`LaTeX Plugin should not throw error by default 1`] = ` +exports[`LaTeX Plugin should handle multiple LaTeX formulas 1`] = `
-

- latex: -

-

- +

+

- \\begin{align + + + -

-
-
-`; - -exports[`LaTeX Plugin should render block LaTeX with $$..$$ syntax 1`] = ` -
-
-

- + and

+

+
`; -exports[`LaTeX Plugin should render block LaTeX with \\[..\\] syntax 1`] = ` +exports[`LaTeX Plugin should not throw error by default 1`] = `
- - +

+ latex: +

+

+

+
+
+
+`; + +exports[`LaTeX Plugin should render block LaTeX with $$..$$ syntax 1`] = ` +
+
+
+

+ + - -

+
`; -exports[`LaTeX Plugin should render inline LaTeX with $$\\n..\\n$$ syntax 1`] = ` +exports[`LaTeX Plugin should render block LaTeX with \\[..\\] syntax 1`] = `
-

- latex: +

+
+
+`; + +exports[`LaTeX Plugin should render inline LaTeX with $$\\n..\\n$$ syntax 1`] = ` +
+
+
+

+ latex: + + +

+

+
`; @@ -1684,94 +1724,98 @@ exports[`LaTeX Plugin should render inline LaTeX with $..$ syntax 1`] = ` class="ant-x-markdown x-markdown" style="direction: ltr;" > -

- +

+

+

+
`; @@ -1796,84 +1840,88 @@ exports[`LaTeX Plugin should render inline LaTeX with [\\n..\\n] syntax 1`] = ` class="ant-x-markdown x-markdown" style="direction: ltr;" > -

- latex: - +

+

+ latex:

+

+
`; diff --git a/packages/x/components/bubble/__tests__/__snapshots__/demo-extend.test.ts.snap b/packages/x/components/bubble/__tests__/__snapshots__/demo-extend.test.ts.snap index 7b1696559..357034491 100644 --- a/packages/x/components/bubble/__tests__/__snapshots__/demo-extend.test.ts.snap +++ b/packages/x/components/bubble/__tests__/__snapshots__/demo-extend.test.ts.snap @@ -3870,19 +3870,23 @@ exports[`renders components/bubble/demo/markdown.tsx extend context correctly 1` class="ant-x-markdown x-markdown" style="direction: ltr;" > -
+
+
+

+ Render as markdown content to show rich text! +

+

- Render as markdown content to show rich text! + Link: + + Ant Design X +

-
-

- Link: - - Ant Design X - -

+ diff --git a/packages/x/docs/x-markdown/demo/codeDemo/footer.tsx b/packages/x/docs/x-markdown/demo/codeDemo/footer.tsx new file mode 100644 index 000000000..dbc43844d --- /dev/null +++ b/packages/x/docs/x-markdown/demo/codeDemo/footer.tsx @@ -0,0 +1,98 @@ +import { XMarkdown } from '@ant-design/x-markdown'; +import { Button, Card, Spin } from 'antd'; +import React, { useEffect, useRef, useState } from 'react'; +import { useIntl } from 'react-intl'; + +const FooterDemo = () => { + const [content, setContent] = useState(''); + const [loading, setLoading] = useState(true); + const timer = useRef | null>(null); + const { locale } = useIntl(); + + const text = ` +# AI Assistant Response + +Hello! I'm your AI assistant. + +I'm here to help you with various tasks. + +Let me provide you with a comprehensive answer about how footer works in streaming scenarios. + +## Key Features + +- **Loading Indicators**: Show processing status +- **Completion Status**: Display when content is fully loaded +- **Custom Styling**: Fully customizable appearance + `; + + const startStreaming = () => { + setLoading(true); + setContent(''); + + let currentIndex = 0; + const streamInterval = setInterval(() => { + if (currentIndex <= text.length) { + setContent(text.slice(0, currentIndex)); + currentIndex += 2; + } else { + clearInterval(streamInterval); + setLoading(false); + } + }, 30); + + timer.current = streamInterval; + }; + + const handleReRender = () => { + if (timer.current) { + clearInterval(timer.current); + } + startStreaming(); + }; + + useEffect(() => { + startStreaming(); + return () => { + if (timer.current) { + clearInterval(timer.current); + } + }; + }, []); + + return ( + Re-Render} + > + + + AI is thinking... + + ) : ( +
+ ✨ Response completed · Generated by AI Assistant +
+ ) + } + streaming={{ hasNextChunk: loading }} + /> +
+ ); +}; + +export default FooterDemo; diff --git a/packages/x/docs/x-markdown/examples.en-US.md b/packages/x/docs/x-markdown/examples.en-US.md index 66873aa60..e08e57a6f 100644 --- a/packages/x/docs/x-markdown/examples.en-US.md +++ b/packages/x/docs/x-markdown/examples.en-US.md @@ -14,6 +14,7 @@ Used for rendering streaming Markdown format returned by LLM. + @@ -32,6 +33,9 @@ Used for rendering streaming Markdown format returned by LLM. | components | Custom React components to replace HTML elements | `Record \| keyof JSX.IntrinsicElements>`, see [details](/x-markdowns/components) | - | | paragraphTag | Custom HTML tag for paragraph elements to prevent validation errors when custom components contain block-level elements | `keyof JSX.IntrinsicElements` | `'p'` | | streaming | Configuration for streaming rendering behavior | `StreamingOption`, see [syntax processing](/x-markdowns/streaming-syntax) and [animation effects](/x-markdowns/streaming-animation) | - | +| footer | React node displayed at the end | `React.ReactNode` | - | +| footerClassName | The CSS class for the footer | `string` | - | +| footerStyle | The styles for the footer | `CSSProperties` | - | | config | Marked.js configuration for Markdown parsing and extensions | [`MarkedExtension`](https://marked.js.org/using_advanced#options) | `{ gfm: true }` | | openLinksInNewTab | Whether to add `target="_blank"` to all a tags | `boolean` | `false` | | dompurifyConfig | DOMPurify configuration for HTML sanitization and XSS protection | [`DOMPurify.Config`](https://github.com/cure53/DOMPurify#can-i-configure-dompurify) | - | diff --git a/packages/x/docs/x-markdown/examples.zh-CN.md b/packages/x/docs/x-markdown/examples.zh-CN.md index 0f849a935..1484c6650 100644 --- a/packages/x/docs/x-markdown/examples.zh-CN.md +++ b/packages/x/docs/x-markdown/examples.zh-CN.md @@ -14,6 +14,7 @@ order: 2 + @@ -32,6 +33,9 @@ order: 2 | components | 用于替换 HTML 元素的自定义 React 组件 | `Record \| keyof JSX.IntrinsicElements>`,查看[详情](/x-markdowns/components-cn) | - | | paragraphTag | 段落元素的自定义 HTML 标签,防止自定义组件包含块级元素时的验证错误 | `keyof JSX.IntrinsicElements` | `'p'` | | streaming | 流式渲染行为的配置 | `StreamingOption`,查看[语法处理](/x-markdowns/streaming-syntax)和[动画效果](/x-markdowns/streaming-animation) | - | +| footer | 显示在文档末尾的 React 节点 | `React.ReactNode` | - | +| footerClassName | 为`footer`设置CSS类 | `string` | - | +| footerStyle | 为`footer`设置内联样式 | `CSSProperties` | - | | config | Markdown 解析和扩展的 Marked.js 配置 | [`MarkedExtension`](https://marked.js.org/using_advanced#options) | `{ gfm: true }` | | openLinksInNewTab | 是否为所有 a 标签添加 `target="_blank"` | `boolean` | `false` | | dompurifyConfig | HTML 净化和 XSS 防护的 DOMPurify 配置 | [`DOMPurify.Config`](https://github.com/cure53/DOMPurify#can-i-configure-dompurify) | - |