use-terminal-viewport.ts
ink/hooks/use-terminal-viewport.ts
97
Lines
3977
Bytes
1
Exports
3
Imports
10
Keywords
What this is
This page documents one file from the repository and includes its full source so you can read it without leaving the docs site.
Beginner explanation
This file is one piece of the larger system. Its name, directory, imports, and exports show where it fits. Start by reading the exports and related files first.
How it is used
Start from the exports list and related files. Those are the easiest clues for where this file fits into the system.
Expert explanation
Architecturally, this file intersects with ui-flow. It contains 97 lines, 3 detected imports, and 1 detected exports.
Important relationships
Detected exports
useTerminalViewport
Keywords
elementparentyoganoderowsscrolltopdomelementisvisibleentrycurrentyoga
Detected imports
react../components/TerminalSizeContext.js../dom.js
Source notes
This page embeds the full file contents. Small or leaf files are still indexed honestly instead of being over-explained.
Full source
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]
}