⚡ perf(vscode): 代码阅读预览屏幕外的顶层块跳过样式、排版和绘制(content-visibility),没画过的按内容估的高度占位;跳到某一行、恢复滚动位置、跳到改动和定义前先把目标那一带照常排版再量,滚过去位置不变

This commit is contained in:
cruldra committed 2026-10-13 15:11:21 +11:00
1 parent ffb0f147e1
commit 8a5d3761fd
9 files changed
+201 -9

No files matched your search

@@ -13,6 +13,7 @@ import { BlockTools } from './BlockTools'
import { CodeEditor } from './CodeEditor'
import { DiffText } from './DiffText'
import { removedTitle } from './diffView'
import { estimatedHeight } from './itemHeight'
import { KindIcon } from './kindIcons'
import { PropertyGrid, PropertyItems, PropertyTable } from './PropertyGrid'
import { chatOf, EMPTY_CHAT, sameChat, unfinished } from './qa'
@@ -303,7 +304,7 @@ const BlockItem = memo(({ block, item }: { block: BlockView, item: boolean }): R
<>
{shape.run !== undefined && <UnchangedStrip count={shape.run} onOpen={() => tree.onOpenRun(block.id)} />}
{shape.shown && (
<Item item={item} line={block.startLine}>
<Item item={item} line={block.startLine} height={item ? estimatedHeight(block, shape.open) : 0}>
<BlockCard block={block} shape={shape} ask={ask} rewrite={rewriting} />
{/* 输入框、展开状态跟着块的记号走,文档刷新块 id 变了也不丢 */}
{qa && ask.chat && (
@@ -344,11 +345,14 @@ const BlockItem = memo(({ block, item }: { block: BlockView, item: boolean }): R
})
BlockItem.displayName = 'BlockItem'
/** 顶层一块的外层(见 BlockTree 的 items);不套时原样 */
function Item({ item, line, children }: { item: boolean, line: number, children: ReactNode }): ReactElement {
/**
* 顶层一块的外层(见 BlockTree 的 items);不套时原样。离视口远时浏览器跳过它里面的样式、排版和绘制(.cr-item 的 content-visibility),
* 没画过时按估的高度(height,见 estimatedHeight)占位,画过一次就记住真实高度。
*/
function Item({ item, line, height, children }: { item: boolean, line: number, height: number, children: ReactNode }): ReactElement {
if (!item)
return <>{children}</>
return <div className="cr-item flex flex-col gap-2" data-line={line}>{children}</div>
return <div className="cr-item flex flex-col gap-2" data-line={line} style={{ containIntrinsicBlockSize: `auto ${height}px` }}>{children}</div>
}
/**
@@ -75,6 +75,7 @@ export function StickyLines({ content, blocks, settings }: { content: RefObject<
const card = content.current?.querySelector<HTMLElement>(`.cr-block[data-id="${id}"]`)
if (!card)
return
pageLayout.settle(card)
window.scrollTo(0, card.getBoundingClientRect().top + window.scrollY - pageLayout.pinnedHeight() - stickyInset(card, settings))
flash(card)
}
@@ -72,6 +72,22 @@ body:is(.vscode-light, .vscode-high-contrast-light) [data-badge] { --kind: var(-
opacity: 0.4;
}
/*
* 页面上顶层的一块(卡片连同问答区、子块,BlockTree 的 items):离视口远时浏览器跳过它里面的样式、排版和绘制,大文件全展开时重画、滚动不用每次算整页;
* 没画过时按估的高度占位(行内的 contain-intrinsic-block-size,auto 是画过一次就记住真实高度)。跳过的内容照样能页内查找、Tab 到、选中。
* 它把里面的绘制关在自己的框里:往上伸出卡片的「新增」小标,靠上面多留的 8px(再用负的外边距抵掉,位置不变)照样画得出来;
* 类名弹层打开时这一块照常画(不关在框里),弹层才盖得住后面的块
*/
.cr-item {
content-visibility: auto;
padding-top: 8px;
margin-top: -8px;
}
/* 要滚过去的那一带(见 pageLayout.settle)、类名弹层打开着的那一块照常排版、绘制 */
.cr-item:is([data-settle], :has(.cr-classes-popup)) {
content-visibility: visible;
}
/* 层级竖线:悬停一个块时,它子层的竖线换成它的种类色,标出这个块管到哪里为止(块和子层中间隔着问答区也算) */
.cr-guide {
background: var(--vscode-editorIndentGuide-background1);
@@ -0,0 +1,44 @@
import type { BlockView } from './types'
import { describe, expect, it } from 'vitest'
import { estimatedHeight } from './itemHeight'
function block(id: string, kind: string, extra: Partial<BlockView> = {}): BlockView {
return {
id,
anchor: id,
kind,
startLine: 0,
endLine: 0,
code: '',
codeTokens: [],
attributes: { kind },
concepts: [],
children: [],
translation: [{ text: id, role: 'plain' }],
...extra,
} as BlockView
}
describe('顶层块没画过时按估的高度占位', () => {
const statement = block('0.0', 'statement')
const nested = block('0.1', 'function', { children: [block('0.1.0', 'statement')] })
const fn = block('0', 'function', {
properties: [
{ label: '名称', value: [{ text: 'load', role: 'function' }] },
{ label: '输入参数', value: [{ text: '2 个', role: 'hint' }], columns: ['名称', '类型'], items: [{ cells: [[], []] }, { cells: [[], []] }] },
],
children: [statement, nested],
} as Partial<BlockView>)
it('属性表按行数、表格按行数估;展开时加上子块,子块里按默认折叠(里面的函数收着)', () => {
const closed = estimatedHeight(fn, false)
expect(closed).toBeGreaterThan(estimatedHeight(statement, false) + 3 * 20)
const open = estimatedHeight(fn, true)
expect(open - closed).toBe(2 * 8 + estimatedHeight(statement, false) + estimatedHeight(nested, false))
})
it('挂在卡片子行上的子块默认收着,不算进去', () => {
const withSlot = { ...fn, children: [...fn.children, block('0.2', 'demo.method', { slot: { row: 1, item: 0 } })] }
expect(estimatedHeight(withSlot, true)).toBe(estimatedHeight(fn, true))
})
})
@@ -0,0 +1,63 @@
import type { BlockView, PreviewText, PropertyItem, PropertyRow } from './types'
import { foldedByDefault } from './viewState'
/** 一行着色的词的高(像素),同 .cr-code 的行高 */
const LINE = 20
/** 属性表里行与行之间的空隙 */
const ROW_GAP = 3
/** 卡片的上下内边距和边框 */
const CARD_CHROME = 18
/** 同一层里块与块、卡片与它的子层之间的空隙(gap-2) */
const GAP = 8
function textHeight(text: PreviewText | undefined): number {
if (!text)
return 0
return 'markdown' in text ? LINE * Math.max(1, text.markdown.split('\n').filter(line => line.trim() !== '').length) : LINE
}
function itemsHeight(items: readonly PropertyItem[] | undefined): number {
return (items ?? []).reduce((sum, item) => sum + LINE + 2 + itemsHeight(item.items) + (item.table ? tableHeight(item.table.items) : 0), 0)
}
/** 表格:表头一行,下面每个子行一行 */
function tableHeight(items: readonly PropertyItem[]): number {
return LINE + 4 + itemsHeight(items)
}
function rowsHeight(rows: readonly PropertyRow[]): number {
return rows.reduce((sum, row) => sum + textHeight(row.value) + ROW_GAP + (row.columns ? tableHeight(row.items ?? []) : itemsHeight(row.items)), 0)
}
/** 一张卡片大约多高:属性表按行数,一行译文按译文、拆开的各项、实参表、说明、几行属性 */
function cardHeight(block: BlockView): number {
const content = 'properties' in block
? rowsHeight(block.properties)
: textHeight(block.translation) + itemsHeight(block.items) + (block.table ? tableHeight(block.table.items) : 0) + textHeight(block.doc) + rowsHeight(block.rows ?? [])
return CARD_CHROME + Math.max(LINE, content)
}
const subtrees = new WeakMap<BlockView, number>()
/** 一块连同子块大约多高,子块按默认折叠(见 foldedByDefault);同一个块对象只算一次 */
function subtreeHeight(block: BlockView): number {
let height = subtrees.get(block)
if (height === undefined) {
height = cardHeight(block) + (foldedByDefault(block) ? 0 : childrenHeight(block))
subtrees.set(block, height)
}
return height
}
/** 子块那一层:挂在卡片子行上的(BlockSlot)画在卡片里,默认收着,不算 */
function childrenHeight(block: BlockView): number {
return block.children.reduce((sum, child) => child.slot ? sum : sum + GAP + subtreeHeight(child), 0)
}
/**
* 顶层一块(卡片连同展开着的子块)大约多高(像素):没画过的顶层块按它占位(content-visibility),滚动条和远处块的位置先按它算,
* 画过一次就换成记住的真实高度。open 是它的子块那一层画不画,子块里面按默认折叠估。
*/
export function estimatedHeight(block: BlockView, open: boolean): number {
return cardHeight(block) + (open ? childrenHeight(block) : 0)
}
@@ -97,4 +97,15 @@ describe('卡片位置量过就记着', () => {
layout.anchorAt(0)
expect(reads).toBe(first * 2)
})
it('要滚过去的:目标所在的顶层块连同上下几屏之内的先照常排版,过两帧放回去', async () => {
const { root } = page()
const layout = new PageLayout()
layout.attach(root, null)
const items = [...root.querySelectorAll('.cr-item')]
expect(layout.cardForLine(40)?.line).toBe(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)
})
})
+55 -4
View File
@@ -156,18 +156,69 @@ export class PageLayout {
return anchorAt(items.slice(index, index + 2).flatMap(item => this.cardsOf(item)), scrollTop)
}
/** 包含这一行的块(同 blockForLine):顶层块按起始行排,先找到它所在的那段,只量那一段。 */
/** 包含这一行的块(同 blockForLine),要滚过去的:顶层块按起始行排,先找到它所在的那段,把那一带先排版好(见 settle)再量。 */
cardForLine(line: number): BlockBox | undefined {
const items = this.itemBoxes()
const items = this.settledAt(line)
return items.length === 0 ? undefined : blockForLine(this.cardsOf(items[itemOfLine(items, line)]), line)
}
/** 恢复时视口顶部要在的位置(同 scrollTopFor):起始行是 anchor.line 的块只会在它所在的那段里。 */
/** 恢复时视口顶部要在的位置(同 scrollTopFor):起始行是 anchor.line 的块只会在它所在的那段里,那一带先排版好(见 settle)再量。 */
topFor(anchor: ScrollAnchor): number {
const items = this.itemBoxes()
const items = this.settledAt(anchor.line)
return items.length === 0 ? anchor.scrollTop : scrollTopFor(this.cardsOf(items[itemOfLine(items, anchor.line)]), anchor)
}
/**
* 要滚到 el 那里之前调:它所在的顶层块连同上面一屏半、下面两屏半之内的顶层块先照常排版,不按估的高度占位(.cr-item[data-settle])。
* 不然滚过去以后浏览器才画出视口附近没画过的块(content-visibility),它们换成真实高度会把目标顶走;先画出来,量出来的位置滚过去就不再变。
* 过两帧(浏览器记下它们的真实高度以后)放回去,离视口远的照旧跳过、按记住的高度占位。
*/
settle(el: Element): void {
const item = el.closest('.cr-item')
const index = this.itemBoxes().findIndex(box => box.el === item)
if (index >= 0)
this.settleAround(index)
}
/** line 所在的那段顶层块那一带先排版好(见 settle),交回排好以后重量的顶层块 */
private settledAt(line: number): ItemBox[] {
const items = this.itemBoxes()
if (items.length > 0)
this.settleAround(itemOfLine(items, line))
return this.itemBoxes()
}
private settling: HTMLElement[] = []
private release = 0
private settleAround(index: number): void {
const items = this.itemBoxes()
const view = window.innerHeight
const settle = (box: ItemBox): void => {
box.el.setAttribute('data-settle', '')
this.settling.push(box.el)
}
settle(items[index])
for (let i = index - 1, above = 0; i >= 0 && above < 1.5 * view; i--) {
settle(items[i])
above += items[i].bottom - items[i].top
}
for (let i = index + 1, below = 0; i < items.length && below < 2.5 * view; i++) {
settle(items[i])
below += items[i].bottom - items[i].top
}
this.invalidate()
// 又要滚到别处时从头再数两帧
cancelAnimationFrame(this.release)
this.release = requestAnimationFrame(() => {
this.release = requestAnimationFrame(() => {
for (const el of this.settling)
el.removeAttribute('data-settle')
this.settling = []
})
})
}
/**
* 能钉住的卡片,按页面先后,竖直坐标以筛选栏下沿为 0:画出来了、不在编辑的(折叠着的、没有子块的也算)。
* 只留可能钉住的:上沿在钉住的那几行(高 band)以内或更高、下沿(连同子块)还在筛选栏下沿以下。只看和这一段相交的顶层块。
@@ -32,6 +32,7 @@ export function useChangeNav(stops: readonly ChangeStop[], collapsed: ReadonlySe
}
const card = document.querySelector<HTMLElement>(`.cr-block[data-id="${target.id}"]`)
if (card) {
pageLayout.settle(card)
window.scrollTo(0, card.getBoundingClientRect().top + window.scrollY - pageLayout.pinnedHeight() - stickyInset(card, sticky) - MARGIN)
flash(card)
}
@@ -24,8 +24,9 @@ export function flash(el: HTMLElement): void {
el.setAttribute('data-flash', '')
}
/** 把 el 滚进钉住的筛选栏和卡片下面的视口。已经整个看得见就不滚。beforeScroll 在真正滚动前拿到将要落到的位置。 */
/** 把 el 滚进钉住的筛选栏和卡片下面的视口,量之前先把它那一带排版好(pageLayout.settle)。已经整个看得见就不滚。beforeScroll 在真正滚动前拿到将要落到的位置。 */
export function showInViewport(el: HTMLElement, beforeScroll?: (position: ScrollPosition) => void): void {
pageLayout.settle(el)
const rect = el.getBoundingClientRect()
const pinned = pageLayout.pinnedHeight() + stickyHeight()
const top = scrollTopToShow(