File size: 1,933 Bytes
064bfd6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 | import { useContext, useEffect, useState } from 'react'
import { ClockContext } from '../components/ClockContext.js'
import type { DOMElement } from '../dom.js'
import { useTerminalViewport } from './use-terminal-viewport.js'
/**
* Hook for synchronized animations that pause when offscreen.
*
* Returns a ref to attach to the animated element and the current animation time.
* All instances share the same clock, so animations stay in sync.
* The clock only runs when at least one keepAlive subscriber exists.
*
* Pass `null` to pause — unsubscribes from the clock so no ticks fire.
* Time freezes at the last value and resumes from the current clock time
* when a number is passed again.
*
* @param intervalMs - How often to update, or null to pause
* @returns [ref, time] - Ref to attach to element, elapsed time in ms
*
* @example
* function Spinner() {
* const [ref, time] = useAnimationFrame(120)
* const frame = Math.floor(time / 120) % FRAMES.length
* return <Box ref={ref}>{FRAMES[frame]}</Box>
* }
*
* The clock automatically slows when the terminal is blurred,
* so consumers don't need to handle focus state.
*/
export function useAnimationFrame(
intervalMs: number | null = 16,
): [ref: (element: DOMElement | null) => void, time: number] {
const clock = useContext(ClockContext)
const [viewportRef, { isVisible }] = useTerminalViewport()
const [time, setTime] = useState(() => clock?.now() ?? 0)
const active = isVisible && intervalMs !== null
useEffect(() => {
if (!clock || !active) return
let lastUpdate = clock.now()
const onChange = (): void => {
const now = clock.now()
if (now - lastUpdate >= intervalMs!) {
lastUpdate = now
setTime(now)
}
}
// keepAlive: true — visible animations drive the clock
return clock.subscribe(onChange, true)
}, [clock, intervalMs, active])
return [viewportRef, time]
}
|