export interface FrameScheduler { cancel: (frameId: number) => void; request: (callback: () => void) => number; } export interface ProgressiveTextOptions { onText: (content: string) => void; isReducedMotion: boolean; scheduler?: FrameScheduler | undefined; } /** * Decouples network token size from visual typing cadence. Providers may send * a full sentence in one event; the queue still reveals it across animation * frames unless reduced motion is requested. */ export class ProgressiveText { private readonly onText: (content: string) => void; private readonly isReducedMotion: boolean; private readonly scheduler: FrameScheduler; private pendingText = ""; private frameId: number | undefined; private drainResolvers: Array<() => void> = []; constructor(options: ProgressiveTextOptions) { this.onText = options.onText; this.isReducedMotion = options.isReducedMotion; this.scheduler = options.scheduler ?? createFrameScheduler(); } enqueue(content: string): void { if (content.length === 0) return; if (this.isReducedMotion) { this.onText(content); return; } this.pendingText += content; this.scheduleNextFrame(); } /** Resolves only after every queued character is visible. */ async drain(): Promise { if (this.pendingText.length === 0 && this.frameId === undefined) return; await new Promise((resolve) => this.drainResolvers.push(resolve)); } cancel(): void { if (this.frameId !== undefined) this.scheduler.cancel(this.frameId); this.frameId = undefined; this.pendingText = ""; this.resolveDrain(); } private scheduleNextFrame(): void { if (this.frameId !== undefined) return; this.frameId = this.scheduler.request(() => this.flushFrame()); } private flushFrame(): void { this.frameId = undefined; // Aim for roughly twelve frames without making short tokens feel delayed. // As more text arrives, the adaptive batch prevents an ever-growing queue. const characterCount = Math.max(1, Math.ceil(this.pendingText.length / 12)); const visibleText = this.pendingText.slice(0, characterCount); this.pendingText = this.pendingText.slice(characterCount); this.onText(visibleText); if (this.pendingText.length > 0) { this.scheduleNextFrame(); return; } this.resolveDrain(); } private resolveDrain(): void { for (const resolve of this.drainResolvers) resolve(); this.drainResolvers = []; } } function createFrameScheduler(): FrameScheduler { if (typeof requestAnimationFrame === "function") { return { request: (callback) => requestAnimationFrame(callback), cancel: (frameId) => cancelAnimationFrame(frameId), }; } return { request: (callback) => window.setTimeout(callback, 16), cancel: (frameId) => window.clearTimeout(frameId), }; }