import { type RefObject, useState, useEffect, useCallback, useMemo } from "react"; import { type DOMElement, addLayoutListener } from "#/dom.ts"; // Yoga's `right`/`bottom` are omitted: always `0` for flow layout and unintuitive for absolute positioning. /** Metrics of a box element. All positions are relative to the element's parent. */ export type BoxMetrics = { /** Element width. */ readonly width: number; /** Element height. */ readonly height: number; /** Distance from the left edge of the parent. */ readonly left: number; /** Distance from the top edge of the parent. */ readonly top: number; }; export type UseBoxMetricsResult = BoxMetrics & { /** Whether the currently tracked element has been measured in the latest layout pass. */ readonly hasMeasured: boolean; }; const emptyMetrics: BoxMetrics = { width: 0, height: 0, left: 0, top: 0, }; // eslint-disable-next-line @typescript-eslint/no-restricted-types const findRootNode = (node: DOMElement | null): DOMElement | undefined => { if (!node) { return; } if (!node.parentNode) { return node.nodeName === "ink-root" ? node : undefined; } return findRootNode(node.parentNode); }; /** A React hook that returns the current layout metrics for a tracked box element. It updates when layout changes (for example terminal resize, sibling/content changes, or position changes). The hook returns `{width: 0, height: 0, left: 0, top: 0}` until the first layout pass completes. It also returns zeros when the tracked ref is detached. Use `hasMeasured` to detect when the currently tracked element has been measured. @example ```tsx import {useRef} from 'react'; import {Box, Text, useBoxMetrics} from 'ink'; const Example = () => { const ref = useRef(null); const {width, height, left, top, hasMeasured} = useBoxMetrics(ref); return ( {hasMeasured ? `${width}x${height} at ${left},${top}` : 'Measuring...'} ); }; ``` */ export const useBoxMetrics = ( /* eslint-disable-next-line @typescript-eslint/no-restricted-types -- Creating a ref object with an initial null, especially when the ref object will be passed to a DOM node's ref attribute, is common in React. */ ref: RefObject, ): UseBoxMetricsResult => { const [metrics, setMetrics] = useState(emptyMetrics); const [hasMeasured, setHasMeasured] = useState(false); const updateMetrics = useCallback(() => { const layout = ref.current?.yogaNode?.getComputedLayout() ?? emptyMetrics; setMetrics((previousMetrics) => { const hasChanged = previousMetrics.width !== layout.width || previousMetrics.height !== layout.height || previousMetrics.left !== layout.left || previousMetrics.top !== layout.top; return hasChanged ? layout : previousMetrics; }); setHasMeasured(Boolean(ref.current)); }, [ref]); // Runs after every render of this component. // This keeps metrics fresh when local state/props in this subtree change. useEffect(updateMetrics); // Subscribe to root layout commits so memoized components still receive // sibling-driven position/size updates, even when they skip re-rendering. useEffect(() => { const rootNode = findRootNode(ref.current); if (!rootNode) { return; } return addLayoutListener(rootNode, updateMetrics); }); return useMemo( () => ({ ...metrics, hasMeasured, }), [metrics, hasMeasured], ); };