fix(app): avoid replaying streamed markdown fade (#44396)

This commit is contained in:
Brendan Allan
2026-08-23 21:10:43 +08:00
committed by GitHub
parent de11122c96
commit 1def4aa35a
3 changed files with 43 additions and 16 deletions
@@ -40,3 +40,23 @@ test("keeps completed block text compact", () => {
},
])
})
test("marks words for animation only when requested", () => {
expect(
parseMarkdownNodes("<p>Hello</p>", true).flatMap((node) => (node.type === "element" ? node.children : [node]))[0],
).toEqual({
key: "0.0:0",
type: "word",
text: "Hello",
})
expect(
parseMarkdownNodes("<p>Hello</p>", true, true).flatMap((node) =>
node.type === "element" ? node.children : [node],
)[0],
).toEqual({
key: "0.0:0",
type: "word",
text: "Hello",
animate: true,
})
})
@@ -5,7 +5,7 @@ import { Dynamic, render } from "solid-js/web"
type MarkdownNode =
| { key: string; type: "element"; tag: string; attributes: Record<string, string>; children: MarkdownNode[] }
| { key: string; type: "text"; text: string }
| { key: string; type: "word"; text: string }
| { key: string; type: "word"; text: string; animate?: true }
export function createMarkdownRenderer(root: HTMLDivElement, html: string, words: boolean) {
const [nodes, setNodes] = createStore(parseMarkdownNodes(html, words))
@@ -17,8 +17,8 @@ export function createMarkdownRenderer(root: HTMLDivElement, html: string, words
ready = true
return {
update(next: string, nextWords: boolean) {
setNodes(reconcile(parseMarkdownNodes(next, nextWords), { key: "key" }))
update(next: string, nextWords: boolean, animate = true) {
setNodes(reconcile(parseMarkdownNodes(next, nextWords, animate), { key: "key" }))
},
dispose,
}
@@ -30,7 +30,7 @@ function MarkdownDomNode(props: { node: MarkdownNode; animate: () => boolean })
if (node.type === "word") {
let ref: HTMLSpanElement | undefined
onMount(() => {
if (props.animate()) ref?.setAttribute("data-markdown-enter", "")
if (props.animate() && node.animate) ref?.setAttribute("data-markdown-enter", "")
})
return (
<span ref={ref} data-markdown-word="">
@@ -45,19 +45,19 @@ function MarkdownDomNode(props: { node: MarkdownNode; animate: () => boolean })
)
}
export function parseMarkdownNodes(html: string, words: boolean) {
export function parseMarkdownNodes(html: string, words: boolean, animate = false) {
const template = document.createElement("template")
template.innerHTML = html
return Array.from(template.content.childNodes).flatMap((node, index) => parseNode(node, `${index}`, words))
return Array.from(template.content.childNodes).flatMap((node, index) => parseNode(node, `${index}`, words, animate))
}
function parseNode(node: Node, key: string, words: boolean): MarkdownNode[] {
function parseNode(node: Node, key: string, words: boolean, animate: boolean): MarkdownNode[] {
if (node instanceof Text) {
if (!words) return [{ key, type: "text", text: node.data }]
return node.data.split(/(\s+)/).flatMap((text, index): MarkdownNode[] => {
if (!text) return []
if (/^\s+$/.test(text)) return [{ key: `${key}:${index}`, type: "text", text }]
return [{ key: `${key}:${index}`, type: "word", text }]
return [{ key: `${key}:${index}`, type: "word", text, ...(animate ? { animate: true as const } : {}) }]
})
}
if (!(node instanceof Element)) return []
@@ -67,7 +67,7 @@ function parseNode(node: Node, key: string, words: boolean): MarkdownNode[] {
type: "element",
tag: node.tagName.toLowerCase(),
attributes: Object.fromEntries(Array.from(node.attributes).map((attribute) => [attribute.name, attribute.value])),
children: Array.from(node.childNodes).flatMap((child, index) => parseNode(child, `${key}.${index}`, words)),
children: Array.from(node.childNodes).flatMap((child, index) => parseNode(child, `${key}.${index}`, words, animate)),
},
]
}
@@ -54,7 +54,10 @@ type RenderResult = {
}
const renderedCodeTokens = new WeakMap<HTMLDivElement, RenderedCodeState>()
const renderedMarkdown = new WeakMap<HTMLDivElement, ReturnType<typeof createMarkdownRenderer>>()
const renderedMarkdown = new WeakMap<
HTMLDivElement,
{ renderer: ReturnType<typeof createMarkdownRenderer>; raw: string }
>()
function escape(text: string) {
return text
@@ -184,7 +187,7 @@ function disposeRenderedMarkdown(root: Element) {
...Array.from(root.querySelectorAll<HTMLDivElement>("[data-markdown-block]")),
]
blocks.forEach((block) => {
renderedMarkdown.get(block)?.dispose()
renderedMarkdown.get(block)?.renderer.dispose()
renderedMarkdown.delete(block)
})
}
@@ -629,17 +632,21 @@ function updateBlock(container: HTMLDivElement, index: number, block: RenderedBl
const html = source.innerHTML
if (existing) {
const renderer = renderedMarkdown.get(existing)
if (renderer) {
renderer.update(html, block.mode === "live")
const rendered = renderedMarkdown.get(existing)
if (rendered) {
rendered.renderer.update(html, block.mode === "live", rendered.raw !== block.raw)
rendered.raw = block.raw
return
}
existing.innerHTML = ""
renderedMarkdown.set(existing, createMarkdownRenderer(existing, html, block.mode === "live"))
renderedMarkdown.set(existing, {
renderer: createMarkdownRenderer(existing, html, block.mode === "live"),
raw: block.raw,
})
return
}
renderedMarkdown.set(next, createMarkdownRenderer(next, html, block.mode === "live"))
renderedMarkdown.set(next, { renderer: createMarkdownRenderer(next, html, block.mode === "live"), raw: block.raw })
if (!current) {
container.appendChild(next)
return