Files
claude-code-mirror/claude-code源码-中文注释/src/ink/focus.ts
2026-04-03 13:01:19 +08:00

182 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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!
}