import measureText from '../core/measure-text.js' import { expandTabs } from '../core/tabstops.js' import wrapText from '../core/wrap-text.js' import { createLayoutNode } from '../layout/engine.js' import type { LayoutNode } from '../layout/node.js' import { LayoutDisplay, LayoutMeasureMode } from '../layout/node.js' import type { FocusManager } from './focus.js' import { addPendingClear, nodeCache } from './node-cache.js' import squashTextNodes from './squash-text-nodes.js' import type { Styles, TextStyles } from './styles.js' type InkNode = { parentNode: DOMElement | undefined yogaNode?: LayoutNode style: Styles } export type TextName = '#text' export type ElementNames = | 'ink-root' | 'ink-box' | 'ink-text' | 'ink-virtual-text' | 'ink-link' | 'ink-progress' | 'ink-raw-ansi' export type NodeNames = ElementNames | TextName // eslint-disable-next-line @typescript-eslint/naming-convention export type DOMElement = { nodeName: ElementNames attributes: Record childNodes: DOMNode[] textStyles?: TextStyles // Internal properties onComputeLayout?: () => void onRender?: () => void onImmediateRender?: () => void // Used to skip empty renders during React 19's effect double-invoke in test mode hasRenderedContent?: boolean // When true, this node needs re-rendering dirty: boolean // Set by the reconciler's hideInstance/unhideInstance; survives style updates. isHidden?: boolean // Event handlers set by the reconciler for the capture/bubble dispatcher. // Stored separately from attributes so handler identity changes don't // mark dirty and defeat the blit optimization. _eventHandlers?: Record // Scroll state for overflow: 'scroll' boxes. scrollTop?: number pendingScrollDelta?: number scrollClampMin?: number scrollClampMax?: number scrollHeight?: number scrollViewportHeight?: number scrollViewportTop?: number stickyScroll?: boolean scrollAnchor?: { el: DOMElement; offset: number } focusManager?: FocusManager debugOwnerChain?: string[] } & InkNode export type TextNode = { nodeName: TextName nodeValue: string } & InkNode // eslint-disable-next-line @typescript-eslint/naming-convention export type DOMNode = T extends { nodeName: infer U } ? U extends '#text' ? TextNode : DOMElement : never // eslint-disable-next-line @typescript-eslint/naming-convention export type DOMNodeAttribute = boolean | string | number export const createNode = (nodeName: ElementNames): DOMElement => { const needsYogaNode = nodeName !== 'ink-virtual-text' && nodeName !== 'ink-link' && nodeName !== 'ink-progress' const node: DOMElement = { nodeName, style: {}, attributes: {}, childNodes: [], parentNode: undefined, yogaNode: needsYogaNode ? createLayoutNode() : undefined, dirty: false, } if (nodeName === 'ink-text') { node.yogaNode?.setMeasureFunc(measureTextNode.bind(null, node)) } else if (nodeName === 'ink-raw-ansi') { node.yogaNode?.setMeasureFunc(measureRawAnsiNode.bind(null, node)) } return node } export const appendChildNode = (node: DOMElement, childNode: DOMElement): void => { if (childNode.parentNode) { removeChildNode(childNode.parentNode, childNode) } childNode.parentNode = node node.childNodes.push(childNode) if (childNode.yogaNode) { node.yogaNode?.insertChild(childNode.yogaNode, node.yogaNode.getChildCount()) } markDirty(node) } export const insertBeforeNode = ( node: DOMElement, newChildNode: DOMNode, beforeChildNode: DOMNode, ): void => { if (newChildNode.parentNode) { removeChildNode(newChildNode.parentNode, newChildNode) } newChildNode.parentNode = node const index = node.childNodes.indexOf(beforeChildNode) if (index >= 0) { let yogaIndex = 0 if (newChildNode.yogaNode && node.yogaNode) { for (let i = 0; i < index; i++) { if (node.childNodes[i]?.yogaNode) { yogaIndex++ } } } node.childNodes.splice(index, 0, newChildNode) if (newChildNode.yogaNode && node.yogaNode) { node.yogaNode.insertChild(newChildNode.yogaNode, yogaIndex) } markDirty(node) return } node.childNodes.push(newChildNode) if (newChildNode.yogaNode) { node.yogaNode?.insertChild(newChildNode.yogaNode, node.yogaNode.getChildCount()) } markDirty(node) } export const removeChildNode = (node: DOMElement, removeNode: DOMNode): void => { if (removeNode.yogaNode) { removeNode.parentNode?.yogaNode?.removeChild(removeNode.yogaNode) } collectRemovedRects(node, removeNode) removeNode.parentNode = undefined const index = node.childNodes.indexOf(removeNode) if (index >= 0) { node.childNodes.splice(index, 1) } markDirty(node) } function collectRemovedRects(parent: DOMElement, removed: DOMNode, underAbsolute = false): void { if (removed.nodeName === '#text') return const elem = removed as DOMElement const isAbsolute = underAbsolute || elem.style.position === 'absolute' const cached = nodeCache.get(elem) if (cached) { addPendingClear(parent, cached, isAbsolute) nodeCache.delete(elem) } for (const child of elem.childNodes) { collectRemovedRects(parent, child, isAbsolute) } } export const setAttribute = (node: DOMElement, key: string, value: DOMNodeAttribute): void => { if (key === 'children') { return } if (node.attributes[key] === value) { return } node.attributes[key] = value markDirty(node) } export const setStyle = (node: DOMNode, style: Styles): void => { if (stylesEqual(node.style, style)) { return } node.style = style markDirty(node) } export const setTextStyles = (node: DOMElement, textStyles: TextStyles): void => { if (shallowEqual(node.textStyles, textStyles)) { return } node.textStyles = textStyles markDirty(node) } function stylesEqual(a: Styles, b: Styles): boolean { return shallowEqual(a, b) } function shallowEqual(a: T | undefined, b: T | undefined): boolean { if (a === b) return true if (a === undefined || b === undefined) return false const aKeys = Object.keys(a) as (keyof T)[] const bKeys = Object.keys(b) as (keyof T)[] if (aKeys.length !== bKeys.length) return false for (const key of aKeys) { if (a[key] !== b[key]) return false } return true } export const createTextNode = (text: string): TextNode => { const node: TextNode = { nodeName: '#text', nodeValue: text, yogaNode: undefined, parentNode: undefined, style: {}, } setTextNodeValue(node, text) return node } const measureTextNode = ( node: DOMNode, width: number, widthMode: LayoutMeasureMode, ): { width: number; height: number } => { const rawText = node.nodeName === '#text' ? node.nodeValue : squashTextNodes(node) const text = expandTabs(rawText) const dimensions = measureText(text, width) if (dimensions.width <= width) { return dimensions } if (dimensions.width >= 1 && width > 0 && width < 1) { return dimensions } if (text.includes('\n') && widthMode === LayoutMeasureMode.Undefined) { const effectiveWidth = Math.max(width, dimensions.width) return measureText(text, effectiveWidth) } const textWrap = node.style?.textWrap ?? 'wrap' const wrappedText = wrapText(text, width, textWrap) return measureText(wrappedText, width) } const measureRawAnsiNode = ( node: DOMElement, ): { width: number height: number } => ({ width: node.attributes['rawWidth'] as number, height: node.attributes['rawHeight'] as number, }) export const markDirty = (node?: DOMNode): void => { let current: DOMNode | undefined = node let markedYoga = false while (current) { if (current.nodeName !== '#text') { ;(current as DOMElement).dirty = true if ( !markedYoga && (current.nodeName === 'ink-text' || current.nodeName === 'ink-raw-ansi') && current.yogaNode ) { current.yogaNode.markDirty() markedYoga = true } } current = current.parentNode } } export const scheduleRenderFrom = (node?: DOMNode): void => { let cur: DOMNode | undefined = node while (cur?.parentNode) cur = cur.parentNode if (cur && cur.nodeName !== '#text') (cur as DOMElement).onRender?.() } export const setTextNodeValue = (node: TextNode, text: string): void => { if (typeof text !== 'string') { text = String(text) } if (node.nodeValue === text) { return } node.nodeValue = text markDirty(node) } function isDOMElement(node: DOMElement | TextNode): node is DOMElement { return node.nodeName !== '#text' } export const clearYogaNodeReferences = (node: DOMElement | TextNode): void => { if ('childNodes' in node) { for (const child of node.childNodes) { clearYogaNodeReferences(child) } } node.yogaNode = undefined } export function findOwnerChainAtRow(root: DOMElement, y: number): string[] { let best: string[] = [] walk(root, 0) return best function walk(node: DOMElement, offsetY: number): void { const yoga = node.yogaNode if (!yoga || yoga.getDisplay() === LayoutDisplay.None) return const top = offsetY + yoga.getComputedTop() const height = yoga.getComputedHeight() if (y < top || y >= top + height) return if (node.debugOwnerChain) best = node.debugOwnerChain for (const child of node.childNodes) { if (isDOMElement(child)) walk(child, top) } } }