mirror of https://github.com/buster-so/buster.git
Enhance markdown streaming with word-by-word animation and improved content handling
Co-authored-by: natemkelley <natemkelley@gmail.com>
This commit is contained in:
parent
612700582f
commit
a72f66d188
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue