import type { DOMElement } from './dom.js' import { FocusEvent } from './events/focus-event.js' const MAX_FOCUS_STACK = 32 /** * FocusManager - Ink 终端 UI 的类 DOM 焦点管理器 * * 纯状态管理 —— 追踪 activeElement 和焦点栈。 * 不引用树结构;调用者在需要树遍历时传入根节点。 * * 存储在根 DOMElement 上,以便任何节点可以通过 walking * parentNode 访问它(类似浏览器的 node.ownerDocument)。 */ export class FocusManager { activeElement: DOMElement | null = null private dispatchFocusEvent: (target: DOMElement, event: FocusEvent) => boolean private enabled = true private focusStack: DOMElement[] = [] constructor( dispatchFocusEvent: (target: DOMElement, event: FocusEvent) => boolean, ) { this.dispatchFocusEvent = dispatchFocusEvent } focus(node: DOMElement): void { if (node === this.activeElement) return if (!this.enabled) return const previous = this.activeElement if (previous) { // 推送前去重,防止 Tab 循环时无限增长 const idx = this.focusStack.indexOf(previous) if (idx !== -1) this.focusStack.splice(idx, 1) this.focusStack.push(previous) if (this.focusStack.length > MAX_FOCUS_STACK) this.focusStack.shift() this.dispatchFocusEvent(previous, new FocusEvent('blur', node)) } this.activeElement = node this.dispatchFocusEvent(node, new FocusEvent('focus', previous)) } blur(): void { if (!this.activeElement) return const previous = this.activeElement this.activeElement = null this.dispatchFocusEvent(previous, new FocusEvent('blur', null)) } /** * 当节点从树中移除时由 reconciler 调用。 * 处理被移除节点及其在已移除子树中的任何焦点后代。 * 分发 blur 并从栈中恢复焦点。 */ handleNodeRemoved(node: DOMElement, root: DOMElement): void { // 从栈中移除该节点及其后代 this.focusStack = this.focusStack.filter( n => n !== node && isInTree(n, root), ) // 检查 activeElement 是否是被移除的节点或其后代 if (!this.activeElement) return if (this.activeElement !== node && isInTree(this.activeElement, root)) { return } const removed = this.activeElement this.activeElement = null this.dispatchFocusEvent(removed, new FocusEvent('blur', null)) // 恢复焦点到最近仍挂载的元素 while (this.focusStack.length > 0) { const candidate = this.focusStack.pop()! if (isInTree(candidate, root)) { this.activeElement = candidate this.dispatchFocusEvent(candidate, new FocusEvent('focus', removed)) return } } } handleAutoFocus(node: DOMElement): void { this.focus(node) } handleClickFocus(node: DOMElement): void { const tabIndex = node.attributes['tabIndex'] if (typeof tabIndex !== 'number') return this.focus(node) } enable(): void { this.enabled = true } disable(): void { this.enabled = false } focusNext(root: DOMElement): void { this.moveFocus(1, root) } focusPrevious(root: DOMElement): void { this.moveFocus(-1, root) } private moveFocus(direction: 1 | -1, root: DOMElement): void { if (!this.enabled) return const tabbable = collectTabbable(root) if (tabbable.length === 0) return const currentIndex = this.activeElement ? tabbable.indexOf(this.activeElement) : -1 const nextIndex = currentIndex === -1 ? direction === 1 ? 0 : tabbable.length - 1 : (currentIndex + direction + tabbable.length) % tabbable.length const next = tabbable[nextIndex] if (next) { this.focus(next) } } } function collectTabbable(root: DOMElement): DOMElement[] { const result: DOMElement[] = [] walkTree(root, result) return result } function walkTree(node: DOMElement, result: DOMElement[]): void { const tabIndex = node.attributes['tabIndex'] if (typeof tabIndex === 'number' && tabIndex >= 0) { result.push(node) } for (const child of node.childNodes) { if (child.nodeName !== '#text') { walkTree(child, result) } } } function isInTree(node: DOMElement, root: DOMElement): boolean { let current: DOMElement | undefined = node while (current) { if (current === root) return true current = current.parentNode } return false } /** * 向上走到根节点并返回。根节点是持有 FocusManager 的节点—— * 类似浏览器的 node.getRootNode()。 */ export function getRootNode(node: DOMElement): DOMElement { let current: DOMElement | undefined = node while (current) { if (current.focusManager) return current current = current.parentNode } throw new Error('Node is not in a tree with a FocusManager') } /** * 向上走到根节点并返回其 FocusManager。 * 类似浏览器的 node.ownerDocument —— 焦点属于根节点。 */ export function getFocusManager(node: DOMElement): FocusManager { return getRootNode(node).focusManager! }