Compare commits

...
4 changed files with 88 additions and 7 deletions
@@ -41,6 +41,32 @@ test("keeps completed block text compact", () => {
])
})
test("marks streamed inline code for animation", () => {
expect(parseMarkdownNodes("<p><code>file.ts:29-43</code></p>", true, true)).toEqual([
{
key: "0",
type: "element",
tag: "p",
attributes: {},
children: [
{
key: "0.0",
type: "element",
tag: "code",
attributes: {},
children: Array.from("file.ts:29-43", (text, index) => ({
key: `0.0.0:${index}`,
type: "word",
text,
animate: true,
})),
animate: true,
},
],
},
])
})
test("marks words for animation only when requested", () => {
expect(
parseMarkdownNodes("<p>Hello</p>", true).flatMap((node) => (node.type === "element" ? node.children : [node]))[0],
@@ -3,7 +3,14 @@ import { createStore, reconcile } from "solid-js/store"
import { Dynamic, render } from "solid-js/web"
type MarkdownNode =
| { key: string; type: "element"; tag: string; attributes: Record<string, string>; children: MarkdownNode[] }
| {
key: string
type: "element"
tag: string
attributes: Record<string, string>
children: MarkdownNode[]
animate?: true
}
| { key: string; type: "text"; text: string }
| { key: string; type: "word"; text: string; animate?: true }
@@ -38,8 +45,12 @@ function MarkdownDomNode(props: { node: MarkdownNode; animate: () => boolean })
</span>
)
}
let ref: HTMLElement | undefined
onMount(() => {
if (props.animate() && node.animate) ref?.setAttribute("data-markdown-enter", "")
})
return (
<Dynamic component={node.tag} {...node.attributes}>
<Dynamic component={node.tag} ref={ref} {...node.attributes}>
<For each={node.children}>{(node) => <MarkdownDomNode node={node} animate={props.animate} />}</For>
</Dynamic>
)
@@ -51,10 +62,10 @@ export function parseMarkdownNodes(html: string, words: boolean, animate = false
return Array.from(template.content.childNodes).flatMap((node, index) => parseNode(node, `${index}`, words, animate))
}
function parseNode(node: Node, key: string, words: boolean, animate: boolean): MarkdownNode[] {
function parseNode(node: Node, key: string, words: boolean, animate: boolean, inlineCode = false): MarkdownNode[] {
if (node instanceof Text) {
if (!words) return [{ key, type: "text", text: node.data }]
return node.data.split(/(\s+)/).flatMap((text, index): MarkdownNode[] => {
return (inlineCode ? Array.from(node.data) : 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, ...(animate ? { animate: true as const } : {}) }]
@@ -67,7 +78,18 @@ function parseNode(node: Node, key: string, words: boolean, animate: boolean): M
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, animate)),
children: Array.from(node.childNodes).flatMap((child, index) =>
parseNode(
child,
`${key}.${index}`,
words,
animate,
inlineCode || (node.tagName === "CODE" && node.parentElement?.tagName !== "PRE"),
),
),
...(words && animate && node.tagName === "CODE" && node.parentElement?.tagName !== "PRE"
? { animate: true as const }
: {}),
},
]
}
@@ -20,7 +20,7 @@
display: inline;
}
[data-markdown-word][data-markdown-enter] {
:is([data-markdown-word], code)[data-markdown-enter] {
animation: markdown-word-enter 180ms ease-out both;
}
@@ -347,7 +347,7 @@
}
@media (prefers-reduced-motion: reduce) {
[data-component="markdown"] [data-markdown-word][data-markdown-enter] {
[data-component="markdown"] :is([data-markdown-word], code)[data-markdown-enter] {
animation: none;
}
}
@@ -52,3 +52,36 @@ export const StreamingResponse = {
</div>
),
}
function StreamingInlineCodeMarkdown() {
const chunks = [
"Updated ",
"`apps/cloud",
"flare/src/",
"editor/Cloud",
"Auth.ts:29",
"-43` and ",
"`packages/",
"session-ui/src/",
"components/markdown",
"-solid.tsx`, ",
"then verified ",
"the changes with ",
"`bun type",
"check` and ",
"`bun te",
"st`.",
]
const [count, setCount] = createSignal(1)
const timer = setInterval(() => setCount((value) => (value >= chunks.length ? 1 : value + 1)), 220)
onCleanup(() => clearInterval(timer))
return <Markdown text={chunks.slice(0, count()).join("")} streaming />
}
export const StreamingInlineCode = {
render: () => (
<div class="mx-auto max-w-[760px] rounded-lg border border-border-weak-base bg-background-base px-5 py-4">
<StreamingInlineCodeMarkdown />
</div>
),
}