react-typing-hook
Version:
Animated typing in ~400 bytes 🐡 of React Hook.
108 lines (85 loc) • 3.12 kB
text/typescript
import { useEffect, useRef, RefObject } from "react";
import './index.css'
type Typing<T> = (node: T, speed: number, ...args: TypingSteps<T>) => Promise<void>
type Editor<T> = (node: T) => number
type GeneratorEditor<T> = Generator<Editor<T>, void, Editor<T>>
type TypingSteps<T> = Array<string | number | (() => any) | Typing<T>>
interface TypingOptions<T> {
steps: TypingSteps<T>,
loop?: number,
speed?: number
}
export default function useTyping<T extends HTMLElement>({ steps, loop, speed = 60 } : TypingOptions<T>) : RefObject<T> {
const ref = useRef<T>(null);
async function typing<T extends HTMLElement>(node: T, speed: number, ...args: TypingSteps<T>): Promise<void> {
for (const arg of args) {
switch (typeof arg) {
case "string":
await edit(node, speed, arg);
break;
case "number":
await wait(arg);
break;
case "function":
await arg(node, speed, ...args);
break;
default:
await arg;
}
}
}
async function edit<T extends HTMLElement>(node: T, speed: number, text: string): Promise<void> {
const textContent = node.textContent || '';
const overlap = getOverlap(textContent, text);
await perform<T>(node, speed, [
...deleter(textContent, overlap) as Iterable<string>,
...writer(text, overlap) as Iterable<string>,
]);
}
async function wait(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function perform<T>(node: T, speed: number, edits: Iterable<string>): Promise<void> {
for (const op of editor(edits) as Iterable<Editor<T>>) {
op(node);
await wait(speed + speed * (Math.random() - 0.5));
}
}
function* editor<T extends HTMLElement>(edits: Iterable<string>): GeneratorEditor<T> {
for (const edit of edits) {
yield (node: T) => requestAnimationFrame(() => (node.textContent = edit));
}
}
function* writer([...text]: Iterable<string>, startIndex = 0, endIndex = text.length): Generator<string, void, string> {
while (startIndex < endIndex) {
yield text.slice(0, ++startIndex).join("");
}
}
function* deleter([...text]: Iterable<string>, startIndex = 0, endIndex = text.length): Generator<string, void, string> {
while (endIndex > startIndex) {
yield text.slice(0, --endIndex).join("");
}
}
function getOverlap(start: string, [...end]: string): number {
return [...start, NaN].findIndex((char, i) => end[i] !== char);
}
const loopedType = typing;
useEffect(() => {
if(ref.current != null) {
if (loop === Infinity) {
typing(ref.current, speed, ...steps, loopedType);
} else if (typeof loop === "number") {
typing(
ref.current,
speed,
...Array(loop)
.fill(steps)
.flat()
);
} else {
typing(ref.current, speed, ...steps);
}
}
}, []);
return ref
}