UNPKG

ink

Version:
87 lines (84 loc) 3.95 kB
import { useState, useLayoutEffect, useRef, useCallback, useContext, } from 'react'; import AnimationContext from '../components/AnimationContext.js'; const defaultAnimationInterval = 100; const maximumTimerInterval = 2_147_483_647; const zeroAnimState = { frame: 0, time: 0, delta: 0 }; /** A React hook that drives animations. Returns a frame counter, elapsed time, frame delta, and a reset function. All animations share a single timer internally, so multiple animated components consolidate into one render cycle. @example ``` import {Text, useAnimation} from 'ink'; const Spinner = () => { const {frame} = useAnimation({interval: 80}); const characters = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; return <Text>{characters[frame % characters.length]}</Text>; }; ``` */ export default function useAnimation(options) { const { interval = defaultAnimationInterval, isActive = true } = options ?? {}; const safeInterval = normalizeAnimationInterval(interval); const { subscribe, renderThrottleMs } = useContext(AnimationContext); const [resetKey, setResetKey] = useState(0); const [animState, setAnimState] = useState(zeroAnimState); const nextRenderTimeRef = useRef(0); const lastRenderTimeRef = useRef(0); const previousOptionsRef = useRef({ isActive, safeInterval, resetKey }); const previousOptions = previousOptionsRef.current; const shouldReset = isActive && (safeInterval !== previousOptions.safeInterval || !previousOptions.isActive || resetKey !== previousOptions.resetKey); const reset = useCallback(() => { setResetKey(k => k + 1); }, []); useLayoutEffect(() => { if (!isActive) { return; } // Reset to zero immediately so any render that occurs between this // effect commit and the first tick shows zeros, not stale values. // On initial mount this is a no-op: Object.is bails out because the // state was initialized with the same zeroAnimState reference. setAnimState(zeroAnimState); let startTime = 0; const { startTime: subscriberStartTime, unsubscribe } = subscribe(currentTime => { const isThrottled = renderThrottleMs > 0 && currentTime < nextRenderTimeRef.current; if (isThrottled) { // Coalesce intermediate ticks while Ink is inside the current // render-throttle window; the next allowed render will jump // straight to the latest elapsed values. return; } const elapsed = currentTime - startTime; const nextDelta = currentTime - lastRenderTimeRef.current; lastRenderTimeRef.current = currentTime; nextRenderTimeRef.current = currentTime + renderThrottleMs; setAnimState({ frame: Math.floor(elapsed / safeInterval), time: elapsed, delta: nextDelta, }); }, safeInterval); // Use the scheduler's start time instead of sampling our own clock so the // first delivered tick cannot start one frame late. startTime = subscriberStartTime; lastRenderTimeRef.current = subscriberStartTime; nextRenderTimeRef.current = startTime + renderThrottleMs; return unsubscribe; }, [safeInterval, isActive, subscribe, renderThrottleMs, resetKey]); useLayoutEffect(() => { previousOptionsRef.current = { isActive, safeInterval, resetKey }; }, [isActive, safeInterval, resetKey]); if (shouldReset) { return { ...zeroAnimState, reset }; } return { ...animState, reset }; } function normalizeAnimationInterval(interval) { if (!Number.isFinite(interval)) { return defaultAnimationInterval; } return Math.min(maximumTimerInterval, Math.max(1, interval)); } //# sourceMappingURL=use-animation.js.map