π File detail
ink/hooks/use-terminal-viewport.ts
π§© .tsπ 97 linesπΎ 3,977 bytesπ text
β Back to All Filesπ― Use case
This file lives under βink/β, which covers Ink terminal UI (layouts, TTY IO, keyboard, renderer components). On the API surface it exposes useTerminalViewport β mainly functions, hooks, or classes. Dependencies touch React UI. It composes internal code from components and dom (relative imports).
Generated from folder role, exports, dependency roots, and inline comments β not hand-reviewed for every path.
π§ Inline summary
import { useCallback, useContext, useLayoutEffect, useRef } from 'react' import { TerminalSizeContext } from '../components/TerminalSizeContext.js' import type { DOMElement } from '../dom.js' type ViewportEntry = {
π€ Exports (heuristic)
useTerminalViewport
π External import roots
Package roots from from "β¦" (relative paths omitted).
react
π₯οΈ Source preview
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]
}