♻️ refactor(vscode): 代码阅读不再在页面里拦 ⌘↓ / ⌘↑,快捷键只在「键盘快捷方式」里绑;跳到下一处时把浏览器自己的滚动拉回来,页面不再先滚到底

This commit is contained in:
cruldra committed 2026-09-27 00:33:18 +08:00
1 parent 0cad5722bb
commit 1b1299dc1f
10 files changed
+157 -165

No files matched your search

-15
View File
@@ -32,19 +32,6 @@ export interface Occurrence {
kind: 'text' | 'read' | 'write'
}
/**
* 一个按键组合,按 KeyboardEvent 的写法:key 是 KeyboardEvent.key('ArrowDown'),四个修饰键都要对上。
* webview 收到的每个按键 VS Code 都会转给自己的快捷键系统,但不告诉 webview 有没有命令接了它;
* 浏览器对这个键的默认动作(⌘↓ 滚到底)又只能在按下的当下拦,所以 webview 要事先知道命令绑的是哪些键。
*/
export interface KeyCombo {
key: string
ctrl: boolean
shift: boolean
alt: boolean
meta: boolean
}
export interface DocumentMessage {
type: 'document'
/** TextDocument.version:webview 靠它判断是不是新一份文档 */
@@ -98,7 +85,6 @@ export interface QaMessage {
* askProfiles:「问 AI」输入框旁边的 profile 下拉:选项和选中的那项;ready 后发一次,任何一个预览改了都发给所有预览
* answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事
* answerEnd:askId 那个问题答完了:done 带最终的回答和存下的那条的 id,failed 带给人看的原因,stopped 是被叫停
* occurrenceKeys:「跳到下一处 / 上一处高亮」这两条命令在 package.json 里默认绑的键;ready 后发一次,见 KeyCombo
* moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现,由这两条命令发给活动的预览
*/
export type ExtensionToWebview
@@ -106,7 +92,6 @@ export type ExtensionToWebview
| ({ 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
@@ -1,7 +1,7 @@
import { readFileSync } from 'node:fs'
import * as path from 'node:path'
import { describe, expect, it } from 'vitest'
import { NavigationContext, navigationKeys, NEXT_OCCURRENCE, OCCURRENCES_CONTEXT, parseKey, PREVIOUS_OCCURRENCE } from './occurrenceNavigation'
import { NavigationContext, NEXT_OCCURRENCE, OCCURRENCES_CONTEXT, PREVIOUS_OCCURRENCE } from './occurrenceNavigation'
interface Manifest {
contributes: {
@@ -37,33 +37,11 @@ describe('跳到下一处 / 上一处高亮:package.json 里的命令和快捷
])
})
it('页面要拦的键取自这两条命令默认绑的键,各平台的都算、重复的去掉', () => {
expect(navigationKeys(manifest)).toEqual([
{ key: 'ArrowDown', ctrl: true, shift: false, alt: false, meta: false },
{ key: 'ArrowDown', ctrl: false, shift: false, alt: false, meta: true },
{ key: 'ArrowUp', ctrl: true, shift: false, alt: false, meta: false },
{ key: 'ArrowUp', ctrl: false, shift: false, alt: false, meta: true },
])
expect(navigationKeys({ contributes: { keybindings: [
{ command: NEXT_OCCURRENCE, key: 'alt+j', linux: 'alt+j', win: 'ctrl+k ctrl+j' },
{ command: 'other', key: 'cmd+down' },
] } })).toEqual([{ key: 'j', ctrl: false, shift: false, alt: true, meta: false }])
expect(navigationKeys(undefined)).toEqual([])
})
})
describe('快捷键写法 → 按键组合', () => {
it('修饰键、方向键、字母;cmd / meta / win 都是 meta', () => {
expect(parseKey('cmd+down')).toEqual({ key: 'ArrowDown', ctrl: false, shift: false, alt: false, meta: true })
expect(parseKey('Ctrl+Shift+PageUp')).toEqual({ key: 'PageUp', ctrl: true, shift: true, alt: false, meta: false })
expect(parseKey('win+k')).toEqual({ key: 'k', ctrl: false, shift: false, alt: false, meta: true })
})
it('分两下按的、认不得的键名、认不得的修饰键没有', () => {
expect(parseKey('ctrl+k ctrl+x')).toBeUndefined()
expect(parseKey('ctrl+f5')).toBeUndefined()
expect(parseKey('hyper+down')).toBeUndefined()
expect(parseKey('ctrl+')).toBeUndefined()
it('键只在 package.json 里绑,扩展、页面的代码里都不写死键:用户改绑后原来的键照常', () => {
const sources = ['occurrenceNavigation.ts', 'previewEditor.ts', 'messages.ts', '../index.ts'].map(file => readFileSync(path.resolve(__dirname, file), 'utf8'))
const webview = ['occurrences.ts', 'useOccurrences.ts', 'types.ts'].map(file => readFileSync(path.resolve(__dirname, '../../../webview-ui/src/codeReader', file), 'utf8'))
for (const text of [...sources, ...webview])
expect(text).not.toMatch(/ArrowDown|ArrowUp|cmd\+|ctrl\+|KeyCombo|keybindings|preventDefault/)
})
})
@@ -1,5 +1,3 @@
import type { KeyCombo } from './messages'
/** 在高亮着的名字的各处出现之间跳:两条命令、快捷键的 when 子句用的上下文键。 */
export const NEXT_OCCURRENCE = 'superpowers.codeReader.nextOccurrence'
@@ -7,54 +5,6 @@ export const PREVIOUS_OCCURRENCE = 'superpowers.codeReader.previousOccurrence'
/** 活动的预览里能用键盘跳(有高亮、页面有焦点、焦点不在输入框里)。 */
export const OCCURRENCES_CONTEXT = 'superpowers.codeReader.hasOccurrences'
/** VS Code 快捷键写法里的键名 → KeyboardEvent.key;单个字母、数字照写。 */
const KEY_NAMES: Record<string, string> = {
up: 'ArrowUp',
down: 'ArrowDown',
left: 'ArrowLeft',
right: 'ArrowRight',
home: 'Home',
end: 'End',
pageup: 'PageUp',
pagedown: 'PageDown',
}
/** `cmd+down` 这种一下按完的组合 → KeyCombo;`ctrl+k ctrl+x` 这种分两下按的、认不得的键名没有。 */
export function parseKey(text: string): KeyCombo | undefined {
const parts = text.trim().toLowerCase().split('+')
if (parts.some(part => part === '' || /\s/.test(part)))
return undefined
const name = parts.pop() ?? ''
const key = KEY_NAMES[name] ?? (/^[a-z0-9]$/.test(name) ? name : undefined)
const modifiers = new Set(parts)
if (!key || [...modifiers].some(modifier => !['ctrl', 'shift', 'alt', 'cmd', 'meta', 'win'].includes(modifier)))
return undefined
return {
key,
ctrl: modifiers.has('ctrl'),
shift: modifiers.has('shift'),
alt: modifiers.has('alt'),
meta: modifiers.has('cmd') || modifiers.has('meta') || modifiers.has('win'),
}
}
interface Keybinding { command?: unknown, key?: unknown, mac?: unknown, linux?: unknown, win?: unknown }
/**
* package.json 给这两条命令默认绑的键,各平台的都算(扩展可能跑在远程机器上,看不出界面在哪个系统)。
* 用户在「键盘快捷方式」里改绑的读不到:VS Code 没有给扩展查快捷键的接口。
*/
export function navigationKeys(manifest: unknown): KeyCombo[] {
const bindings = (manifest as { contributes?: { keybindings?: Keybinding[] } } | undefined)?.contributes?.keybindings ?? []
const combos = bindings
.filter(binding => binding.command === NEXT_OCCURRENCE || binding.command === PREVIOUS_OCCURRENCE)
.flatMap(binding => [binding.key, binding.mac, binding.linux, binding.win])
.filter((text): text is string => typeof text === 'string')
.map(parseKey)
.filter((combo): combo is KeyCombo => combo !== undefined)
return combos.filter((combo, i) => combos.findIndex(other => JSON.stringify(other) === JSON.stringify(combo)) === i)
}
/**
* 上下文键跟着各预览报来的状态走:有一个预览能跳就是真(同一时刻只有一个页面有焦点),变了才设。
* 预览关掉、页面被销毁(标签页切走)时当它不能跳。
@@ -7,7 +7,7 @@ import type { CodeReaderRegistry } from '../registry'
import type { TreeSitterRuntime } from '../runtime'
import type { BlockFilter } from './blockFilter'
import type { Definition, FoundLocation } from './definitions'
import type { ExtensionToWebview, KeyCombo, Reveal, WebviewToExtension } from './messages'
import type { ExtensionToWebview, Reveal, WebviewToExtension } from './messages'
import { commands, Range, window, workspace, WorkspaceEdit } from 'vscode'
import { logger } from '../../logging/logger'
import { webviewHtml, webviewUiRoot } from '../../webview/html'
@@ -46,8 +46,6 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
readonly resolveReference: (reference: RuleReference) => Promise<RuleLocation[]>,
/** 问 AI:各预览共用,同一份文件开在几处时问答、正在答的问题都一样 */
readonly qa: CodeQa,
/** 「跳到下一处 / 上一处高亮」默认绑的键:页面在这些键上拦下浏览器自己的滚动 */
readonly occurrenceKeys: readonly KeyCombo[],
) {}
resolveCustomTextEditor(document: TextDocument, panel: WebviewPanel): void {
@@ -282,7 +280,6 @@ class Preview {
this.ready = true
// 先于文档发:第一份文档按筛选画,不会先整份露出来再藏
this.post({ type: 'filter', excluded: this.filter.excluded })
this.post({ type: 'occurrenceKeys', keys: [...this.owner.occurrenceKeys] })
this.postQa()
void this.postProfiles()
await this.push()
+2 -2
View File
@@ -3,7 +3,7 @@ import type { CodeReaderApi, RuleLocation, RuleReference } from './contract'
import { commands, window, workspace } from 'vscode'
import { logger } from '../logging/logger'
import { BlockFilter } from './editor/blockFilter'
import { navigationKeys, NEXT_OCCURRENCE, PREVIOUS_OCCURRENCE } from './editor/occurrenceNavigation'
import { NEXT_OCCURRENCE, PREVIOUS_OCCURRENCE } from './editor/occurrenceNavigation'
import { PREVIEW_VIEW_TYPE, PreviewEditorProvider } from './editor/previewEditor'
import { showPreview, showSource } from './editor/switchEditor'
import { registerCopyCommands } from './explorer/copyCommands'
@@ -66,7 +66,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
}, report))
const resolve = (reference: RuleReference): Promise<RuleLocation[]> => resolveReference(reference, registry.libraryRules(), indexes, report)
const qa = new CodeQa(context.globalState)
const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve, qa, navigationKeys(context.extension.packageJSON))
const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve, qa)
const notes = new FileNotes()
const files = new FileTreeProvider(OPEN_COMMAND, notes)
@@ -1,12 +1,12 @@
import type { Click, Highlight } from './occurrences'
import type { BlockView, Inline, KeyCombo, Occurrence, PreviewText, PropertyItem, Segment, WebviewToExtension } from './types'
import type { BlockView, Inline, 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, isCurrent, matchesKey, occurrenceCount, OccurrenceHighlight, OccurrenceNavigation, occurrenceStyle, segmentOccurrence, stepIndex } from './occurrences'
import { clickAction, isCurrent, KEY_SCROLL_MS, KEY_TO_COMMAND_MS, KeyScroll, occurrenceCount, OccurrenceHighlight, OccurrenceNavigation, occurrenceStyle, segmentOccurrence, stepIndex } from './occurrences'
function name(text: string, offset: number): Segment {
return { text, role: 'variable', offset }
@@ -310,20 +310,43 @@ describe('occurrences:用键盘在各处出现之间跳', () => {
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)
it('不拦任何键:跳转发来时回到刚按键那一刻的位置(只回一次),隔太久、点过鼠标、没按过键(命令面板)不回', () => {
const scroll = new KeyScroll()
expect(scroll.take(100)).toBeUndefined()
scroll.press({ x: 0, y: 40 }, 1000)
expect(scroll.take(1012)).toEqual({ x: 0, y: 40 })
expect(scroll.take(1013)).toBeUndefined()
scroll.press({ x: 0, y: 40 }, 2000)
expect(scroll.take(2000 + KEY_TO_COMMAND_MS + 1)).toBeUndefined()
scroll.press({ x: 0, y: 40 }, 3000)
scroll.forget()
expect(scroll.take(3010)).toBeUndefined()
})
it('跳完守住跳到的位置:浏览器对那个键自己的滚动晚于命令才开始,按键后 KEY_SCROLL_MS 内滚走了就拉回来', () => {
const scroll = new KeyScroll()
scroll.press({ x: 0, y: 40 }, 1000)
scroll.take(1012)
// 刚回到按键那一刻、还没跳完:守住按键那一刻的位置
expect(scroll.scrolled({ x: 0, y: 300 }, 1020)).toEqual({ x: 0, y: 40 })
scroll.hold({ x: 0, y: 900 })
expect(scroll.scrolled({ x: 0, y: 900 }, 1030)).toBeUndefined()
expect(scroll.scrolled({ x: 0, y: 1200 }, 1040)).toEqual({ x: 0, y: 900 })
expect(scroll.scrolled({ x: 0, y: 1200 }, 1000 + KEY_SCROLL_MS + 1)).toBeUndefined()
expect(scroll.scrolled({ x: 0, y: 1300 }, 1000 + KEY_SCROLL_MS + 2)).toBeUndefined()
})
it('不是按键触发的跳转、又按了键、点了鼠标:不守', () => {
const scroll = new KeyScroll()
scroll.take(10)
scroll.hold({ x: 0, y: 900 })
expect(scroll.scrolled({ x: 0, y: 0 }, 20)).toBeUndefined()
scroll.press({ x: 0, y: 40 }, 1000)
scroll.take(1010)
scroll.press({ x: 0, y: 40 }, 1100)
expect(scroll.scrolled({ x: 0, y: 300 }, 1110)).toBeUndefined()
scroll.take(1120)
scroll.forget()
expect(scroll.scrolled({ x: 0, y: 300 }, 1130)).toBeUndefined()
})
})
+54 -14
View File
@@ -1,4 +1,4 @@
import type { Inline, KeyCombo, Occurrence, Segment, WebviewToExtension } from './types'
import type { Inline, Occurrence, Segment, WebviewToExtension } from './types'
/** 高亮画成哪种底色,和 VS Code 编辑器一样:写入(定义、赋值)用强高亮色,读取和按文字认出的用普通高亮色。 */
export type OccurrenceStyle = 'strong' | 'normal'
@@ -111,10 +111,58 @@ export function stepIndex(
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
/** 按键之后多久内发来的跳转算这个键触发的:命令从 VS Code 绕一圈回到页面通常只要几十毫秒 */
export const KEY_TO_COMMAND_MS = 1000
/** 浏览器对一个键自己的滚动(⌘↓ 滚到底)按下后多久内会开始:它可能在命令发来之后才开始,这段时间里跳到的位置要守住 */
export const KEY_SCROLL_MS = 500
export interface ScrollPosition { x: number, y: number }
/**
* 跳到下一处 / 上一处的命令由 VS Code 的快捷键系统触发,页面不知道绑的是哪个键,浏览器对这个键自己的动作(⌘↓ 滚到底)拦不住;
* 它还可能早于命令开始,也可能晚于命令(实测命令十几毫秒就到,滚动动画再过一帧才开始)。所以不管哪个键:
* 按键那一刻记下页面滚在哪(press);命令发来时先回到那里(take)再滚到目标;跳完守住跳到的位置(hold),
* 按键后 KEY_SCROLL_MS 内浏览器自己滚走了就拉回来(scrolled)。
* 按键后点了鼠标、滚了滚轮、又按了键,或跳转隔了 KEY_TO_COMMAND_MS 以上才来(从命令面板运行),都不算这个键触发的。
*/
export class KeyScroll {
private pressed: { position: ScrollPosition, at: number } | undefined
private held: { position: ScrollPosition, until: number } | undefined
/** 按下任何键(照常交给浏览器和 VS Code 处理)时记下页面滚在哪。 */
press(position: ScrollPosition, at: number): void {
this.pressed = { position, at }
this.held = undefined
}
forget(): void {
this.pressed = undefined
this.held = undefined
}
/** 跳转发来时:刚按过键就交出按键那一刻的位置(只交一次),从这时起守住它,直到跳完换成跳到的位置。 */
take(now: number): ScrollPosition | undefined {
const pressed = this.pressed
this.pressed = undefined
this.held = pressed && now - pressed.at <= KEY_TO_COMMAND_MS ? { position: pressed.position, until: pressed.at + KEY_SCROLL_MS } : undefined
return this.held?.position
}
/** 跳完了(滚到了目标,或没处可跳留在原地):按键触发的跳转改守这个位置。 */
hold(position: ScrollPosition): void {
if (this.held)
this.held = { ...this.held, position }
}
/** 页面滚动了:还在守着、又被浏览器滚走了,给出要拉回的位置。 */
scrolled(position: ScrollPosition, now: number): ScrollPosition | undefined {
const held = this.held
if (!held || now > held.until) {
this.held = undefined
return undefined
}
return held.position.x === position.x && held.position.y === position.y ? undefined : held.position
}
}
/**
@@ -210,7 +258,7 @@ export interface NavigationFacts {
highlighted: boolean
/** 页面有焦点:按的键才会到这个页面 */
focused: boolean
/** 焦点在输入框里:⌘↑ / ⌘↓ 这些键归输入框移光标 */
/** 焦点在输入框里:按的键归输入框(打字、移光标),不拿来跳 */
typing: boolean
}
@@ -237,14 +285,6 @@ export class OccurrenceNavigation {
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)
-15
View File
@@ -142,19 +142,6 @@ 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
@@ -247,7 +234,6 @@ export interface AnswerEnd {
* askProfiles:「问 AI」的 profile 下拉;ready 后来一次,任何一个预览改了都会来
* answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事
* answerEnd:askId 那个问题答完了,见 AnswerEnd
* occurrenceKeys:「跳到下一处 / 上一处高亮」这两条命令默认绑的键;ready 后来一次,见 KeyCombo
* moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现
*/
export type ExtensionToWebview
@@ -255,7 +241,6 @@ export type ExtensionToWebview
| ({ 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
@@ -1,7 +1,7 @@
import type { Click, Highlight } from './occurrences'
import type { BlockView, KeyCombo, Occurrence } from './types'
import type { Click, Highlight, ScrollPosition } from './occurrences'
import type { BlockView, Occurrence } from './types'
import { createContext, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { clickAction, OccurrenceHighlight, OccurrenceNavigation } from './occurrences'
import { clickAction, KeyScroll, OccurrenceHighlight, OccurrenceNavigation } from './occurrences'
import { occurrencePlan } from './reveal'
import { showInViewport } from './useReveal'
import { onMessage, postMessage } from './vscode'
@@ -47,15 +47,30 @@ function isTyping(target: EventTarget | null): boolean {
|| (target instanceof HTMLInputElement && !NON_TEXT_INPUTS.has(target.type))
}
function scrollPosition(): ScrollPosition {
return { x: window.scrollX, y: window.scrollY }
}
/**
* 立刻滚到这里,顺带打断浏览器正在播(或刚排上、还没开始播)的滚动动画。滚到原地浏览器当没滚、不打断动画,
* 所以先挪一个像素再回来:同一段脚本里连着滚两下,中间不画,画面上看不出。
*/
function scrollBack({ x, y }: ScrollPosition): void {
window.scrollTo({ left: x, top: y > 0 ? y - 1 : y + 1, behavior: 'instant' })
window.scrollTo({ left: x, top: y, behavior: 'instant' })
}
/**
* 单击名字高亮它在文档里的各处出现,点空白处、Esc、换文档版本或翻译清掉;返回眼下高亮着的名字。
* 高亮着时扩展端的「跳到下一处 / 上一处高亮」命令发来 moveOccurrence:当前那一处换成下一处页面上找得到的出现,
* 展开把它折叠住的块、属性行和子行,再把它滚进视口。
* 触发命令的那个键浏览器自己的滚动不拦(页面不知道是哪个键),跳的时候盖掉它,见 KeyScroll。
*/
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 [keyScroll] = useState(() => new KeyScroll())
/** 用键盘跳过几次:每跳一次,画完后把当前那一处滚进视口 */
const [moves, setMoves] = useState(0)
const latestPage = useRef(page)
@@ -71,22 +86,28 @@ export function useOccurrences(version: number | undefined, translatorId: string
// 要展开的已经展开、当前那一处已经画出来(data-current),页面上按先后第一个就是它
useLayoutEffect(() => {
const el = moves > 0 ? document.querySelector<HTMLElement>('[data-current]') : null
if (moves === 0)
return
const el = document.querySelector<HTMLElement>('[data-current]')
if (el)
showInViewport(el)
}, [moves])
keyScroll.hold(scrollPosition())
}, [moves, keyScroll])
useEffect(() => {
let keys: KeyCombo[] = []
const move = (step: 1 | -1): void => {
// 触发命令的那个键浏览器可能已经照自己的意思滚了(⌘↓ 滚到底),先回到按键那一刻
const before = keyScroll.take(performance.now())
if (before)
scrollBack(before)
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 planOf = (occurrence: Occurrence): ReturnType<typeof occurrencePlan> => current && occurrencePlan(current.blocks, occurrence, current.skip)
const target = current && occurrences.move(step, occurrence => planOf(occurrence) !== undefined)
const plan = target && planOf(target)
if (!plan)
if (!current || !plan) {
keyScroll.hold(scrollPosition())
return
}
const folded = plan.expand.filter(id => current.collapsed.has(id))
if (folded.length > 0)
current.expand(folded)
@@ -94,12 +115,18 @@ export function useOccurrences(version: number | undefined, translatorId: string
}
// 捕获阶段:赶在块里的按钮处理点击、折叠箭头换图标之前读出点在哪
const onClick = (event: MouseEvent): void => occurrences.click(clickAction(clickOf(event)))
// 任何键都照常交给浏览器和 VS Code,这里只记下按键那一刻页面滚在哪
const onKeyDown = (event: KeyboardEvent): void => keyScroll.press(scrollPosition(), event.timeStamp)
const onPointer = (): void => keyScroll.forget()
const onScroll = (event: Event): void => {
const back = keyScroll.scrolled(scrollPosition(), event.timeStamp)
if (back)
scrollBack(back)
}
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 })
@@ -108,13 +135,15 @@ export function useOccurrences(version: number | undefined, translatorId: string
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', onKeyDown, true)
window.addEventListener('pointerdown', onPointer, true)
window.addEventListener('wheel', onPointer, { capture: true, passive: true })
window.addEventListener('scroll', onScroll, { passive: true })
window.addEventListener('keydown', onKey)
window.addEventListener('focus', onFocus)
window.addEventListener('blur', onBlur)
@@ -123,13 +152,17 @@ export function useOccurrences(version: number | undefined, translatorId: string
return () => {
off()
document.removeEventListener('click', onClick, true)
window.removeEventListener('keydown', onKeyDown, true)
window.removeEventListener('pointerdown', onPointer, true)
window.removeEventListener('wheel', onPointer, { capture: true })
window.removeEventListener('scroll', onScroll)
window.removeEventListener('keydown', onKey)
window.removeEventListener('focus', onFocus)
window.removeEventListener('blur', onBlur)
document.removeEventListener('focusin', onFocusIn)
document.removeEventListener('focusout', onFocusOut)
}
}, [occurrences, navigation])
}, [occurrences, navigation, keyScroll])
return highlight
}