🐛 fix(vscode): 代码阅读预览跳到定义后页面还在长高(注释里的 mermaid 画成图、中文名到了)不再把视口带跑:页面滚动统一交给滚动所有者,跟住跳到的目标、用户自己滚了改钉视口顶部那块,关掉浏览器自带的滚动锚定
This commit is contained in:
1 parent
2fa6319dd4
commit
3c5f5f5f35
24 files changed
+1012
-145
No files matched your search
@@ -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])
|
||||
|
||||
@@ -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 出去、切到别的窗口)就关
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)])
|
||||
|
||||
@@ -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)
|
||||
// 要展开的已经展开:按展开后的页面重画一遍,再滚到当前那处;它画不出来就滚到它那段字、那块。
|
||||
// 之后页面长高时照画好的现找:画的那段字被换掉了(范围缩成一点)就退到那段字、那块
|
||||
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
|
||||
const target = painted.current ?? painted.text ?? (block === undefined ? null : document.querySelector<HTMLElement>(`.cr-block[data-id="${block}"]`))
|
||||
if (target)
|
||||
showInViewport(target)
|
||||
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)
|
||||
|
||||
@@ -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 base = plan && placement.of(plan.id)?.offset
|
||||
// 块正在编辑时页面上没有属性表,就闪整块
|
||||
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 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 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
|
||||
}
|
||||
Reference in new issue
Block a user