Enhance markdown streaming with word-by-word animation and improved content handling

Co-authored-by: natemkelley <natemkelley@gmail.com>
This commit is contained in:
Cursor Agent 2025-07-01 16:09:22 +00:00
parent 612700582f
commit a72f66d188
2 changed files with 305 additions and 80 deletions

View File

@ -368,97 +368,138 @@ The user wants to save the existing "Monthly Sales by Sales Rep" chart into a da
// Streaming content simulation
const StreamingMarkdown = () => {
const [streamedContent, setStreamedContent] = useState('# Streaming Markdown Demo\n\n');
const [streamedContent, setStreamedContent] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const fullContent = `# Streaming Markdown Demo
const fullContent = `# Streaming Word-by-Word Demo
## Introduction
This is a **comprehensive test** of the streaming animation system. Each word should fade in smoothly as it appears, creating a natural reading experience.
This demonstrates how markdown content can be streamed and animated as it appears. Each new word, sentence, or paragraph will fade in smoothly as it's added to the content.
## Paragraph Streaming
## Streaming Features
Here we test how words get added to existing paragraphs. This sentence will demonstrate **bold text** and *italic text* being streamed word by word. The animation should be smooth and natural.
**Real-time updates**: Content appears progressively as it's streamed from a server or API.
## Mixed Content Test
**Smooth animations**: Each new piece of content fades in with appropriate timing.
### Subheading Animation
**Paragraph continuity**: When words are added to existing paragraphs, they animate in seamlessly.
This section tests streaming with **multiple formatting** types:
### Code Examples
- First list item with **bold content**
- Second item with *italic styling*
- Third item with ~~strikethrough~~ text
- Fourth item with \`inline code\` blocks
Streaming can include complex formatting like:
### Complex Paragraph
\`\`\`javascript
function streamContent(data) {
// Process streaming data
const chunks = data.split(' ');
chunks.forEach((chunk, index) => {
setTimeout(() => {
appendToStream(chunk);
}, index * 100);
});
}
\`\`\`
This paragraph contains **bold text**, *italic text*, ~~strikethrough~~, and \`inline code\`. Each word should animate in individually while preserving the markdown formatting. The streaming should feel natural and not overwhelming.
### Lists and Other Elements
## Blockquote Test
- First item appears
- Second item fades in
- Third item follows smoothly
- Complex **formatting** works too
- Including *italic* text
- And ~~strikethrough~~ text
> This blockquote will stream in word by word, testing how the animation works with different markdown elements. Each word should have its own fade-in animation.
### Tables Stream Too
## Final Notes
| Column 1 | Column 2 | Column 3 |
|----------|----------|----------|
| Row 1 | Data | Values |
| Row 2 | More | Content |
| Row 3 | Final | Row |
> Even blockquotes can be streamed progressively, maintaining proper formatting and animation timing throughout the entire process.
## Conclusion
This streaming approach provides a natural, engaging way to display content as it's generated or received, perfect for AI responses, live updates, or progressive content loading.`;
The streaming should work seamlessly across all markdown elements, providing a smooth and engaging user experience for real-time content updates.`;
const startStreaming = () => {
setIsStreaming(true);
setStreamedContent('# Streaming Markdown Demo\n\n');
setStreamedContent('');
const words = fullContent.split(' ');
let currentContent = '# Streaming Markdown Demo\n\n';
// Split content into words while preserving markdown structure
const words = fullContent.split(/(\s+)/); // Split but keep whitespace
let currentIndex = 0;
words.slice(4).forEach((word, index) => { // Skip the first few words that are already there
setTimeout(() => {
currentContent += word + ' ';
setStreamedContent(currentContent);
if (index === words.length - 5) { // Last word
setIsStreaming(false);
}
}, index * 80); // 80ms delay between words
});
const streamNextWord = () => {
if (currentIndex < words.length) {
setStreamedContent((prev: string) => prev + words[currentIndex]);
currentIndex++;
// Variable timing - faster for whitespace, slower for words
const isWhitespace = words[currentIndex - 1]?.trim() === '';
const delay = isWhitespace ? 10 : 120; // 10ms for spaces, 120ms for words
setTimeout(streamNextWord, delay);
} else {
setIsStreaming(false);
}
};
streamNextWord();
};
const startFastStreaming = () => {
setIsStreaming(true);
setStreamedContent('');
const words = fullContent.split(/(\s+)/);
let currentIndex = 0;
const streamNextWord = () => {
if (currentIndex < words.length) {
setStreamedContent((prev: string) => prev + words[currentIndex]);
currentIndex++;
const isWhitespace = words[currentIndex - 1]?.trim() === '';
const delay = isWhitespace ? 5 : 50; // Faster streaming
setTimeout(streamNextWord, delay);
} else {
setIsStreaming(false);
}
};
streamNextWord();
};
const startParagraphStreaming = () => {
setIsStreaming(true);
setStreamedContent('# Paragraph Test\n\nThis is the beginning of a sentence');
const additionalWords = [
' that', ' will', ' continue', ' to', ' grow', ' word', ' by', ' word',
' with', ' **bold**', ' and', ' *italic*', ' formatting', ' included.',
' Each', ' new', ' word', ' should', ' animate', ' in', ' smoothly.'
];
additionalWords.forEach((word, index) => {
setTimeout(() => {
setStreamedContent((prev: string) => prev + word);
if (index === additionalWords.length - 1) {
setIsStreaming(false);
}
}, index * 150);
});
};
const resetContent = () => {
setStreamedContent('# Streaming Markdown Demo\n\n');
setStreamedContent('');
setIsStreaming(false);
};
return (
<div className="space-y-4">
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
<Button onClick={startStreaming} disabled={isStreaming}>
{isStreaming ? 'Streaming...' : 'Start Streaming'}
{isStreaming ? 'Streaming...' : 'Start Full Demo'}
</Button>
<Button onClick={startFastStreaming} disabled={isStreaming}>
Fast Stream
</Button>
<Button onClick={startParagraphStreaming} disabled={isStreaming}>
Paragraph Test
</Button>
<Button onClick={resetContent} disabled={isStreaming}>
Reset
</Button>
</div>
<div className="text-sm text-gray-600">
<p><strong>Full Demo:</strong> Complete streaming test with all markdown elements</p>
<p><strong>Fast Stream:</strong> Same content but faster timing</p>
<p><strong>Paragraph Test:</strong> Tests word-by-word addition to existing paragraphs</p>
</div>
<div className="p-4 border rounded-lg bg-gray-50">
<AppMarkdown
markdown={streamedContent}

View File

@ -1,5 +1,6 @@
import { cva } from 'class-variance-authority';
import type React from 'react';
import { useEffect, useRef, useState } from 'react';
import type { ExtraProps } from 'react-markdown';
import { cn } from '../../../../lib/classMerge';
import { AppCodeBlock } from '../AppCodeBlock/AppCodeBlock';
@ -10,6 +11,64 @@ const getAnimationClass = (showLoader: boolean, isStreaming: boolean = false): s
return isStreaming ? 'streaming-content' : 'fade-in duration-700';
};
// Utility to diff content and wrap new words with animations
const useStreamingContent = (
newContent: string,
isStreaming: boolean,
showLoader: boolean
): React.ReactNode => {
const [renderedContent, setRenderedContent] = useState<React.ReactNode>(newContent);
const previousContentRef = useRef<string>('');
const animationKeyRef = useRef<number>(0);
useEffect(() => {
if (!isStreaming || !showLoader) {
setRenderedContent(newContent);
previousContentRef.current = newContent;
return;
}
const prevContent = previousContentRef.current;
// If new content is longer than previous, we have new content to animate
if (newContent.length > prevContent.length && newContent.startsWith(prevContent)) {
const newPart = newContent.slice(prevContent.length);
const existingPart = prevContent;
// Split new content into words for individual animation
const newWords = newPart.split(/(\s+)/); // Split but keep whitespace
const animatedNewContent = newWords.map((word, index) => {
if (word.trim() === '') return word; // Return whitespace as-is
animationKeyRef.current += 1;
return (
<span
key={`stream-${animationKeyRef.current}`}
className="streaming-content"
>
{word}
</span>
);
});
setRenderedContent(
<>
{existingPart}
{animatedNewContent}
</>
);
} else {
// If content changed in other ways, just render it normally
setRenderedContent(newContent);
}
previousContentRef.current = newContent;
}, [newContent, isStreaming, showLoader]);
return renderedContent;
};
export interface ExtraPropsExtra extends ExtraProps {
numberOfLineMarkdown: number;
isStreaming?: boolean;
@ -42,24 +101,27 @@ export const CustomParagraph: React.FC<
isStreaming?: boolean;
} & ExtraPropsExtra
> = ({ children, markdown, showLoader, isStreaming = false, ...rest }) => {
// Convert children to string for diffing
const textContent = typeof children === 'string' ? children :
Array.isArray(children) ? children.join('') :
children?.toString() || '';
if (Array.isArray(children)) {
return (
<p className={cn('text-size-inherit! transform-none!', getAnimationClass(showLoader, isStreaming))}>
{children}
</p>
);
}
const streamedContent = useStreamingContent(textContent, isStreaming, showLoader);
//weird bug where all web components are rendered as p
//web components are objects
if (typeof children === 'object') {
// For non-text children (like other React elements), pass through normally
if (typeof children === 'object' && !Array.isArray(children) && children !== null) {
return <>{children}</>;
}
const getBaseAnimationClass = () => {
if (!showLoader) return '';
// Only apply base animation if not streaming (to avoid double animation)
return !isStreaming ? 'fade-in duration-700' : '';
};
return (
<p className={cn('text-size-inherit! transform-none!', getAnimationClass(showLoader, isStreaming))}>
{children}
<p className={cn('text-size-inherit! transform-none!', getBaseAnimationClass())}>
{isStreaming && showLoader ? streamedContent : children}
</p>
);
};
@ -91,14 +153,26 @@ export const CustomHeading: React.FC<
> = ({ level, children, markdown, stripFormatting = false, showLoader, isStreaming = false, ...rest }) => {
const HeadingTag = `h${level}` as keyof JSX.IntrinsicElements;
// Convert children to string for diffing
const textContent = typeof children === 'string' ? children :
Array.isArray(children) ? children.join('') :
children?.toString() || '';
const streamedContent = useStreamingContent(textContent, isStreaming, showLoader);
const getBaseAnimationClass = () => {
if (!showLoader) return '';
return !isStreaming ? 'fade-in duration-700' : '';
};
return (
<HeadingTag
className={cn(
headingVariants({ level: stripFormatting ? 'base' : level }),
'transform-none!',
getAnimationClass(showLoader, isStreaming)
getBaseAnimationClass()
)}>
{children}
{isStreaming && showLoader ? streamedContent : children}
</HeadingTag>
);
};
@ -149,9 +223,22 @@ export const CustomListItem: React.FC<
isStreaming?: boolean;
} & ExtraPropsExtra
> = ({ children, showLoader, isStreaming = false }) => {
// Convert children to string for diffing if it's simple text
const textContent = typeof children === 'string' ? children :
(Array.isArray(children) && children.every(child => typeof child === 'string')) ?
children.join('') : null;
const streamedContent = textContent ?
useStreamingContent(textContent, isStreaming, showLoader) : null;
const getBaseAnimationClass = () => {
if (!showLoader) return '';
return !isStreaming ? 'fade-in duration-700' : '';
};
return (
<li className={cn('transform-none! space-y-1', getAnimationClass(showLoader, isStreaming))}>
{children}
<li className={cn('transform-none! space-y-1', getBaseAnimationClass())}>
{isStreaming && showLoader && streamedContent ? streamedContent : children}
</li>
);
};
@ -194,8 +281,21 @@ export const CustomSpan: React.FC<
isStreaming?: boolean;
} & ExtraPropsExtra
> = ({ children, markdown, showLoader, isStreaming = false, ...rest }) => {
const textContent = typeof children === 'string' ? children :
Array.isArray(children) ? children.join('') :
children?.toString() || '';
const streamedContent = useStreamingContent(textContent, isStreaming, showLoader);
const getBaseAnimationClass = () => {
if (!showLoader) return '';
return !isStreaming ? 'fade-in duration-700' : '';
};
return (
<span className={cn('transform-none!', getAnimationClass(showLoader, isStreaming))}>{children}</span>
<span className={cn('transform-none!', getBaseAnimationClass())}>
{isStreaming && showLoader ? streamedContent : children}
</span>
);
};
@ -207,9 +307,20 @@ export const CustomStrong: React.FC<
isStreaming?: boolean;
} & ExtraPropsExtra
> = ({ children, markdown, showLoader, isStreaming = false, ...rest }) => {
const textContent = typeof children === 'string' ? children :
Array.isArray(children) ? children.join('') :
children?.toString() || '';
const streamedContent = useStreamingContent(textContent, isStreaming, showLoader);
const getBaseAnimationClass = () => {
if (!showLoader) return '';
return !isStreaming ? 'fade-in duration-700' : '';
};
return (
<strong className={cn('transform-none!', getAnimationClass(showLoader, isStreaming))}>
{children}
<strong className={cn('transform-none!', getBaseAnimationClass())}>
{isStreaming && showLoader ? streamedContent : children}
</strong>
);
};
@ -222,8 +333,21 @@ export const CustomEm: React.FC<
isStreaming?: boolean;
} & ExtraPropsExtra
> = ({ children, markdown, showLoader, isStreaming = false, ...rest }) => {
const textContent = typeof children === 'string' ? children :
Array.isArray(children) ? children.join('') :
children?.toString() || '';
const streamedContent = useStreamingContent(textContent, isStreaming, showLoader);
const getBaseAnimationClass = () => {
if (!showLoader) return '';
return !isStreaming ? 'fade-in duration-700' : '';
};
return (
<em className={cn('transform-none!', getAnimationClass(showLoader, isStreaming))}>{children}</em>
<em className={cn('transform-none!', getBaseAnimationClass())}>
{isStreaming && showLoader ? streamedContent : children}
</em>
);
};
@ -235,7 +359,22 @@ export const CustomItalic: React.FC<
isStreaming?: boolean;
} & ExtraPropsExtra
> = ({ children, markdown, showLoader, isStreaming = false, ...rest }) => {
return <i className={cn('transform-none!', getAnimationClass(showLoader, isStreaming))}>{children}</i>;
const textContent = typeof children === 'string' ? children :
Array.isArray(children) ? children.join('') :
children?.toString() || '';
const streamedContent = useStreamingContent(textContent, isStreaming, showLoader);
const getBaseAnimationClass = () => {
if (!showLoader) return '';
return !isStreaming ? 'fade-in duration-700' : '';
};
return (
<i className={cn('transform-none!', getBaseAnimationClass())}>
{isStreaming && showLoader ? streamedContent : children}
</i>
);
};
export const CustomUnderline: React.FC<
@ -246,7 +385,22 @@ export const CustomUnderline: React.FC<
isStreaming?: boolean;
} & ExtraPropsExtra
> = ({ children, markdown, showLoader, isStreaming = false, ...rest }) => {
return <u className={cn('transform-none!', getAnimationClass(showLoader, isStreaming))}>{children}</u>;
const textContent = typeof children === 'string' ? children :
Array.isArray(children) ? children.join('') :
children?.toString() || '';
const streamedContent = useStreamingContent(textContent, isStreaming, showLoader);
const getBaseAnimationClass = () => {
if (!showLoader) return '';
return !isStreaming ? 'fade-in duration-700' : '';
};
return (
<u className={cn('transform-none!', getBaseAnimationClass())}>
{isStreaming && showLoader ? streamedContent : children}
</u>
);
};
export const CustomStrikethrough: React.FC<
@ -257,7 +411,22 @@ export const CustomStrikethrough: React.FC<
isStreaming?: boolean;
} & ExtraPropsExtra
> = ({ children, markdown, showLoader, isStreaming = false, ...rest }) => {
return <s className={cn('transform-none!', getAnimationClass(showLoader, isStreaming))}>{children}</s>;
const textContent = typeof children === 'string' ? children :
Array.isArray(children) ? children.join('') :
children?.toString() || '';
const streamedContent = useStreamingContent(textContent, isStreaming, showLoader);
const getBaseAnimationClass = () => {
if (!showLoader) return '';
return !isStreaming ? 'fade-in duration-700' : '';
};
return (
<s className={cn('transform-none!', getBaseAnimationClass())}>
{isStreaming && showLoader ? streamedContent : children}
</s>
);
};
export const CustomLink: React.FC<
@ -268,5 +437,20 @@ export const CustomLink: React.FC<
isStreaming?: boolean;
} & ExtraPropsExtra
> = ({ children, markdown, showLoader, isStreaming = false, ...rest }) => {
return <a className={cn('transform-none!', getAnimationClass(showLoader, isStreaming))}>{children}</a>;
const textContent = typeof children === 'string' ? children :
Array.isArray(children) ? children.join('') :
children?.toString() || '';
const streamedContent = useStreamingContent(textContent, isStreaming, showLoader);
const getBaseAnimationClass = () => {
if (!showLoader) return '';
return !isStreaming ? 'fade-in duration-700' : '';
};
return (
<a className={cn('transform-none!', getBaseAnimationClass())}>
{isStreaming && showLoader ? streamedContent : children}
</a>
);
};