⚡ perf(vscode): 代码阅读预览屏幕外的顶层块跳过样式、排版和绘制(content-visibility),没画过的按内容估的高度占位;跳到某一行、恢复滚动位置、跳到改动和定义前先把目标那一带照常排版再量,滚过去位置不变
This commit is contained in:
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 { CodeEditor } from './CodeEditor'
|
||||||
import { DiffText } from './DiffText'
|
import { DiffText } from './DiffText'
|
||||||
import { removedTitle } from './diffView'
|
import { removedTitle } from './diffView'
|
||||||
|
import { estimatedHeight } from './itemHeight'
|
||||||
import { KindIcon } from './kindIcons'
|
import { KindIcon } from './kindIcons'
|
||||||
import { PropertyGrid, PropertyItems, PropertyTable } from './PropertyGrid'
|
import { PropertyGrid, PropertyItems, PropertyTable } from './PropertyGrid'
|
||||||
import { chatOf, EMPTY_CHAT, sameChat, unfinished } from './qa'
|
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.run !== undefined && <UnchangedStrip count={shape.run} onOpen={() => tree.onOpenRun(block.id)} />}
|
||||||
{shape.shown && (
|
{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} />
|
<BlockCard block={block} shape={shape} ask={ask} rewrite={rewriting} />
|
||||||
{/* 输入框、展开状态跟着块的记号走,文档刷新块 id 变了也不丢 */}
|
{/* 输入框、展开状态跟着块的记号走,文档刷新块 id 变了也不丢 */}
|
||||||
{qa && ask.chat && (
|
{qa && ask.chat && (
|
||||||
@@ -344,11 +345,14 @@ const BlockItem = memo(({ block, item }: { block: BlockView, item: boolean }): R
|
|||||||
})
|
})
|
||||||
BlockItem.displayName = 'BlockItem'
|
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)
|
if (!item)
|
||||||
return <>{children}</>
|
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}"]`)
|
const card = content.current?.querySelector<HTMLElement>(`.cr-block[data-id="${id}"]`)
|
||||||
if (!card)
|
if (!card)
|
||||||
return
|
return
|
||||||
|
pageLayout.settle(card)
|
||||||
window.scrollTo(0, card.getBoundingClientRect().top + window.scrollY - pageLayout.pinnedHeight() - stickyInset(card, settings))
|
window.scrollTo(0, card.getBoundingClientRect().top + window.scrollY - pageLayout.pinnedHeight() - stickyInset(card, settings))
|
||||||
flash(card)
|
flash(card)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,22 @@ body:is(.vscode-light, .vscode-high-contrast-light) [data-badge] { --kind: var(-
|
|||||||
opacity: 0.4;
|
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 {
|
.cr-guide {
|
||||||
background: var(--vscode-editorIndentGuide-background1);
|
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)
|
layout.anchorAt(0)
|
||||||
expect(reads).toBe(first * 2)
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -156,18 +156,69 @@ export class PageLayout {
|
|||||||
return anchorAt(items.slice(index, index + 2).flatMap(item => this.cardsOf(item)), scrollTop)
|
return anchorAt(items.slice(index, index + 2).flatMap(item => this.cardsOf(item)), scrollTop)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 包含这一行的块(同 blockForLine):顶层块按起始行排,先找到它所在的那段,只量那一段。 */
|
/** 包含这一行的块(同 blockForLine),要滚过去的:顶层块按起始行排,先找到它所在的那段,把那一带先排版好(见 settle)再量。 */
|
||||||
cardForLine(line: number): BlockBox | undefined {
|
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)
|
return items.length === 0 ? undefined : blockForLine(this.cardsOf(items[itemOfLine(items, line)]), line)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 恢复时视口顶部要在的位置(同 scrollTopFor):起始行是 anchor.line 的块只会在它所在的那段里。 */
|
/** 恢复时视口顶部要在的位置(同 scrollTopFor):起始行是 anchor.line 的块只会在它所在的那段里,那一带先排版好(见 settle)再量。 */
|
||||||
topFor(anchor: ScrollAnchor): number {
|
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)
|
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:画出来了、不在编辑的(折叠着的、没有子块的也算)。
|
* 能钉住的卡片,按页面先后,竖直坐标以筛选栏下沿为 0:画出来了、不在编辑的(折叠着的、没有子块的也算)。
|
||||||
* 只留可能钉住的:上沿在钉住的那几行(高 band)以内或更高、下沿(连同子块)还在筛选栏下沿以下。只看和这一段相交的顶层块。
|
* 只留可能钉住的:上沿在钉住的那几行(高 band)以内或更高、下沿(连同子块)还在筛选栏下沿以下。只看和这一段相交的顶层块。
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export function useChangeNav(stops: readonly ChangeStop[], collapsed: ReadonlySe
|
|||||||
}
|
}
|
||||||
const card = document.querySelector<HTMLElement>(`.cr-block[data-id="${target.id}"]`)
|
const card = document.querySelector<HTMLElement>(`.cr-block[data-id="${target.id}"]`)
|
||||||
if (card) {
|
if (card) {
|
||||||
|
pageLayout.settle(card)
|
||||||
window.scrollTo(0, card.getBoundingClientRect().top + window.scrollY - pageLayout.pinnedHeight() - stickyInset(card, sticky) - MARGIN)
|
window.scrollTo(0, card.getBoundingClientRect().top + window.scrollY - pageLayout.pinnedHeight() - stickyInset(card, sticky) - MARGIN)
|
||||||
flash(card)
|
flash(card)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,8 +24,9 @@ export function flash(el: HTMLElement): void {
|
|||||||
el.setAttribute('data-flash', '')
|
el.setAttribute('data-flash', '')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 把 el 滚进钉住的筛选栏和卡片下面的视口。已经整个看得见就不滚。beforeScroll 在真正滚动前拿到将要落到的位置。 */
|
/** 把 el 滚进钉住的筛选栏和卡片下面的视口,量之前先把它那一带排版好(pageLayout.settle)。已经整个看得见就不滚。beforeScroll 在真正滚动前拿到将要落到的位置。 */
|
||||||
export function showInViewport(el: HTMLElement, beforeScroll?: (position: ScrollPosition) => void): void {
|
export function showInViewport(el: HTMLElement, beforeScroll?: (position: ScrollPosition) => void): void {
|
||||||
|
pageLayout.settle(el)
|
||||||
const rect = el.getBoundingClientRect()
|
const rect = el.getBoundingClientRect()
|
||||||
const pinned = pageLayout.pinnedHeight() + stickyHeight()
|
const pinned = pageLayout.pinnedHeight() + stickyHeight()
|
||||||
const top = scrollTopToShow(
|
const top = scrollTopToShow(
|
||||||
|
|||||||
Reference in new issue
Block a user