🐛 fix(vscode): 代码阅读预览跳到定义后页面还在长高(注释里的 mermaid 画成图、中文名到了)不再把视口带跑:页面滚动统一交给滚动所有者,跟住跳到的目标、用户自己滚了改钉视口顶部那块,关掉浏览器自带的滚动锚定

This commit is contained in:
cruldra committed 2026-10-14 06:11:25 +11:00
1 parent 2fa6319dd4
commit 3c5f5f5f35
24 files changed
+1016 -149

No files matched your search

+8 -2
View File
@@ -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<HTMLDivElement>(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<HTMLElement>('.cr-filter-bar'), () => page.get().placement)
const detachLayout = pageLayout.attach(el, document.querySelector<HTMLElement>('.cr-filter-bar'), () => page.get().placement, () => scrollOwner.relayout())
const detachScroll = scrollOwner.attach()
const detachFind = findPainter.attach(el)
return () => {
detachLayout()
detachScroll()
detachFind()
}
}, [page, findPainter])
+14 -5
View File
@@ -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<HTMLButtonElement>(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<HTMLDivElement>): 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<HTMLTextAreaElement>(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 (
<>
@@ -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<HTMLButtonElement>('[data-call-tree][data-active]')?.focus()
document.querySelector<HTMLButtonElement>('[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<HTMLButtonElement>('[data-call-tree][data-active]')?.focus()
document.querySelector<HTMLButtonElement>('[data-call-tree][data-active]')?.focus({ preventScroll: true })
onClose()
}
@@ -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])
@@ -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 出去、切到别的窗口)就关
+1 -1
View File
@@ -24,7 +24,7 @@ export interface FindBarProps {
export function FindBar({ query, count, canMove, focus, onQuery, onMove, onClose }: FindBarProps): ReactElement {
const input = useRef<HTMLInputElement>(null)
useLayoutEffect(() => {
input.current?.focus()
input.current?.focus({ preventScroll: true })
input.current?.select()
}, [focus])
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
@@ -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<HTMLElement>(`.cr-block[data-id="${id}"]`)
const locate = (): HTMLElement | null => content.current?.querySelector<HTMLElement>(`.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)
}
@@ -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;
@@ -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;带了别的修饰键不算', () => {
@@ -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
}
}
@@ -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<Element>()
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)
})
})
+46 -13
View File
@@ -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<Element>()
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 {
@@ -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<ViewState>) => 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<Memento, 'get' | 'update'>)
registry.registerLanguage(createPythonAdapter(WASM_DIR))
registry.registerTranslator(pythonZhTranslator)
/** 扩展端发给新开的预览的第一份文档:带着要跳到的定义 */
async function documentRevealing(source: string, needle: string): Promise<ExtensionToWebview> {
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<FakeResizeObserver>()
private readonly targets = new Set<Element>()
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<void> {
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<void> {
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(<App />))
}
async function deliver(msg: ExtensionToWebview): Promise<void> {
await act(async () => {
for (const listener of host.listeners)
listener(msg)
})
}
/** 等计时器、动画帧跑完 */
async function settle(ms = 30): Promise<void> {
await act(async () => {
await new Promise(resolve => setTimeout(resolve, ms))
})
}
/** 页面上(不算钉住的那几行)定义处显示着 text 的那个名字 */
function nameOf(text: string): HTMLElement {
return [...container.querySelectorAll<HTMLElement>('.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<HTMLElement>('.cr-mermaid')!
expect(box(placeholder).bottom).toBeLessThan(box(target).top)
await act(async () => {
placeholder.innerHTML = '<svg></svg>'
})
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<HTMLElement>('.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 = '<svg></svg>'
})
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<ExtensionToWebview, { type: 'document' }>
await deliver(plain)
await settle()
const card = (): HTMLElement => nameOf('step2').closest<HTMLElement>('.cr-block')!
expect(card().getBoundingClientRect().top).toBe(-20)
await act(async () => {
container.querySelector('.cr-mermaid')!.innerHTML = '<svg></svg>'
})
await resizeAll()
await settle()
expect(card().getBoundingClientRect().top).toBe(-20)
})
})
@@ -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)
})
})
@@ -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<ScrollListener>()
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),
})
@@ -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)
@@ -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)
@@ -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<HTMLElement>('.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) => {
@@ -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<string>, expand: (ids: string[]) => void, sticky: StickyScrollSettings | undefined): {
move: (step: 1 | -1) => void
goTo: (stop: ChangeStop) => void
} {
const [target, setTarget] = useState<ChangeStop | null>(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<HTMLElement>(`.cr-block[data-id="${target.id}"]`)
const locate = (): HTMLElement | null => document.querySelector<HTMLElement>(`.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)])
+15 -7
View File
@@ -135,13 +135,21 @@ export function useFind(page: OccurrencePage | undefined, marks: ReadableStore<T
useLayoutEffect(() => {
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<HTMLElement>(`.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<HTMLElement>(`.cr-block[data-id="${block}"]`)
}
if (locate())
showInViewport(locate)
painter.place()
}, [reveals, painter, find])
@@ -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<HTMLElement>('[data-current]')
if (el)
showInViewport(el, position => keyScroll.own(position))
const current = (): HTMLElement | null => document.querySelector<HTMLElement>('[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)
+23 -28
View File
@@ -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<HTMLElement, number>()
@@ -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<HTMLElement>(`.cr-block[data-id="${plan.id}"]`) : null
const row = plan?.row === undefined ? null : document.querySelector<HTMLElement>(`: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<HTMLElement>(`.cr-block[data-id="${plan.id}"]`) : null
const row = plan?.row === undefined ? null : document.querySelector<HTMLElement>(`: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()
@@ -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)
}
}, [])
@@ -70,8 +70,3 @@ export function useStickyLines(content: RefObject<HTMLElement | null>, 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<HTMLElement>('.cr-sticky')?.offsetHeight ?? 0
}