/** * Terminal Typing Animation Engine * * Handles the character-by-character typing animation for terminal lines. */ import type { ParsedLine, DisplayedLine } from './types'; import type { TerminalSettings } from '$lib/config'; import { calculateTypeSpeed } from '$lib'; export interface TypingState { displayedLines: DisplayedLine[]; currentLineIndex: number; isTyping: boolean; isComplete: boolean; skipRequested: boolean; selectedIndex: number; } export interface TypingOptions { parsedLines: ParsedLine[]; speedMultiplier: number; terminalSettings: TerminalSettings; buttonIndices: number[]; interactive: boolean; autoscroll: boolean; getBodyElement: () => HTMLDivElement | undefined; getState: () => TypingState; setState: (updates: Partial) => void; onComplete?: () => void; scrollToHash: () => void; } function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Scroll terminal body to bottom */ function scrollToBottom(bodyElement: HTMLDivElement | undefined, autoscroll: boolean) { if (!autoscroll || !bodyElement) return; bodyElement.scrollTo({ top: bodyElement.scrollHeight, behavior: 'smooth' }); } /** * Run the typing animation for all lines */ export async function runTypingAnimation(options: TypingOptions): Promise { const { parsedLines, speedMultiplier, terminalSettings, buttonIndices, interactive, autoscroll, getBodyElement, getState, setState, onComplete, scrollToHash } = options; if (parsedLines.length === 0) return; setState({ isTyping: true }); // Apply speed multiplier to start delay const startDelayMs = speedMultiplier === 0 ? 0 : terminalSettings.startDelay * speedMultiplier; await sleep(startDelayMs); if (getState().skipRequested) return; for (let i = 0; i < parsedLines.length; i++) { const state = getState(); if (state.skipRequested) return; const parsed = parsedLines[i]; const line = parsed.line; const plainLength = parsed.plainText.length; // Add new line to displayed lines const currentDisplayed = [...state.displayedLines, { parsed, charIndex: 0, complete: false, showImage: false }]; setState({ displayedLines: currentDisplayed, currentLineIndex: i }); if (line.delay && speedMultiplier > 0) { await sleep(line.delay * speedMultiplier); if (getState().skipRequested) return; } const bodyElement = getBodyElement(); // Handle different line types if (line.type === 'image') { await sleep(speedMultiplier === 0 ? 0 : 100 * speedMultiplier); if (getState().skipRequested) return; updateDisplayedLine(i, { parsed, charIndex: plainLength, complete: true, showImage: true }, getState, setState); scrollToBottom(bodyElement, autoscroll); } else if (line.type === 'blank' || line.type === 'divider') { updateDisplayedLine(i, { parsed, charIndex: plainLength, complete: true, showImage: false }, getState, setState); } else if (line.type === 'button') { // Buttons appear instantly updateDisplayedLine(i, { parsed, charIndex: plainLength, complete: true, showImage: false }, getState, setState); scrollToBottom(bodyElement, autoscroll); } else if (speedMultiplier === 0) { // Instant mode - no typing animation updateDisplayedLine(i, { parsed, charIndex: plainLength, complete: true, showImage: false }, getState, setState); if (i % 5 === 0) scrollToBottom(bodyElement, autoscroll); } else if (line.type === 'header') { const typeSpeed = calculateTypeSpeed(plainLength, speedMultiplier * 0.25); for (let j = 0; j <= plainLength; j++) { if (getState().skipRequested) return; updateDisplayedLine(i, { parsed, charIndex: j, complete: j === plainLength, showImage: false }, getState, setState); if (j < plainLength) await sleep(typeSpeed); } scrollToBottom(bodyElement, autoscroll); } else { const typeSpeed = calculateTypeSpeed(plainLength, speedMultiplier); for (let j = 0; j <= plainLength; j++) { if (getState().skipRequested) return; updateDisplayedLine(i, { parsed, charIndex: j, complete: j === plainLength, showImage: false }, getState, setState); if (j % 10 === 0) scrollToBottom(bodyElement, autoscroll); if (j < plainLength) { await sleep(typeSpeed); } } } if (getState().skipRequested) return; // Mark line as complete const finalState = getState(); const updatedLines = [...finalState.displayedLines]; if (updatedLines[i]) { updatedLines[i].complete = true; setState({ displayedLines: updatedLines }); } scrollToBottom(bodyElement, autoscroll); if (i < parsedLines.length - 1 && speedMultiplier > 0) { await sleep(terminalSettings.lineDelay * speedMultiplier); } } setState({ isTyping: false, isComplete: true }); if (interactive && buttonIndices.length > 0) { setState({ selectedIndex: buttonIndices[0] }); } // Scroll to hash anchor if present in URL scrollToHash(); onComplete?.(); } /** * Update a single displayed line at index */ function updateDisplayedLine( index: number, update: DisplayedLine, getState: () => TypingState, setState: (updates: Partial) => void ) { const state = getState(); const updatedLines = [...state.displayedLines]; updatedLines[index] = update; setState({ displayedLines: updatedLines }); } /** * Skip animation and show all content instantly */ export function skipTypingAnimation( parsedLines: ParsedLine[], buttonIndices: number[], interactive: boolean, getState: () => TypingState, setState: (updates: Partial) => void, getBodyElement: () => HTMLDivElement | undefined, autoscroll: boolean, onComplete?: () => void ): void { const state = getState(); if (!state.isTyping) return; const displayedLines = parsedLines.map(parsed => ({ parsed, charIndex: parsed.plainText.length, complete: true, showImage: parsed.line.type === 'image' })); setState({ skipRequested: true, displayedLines, isTyping: false, isComplete: true, currentLineIndex: parsedLines.length - 1, selectedIndex: interactive && buttonIndices.length > 0 ? buttonIndices[0] : -1 }); scrollToBottom(getBodyElement(), autoscroll); onComplete?.(); }