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
4 changes: 4 additions & 0 deletions packages/streamdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,10 @@ export const Block = memo(
if (animatePluginProp) {
const prevCount = animatePluginProp.getLastRenderCharCount();
animatePluginProp.setPrevContentLength(prevCount);
// When the block has an incomplete code fence, animate code block
// content incrementally instead of skipping it. This gives visual
// feedback that code is streaming in token-by-token.
animatePluginProp.setAnimateCodeBlocks(isIncomplete);
}

// Note: remend is already applied to the entire markdown before parsing into blocks
Expand Down
25 changes: 21 additions & 4 deletions packages/streamdown/lib/animate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ export interface AnimatePlugin {
getLastRenderCharCount: () => number;
name: "animate";
rehypePlugin: Pluggable;
/**
* Enable or disable animation inside code/pre blocks.
* When true, code block content will be animated incrementally
* during streaming (useful for incomplete/unclosed code fences).
*/
setAnimateCodeBlocks: (enabled: boolean) => void;
/**
* Set the number of HAST text characters from a previous render.
* Characters up to this count will get duration=0ms, preventing
Expand All @@ -31,17 +37,23 @@ export interface AnimateOptions {
const WHITESPACE_RE = /\s/;
const WHITESPACE_ONLY_RE = /^\s+$/;
const SKIP_TAGS = new Set(["code", "pre", "svg", "math", "annotation"]);
const SKIP_TAGS_WITHOUT_CODE = new Set(["svg", "math", "annotation"]);

const isElement = (node: unknown): node is Element =>
typeof node === "object" &&
node !== null &&
"type" in node &&
(node as Element).type === "element";

const hasSkipAncestor = (ancestors: Node[]): boolean =>
ancestors.some(
(ancestor) => isElement(ancestor) && SKIP_TAGS.has(ancestor.tagName)
const hasSkipAncestor = (
ancestors: Node[],
animateCodeBlocks: boolean
): boolean => {
const tags = animateCodeBlocks ? SKIP_TAGS_WITHOUT_CODE : SKIP_TAGS;
return ancestors.some(
(ancestor) => isElement(ancestor) && tags.has(ancestor.tagName)
);
};

const splitByWord = (text: string): string[] => {
const parts: string[] = [];
Expand Down Expand Up @@ -126,6 +138,7 @@ interface AnimateConfig {
* object that setPrevContentLength / getLastRenderCharCount mutate.
*/
interface AnimateRenderState {
animateCodeBlocks: boolean;
lastRenderCharCount: number;
prevContentLength: number;
}
Expand All @@ -143,7 +156,7 @@ const processTextNode = (
return;
}

if (hasSkipAncestor(ancestors)) {
if (hasSkipAncestor(ancestors, renderState.animateCodeBlocks)) {
return SKIP;
}

Expand Down Expand Up @@ -203,6 +216,7 @@ export function createAnimatePlugin(options?: AnimateOptions): AnimatePlugin {
// Mutable render state — the rehype closure and the plugin API methods
// both reference this same object.
const renderState: AnimateRenderState = {
animateCodeBlocks: false,
prevContentLength: 0,
lastRenderCharCount: 0,
};
Expand Down Expand Up @@ -230,6 +244,9 @@ export function createAnimatePlugin(options?: AnimateOptions): AnimatePlugin {
name: "animate",
type: "animate",
rehypePlugin: rehypeAnimate,
setAnimateCodeBlocks(enabled: boolean) {
renderState.animateCodeBlocks = enabled;
},
setPrevContentLength(length: number) {
renderState.prevContentLength = length;
},
Expand Down