✨ feat(vscode): 代码阅读的名字高亮更显眼(当前那处描框、别处画细线),⌘↓ / ⌘↑ 在各处之间跳,跳到折叠处自动展开,筛选栏写第几处
This commit is contained in:
1 parent
c5c5d2b517
commit
49f253b80f
18 files changed
+731
-53
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { BlockTreeState } from './BlockTree'
|
||||
import type { DocumentMessage, Excluded, Reveal } from './types'
|
||||
import type { OccurrencePage } from './useOccurrences'
|
||||
import type { ViewState } from './viewState'
|
||||
import { TriangleAlert } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
@@ -53,7 +54,15 @@ export function App(): ReactElement | null {
|
||||
useScrollPosition(doc)
|
||||
useReveal(doc, reveal, collapsed, filter.hidden, covered, show, expand, revealed)
|
||||
useDefinitionLinks(doc?.version)
|
||||
const highlight = useOccurrences(doc?.version, doc?.translatorId)
|
||||
const occurrencePage = useMemo((): OccurrencePage | undefined => doc
|
||||
? {
|
||||
blocks: doc.blocks,
|
||||
skip: id => filter.hidden.has(id) || covered.has(id) || view.editing.includes(id),
|
||||
collapsed,
|
||||
expand,
|
||||
}
|
||||
: undefined, [doc, filter, covered, view.editing, collapsed, expand])
|
||||
const highlight = useOccurrences(doc?.version, doc?.translatorId, occurrencePage)
|
||||
|
||||
useEffect(() => {
|
||||
const off = onMessage((msg) => {
|
||||
|
||||
@@ -6,12 +6,13 @@ import { Check, ChevronDown, ListFilter } from 'lucide-react'
|
||||
import { useId, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { SelectMenu } from '@/components/ui/select-menu'
|
||||
import { facetLabel, facetsOf, setChecked, toggleValue } from './filter'
|
||||
import { occurrenceCount } from './occurrences'
|
||||
|
||||
export interface FilterBarProps {
|
||||
attributes: FileAttribute[]
|
||||
excluded: Excluded
|
||||
view: FilterView
|
||||
/** 眼下高亮着的名字:汇总前面写「名字 · N 处」,折叠、筛掉的块里的出现也算在 N 里 */
|
||||
/** 眼下高亮着的名字:汇总前面写「名字 · 2/5」(当前是第几处 / 共几处,见 occurrenceCount),折叠、筛掉的块里的出现也算在总数里 */
|
||||
highlight: Highlight | undefined
|
||||
onChange: (excluded: Excluded) => void
|
||||
translators: TranslatorOption[]
|
||||
@@ -20,7 +21,7 @@ export interface FilterBarProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览顶部钉住的一条:「筛选」、这份文件里每个能筛的属性一个下拉框、右边汇总(高亮着名字时先是「名字 · N 处」,再是共 N 块 / 已隐藏 N 块 · 清除),
|
||||
* 预览顶部钉住的一条:「筛选」、这份文件里每个能筛的属性一个下拉框、右边汇总(高亮着名字时先是「名字 · 2/5」,再是共 N 块 / 已隐藏 N 块 · 清除),
|
||||
* 多于一种翻译时最右边是翻译下拉框。内容和代码块同宽对齐,滚动时块从它下面过去。
|
||||
*/
|
||||
export function FilterBar({ attributes, excluded, view, highlight, onChange, translators, translatorId, onSelectTranslator }: FilterBarProps): ReactElement {
|
||||
@@ -44,7 +45,7 @@ export function FilterBar({ attributes, excluded, view, highlight, onChange, tra
|
||||
<>
|
||||
<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} 处`}
|
||||
{occurrenceCount(highlight)}
|
||||
<span className="mx-1.5 h-3 border-l border-[var(--vscode-panel-border)]" aria-hidden />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -3,12 +3,13 @@ import type { ReactElement } from 'react'
|
||||
import type { GroupStyle, Inline, SegmentGroup } from './types'
|
||||
import { Repeat } from 'lucide-react'
|
||||
import { useContext } from 'react'
|
||||
import { segmentOccurrence } from './occurrences'
|
||||
import { isCurrent, segmentOccurrence } from './occurrences'
|
||||
import { HighlightContext } from './useOccurrences'
|
||||
|
||||
/**
|
||||
* 按词性给每一段上色,颜色在 codeReader.css 的 .cr-role-* 里按主题切换;带源码下标的名字记在 data-offset 上,
|
||||
* 跳到定义、高亮各处出现用;是眼下高亮着的名字的一处出现时 data-occurrence 标出底色。框起来的一组词画成小框,框里的词同样处理。
|
||||
* 跳到定义、高亮各处出现用;是眼下高亮着的名字的一处出现时 data-occurrence 标出底色,是当前那一处(点中的、用键盘跳到的)时
|
||||
* 再标 data-current。框起来的一组词画成小框,框里的词同样处理。
|
||||
*/
|
||||
export function SegmentText({ segments }: { segments: Inline[] }): ReactElement {
|
||||
const highlight = useContext(HighlightContext)
|
||||
@@ -21,6 +22,7 @@ export function SegmentText({ segments }: { segments: Inline[] }): ReactElement
|
||||
className={`cr-role-${piece.role}`}
|
||||
data-offset={piece.offset}
|
||||
data-occurrence={highlight && segmentOccurrence(piece, highlight.occurrences)}
|
||||
data-current={highlight && isCurrent(piece, highlight) ? '' : undefined}
|
||||
>
|
||||
{piece.text}
|
||||
</span>
|
||||
|
||||
@@ -292,10 +292,13 @@ body:is(.vscode-light, .vscode-high-contrast-light) [data-badge] { --kind: var(-
|
||||
|
||||
/*
|
||||
* 单击名字高亮它在文件里的各处出现:颜色取 VS Code 编辑器的单词高亮,写入(定义、赋值)用强高亮色,读取和按文字认出的用普通高亮色;
|
||||
* 主题给了边框色(高对比主题)才描边。筛选栏上「名字 · N 处」的名字也按普通高亮画。描边画在字框里面,不挤动排版
|
||||
* 主题给了边框色(高对比主题)才描边。很多主题没配单词高亮色,默认的灰底很淡,所以再用 focusBorder 色补一笔:
|
||||
* 各处底下一条细线,当前那一处(点中的、用键盘跳到的)描一圈框。筛选栏上「名字 · 2/5」的名字也按普通高亮画。
|
||||
* 线和框都画在字框里面,不挤动排版;细线用内阴影画,不占 text-decoration(⌘ 指着名字时的链接下划线用它)
|
||||
*/
|
||||
[data-occurrence] {
|
||||
outline-offset: -1px;
|
||||
box-shadow: inset 0 -1px 0 var(--vscode-focusBorder);
|
||||
}
|
||||
[data-occurrence="normal"] {
|
||||
background: var(--vscode-editor-wordHighlightBackground);
|
||||
@@ -305,6 +308,10 @@ body:is(.vscode-light, .vscode-high-contrast-light) [data-badge] { --kind: var(-
|
||||
background: var(--vscode-editor-wordHighlightStrongBackground);
|
||||
outline: 1px solid var(--vscode-editor-wordHighlightStrongBorder, transparent);
|
||||
}
|
||||
[data-occurrence][data-current] {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
box-shadow: none;
|
||||
}
|
||||
.cr-occurrence-name {
|
||||
padding: 0 3px;
|
||||
color: var(--vscode-editor-foreground);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { Click, Highlight } from './occurrences'
|
||||
import type { BlockView, Inline, Occurrence, PreviewText, PropertyItem, Segment, WebviewToExtension } from './types'
|
||||
import type { BlockView, Inline, KeyCombo, 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'
|
||||
import { clickAction, isCurrent, matchesKey, occurrenceCount, OccurrenceHighlight, OccurrenceNavigation, occurrenceStyle, segmentOccurrence, stepIndex } from './occurrences'
|
||||
|
||||
function name(text: string, offset: number): Segment {
|
||||
return { text, role: 'variable', offset }
|
||||
@@ -121,8 +121,8 @@ describe('occurrences:高亮的状态', () => {
|
||||
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 }])
|
||||
expect(occ.highlight).toEqual({ name: 'ALPHABET', occurrences, current: 0 })
|
||||
expect(changes).toEqual([{ name: 'ALPHABET', occurrences, current: 0 }])
|
||||
|
||||
find(occ, 5, 'secrets')
|
||||
occ.answer(asked()[1].id, [])
|
||||
@@ -136,7 +136,7 @@ describe('occurrences:高亮的状态', () => {
|
||||
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)] })
|
||||
expect(occ.highlight).toEqual({ name: 'secrets', occurrences: [at(5, 12), at(40, 47)], current: 0 })
|
||||
})
|
||||
|
||||
it('只认最近一次询问的回答,迟到的丢掉', () => {
|
||||
@@ -146,9 +146,9 @@ describe('occurrences:高亮的状态', () => {
|
||||
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)] })
|
||||
expect(occ.highlight).toEqual({ name: 'secrets', occurrences: [at(5, 12)], current: 0 })
|
||||
occ.answer(second.id, [at(99, 100)])
|
||||
expect(occ.highlight).toEqual({ name: 'secrets', occurrences: [at(5, 12)] })
|
||||
expect(occ.highlight).toEqual({ name: 'secrets', occurrences: [at(5, 12)], current: 0 })
|
||||
})
|
||||
|
||||
it('回答不留到下次用:答过「没有」后再点同一个名字照样再问', () => {
|
||||
@@ -227,3 +227,103 @@ function allSegments(views: BlockView[]): Segment[] {
|
||||
...allSegments(view.children),
|
||||
])
|
||||
}
|
||||
|
||||
const ANYWHERE = (): boolean => true
|
||||
|
||||
describe('occurrences:用键盘在各处出现之间跳', () => {
|
||||
it('回答照源码先后排好,点中的那处是当前那一处;筛选栏写「第几处/共几处」,点中的不在回答里时写「N 处」', () => {
|
||||
const { occ, asked } = setup()
|
||||
find(occ, 40, 'ALPHABET')
|
||||
occ.answer(asked()[0].id, [at(90, 98), at(40, 48), at(10, 18, 'write')])
|
||||
expect(occ.highlight).toEqual({ name: 'ALPHABET', occurrences: [at(10, 18, 'write'), at(40, 48), at(90, 98)], current: 1 })
|
||||
expect(occurrenceCount(occ.highlight!)).toBe('2/3')
|
||||
|
||||
find(occ, 60, 'ALPHABET')
|
||||
occ.answer(asked()[1].id, [at(10, 18), at(40, 48), at(90, 98)])
|
||||
expect(occ.highlight?.current).toBeUndefined()
|
||||
expect(occurrenceCount(occ.highlight!)).toBe('3 处')
|
||||
})
|
||||
|
||||
it('往后、往前按源码先后跳,到头了从另一头接着跳;当前那一处跟着换', () => {
|
||||
const { occ, asked, changes } = setup()
|
||||
find(occ, 40, 'ALPHABET')
|
||||
occ.answer(asked()[0].id, [at(10, 18), at(40, 48), at(90, 98)])
|
||||
expect(occ.move(1, ANYWHERE)).toEqual(at(90, 98))
|
||||
expect(occ.highlight?.current).toBe(2)
|
||||
expect(occ.move(1, ANYWHERE)).toEqual(at(10, 18))
|
||||
expect(occ.highlight?.current).toBe(0)
|
||||
expect(occ.move(-1, ANYWHERE)).toEqual(at(90, 98))
|
||||
expect(occ.move(-1, ANYWHERE)).toEqual(at(40, 48))
|
||||
expect(occurrenceCount(occ.highlight!)).toBe('2/3')
|
||||
expect(changes.at(-1)).toEqual({ name: 'ALPHABET', occurrences: [at(10, 18), at(40, 48), at(90, 98)], current: 1 })
|
||||
})
|
||||
|
||||
it('页面上找不到的(在被筛掉的块里)跳过,总数照旧;一处都找不到、没有高亮时不动', () => {
|
||||
const { occ, asked, changes } = setup()
|
||||
expect(occ.move(1, ANYWHERE)).toBeUndefined()
|
||||
find(occ, 10, 'ALPHABET')
|
||||
occ.answer(asked()[0].id, [at(10, 18), at(40, 48), at(90, 98)])
|
||||
const shown = (occurrence: Occurrence): boolean => occurrence.start !== 40
|
||||
expect(occ.move(1, shown)).toEqual(at(90, 98))
|
||||
expect(occ.move(1, shown)).toEqual(at(10, 18))
|
||||
expect(occ.move(-1, shown)).toEqual(at(90, 98))
|
||||
expect(occurrenceCount(occ.highlight!)).toBe('3/3')
|
||||
const before = changes.length
|
||||
expect(occ.move(1, () => false)).toBeUndefined()
|
||||
expect(changes).toHaveLength(before)
|
||||
expect(occ.highlight?.current).toBe(2)
|
||||
})
|
||||
|
||||
it('点中的名字不在回答里:从点中的地方往后是它后面第一处,往前是它前面最后一处', () => {
|
||||
const occurrences = [at(10, 18), at(40, 48), at(90, 98)]
|
||||
expect(stepIndex(occurrences, undefined, 60, 1, ANYWHERE)).toBe(2)
|
||||
expect(stepIndex(occurrences, undefined, 60, -1, ANYWHERE)).toBe(1)
|
||||
expect(stepIndex(occurrences, undefined, 5, -1, ANYWHERE)).toBe(2)
|
||||
expect(stepIndex(occurrences, undefined, 95, 1, ANYWHERE)).toBe(0)
|
||||
expect(stepIndex(occurrences, 0, 0, -1, ANYWHERE)).toBe(2)
|
||||
expect(stepIndex(occurrences, 1, 0, 1, occurrence => occurrence.start === 40)).toBe(1)
|
||||
expect(stepIndex([], undefined, 0, 1, ANYWHERE)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('当前那一处:和它重叠的段描边,别的出现、没下标的段不描', () => {
|
||||
const highlight = { name: 'ALPHABET', occurrences: [at(10, 18), at(40, 48)], current: 1 }
|
||||
expect(isCurrent(name('ALPHABET', 40), highlight)).toBe(true)
|
||||
expect(isCurrent(name('ALPHABET', 10), highlight)).toBe(false)
|
||||
expect(isCurrent({ text: 'ALPHABET', role: 'plain' }, highlight)).toBe(false)
|
||||
expect(isCurrent(name('ALPHABET', 40), { ...highlight, current: undefined })).toBe(false)
|
||||
})
|
||||
|
||||
it('能不能用键盘跳:有高亮、页面有焦点、焦点不在输入框里;第一次都告诉扩展端,之后变了才告诉', () => {
|
||||
const sent: WebviewToExtension[] = []
|
||||
const navigation = new OccurrenceNavigation(msg => sent.push(msg), { focused: true, typing: false })
|
||||
const reports = (): boolean[] => sent.flatMap(msg => msg.type === 'occurrenceNavigation' ? [msg.available] : [])
|
||||
navigation.update({})
|
||||
expect(reports()).toEqual([false])
|
||||
navigation.update({ highlighted: true })
|
||||
expect(navigation.available).toBe(true)
|
||||
navigation.update({ highlighted: true })
|
||||
navigation.update({ typing: true })
|
||||
navigation.update({ typing: false })
|
||||
navigation.update({ focused: false })
|
||||
navigation.update({ highlighted: false })
|
||||
navigation.update({ focused: true })
|
||||
expect(reports()).toEqual([false, true, false, true, false])
|
||||
})
|
||||
|
||||
it('命令绑的键:能跳时拦下浏览器的默认动作(⌘↓ 滚到底),不能跳、别的键、修饰键对不上都不拦', () => {
|
||||
const keys: KeyCombo[] = [{ key: 'ArrowDown', ctrl: false, shift: false, alt: false, meta: true }, { key: 'j', ctrl: true, shift: false, alt: false, meta: false }]
|
||||
const press = (key: string, modifiers: Partial<Record<'ctrlKey' | 'shiftKey' | 'altKey' | 'metaKey', boolean>> = {}): Pick<KeyboardEvent, 'key' | 'ctrlKey' | 'shiftKey' | 'altKey' | 'metaKey'> =>
|
||||
({ key, ctrlKey: false, shiftKey: false, altKey: false, metaKey: false, ...modifiers })
|
||||
const navigation = new OccurrenceNavigation(() => {}, { focused: true, typing: false })
|
||||
expect(navigation.blocks(press('ArrowDown', { metaKey: true }), keys)).toBe(false)
|
||||
navigation.update({ highlighted: true })
|
||||
expect(navigation.blocks(press('ArrowDown', { metaKey: true }), keys)).toBe(true)
|
||||
expect(navigation.blocks(press('J', { ctrlKey: true }), keys)).toBe(true)
|
||||
expect(navigation.blocks(press('ArrowDown', { metaKey: true, shiftKey: true }), keys)).toBe(false)
|
||||
expect(navigation.blocks(press('ArrowDown'), keys)).toBe(false)
|
||||
expect(navigation.blocks(press('ArrowUp', { metaKey: true }), keys)).toBe(false)
|
||||
navigation.update({ typing: true })
|
||||
expect(navigation.blocks(press('ArrowDown', { metaKey: true }), keys)).toBe(false)
|
||||
expect(matchesKey(press('ArrowDown', { metaKey: true }), keys[0])).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Occurrence, Segment, WebviewToExtension } from './types'
|
||||
import type { Inline, KeyCombo, Occurrence, Segment, WebviewToExtension } from './types'
|
||||
|
||||
/** 高亮画成哪种底色,和 VS Code 编辑器一样:写入(定义、赋值)用强高亮色,读取和按文字认出的用普通高亮色。 */
|
||||
export type OccurrenceStyle = 'strong' | 'normal'
|
||||
@@ -12,13 +12,9 @@ export function occurrenceStyle(kind: Occurrence['kind']): OccurrenceStyle {
|
||||
* 同时碰上写入和读取的按写入画。没有下标的(译词、提示词、编辑状态的源码)不画。
|
||||
*/
|
||||
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) {
|
||||
if (overlaps(segment, occurrence)) {
|
||||
style = occurrenceStyle(occurrence.kind)
|
||||
if (style === 'strong')
|
||||
return style
|
||||
@@ -27,6 +23,17 @@ export function segmentOccurrence(segment: Segment, occurrences: readonly Occurr
|
||||
return style
|
||||
}
|
||||
|
||||
/** 段照抄自源码、在源码里占的 [offset, offset + 字数) 和这处出现重叠;没有下标的段不算。 */
|
||||
export function overlaps(segment: Segment, occurrence: Occurrence): boolean {
|
||||
const { offset } = segment
|
||||
return offset !== undefined && occurrence.start < offset + segment.text.length && offset < occurrence.end
|
||||
}
|
||||
|
||||
/** 文字里(连框里)有段和这处出现重叠。 */
|
||||
export function showsOccurrence(pieces: readonly Inline[], occurrence: Occurrence): boolean {
|
||||
return pieces.some(piece => 'role' in piece ? overlaps(piece, occurrence) : showsOccurrence(piece.segments, occurrence))
|
||||
}
|
||||
|
||||
/** 一次点击的情况,useOccurrences 从事件和页面上读出来。 */
|
||||
export interface Click {
|
||||
/** 按着 ⌘ / Ctrl / Alt / Shift:不是单纯的单击(⌘ / Ctrl + 点击归跳到定义) */
|
||||
@@ -62,20 +69,68 @@ export function clickAction(click: Click): ClickAction {
|
||||
/** 眼下高亮着的名字:点中时它的文字(筛选栏上显示)和它在文档里的各处出现。 */
|
||||
export interface Highlight {
|
||||
name: string
|
||||
/** 照源码先后排 */
|
||||
occurrences: readonly Occurrence[]
|
||||
/** 当前那一处(点中的、用键盘跳到的)在 occurrences 里的序号;点中的名字不在回答里时没有 */
|
||||
current?: number
|
||||
}
|
||||
|
||||
/** 段是不是当前那一处出现(点中的、用键盘跳到的)。 */
|
||||
export function isCurrent(segment: Segment, highlight: Highlight): boolean {
|
||||
const current = highlight.current === undefined ? undefined : highlight.occurrences[highlight.current]
|
||||
return current !== undefined && overlaps(segment, current)
|
||||
}
|
||||
|
||||
/** 筛选栏上名字后面写的:有当前那一处时「第几处/共几处」(2/5),没有时「N 处」;看不见的也算在总数里。 */
|
||||
export function occurrenceCount(highlight: Highlight): string {
|
||||
const total = highlight.occurrences.length
|
||||
return highlight.current === undefined ? `${total} 处` : `${highlight.current + 1}/${total}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 往后(step 1)/ 往前(step -1)数,下一处能跳到的出现的序号,到头了从另一头接着数;一处都跳不到时没有。
|
||||
* 从当前那一处(current)数起;没有当前那一处时从源码下标 from(点中的名字)数起。
|
||||
*/
|
||||
export function stepIndex(
|
||||
occurrences: readonly Occurrence[],
|
||||
current: number | undefined,
|
||||
from: number,
|
||||
step: 1 | -1,
|
||||
reachable: (occurrence: Occurrence) => boolean,
|
||||
): number | undefined {
|
||||
const count = occurrences.length
|
||||
// 没有当前那一处时,起点是 from 前面最后一处(往后数)/ 后面第一处(往前数),数的时候跳过它本身所在的位置
|
||||
const start = current ?? (step === 1
|
||||
? occurrences.filter(occurrence => occurrence.start <= from).length - 1
|
||||
: count - occurrences.filter(occurrence => occurrence.start >= from).length)
|
||||
for (let k = 1; k <= count; k++) {
|
||||
const index = (((start + step * k) % count) + count) % count
|
||||
if (reachable(occurrences[index]))
|
||||
return index
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** 按下的键是不是这个组合:键名(字母不分大小写)和四个修饰键都对上。 */
|
||||
export function matchesKey(event: Pick<KeyboardEvent, 'key' | 'ctrlKey' | 'shiftKey' | 'altKey' | 'metaKey'>, combo: KeyCombo): boolean {
|
||||
return event.key.toLowerCase() === combo.key.toLowerCase()
|
||||
&& event.ctrlKey === combo.ctrl && event.shiftKey === combo.shift && event.altKey === combo.alt && event.metaKey === combo.meta
|
||||
}
|
||||
|
||||
/**
|
||||
* 单击名字高亮它在文档里的各处出现,和编辑器里一样每回点都现问扩展端,回答不留到下次用;
|
||||
* 只认最近一次询问的回答。回答到之前旧高亮留着(点同一个名字的另一处不闪),回答是空的就不高亮。
|
||||
* 高亮着时能用键盘在各处出现之间跳(move),跳到的那处是当前那一处。
|
||||
*/
|
||||
export class OccurrenceHighlight {
|
||||
private version: number | undefined
|
||||
private translatorId: string | undefined
|
||||
/** 最近一次询问的 id;别的回答(点了别处、清掉之后才到的)都丢掉 */
|
||||
private request = 0
|
||||
/** 最近一次询问点中的名字;答过或清掉后没有 */
|
||||
private asked: string | undefined
|
||||
/** 最近一次询问点中的那段名字;答过或清掉后没有 */
|
||||
private asked: Segment | undefined
|
||||
/** 高亮着的名字没有当前那一处时,从这个源码下标(点中的名字)数起跳到哪一处 */
|
||||
private from = 0
|
||||
private current: Highlight | undefined
|
||||
|
||||
constructor(
|
||||
@@ -103,12 +158,29 @@ export class OccurrenceHighlight {
|
||||
this.find(action.offset, action.name)
|
||||
}
|
||||
|
||||
/** 回答照源码先后排好;点中的那处就是当前那一处。 */
|
||||
answer(id: number, occurrences: Occurrence[]): void {
|
||||
if (id !== this.request || this.asked === undefined)
|
||||
const asked = this.asked
|
||||
if (id !== this.request || asked === undefined)
|
||||
return
|
||||
const name = this.asked
|
||||
this.asked = undefined
|
||||
this.show(occurrences.length > 0 ? { name, occurrences } : undefined)
|
||||
const sorted = [...occurrences].sort((a, b) => a.start - b.start || a.end - b.end)
|
||||
const current = sorted.findIndex(occurrence => overlaps(asked, occurrence))
|
||||
this.from = asked.offset ?? 0
|
||||
this.show(sorted.length > 0 ? { name: asked.text, occurrences: sorted, ...(current >= 0 && { current }) } : undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前那一处换成往后(step 1)/ 往前(step -1)的下一处能跳到的出现(见 stepIndex),返回它;
|
||||
* 没有高亮、一处都跳不到时不动,返回 undefined。
|
||||
*/
|
||||
move(step: 1 | -1, reachable: (occurrence: Occurrence) => boolean): Occurrence | undefined {
|
||||
const highlight = this.current
|
||||
const index = highlight && stepIndex(highlight.occurrences, highlight.current, this.from, step, reachable)
|
||||
if (!highlight || index === undefined)
|
||||
return undefined
|
||||
this.show({ ...highlight, current: index })
|
||||
return highlight.occurrences[index]
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
@@ -120,7 +192,7 @@ export class OccurrenceHighlight {
|
||||
private find(offset: number, name: string): void {
|
||||
if (this.version === undefined)
|
||||
return
|
||||
this.asked = name
|
||||
this.asked = { text: name, role: 'plain', offset }
|
||||
this.post({ type: 'findOccurrences', id: ++this.request, version: this.version, offset })
|
||||
}
|
||||
|
||||
@@ -131,3 +203,53 @@ export class OccurrenceHighlight {
|
||||
this.changed(highlight)
|
||||
}
|
||||
}
|
||||
|
||||
/** 能不能用键盘跳要看的几件事。 */
|
||||
export interface NavigationFacts {
|
||||
/** 有名字高亮着 */
|
||||
highlighted: boolean
|
||||
/** 页面有焦点:按的键才会到这个页面 */
|
||||
focused: boolean
|
||||
/** 焦点在输入框里:⌘↑ / ⌘↓ 这些键归输入框移光标 */
|
||||
typing: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 眼下能不能用键盘在高亮着的名字的各处出现之间跳:有高亮、页面有焦点、焦点不在输入框里。变了才告诉扩展端,
|
||||
* 扩展端跟着设快捷键 when 子句用的上下文键;页面也按它决定要不要拦下这些键在浏览器里的默认动作(滚到顶、滚到底)。
|
||||
*/
|
||||
export class OccurrenceNavigation {
|
||||
private facts: NavigationFacts
|
||||
private reported: boolean | undefined
|
||||
|
||||
constructor(private readonly post: (msg: WebviewToExtension) => void, initial: Omit<NavigationFacts, 'highlighted'>) {
|
||||
this.facts = { highlighted: false, ...initial }
|
||||
}
|
||||
|
||||
get available(): boolean {
|
||||
const { highlighted, focused, typing } = this.facts
|
||||
return highlighted && focused && !typing
|
||||
}
|
||||
|
||||
/** 改了哪几件事;第一次调用时不管变没变都告诉扩展端(页面重新加载后扩展端记的可能是旧页面的) */
|
||||
update(change: Partial<NavigationFacts>): void {
|
||||
this.facts = { ...this.facts, ...change }
|
||||
this.report()
|
||||
}
|
||||
|
||||
/**
|
||||
* 这个键要不要拦下浏览器的默认动作:能跳、又是那两条命令绑的键。VS Code 把页面上按的每个键都转给自己的快捷键系统,
|
||||
* 但不回头告诉页面有没有命令接了它,浏览器的默认动作又只能在按下的当下拦,所以只能照命令绑的键(keys)当场判断。
|
||||
*/
|
||||
blocks(event: Pick<KeyboardEvent, 'key' | 'ctrlKey' | 'shiftKey' | 'altKey' | 'metaKey'>, keys: readonly KeyCombo[]): boolean {
|
||||
return this.available && keys.some(combo => matchesKey(event, combo))
|
||||
}
|
||||
|
||||
private report(): void {
|
||||
const { available } = this
|
||||
if (available === this.reported)
|
||||
return
|
||||
this.reported = available
|
||||
this.post({ type: 'occurrenceNavigation', available })
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { LanguageAdapter, Translator } from '../../../src/codeReader/contract'
|
||||
import type { RevealPlan } from './reveal'
|
||||
import type { BlockKind, BlockView, Inline, PropertyItem, PropertyRow, Segment } from './types'
|
||||
import type { BlockKind, BlockView, Inline, Occurrence, PropertyItem, PropertyRow, Segment } from './types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { toBlockViews } from '../../../src/codeReader/blockView'
|
||||
import { createPythonAdapter } from '../../../src/codeReader/languages/python'
|
||||
@@ -9,7 +9,7 @@ import { sqlalchemyRule } from '../../../src/codeReader/rules/sqlalchemy'
|
||||
import { WASM_DIR, withBlocks } from '../../../src/codeReader/testSupport'
|
||||
import { pythonZhTranslator } from '../../../src/codeReader/translators/pythonZh'
|
||||
import { typescriptZhTranslator } from '../../../src/codeReader/translators/typescriptZh'
|
||||
import { revealPlan, scrollTopToShow } from './reveal'
|
||||
import { occurrencePlan, revealPlan, scrollTopToShow } from './reveal'
|
||||
import { collapsedIds, propertyItemId, propertyRowId } from './viewState'
|
||||
|
||||
function name(text: string, offset: number): Segment {
|
||||
@@ -52,6 +52,44 @@ const BLOCKS: BlockView[] = [
|
||||
factBlock('2', 'const', 8, [{ label: '名称', value: [name('LIMIT', 130)] }]),
|
||||
]
|
||||
|
||||
describe('用键盘跳到高亮的一处出现:滚到哪块、展开哪些', () => {
|
||||
const nowhere = (): boolean => false
|
||||
const at = (start: number, end: number): Occurrence => ({ start, end, kind: 'read' })
|
||||
|
||||
it('在嵌套块的译文里:目标是那块,展开它所有的祖先块', () => {
|
||||
expect(occurrencePlan(BLOCKS, at(102, 108), nowhere)).toEqual({ id: '1.0.0', expand: ['1', '1.0'] })
|
||||
})
|
||||
|
||||
it('在属性行展开的子行里(导入项、参数):连那一行一起展开;在属性行的值里不用展开', () => {
|
||||
expect(occurrencePlan(BLOCKS, at(34, 40), nowhere)).toEqual({ id: '0', expand: ['0/p1'] })
|
||||
expect(occurrencePlan(BLOCKS, at(51, 55), nowhere)).toEqual({ id: '1', expand: ['1/p2'] })
|
||||
expect(occurrencePlan(BLOCKS, at(130, 135), nowhere)).toEqual({ id: '2', expand: [] })
|
||||
})
|
||||
|
||||
it('和段重叠就算(点分模块名的一节、比名字长的段);页面上哪儿都没有时没有', () => {
|
||||
expect(occurrencePlan(BLOCKS, at(18, 20), nowhere)).toEqual({ id: '0', expand: [] })
|
||||
expect(occurrencePlan(BLOCKS, at(62, 70), nowhere)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('被筛掉的、正在编辑的、被盖住的块里的不算;只跳过那一块,它下面画出来的子块照常算', () => {
|
||||
const hidden = new Set(['1.0', '1.0.0'])
|
||||
expect(occurrencePlan(BLOCKS, at(102, 108), id => hidden.has(id))).toBeUndefined()
|
||||
expect(occurrencePlan(BLOCKS, at(51, 55), id => id === '1')).toBeUndefined()
|
||||
expect(occurrencePlan(BLOCKS, at(102, 108), id => id === '1')).toEqual({ id: '1.0.0', expand: ['1', '1.0'] })
|
||||
})
|
||||
|
||||
it('同一处在页面上显示几次:取页面上的第一处;Markdown 里的不算', () => {
|
||||
const blocks: BlockView[] = [
|
||||
factBlock('0', 'function', 0, [{ label: '说明', value: { markdown: '见 `get_db`' } }], [
|
||||
textBlock('0.0', 'return', 1, [word('返回'), name('get_db', 30)]),
|
||||
]),
|
||||
factBlock('1', 'class', 2, [{ label: '字段', value: [{ text: '1 个', role: 'hint' }], columns: ['字段'], items: [{ cells: [[name('get_db', 30)]] }] }]),
|
||||
]
|
||||
expect(occurrencePlan(blocks, at(30, 36), nowhere)).toEqual({ id: '0.0', expand: ['0'] })
|
||||
expect(occurrencePlan(blocks, at(30, 36), id => id === '0.0')).toEqual({ id: '1', expand: ['1/p0'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('跳到定义:滚到哪块、展开哪些', () => {
|
||||
it('名字在嵌套块的译文里:目标是那块,展开它所有的祖先块', () => {
|
||||
expect(revealPlan(BLOCKS, 4, 102)).toEqual({ id: '1.0.0', expand: ['1', '1.0'] })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { BlockView, PreviewText, PropertyItem, SourceRange } from './types'
|
||||
import type { BlockView, Occurrence, PreviewText, PropertyItem, SourceRange } from './types'
|
||||
import { blockPath } from './filter'
|
||||
import { showsOccurrence } from './occurrences'
|
||||
import { showsOffset } from './segments'
|
||||
import { propertyItemId, propertyRowId } from './viewState'
|
||||
|
||||
@@ -61,7 +62,7 @@ function locate(block: BlockView, offset: number): Spot | undefined {
|
||||
for (const { row, id } of rows) {
|
||||
if (shows(row.value, offset))
|
||||
return { expand: [] }
|
||||
const expand = showingItem(row.items, id, offset)
|
||||
const expand = showingItem(row.items, id, cell => shows(cell, offset))
|
||||
if (expand)
|
||||
return { expand }
|
||||
}
|
||||
@@ -81,12 +82,12 @@ function definingItem(items: PropertyItem[] | undefined, parentId: string, offse
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** parentId 下面一层层的子行里,哪个显示着 offset 处的名字:要展开的各层(parentId 到那个子行所在的那一层)。 */
|
||||
function showingItem(items: PropertyItem[] | undefined, parentId: string, offset: number): string[] | undefined {
|
||||
/** parentId 下面一层层的子行里,哪个的格子满足 test(显示着要找的名字):要展开的各层(parentId 到那个子行所在的那一层)。 */
|
||||
function showingItem(items: PropertyItem[] | undefined, parentId: string, test: (cell: PreviewText) => boolean): string[] | undefined {
|
||||
for (const [index, item] of (items ?? []).entries()) {
|
||||
if (item.cells.some(cell => shows(cell, offset)))
|
||||
if (item.cells.some(test))
|
||||
return [parentId]
|
||||
const inner = showingItem(item.items, propertyItemId(parentId, index), offset)
|
||||
const inner = showingItem(item.items, propertyItemId(parentId, index), test)
|
||||
if (inner)
|
||||
return [parentId, ...inner]
|
||||
}
|
||||
@@ -119,6 +120,34 @@ export function uncoveredPlan(plan: RevealPlan, covered: ReadonlyMap<string, str
|
||||
return owner === undefined ? plan : { id: owner, expand: blockPath(owner).slice(0, -1) }
|
||||
}
|
||||
|
||||
/** 块里哪儿显示着满足 test 的文字(译文、属性行的值、各层子行的格子):要展开的属性行和子行;不在这块里是 undefined。 */
|
||||
function showingSpot(block: BlockView, test: (text: PreviewText) => boolean): string[] | undefined {
|
||||
if ('translation' in block)
|
||||
return test(block.translation) ? [] : undefined
|
||||
for (const [index, row] of block.properties.entries()) {
|
||||
if (test(row.value))
|
||||
return []
|
||||
const expand = showingItem(row.items, propertyRowId(block.id, index), test)
|
||||
if (expand)
|
||||
return expand
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 用键盘跳到高亮着的名字的一处出现:页面上按先后第一处显示着它(有段和它重叠)的块,以及要展开的块、属性行和子行。
|
||||
* skip 的块(被筛掉的、正在编辑的、被正在编辑的祖先盖住的)里的不算;Markdown 说明里的名字不带下标,也不算。页面上哪儿都没有时是 undefined。
|
||||
*/
|
||||
export function occurrencePlan(blocks: BlockView[], occurrence: Occurrence, skip: (id: string) => boolean): RevealPlan | undefined {
|
||||
const test = (text: PreviewText): boolean => !('markdown' in text) && showsOccurrence(text, occurrence)
|
||||
for (const { block, ancestors } of placed(blocks)) {
|
||||
const expand = skip(block.id) ? undefined : showingSpot(block, test)
|
||||
if (expand)
|
||||
return { id: block.id, expand: [...ancestors, ...expand] }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 让一块露出来时视口顶部要在的位置,和 VS Code 跳到定义一样:整块已经在视口里就不滚;否则放到视口正中,比视口还高就顶部对齐。
|
||||
* viewTop / viewHeight 是视口里露出块的那部分(钉住的筛选栏下面)。
|
||||
|
||||
@@ -142,6 +142,19 @@ export interface Occurrence {
|
||||
kind: 'text' | 'read' | 'write'
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个按键组合,按 KeyboardEvent 的写法:key 是 KeyboardEvent.key('ArrowDown'),四个修饰键都要对上。
|
||||
* 页面上按的每个键 VS Code 都会转给自己的快捷键系统,但不告诉页面有没有命令接了它;浏览器对这个键的默认动作
|
||||
* (⌘↓ 滚到底)又只能在按下的当下拦,所以页面要事先知道命令绑的是哪些键
|
||||
*/
|
||||
export interface KeyCombo {
|
||||
key: string
|
||||
ctrl: boolean
|
||||
shift: boolean
|
||||
alt: boolean
|
||||
meta: boolean
|
||||
}
|
||||
|
||||
export interface DocumentMessage {
|
||||
type: 'document'
|
||||
version: number
|
||||
@@ -234,12 +247,16 @@ export interface AnswerEnd {
|
||||
* askProfiles:「问 AI」的 profile 下拉;ready 后来一次,任何一个预览改了都会来
|
||||
* answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事
|
||||
* answerEnd:askId 那个问题答完了,见 AnswerEnd
|
||||
* occurrenceKeys:「跳到下一处 / 上一处高亮」这两条命令默认绑的键;ready 后来一次,见 KeyCombo
|
||||
* moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现
|
||||
*/
|
||||
export type ExtensionToWebview
|
||||
= | DocumentMessage
|
||||
| ({ type: 'reveal' } & Reveal)
|
||||
| { type: 'definitionProbed', id: number, found: boolean }
|
||||
| { type: 'occurrencesFound', id: number, occurrences: Occurrence[] }
|
||||
| { type: 'occurrenceKeys', keys: KeyCombo[] }
|
||||
| { type: 'moveOccurrence', step: 1 | -1 }
|
||||
| { type: 'filter', excluded: Excluded }
|
||||
| QaMessage
|
||||
| ({ type: 'askProfiles' } & ProfileChoice)
|
||||
@@ -259,6 +276,7 @@ export type ExtensionToWebview
|
||||
* stopAsk:叫停 askId 那个问题,这次不存
|
||||
* deleteQa:删掉 anchor 那块下面的一次问答(已经理解了)
|
||||
* selectAskProfile:「问 AI」换 profile;不在本地先改,等扩展端发回 askProfiles 再按它显示
|
||||
* occurrenceNavigation:眼下能不能用键盘在高亮着的名字的各处出现之间跳(有高亮、页面有焦点、焦点不在输入框里),变了才发
|
||||
*/
|
||||
export type WebviewToExtension
|
||||
= | { type: 'ready' }
|
||||
@@ -273,3 +291,4 @@ export type WebviewToExtension
|
||||
| { type: 'stopAsk', askId: string }
|
||||
| { type: 'deleteQa', anchor: string, entryId: string }
|
||||
| { type: 'selectAskProfile', id: string }
|
||||
| { type: 'occurrenceNavigation', available: boolean }
|
||||
@@ -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
|
||||
}
|
||||
@@ -23,6 +23,15 @@ function flash(el: HTMLElement): void {
|
||||
el.setAttribute('data-flash', '')
|
||||
}
|
||||
|
||||
/** 把 el 滚进钉住的筛选栏下面的视口,规则见 scrollTopToShow:已经整个看得见就不滚。 */
|
||||
export function showInViewport(el: HTMLElement): void {
|
||||
const rect = el.getBoundingClientRect()
|
||||
const pinned = pinnedHeight()
|
||||
const top = scrollTopToShow({ top: rect.top + window.scrollY, bottom: rect.bottom + window.scrollY }, window.scrollY + pinned, window.innerHeight - pinned)
|
||||
if (top !== undefined)
|
||||
window.scrollTo(0, top - pinned)
|
||||
}
|
||||
|
||||
/**
|
||||
* 滚到 reveal 指的位置,处理完调 done:
|
||||
* ① 从文本编辑器切过来(没有 definition):包含那一行的块顶到视口顶部(筛选栏下面),折叠着的、被筛掉的不展开;
|
||||
@@ -66,11 +75,7 @@ export function useReveal(
|
||||
const row = plan?.row === undefined ? null : document.querySelector<HTMLElement>(`:is(.cr-prop-row, .cr-prop-item, .cr-table-row)[data-id="${plan.row}"]`)
|
||||
const el = row ?? (plan && document.querySelector<HTMLElement>(`.cr-block[data-id="${plan.id}"]`))
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect()
|
||||
const pinned = pinnedHeight()
|
||||
const top = scrollTopToShow({ top: rect.top + window.scrollY, bottom: rect.bottom + window.scrollY }, window.scrollY + pinned, window.innerHeight - pinned)
|
||||
if (top !== undefined)
|
||||
window.scrollTo(0, top - pinned)
|
||||
showInViewport(el)
|
||||
flash(el)
|
||||
}
|
||||
done()
|
||||
|
||||
Reference in new issue
Block a user