diff --git a/vscode/webview-ui/src/codeReader/App.tsx b/vscode/webview-ui/src/codeReader/App.tsx index 8ea4c17..6bdee0a 100644 --- a/vscode/webview-ui/src/codeReader/App.tsx +++ b/vscode/webview-ui/src/codeReader/App.tsx @@ -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(null) + /** 页面内容挂上时,卡片的位置从它量(pageLayout):筛选栏和它在同一次提交里画出来 */ + const attachContent = useCallback((el: HTMLDivElement | null) => { + content.current = el + return el ? pageLayout.attach(el, document.querySelector('.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 && } -
+
{qaAvailable && (
这个文件里没有代码。
: doc.blocks.some(block => !filter.hidden.has(block.id)) - ? + ? :
所有块都被筛选隐藏了。
}
{sticky?.enabled && } diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx index ce5c2f1..e113066 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -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 ( - + ) } @@ -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 && tree.onOpenRun(block.id)} />} {shape.shown && ( - <> + {/* 输入框、展开状态跟着块的记号走,文档刷新块 id 变了也不丢 */} {qa && ask.chat && ( @@ -333,20 +337,27 @@ const BlockItem = memo(({ block }: { block: BlockView }): ReactElement => { /> )} {shape.open && } - + )} ) }) BlockItem.displayName = 'BlockItem' +/** 顶层一块的外层(见 BlockTree 的 items);不套时原样 */ +function Item({ item, line, children }: { item: boolean, line: number, children: ReactNode }): ReactElement { + if (!item) + return <>{children} + return
{children}
+} + /** * 一层代码块:左边一条竖线标出这一层,每块的子块紧跟在它下面、再往右缩进一层。被筛掉、被盖住的块不画,不留空位,竖线跟着变短。 * 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 => ( - + {removedAfter(block.id)} ))} diff --git a/vscode/webview-ui/src/codeReader/StickyLines.tsx b/vscode/webview-ui/src/codeReader/StickyLines.tsx index ce9bde2..e0255ec 100644 --- a/vscode/webview-ui/src/codeReader/StickyLines.tsx +++ b/vscode/webview-ui/src/codeReader/StickyLines.tsx @@ -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(`.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) } diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index facebc8..060a0db 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -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 { diff --git a/vscode/webview-ui/src/codeReader/occurrences.test.ts b/vscode/webview-ui/src/codeReader/occurrences.test.ts index 0b2db0f..fbff43b 100644 --- a/vscode/webview-ui/src/codeReader/occurrences.test.ts +++ b/vscode/webview-ui/src/codeReader/occurrences.test.ts @@ -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;带了别的修饰键不算', () => { diff --git a/vscode/webview-ui/src/codeReader/occurrences.ts b/vscode/webview-ui/src/codeReader/occurrences.ts index d2b57e4..9227634 100644 --- a/vscode/webview-ui/src/codeReader/occurrences.ts +++ b/vscode/webview-ui/src/codeReader/occurrences.ts @@ -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 diff --git a/vscode/webview-ui/src/codeReader/pageLayout.test.ts b/vscode/webview-ui/src/codeReader/pageLayout.test.ts new file mode 100644 index 0000000..658ceeb --- /dev/null +++ b/vscode/webview-ui/src/codeReader/pageLayout.test.ts @@ -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(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 = {}, ...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) + }) +}) diff --git a/vscode/webview-ui/src/codeReader/pageLayout.ts b/vscode/webview-ui/src/codeReader/pageLayout.ts new file mode 100644 index 0000000..fc9ceaf --- /dev/null +++ b/vscode/webview-ui/src/codeReader/pageLayout.ts @@ -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() + for (const level of item.querySelectorAll('.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('.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() + 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() diff --git a/vscode/webview-ui/src/codeReader/useChangeNav.ts b/vscode/webview-ui/src/codeReader/useChangeNav.ts index bd192db..2e6d78e 100644 --- a/vscode/webview-ui/src/codeReader/useChangeNav.ts +++ b/vscode/webview-ui/src/codeReader/useChangeNav.ts @@ -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(`.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]) diff --git a/vscode/webview-ui/src/codeReader/useOccurrences.ts b/vscode/webview-ui/src/codeReader/useOccurrences.ts index cd48b1e..18fa9aa 100644 --- a/vscode/webview-ui/src/codeReader/useOccurrences.ts +++ b/vscode/webview-ui/src/codeReader/useOccurrences.ts @@ -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)) } diff --git a/vscode/webview-ui/src/codeReader/useReveal.ts b/vscode/webview-ui/src/codeReader/useReveal.ts index 13613a6..89e0917 100644 --- a/vscode/webview-ui/src/codeReader/useReveal.ts +++ b/vscode/webview-ui/src/codeReader/useReveal.ts @@ -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 } diff --git a/vscode/webview-ui/src/codeReader/useScrollPosition.ts b/vscode/webview-ui/src/codeReader/useScrollPosition.ts index 9e283c7..7c2cd1c 100644 --- a/vscode/webview-ui/src/codeReader/useScrollPosition.ts +++ b/vscode/webview-ui/src/codeReader/useScrollPosition.ts @@ -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('.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('.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]) diff --git a/vscode/webview-ui/src/codeReader/useStickyScroll.ts b/vscode/webview-ui/src/codeReader/useStickyScroll.ts index 3571020..71b1262 100644 --- a/vscode/webview-ui/src/codeReader/useStickyScroll.ts +++ b/vscode/webview-ui/src/codeReader/useStickyScroll.ts @@ -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() - for (const level of root.querySelectorAll('.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('.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, maxLineCount: number): PinnedLines { const [lines, setLines] = useState(NO_PINNED) @@ -68,9 +36,8 @@ export function useStickyLines(content: RefObject, 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 = () => {