From 8a5d3761fd29792d20e113a34afb8d7d0e0cb9ca Mon Sep 17 00:00:00 2001 From: cruldra Date: Tue, 13 Oct 2026 15:11:21 +1100 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=20perf(vscode):=20=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E9=98=85=E8=AF=BB=E9=A2=84=E8=A7=88=E5=B1=8F=E5=B9=95=E5=A4=96?= =?UTF-8?q?=E7=9A=84=E9=A1=B6=E5=B1=82=E5=9D=97=E8=B7=B3=E8=BF=87=E6=A0=B7?= =?UTF-8?q?=E5=BC=8F=E3=80=81=E6=8E=92=E7=89=88=E5=92=8C=E7=BB=98=E5=88=B6?= =?UTF-8?q?=EF=BC=88content-visibility=EF=BC=89=EF=BC=8C=E6=B2=A1=E7=94=BB?= =?UTF-8?q?=E8=BF=87=E7=9A=84=E6=8C=89=E5=86=85=E5=AE=B9=E4=BC=B0=E7=9A=84?= =?UTF-8?q?=E9=AB=98=E5=BA=A6=E5=8D=A0=E4=BD=8D=EF=BC=9B=E8=B7=B3=E5=88=B0?= =?UTF-8?q?=E6=9F=90=E4=B8=80=E8=A1=8C=E3=80=81=E6=81=A2=E5=A4=8D=E6=BB=9A?= =?UTF-8?q?=E5=8A=A8=E4=BD=8D=E7=BD=AE=E3=80=81=E8=B7=B3=E5=88=B0=E6=94=B9?= =?UTF-8?q?=E5=8A=A8=E5=92=8C=E5=AE=9A=E4=B9=89=E5=89=8D=E5=85=88=E6=8A=8A?= =?UTF-8?q?=E7=9B=AE=E6=A0=87=E9=82=A3=E4=B8=80=E5=B8=A6=E7=85=A7=E5=B8=B8?= =?UTF-8?q?=E6=8E=92=E7=89=88=E5=86=8D=E9=87=8F=EF=BC=8C=E6=BB=9A=E8=BF=87?= =?UTF-8?q?=E5=8E=BB=E4=BD=8D=E7=BD=AE=E4=B8=8D=E5=8F=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../webview-ui/src/codeReader/BlockTree.tsx | 12 ++-- .../webview-ui/src/codeReader/StickyLines.tsx | 1 + .../webview-ui/src/codeReader/codeReader.css | 16 +++++ .../src/codeReader/itemHeight.test.ts | 44 +++++++++++++ .../webview-ui/src/codeReader/itemHeight.ts | 63 +++++++++++++++++++ .../src/codeReader/pageLayout.test.ts | 11 ++++ .../webview-ui/src/codeReader/pageLayout.ts | 59 +++++++++++++++-- .../webview-ui/src/codeReader/useChangeNav.ts | 1 + vscode/webview-ui/src/codeReader/useReveal.ts | 3 +- 9 files changed, 201 insertions(+), 9 deletions(-) create mode 100644 vscode/webview-ui/src/codeReader/itemHeight.test.ts create mode 100644 vscode/webview-ui/src/codeReader/itemHeight.ts diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx index e113066..3db07e5 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -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 && tree.onOpenRun(block.id)} />} {shape.shown && ( - + {/* 输入框、展开状态跟着块的记号走,文档刷新块 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
{children}
+ return
{children}
} /** diff --git a/vscode/webview-ui/src/codeReader/StickyLines.tsx b/vscode/webview-ui/src/codeReader/StickyLines.tsx index e0255ec..fd0adb7 100644 --- a/vscode/webview-ui/src/codeReader/StickyLines.tsx +++ b/vscode/webview-ui/src/codeReader/StickyLines.tsx @@ -75,6 +75,7 @@ export function StickyLines({ content, blocks, settings }: { content: RefObject< const card = content.current?.querySelector(`.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) } diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index 060a0db..feccf5d 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -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); diff --git a/vscode/webview-ui/src/codeReader/itemHeight.test.ts b/vscode/webview-ui/src/codeReader/itemHeight.test.ts new file mode 100644 index 0000000..254b76d --- /dev/null +++ b/vscode/webview-ui/src/codeReader/itemHeight.test.ts @@ -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 { + 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) + + 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)) + }) +}) diff --git a/vscode/webview-ui/src/codeReader/itemHeight.ts b/vscode/webview-ui/src/codeReader/itemHeight.ts new file mode 100644 index 0000000..a70df55 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/itemHeight.ts @@ -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() + +/** 一块连同子块大约多高,子块按默认折叠(见 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) +} diff --git a/vscode/webview-ui/src/codeReader/pageLayout.test.ts b/vscode/webview-ui/src/codeReader/pageLayout.test.ts index 658ceeb..c42d28a 100644 --- a/vscode/webview-ui/src/codeReader/pageLayout.test.ts +++ b/vscode/webview-ui/src/codeReader/pageLayout.test.ts @@ -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) + }) }) diff --git a/vscode/webview-ui/src/codeReader/pageLayout.ts b/vscode/webview-ui/src/codeReader/pageLayout.ts index fc9ceaf..66da1e1 100644 --- a/vscode/webview-ui/src/codeReader/pageLayout.ts +++ b/vscode/webview-ui/src/codeReader/pageLayout.ts @@ -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)以内或更高、下沿(连同子块)还在筛选栏下沿以下。只看和这一段相交的顶层块。 diff --git a/vscode/webview-ui/src/codeReader/useChangeNav.ts b/vscode/webview-ui/src/codeReader/useChangeNav.ts index 2e6d78e..8141a8e 100644 --- a/vscode/webview-ui/src/codeReader/useChangeNav.ts +++ b/vscode/webview-ui/src/codeReader/useChangeNav.ts @@ -32,6 +32,7 @@ export function useChangeNav(stops: readonly ChangeStop[], collapsed: ReadonlySe } const card = document.querySelector(`.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) } diff --git a/vscode/webview-ui/src/codeReader/useReveal.ts b/vscode/webview-ui/src/codeReader/useReveal.ts index 89e0917..1c07c8b 100644 --- a/vscode/webview-ui/src/codeReader/useReveal.ts +++ b/vscode/webview-ui/src/codeReader/useReveal.ts @@ -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(