@goodpie/html-typer
Version:
Types out HTML into a view, supporting vanilla JS
123 lines (109 loc) • 3.82 kB
text/typescript
import DOMPurify from 'dompurify';
export interface Chunk {
tagName: string;
classes?: string[];
content?: string;
children?: Chunk[];
}
/**
* Animates the rendering of HTML content by gradually adding text.
*
* @param container - The element to render content into.
* @param html - The HTML string to animate.
* @param speed - The interval in milliseconds between each character.
* @returns A promise that resolves when the animation is complete.
*/
export const animateHtmlRendering = async (
container: HTMLElement,
html: string,
speed: number = 50
): Promise<void> => {
speed = Math.max(1, speed);
// Sanitize HTML before processing
const sanitizedHtml = DOMPurify.sanitize(html);
container.innerHTML = '';
const chunks: Chunk[] = splitHtmlString(sanitizedHtml);
// animateTextContent now has access to container via closure.
const animateTextContent = (element: Node, text: string, speed: number): Promise<void> => {
return new Promise(resolve => {
let index = 0;
const textInterval = setInterval(() => {
if (index < text.length) {
element.textContent += text[index++];
} else {
clearInterval(textInterval);
resolve();
}
// Auto-scroll to the bottom of the container's parent
requestAnimationFrame(() => {
if (container.parentElement) {
container.parentElement.scrollTop = container.parentElement.scrollHeight;
}
});
}, speed);
});
};
const processChunk = async (chunks: Chunk[], parent: HTMLElement, speed: number) => {
for (const chunk of chunks) {
if (chunk.tagName === "#text") {
const textNode = document.createTextNode("");
parent.appendChild(textNode);
await animateTextContent(textNode, chunk.content || "", speed);
} else {
const newElement = document.createElement(chunk.tagName);
if (chunk.classes && chunk.classes.length > 0) {
newElement.classList.add(...chunk.classes);
}
parent.appendChild(newElement);
if (chunk.content) {
await animateTextContent(newElement, chunk.content, speed);
}
if (chunk.children && chunk.children.length > 0) {
await processChunk(chunk.children, newElement, speed);
}
}
}
};
await processChunk(chunks, container, speed);
};
/**
* Splits an HTML string into a structured set of chunks for animation.
*
* @param input - The HTML string to parse.
* @returns An array of chunk objects representing the DOM structure.
*/
const splitHtmlString = (input: string): Chunk[] => {
const parser = new DOMParser();
const doc = parser.parseFromString(input, 'text/html');
const result: Chunk[] = [];
const traverse = (node: ChildNode, parent: Chunk | null = null) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const element: Chunk = {
tagName: (node as HTMLElement).tagName.toLowerCase(),
classes: (node as HTMLElement).className
? (node as HTMLElement).className.split(/\s+/).filter(c => !!c)
: [],
children: []
};
if (parent) {
parent.children!.push(element);
} else {
result.push(element);
}
node.childNodes.forEach(child => traverse(child, element));
} else if (node.nodeType === Node.TEXT_NODE && node.textContent && node.textContent.trim()) {
const textChunk = {
tagName: "#text",
content: node.textContent.trim()
};
// Push text nodes at any level, including top-level (when parent is null)
if (parent) {
parent.children!.push(textChunk);
} else {
result.push(textChunk);
}
}
};
doc.body.childNodes.forEach(node => traverse(node));
return result;
};