diff --git a/vscode/src/codeReader/editor/occurrenceNavigation.test.ts b/vscode/src/codeReader/editor/occurrenceNavigation.test.ts index 94ff994..d3c401e 100644 --- a/vscode/src/codeReader/editor/occurrenceNavigation.test.ts +++ b/vscode/src/codeReader/editor/occurrenceNavigation.test.ts @@ -37,11 +37,17 @@ describe('跳到下一处 / 上一处高亮:package.json 里的命令和快捷 ]) }) - it('键只在 package.json 里绑,扩展、页面的代码里都不写死键:用户改绑后原来的键照常', () => { + it('扩展端不写死键,跳转仍等命令消息。页面只认出默认的 ⌘↓ / ⌘↑(Windows 是 ctrl),取消浏览器滚到底 / 滚到顶,但不停事件继续传', () => { 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]) + const types = readFileSync(path.resolve(__dirname, '../../../webview-ui/src/codeReader/types.ts'), 'utf8') + for (const text of [...sources, types]) expect(text).not.toMatch(/ArrowDown|ArrowUp|cmd\+|ctrl\+|KeyCombo|keybindings|preventDefault/) + const page = ['occurrences.ts', 'useOccurrences.ts'].map(file => readFileSync(path.resolve(__dirname, '../../../webview-ui/src/codeReader', file), 'utf8')) + expect(page.join('\n')).toMatch(/ArrowDown/) + expect(page.join('\n')).toMatch(/ArrowUp/) + expect(page.join('\n')).toContain('preventDefault()') + expect(page.join('\n')).not.toContain('stopPropagation') + expect(page.join('\n')).toContain('move(msg.step, true)') }) }) diff --git a/vscode/webview-ui/src/codeReader/App.tsx b/vscode/webview-ui/src/codeReader/App.tsx index 058859c..4954d37 100644 --- a/vscode/webview-ui/src/codeReader/App.tsx +++ b/vscode/webview-ui/src/codeReader/App.tsx @@ -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} diff --git a/vscode/webview-ui/src/codeReader/FilterBar.test.tsx b/vscode/webview-ui/src/codeReader/FilterBar.test.tsx new file mode 100644 index 0000000..6ff5031 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/FilterBar.test.tsx @@ -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(), temporary: new Set(), 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="下一处"') + }) +}) diff --git a/vscode/webview-ui/src/codeReader/FilterBar.tsx b/vscode/webview-ui/src/codeReader/FilterBar.tsx index 1fa942d..7fb5f9c 100644 --- a/vscode/webview-ui/src/codeReader/FilterBar.tsx +++ b/vscode/webview-ui/src/codeReader/FilterBar.tsx @@ -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 (
@@ -48,6 +50,21 @@ export function FilterBar({ attributes, excluded, view, highlight, onChange, tra {highlight.name} · {occurrenceCount(highlight)} + + {([[-1, ArrowUp, '上一处'], [1, ArrowDown, '下一处']] as const).map(([step, Icon, label]) => ( + + ))} + )} diff --git a/vscode/webview-ui/src/codeReader/occurrences.test.ts b/vscode/webview-ui/src/codeReader/occurrences.test.ts index aefceb6..45848ba 100644 --- a/vscode/webview-ui/src/codeReader/occurrences.test.ts +++ b/vscode/webview-ui/src/codeReader/occurrences.test.ts @@ -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) + }) }) diff --git a/vscode/webview-ui/src/codeReader/occurrences.ts b/vscode/webview-ui/src/codeReader/occurrences.ts index 781bea7..68aaa28 100644 --- a/vscode/webview-ui/src/codeReader/occurrences.ts +++ b/vscode/webview-ui/src/codeReader/occurrences.ts @@ -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 } } diff --git a/vscode/webview-ui/src/codeReader/useOccurrences.ts b/vscode/webview-ui/src/codeReader/useOccurrences.ts index 8ad65af..9804ffe 100644 --- a/vscode/webview-ui/src/codeReader/useOccurrences.ts +++ b/vscode/webview-ui/src/codeReader/useOccurrences.ts @@ -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() 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('[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 => 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 } } diff --git a/vscode/webview-ui/src/codeReader/useReveal.ts b/vscode/webview-ui/src/codeReader/useReveal.ts index fb8c5d6..55ebd6c 100644 --- a/vscode/webview-ui/src/codeReader/useReveal.ts +++ b/vscode/webview-ui/src/codeReader/useReveal.ts @@ -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) } /**