97 lines
3.9 KiB
TypeScript
97 lines
3.9 KiB
TypeScript
import { useCallback, useContext, useLayoutEffect, useRef } from 'react'
|
|
import { TerminalSizeContext } from '../components/TerminalSizeContext.js'
|
|
import type { DOMElement } from '../dom.js'
|
|
|
|
type ViewportEntry = {
|
|
/**
|
|
* Whether the element is currently within the terminal viewport
|
|
*/
|
|
isVisible: boolean
|
|
}
|
|
|
|
/**
|
|
* Hook to detect if a component is within the terminal viewport.
|
|
*
|
|
* Returns a callback ref and a viewport entry object.
|
|
* Attach the ref to the component you want to track.
|
|
*
|
|
* The entry is updated during the layout phase (useLayoutEffect) so callers
|
|
* always read fresh values during render. Visibility changes do NOT trigger
|
|
* re-renders on their own — callers that re-render for other reasons (e.g.
|
|
* animation ticks, state changes) will pick up the latest value naturally.
|
|
* This avoids infinite update loops when combined with other layout effects
|
|
* that also call setState.
|
|
*
|
|
* @example
|
|
* const [ref, entry] = useTerminalViewport()
|
|
* return <Box ref={ref}><Animation enabled={entry.isVisible}>...</Animation></Box>
|
|
*/
|
|
export function useTerminalViewport(): [
|
|
ref: (element: DOMElement | null) => void,
|
|
entry: ViewportEntry,
|
|
] {
|
|
const terminalSize = useContext(TerminalSizeContext)
|
|
const elementRef = useRef<DOMElement | null>(null)
|
|
const entryRef = useRef<ViewportEntry>({ isVisible: true })
|
|
|
|
const setElement = useCallback((el: DOMElement | null) => {
|
|
elementRef.current = el
|
|
}, [])
|
|
|
|
// Runs on every render because yoga layout values can change
|
|
// without React being aware. Only updates the ref — no setState
|
|
// to avoid cascading re-renders during the commit phase.
|
|
// Walks the DOM ancestor chain fresh each time to avoid holding stale
|
|
// references after yoga tree rebuilds.
|
|
useLayoutEffect(() => {
|
|
const element = elementRef.current
|
|
if (!element?.yogaNode || !terminalSize) {
|
|
return
|
|
}
|
|
|
|
const height = element.yogaNode.getComputedHeight()
|
|
const rows = terminalSize.rows
|
|
|
|
// Walk the DOM parent chain (not yoga.getParent()) so we can detect
|
|
// scroll containers and subtract their scrollTop. Yoga computes layout
|
|
// positions without scroll offset — scrollTop is applied at render time.
|
|
// Without this, an element inside a ScrollBox whose yoga position exceeds
|
|
// terminalRows would be considered offscreen even when scrolled into view
|
|
// (e.g., the spinner in fullscreen mode after enough messages accumulate).
|
|
let absoluteTop = element.yogaNode.getComputedTop()
|
|
let parent: DOMElement | undefined = element.parentNode
|
|
let root = element.yogaNode
|
|
while (parent) {
|
|
if (parent.yogaNode) {
|
|
absoluteTop += parent.yogaNode.getComputedTop()
|
|
root = parent.yogaNode
|
|
}
|
|
// scrollTop is only ever set on scroll containers (by ScrollBox + renderer).
|
|
// Non-scroll nodes have undefined scrollTop → falsy fast-path.
|
|
if (parent.scrollTop) absoluteTop -= parent.scrollTop
|
|
parent = parent.parentNode
|
|
}
|
|
|
|
// Only the root's height matters
|
|
const screenHeight = root.getComputedHeight()
|
|
|
|
const bottom = absoluteTop + height
|
|
// When content overflows the viewport (screenHeight > rows), the
|
|
// cursor-restore at frame end scrolls one extra row into scrollback.
|
|
// log-update.ts accounts for this with scrollbackRows = viewportY + 1.
|
|
// We must match, otherwise an element at the boundary is considered
|
|
// "visible" here (animation keeps ticking) but its row is treated as
|
|
// scrollback by log-update (content change → full reset → flicker).
|
|
const cursorRestoreScroll = screenHeight > rows ? 1 : 0
|
|
const viewportY = Math.max(0, screenHeight - rows) + cursorRestoreScroll
|
|
const viewportBottom = viewportY + rows
|
|
const visible = bottom > viewportY && absoluteTop < viewportBottom
|
|
|
|
if (visible !== entryRef.current.isVisible) {
|
|
entryRef.current = { isVisible: visible }
|
|
}
|
|
})
|
|
|
|
return [setElement, entryRef.current]
|
|
}
|