ink
Version:
React for CLI
87 lines (84 loc) • 3.95 kB
JavaScript
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