From 3c5f5f5f35162d2f73b0543620938cf23f27eac5 Mon Sep 17 00:00:00 2001 From: cruldra Date: Wed, 14 Oct 2026 06:11:25 +1100 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(vscode):=20=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E9=98=85=E8=AF=BB=E9=A2=84=E8=A7=88=E8=B7=B3=E5=88=B0?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E5=90=8E=E9=A1=B5=E9=9D=A2=E8=BF=98=E5=9C=A8?= =?UTF-8?q?=E9=95=BF=E9=AB=98=EF=BC=88=E6=B3=A8=E9=87=8A=E9=87=8C=E7=9A=84?= =?UTF-8?q?=20mermaid=20=E7=94=BB=E6=88=90=E5=9B=BE=E3=80=81=E4=B8=AD?= =?UTF-8?q?=E6=96=87=E5=90=8D=E5=88=B0=E4=BA=86=EF=BC=89=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=8A=8A=E8=A7=86=E5=8F=A3=E5=B8=A6=E8=B7=91=EF=BC=9A=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E6=BB=9A=E5=8A=A8=E7=BB=9F=E4=B8=80=E4=BA=A4=E7=BB=99?= =?UTF-8?q?=E6=BB=9A=E5=8A=A8=E6=89=80=E6=9C=89=E8=80=85=EF=BC=8C=E8=B7=9F?= =?UTF-8?q?=E4=BD=8F=E8=B7=B3=E5=88=B0=E7=9A=84=E7=9B=AE=E6=A0=87=E3=80=81?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E8=87=AA=E5=B7=B1=E6=BB=9A=E4=BA=86=E6=94=B9?= =?UTF-8?q?=E9=92=89=E8=A7=86=E5=8F=A3=E9=A1=B6=E9=83=A8=E9=82=A3=E5=9D=97?= =?UTF-8?q?=EF=BC=8C=E5=85=B3=E6=8E=89=E6=B5=8F=E8=A7=88=E5=99=A8=E8=87=AA?= =?UTF-8?q?=E5=B8=A6=E7=9A=84=E6=BB=9A=E5=8A=A8=E9=94=9A=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vscode/webview-ui/src/codeReader/App.tsx | 10 +- .../webview-ui/src/codeReader/AskThread.tsx | 19 +- .../src/codeReader/CallTreeDialog.tsx | 4 +- .../webview-ui/src/codeReader/CodeEditor.tsx | 3 +- .../webview-ui/src/codeReader/FilterBar.tsx | 4 +- vscode/webview-ui/src/codeReader/FindBar.tsx | 2 +- .../webview-ui/src/codeReader/StickyLines.tsx | 9 +- .../webview-ui/src/codeReader/codeReader.css | 5 + .../src/codeReader/occurrences.test.ts | 37 +-- .../webview-ui/src/codeReader/occurrences.ts | 24 +- .../src/codeReader/pageLayout.test.ts | 70 ++++- .../webview-ui/src/codeReader/pageLayout.ts | 59 +++- .../src/codeReader/pageScroll.test.tsx | 296 ++++++++++++++++++ .../src/codeReader/scrollOwner.test.ts | 230 ++++++++++++++ .../webview-ui/src/codeReader/scrollOwner.ts | 210 +++++++++++++ .../src/codeReader/scrollPosition.test.ts | 23 +- .../src/codeReader/scrollPosition.ts | 14 + .../webview-ui/src/codeReader/stickyScroll.ts | 5 + .../webview-ui/src/codeReader/useChangeNav.ts | 18 +- vscode/webview-ui/src/codeReader/useFind.ts | 22 +- .../src/codeReader/useOccurrences.ts | 32 +- vscode/webview-ui/src/codeReader/useReveal.ts | 51 ++- .../src/codeReader/useScrollPosition.ts | 13 +- .../src/codeReader/useStickyScroll.ts | 5 - 24 files changed, 1016 insertions(+), 149 deletions(-) create mode 100644 vscode/webview-ui/src/codeReader/pageScroll.test.tsx create mode 100644 vscode/webview-ui/src/codeReader/scrollOwner.test.ts create mode 100644 vscode/webview-ui/src/codeReader/scrollOwner.ts diff --git a/vscode/webview-ui/src/codeReader/App.tsx b/vscode/webview-ui/src/codeReader/App.tsx index 9d1c83b..d8a43fe 100644 --- a/vscode/webview-ui/src/codeReader/App.tsx +++ b/vscode/webview-ui/src/codeReader/App.tsx @@ -23,6 +23,7 @@ import { pageLayout } from './pageLayout' import { createPageStore } from './pageState' import { NO_PLACEMENT, placementOf } from './placement' import { chatOf, PATH_ASK_ANCHOR, sameChat } from './qa' +import { scrollOwner } from './scrollOwner' import { TextMarksContext } from './SegmentText' import { shareBlocks, shareUnchanged } from './sharing' import { StickyLines } from './StickyLines' @@ -83,15 +84,20 @@ export function App(): ReactElement | null { const content = useRef(null) /** 查找栏的高亮画在页面内容里 */ const [findPainter] = useState(() => new FindPainter()) - /** 页面内容挂上时,卡片的位置从它量(pageLayout):筛选栏和它在同一次提交里画出来;查找的高亮也画在它里面 */ + /** + * 页面内容挂上时,卡片的位置从它量(pageLayout):筛选栏和它在同一次提交里画出来;查找的高亮也画在它里面。 + * 页面的滚动都归 [scrollOwner]:内容大小一变,它照着跳转、恢复位置时定下的意图滚回去。 + */ const attachContent = useCallback((el: HTMLDivElement | null) => { content.current = el if (!el) return undefined - const detachLayout = pageLayout.attach(el, document.querySelector('.cr-filter-bar'), () => page.get().placement) + const detachLayout = pageLayout.attach(el, document.querySelector('.cr-filter-bar'), () => page.get().placement, () => scrollOwner.relayout()) + const detachScroll = scrollOwner.attach() const detachFind = findPainter.attach(el) return () => { detachLayout() + detachScroll() detachFind() } }, [page, findPainter]) diff --git a/vscode/webview-ui/src/codeReader/AskThread.tsx b/vscode/webview-ui/src/codeReader/AskThread.tsx index 144de88..68e98a9 100644 --- a/vscode/webview-ui/src/codeReader/AskThread.tsx +++ b/vscode/webview-ui/src/codeReader/AskThread.tsx @@ -10,6 +10,7 @@ import { SelectMenu } from '@/components/ui/select-menu' import { renderMarkdown, renderMarkdownOnce } from './markdown' import { answerPreview, relativeTime, unfinished } from './qa' import { MAX_IMAGES, pickImages, readImage, transferFiles } from './qaImages' +import { showInViewport } from './useReveal' /** 一种对话在界面上怎么称呼:区域的名字、图标、「问过 N 次」、删一条、输入框的提示和名字、回答长出来时的状态 */ export interface ThreadWording { @@ -83,10 +84,10 @@ function ImagePreview({ url, onClose }: { url: string, onClose: () => void }): R const close = useRef(null) useEffect(() => { const opener = document.activeElement - close.current?.focus() + close.current?.focus({ preventScroll: true }) return () => { if (opener instanceof HTMLElement) - opener.focus() + opener.focus({ preventScroll: true }) } }, []) const onKeyDown = (event: KeyboardEvent): void => { @@ -194,6 +195,14 @@ function CurrentItem({ ask, wording, onOpenImage }: { ask: Ask, wording: ThreadW } /** 底下的输入框:Enter 发送、Shift+Enter 换行、Esc 收起;正在答时发送换成停止,排着队时换成取消排队。能贴图、拖图,贴的图在输入框上面一排,随下一个问题发 */ +/** 把光标放进输入框;它不在视口里时交给[滚动所有者](vscode/webview-ui/src/codeReader/scrollOwner.ts#ScrollOwner)滚过去(见 [showInViewport]),不让浏览器自己滚。 */ +function focusField(field: HTMLTextAreaElement | null): void { + if (!field) + return + field.focus({ preventScroll: true }) + showInViewport(() => field.isConnected ? field : null) +} + function AskInput({ active, placeholder, label, profiles, onSelectProfile, onAsk, onStop, onClose, closable, onOpenImage, autoFocus, focusToken }: { /** 正在答或排着队的那一轮 */ active: Ask | undefined @@ -222,11 +231,11 @@ function AskInput({ active, placeholder, label, profiles, onSelectProfile, onAsk const textarea = useRef(null) useEffect(() => { if (autoFocus) - textarea.current?.focus() + focusField(textarea.current) }, [autoFocus]) useEffect(() => { if (focusToken > 0) - textarea.current?.focus() + focusField(textarea.current) }, [focusToken]) const attach = (files: File[]): void => { if (files.length === 0) @@ -275,7 +284,7 @@ function AskInput({ active, placeholder, label, profiles, onSelectProfile, onAsk } const remove = (index: number): void => { setImages(current => current.filter((_, i) => i !== index)) - textarea.current?.focus() + focusField(textarea.current) } return ( <> diff --git a/vscode/webview-ui/src/codeReader/CallTreeDialog.tsx b/vscode/webview-ui/src/codeReader/CallTreeDialog.tsx index 1b2958e..350bc84 100644 --- a/vscode/webview-ui/src/codeReader/CallTreeDialog.tsx +++ b/vscode/webview-ui/src/codeReader/CallTreeDialog.tsx @@ -35,7 +35,7 @@ export function CallTreeDialog({ kind, badge, name, where, status, trees, select const onKey = (event: KeyboardEvent) => { if (event.key !== 'Escape') return - document.querySelector('[data-call-tree][data-active]')?.focus() + document.querySelector('[data-call-tree][data-active]')?.focus({ preventScroll: true }) onClose() } window.addEventListener('keydown', onKey) @@ -43,7 +43,7 @@ export function CallTreeDialog({ kind, badge, name, where, status, trees, select }, [onClose]) function close(): void { - document.querySelector('[data-call-tree][data-active]')?.focus() + document.querySelector('[data-call-tree][data-active]')?.focus({ preventScroll: true }) onClose() } diff --git a/vscode/webview-ui/src/codeReader/CodeEditor.tsx b/vscode/webview-ui/src/codeReader/CodeEditor.tsx index 6c094eb..eb665ed 100644 --- a/vscode/webview-ui/src/codeReader/CodeEditor.tsx +++ b/vscode/webview-ui/src/codeReader/CodeEditor.tsx @@ -81,7 +81,8 @@ export function CodeEditor({ sourceText, place, version, autoFocus, onAutoFocuse const el = textareaRef.current if (!autoFocus || !el) return - el.focus() + // 编辑框就在刚点了「编辑」的那张卡片原处,看得见,不用滚 + el.focus({ preventScroll: true }) el.setSelectionRange(el.value.length, el.value.length) onAutoFocused() }, [autoFocus, onAutoFocused]) diff --git a/vscode/webview-ui/src/codeReader/FilterBar.tsx b/vscode/webview-ui/src/codeReader/FilterBar.tsx index 7fb5f9c..9e32dba 100644 --- a/vscode/webview-ui/src/codeReader/FilterBar.tsx +++ b/vscode/webview-ui/src/codeReader/FilterBar.tsx @@ -120,7 +120,7 @@ function FacetMenu({ facet, onToggle, onSetAll }: { useLayoutEffect(() => { if (open) - listRef.current?.focus() + listRef.current?.focus({ preventScroll: true }) }, [open]) useLayoutEffect(() => { @@ -135,7 +135,7 @@ function FacetMenu({ facet, onToggle, onSetAll }: { const close = (): void => { setOpen(false) - buttonRef.current?.focus() + buttonRef.current?.focus({ preventScroll: true }) } // 焦点离开按钮和菜单(点外面、Tab 出去、切到别的窗口)就关 diff --git a/vscode/webview-ui/src/codeReader/FindBar.tsx b/vscode/webview-ui/src/codeReader/FindBar.tsx index 059e4bb..7589135 100644 --- a/vscode/webview-ui/src/codeReader/FindBar.tsx +++ b/vscode/webview-ui/src/codeReader/FindBar.tsx @@ -24,7 +24,7 @@ export interface FindBarProps { export function FindBar({ query, count, canMove, focus, onQuery, onMove, onClose }: FindBarProps): ReactElement { const input = useRef(null) useLayoutEffect(() => { - input.current?.focus() + input.current?.focus({ preventScroll: true }) input.current?.select() }, [focus]) const onKeyDown = (event: KeyboardEvent): void => { diff --git a/vscode/webview-ui/src/codeReader/StickyLines.tsx b/vscode/webview-ui/src/codeReader/StickyLines.tsx index afb8a36..295d0de 100644 --- a/vscode/webview-ui/src/codeReader/StickyLines.tsx +++ b/vscode/webview-ui/src/codeReader/StickyLines.tsx @@ -3,6 +3,7 @@ import type { BlockView, StickyScrollSettings } from './types' import { useContext, useLayoutEffect, useMemo } from 'react' import { KindIcon } from './kindIcons' import { pageLayout } from './pageLayout' +import { scrollOwner } from './scrollOwner' import { BlockIdContext, SegmentText, TextMarksContext } from './SegmentText' import { selectStore } from './store' import { flash } from './useReveal' @@ -47,7 +48,7 @@ export function PinnedHead({ block }: { block: BlockView }): ReactElement { * 钉在顶上的卡片(同 VS Code 编辑器的 Sticky Scroll,设置也用它的,一张卡片就是一层):卡片的开头已经滚到筛选栏后面、卡片(连同子块) * 还有一部分看得见时,筛选栏下面钉一行这张卡片的头(PinnedHead),外层的在上、里层的往下摞,每行和那张卡片左右对齐(越里层越往右); * 这张卡片快滚完时,最里那行被下面的内容顶上去、从上一行下面滑走。哪几张见 pinnedLines,只按页面上画出来的算: - * 折叠着的卡片自己照样钉(它的子块没画出来,不算);被筛掉的不在页面上,正在编辑的不钉。点一行把那张卡片滚到顶上(外层还钉着的几行下面)、闪几下。 + * 折叠着的卡片自己照样钉(它的子块没画出来,不算);被筛掉的不在页面上,正在编辑的不钉。点一行把那张卡片滚到顶上(外层还钉着的几行下面,之后由 [scrollOwner] 跟住)、闪几下。 * 钉着的名字不画页面上的高亮(页面上找当前那一处只认卡片里的);跳到定义、悬停看卡片照旧。 * content 是块树所在的页面内容,blocks 是这份文档的块。 */ @@ -74,11 +75,13 @@ export function StickyLines({ content, blocks, settings }: { content: RefObject< return null const jump = (id: string): void => { - const card = content.current?.querySelector(`.cr-block[data-id="${id}"]`) + const locate = (): HTMLElement | null => content.current?.querySelector(`.cr-block[data-id="${id}"]`) ?? null + const card = locate() if (!card) return pageLayout.settle(card) - window.scrollTo(0, card.getBoundingClientRect().top + window.scrollY - pageLayout.pinnedHeight() - stickyInset(card, settings)) + const inset = pageLayout.pinnedHeight() + stickyInset(card, settings) + scrollOwner.reveal(locate, box => box.top - inset) flash(card) } diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index 8d1f5fc..4e9987b 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -1,5 +1,10 @@ @import "tailwindcss"; +/* 关掉浏览器的滚动锚定:页面长高变矮时由[滚动所有者](vscode/webview-ui/src/codeReader/scrollOwner.ts#ScrollOwner)照跳转、恢复位置时定下的意图滚回去,两边一起补会补两遍 */ +html { + overflow-anchor: none; +} + /* 词性与块种类的配色(--cr-keyword 这些、--cr-kind-*)由扩展端按当前颜色主题算好发来,设在 body 上(codeColors.ts)。其余界面颜色一律用 --vscode-* 变量。 */ body { margin: 0; diff --git a/vscode/webview-ui/src/codeReader/occurrences.test.ts b/vscode/webview-ui/src/codeReader/occurrences.test.ts index f9206a9..4c28a58 100644 --- a/vscode/webview-ui/src/codeReader/occurrences.test.ts +++ b/vscode/webview-ui/src/codeReader/occurrences.test.ts @@ -369,54 +369,49 @@ describe('occurrences:用键盘在各处出现之间跳', () => { scroll.press({ x: 0, y: 40 }, 1000) scroll.take(1012) // 刚回到按键那一刻、还没跳完:守住按键那一刻的位置 - expect(scroll.scrolled(1020, () => ({ x: 0, y: 300 }))).toEqual({ x: 0, y: 40 }) + expect(scroll.scrolled(1020, () => ({ x: 0, y: 300 }), false)).toEqual({ x: 0, y: 40 }) scroll.hold({ x: 0, y: 900 }) - expect(scroll.scrolled(1030, () => ({ x: 0, y: 900 }))).toBeUndefined() - expect(scroll.scrolled(1040, () => ({ x: 0, y: 1200 }))).toEqual({ x: 0, y: 900 }) - expect(scroll.scrolled(1000 + KEY_SCROLL_MS + 1, () => ({ x: 0, y: 1200 }))).toBeUndefined() - expect(scroll.scrolled(1000 + KEY_SCROLL_MS + 2, () => ({ x: 0, y: 1300 }))).toBeUndefined() + expect(scroll.scrolled(1030, () => ({ x: 0, y: 900 }), false)).toBeUndefined() + expect(scroll.scrolled(1040, () => ({ x: 0, y: 1200 }), false)).toEqual({ x: 0, y: 900 }) + expect(scroll.scrolled(1000 + KEY_SCROLL_MS + 1, () => ({ x: 0, y: 1200 }), false)).toBeUndefined() + expect(scroll.scrolled(1000 + KEY_SCROLL_MS + 2, () => ({ x: 0, y: 1300 }), false)).toBeUndefined() }) it('不是按键触发的跳转、又按了键、点了鼠标:不守', () => { const scroll = new KeyScroll() scroll.take(10) scroll.hold({ x: 0, y: 900 }) - expect(scroll.scrolled(20, () => ({ x: 0, y: 0 }))).toBeUndefined() + expect(scroll.scrolled(20, () => ({ x: 0, y: 0 }), false)).toBeUndefined() scroll.press({ x: 0, y: 40 }, 1000) scroll.take(1010) scroll.press({ x: 0, y: 40 }, 1100) - expect(scroll.scrolled(1110, () => ({ x: 0, y: 300 }))).toBeUndefined() + expect(scroll.scrolled(1110, () => ({ x: 0, y: 300 }), false)).toBeUndefined() scroll.take(1120) scroll.forget() - expect(scroll.scrolled(1130, () => ({ x: 0, y: 300 }))).toBeUndefined() + expect(scroll.scrolled(1130, () => ({ x: 0, y: 300 }), false)).toBeUndefined() }) it('不守着时滚动不读滚动位置(读它会逼浏览器先排版)', () => { const scroll = new KeyScroll() const read = vi.fn(() => ({ x: 0, y: 300 })) - expect(scroll.scrolled(10, read)).toBeUndefined() + expect(scroll.scrolled(10, read, false)).toBeUndefined() scroll.press({ x: 0, y: 40 }, 1000) - expect(scroll.scrolled(1005, read)).toBeUndefined() + expect(scroll.scrolled(1005, read, false)).toBeUndefined() expect(read).not.toHaveBeenCalled() scroll.take(1010) - expect(scroll.scrolled(1020, read)).toEqual({ x: 0, y: 40 }) + expect(scroll.scrolled(1020, read, false)).toEqual({ x: 0, y: 40 }) expect(read).toHaveBeenCalledTimes(1) }) - it('我们自己滚过的位置不算浏览器滚走:差 1 像素、滚进视口都不再拉一次,真被滚走仍拉回', () => { + it('我们自己滚的(滚动所有者认出来的)不算浏览器滚走,改守滚到的位置;之后真被滚走拉回到那里', () => { 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(1020, () => ({ x: 0, y: 39 }))).toBeUndefined() - expect(scroll.scrolled(1021, () => ({ x: 0, y: 39 }))).toBeUndefined() - expect(scroll.scrolled(1022, () => ({ x: 0, y: 800 }))).toBeUndefined() + // 滚进视口落到 800,跳完守它;页面长高时滚动所有者自己滚到 1320 + expect(scroll.scrolled(1020, () => ({ x: 0, y: 800 }), true)).toBeUndefined() scroll.hold({ x: 0, y: 800 }) - expect(scroll.scrolled(1030, () => ({ x: 0, y: 39 }))).toBeUndefined() - expect(scroll.scrolled(1040, () => ({ x: 0, y: 300 }))).toEqual({ x: 0, y: 800 }) + expect(scroll.scrolled(1030, () => ({ x: 0, y: 1320 }), true)).toBeUndefined() + expect(scroll.scrolled(1040, () => ({ x: 0, y: 300 }), false)).toEqual({ x: 0, y: 1320 }) }) it('默认快捷键:mac 是 ⌘↓ / ⌘↑,别的系统是 ctrl;带了别的修饰键不算', () => { diff --git a/vscode/webview-ui/src/codeReader/occurrences.ts b/vscode/webview-ui/src/codeReader/occurrences.ts index d564da3..dd72aab 100644 --- a/vscode/webview-ui/src/codeReader/occurrences.ts +++ b/vscode/webview-ui/src/codeReader/occurrences.ts @@ -188,38 +188,28 @@ export function occurrenceJumpKey( * 但用户改过快捷键时别的键仍会自己滚,而且可能早于或晚于命令。所以任何键: * 按键那一刻记下页面滚在哪(press);命令发来时先回到那里(take)再滚到目标;跳完守住跳到的位置(hold), * 按键后 KEY_SCROLL_MS 内浏览器自己滚走了就拉回来(scrolled)。 - * 我们自己滚到的位置用 own 记下(拉回时差的那一像素、滚进视口),对应的滚动事件不算浏览器滚走;事件可能晚于滚动调用才到。 + * 我们自己滚的(拉回、滚进视口、页面长高时滚回去)由[滚动所有者](vscode/webview-ui/src/codeReader/scrollOwner.ts#ScrollOwner)认出来(scrolled 的 `ours`),不算浏览器滚走。 * 按键后点了鼠标、滚了滚轮、又按了键,或跳转隔了 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 } @@ -230,17 +220,21 @@ export class KeyScroll { this.held = { ...this.held, position } } - /** 页面滚动了:还在守着、又被浏览器滚走了,给出要拉回的位置。我们自己滚到的位置不算。守着时才读滚动位置(read):读它会逼浏览器先排版,平常滚动不读。 */ - scrolled(now: number, read: () => ScrollPosition): ScrollPosition | undefined { + /** + * 页面滚动了:还在守着、又被浏览器滚走了,给出要拉回的位置。`ours` 为真是我们自己滚的,不拉,改守滚到的位置。 + * 守着时才读滚动位置(read):读它会逼浏览器先排版,平常滚动不读。 + */ + scrolled(now: number, read: () => ScrollPosition, ours: boolean): ScrollPosition | undefined { const held = this.held if (!held || now > held.until) { this.held = undefined - this.owned = [] return undefined } const position = read() - if (this.owned.some(item => item.x === position.x && item.y === position.y)) + if (ours) { + this.held = { ...held, position } return undefined + } return held.position.x === position.x && held.position.y === position.y ? undefined : held.position } } diff --git a/vscode/webview-ui/src/codeReader/pageLayout.test.ts b/vscode/webview-ui/src/codeReader/pageLayout.test.ts index 22c0cc3..181a2d8 100644 --- a/vscode/webview-ui/src/codeReader/pageLayout.test.ts +++ b/vscode/webview-ui/src/codeReader/pageLayout.test.ts @@ -2,9 +2,9 @@ import type { Placement } from './placement' import type { BlockBox } from './scrollPosition' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { PageLayout } from './pageLayout' -import { anchorAt, blockForLine } from './scrollPosition' +import { anchorAt, blockForLine, holdAt } from './scrollPosition' /** 量过几次位置 */ let reads = 0 @@ -66,7 +66,7 @@ describe('卡片位置量过就记着', () => { it('滚动位置、包含某一行的块:和把全部卡片量一遍算出来的一样', () => { const { root, cards } = page() const layout = new PageLayout() - layout.attach(root, null, () => PLACEMENT) + layout.attach(root, null, () => PLACEMENT, () => {}) for (const y of [0, 50, 100, 310, 950, 1200, 1999, 2500]) { const found = layout.anchorAt(y) expect(found && { line: found.line, offset: found.offset }, `y ${y}`).toEqual((({ line, offset }) => ({ line, offset }))(anchorAt(cards, y)!)) @@ -75,12 +75,14 @@ describe('卡片位置量过就记着', () => { expect(layout.cardForLine(line)?.line, `line ${line}`).toBe(blockForLine(cards, line)?.line) expect(layout.topFor({ line: 8, offset: 5, scrollTop: 9999 })).toBe(325) expect(layout.topFor({ line: 9, offset: 5, scrollTop: 9999 })).toBe(9999) + for (const y of [0, 50, 105, 310, 950, 1005, 1200, 1509, 1999, 2500]) + expect(layout.holdAt(y), `y ${y}`).toEqual(holdAt(cards, y)) }) it('钉住的卡片:只算和顶上那一段相交的顶层块,在编辑的不钉;外层卡片的下沿算到它的子层', () => { const { root } = page() const layout = new PageLayout() - layout.attach(root, null, () => PLACEMENT) + layout.attach(root, null, () => PLACEMENT, () => {}) window.scrollTo(0, 0) const scopes = layout.stickyScopes(48) expect(scopes.map(scope => [scope.id, scope.depth, scope.top, scope.bottom])).toEqual([['0', 0, 0, 900]]) @@ -89,7 +91,7 @@ describe('卡片位置量过就记着', () => { it('滚动时不再量:同一份内容问几次都只量一次;只量用到的那几段;内容变了才重量', async () => { const { root } = page() const layout = new PageLayout() - layout.attach(root, null, () => PLACEMENT) + layout.attach(root, null, () => PLACEMENT, () => {}) layout.anchorAt(0) // 三段顶层块的外框 + 第 0、1 段里的卡片和子层 const first = reads @@ -104,14 +106,66 @@ describe('卡片位置量过就记着', () => { expect(reads).toBe(first * 2) }) - it('要滚过去的:目标所在的顶层块连同上下几屏之内的先照常排版,过两帧放回去', async () => { + it('要滚过去的(settleLine):目标所在的顶层块连同上下几屏之内的先照常排版,过两帧放回去;只是量位置不动排版', async () => { const { root } = page() const layout = new PageLayout() - layout.attach(root, null, () => PLACEMENT) + layout.attach(root, null, () => PLACEMENT, () => {}) const items = [...root.querySelectorAll('.cr-item')] - expect(layout.cardForLine(40)?.line).toBe(40) + layout.cardForLine(40) + layout.topFor({ line: 40, offset: 0, scrollTop: 0 }) + expect(items.some(item => item.hasAttribute('data-settle'))).toBe(false) + layout.settleLine(40) expect(items.map(item => item.hasAttribute('data-settle'))).toEqual([true, true, true]) await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(resolve)))) expect(items.some(item => item.hasAttribute('data-settle'))).toBe(false) }) }) + +describe('大小变了告诉滚动所有者', () => { + /** 假的 ResizeObserver:记下看着哪些元素,测试里手动报 */ + class FakeResizeObserver { + static last: FakeResizeObserver | undefined + readonly targets = new Set() + constructor(readonly report: () => void) { + FakeResizeObserver.last = this + } + + observe(target: Element): void { + this.targets.add(target) + } + + unobserve(target: Element): void { + this.targets.delete(target) + } + + disconnect(): void { + this.targets.clear() + } + } + + afterEach(() => vi.unstubAllGlobals()) + + it('看着页面内容、筛选栏和每一段顶层块(加的、去掉的跟着换);报来时先作废记着的位置,再告诉它', async () => { + vi.stubGlobal('ResizeObserver', FakeResizeObserver) + const { root } = page() + const bar = document.createElement('div') + const layout = new PageLayout() + const resized = vi.fn(() => layout.anchorAt(0)) + layout.attach(root, bar, () => PLACEMENT, resized) + const sizes = FakeResizeObserver.last! + const column = root.querySelector('.cr-level > :last-child')! + expect(sizes.targets).toEqual(new Set([root, bar, ...column.children])) + + const added = place(div('cr-item', {}, card('3', 60, 2010, 2200)), 2010, 2200) + column.firstElementChild!.remove() + column.append(added) + await Promise.resolve() + expect(sizes.targets).toEqual(new Set([root, bar, ...column.children])) + + layout.anchorAt(0) + const before = reads + sizes.report() + expect(resized).toHaveBeenCalledTimes(1) + expect(reads).toBeGreaterThan(before) + }) +}) diff --git a/vscode/webview-ui/src/codeReader/pageLayout.ts b/vscode/webview-ui/src/codeReader/pageLayout.ts index cfeeda1..d81196a 100644 --- a/vscode/webview-ui/src/codeReader/pageLayout.ts +++ b/vscode/webview-ui/src/codeReader/pageLayout.ts @@ -2,7 +2,7 @@ import type { Placement } from './placement' import type { BlockBox, ScrollAnchor } from './scrollPosition' import type { StickyScope } from './stickyScroll' import { NO_PLACEMENT } from './placement' -import { anchorAt, blockForLine, scrollTopFor } from './scrollPosition' +import { anchorAt, blockForLine, holdAt, scrollTopFor } from './scrollPosition' /** 一张卡片量出来的位置(文档坐标):BlockBox 之外,块 id、外面套着几张卡片、连同画出来的子块的下沿、左右边,在不在编辑。 */ interface CardBox extends BlockBox { @@ -96,19 +96,46 @@ export class PageLayout { private settling: HTMLElement[] = [] private release = 0 - /** 量 root(块树所在的页面内容)里的卡片,筛选栏是 bar,各块在文件里的位置从 placement 取;返回停止观察的函数 */ - attach(root: HTMLElement, bar: HTMLElement | null, placement: () => Placement): () => void { + /** + * 量 root(块树所在的页面内容)里的卡片,筛选栏是 bar,各块在文件里的位置从 placement 取;返回停止观察的函数。 + * `root`、筛选栏、各段顶层块的大小一变(浏览器绘制之前),先作废记着的位置再调 `resized`:[滚动所有者](vscode/webview-ui/src/codeReader/scrollOwner.ts#ScrollOwner)在那里照意图滚回去,画面上不会先跳一下。 + */ + attach(root: HTMLElement, bar: HTMLElement | null, placement: () => Placement, resized: () => void): () => void { this.root = root this.bar = bar this.placement = placement this.invalidate() const invalidate = (): void => this.invalidate() - const mutations = new MutationObserver(invalidate) - mutations.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-editing', 'data-id'] }) - const sizes = new ResizeObserver(invalidate) + const sizes = new ResizeObserver(() => { + this.invalidate() + resized() + }) sizes.observe(root) if (bar) sizes.observe(bar) + // 顶层块一段段地看:一段长高、另一段同时变矮时 root 的大小不变 + const watched = new Set() + const watchItems = (): void => { + const items = new Set(root.querySelectorAll(':scope > .cr-level > :last-child > .cr-item')) + for (const el of watched) { + if (!items.has(el)) { + sizes.unobserve(el) + watched.delete(el) + } + } + for (const el of items) { + if (!watched.has(el)) { + sizes.observe(el) + watched.add(el) + } + } + } + watchItems() + const mutations = new MutationObserver(() => { + this.invalidate() + watchItems() + }) + mutations.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-editing', 'data-id'] }) window.addEventListener('resize', invalidate) return () => { mutations.disconnect() @@ -174,18 +201,25 @@ export class PageLayout { return anchorAt(items.slice(index, index + 2).flatMap(item => this.cardsOf(item)), scrollTop) } - /** 包含这一行的块(同 blockForLine),要滚过去的:顶层块按起始行排,先找到它所在的那段,把那一带先排版好(见 settle)再量。 */ + /** 包含这一行的块(同 [blockForLine]):顶层块按起始行排,只量它所在的那段。 */ cardForLine(line: number): BlockBox | undefined { - const items = this.settledAt(line) + const items = this.itemBoxes() return items.length === 0 ? undefined : blockForLine(this.cardsOf(items[itemOfLine(items, line)]), line) } - /** 恢复时视口顶部要在的位置(同 scrollTopFor):起始行是 anchor.line 的块只会在它所在的那段里,那一带先排版好(见 settle)再量。 */ + /** 视口顶部眼下该在的位置(同 [scrollTopFor]):起始行是 `anchor.line` 的块只会在它所在的那段里,只量那段。 */ topFor(anchor: ScrollAnchor): number { - const items = this.settledAt(anchor.line) + const items = this.itemBoxes() return items.length === 0 ? anchor.scrollTop : scrollTopFor(this.cardsOf(items[itemOfLine(items, anchor.line)]), anchor) } + /** 页面变了也要钉住的那块(同 [holdAt]):只量上沿在 `scrollTop` 或更高的最后一段和它前一段(`scrollTop` 落在两段之间的空隙里时是前一段的块)。 */ + holdAt(scrollTop: number): ScrollAnchor | undefined { + const items = this.itemBoxes() + const index = firstWhere(items, item => item.top > scrollTop) + return index === 0 ? undefined : holdAt(items.slice(Math.max(0, index - 2), index).flatMap(item => this.cardsOf(item)), scrollTop) + } + /** * 要滚到 el 那里之前调:它所在的顶层块连同上面一屏半、下面两屏半之内的顶层块先照常排版,不按估的高度占位(.cr-item[data-settle])。 * 不然滚过去以后浏览器才画出视口附近没画过的块(content-visibility),它们换成真实高度会把目标顶走;先画出来,量出来的位置滚过去就不再变。 @@ -198,12 +232,11 @@ export class PageLayout { this.settleAround(index) } - /** line 所在的那段顶层块那一带先排版好(见 settle),交回排好以后重量的顶层块 */ - private settledAt(line: number): ItemBox[] { + /** 要滚到 `line` 那块之前调(同 [settle]):它所在的那段顶层块那一带先照常排版。 */ + settleLine(line: number): void { const items = this.itemBoxes() if (items.length > 0) this.settleAround(itemOfLine(items, line)) - return this.itemBoxes() } private settleAround(index: number): void { diff --git a/vscode/webview-ui/src/codeReader/pageScroll.test.tsx b/vscode/webview-ui/src/codeReader/pageScroll.test.tsx new file mode 100644 index 0000000..9675ee7 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/pageScroll.test.tsx @@ -0,0 +1,296 @@ +// @vitest-environment happy-dom + +import type { Root } from 'react-dom/client' +import type { Memento } from 'vscode' +import type { ExtensionToWebview, WebviewToExtension } from './types' +import type { ViewState, ViewStateStore } from './viewState' +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { documentMessage, pageDocument } from '../../../src/codeReader/editor/documentMessage' +import { createPythonAdapter } from '../../../src/codeReader/languages/python' +import { CodeReaderRegistry } from '../../../src/codeReader/registry' +import { runtime, WASM_DIR } from '../../../src/codeReader/testSupport' +import { pythonZhTranslator } from '../../../src/codeReader/translators/pythonZh' +import { App } from './App' +import { createViewStateStore } from './viewState' + +/** + * 跳到定义以后页面还在长高:目标上面的 ```mermaid 占位换成图、高出一截,目标仍停在跳过去时视口里的那个位置。 + * happy-dom 不排版,位置按页面先后往下摞(见 box);ResizeObserver 换成假的,布局变了由测试手动报。 + */ + +const host = vi.hoisted(() => ({ + posted: [] as WebviewToExtension[], + listeners: new Set<(msg: unknown) => void>(), + store: undefined as ViewStateStore | undefined, +})) + +vi.mock('./vscode', () => ({ + postMessage: (msg: WebviewToExtension) => host.posted.push(msg), + onMessage: (handler: (msg: unknown) => void) => { + host.listeners.add(handler) + return () => host.listeners.delete(handler) + }, + viewState: { + get: () => host.store!.get(), + update: (patch: Partial) => host.store!.update(patch), + }, +})) + +// 页面上的 mermaid 占位由测试换成图,真的 mermaid 一直画不完 +vi.mock('mermaid', () => ({ default: { initialize: () => {}, render: () => new Promise(() => {}) } })) + +const SOURCE = [ + 'class SessionMgr:', + ' def set_title(self, sid, title):', + ' """改标题。"""', + ' return sid', + '', + ' def create(self, user):', + ' """建一个会话。', + '', + ' ```mermaid', + ' graph TD', + ' A --> B', + ' ```', + ' """', + ' return user', + '', + ...[1, 2, 3, 4, 5, 6].flatMap(n => [` def step${n}(self):`, ` return ${n}`, '']), + ' def create_for_user(self, user):', + ' return self.create(user)', + '', +].join('\n') + +const registry = new CodeReaderRegistry({ get: () => undefined, update: async () => {} } as Pick) +registry.registerLanguage(createPythonAdapter(WASM_DIR)) +registry.registerTranslator(pythonZhTranslator) + +/** 扩展端发给新开的预览的第一份文档:带着要跳到的定义 */ +async function documentRevealing(source: string, needle: string): Promise { + const { message } = await documentMessage({ languageId: 'python', version: 1, getText: () => source, fileName: 'service.py' }, registry, runtime, () => {}) + const offset = source.indexOf(needle) + const line = source.slice(0, offset).split('\n').length - 1 + return { ...pageDocument(message), names: [], reveal: { line, definition: offset } } as ExtensionToWebview +} + +/** 一张卡片多高;里面的 mermaid 占位另算:还是代码块时 280px,换成图以后 800px */ +const CARD = 100 +const NAME = 20 + +function mermaidHeight(el: Element): number { + return el.querySelector('svg') ? 800 : 280 +} + +function cardHeight(card: Element): number { + return CARD + [...card.querySelectorAll('.cr-mermaid')].reduce((sum, el) => sum + mermaidHeight(el), 0) +} + +/** node 在 other 后面(不在它里面也不包着它) */ +function after(node: Element, other: Element): boolean { + return other !== node && !other.contains(node) && !node.contains(other) && (other.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0 +} + +/** 页面坐标里的上下边:卡片按页面先后往下摞;卡片里的元素从卡片顶往下、让开它前面的 mermaid;包着卡片的从第一张到最后一张 */ +function box(el: Element): { top: number, bottom: number } { + const cards = [...document.querySelectorAll('.cr-block')] + const above = (node: Element): number => cards.filter(card => after(node, card)).reduce((sum, card) => sum + cardHeight(card), 0) + const card = el.closest('.cr-block') + if (card) { + const top = above(card) + if (el === card) + return { top, bottom: top + cardHeight(card) } + const inner = top + [...card.querySelectorAll('.cr-mermaid')].filter(other => after(el, other)).reduce((sum, other) => sum + mermaidHeight(other), 0) + return { top: inner, bottom: inner + (el.matches('.cr-mermaid') ? mermaidHeight(el) : NAME) } + } + const inside = cards.filter(other => el.contains(other)) + if (inside.length === 0) + return { top: above(el), bottom: above(el) } + const last = inside.at(-1)! + return { top: above(inside[0]), bottom: above(last) + cardHeight(last) } +} + +/** 假的 ResizeObserver:记下各自看着哪些元素,测试里 resizeAll 时一起报 */ +class FakeResizeObserver { + static all = new Set() + private readonly targets = new Set() + constructor(private readonly callback: ResizeObserverCallback) { + FakeResizeObserver.all.add(this) + } + + observe(target: Element): void { + this.targets.add(target) + } + + unobserve(target: Element): void { + this.targets.delete(target) + } + + disconnect(): void { + this.targets.clear() + FakeResizeObserver.all.delete(this) + } + + report(): void { + const entries = [...this.targets].map((target) => { + const { top, bottom } = box(target) + const size = [{ blockSize: bottom - top, inlineSize: 800 }] + return { target, borderBoxSize: size, contentBoxSize: size, devicePixelContentBoxSize: size, contentRect: target.getBoundingClientRect() } + }) as unknown as ResizeObserverEntry[] + if (entries.length > 0) + this.callback(entries, this as unknown as ResizeObserver) + } +} + +/** 布局变了:各个 ResizeObserver 报一次 */ +async function resizeAll(): Promise { + await act(async () => { + for (const observer of [...FakeResizeObserver.all]) + observer.report() + }) +} + +/** 页面滚在哪;window.scrollTo 改它,滚动事件像浏览器那样稍后才到 */ +const scroll = { y: 0, pending: false } + +function fakeScrollTo(a?: number | ScrollToOptions, b?: number): void { + const top = typeof a === 'object' ? a.top : b + if (top === undefined || top === scroll.y) + return + scroll.y = Math.max(0, top) + if (scroll.pending) + return + scroll.pending = true + setTimeout(() => { + scroll.pending = false + window.dispatchEvent(new Event('scroll')) + }) +} + +let container: HTMLDivElement +let root: Root +let saved: ViewState | undefined + +async function load(): Promise { + host.store = createViewStateStore({ + getState: () => saved, + setState: (state) => { + saved = JSON.parse(JSON.stringify(state)) as ViewState + }, + }) + host.posted.length = 0 + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) + await act(async () => root.render()) +} + +async function deliver(msg: ExtensionToWebview): Promise { + await act(async () => { + for (const listener of host.listeners) + listener(msg) + }) +} + +/** 等计时器、动画帧跑完 */ +async function settle(ms = 30): Promise { + await act(async () => { + await new Promise(resolve => setTimeout(resolve, ms)) + }) +} + +/** 页面上(不算钉住的那几行)定义处显示着 text 的那个名字 */ +function nameOf(text: string): HTMLElement { + return [...container.querySelectorAll('.cr-block [data-offset]')].find(el => el.textContent === text)! +} + +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + vi.stubGlobal('ResizeObserver', FakeResizeObserver) + saved = undefined + scroll.y = 0 + scroll.pending = false + Object.defineProperty(window, 'scrollY', { configurable: true, get: () => scroll.y }) + vi.spyOn(window, 'scrollTo').mockImplementation(fakeScrollTo as typeof window.scrollTo) + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) { + const { top, bottom } = box(this) + return { top: top - scroll.y, bottom: bottom - scroll.y, left: 0, right: 800, width: 800, height: bottom - top, x: 0, y: top - scroll.y, toJSON: () => ({}) } as DOMRect + }) + // happy-dom 没有 Web Animations:闪的那一处没有动画可重播 + if (!Element.prototype.getAnimations) + Element.prototype.getAnimations = () => [] +}) + +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + host.listeners.clear() + FakeResizeObserver.all.clear() + Reflect.deleteProperty(window, 'scrollY') + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('跳过去以后页面还在长高,目标跟着不动', () => { + it('新开的预览跳到方法定义:上面一个方法注释里的 mermaid 画成图、高出一截,目标仍在跳过去时视口里的位置', async () => { + await load() + await deliver(await documentRevealing(SOURCE, 'create_for_user')) + await settle() + const target = nameOf('create_for_user') + const before = target.getBoundingClientRect().top + expect(window.scrollY).toBeGreaterThan(0) + expect(before).toBeGreaterThanOrEqual(0) + expect(before).toBeLessThan(window.innerHeight) + + const placeholder = container.querySelector('.cr-mermaid')! + expect(box(placeholder).bottom).toBeLessThan(box(target).top) + await act(async () => { + placeholder.innerHTML = '' + }) + await resizeAll() + await settle() + + expect(nameOf('create_for_user').getBoundingClientRect().top).toBe(before) + }) + + it('跳过去以后用户自己滚了:不再把目标拉回原处,改钉视口顶部那块,上面长高时它照旧停在原处', async () => { + await load() + await deliver(await documentRevealing(SOURCE, 'create_for_user')) + await settle() + // 用户滚到 step2 那张卡片中间:滚动事件到的时候页面不在页面自己滚到的位置 + const step2 = (): HTMLElement => nameOf('step2').closest('.cr-block')! + scroll.y = box(step2()).top + 20 + await act(async () => { + window.dispatchEvent(new Event('scroll')) + }) + const before = step2().getBoundingClientRect().top + + await act(async () => { + container.querySelector('.cr-mermaid')!.innerHTML = '' + }) + await resizeAll() + await settle() + + expect(step2().getBoundingClientRect().top).toBe(before) + }) + + it('重新加载后恢复到记下的块:之后上面长高,那块照旧停在原处', async () => { + const step2 = SOURCE.split('\n').findIndex(line => line.includes('def step2')) + saved = { editing: [], folds: { 0: false }, scroll: { line: step2, offset: 20, scrollTop: 700 } } + await load() + const { reveal: _, ...plain } = await documentRevealing(SOURCE, 'create_for_user') as Extract + await deliver(plain) + await settle() + const card = (): HTMLElement => nameOf('step2').closest('.cr-block')! + expect(card().getBoundingClientRect().top).toBe(-20) + + await act(async () => { + container.querySelector('.cr-mermaid')!.innerHTML = '' + }) + await resizeAll() + await settle() + + expect(card().getBoundingClientRect().top).toBe(-20) + }) +}) diff --git a/vscode/webview-ui/src/codeReader/scrollOwner.test.ts b/vscode/webview-ui/src/codeReader/scrollOwner.test.ts new file mode 100644 index 0000000..ba046e6 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/scrollOwner.test.ts @@ -0,0 +1,230 @@ +import type { ScrollPosition } from './occurrences' +import type { ScrollPage } from './scrollOwner' +import type { BlockBox } from './scrollPosition' +import { describe, expect, it } from 'vitest' +import { ScrollOwner } from './scrollOwner' +import { holdAt, scrollTopFor } from './scrollPosition' + +/** + * 假页面:块按页面坐标摆着,筛选栏高 40;scrollTo 只改位置(夹在 0 和最远能滚到的地方之间),滚动事件由测试自己发。 + * 第 3 块注释里的图画出来、页面长高时,从它往下的块都往下挪。 + */ +function fakePage(): { + page: ScrollPage + state: { y: number, pinned: number, settled: number[], scrolls: number[] } + boxes: BlockBox[] + grow: (line: number, by: number) => void + topOf: (line: number) => number +} { + const boxes: BlockBox[] = [0, 10, 20, 30, 40, 50, 60].map((line, index) => ({ line, top: index * 300, bottom: index * 300 + 280 })) + const state = { y: 0, pinned: 40, settled: [] as number[], scrolls: [] as number[] } + const page: ScrollPage = { + position: () => ({ x: 0, y: state.y }), + scrollTo: ({ y }: ScrollPosition) => { + state.y = Math.min(Math.max(0, y), 5000) + state.scrolls.push(state.y) + return { x: 0, y: state.y } + }, + pinnedHeight: () => state.pinned, + holdAt: top => holdAt(boxes, top), + topFor: anchor => scrollTopFor(boxes, anchor), + settleLine: line => state.settled.push(line), + } + const grow = (line: number, by: number): void => { + for (const box of boxes) { + if (box.line > line) { + box.top += by + box.bottom += by + } + else if (box.line === line) { + box.bottom += by + } + } + } + /** 起始行是 line 的块在视口里的上沿 */ + const topOf = (line: number): number => boxes.find(box => box.line === line)!.top - state.y + return { page, state, boxes, grow, topOf } +} + +/** 起始行是 line 的块:每次现找它在页面里的位置 */ +function blockAt(boxes: BlockBox[], y: () => number, line: number): () => { getBoundingClientRect: () => { top: number, bottom: number } } { + return () => ({ + getBoundingClientRect: () => { + const box = boxes.find(item => item.line === line)! + return { top: box.top - y(), bottom: box.bottom - y() } + }, + }) +} + +const SCROLL = new Event('scroll') + +describe('跳到一处以后,页面长高变矮都跟着它', () => { + it('上面长高:滚回去,目标停在跳过去时筛选栏下面的那个位置;自己滚出来的滚动事件不算用户滚动,接着跟', () => { + const { page, state, boxes, grow, topOf } = fakePage() + const owner = new ScrollOwner(page) + owner.reveal(blockAt(boxes, () => state.y, 50), box => box.top - 100) + expect(state.y).toBe(1400) + expect(topOf(50)).toBe(100) + + grow(20, 520) + owner.relayout() + expect(topOf(50)).toBe(100) + owner.scrolled(SCROLL) + + grow(20, 30) + owner.relayout() + expect(topOf(50)).toBe(100) + }) + + it('目标已经看得见、不用滚:照样跟住它眼下的位置', () => { + const { page, state, boxes, grow, topOf } = fakePage() + const owner = new ScrollOwner(page) + state.y = 100 + owner.reveal(blockAt(boxes, () => state.y, 10), () => undefined) + expect(state.scrolls).toEqual([]) + grow(0, 50) + owner.relayout() + expect(topOf(10)).toBe(200) + }) + + it('筛选栏变高:目标照旧在筛选栏下沿往下那么远,不滚到栏后面去', () => { + const { page, state, boxes, topOf } = fakePage() + const owner = new ScrollOwner(page) + owner.reveal(blockAt(boxes, () => state.y, 50), box => box.top - 100) + // 筛选栏在页面里占着位置,它变高,下面的内容跟着往下挪同样多 + state.pinned += 30 + for (const box of boxes) { + box.top += 30 + box.bottom += 30 + } + owner.relayout() + expect(topOf(50)).toBe(130) + }) + + it('布局没变:不滚', () => { + const { page, state, boxes } = fakePage() + const owner = new ScrollOwner(page) + owner.reveal(blockAt(boxes, () => state.y, 50), box => box.top - 100) + owner.relayout() + expect(state.scrolls).toEqual([1400]) + }) + + it('目标不见了(收起来、开始编辑):改钉住视口顶部那块', () => { + const { page, state, boxes, grow, topOf } = fakePage() + const owner = new ScrollOwner(page) + let shown = true + const target = blockAt(boxes, () => state.y, 50) + owner.reveal(() => shown ? target() : null, box => box.top - 100) + shown = false + owner.relayout() + const before = topOf(40) + grow(20, 200) + owner.relayout() + expect(topOf(40)).toBe(before) + }) +}) + +describe('用户自己滚了:不再跟目标,改钉住视口顶部那块', () => { + it('滚动事件到的时候页面不在自己滚到的位置:钉住筛选栏下沿那块;之后上面长高跟着那块走,不再把目标拉回原位', () => { + const { page, state, boxes, grow, topOf } = fakePage() + const owner = new ScrollOwner(page) + owner.reveal(blockAt(boxes, () => state.y, 50), box => box.top - 100) + state.y = 1000 + owner.scrolled(SCROLL) + // 视口顶部(1040)落在第 4 块(900 ~ 1180)里 + const before = topOf(30) + + grow(20, 520) + owner.relayout() + expect(topOf(30)).toBe(before) + expect(topOf(50)).toBe(before + 600) + }) + + it('滚轮、按键、按下鼠标这些:先按眼下的布局把目标对好,再改钉视口顶部那块', () => { + const { page, state, boxes, grow, topOf } = fakePage() + const owner = new ScrollOwner(page) + owner.reveal(blockAt(boxes, () => state.y, 50), box => box.top - 100) + // 页面已经长高、还没来得及跟(ResizeObserver 还没报),用户就动了手 + grow(20, 520) + owner.letGo() + expect(topOf(50)).toBe(100) + const before = topOf(40) + grow(40, 300) + owner.relayout() + expect(topOf(40)).toBe(before) + }) + + it('滚到所有块上面:什么都不钉,上面长高也不滚', () => { + const { page, state, boxes, grow } = fakePage() + boxes.forEach((box) => { + box.top += 200 + box.bottom += 200 + }) + const owner = new ScrollOwner(page) + owner.reveal(blockAt(boxes, () => state.y, 50), box => box.top - 100) + state.y = 0 + owner.scrolled(SCROLL) + grow(0, 100) + owner.relayout() + expect(state.y).toBe(0) + }) + + it('订阅的人知道是谁滚的:自己滚到的位置是自己的,别的位置是用户的', () => { + const { page, state, boxes } = fakePage() + const owner = new ScrollOwner(page) + const heard: boolean[] = [] + const off = owner.onScroll(ours => heard.push(ours)) + owner.reveal(blockAt(boxes, () => state.y, 50), box => box.top - 100) + owner.scrolled(SCROLL) + state.y = 300 + owner.scrolled(SCROLL) + off() + owner.scrolled(SCROLL) + expect(heard).toEqual([true, false]) + }) + + it('页面换没换过地方:跳完自己滚回去、用户点按钮(动手前改钉视口顶部那块)都还是同一个数,用户滚了就变', () => { + const { page, state, boxes, grow } = fakePage() + const owner = new ScrollOwner(page) + owner.reveal(blockAt(boxes, () => state.y, 50), box => box.top - 100) + const jumped = owner.turn + owner.scrolled(SCROLL) + grow(20, 100) + owner.relayout() + owner.scrolled(SCROLL) + owner.letGo() + expect(owner.turn).toBe(jumped) + state.y = 900 + owner.scrolled(SCROLL) + expect(owner.turn).not.toBe(jumped) + }) +}) + +describe('恢复位置、拉回原处', () => { + it('恢复记下的块锚点:先把那一带排版好再滚过去,之后跟着那块', () => { + const { page, state, grow, topOf } = fakePage() + const owner = new ScrollOwner(page) + owner.align({ line: 40, offset: 30, scrollTop: 0 }) + expect(state.settled).toEqual([40]) + expect(topOf(40)).toBe(10) + grow(10, 250) + owner.relayout() + expect(topOf(40)).toBe(10) + }) + + it('拉回原处:先差一个像素再回来(打断浏览器正在播的滚动),之后钉住那里;这两下的滚动事件不算用户滚动', () => { + const { page, state, grow, topOf } = fakePage() + const owner = new ScrollOwner(page) + const heard: boolean[] = [] + owner.onScroll(ours => heard.push(ours)) + state.y = 2000 + owner.pullBack({ x: 0, y: 700 }) + expect(state.scrolls).toEqual([699, 700]) + owner.scrolled(SCROLL) + expect(heard).toEqual([true]) + const before = topOf(20) + grow(10, 90) + owner.relayout() + expect(topOf(20)).toBe(before) + }) +}) diff --git a/vscode/webview-ui/src/codeReader/scrollOwner.ts b/vscode/webview-ui/src/codeReader/scrollOwner.ts new file mode 100644 index 0000000..13bab74 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/scrollOwner.ts @@ -0,0 +1,210 @@ +import type { ScrollPosition } from './occurrences' +import type { ScrollAnchor } from './scrollPosition' +import { pageLayout } from './pageLayout' + +/** 量得出位置的跳转目标:一个元素,或查找栏的一处命中那样的一段字。 */ +export interface ScrollTarget { + getBoundingClientRect: () => { top: number, bottom: number } +} + +/** 找跳转目标:每次现找(页面重画以后元素可能换了一个),找不到时是 `null`。 */ +export type LocateTarget = () => ScrollTarget | null + +/** 滚动所有者用到的页面:滚动位置和按块量的位置。真页面见 [scrollOwner],测试里换成假的。 */ +export interface ScrollPage { + /** 页面眼下滚在哪 */ + position: () => ScrollPosition + /** 立刻滚到这里(不播动画),交回实际落到的位置:超出能滚的范围时浏览器会夹住 */ + scrollTo: (position: ScrollPosition) => ScrollPosition + /** 顶部钉住的筛选栏多高:视口里露出块的部分从它下沿开始 */ + pinnedHeight: () => number + /** 页面变了也要钉住不动的那块(见 [holdAt](vscode/webview-ui/src/codeReader/scrollPosition.ts#holdAt));`top` 是筛选栏下沿在页面里的位置 */ + holdAt: (top: number) => ScrollAnchor | undefined + /** 照块锚点算筛选栏下沿眼下该对着页面里的哪 */ + topFor: (anchor: ScrollAnchor) => number + /** 要滚到 `line` 那块之前,先把它那一带照常排版好 */ + settleLine: (line: number) => void +} + +/** 跟住一个目标:它的上沿停在筛选栏下沿往下 `offset` 像素 */ +interface Follow { + locate: LocateTarget + offset: number +} + +/** 筛选栏下沿钉在一块的某个位置 */ +interface Hold { + anchor: ScrollAnchor +} + +type Intent = Follow | Hold + +/** 页面滚动了;`ours` 为真表示是滚动所有者自己滚的,不是用户或浏览器滚的 */ +export type ScrollListener = (ours: boolean, event: Event) => void + +/** 两处位置差不到一个像素 */ +function near(a: ScrollPosition, b: ScrollPosition): boolean { + return Math.abs(a.x - b.x) < 1 && Math.abs(a.y - b.y) < 1 +} + +/** + * 页面唯一的滚动所有者:跳转、恢复位置都把想停在哪交给它,页面内容长高变矮时它照着重新量、重新滚,自己做浏览器的滚动锚定(页面上关掉了浏览器的)。 + * + * ```mermaid + * stateDiagram-v2 + * [*] --> 不管 + * 不管 --> 跟住目标: reveal + * 钉住块 --> 跟住目标: reveal + * 不管 --> 钉住块: align、拉回、用户滚动 + * 跟住目标 --> 钉住块: align、拉回、用户滚动、用户动手、目标不见了 + * 钉住块 --> 钉住块: align、拉回、用户滚动 + * 跟住目标 --> 不管: 改钉时视口顶部在所有块上面 + * 钉住块 --> 不管: 改钉时视口顶部在所有块上面 + * ``` + * + * 用户滚动是滚动事件到的时候页面不在自己最后滚到的位置:滚轮、键盘、触摸、拖滚动条,浏览器为了露出焦点、跟着光标滚的都算。 + * 用户动手是按下鼠标、按键、滚轮、触摸,在它们可能带来的滚动、点开收起之前。 + */ +export class ScrollOwner { + private intent: Intent | undefined + /** 自己最后滚到的位置:滚动事件到的时候页面还在这里,就是自己滚的 */ + private own: ScrollPosition | undefined + private turns = 0 + private readonly listeners = new Set() + + constructor(private readonly page: ScrollPage) {} + + /** + * 页面换过几次地方:跳过去、恢复位置、拉回、用户滚动各算一次,页面长高时自己滚回去、用户动手前改钉视口顶部那块不算。 + * 跳完记下,之后还是这个数就是用户没滚过、也没跳到别处。 + */ + get turn(): number { + return this.turns + } + + /** + * 把 `locate` 找到的目标滚到 `scrollYFor` 给的位置,之后跟住它,直到用户自己动手或换了别的意图。 + * + * @param locate 每次现找目标;眼下找不到就什么都不做 + * @param scrollYFor 按目标在页面里的上下边给出要滚到的 scrollY;`undefined` 表示不用滚,就停在眼下这里跟住 + */ + reveal(locate: LocateTarget, scrollYFor: (box: { top: number, bottom: number }) => number | undefined): void { + const target = locate() + if (!target) + return + const { y } = this.page.position() + const rect = target.getBoundingClientRect() + const wanted = scrollYFor({ top: rect.top + y, bottom: rect.bottom + y }) + const landed = wanted === undefined ? y : this.scrollTo(wanted) + this.moved({ locate, offset: rect.top + y - landed - this.page.pinnedHeight() }) + } + + /** 让筛选栏下沿对着块锚点那里(先把那一带排版好再量),之后钉住那块。 */ + align(anchor: ScrollAnchor): void { + this.page.settleLine(anchor.line) + this.scrollTo(this.page.topFor(anchor) - this.page.pinnedHeight()) + this.moved({ anchor }) + } + + /** + * 把页面拉回 `position`,顺带打断浏览器正在播(或刚排上)的滚动动画,之后钉住那里的块。 + * 滚到原地浏览器当没滚、不打断动画,所以先差一个像素再回来:同一段脚本里连着滚两下,中间不画,看不出来。 + */ + pullBack(position: ScrollPosition): void { + this.page.scrollTo({ x: position.x, y: position.y > 0 ? position.y - 1 : position.y + 1 }) + this.own = this.page.scrollTo(position) + this.moved(this.holdHere()) + } + + /** 页面大小变了(ResizeObserver 报的,在浏览器绘制之前):照意图重新量,位置差了一个像素以上就滚回去。目标不见了改钉视口顶部那块。 */ + relayout(): void { + const intent = this.intent + if (!intent) + return + const { y } = this.page.position() + let top: number + if ('anchor' in intent) { + top = this.page.topFor(intent.anchor) - this.page.pinnedHeight() + } + else { + const target = intent.locate() + if (!target) { + this.intent = this.holdHere() + return + } + top = y + target.getBoundingClientRect().top - this.page.pinnedHeight() - intent.offset + } + if (Math.abs(top - y) >= 1) + this.scrollTo(top) + } + + /** 用户要动手了(滚轮、触摸、按下鼠标、按键):正跟着的目标先按眼下的布局对好,再改钉视口顶部那块,之后点开收起、滚动都以那块为准。 */ + letGo(): void { + if (!this.intent || 'anchor' in this.intent) + return + this.relayout() + this.intent = this.holdHere() + } + + /** 页面滚动事件:不是自己滚到的位置就算用户滚的,改钉住视口顶部那块;再告诉订阅的人。 */ + scrolled(event: Event): void { + const ours = this.own !== undefined && near(this.page.position(), this.own) + if (!ours) { + this.own = undefined + this.moved(this.holdHere()) + } + for (const listener of this.listeners) + listener(ours, event) + } + + /** 订阅页面滚动(见 [ScrollListener]),返回退订的函数 */ + onScroll(listener: ScrollListener): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + /** 听页面的滚动和用户动手的事件;返回停止听的函数 */ + attach(): () => void { + const onScroll = (event: Event): void => this.scrolled(event) + const onInput = (): void => this.letGo() + const inputs = ['wheel', 'touchstart', 'pointerdown', 'keydown'] as const + window.addEventListener('scroll', onScroll, { passive: true }) + for (const type of inputs) + window.addEventListener(type, onInput, { capture: true, passive: true }) + return () => { + window.removeEventListener('scroll', onScroll) + for (const type of inputs) + window.removeEventListener(type, onInput, { capture: true }) + } + } + + /** 页面换了地方(跳过去、恢复位置、拉回、用户滚动),之后照 `intent` 停 */ + private moved(intent: Intent | undefined): void { + this.intent = intent + this.turns++ + } + + /** 钉住视口顶部(筛选栏下沿)眼下那块;它在所有块上面时不钉 */ + private holdHere(): Hold | undefined { + const anchor = this.page.holdAt(this.page.position().y + this.page.pinnedHeight()) + return anchor && { anchor } + } + + private scrollTo(y: number): number { + this.own = this.page.scrollTo({ x: this.page.position().x, y }) + return this.own.y + } +} + +/** 预览页只有一个滚动所有者:量的是 [pageLayout],滚的是整个窗口。 */ +export const scrollOwner = new ScrollOwner({ + position: () => ({ x: window.scrollX, y: window.scrollY }), + scrollTo: ({ x, y }) => { + window.scrollTo({ left: x, top: y, behavior: 'instant' }) + return { x: window.scrollX, y: window.scrollY } + }, + pinnedHeight: () => pageLayout.pinnedHeight(), + holdAt: top => pageLayout.holdAt(top), + topFor: anchor => pageLayout.topFor(anchor), + settleLine: line => pageLayout.settleLine(line), +}) diff --git a/vscode/webview-ui/src/codeReader/scrollPosition.test.ts b/vscode/webview-ui/src/codeReader/scrollPosition.test.ts index 5b69f91..9b70e02 100644 --- a/vscode/webview-ui/src/codeReader/scrollPosition.test.ts +++ b/vscode/webview-ui/src/codeReader/scrollPosition.test.ts @@ -1,6 +1,6 @@ import type { BlockBox } from './scrollPosition' import { describe, expect, it } from 'vitest' -import { anchorAt, blockForLine, scrollTopFor } from './scrollPosition' +import { anchorAt, blockForLine, holdAt, scrollTopFor } from './scrollPosition' // 页面上的块:顶部有 20px 工具栏,块与块之间 8px 间距 const boxes: BlockBox[] = [ @@ -32,6 +32,27 @@ describe('anchorAt:视口顶部那块', () => { }) }) +describe('holdAt:页面变了也钉住不动的那块', () => { + it('视口顶部落在块中间:钉这块', () => { + expect(holdAt(boxes, 150)).toEqual({ line: 4, offset: 34, scrollTop: 150 }) + }) + + it('视口顶部落在块间空隙(或上面那块的问答区)里:钉上面那块,下面那块长高不牵动视口', () => { + expect(holdAt(boxes, 110)).toEqual({ line: 3, offset: 42, scrollTop: 110 }) + }) + + it('还在所有块上面:不钉', () => { + expect(holdAt(boxes, 0)).toBeUndefined() + }) + + it('起始行相同的外层块和第一个子块:钉靠前的那块,按起始行找回来还是原处', () => { + const nested = [{ line: 0, top: 20, bottom: 60 }, { line: 4, top: 68, bottom: 100 }, { line: 4, top: 108, bottom: 200 }] + const anchor = holdAt(nested, 150)! + expect(anchor).toEqual({ line: 4, offset: 82, scrollTop: 150 }) + expect(scrollTopFor(nested, anchor)).toBe(150) + }) +}) + describe('scrollTopFor:按记下的位置恢复', () => { it('内容没变:回到原来的 scrollTop', () => { expect(scrollTopFor(boxes, anchorAt(boxes, 150)!)).toBe(150) diff --git a/vscode/webview-ui/src/codeReader/scrollPosition.ts b/vscode/webview-ui/src/codeReader/scrollPosition.ts index 9b519a9..cd310ad 100644 --- a/vscode/webview-ui/src/codeReader/scrollPosition.ts +++ b/vscode/webview-ui/src/codeReader/scrollPosition.ts @@ -23,6 +23,20 @@ export function anchorAt(boxes: readonly BlockBox[], scrollTop: number): ScrollA return box && { line: box.line, offset: scrollTop - box.top, scrollTop } } +/** + * 页面内容变了也要钉住不动的那块:上沿在视口顶部(`scrollTop`,见 [ScrollAnchor])或更高的最后一块,视口顶部落在它里面、它的问答区里、它和下一块的空隙里都算它。 + * 起始行相同的几块(外层块和它的第一个子块)记成页面上靠前的那块,[scrollTopFor] 按起始行找回来的就是它。视口顶部在所有块上面时不钉。 + */ +export function holdAt(boxes: readonly BlockBox[], scrollTop: number): ScrollAnchor | undefined { + let held: BlockBox | undefined + for (const box of boxes) { + if (box.top <= scrollTop) + held = box + } + const first = held && boxes.find(box => box.line === held.line) + return first && { line: first.line, offset: scrollTop - first.top, scrollTop } +} + /** 恢复时视口顶部要在的位置(见 ScrollAnchor):起始行还是那一行的块还在就对齐到它,否则用原来的 scrollTop。 */ export function scrollTopFor(boxes: readonly BlockBox[], anchor: ScrollAnchor): number { const box = boxes.find(b => b.line === anchor.line) diff --git a/vscode/webview-ui/src/codeReader/stickyScroll.ts b/vscode/webview-ui/src/codeReader/stickyScroll.ts index 87864d4..60df3c7 100644 --- a/vscode/webview-ui/src/codeReader/stickyScroll.ts +++ b/vscode/webview-ui/src/codeReader/stickyScroll.ts @@ -54,6 +54,11 @@ export function insetAbove(depth: number, lineHeight: number, limit: number): nu return Math.min(depth, limit) * lineHeight } +/** 眼下钉着的几行占多高([StickyLines](vscode/webview-ui/src/codeReader/StickyLines.tsx#StickyLines) 画的 `.cr-sticky`),没钉时是 0。 */ +export function stickyHeight(): number { + return document.querySelector('.cr-sticky')?.offsetHeight ?? 0 +} + /** 两次算出来钉的一样(同几张、左右位置一样、推得一样多):一样就不重画 */ export function samePinned(a: PinnedLines, b: PinnedLines): boolean { return a.offset === b.offset && a.scopes.length === b.scopes.length && a.scopes.every((scope, index) => { diff --git a/vscode/webview-ui/src/codeReader/useChangeNav.ts b/vscode/webview-ui/src/codeReader/useChangeNav.ts index 8141a8e..f679df5 100644 --- a/vscode/webview-ui/src/codeReader/useChangeNav.ts +++ b/vscode/webview-ui/src/codeReader/useChangeNav.ts @@ -3,6 +3,7 @@ import type { StickyScrollSettings } from './types' import { useCallback, useLayoutEffect, useRef, useState } from 'react' import { nextStop } from './diffView' import { pageLayout } from './pageLayout' +import { scrollOwner } from './scrollOwner' import { flash } from './useReveal' import { stickyInset } from './useStickyScroll' @@ -12,15 +13,16 @@ const MARGIN = 8 /** * 「上一处 / 下一处」和点总览条的刻度:跳到一处改动。它在折叠着的块里时先展开那几层(expand),渲染好了再把它滚到筛选栏下面 * (钉着外层卡片时在钉着的几行下面,sticky 是 VS Code 的 Sticky Scroll 设置)、闪几下。 - * 接着走从哪算:跳完之后页面没滚过就从刚跳到的那处接着走,用户自己滚过了就按视口顶上那块的行找(见 nextStop)。 + * 跳过去以后由 [scrollOwner] 跟住那张卡片。接着走从哪算:跳完之后用户没滚过、也没跳到别处就从刚跳到的那处接着走, + * 否则按视口顶上那块的行找(见 [nextStop]);页面长高时滚动所有者自己滚回去的不算用户滚过。 */ export function useChangeNav(stops: readonly ChangeStop[], collapsed: ReadonlySet, expand: (ids: string[]) => void, sticky: StickyScrollSettings | undefined): { move: (step: 1 | -1) => void goTo: (stop: ChangeStop) => void } { const [target, setTarget] = useState(null) - /** 最近一次跳到的那处,和跳完时页面滚到哪 */ - const jumped = useRef<{ id: string, scrollY: number } | null>(null) + /** 最近一次跳到的那处,和跳完时页面换过几次地方(见 [ScrollOwner.turn](vscode/webview-ui/src/codeReader/scrollOwner.ts#ScrollOwner.turn)) */ + const jumped = useRef<{ id: string, turn: number } | null>(null) useLayoutEffect(() => { if (!target) @@ -30,20 +32,22 @@ export function useChangeNav(stops: readonly ChangeStop[], collapsed: ReadonlySe expand(hidden) return } - const card = document.querySelector(`.cr-block[data-id="${target.id}"]`) + const locate = (): HTMLElement | null => document.querySelector(`.cr-block[data-id="${target.id}"]`) + const card = locate() if (card) { pageLayout.settle(card) - window.scrollTo(0, card.getBoundingClientRect().top + window.scrollY - pageLayout.pinnedHeight() - stickyInset(card, sticky) - MARGIN) + const inset = pageLayout.pinnedHeight() + stickyInset(card, sticky) + MARGIN + scrollOwner.reveal(locate, box => box.top - inset) flash(card) } - jumped.current = { id: target.id, scrollY: window.scrollY } + jumped.current = { id: target.id, turn: scrollOwner.turn } setTarget(null) }, [target, collapsed, expand, sticky]) const move = useCallback((step: 1 | -1) => { if (stops.length === 0) return - const from = jumped.current && Math.abs(window.scrollY - jumped.current.scrollY) < 2 ? jumped.current.id : undefined + const from = jumped.current?.turn === scrollOwner.turn ? jumped.current.id : undefined const index = from === undefined ? -1 : stops.findIndex(stop => stop.id === from) const at = index >= 0 ? { index } : { line: pageLayout.anchorAt(window.scrollY + pageLayout.pinnedHeight())?.line } setTarget(stops[nextStop(stops, at, step)]) diff --git a/vscode/webview-ui/src/codeReader/useFind.ts b/vscode/webview-ui/src/codeReader/useFind.ts index 659a5eb..c85d012 100644 --- a/vscode/webview-ui/src/codeReader/useFind.ts +++ b/vscode/webview-ui/src/codeReader/useFind.ts @@ -135,13 +135,21 @@ export function useFind(page: OccurrencePage | undefined, marks: ReadableStore { if (reveals === 0) return - // 要展开的已经展开:按展开后的页面重画一遍,再滚到当前那处;它画不出来就滚到它那段字、那块 - const painted = painter.show(latest.current.open ? find.paint() : undefined) - const { current, matches } = find.state - const block = current === undefined ? undefined : find.unitOf(matches[current]).block - const target = painted.current ?? painted.text ?? (block === undefined ? null : document.querySelector(`.cr-block[data-id="${block}"]`)) - if (target) - showInViewport(target) + // 要展开的已经展开:按展开后的页面重画一遍,再滚到当前那处;它画不出来就滚到它那段字、那块。 + // 之后页面长高时照画好的现找:画的那段字被换掉了(范围缩成一点)就退到那段字、那块 + painter.show(latest.current.open ? find.paint() : undefined) + const locate = (): HTMLElement | Range | null => { + const { current: range, text } = painter.painted + if (range && !range.collapsed && range.startContainer.isConnected) + return range + if (text?.isConnected) + return text + const { current, matches } = find.state + const block = current === undefined ? undefined : find.unitOf(matches[current]).block + return block === undefined ? null : document.querySelector(`.cr-block[data-id="${block}"]`) + } + if (locate()) + showInViewport(locate) painter.place() }, [reveals, painter, find]) diff --git a/vscode/webview-ui/src/codeReader/useOccurrences.ts b/vscode/webview-ui/src/codeReader/useOccurrences.ts index 6ab0588..8372b16 100644 --- a/vscode/webview-ui/src/codeReader/useOccurrences.ts +++ b/vscode/webview-ui/src/codeReader/useOccurrences.ts @@ -8,6 +8,7 @@ import { documentNameAt, PEEK_ATTRIBUTE } from './definitionPeek' import { clickAction, KeyScroll, OccurrenceHighlight, occurrenceJumpKey, OccurrenceNavigation } from './occurrences' import { nameOffset, NO_PLACEMENT } from './placement' import { occurrencePlan } from './reveal' +import { scrollOwner } from './scrollOwner' import { useStore } from './store' import { showInViewport } from './useReveal' import { onMessage, postMessage } from './vscode' @@ -64,19 +65,6 @@ function isMac(): boolean { return /Mac|iPhone|iPad/.test(navigator.platform) } -/** - * 立刻滚到这里,顺带打断浏览器正在播(或刚排上、还没开始播)的滚动动画。滚到原地浏览器当没滚、不打断动画, - * 所以先挪一个像素再回来:同一段脚本里连着滚两下,中间不画,画面上看不出。 - * 这两处位置先交给 own:滚动事件可能晚于这次调用才到,不能被当成浏览器把页面滚走。 - */ -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、换文档版本或翻译清掉;高亮着的名字记进 marks(页面文字的标记),返回它和筛选栏上「上一处 / 下一处」用的跳转。 * 高亮着时扩展端的「跳到下一处 / 上一处高亮」命令发来 moveOccurrence,筛选栏上的按钮走同一条跳转:当前那一处换成下一处页面上找得到的出现, @@ -109,9 +97,9 @@ export function useOccurrences(version: number | undefined, translatorId: string useLayoutEffect(() => { if (moves === 0) return - const el = document.querySelector('[data-current]') - if (el) - showInViewport(el, position => keyScroll.own(position)) + const current = (): HTMLElement | null => document.querySelector('[data-current]') + if (current()) + showInViewport(current) keyScroll.hold(scrollPosition()) }, [moves, keyScroll]) @@ -121,7 +109,7 @@ export function useOccurrences(version: number | undefined, translatorId: string // 触发命令的那个键浏览器可能已经照自己的意思滚了(⌘↓ 滚到底),先回到按键那一刻 const before = keyScroll.take(performance.now()) if (before) - scrollBack(before, position => keyScroll.own(position)) + scrollOwner.pullBack(before) } else { // 筛选栏按钮不是按键,别把这次滚动当成 ⌘↓ 拉回按键时的位置 @@ -150,10 +138,10 @@ export function useOccurrences(version: number | undefined, translatorId: string keyScroll.press(scrollPosition(), event.timeStamp) } const onPointer = (): void => keyScroll.forget() - const onScroll = (event: Event): void => { - const back = keyScroll.scrolled(event.timeStamp, scrollPosition) + const onScroll = (ours: boolean, event: Event): void => { + const back = keyScroll.scrolled(event.timeStamp, scrollPosition, ours) if (back) - scrollBack(back, position => keyScroll.own(position)) + scrollOwner.pullBack(back) } const onKey = (event: KeyboardEvent): void => { // 输入框、下拉列表自己用掉的 Esc 不算 @@ -175,7 +163,7 @@ export function useOccurrences(version: number | undefined, translatorId: string window.addEventListener('keydown', onKeyDown, true) window.addEventListener('pointerdown', onPointer, true) window.addEventListener('wheel', onPointer, { capture: true, passive: true }) - window.addEventListener('scroll', onScroll, { passive: true }) + const offScroll = scrollOwner.onScroll(onScroll) window.addEventListener('keydown', onKey) window.addEventListener('focus', onFocus) window.addEventListener('blur', onBlur) @@ -187,7 +175,7 @@ export function useOccurrences(version: number | undefined, translatorId: string window.removeEventListener('keydown', onKeyDown, true) window.removeEventListener('pointerdown', onPointer, true) window.removeEventListener('wheel', onPointer, { capture: true }) - window.removeEventListener('scroll', onScroll) + offScroll() window.removeEventListener('keydown', onKey) window.removeEventListener('focus', onFocus) window.removeEventListener('blur', onBlur) diff --git a/vscode/webview-ui/src/codeReader/useReveal.ts b/vscode/webview-ui/src/codeReader/useReveal.ts index d465fc0..a12525e 100644 --- a/vscode/webview-ui/src/codeReader/useReveal.ts +++ b/vscode/webview-ui/src/codeReader/useReveal.ts @@ -1,11 +1,11 @@ -import type { ScrollPosition } from './occurrences' import type { DocumentMessage, Reveal } from './types' import { useLayoutEffect } from 'react' import { blockPath } from './filter' import { pageLayout } from './pageLayout' import { placementOf } from './placement' import { revealPlan, scrollTopToShow, uncoveredPlan } from './reveal' -import { stickyHeight } from './useStickyScroll' +import { scrollOwner } from './scrollOwner' +import { stickyHeight } from './stickyScroll' /** 正在闪的元素和到点摘掉 data-flash 的计时器 */ const flashing = new WeakMap() @@ -28,26 +28,16 @@ export function flash(el: HTMLElement): void { } /** - * 把 target(一个元素,或查找栏的一处命中这样的一段字)滚进钉住的筛选栏和卡片下面的视口,量之前先把它那一带排版好(pageLayout.settle)。 - * 已经整个看得见就不滚。beforeScroll 在真正滚动前拿到将要落到的位置。 + * 把 `locate` 找到的目标(一个元素,或查找栏的一处命中这样的一段字)滚进钉住的筛选栏和卡片下面的视口,量之前先把它那一带排版好([PageLayout.settle](vscode/webview-ui/src/codeReader/pageLayout.ts#PageLayout.settle));已经整个看得见就不滚。 + * 之后由 [scrollOwner] 跟住它:页面再长高变矮也停在原处,直到用户自己动手。`locate` 每次现找,页面重画以后元素可能换了一个。 */ -export function showInViewport(target: HTMLElement | Range, beforeScroll?: (position: ScrollPosition) => void): void { +export function showInViewport(locate: () => HTMLElement | Range | null): void { + const target = locate() const el = target instanceof Range ? target.startContainer.parentElement : target if (el) pageLayout.settle(el) - const rect = target.getBoundingClientRect() const pinned = pageLayout.pinnedHeight() + stickyHeight() - const top = scrollTopToShow( - { top: rect.top + window.scrollY, bottom: rect.bottom + window.scrollY }, - window.scrollY, - window.innerHeight, - pinned, - ) - if (top === undefined) - return - const position = { x: 0, y: top } - beforeScroll?.(position) - window.scrollTo(position.x, position.y) + scrollOwner.reveal(locate, box => scrollTopToShow(box, window.scrollY, window.innerHeight, pinned)) } /** 这块自己显示着的、下标(块里的)就是定义处的那个名字。子块里的同名下标不算,免得闪到旁边那张卡片。 */ @@ -68,6 +58,7 @@ function nameAt(block: HTMLElement, offset: number): HTMLElement | null { * 每一步之后重新渲染、再进来一次;最后把目标滚进筛选栏下面并闪几下。 * 属性行 / 子行是这处定义时闪那一行;这块里显示着这个名字(下标盖住定义处)就闪这个名字,不闪整张卡片; * 名字不在页面上才闪整块。目标被正在编辑的祖先盖住时闪那个祖先(uncoveredPlan)。 + * 滚过去以后由 [scrollOwner] 跟住目标(① 跟住那块),页面再长高(注释里的图画出来、中文名到了)也停在原处。 */ export function useReveal( doc: DocumentMessage | null, @@ -82,15 +73,16 @@ export function useReveal( useLayoutEffect(() => { if (!doc || !reveal) return - if (reveal.definition === undefined) { - const top = pageLayout.cardForLine(reveal.line)?.top - if (top !== undefined) - window.scrollTo(0, top - pageLayout.pinnedHeight()) + const { line, definition } = reveal + if (definition === undefined) { + const card = pageLayout.cardForLine(line) + if (card) + scrollOwner.align({ line: card.line, offset: 0, scrollTop: card.top }) done() return } const placement = placementOf(doc.blocks) - const found = revealPlan(doc.blocks, placement, reveal.line, reveal.definition) + const found = revealPlan(doc.blocks, placement, line, definition) const plan = found && uncoveredPlan(found, covered) const path = plan ? blockPath(plan.id) : [] if (path.some(id => filtered.has(id))) { @@ -102,14 +94,17 @@ export function useReveal( expand(hidden) return } - // 块正在编辑时页面上没有属性表,就闪整块 - const block = plan ? document.querySelector(`.cr-block[data-id="${plan.id}"]`) : null - const row = plan?.row === undefined ? null : document.querySelector(`:is(.cr-prop-row, .cr-prop-item, .cr-table-row)[data-id="${plan.row}"]`) const base = plan && placement.of(plan.id)?.offset - const name = row || !block || base === undefined ? null : nameAt(block, reveal.definition - base) - const el = row ?? name ?? block + // 块正在编辑时页面上没有属性表,就闪整块 + const locate = (): HTMLElement | null => { + const block = plan ? document.querySelector(`.cr-block[data-id="${plan.id}"]`) : null + const row = plan?.row === undefined ? null : document.querySelector(`:is(.cr-prop-row, .cr-prop-item, .cr-table-row)[data-id="${plan.row}"]`) + const name = row || !block || base === undefined ? null : nameAt(block, definition - base) + return row ?? name ?? block + } + const el = locate() if (el) { - showInViewport(el) + showInViewport(locate) flash(el) } done() diff --git a/vscode/webview-ui/src/codeReader/useScrollPosition.ts b/vscode/webview-ui/src/codeReader/useScrollPosition.ts index 7c2cd1c..76adc5f 100644 --- a/vscode/webview-ui/src/codeReader/useScrollPosition.ts +++ b/vscode/webview-ui/src/codeReader/useScrollPosition.ts @@ -1,6 +1,7 @@ import type { DocumentMessage } from './types' import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { pageLayout } from './pageLayout' +import { scrollOwner } from './scrollOwner' import { postMessage, viewState } from './vscode' /** 滚动时最多这么久记一次位置 */ @@ -38,7 +39,8 @@ function record(reportedLine: { current: number | undefined }): void { /** * 记住并恢复预览看到的位置: - * ① 滚动时把视口顶部那块记进 webview 状态,页面重新加载后(VS Code 重启后恢复标签页、webview 进程崩了重建)照着滚回去; + * ① 滚动时([scrollOwner] 转来的滚动事件)把视口顶部那块记进 webview 状态,页面重新加载后(VS Code 重启后恢复标签页、webview 进程崩了重建) + * 交给 [scrollOwner] 滚回去、钉住那块,之后注释里的图画出来、中文名到了也不跑; * ② 同时把那块的起始行报给扩展端,切回文本编辑器时把这行放到顶部。 * 第一份文档带 reveal(刚从文本编辑器切过来、跳到定义)时不恢复,交给 useReveal 滚。 * 块的位置从 pageLayout 取:滚动时用量过的,不每次量全部卡片。 @@ -54,21 +56,20 @@ export function useScrollPosition(doc: DocumentMessage | null): void { restored.current = true const saved = viewState.get().scroll if (doc.reveal === undefined && saved) - window.scrollTo(0, pageLayout.topFor(saved) - pageLayout.pinnedHeight()) + scrollOwner.align(saved) record(reportedLine) }, [doc]) useEffect(() => { let timer: number | undefined - const onScroll = (): void => { + const off = scrollOwner.onScroll(() => { timer ??= window.setTimeout(() => { timer = undefined record(reportedLine) }, RECORD_INTERVAL_MS) - } - window.addEventListener('scroll', onScroll, { passive: true }) + }) return () => { - window.removeEventListener('scroll', onScroll) + off() window.clearTimeout(timer) } }, []) diff --git a/vscode/webview-ui/src/codeReader/useStickyScroll.ts b/vscode/webview-ui/src/codeReader/useStickyScroll.ts index 71b1262..8cec920 100644 --- a/vscode/webview-ui/src/codeReader/useStickyScroll.ts +++ b/vscode/webview-ui/src/codeReader/useStickyScroll.ts @@ -70,8 +70,3 @@ export function useStickyLines(content: RefObject, maxLineCo export function stickyInset(card: Element, settings: StickyScrollSettings | undefined): number { return settings?.enabled ? insetAbove(depthOf(card), STICKY_LINE_HEIGHT, limitOf(settings.maxLineCount)) : 0 } - -/** 眼下钉着的几行占多高(StickyLines 画的 .cr-sticky),没钉时是 0。 */ -export function stickyHeight(): number { - return document.querySelector('.cr-sticky')?.offsetHeight ?? 0 -}