✨ feat(vscode): 代码阅读预览单击名字高亮它在文件里的各处出现

This commit is contained in:
cruldra committed 2026-09-26 15:51:52 +08:00
1 parent 5872cf7958
commit 9c0f05362c
12 files changed
+558 -9

No files matched your search

+5 -2
View File
@@ -8,6 +8,7 @@ import { BlockLevel } from './BlockTree'
import { applyFilter, keptWhileEditing } from './filter'
import { FilterBar } from './FilterBar'
import { useDefinitionLinks } from './useDefinitionLinks'
import { HighlightContext, useOccurrences } from './useOccurrences'
import { useReveal } from './useReveal'
import { useScrollPosition } from './useScrollPosition'
import { collapsedIds } from './viewState'
@@ -51,6 +52,7 @@ export function App(): ReactElement | null {
useScrollPosition(doc)
useReveal(doc, reveal, collapsed, filter.hidden, show, expand, revealed)
useDefinitionLinks(doc?.version)
const highlight = useOccurrences(doc?.version, doc?.translatorId)
useEffect(() => {
const off = onMessage((msg) => {
@@ -100,11 +102,12 @@ export function App(): ReactElement | null {
return null
return (
<>
<HighlightContext value={highlight}>
<FilterBar
attributes={doc.attributes}
excluded={excluded}
view={filter}
highlight={highlight}
onChange={next => postMessage({ type: 'setFilter', excluded: next })}
translators={doc.translators}
translatorId={doc.translatorId}
@@ -129,6 +132,6 @@ export function App(): ReactElement | null {
? <BlockLevel blocks={doc.blocks} tree={tree} />
: <div className="text-[var(--vscode-descriptionForeground)]">所有块都被筛选隐藏了。</div>}
</div>
</>
</HighlightContext>
)
}
+14 -3
View File
@@ -1,5 +1,6 @@
import type { KeyboardEvent, ReactElement } from 'react'
import type { Facet, FilterView } from './filter'
import type { Highlight } from './occurrences'
import type { Excluded, FileAttribute, TranslatorOption } from './types'
import { Check, ChevronDown, ListFilter } from 'lucide-react'
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
@@ -10,6 +11,8 @@ export interface FilterBarProps {
attributes: FileAttribute[]
excluded: Excluded
view: FilterView
/** 眼下高亮着的名字:汇总前面写「名字 · N 处」,折叠、筛掉的块里的出现也算在 N 里 */
highlight: Highlight | undefined
onChange: (excluded: Excluded) => void
translators: TranslatorOption[]
translatorId: string
@@ -17,10 +20,10 @@ export interface FilterBarProps {
}
/**
* 预览顶部钉住的一条:「筛选」、这份文件里每个能筛的属性一个下拉框、右边汇总(共 N 块 / 已隐藏 N 块 · 清除),
* 预览顶部钉住的一条:「筛选」、这份文件里每个能筛的属性一个下拉框、右边汇总(高亮着名字时先是「名字 · N 处」,再是共 N 块 / 已隐藏 N 块 · 清除),
* 多于一种翻译时最右边是翻译下拉框。内容和代码块同宽对齐,滚动时块从它下面过去。
*/
export function FilterBar({ attributes, excluded, view, onChange, translators, translatorId, onSelectTranslator }: FilterBarProps): ReactElement {
export function FilterBar({ attributes, excluded, view, highlight, onChange, translators, translatorId, onSelectTranslator }: FilterBarProps): ReactElement {
return (
<div className="cr-filter-bar">
<div className="mx-auto box-content flex max-w-[880px] flex-wrap items-center gap-2 px-6 py-1.5">
@@ -31,7 +34,15 @@ export function FilterBar({ attributes, excluded, view, onChange, translators, t
{facetsOf(attributes, excluded).map(facet => (
<FacetMenu key={facet.id} facet={facet} onToggle={value => onChange(toggleValue(excluded, facet.id, value))} />
))}
<span className="ml-auto flex h-6 items-center gap-1.5 pl-2 text-xs whitespace-nowrap text-[var(--vscode-descriptionForeground)]">
<span className="ml-auto flex h-6 min-w-0 items-center gap-1.5 pl-2 text-xs whitespace-nowrap text-[var(--vscode-descriptionForeground)]">
{highlight && (
<>
<span className="cr-occurrence-name max-w-[240px] min-w-0 truncate" data-occurrence="normal" title={highlight.name}>{highlight.name}</span>
<span aria-hidden>·</span>
{`${highlight.occurrences.length} 处`}
<span className="mx-1.5 h-3 border-l border-[var(--vscode-panel-border)]" aria-hidden />
</>
)}
{view.hiddenCount > 0
? (
<>
@@ -1,12 +1,26 @@
import type { ReactElement } from 'react'
import type { Segment } from './types'
import { useContext } from 'react'
import { segmentOccurrence } from './occurrences'
import { HighlightContext } from './useOccurrences'
/** 按词性给每一段上色,颜色在 codeReader.css 的 .cr-role-* 里按主题切换;带源码下标的名字记在 data-offset 上,跳到定义用。 */
/**
* 按词性给每一段上色,颜色在 codeReader.css 的 .cr-role-* 里按主题切换;带源码下标的名字记在 data-offset 上,
* 跳到定义、高亮各处出现用;是眼下高亮着的名字的一处出现时 data-occurrence 标出底色。
*/
export function SegmentText({ segments }: { segments: Segment[] }): ReactElement {
const highlight = useContext(HighlightContext)
return (
<>
{segments.map((segment, index) => (
<span key={index} className={`cr-role-${segment.role}`} data-offset={segment.offset}>{segment.text}</span>
<span
key={index}
className={`cr-role-${segment.role}`}
data-offset={segment.offset}
data-occurrence={highlight && segmentOccurrence(segment, highlight.occurrences)}
>
{segment.text}
</span>
))}
</>
)
@@ -249,6 +249,27 @@ body.vscode-high-contrast-light {
cursor: pointer;
}
/*
* 单击名字高亮它在文件里的各处出现:颜色取 VS Code 编辑器的单词高亮,写入(定义、赋值)用强高亮色,读取和按文字认出的用普通高亮色;
* 主题给了边框色(高对比主题)才描边。筛选栏上「名字 · N 处」的名字也按普通高亮画。描边画在字框里面,不挤动排版
*/
[data-occurrence] {
outline-offset: -1px;
}
[data-occurrence="normal"] {
background: var(--vscode-editor-wordHighlightBackground);
outline: 1px solid var(--vscode-editor-wordHighlightBorder, transparent);
}
[data-occurrence="strong"] {
background: var(--vscode-editor-wordHighlightStrongBackground);
outline: 1px solid var(--vscode-editor-wordHighlightStrongBorder, transparent);
}
.cr-occurrence-name {
padding: 0 3px;
color: var(--vscode-editor-foreground);
font-family: var(--vscode-editor-font-family, monospace);
}
/* 属性表的属性名:界面字体,和值一样的行高,两边逐行对齐 */
.cr-prop-label {
font-size: 12.5px;
@@ -0,0 +1,224 @@
import type { Click, Highlight } from './occurrences'
import type { BlockView, Occurrence, PreviewText, PropertyItem, Segment, WebviewToExtension } from './types'
import { describe, expect, it } from 'vitest'
import { toBlockViews } from '../../../src/codeReader/blockView'
import { createPythonAdapter } from '../../../src/codeReader/languages/python'
import { WASM_DIR, withBlocks } from '../../../src/codeReader/testSupport'
import { pythonZhTranslator } from '../../../src/codeReader/translators/pythonZh'
import { DefinitionLinks } from './definitionLinks'
import { clickAction, OccurrenceHighlight, occurrenceStyle, segmentOccurrence } from './occurrences'
function name(text: string, offset: number): Segment {
return { text, role: 'variable', offset }
}
function at(start: number, end: number, kind: Occurrence['kind'] = 'read'): Occurrence {
return { start, end, kind }
}
function click(overrides: Partial<Click> = {}): Click {
return { modified: false, count: 1, selecting: false, control: false, ...overrides }
}
function setup(version = 1, translatorId = 'zh'): { occ: OccurrenceHighlight, asked: () => { id: number, version: number, offset: number }[], changes: (Highlight | undefined)[] } {
const sent: WebviewToExtension[] = []
const changes: (Highlight | undefined)[] = []
const occ = new OccurrenceHighlight(msg => sent.push(msg), highlight => changes.push(highlight))
occ.setDocument(version, translatorId)
const asked = (): { id: number, version: number, offset: number }[] => sent.flatMap(msg => msg.type === 'findOccurrences' ? [{ id: msg.id, version: msg.version, offset: msg.offset }] : [])
return { occ, asked, changes }
}
function find(occ: OccurrenceHighlight, offset: number, text: string): void {
occ.click(clickAction(click({ name: { offset, text } })))
}
describe('occurrences:哪些段画高亮', () => {
it('段在源码里的范围和一处出现重叠就画,边界相接不算,没有下标的不画', () => {
const occurrences = [at(10, 18)]
expect(segmentOccurrence(name('ALPHABET', 10), occurrences)).toBe('normal')
expect(segmentOccurrence(name('x', 2), occurrences)).toBeUndefined()
expect(segmentOccurrence(name('ab', 8), occurrences)).toBeUndefined()
expect(segmentOccurrence(name('ab', 18), occurrences)).toBeUndefined()
expect(segmentOccurrence(name('ab', 17), occurrences)).toBe('normal')
expect(segmentOccurrence({ text: '常量', role: 'storage' }, occurrences)).toBeUndefined()
// 整段字符串比名字长:出现落在字符串里面也算
expect(segmentOccurrence({ text: '\'./util\'', role: 'string', offset: 9 }, occurrences)).toBe('normal')
})
it('写入用强高亮色,读取和按文字认出的用普通高亮色;同一段碰上两种按写入画', () => {
expect(occurrenceStyle('write')).toBe('strong')
expect(occurrenceStyle('read')).toBe('normal')
expect(occurrenceStyle('text')).toBe('normal')
expect(segmentOccurrence(name('a', 0), [at(0, 1, 'text')])).toBe('normal')
expect(segmentOccurrence(name('a', 0), [at(0, 1, 'read'), at(0, 1, 'write')])).toBe('strong')
})
it('真实的 Python 译文和属性表:定义处按写入、用到的地方按读取画;点分模块名每一节各按自己的范围认', async () => {
const source = [
'import luffy_agent.core.db',
'import secrets',
'',
'ALPHABET = "abcdefgh"',
'',
'def token():',
' return "".join(secrets.choice(ALPHABET) for _ in range(8))',
].join('\n')
const py = createPythonAdapter(WASM_DIR)
const views = await withBlocks(py, 'python', source, blocks => toBlockViews(blocks, source, py, pythonZhTranslator))
const highlighted = (occurrences: Occurrence[]): string[] => allSegments(views).flatMap((segment) => {
const style = segmentOccurrence(segment, occurrences)
return style ? [`${segment.text}:${style}`] : []
})
const range = (needle: string, kind: Occurrence['kind'], from = 0): Occurrence => {
const start = source.indexOf(needle, from)
expect(start, needle).toBeGreaterThanOrEqual(0)
return at(start, start + needle.length, kind)
}
const definition = range('ALPHABET', 'write')
expect(highlighted([definition, range('ALPHABET', 'read', definition.end)])).toEqual(['ALPHABET:strong', 'ALPHABET:normal'])
expect(highlighted([range('core', 'read')])).toEqual(['core:normal'])
expect(highlighted([range('luffy_agent.core.db', 'read')])).toEqual(['luffy_agent:normal', 'core:normal', 'db:normal'])
})
})
describe('occurrences:点击', () => {
it('单击名字问它的各处出现,名字在折叠箭头这类按钮里也算;点空白处清掉,点在按钮、链接、筛选栏上不动', () => {
expect(clickAction(click({ name: { offset: 3, text: 'x' } }))).toEqual({ kind: 'find', offset: 3, name: 'x' })
expect(clickAction(click({ name: { offset: 3, text: 'x' }, control: true }))).toEqual({ kind: 'find', offset: 3, name: 'x' })
expect(clickAction(click())).toEqual({ kind: 'clear' })
expect(clickAction(click({ control: true }))).toEqual({ kind: 'ignore' })
})
it('拖着选字、双击、带修饰键的点击都不动高亮', () => {
const onName = { name: { offset: 3, text: 'x' } }
expect(clickAction(click({ ...onName, selecting: true }))).toEqual({ kind: 'ignore' })
expect(clickAction(click({ selecting: true }))).toEqual({ kind: 'ignore' })
expect(clickAction(click({ ...onName, count: 2 }))).toEqual({ kind: 'ignore' })
expect(clickAction(click({ count: 2 }))).toEqual({ kind: 'ignore' })
expect(clickAction(click({ ...onName, modified: true }))).toEqual({ kind: 'ignore' })
expect(clickAction(click({ modified: true }))).toEqual({ kind: 'ignore' })
})
it('⌘ / Ctrl + 点击名字照旧跳到定义,不问出现处', () => {
const sent: WebviewToExtension[] = []
const links = new DefinitionLinks(msg => sent.push(msg))
links.setVersion(1)
const occ = new OccurrenceHighlight(msg => sent.push(msg), () => {})
occ.setDocument(1, 'zh')
expect(links.click(42)).toBe(true)
occ.click(clickAction(click({ modified: true, name: { offset: 42, text: 'x' } })))
expect(sent).toEqual([{ type: 'goToDefinition', version: 1, offset: 42 }])
})
})
describe('occurrences:高亮的状态', () => {
it('问到的各处出现连同点中的名字一起显示;回答是空的不高亮', () => {
const { occ, asked, changes } = setup()
find(occ, 20, 'ALPHABET')
expect(asked()).toEqual([{ id: asked()[0].id, version: 1, offset: 20 }])
const occurrences = [at(20, 28, 'write'), at(60, 68)]
occ.answer(asked()[0].id, occurrences)
expect(occ.highlight).toEqual({ name: 'ALPHABET', occurrences })
expect(changes).toEqual([{ name: 'ALPHABET', occurrences }])
find(occ, 5, 'secrets')
occ.answer(asked()[1].id, [])
expect(occ.highlight).toBeUndefined()
})
it('点另一个名字:回答到之前旧高亮留着,到了就换成新的', () => {
const { occ, asked } = setup()
find(occ, 20, 'ALPHABET')
occ.answer(asked()[0].id, [at(20, 28)])
find(occ, 5, 'secrets')
expect(occ.highlight?.name).toBe('ALPHABET')
occ.answer(asked()[1].id, [at(5, 12), at(40, 47)])
expect(occ.highlight).toEqual({ name: 'secrets', occurrences: [at(5, 12), at(40, 47)] })
})
it('只认最近一次询问的回答,迟到的丢掉', () => {
const { occ, asked } = setup()
find(occ, 20, 'ALPHABET')
find(occ, 5, 'secrets')
const [first, second] = asked()
occ.answer(second.id, [at(5, 12)])
occ.answer(first.id, [at(20, 28)])
expect(occ.highlight).toEqual({ name: 'secrets', occurrences: [at(5, 12)] })
occ.answer(second.id, [at(99, 100)])
expect(occ.highlight).toEqual({ name: 'secrets', occurrences: [at(5, 12)] })
})
it('回答不留到下次用:答过「没有」后再点同一个名字照样再问', () => {
const { occ, asked } = setup()
find(occ, 20, 'ALPHABET')
occ.answer(asked()[0].id, [])
find(occ, 20, 'ALPHABET')
expect(asked()).toHaveLength(2)
occ.answer(asked()[1].id, [at(20, 28)])
expect(occ.highlight?.name).toBe('ALPHABET')
})
it('点空白处、Esc 清掉高亮,清掉后才到的回答也丢掉', () => {
const { occ, asked } = setup()
find(occ, 20, 'ALPHABET')
occ.answer(asked()[0].id, [at(20, 28)])
occ.click(clickAction(click()))
expect(occ.highlight).toBeUndefined()
find(occ, 20, 'ALPHABET')
occ.answer(asked()[1].id, [at(20, 28)])
find(occ, 5, 'secrets')
occ.clear()
occ.answer(asked()[2].id, [at(5, 12)])
expect(occ.highlight).toBeUndefined()
})
it('换了文档版本或翻译就清掉,同一版本同一翻译重发不清;换版本前问的回答丢掉', () => {
const { occ, asked } = setup(1, 'zh')
find(occ, 20, 'ALPHABET')
occ.answer(asked()[0].id, [at(20, 28)])
occ.setDocument(1, 'zh')
expect(occ.highlight?.name).toBe('ALPHABET')
occ.setDocument(1, 'ai')
expect(occ.highlight).toBeUndefined()
find(occ, 20, 'ALPHABET')
occ.answer(asked()[1].id, [at(20, 28)])
find(occ, 5, 'secrets')
occ.setDocument(2, 'ai')
expect(occ.highlight).toBeUndefined()
occ.answer(asked()[2].id, [at(5, 12)])
expect(occ.highlight).toBeUndefined()
find(occ, 20, 'ALPHABET')
expect(asked()[3].version).toBe(2)
})
it('还没有文档时点名字不问', () => {
const sent: WebviewToExtension[] = []
const occ = new OccurrenceHighlight(msg => sent.push(msg), () => {})
find(occ, 20, 'ALPHABET')
expect(sent).toEqual([])
})
})
function textSegments(text: PreviewText): Segment[] {
return 'markdown' in text ? [] : text
}
function itemSegments(items: PropertyItem[] | undefined): Segment[] {
return (items ?? []).flatMap(item => [...item.cells.flat(), ...itemSegments(item.items)])
}
/** 预览里画出来的所有段,按页面上从上到下的顺序:每块的属性表或译文,再是它的子块。 */
function allSegments(views: BlockView[]): Segment[] {
return views.flatMap(view => [
...('properties' in view
? view.properties.flatMap(row => [...textSegments(row.value), ...itemSegments(row.items)])
: textSegments(view.translation)),
...allSegments(view.children),
])
}
@@ -0,0 +1,133 @@
import type { Occurrence, Segment, WebviewToExtension } from './types'
/** 高亮画成哪种底色,和 VS Code 编辑器一样:写入(定义、赋值)用强高亮色,读取和按文字认出的用普通高亮色。 */
export type OccurrenceStyle = 'strong' | 'normal'
export function occurrenceStyle(kind: Occurrence['kind']): OccurrenceStyle {
return kind === 'write' ? 'strong' : 'normal'
}
/**
* 一段文字画不画高亮、画哪种:它照抄自源码,在源码里占 [offset, offset + 字数),和哪处出现重叠就画,
* 同时碰上写入和读取的按写入画。没有下标的(译词、提示词、编辑状态的源码)不画。
*/
export function segmentOccurrence(segment: Segment, occurrences: readonly Occurrence[]): OccurrenceStyle | undefined {
const { offset } = segment
if (offset === undefined)
return undefined
const end = offset + segment.text.length
let style: OccurrenceStyle | undefined
for (const occurrence of occurrences) {
if (occurrence.start < end && offset < occurrence.end) {
style = occurrenceStyle(occurrence.kind)
if (style === 'strong')
return style
}
}
return style
}
/** 一次点击的情况,useOccurrences 从事件和页面上读出来。 */
export interface Click {
/** 按着 ⌘ / Ctrl / Alt / Shift:不是单纯的单击(⌘ / Ctrl + 点击归跳到定义) */
modified: boolean
/** 连击的第几下(MouseEvent.detail),双击的第二下是 2 */
count: number
/** 点完页面上有选中的文字:这是拖着选字,不是点 */
selecting: boolean
/** 点中的带下标的名字 */
name?: { offset: number, text: string }
/** 点在自己管点击的东西上:按钮、链接、输入框、下拉列表、筛选栏、属性名 */
control: boolean
}
/** find:问 offset 处名字的各处出现;clear:清掉高亮;ignore:不动 */
export type ClickAction
= | { kind: 'find', offset: number, name: string }
| { kind: 'clear' }
| { kind: 'ignore' }
/**
* 单击名字就高亮,名字在折叠箭头这类按钮里也算(按钮照旧折叠);点空白处清掉;
* 修饰键 + 点击、双击、拖着选字、点在按钮 / 链接 / 筛选栏这些自己管点击的东西上,高亮不动。
*/
export function clickAction(click: Click): ClickAction {
if (click.modified || click.count > 1 || click.selecting)
return { kind: 'ignore' }
if (click.name)
return { kind: 'find', offset: click.name.offset, name: click.name.text }
return click.control ? { kind: 'ignore' } : { kind: 'clear' }
}
/** 眼下高亮着的名字:点中时它的文字(筛选栏上显示)和它在文档里的各处出现。 */
export interface Highlight {
name: string
occurrences: readonly Occurrence[]
}
/**
* 单击名字高亮它在文档里的各处出现,和编辑器里一样每回点都现问扩展端,回答不留到下次用;
* 只认最近一次询问的回答。回答到之前旧高亮留着(点同一个名字的另一处不闪),回答是空的就不高亮。
*/
export class OccurrenceHighlight {
private version: number | undefined
private translatorId: string | undefined
/** 最近一次询问的 id;别的回答(点了别处、清掉之后才到的)都丢掉 */
private request = 0
/** 最近一次询问点中的名字;答过或清掉后没有 */
private asked: string | undefined
private current: Highlight | undefined
constructor(
private readonly post: (msg: WebviewToExtension) => void,
private readonly changed: (highlight: Highlight | undefined) => void,
) {}
get highlight(): Highlight | undefined {
return this.current
}
/** 换了文档版本或翻译:下标、页面上的名字都换了,高亮清掉 */
setDocument(version: number | undefined, translatorId: string | undefined): void {
if (version === this.version && translatorId === this.translatorId)
return
this.version = version
this.translatorId = translatorId
this.clear()
}
click(action: ClickAction): void {
if (action.kind === 'clear')
this.clear()
else if (action.kind === 'find')
this.find(action.offset, action.name)
}
answer(id: number, occurrences: Occurrence[]): void {
if (id !== this.request || this.asked === undefined)
return
const name = this.asked
this.asked = undefined
this.show(occurrences.length > 0 ? { name, occurrences } : undefined)
}
clear(): void {
this.request++
this.asked = undefined
this.show(undefined)
}
private find(offset: number, name: string): void {
if (this.version === undefined)
return
this.asked = name
this.post({ type: 'findOccurrences', id: ++this.request, version: this.version, offset })
}
private show(highlight: Highlight | undefined): void {
if (highlight === this.current)
return
this.current = highlight
this.changed(highlight)
}
}
+14
View File
@@ -95,6 +95,16 @@ export interface Reveal {
definition?: number
}
/**
* 点中的名字在这份文档里的一处出现:源码下标 [start, end)。kind 同 VS Code 的 DocumentHighlightKind:
* write 是写入(定义、赋值),read 是读取,text 是语言扩展按文字认出来的
*/
export interface Occurrence {
start: number
end: number
kind: 'text' | 'read' | 'write'
}
export interface DocumentMessage {
type: 'document'
version: number
@@ -112,12 +122,14 @@ export interface DocumentMessage {
/**
* reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal)
* definitionProbed:id 那次 probeDefinition 的回答,found 表示查得到定义
* occurrencesFound:id 那次 findOccurrences 的回答,语言扩展没答(或没装)就是空的
* filter:整个工作区共用的块筛选;ready 后先于第一份文档来一次,之后任何一个预览改了都会来
*/
export type ExtensionToWebview
= | DocumentMessage
| ({ type: 'reveal' } & Reveal)
| { type: 'definitionProbed', id: number, found: boolean }
| { type: 'occurrencesFound', id: number, occurrences: Occurrence[] }
| { type: 'filter', excluded: Excluded }
/**
@@ -126,6 +138,7 @@ export type ExtensionToWebview
* probeDefinition:按住 ⌘ / Ctrl 指到带下标的名字上,问 offset 处有没有定义;id 标出是哪一次问,回答带回来;
* version 是这份下标所属的文档版本
* goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义
* findOccurrences:单击带下标的名字,问它在这份文档里出现在哪几处;id、version 同 probeDefinition
* setFilter:改块筛选(整份替换);不在本地先改,等扩展端发回 filter 再按它显示
*/
export type WebviewToExtension
@@ -136,3 +149,4 @@ export type WebviewToExtension
| { type: 'visibleLine', line: number }
| { type: 'probeDefinition', id: number, version: number, offset: number }
| { type: 'goToDefinition', version: number, offset: number }
| { type: 'findOccurrences', id: number, version: number, offset: number }
@@ -0,0 +1,55 @@
import type { Click, Highlight } from './occurrences'
import { createContext, useEffect, useLayoutEffect, useState } from 'react'
import { clickAction, OccurrenceHighlight } from './occurrences'
import { onMessage, postMessage } from './vscode'
/** 眼下高亮着的名字,SegmentText 按它给各处出现上底色。 */
export const HighlightContext = createContext<Highlight | undefined>(undefined)
/** 自己管点击的东西:点在上面(名字以外的地方)高亮不动 */
const CONTROLS = 'a, button, input, select, textarea, [role="listbox"], .cr-filter-bar, .cr-prop-label'
function clickOf(event: MouseEvent): Click {
const target = event.target instanceof Element ? event.target : null
const name = target?.closest<HTMLElement>('[data-offset]')
const selection = window.getSelection()
return {
modified: event.metaKey || event.ctrlKey || event.altKey || event.shiftKey,
count: event.detail,
selecting: selection !== null && !selection.isCollapsed,
name: name?.dataset.offset === undefined ? undefined : { offset: Number(name.dataset.offset), text: name.textContent ?? '' },
control: target?.closest(CONTROLS) != null,
}
}
/** 单击名字高亮它在文档里的各处出现,点空白处、Esc、换文档版本或翻译清掉;返回眼下高亮着的名字。 */
export function useOccurrences(version: number | undefined, translatorId: string | undefined): Highlight | undefined {
const [highlight, setHighlight] = useState<Highlight | undefined>()
const [occurrences] = useState(() => new OccurrenceHighlight(postMessage, setHighlight))
// 赶在绘制前清掉:新文档的下标换了意思,旧高亮不能落在新页面的别的名字上
useLayoutEffect(() => occurrences.setDocument(version, translatorId), [occurrences, version, translatorId])
useEffect(() => {
// 捕获阶段:赶在块里的按钮处理点击、折叠箭头换图标之前读出点在哪
const onClick = (event: MouseEvent): void => occurrences.click(clickAction(clickOf(event)))
// 输入框、下拉列表自己用掉的 Esc 不算
const onKey = (event: KeyboardEvent): void => {
if (event.key === 'Escape' && !event.defaultPrevented)
occurrences.clear()
}
const off = onMessage((msg) => {
if (msg.type === 'occurrencesFound')
occurrences.answer(msg.id, msg.occurrences)
})
document.addEventListener('click', onClick, true)
window.addEventListener('keydown', onKey)
return () => {
off()
document.removeEventListener('click', onClick, true)
window.removeEventListener('keydown', onKey)
}
}, [occurrences])
return highlight
}