Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions packages/x-markdown/src/XMarkdown/__tests__/Renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1516,6 +1516,58 @@ describe('Renderer', () => {

createElementSpy.mockRestore();
});

it('should apply animation to text nodes inside custom components when enableAnimationForCustomComponents is true', () => {
const createElementSpy = jest.spyOn(React, 'createElement');

const CustomComponent: React.FC<any> = (props) => React.createElement('div', props);
const renderer = new Renderer({
components: { 'content-render': CustomComponent },
streaming: {
enableAnimation: true,
enableAnimationForCustomComponents: true,
animationConfig: {
fadeDuration: 150,
easing: 'ease-out',
},
},
});

const html = '<content-render>Hello World</content-render>';
renderer.processHtml(html);

// Verify that AnimationText was used for text inside custom component
const animationTextCalls = createElementSpy.mock.calls.filter(
(call) => call[0]?.displayName === 'AnimationText' || call[0]?.name === 'AnimationText',
);
expect(animationTextCalls.length).toBeGreaterThan(0);

createElementSpy.mockRestore();
});

it('should NOT apply animation to text nodes inside custom components when enableAnimationForCustomComponents is false', () => {
const createElementSpy = jest.spyOn(React, 'createElement');

const CustomComponent: React.FC<any> = (props) => React.createElement('div', props);
const renderer = new Renderer({
components: { 'content-render': CustomComponent },
streaming: {
enableAnimation: true,
enableAnimationForCustomComponents: false,
},
});

const html = '<content-render>Hello World</content-render>';
renderer.processHtml(html);

// Verify that AnimationText was NOT used for text inside custom component
const animationTextCalls = createElementSpy.mock.calls.filter(
(call) => call[0]?.displayName === 'AnimationText' || call[0]?.name === 'AnimationText',
);
expect(animationTextCalls.length).toBe(0);

createElementSpy.mockRestore();
});
});

describe('SSR / no DOM safety', () => {
Expand Down
9 changes: 7 additions & 2 deletions packages/x-markdown/src/XMarkdown/core/Renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,17 +172,22 @@ class Renderer {
unclosedTags: Set<string> | undefined,
cidRef: { current: number; tagIndexes: Record<string, number> },
) {
const { enableAnimation, animationConfig } = this.options.streaming || {};
const { enableAnimation, animationConfig, enableAnimationForCustomComponents } =
this.options.streaming || {};
return (domNode: DOMNode) => {
const key = `x-markdown-component-${cidRef.current++}`;

// Check if it's a text node with data
const isValidTextNode =
domNode.type === 'text' && domNode.data && Renderer.NON_WHITESPACE_REGEX.test(domNode.data);
// Skip animation for text nodes inside custom components to preserve their internal structure
// unless enableAnimationForCustomComponents is true
const parentTagName = (domNode.parent as Element)?.name;
const isParentCustomComponent = parentTagName && this.options.components?.[parentTagName];
const shouldReplaceText = enableAnimation && isValidTextNode && !isParentCustomComponent;
const shouldReplaceText =
enableAnimation &&
isValidTextNode &&
(!isParentCustomComponent || enableAnimationForCustomComponents);
Comment on lines 185 to +190

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

修正自定义组件后代文本的判定。

第 185-190 行只检查 domNode.parent。当输入为 <content-render><span>Hello World</span></content-render> 时,文本节点的父节点是 span。即使 enableAnimationForCustomComponentsfalse,代码仍会创建 AnimationText

遍历文本节点的祖先节点,并在任一祖先是已配置的自定义组件时禁止动画。添加嵌套元素的禁用场景测试。

  • packages/x-markdown/src/XMarkdown/core/Renderer.ts#L185-L190: 检查全部祖先节点,而不是只检查直接父节点。
  • packages/x-markdown/src/XMarkdown/__tests__/Renderer.test.ts#L1548-L1567: 使用嵌套元素作为自定义组件内容,并断言不会创建 AnimationText
📍 Affects 2 files
  • packages/x-markdown/src/XMarkdown/core/Renderer.ts#L185-L190 (this comment)
  • packages/x-markdown/src/XMarkdown/__tests__/Renderer.test.ts#L1548-L1567
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/x-markdown/src/XMarkdown/core/Renderer.ts` around lines 185 - 190,
Update the ancestor detection in Renderer’s text-node animation decision to
traverse all domNode ancestors, not only domNode.parent, and disable
AnimationText whenever any ancestor is a configured custom component unless
enableAnimationForCustomComponents is enabled. In
packages/x-markdown/src/XMarkdown/__tests__/Renderer.test.ts:1548-1567, add or
update coverage using nested elements inside a custom component and assert that
AnimationText is not created.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (shouldReplaceText) {
return React.createElement(AnimationText, { text: domNode.data, key, animationConfig });
}
Expand Down
6 changes: 6 additions & 0 deletions packages/x-markdown/src/XMarkdown/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ interface StreamingOption {
string
>
>;
/**
* @description 是否为自定义组件内的文本节点启用打字机动画效果,配合 contentRender 使用时可实现自定义渲染的逐字输出
* @description Whether to enable typewriter animation for text nodes inside custom components, enabling character-by-character output when used with contentRender
* @default false
*/
enableAnimationForCustomComponents?: boolean;
}

type StreamStatus = 'loading' | 'done';
Expand Down
Loading