⚡ perf(vscode): 代码阅读预览的卡片位置量过就记着,内容或大小变了才重量:粘性标题、记滚动位置、上报可见行、跳转按顶层块二分找,滚动时不再每帧量全部卡片,滚动回调里也不读会逼排版的位置

This commit is contained in:
cruldra committed 2026-10-13 14:58:03 +11:00
1 parent b2e998665e
commit ffb0f147e1
13 files changed
+380 -102

No files matched your search

+11 -3
View File
@@ -5,7 +5,7 @@ import type { TextMarks } from './SegmentText'
import type { BlockView, DocumentMessage } from './types'
import type { OccurrencePage } from './useOccurrences'
import { TriangleAlert } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { AskThread, FEATURE_THREAD } from './AskThread'
import { BlockTree } from './BlockTree'
import { CallTreeDialog } from './CallTreeDialog'
@@ -17,6 +17,7 @@ import { startEditing } from './editing'
import { keptWhileEditing } from './filter'
import { FilterBar } from './FilterBar'
import { OverviewRuler } from './OverviewRuler'
import { pageLayout } from './pageLayout'
import { createPageStore } from './pageState'
import { chatOf, PATH_ASK_ANCHOR, sameChat } from './qa'
import { TextMarksContext } from './SegmentText'
@@ -75,6 +76,13 @@ export function App(): ReactElement | null {
const restoreCall = useRef(viewState.get().callTree)
/** 块树所在的页面内容:钉住卡片时在这里面量 */
const content = useRef<HTMLDivElement>(null)
/** 页面内容挂上时,卡片的位置从它量(pageLayout):筛选栏和它在同一次提交里画出来 */
const attachContent = useCallback((el: HTMLDivElement | null) => {
content.current = el
return el ? pageLayout.attach(el, document.querySelector<HTMLElement>('.cr-filter-bar')) : undefined
}, [])
// 这一层重画了:同一段脚本里接着要量位置的(滚到某一行、跳到改动、滚到当前那一处)不能用重画前量的,先作废;放在它们前面
useLayoutEffect(() => pageLayout.invalidate())
const { doc, editing, folds, reveal, excluded, diff, onlyChanges, openCall, changes, collapsed, filter, covered, unshown, stops } = useStore(page, current => current)
const pathChat = useStore(qa.store, state => chatOf(state, PATH_ASK_ANCHOR), sameChat)
const qaAvailable = useStore(qa.store, state => state.available)
@@ -236,7 +244,7 @@ export function App(): ReactElement | null {
)}
/>
{changes && diff && <OverviewRuler stops={stops} lineCount={diff.lineCount} onPick={nav.goTo} />}
<div ref={content} className="mx-auto box-content max-w-[880px] px-6 py-5">
<div ref={attachContent} className="mx-auto box-content max-w-[880px] px-6 py-5">
{qaAvailable && (
<div className="mb-4">
<AskThread
@@ -273,7 +281,7 @@ export function App(): ReactElement | null {
{doc.blocks.length === 0
? doc.error === undefined && qaSubject === 'file' && <div className="text-[var(--vscode-descriptionForeground)]">这个文件里没有代码。</div>
: doc.blocks.some(block => !filter.hidden.has(block.id))
? <BlockTree blocks={doc.blocks} tree={tree} />
? <BlockTree blocks={doc.blocks} tree={tree} items />
: <div className="text-[var(--vscode-descriptionForeground)]">所有块都被筛选隐藏了。</div>}
</div>
{sticky?.enabled && <StickyLines content={content} blocks={doc.blocks} settings={sticky} />}
+21 -10
View File
@@ -1,4 +1,4 @@
import type { CSSProperties, ReactElement } from 'react'
import type { CSSProperties, ReactElement, ReactNode } from 'react'
import type { RewriteTool } from './BlockTools'
import type { DiffTree, FoldedRuns, PlacedRemoved } from './diffView'
import type { GridSlots } from './PropertyGrid'
@@ -117,11 +117,14 @@ function useBlockTree(): BlockTreeHandle {
return tree
}
/** 一棵块树:blocks 是顶层的块,tree 是它的状态和动作(见 BlockTreeHandle)。 */
export function BlockTree({ blocks, tree }: { blocks: BlockView[], tree: BlockTreeHandle }): ReactElement {
/**
* 一棵块树:blocks 是顶层的块,tree 是它的状态和动作(见 BlockTreeHandle)。
* items:顶层每块(连同它的问答区、子块)套一层 .cr-item,页面上按它分段量位置(见 pageLayout);定义卡片浮窗里只有一块,不套。
*/
export function BlockTree({ blocks, tree, items = false }: { blocks: BlockView[], tree: BlockTreeHandle, items?: boolean }): ReactElement {
return (
<BlockTreeContext value={tree}>
<BlockLevel blocks={blocks} />
<BlockLevel blocks={blocks} items={items} />
</BlockTreeContext>
)
}
@@ -287,8 +290,9 @@ function sameRewrite(a: RewriteShape, b: RewriteShape): boolean {
/**
* 一块:「只看改动」折起来的一段、卡片、下面的问答区和重写记录、子块那一层。块树、问 AI、重写注释三份状态里只订阅这一块自己的,
* 都没变就不重画;子块那一层的各块、卡片的内容各自 memo,这一块重画了它们也不跟着重画。
* item:画出来的卡片、问答区、子块那一层套一层 .cr-item(记着起始行),见 BlockTree。
*/
const BlockItem = memo(({ block }: { block: BlockView }): ReactElement => {
const BlockItem = memo(({ block, item }: { block: BlockView, item: boolean }): ReactElement => {
const tree = useBlockTree()
const { qa, rewrite } = tree
const shape = useStore(tree.state, state => shapeOf(block, state), shallowEqual)
@@ -299,7 +303,7 @@ const BlockItem = memo(({ block }: { block: BlockView }): ReactElement => {
<>
{shape.run !== undefined && <UnchangedStrip count={shape.run} onOpen={() => tree.onOpenRun(block.id)} />}
{shape.shown && (
<>
<Item item={item} line={block.startLine}>
<BlockCard block={block} shape={shape} ask={ask} rewrite={rewriting} />
{/* 输入框、展开状态跟着块的记号走,文档刷新块 id 变了也不丢 */}
{qa && ask.chat && (
@@ -333,20 +337,27 @@ const BlockItem = memo(({ block }: { block: BlockView }): ReactElement => {
/>
)}
{shape.open && <BlockLevel blocks={block.children} owner={block} />}
</>
</Item>
)}
</>
)
})
BlockItem.displayName = 'BlockItem'
/** 顶层一块的外层(见 BlockTree 的 items);不套时原样 */
function Item({ item, line, children }: { item: boolean, line: number, children: ReactNode }): ReactElement {
if (!item)
return <>{children}</>
return <div className="cr-item flex flex-col gap-2" data-line={line}>{children}</div>
}
/**
* 一层代码块:左边一条竖线标出这一层,每块的子块紧跟在它下面、再往右缩进一层。被筛掉、被盖住的块不画,不留空位,竖线跟着变短。
* owner 是这一层所属的块(顶层没有,记在 data-owner 上,钉住卡片时按它算卡片连同子块管到哪、套着几层),悬停它时竖线换成它的种类色。
* 标差异时每张卡片左边外侧一条改动色条,删掉的块收成一行放回原来的位置(见 RemovedCard);「只看改动」时连着没变的块折成一条(UnchangedStrip)。
* slotted:这一层是画在卡片属性表某一行下面的子块(BlockSlot),blocks 只有那几块。
* slotted:这一层是画在卡片属性表某一行下面的子块(BlockSlot),blocks 只有那几块。items:这一层每块套一层 .cr-item(只有页面上的顶层)。
*/
export function BlockLevel({ blocks, owner, slotted = false }: { blocks: BlockView[], owner?: BlockView, slotted?: boolean }): ReactElement {
export function BlockLevel({ blocks, owner, slotted = false, items = false }: { blocks: BlockView[], owner?: BlockView, slotted?: boolean, items?: boolean }): ReactElement {
const tree = useBlockTree()
// 挂在卡片子行上的子块画在卡片属性表里(slotted 的那一层),这一层不画;删掉的块都放回卡片后面这一层
const listed = useMemo(() => slotted ? blocks : blocks.filter(block => !block.slot), [blocks, slotted])
@@ -371,7 +382,7 @@ export function BlockLevel({ blocks, owner, slotted = false }: { blocks: BlockVi
{removedAfter(undefined)}
{listed.map(block => (
<Fragment key={block.id}>
<BlockItem block={block} />
<BlockItem block={block} item={items} />
{removedAfter(block.id)}
</Fragment>
))}
@@ -2,10 +2,11 @@ import type { ReactElement, RefObject } from 'react'
import type { BlockView, StickyScrollSettings } from './types'
import { useContext, useLayoutEffect, useMemo } from 'react'
import { KindIcon } from './kindIcons'
import { pageLayout } from './pageLayout'
import { SegmentText, TextMarksContext } from './SegmentText'
import { selectStore } from './store'
import { flash } from './useReveal'
import { pinnedHeight, usePinnedHeight } from './useScrollPosition'
import { usePinnedHeight } from './useScrollPosition'
import { STICKY_LINE_HEIGHT, stickyInset, useStickyLines } from './useStickyScroll'
/** 块 id → 块,连同各层子块 */
@@ -74,7 +75,7 @@ export function StickyLines({ content, blocks, settings }: { content: RefObject<
const card = content.current?.querySelector<HTMLElement>(`.cr-block[data-id="${id}"]`)
if (!card)
return
window.scrollTo(0, card.getBoundingClientRect().top + window.scrollY - pinnedHeight() - stickyInset(card, settings))
window.scrollTo(0, card.getBoundingClientRect().top + window.scrollY - pageLayout.pinnedHeight() - stickyInset(card, settings))
flash(card)
}
@@ -963,7 +963,8 @@ body {
* 卡片左边外侧的改动色条:3px 宽、和卡片等高,空 5px 再是卡片;没变的卡片留一条透明的,同一层的卡片照样对齐。
* 色条画在卡片里(标不标差异卡片都是同一个元素),标差异的那一层(data-diff)卡片、折起来的一段、问答区都往右让出 8px,色条挪进这 8px 里
*/
.cr-level[data-diff] > div > :is(.cr-block, .cr-unchanged, .cr-qa) {
.cr-level[data-diff] > div > :is(.cr-block, .cr-unchanged, .cr-qa),
.cr-level[data-diff] > div > .cr-item > :is(.cr-block, .cr-qa) {
margin-left: 8px;
}
.cr-change-bar {
@@ -1,6 +1,6 @@
import type { Click, Highlight } from './occurrences'
import type { BlockView, Inline, Occurrence, PreviewText, PropertyItem, Segment, WebviewToExtension } from './types'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { toBlockViews } from '../../../src/codeReader/blockView'
import { createPythonAdapter } from '../../../src/codeReader/languages/python'
import { WASM_DIR, withBlocks } from '../../../src/codeReader/testSupport'
@@ -359,26 +359,38 @@ describe('occurrences:用键盘在各处出现之间跳', () => {
scroll.press({ x: 0, y: 40 }, 1000)
scroll.take(1012)
// 刚回到按键那一刻、还没跳完:守住按键那一刻的位置
expect(scroll.scrolled({ x: 0, y: 300 }, 1020)).toEqual({ x: 0, y: 40 })
expect(scroll.scrolled(1020, () => ({ x: 0, y: 300 }))).toEqual({ x: 0, y: 40 })
scroll.hold({ x: 0, y: 900 })
expect(scroll.scrolled({ x: 0, y: 900 }, 1030)).toBeUndefined()
expect(scroll.scrolled({ x: 0, y: 1200 }, 1040)).toEqual({ x: 0, y: 900 })
expect(scroll.scrolled({ x: 0, y: 1200 }, 1000 + KEY_SCROLL_MS + 1)).toBeUndefined()
expect(scroll.scrolled({ x: 0, y: 1300 }, 1000 + KEY_SCROLL_MS + 2)).toBeUndefined()
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()
})
it('不是按键触发的跳转、又按了键、点了鼠标:不守', () => {
const scroll = new KeyScroll()
scroll.take(10)
scroll.hold({ x: 0, y: 900 })
expect(scroll.scrolled({ x: 0, y: 0 }, 20)).toBeUndefined()
expect(scroll.scrolled(20, () => ({ x: 0, y: 0 }))).toBeUndefined()
scroll.press({ x: 0, y: 40 }, 1000)
scroll.take(1010)
scroll.press({ x: 0, y: 40 }, 1100)
expect(scroll.scrolled({ x: 0, y: 300 }, 1110)).toBeUndefined()
expect(scroll.scrolled(1110, () => ({ x: 0, y: 300 }))).toBeUndefined()
scroll.take(1120)
scroll.forget()
expect(scroll.scrolled({ x: 0, y: 300 }, 1130)).toBeUndefined()
expect(scroll.scrolled(1130, () => ({ x: 0, y: 300 }))).toBeUndefined()
})
it('不守着时滚动不读滚动位置(读它会逼浏览器先排版)', () => {
const scroll = new KeyScroll()
const read = vi.fn(() => ({ x: 0, y: 300 }))
expect(scroll.scrolled(10, read)).toBeUndefined()
scroll.press({ x: 0, y: 40 }, 1000)
expect(scroll.scrolled(1005, read)).toBeUndefined()
expect(read).not.toHaveBeenCalled()
scroll.take(1010)
expect(scroll.scrolled(1020, read)).toEqual({ x: 0, y: 40 })
expect(read).toHaveBeenCalledTimes(1)
})
it('我们自己滚过的位置不算浏览器滚走:差 1 像素、滚进视口都不再拉一次,真被滚走仍拉回', () => {
@@ -389,12 +401,12 @@ describe('occurrences:用键盘在各处出现之间跳', () => {
scroll.own({ x: 0, y: 39 })
scroll.own({ x: 0, y: 40 })
scroll.own({ x: 0, y: 800 })
expect(scroll.scrolled({ x: 0, y: 39 }, 1020)).toBeUndefined()
expect(scroll.scrolled({ x: 0, y: 39 }, 1021)).toBeUndefined()
expect(scroll.scrolled({ x: 0, y: 800 }, 1022)).toBeUndefined()
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()
scroll.hold({ x: 0, y: 800 })
expect(scroll.scrolled({ x: 0, y: 39 }, 1030)).toBeUndefined()
expect(scroll.scrolled({ x: 0, y: 300 }, 1040)).toEqual({ x: 0, y: 800 })
expect(scroll.scrolled(1030, () => ({ x: 0, y: 39 }))).toBeUndefined()
expect(scroll.scrolled(1040, () => ({ x: 0, y: 300 }))).toEqual({ x: 0, y: 800 })
})
it('默认快捷键:mac 是 ⌘↓ / ⌘↑,别的系统是 ctrl;带了别的修饰键不算', () => {
@@ -229,14 +229,15 @@ export class KeyScroll {
this.held = { ...this.held, position }
}
/** 页面滚动了:还在守着、又被浏览器滚走了,给出要拉回的位置。我们自己滚到的位置不算。 */
scrolled(position: ScrollPosition, now: number): ScrollPosition | undefined {
/** 页面滚动了:还在守着、又被浏览器滚走了,给出要拉回的位置。我们自己滚到的位置不算。守着时才读滚动位置(read):读它会逼浏览器先排版,平常滚动不读。 */
scrolled(now: number, read: () => ScrollPosition): 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))
return undefined
return held.position.x === position.x && held.position.y === position.y ? undefined : held.position
@@ -0,0 +1,100 @@
// @vitest-environment happy-dom
import type { BlockBox } from './scrollPosition'
import { afterEach, describe, expect, it } from 'vitest'
import { PageLayout } from './pageLayout'
import { anchorAt, blockForLine } from './scrollPosition'
/** 量过几次位置 */
let reads = 0
/** 元素的位置写死(happy-dom 不排版),每量一次记一次 */
function place<T extends HTMLElement>(el: T, top: number, bottom: number, left = 0, right = 800): T {
el.getBoundingClientRect = () => {
reads++
return { top, bottom, left, right, width: right - left, height: bottom - top, x: left, y: top, toJSON: () => ({}) } as DOMRect
}
return el
}
function div(className: string, attrs: Record<string, string> = {}, ...children: HTMLElement[]): HTMLElement {
const el = document.createElement('div')
el.className = className
for (const [name, value] of Object.entries(attrs))
el.setAttribute(name, value)
el.append(...children)
return el
}
/** 一层:左边竖线那格、右边一栏 */
function level(owner: string | undefined, ...children: HTMLElement[]): HTMLElement {
return div('cr-level', owner === undefined ? {} : { 'data-owner': owner }, div('guide'), div('column', {}, ...children))
}
function card(id: string, line: number, top: number, bottom: number, editing = false): HTMLElement {
return place(div('cr-block', { 'data-id': id, 'data-line': String(line), ...(editing && { 'data-editing': '' }) }), top, bottom)
}
/**
* 三段顶层块,每段 1000px:第 0 段一张卡片和它的两张子卡片(子层到 900 结束),第 1 段一张卡片(在编辑),第 2 段一张卡片。
*/
function page(): { root: HTMLElement, cards: BlockBox[] } {
const root = document.createElement('div')
root.append(level(
undefined,
place(div('cr-item', { 'data-line': '0' }, card('0', 0, 0, 100), place(level('0', card('0.0', 2, 120, 300), card('0.1', 8, 320, 500)), 110, 900)), 0, 1000),
place(div('cr-item', { 'data-line': '20' }, card('1', 20, 1010, 1500, true)), 1010, 1500),
place(div('cr-item', { 'data-line': '40' }, card('2', 40, 1510, 2000)), 1510, 2000),
))
document.body.append(root)
const cards = [[0, 0, 100], [2, 120, 300], [8, 320, 500], [20, 1010, 1500], [40, 1510, 2000]].map(([line, top, bottom]) => ({ line, top, bottom }))
return { root, cards }
}
afterEach(() => {
document.body.innerHTML = ''
reads = 0
})
describe('卡片位置量过就记着', () => {
it('滚动位置、包含某一行的块:和把全部卡片量一遍算出来的一样', () => {
const { root, cards } = page()
const layout = new PageLayout()
layout.attach(root, null)
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)!))
}
for (const line of [0, 1, 5, 9, 25, 100])
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)
})
it('钉住的卡片:只算和顶上那一段相交的顶层块,在编辑的不钉;外层卡片的下沿算到它的子层', () => {
const { root } = page()
const layout = new PageLayout()
layout.attach(root, null)
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]])
})
it('滚动时不再量:同一份内容问几次都只量一次;只量用到的那几段;内容变了才重量', async () => {
const { root } = page()
const layout = new PageLayout()
layout.attach(root, null)
layout.anchorAt(0)
// 三段顶层块的外框 + 第 0、1 段里的卡片和子层
const first = reads
expect(first).toBe(3 + 4 + 1)
for (const y of [0, 10, 20, 300, 600])
layout.anchorAt(y)
layout.stickyScopes(48)
expect(reads).toBe(first)
root.querySelector('.cr-item')!.append(div('cr-qa'))
await Promise.resolve()
layout.anchorAt(0)
expect(reads).toBe(first * 2)
})
})
@@ -0,0 +1,191 @@
import type { BlockBox, ScrollAnchor } from './scrollPosition'
import type { StickyScope } from './stickyScroll'
import { anchorAt, blockForLine, scrollTopFor } from './scrollPosition'
/** 一张卡片量出来的位置(文档坐标):BlockBox 之外,块 id、外面套着几张卡片、连同画出来的子块的下沿、左右边,在不在编辑。 */
interface CardBox extends BlockBox {
id: string
depth: number
scopeBottom: number
left: number
right: number
editing: boolean
}
/** 顶层的一块(.cr-item,块树按它分段):上下边(文档坐标)和起始行。 */
interface ItemBox {
el: HTMLElement
top: number
bottom: number
line: number
}
/** 卡片外面套着几张卡片:它所在的各层子层(BlockLevel 画的、带 data-owner 的)一层算一张 */
export function depthOf(card: Element): number {
let depth = 0
for (let level = card.closest('.cr-level[data-owner]'); level; level = level.parentElement?.closest('.cr-level[data-owner]') ?? null)
depth++
return depth
}
/** 一段顶层块里的卡片(BlockCard 带 data-line;删掉的块那张没有),按页面先后;下沿算到它名下画出来的子层(带 data-owner)的最后 */
function measureCards(item: HTMLElement): CardBox[] {
const x = window.scrollX
const y = window.scrollY
const bottoms = new Map<string, number>()
for (const level of item.querySelectorAll<HTMLElement>('.cr-level[data-owner]')) {
const owner = level.dataset.owner ?? ''
bottoms.set(owner, Math.max(bottoms.get(owner) ?? Number.NEGATIVE_INFINITY, level.getBoundingClientRect().bottom + y))
}
return Array.from(item.querySelectorAll<HTMLElement>('.cr-block[data-line]'), (card) => {
const id = card.dataset.id ?? ''
const rect = card.getBoundingClientRect()
const bottom = rect.bottom + y
return {
id,
line: Number(card.dataset.line),
top: rect.top + y,
bottom,
scopeBottom: Math.max(bottom, bottoms.get(id) ?? bottom),
left: rect.left + x,
right: rect.right + x,
depth: depthOf(card),
editing: card.hasAttribute('data-editing'),
}
})
}
/** 第一个满足 test 的序号,没有时是总数;test 按先后是先不满足、后满足(顶层块按页面先后排,上下边、起始行都是递增的) */
function firstWhere(items: readonly ItemBox[], test: (item: ItemBox) => boolean): number {
let low = 0
let high = items.length
while (low < high) {
const middle = (low + high) >> 1
if (test(items[middle]))
high = middle
else
low = middle + 1
}
return low
}
/** 起始行不超过 line 的最后一段顶层块的序号;line 在所有块之前时是 0 */
function itemOfLine(items: readonly ItemBox[], line: number): number {
return Math.max(0, firstWhere(items, item => item.line > line) - 1)
}
/**
* 页面上卡片的位置和筛选栏的高度,量过就记着,滚动时只在记着的里面按顶层块二分找,不再每帧量全部卡片(量位置会逼浏览器先排版)。
* 顶层的块(.cr-item)先整段量,里面的卡片用到哪段才量哪段(屏幕外的段可能没排版,量它要先排一次)。坐标都是文档坐标(视口坐标 + 滚动位置),滚动不改它。
* 页面内容的结构变了(MutationObserver)、大小变了(ResizeObserver)、窗口大小变了,或页面重画了(invalidate,同一段脚本里改完马上要量的)才作废,下次用时重量。
*/
export class PageLayout {
private root: HTMLElement | null = null
private bar: HTMLElement | null = null
private items: ItemBox[] | undefined
private cards = new Map<HTMLElement, CardBox[]>()
private barHeight: number | undefined
/** 量 root(块树所在的页面内容)里的卡片,筛选栏是 bar;返回停止观察的函数 */
attach(root: HTMLElement, bar: HTMLElement | null): () => void {
this.root = root
this.bar = bar
this.invalidate()
const invalidate = (): void => this.invalidate()
const mutations = new MutationObserver(invalidate)
mutations.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-line', 'data-editing', 'data-id'] })
const sizes = new ResizeObserver(invalidate)
sizes.observe(root)
if (bar)
sizes.observe(bar)
window.addEventListener('resize', invalidate)
return () => {
mutations.disconnect()
sizes.disconnect()
window.removeEventListener('resize', invalidate)
if (this.root === root) {
this.root = null
this.bar = null
this.invalidate()
}
}
}
invalidate(): void {
this.items = undefined
this.cards.clear()
this.barHeight = undefined
}
/** 顶部钉住的筛选栏的高度:视口里露出块的部分从它下面开始,记位置、滚过去都按这部分算。 */
pinnedHeight(): number {
this.barHeight ??= this.bar?.offsetHeight ?? 0
return this.barHeight
}
private itemBoxes(): ItemBox[] {
if (!this.items) {
// 顶层那一层:块树最外面的 .cr-level 的右栏,里面的 .cr-item 一块一段
const column = this.root?.querySelector(':scope > .cr-level > :last-child')
const y = window.scrollY
this.items = Array.from(column?.children ?? []).flatMap((el) => {
if (!(el instanceof HTMLElement) || !el.classList.contains('cr-item'))
return []
const rect = el.getBoundingClientRect()
return [{ el, top: rect.top + y, bottom: rect.bottom + y, line: Number(el.dataset.line) }]
})
}
return this.items
}
private cardsOf(item: ItemBox): CardBox[] {
let cards = this.cards.get(item.el)
if (!cards) {
cards = measureCards(item.el)
this.cards.set(item.el, cards)
}
return cards
}
/** 视口顶部(scrollTop,见 ScrollAnchor)那块(同 anchorAt):只量它所在的那段和下一段(那段里的卡片都在 scrollTop 之上结束时是下一段的第一张)。 */
anchorAt(scrollTop: number): ScrollAnchor | undefined {
const items = this.itemBoxes()
if (items.length === 0)
return undefined
const index = Math.min(firstWhere(items, item => item.bottom > scrollTop), items.length - 1)
return anchorAt(items.slice(index, index + 2).flatMap(item => this.cardsOf(item)), scrollTop)
}
/** 包含这一行的块(同 blockForLine):顶层块按起始行排,先找到它所在的那段,只量那一段。 */
cardForLine(line: number): BlockBox | undefined {
const items = this.itemBoxes()
return items.length === 0 ? undefined : blockForLine(this.cardsOf(items[itemOfLine(items, line)]), line)
}
/** 恢复时视口顶部要在的位置(同 scrollTopFor):起始行是 anchor.line 的块只会在它所在的那段里。 */
topFor(anchor: ScrollAnchor): number {
const items = this.itemBoxes()
return items.length === 0 ? anchor.scrollTop : scrollTopFor(this.cardsOf(items[itemOfLine(items, anchor.line)]), anchor)
}
/**
* 能钉住的卡片,按页面先后,竖直坐标以筛选栏下沿为 0:画出来了、不在编辑的(折叠着的、没有子块的也算)。
* 只留可能钉住的:上沿在钉住的那几行(高 band)以内或更高、下沿(连同子块)还在筛选栏下沿以下。只看和这一段相交的顶层块。
*/
stickyScopes(band: number): StickyScope[] {
const items = this.itemBoxes()
const top = window.scrollY + this.pinnedHeight()
const x = window.scrollX
const scopes: StickyScope[] = []
for (let index = firstWhere(items, item => item.bottom >= top); index < items.length && items[index].top < top + band; index++) {
for (const card of this.cardsOf(items[index])) {
if (!card.editing && card.top - top < band && card.scopeBottom - top >= 0)
scopes.push({ id: card.id, depth: card.depth, top: card.top - top, bottom: card.scopeBottom - top, left: card.left - x, right: card.right - x })
}
}
return scopes
}
}
/** 预览页只有一份页面内容:App 挂上它,滚动位置、钉住的卡片、跳转都从这里取位置。 */
export const pageLayout = new PageLayout()
@@ -2,9 +2,8 @@ import type { ChangeStop } from './diffView'
import type { StickyScrollSettings } from './types'
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
import { nextStop } from './diffView'
import { anchorAt } from './scrollPosition'
import { pageLayout } from './pageLayout'
import { flash } from './useReveal'
import { measureBlocks, pinnedHeight } from './useScrollPosition'
import { stickyInset } from './useStickyScroll'
/** 跳到的那处离筛选栏下沿留多少像素。 */
@@ -33,7 +32,7 @@ export function useChangeNav(stops: readonly ChangeStop[], collapsed: ReadonlySe
}
const card = document.querySelector<HTMLElement>(`.cr-block[data-id="${target.id}"]`)
if (card) {
window.scrollTo(0, card.getBoundingClientRect().top + window.scrollY - pinnedHeight() - stickyInset(card, sticky) - MARGIN)
window.scrollTo(0, card.getBoundingClientRect().top + window.scrollY - pageLayout.pinnedHeight() - stickyInset(card, sticky) - MARGIN)
flash(card)
}
jumped.current = { id: target.id, scrollY: window.scrollY }
@@ -45,7 +44,7 @@ export function useChangeNav(stops: readonly ChangeStop[], collapsed: ReadonlySe
return
const from = jumped.current && Math.abs(window.scrollY - jumped.current.scrollY) < 2 ? jumped.current.id : undefined
const index = from === undefined ? -1 : stops.findIndex(stop => stop.id === from)
const at = index >= 0 ? { index } : { line: anchorAt(measureBlocks(), window.scrollY + pinnedHeight())?.line }
const at = index >= 0 ? { index } : { line: pageLayout.anchorAt(window.scrollY + pageLayout.pinnedHeight())?.line }
setTarget(stops[nextStop(stops, at, step)])
}, [stops])
@@ -145,7 +145,7 @@ export function useOccurrences(version: number | undefined, translatorId: string
}
const onPointer = (): void => keyScroll.forget()
const onScroll = (event: Event): void => {
const back = keyScroll.scrolled(scrollPosition(), event.timeStamp)
const back = keyScroll.scrolled(event.timeStamp, scrollPosition)
if (back)
scrollBack(back, position => keyScroll.own(position))
}
@@ -2,9 +2,8 @@ import type { ScrollPosition } from './occurrences'
import type { DocumentMessage, Reveal } from './types'
import { useLayoutEffect } from 'react'
import { blockPath } from './filter'
import { pageLayout } from './pageLayout'
import { revealPlan, scrollTopToShow, uncoveredPlan } from './reveal'
import { blockForLine } from './scrollPosition'
import { measureBlocks, pinnedHeight } from './useScrollPosition'
import { stickyHeight } from './useStickyScroll'
/** 让一块或属性表里的一行闪几下(codeReader.css 的 [data-flash]);正在闪的同一处再跳一次时从头闪。 */
@@ -28,7 +27,7 @@ export function flash(el: HTMLElement): void {
/** 把 el 滚进钉住的筛选栏和卡片下面的视口。已经整个看得见就不滚。beforeScroll 在真正滚动前拿到将要落到的位置。 */
export function showInViewport(el: HTMLElement, beforeScroll?: (position: ScrollPosition) => void): void {
const rect = el.getBoundingClientRect()
const pinned = pinnedHeight() + stickyHeight()
const pinned = pageLayout.pinnedHeight() + stickyHeight()
const top = scrollTopToShow(
{ top: rect.top + window.scrollY, bottom: rect.bottom + window.scrollY },
window.scrollY,
@@ -75,9 +74,9 @@ export function useReveal(
if (!doc || !reveal)
return
if (reveal.definition === undefined) {
const top = blockForLine(measureBlocks(), reveal.line)?.top
const top = pageLayout.cardForLine(reveal.line)?.top
if (top !== undefined)
window.scrollTo(0, top - pinnedHeight())
window.scrollTo(0, top - pageLayout.pinnedHeight())
done()
return
}
@@ -1,18 +1,15 @@
import type { BlockBox } from './scrollPosition'
import type { DocumentMessage } from './types'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { anchorAt, scrollTopFor } from './scrollPosition'
import { pageLayout } from './pageLayout'
import { postMessage, viewState } from './vscode'
/** 滚动时最多这么久记一次位置 */
const RECORD_INTERVAL_MS = 100
/** 顶部钉住的筛选栏的高度:视口里露出块的部分从它下面开始,记位置、滚过去都按这部分算。 */
export function pinnedHeight(): number {
return document.querySelector<HTMLElement>('.cr-filter-bar')?.offsetHeight ?? 0
}
/** 筛选栏有多高,跟着它变(换行、多出第二行时):钉在它下面的东西(总览条、钉住的卡片)从这儿开始。 */
/**
* 筛选栏有多高,跟着它变(换行、多出第二行时):钉在它下面的东西(总览条、钉住的卡片)从这儿开始。
* 大小变了从 ResizeObserver 报来的尺寸里取,不回头读它的 offsetHeight(那要先排版)。
*/
export function usePinnedHeight(): number {
const [height, setHeight] = useState(0)
useLayoutEffect(() => {
@@ -20,25 +17,16 @@ export function usePinnedHeight(): number {
if (!bar)
return
setHeight(bar.offsetHeight)
const observer = new ResizeObserver(() => setHeight(bar.offsetHeight))
const observer = new ResizeObserver(([entry]) => setHeight(entry.borderBoxSize[0]?.blockSize ?? bar.offsetHeight))
observer.observe(bar)
return () => observer.disconnect()
}, [])
return height
}
/** 页面上每块(BlockCard 带 data-line)的位置,按页面上的先后;被筛掉的块不在页面上,不算。 */
export function measureBlocks(): BlockBox[] {
const y = window.scrollY
return Array.from(document.querySelectorAll<HTMLElement>('.cr-block[data-line]'), (el) => {
const rect = el.getBoundingClientRect()
return { line: Number(el.dataset.line), top: rect.top + y, bottom: rect.bottom + y }
})
}
/** 记下视口顶部那块;它的起始行变了才报给扩展端。 */
function record(reportedLine: { current: number | undefined }): void {
const anchor = anchorAt(measureBlocks(), window.scrollY + pinnedHeight())
const anchor = pageLayout.anchorAt(window.scrollY + pageLayout.pinnedHeight())
if (!anchor)
return
viewState.update({ scroll: anchor })
@@ -53,6 +41,7 @@ function record(reportedLine: { current: number | undefined }): void {
* ① 滚动时把视口顶部那块记进 webview 状态,页面重新加载后(VS Code 重启后恢复标签页、webview 进程崩了重建)照着滚回去;
* ② 同时把那块的起始行报给扩展端,切回文本编辑器时把这行放到顶部。
* 第一份文档带 reveal(刚从文本编辑器切过来、跳到定义)时不恢复,交给 useReveal 滚。
* 块的位置从 pageLayout 取:滚动时用量过的,不每次量全部卡片。
*/
export function useScrollPosition(doc: DocumentMessage | null): void {
const restored = useRef(false)
@@ -64,9 +53,8 @@ export function useScrollPosition(doc: DocumentMessage | null): void {
return
restored.current = true
const saved = viewState.get().scroll
const top = doc.reveal === undefined && saved ? scrollTopFor(measureBlocks(), saved) : undefined
if (top !== undefined)
window.scrollTo(0, top - pinnedHeight())
if (doc.reveal === undefined && saved)
window.scrollTo(0, pageLayout.topFor(saved) - pageLayout.pinnedHeight())
record(reportedLine)
}, [doc])
@@ -1,49 +1,17 @@
import type { RefObject } from 'react'
import type { PinnedLines, StickyScope } from './stickyScroll'
import type { PinnedLines } from './stickyScroll'
import type { StickyScrollSettings } from './types'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { depthOf, pageLayout } from './pageLayout'
import { insetAbove, lineLimit, NO_PINNED, pinnedLines, samePinned } from './stickyScroll'
import { pinnedHeight } from './useScrollPosition'
import { onMessage } from './vscode'
/** 钉在顶上的一行多高(像素) */
export const STICKY_LINE_HEIGHT = 24
/** 卡片外面套着几张卡片:它所在的各层子层(BlockLevel 画的、带 data-owner 的)一层算一张 */
function depthOf(card: Element): number {
let depth = 0
for (let level = card.closest('.cr-level[data-owner]'); level; level = level.parentElement?.closest('.cr-level[data-owner]') ?? null)
depth++
return depth
}
/** 筛选栏下面的可视区域里最多钉几行 */
function limitOf(maxLineCount: number): number {
return lineLimit(window.innerHeight - pinnedHeight(), STICKY_LINE_HEIGHT, maxLineCount)
}
/**
* root 里能钉住的卡片,按页面先后,竖直坐标以筛选栏下沿为 0:画出来了、不在编辑的每一张(BlockCard 带 data-line;删掉的块那张没有),
* 折叠着的、没有子块的也算,卡片自己就是一层。下沿算到它画出来的子块的最后(它名下的子层带 data-owner,挂在子行上的子块那层在卡片里面)。
* 只留可能钉住的:上沿在钉住的那几行(高 band)以内或更高、下沿还在筛选栏下沿以下
*/
function measureScopes(root: HTMLElement, band: number): StickyScope[] {
const bottoms = new Map<string, number>()
for (const level of root.querySelectorAll<HTMLElement>('.cr-level[data-owner]')) {
const owner = level.dataset.owner ?? ''
bottoms.set(owner, Math.max(bottoms.get(owner) ?? Number.NEGATIVE_INFINITY, level.getBoundingClientRect().bottom))
}
const base = pinnedHeight()
const scopes: StickyScope[] = []
for (const card of root.querySelectorAll<HTMLElement>('.cr-block[data-line]:not([data-editing])')) {
const id = card.dataset.id ?? ''
const rect = card.getBoundingClientRect()
const top = rect.top - base
const bottom = Math.max(rect.bottom, bottoms.get(id) ?? rect.bottom) - base
if (top < band && bottom >= 0)
scopes.push({ id, depth: depthOf(card), top, bottom, left: rect.left, right: rect.right })
}
return scopes
return lineLimit(window.innerHeight - pageLayout.pinnedHeight(), STICKY_LINE_HEIGHT, maxLineCount)
}
/** VS Code 的 Sticky Scroll 设置:扩展端 ready 后先于第一份文档发来,设置改了再发;收到之前不钉。 */
@@ -58,7 +26,7 @@ export function useStickySettings(): StickyScrollSettings | undefined {
/**
* 眼下 content 里钉着哪几张卡片(见 pinnedLines),最多 maxLineCount 行(窗口矮时更少,见 lineLimit)。滚动、窗口大小变了、内容的大小变了、用它的组件重画了(页面状态变了)时排一次,
* 每帧最多量一次、算一次(requestAnimationFrame);钉的没变不更新。只有用它的组件跟着重画,块树不动。
* 每帧最多算一次(requestAnimationFrame),卡片的位置用 pageLayout 量过记着的,滚动时不重量;钉的没变不更新。只有用它的组件跟着重画,块树不动。
*/
export function useStickyLines(content: RefObject<HTMLElement | null>, maxLineCount: number): PinnedLines {
const [lines, setLines] = useState(NO_PINNED)
@@ -68,9 +36,8 @@ export function useStickyLines(content: RefObject<HTMLElement | null>, maxLineCo
let frame = 0
const update = (): void => {
frame = 0
const root = content.current
const limit = limitOf(maxLineCount)
const next = root ? pinnedLines(measureScopes(root, limit * STICKY_LINE_HEIGHT), STICKY_LINE_HEIGHT, limit) : NO_PINNED
const next = content.current ? pinnedLines(pageLayout.stickyScopes(limit * STICKY_LINE_HEIGHT), STICKY_LINE_HEIGHT, limit) : NO_PINNED
setLines(prev => samePinned(prev, next) ? prev : next)
}
schedule.current = () => {