✨ feat(vscode): 代码阅读的名字高亮更显眼(当前那处描框、别处画细线),⌘↓ / ⌘↑ 在各处之间跳,跳到折叠处自动展开,筛选栏写第几处

This commit is contained in:
cruldra committed 2026-09-26 23:47:43 +08:00
1 parent c5c5d2b517
commit 49f253b80f
18 files changed
+731 -53

No files matched your search

@@ -1,14 +1,31 @@
import type { Click, Highlight } from './occurrences'
import { createContext, useEffect, useLayoutEffect, useState } from 'react'
import { clickAction, OccurrenceHighlight } from './occurrences'
import type { BlockView, KeyCombo, Occurrence } from './types'
import { createContext, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { clickAction, OccurrenceHighlight, OccurrenceNavigation } from './occurrences'
import { occurrencePlan } from './reveal'
import { showInViewport } from './useReveal'
import { onMessage, postMessage } from './vscode'
/** 眼下高亮着的名字,SegmentText 按它给各处出现上底色。 */
/** 眼下高亮着的名字,SegmentText 按它给各处出现上底色、给当前那一处描边。 */
export const HighlightContext = createContext<Highlight | undefined>(undefined)
/** 自己管点击的东西:点在上面(名字以外的地方)高亮不动 */
const CONTROLS = 'a, button, input, select, textarea, [role="listbox"], [role="dialog"], .cr-filter-bar, .cr-prop-label'
/** 不收文字的输入框:焦点在它们上面时方向键不归它们移光标 */
const NON_TEXT_INPUTS = new Set(['checkbox', 'radio', 'button', 'submit', 'reset'])
/** 用键盘跳到一处出现时要用的页面状态。 */
export interface OccurrencePage {
blocks: BlockView[]
/** 这块里的出现跳不到:被筛掉的、正在编辑的、被正在编辑的祖先盖住的 */
skip: (id: string) => boolean
/** 眼下折叠着的块、属性行和子行 */
collapsed: ReadonlySet<string>
/** 展开这几块、属性行和子行 */
expand: (ids: string[]) => void
}
function clickOf(event: MouseEvent): Click {
const target = event.target instanceof Element ? event.target : null
const name = target?.closest<HTMLElement>('[data-offset]')
@@ -22,34 +39,97 @@ function clickOf(event: MouseEvent): Click {
}
}
/** 单击名字高亮它在文档里的各处出现,点空白处、Esc、换文档版本或翻译清掉;返回眼下高亮着的名字。 */
export function useOccurrences(version: number | undefined, translatorId: string | undefined): Highlight | undefined {
/** 焦点在能打字的地方:文字输入框、多行输入框、下拉列表、可编辑的元素(块的「编辑」) */
function isTyping(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement))
return false
return target.isContentEditable || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement
|| (target instanceof HTMLInputElement && !NON_TEXT_INPUTS.has(target.type))
}
/**
* 单击名字高亮它在文档里的各处出现,点空白处、Esc、换文档版本或翻译清掉;返回眼下高亮着的名字。
* 高亮着时扩展端的「跳到下一处 / 上一处高亮」命令发来 moveOccurrence:当前那一处换成下一处页面上找得到的出现,
* 展开把它折叠住的块、属性行和子行,再把它滚进视口。
*/
export function useOccurrences(version: number | undefined, translatorId: string | undefined, page: OccurrencePage | undefined): Highlight | undefined {
const [highlight, setHighlight] = useState<Highlight | undefined>()
const [occurrences] = useState(() => new OccurrenceHighlight(postMessage, setHighlight))
const [navigation] = useState(() => new OccurrenceNavigation(postMessage, { focused: document.hasFocus(), typing: isTyping(document.activeElement) }))
/** 用键盘跳过几次:每跳一次,画完后把当前那一处滚进视口 */
const [moves, setMoves] = useState(0)
const latestPage = useRef(page)
useLayoutEffect(() => {
latestPage.current = page
})
// 赶在绘制前清掉:新文档的下标换了意思,旧高亮不能落在新页面的别的名字上
useLayoutEffect(() => occurrences.setDocument(version, translatorId), [occurrences, version, translatorId])
useEffect(() => navigation.update({ highlighted: highlight !== undefined }), [navigation, highlight])
// 要展开的已经展开、当前那一处已经画出来(data-current),页面上按先后第一个就是它
useLayoutEffect(() => {
const el = moves > 0 ? document.querySelector<HTMLElement>('[data-current]') : null
if (el)
showInViewport(el)
}, [moves])
useEffect(() => {
let keys: KeyCombo[] = []
const move = (step: 1 | -1): void => {
const current = latestPage.current
if (!current)
return
const planOf = (occurrence: Occurrence): ReturnType<typeof occurrencePlan> => occurrencePlan(current.blocks, occurrence, current.skip)
const target = occurrences.move(step, occurrence => planOf(occurrence) !== undefined)
const plan = target && planOf(target)
if (!plan)
return
const folded = plan.expand.filter(id => current.collapsed.has(id))
if (folded.length > 0)
current.expand(folded)
setMoves(count => count + 1)
}
// 捕获阶段:赶在块里的按钮处理点击、折叠箭头换图标之前读出点在哪
const onClick = (event: MouseEvent): void => occurrences.click(clickAction(clickOf(event)))
// 输入框、下拉列表自己用掉的 Esc 不算
const onKey = (event: KeyboardEvent): void => {
// 输入框、下拉列表自己用掉的 Esc 不算
if (event.key === 'Escape' && !event.defaultPrevented)
occurrences.clear()
else if (navigation.blocks(event, keys))
event.preventDefault()
}
const onFocus = (): void => navigation.update({ focused: true })
const onBlur = (): void => navigation.update({ focused: false })
const onFocusIn = (event: FocusEvent): void => navigation.update({ typing: isTyping(event.target) })
const onFocusOut = (): void => navigation.update({ typing: false })
const off = onMessage((msg) => {
if (msg.type === 'occurrencesFound')
occurrences.answer(msg.id, msg.occurrences)
else if (msg.type === 'occurrenceKeys')
keys = msg.keys
else if (msg.type === 'moveOccurrence')
move(msg.step)
})
navigation.update({})
document.addEventListener('click', onClick, true)
window.addEventListener('keydown', onKey)
window.addEventListener('focus', onFocus)
window.addEventListener('blur', onBlur)
document.addEventListener('focusin', onFocusIn)
document.addEventListener('focusout', onFocusOut)
return () => {
off()
document.removeEventListener('click', onClick, true)
window.removeEventListener('keydown', onKey)
window.removeEventListener('focus', onFocus)
window.removeEventListener('blur', onBlur)
document.removeEventListener('focusin', onFocusIn)
document.removeEventListener('focusout', onFocusOut)
}
}, [occurrences])
}, [occurrences, navigation])
return highlight
}