🐛 fix(vscode): 代码阅读按 ⌘↓ 跳高亮不再来回闪,筛选栏加上上一处、下一处

This commit is contained in:
cruldra committed 2026-10-03 03:11:46 +08:00
1 parent 70a53e5a68
commit b16cc6409e
8 files changed
+221 -36

No files matched your search

+2 -1
View File
@@ -88,7 +88,7 @@ export function App(): ReactElement | null {
expand,
}
: undefined, [doc, unshown, covered, view.editing, collapsed, expand])
const highlight = useOccurrences(doc?.version, doc?.translatorId, occurrencePage)
const { highlight, moveOccurrence } = useOccurrences(doc?.version, doc?.translatorId, occurrencePage)
useEffect(() => {
const off = onMessage((msg) => {
@@ -164,6 +164,7 @@ export function App(): ReactElement | null {
excluded={excluded}
view={filter}
highlight={highlight}
onMoveOccurrence={moveOccurrence}
onChange={next => postMessage({ type: 'setFilter', excluded: next })}
translators={doc.translators}
translatorId={doc.translatorId}
@@ -0,0 +1,75 @@
import type { ReactElement, ReactNode } from 'react'
import type { Highlight } from './occurrences'
import { isValidElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { FilterBar } from './FilterBar'
const view = { hidden: new Set<string>(), temporary: new Set<string>(), hiddenCount: 0, total: 3 }
function bar(highlight: Highlight | undefined, onMoveOccurrence: (step: 1 | -1) => void): ReactElement {
return FilterBar({
attributes: [],
excluded: {},
view,
highlight,
onMoveOccurrence,
onChange: () => {},
translators: [{ id: 'plain', label: '不翻译' }],
translatorId: 'plain',
onSelectTranslator: () => {},
})
}
function buttons(node: ReactNode): ReactElement<{ 'data-tooltip'?: string, 'disabled'?: boolean, 'onClick'?: () => void }>[] {
const found: ReactElement<{ 'data-tooltip'?: string, 'disabled'?: boolean, 'onClick'?: () => void }>[] = []
const visit = (child: ReactNode): void => {
if (Array.isArray(child)) {
child.forEach(visit)
return
}
if (!isValidElement(child))
return
if (child.type === 'button')
found.push(child as ReactElement<{ 'data-tooltip'?: string, 'disabled'?: boolean, 'onClick'?: () => void }>)
visit((child.props as { children?: ReactNode }).children)
}
visit(node)
return found
}
const at = (start: number): Highlight['occurrences'][number] => ({ start, end: start + 1, kind: 'read' })
describe('筛选栏上的上一处 / 下一处', () => {
it('名字和「1/3」右边有两个按钮,点了按方向跳,不用原生 title', () => {
const steps: Array<1 | -1> = []
const tree = bar({ name: 'AIGC_CONTENT_PRODUCER', occurrences: [at(1), at(2), at(3)], current: 0 }, step => steps.push(step))
const html = renderToStaticMarkup(tree)
const countAt = html.indexOf('1/3')
const prevAt = html.indexOf('data-tooltip="上一处"')
const nextAt = html.indexOf('data-tooltip="下一处"')
expect(countAt).toBeGreaterThan(0)
expect(prevAt).toBeGreaterThan(countAt)
expect(nextAt).toBeGreaterThan(prevAt)
expect(html).not.toContain(' title=')
const nav = buttons(tree).filter(button => button.props['data-tooltip'] === '上一处' || button.props['data-tooltip'] === '下一处')
expect(nav.map(button => button.props['data-tooltip'])).toEqual(['上一处', '下一处'])
expect(nav.every(button => button.props.disabled !== true)).toBe(true)
nav[0]?.props.onClick?.()
nav[1]?.props.onClick?.()
expect(steps).toEqual([-1, 1])
})
it('只有一处时两个按钮不可点', () => {
const tree = bar({ name: 'A', occurrences: [at(1)], current: 0 }, () => {})
const nav = buttons(tree).filter(button => button.props['data-tooltip'] === '上一处' || button.props['data-tooltip'] === '下一处')
expect(nav).toHaveLength(2)
expect(nav.every(button => button.props.disabled === true)).toBe(true)
})
it('没有高亮时不显示这组按钮', () => {
const html = renderToStaticMarkup(bar(undefined, () => {}))
expect(html).not.toContain('data-tooltip="上一处"')
expect(html).not.toContain('data-tooltip="下一处"')
})
})
+20 -3
View File
@@ -2,7 +2,7 @@ import type { FocusEvent, KeyboardEvent, ReactElement, ReactNode } 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 { ArrowDown, ArrowUp, 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'
@@ -14,6 +14,8 @@ export interface FilterBarProps {
view: FilterView
/** 眼下高亮着的名字:汇总前面写「名字 · 2/5」(当前是第几处 / 共几处,见 occurrenceCount),折叠、筛掉的块里的出现也算在总数里 */
highlight: Highlight | undefined
/** 汇总里「上一处 / 下一处」,和快捷键走同一条跳转 */
onMoveOccurrence: (step: 1 | -1) => void
onChange: (excluded: Excluded) => void
translators: TranslatorOption[]
translatorId: string
@@ -23,10 +25,10 @@ export interface FilterBarProps {
}
/**
* 预览顶部钉住的一条:「筛选」、这份文件里每个能筛的属性一个下拉框、右边汇总(高亮着名字时先是「名字 · 2/5」,再是共 N 块 / 已隐藏 N 块 · 清除),
* 预览顶部钉住的一条:「筛选」、这份文件里每个能筛的属性一个下拉框、右边汇总(高亮着名字时先是「名字 · 2/5」,接着上一处 / 下一处,再是共 N 块 / 已隐藏 N 块 · 清除),
* 多于一种翻译时最右边是翻译下拉框;标差异时下面再接一行(below)。内容和代码块同宽对齐,滚动时块从它下面过去。
*/
export function FilterBar({ attributes, excluded, view, highlight, onChange, translators, translatorId, onSelectTranslator, below }: FilterBarProps): ReactElement {
export function FilterBar({ attributes, excluded, view, highlight, onMoveOccurrence, onChange, translators, translatorId, onSelectTranslator, below }: 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">
@@ -48,6 +50,21 @@ export function FilterBar({ attributes, excluded, view, highlight, onChange, tra
<span className="cr-occurrence-name max-w-[240px] min-w-0 truncate" data-occurrence="normal" data-tooltip={highlight.name}>{highlight.name}</span>
<span aria-hidden>·</span>
{occurrenceCount(highlight)}
<span className="flex items-center" role="group" aria-label="各处出现之间跳">
{([[-1, ArrowUp, '上一处'], [1, ArrowDown, '下一处']] as const).map(([step, Icon, label]) => (
<button
key={step}
type="button"
className="cr-tool flex h-[22px] w-6 items-center justify-center rounded disabled:opacity-40"
data-tooltip={label}
aria-label={label}
disabled={highlight.occurrences.length < 2}
onClick={() => onMoveOccurrence(step)}
>
<Icon size={14} strokeWidth={1.8} className="text-[var(--vscode-foreground)]" aria-hidden />
</button>
))}
</span>
<span className="mx-1.5 h-3 border-l border-[var(--vscode-panel-border)]" aria-hidden />
</>
)}
@@ -6,7 +6,7 @@ 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, KEY_SCROLL_MS, KEY_TO_COMMAND_MS, KeyScroll, occurrenceCount, OccurrenceHighlight, OccurrenceNavigation, occurrenceStyle, segmentOccurrence, sourceOf, stepIndex } from './occurrences'
import { clickAction, isCurrent, KEY_SCROLL_MS, KEY_TO_COMMAND_MS, KeyScroll, occurrenceCount, OccurrenceHighlight, occurrenceJumpKey, OccurrenceNavigation, occurrenceStyle, segmentOccurrence, sourceOf, stepIndex } from './occurrences'
function name(text: string, offset: number): Segment {
return { text, role: 'variable', offset }
@@ -359,4 +359,34 @@ describe('occurrences:用键盘在各处出现之间跳', () => {
scroll.forget()
expect(scroll.scrolled({ x: 0, y: 300 }, 1130)).toBeUndefined()
})
it('我们自己滚过的位置不算浏览器滚走:差 1 像素、滚进视口都不再拉一次,真被滚走仍拉回', () => {
const scroll = new KeyScroll()
scroll.press({ x: 0, y: 40 }, 1000)
scroll.take(1012)
// scrollBack 先滚到 39 再回到 40;滚进视口则落到另一个位置。这些事件可能晚于滚动调用才到
scroll.own({ x: 0, y: 39 })
scroll.own({ x: 0, y: 40 })
scroll.own({ x: 0, y: 800 })
expect(scroll.scrolled({ x: 0, y: 39 }, 1020)).toBeUndefined()
expect(scroll.scrolled({ x: 0, y: 39 }, 1021)).toBeUndefined()
expect(scroll.scrolled({ x: 0, y: 800 }, 1022)).toBeUndefined()
scroll.hold({ x: 0, y: 800 })
expect(scroll.scrolled({ x: 0, y: 39 }, 1030)).toBeUndefined()
expect(scroll.scrolled({ x: 0, y: 300 }, 1040)).toEqual({ x: 0, y: 800 })
})
it('默认快捷键:mac 是 ⌘↓ / ⌘↑,别的系统是 ctrl;带了别的修饰键不算', () => {
const key = (name: string, mod: Partial<{ metaKey: boolean, ctrlKey: boolean, altKey: boolean, shiftKey: boolean }> = {}) =>
({ key: name, metaKey: false, ctrlKey: false, altKey: false, shiftKey: false, ...mod })
expect(occurrenceJumpKey(key('ArrowDown', { metaKey: true }), true)).toBe(true)
expect(occurrenceJumpKey(key('ArrowUp', { metaKey: true }), true)).toBe(true)
expect(occurrenceJumpKey(key('ArrowDown', { ctrlKey: true }), true)).toBe(false)
expect(occurrenceJumpKey(key('ArrowDown', { ctrlKey: true }), false)).toBe(true)
expect(occurrenceJumpKey(key('ArrowUp', { ctrlKey: true }), false)).toBe(true)
expect(occurrenceJumpKey(key('ArrowDown', { metaKey: true }), false)).toBe(false)
expect(occurrenceJumpKey(key('ArrowDown', { metaKey: true, shiftKey: true }), true)).toBe(false)
expect(occurrenceJumpKey(key('ArrowDown', { metaKey: true, altKey: true }), true)).toBe(false)
expect(occurrenceJumpKey(key('ArrowLeft', { metaKey: true }), true)).toBe(false)
})
})
@@ -129,31 +129,55 @@ export const KEY_SCROLL_MS = 500
export interface ScrollPosition { x: number, y: number }
/**
* 跳到下一处 / 上一处的命令由 VS Code 的快捷键系统触发,页面不知道绑的是哪个键,浏览器对这个键自己的动作(⌘↓ 滚到底)拦不住;
* 它还可能早于命令开始,也可能晚于命令(实测命令十几毫秒就到,滚动动画再过一帧才开始)。所以不管哪个键:
* 默认的「下一处 / 上一处」快捷键:mac 是 ⌘↓ / ⌘↑,别的系统是 ctrl。
* 只用来取消浏览器滚到底 / 滚到顶,不拿它判断命令到了没有(命令是 VS Code 发来的)。
*/
export function occurrenceJumpKey(
event: { key: string, metaKey: boolean, ctrlKey: boolean, altKey: boolean, shiftKey: boolean },
mac: boolean,
): boolean {
if (event.altKey || event.shiftKey || (event.key !== 'ArrowDown' && event.key !== 'ArrowUp'))
return false
return mac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey
}
/**
* 跳到下一处 / 上一处的命令由 VS Code 的快捷键系统触发。默认的 ⌘↓ / ⌘↑(Windows 是 ctrl)在能跳的时候会取消浏览器滚到底 / 滚到顶,
* 但用户改过快捷键时别的键仍会自己滚,而且可能早于或晚于命令。所以任何键:
* 按键那一刻记下页面滚在哪(press);命令发来时先回到那里(take)再滚到目标;跳完守住跳到的位置(hold),
* 按键后 KEY_SCROLL_MS 内浏览器自己滚走了就拉回来(scrolled)。
* 按键后点了鼠标、滚了滚轮、又按了键,或跳转隔了 KEY_TO_COMMAND_MS 以上才来(从命令面板运行),都不算这个键触发的。
* 我们自己滚到的位置用 own 记下(拉回时差的那一像素、滚进视口),对应的滚动事件不算浏览器滚走;事件可能晚于滚动调用才到。
* 按键后点了鼠标、滚了滚轮、又按了键,或跳转隔了 KEY_TO_COMMAND_MS 以上才来(从命令面板运行),都不算这个键触发的。筛选栏上的按钮另走,不经过这里。
*/
export class KeyScroll {
private pressed: { position: ScrollPosition, at: number } | undefined
private held: { position: ScrollPosition, until: number } | undefined
/** 我们自己滚到的位置。守着的这段时间里,落到这里不算浏览器把页面滚走。 */
private owned: ScrollPosition[] = []
/** 按下任何键(照常交给浏览器和 VS Code 处理)时记下页面滚在哪。 */
press(position: ScrollPosition, at: number): void {
this.pressed = { position, at }
this.held = undefined
this.owned = []
}
forget(): void {
this.pressed = undefined
this.held = undefined
this.owned = []
}
/** 记下我们马上要滚到的位置。滚动事件晚到也认。 */
own(position: ScrollPosition): void {
this.owned.push(position)
}
/** 跳转发来时:刚按过键就交出按键那一刻的位置(只交一次),从这时起守住它,直到跳完换成跳到的位置。 */
take(now: number): ScrollPosition | undefined {
const pressed = this.pressed
this.pressed = undefined
this.owned = []
this.held = pressed && now - pressed.at <= KEY_TO_COMMAND_MS ? { position: pressed.position, until: pressed.at + KEY_SCROLL_MS } : undefined
return this.held?.position
}
@@ -164,13 +188,16 @@ export class KeyScroll {
this.held = { ...this.held, position }
}
/** 页面滚动了:还在守着、又被浏览器滚走了,给出要拉回的位置。 */
/** 页面滚动了:还在守着、又被浏览器滚走了,给出要拉回的位置。我们自己滚到的位置不算。 */
scrolled(position: ScrollPosition, now: number): ScrollPosition | undefined {
const held = this.held
if (!held || now > held.until) {
this.held = undefined
this.owned = []
return undefined
}
if (this.owned.some(item => item.x === position.x && item.y === position.y))
return undefined
return held.position.x === position.x && held.position.y === position.y ? undefined : held.position
}
}
@@ -1,7 +1,7 @@
import type { Click, Highlight, ScrollPosition } from './occurrences'
import type { BlockView, Occurrence } from './types'
import { createContext, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { clickAction, KeyScroll, OccurrenceHighlight, OccurrenceNavigation } from './occurrences'
import { createContext, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { clickAction, KeyScroll, OccurrenceHighlight, occurrenceJumpKey, OccurrenceNavigation } from './occurrences'
import { occurrencePlan } from './reveal'
import { showInViewport } from './useReveal'
import { onMessage, postMessage } from './vscode'
@@ -52,29 +52,41 @@ function scrollPosition(): ScrollPosition {
return { x: window.scrollX, y: window.scrollY }
}
/** mac 用 ⌘,别的系统用 ctrl。页面跑在 VS Code 的网页里,平台从浏览器读。 */
function isMac(): boolean {
return /Mac|iPhone|iPad/.test(navigator.platform)
}
/**
* 立刻滚到这里,顺带打断浏览器正在播(或刚排上、还没开始播)的滚动动画。滚到原地浏览器当没滚、不打断动画,
* 所以先挪一个像素再回来:同一段脚本里连着滚两下,中间不画,画面上看不出。
* 这两处位置先交给 own:滚动事件可能晚于这次调用才到,不能被当成浏览器把页面滚走。
*/
function scrollBack({ x, y }: ScrollPosition): void {
window.scrollTo({ left: x, top: y > 0 ? y - 1 : y + 1, behavior: 'instant' })
function scrollBack({ x, y }: ScrollPosition, own: (position: ScrollPosition) => void): void {
const nudged = { x, y: y > 0 ? y - 1 : y + 1 }
own(nudged)
own({ x, y })
window.scrollTo({ left: x, top: nudged.y, behavior: 'instant' })
window.scrollTo({ left: x, top: y, behavior: 'instant' })
}
/**
* 单击名字高亮它在文档里的各处出现,点空白处、Esc、换文档版本或翻译清掉;返回眼下高亮着的名字。
* 高亮着时扩展端的「跳到下一处 / 上一处高亮」命令发来 moveOccurrence:当前那一处换成下一处页面上找得到的出现,
* 展开把它折叠住的块、属性行和子行,再把它滚进视口。
* 触发命令的那个键浏览器自己的滚动不拦(页面不知道是哪个键),跳的时候盖掉它,见 KeyScroll。
* 单击名字高亮它在文档里的各处出现,点空白处、Esc、换文档版本或翻译清掉;返回眼下高亮着的名字,和筛选栏上「上一处 / 下一处」用的跳转。
* 高亮着时扩展端的「跳到下一处 / 上一处高亮」命令发来 moveOccurrence,筛选栏上的按钮走同一条跳转:当前那一处换成下一处页面上找得到的出现,
* 展开把它折叠住的块、属性行和子行,再把它滚进视口。按钮不是按键,不按 ⌘↓ 那样拉回按键时的位置。
* 默认的 ⌘↓ / ⌘↑(Windows 是 ctrl)在能跳的时候取消浏览器滚到底 / 滚到顶,但不停事件继续传(VS Code 在后面才把按键收成命令)。
* 别的键浏览器仍可能自己滚,跳的时候盖掉它,见 KeyScroll。
*/
export function useOccurrences(version: number | undefined, translatorId: string | undefined, page: OccurrencePage | undefined): Highlight | undefined {
export function useOccurrences(version: number | undefined, translatorId: string | undefined, page: OccurrencePage | undefined): { highlight: Highlight | undefined, moveOccurrence: (step: 1 | -1) => void } {
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)
/** 筛选栏按钮调用的跳转。真正的函数在下面的效果里,挂上监听时才有。 */
const moveFromButton = useRef<(step: 1 | -1) => void>(() => {})
useLayoutEffect(() => {
latestPage.current = page
@@ -91,16 +103,22 @@ export function useOccurrences(version: number | undefined, translatorId: string
return
const el = document.querySelector<HTMLElement>('[data-current]')
if (el)
showInViewport(el)
showInViewport(el, position => keyScroll.own(position))
keyScroll.hold(scrollPosition())
}, [moves, keyScroll])
useEffect(() => {
const move = (step: 1 | -1): void => {
// 触发命令的那个键浏览器可能已经照自己的意思滚了(⌘↓ 滚到底),先回到按键那一刻
const before = keyScroll.take(performance.now())
if (before)
scrollBack(before)
const move = (step: 1 | -1, fromKey: boolean): void => {
if (fromKey) {
// 触发命令的那个键浏览器可能已经照自己的意思滚了(⌘↓ 滚到底),先回到按键那一刻
const before = keyScroll.take(performance.now())
if (before)
scrollBack(before, position => keyScroll.own(position))
}
else {
// 筛选栏按钮不是按键,别把这次滚动当成 ⌘↓ 拉回按键时的位置
keyScroll.forget()
}
const current = latestPage.current
const planOf = (occurrence: Occurrence): ReturnType<typeof occurrencePlan> => current && occurrencePlan(current.blocks, occurrence, current.skip)
const target = current && occurrences.move(step, occurrence => planOf(occurrence) !== undefined)
@@ -116,13 +134,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)
moveFromButton.current = step => move(step, false)
// 任何键都照常交给 VS Code。能跳时,默认的 ⌘↓ / ⌘↑(Windows 是 ctrl)取消浏览器滚到底 / 滚到顶,但不停冒泡,命令还是收得到
const onKeyDown = (event: KeyboardEvent): void => {
if (navigation.available && occurrenceJumpKey(event, isMac()))
event.preventDefault()
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)
scrollBack(back, position => keyScroll.own(position))
}
const onKey = (event: KeyboardEvent): void => {
// 输入框、下拉列表自己用掉的 Esc 不算
@@ -137,7 +160,7 @@ export function useOccurrences(version: number | undefined, translatorId: string
if (msg.type === 'occurrencesFound')
occurrences.answer(msg.id, msg.occurrences)
else if (msg.type === 'moveOccurrence')
move(msg.step)
move(msg.step, true)
})
navigation.update({})
document.addEventListener('click', onClick, true)
@@ -165,5 +188,6 @@ export function useOccurrences(version: number | undefined, translatorId: string
}
}, [occurrences, navigation, keyScroll])
return highlight
const moveOccurrence = useCallback((step: 1 | -1) => moveFromButton.current(step), [])
return { highlight, moveOccurrence }
}
@@ -1,3 +1,4 @@
import type { ScrollPosition } from './occurrences'
import type { DocumentMessage, Reveal } from './types'
import { useLayoutEffect } from 'react'
import { blockPath } from './filter'
@@ -24,12 +25,16 @@ export function flash(el: HTMLElement): void {
}
/** 把 el 滚进钉住的筛选栏下面的视口,规则见 scrollTopToShow:已经整个看得见就不滚。 */
export function showInViewport(el: HTMLElement): void {
/** 把 el 滚进钉住的筛选栏下面的视口,规则见 scrollTopToShow:已经整个看得见就不滚。beforeScroll 在真正滚动前拿到将要落到的位置。 */
export function showInViewport(el: HTMLElement, beforeScroll?: (position: ScrollPosition) => void): 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)
if (top === undefined)
return
const position = { x: 0, y: top - pinned }
beforeScroll?.(position)
window.scrollTo(position.x, position.y)
}
/**