✨ feat(vscode): 代码阅读滚进卡片的子块时把外层卡片钉在顶上,同 VS Code 的 Sticky Scroll
This commit is contained in:
1 parent
5ce2e904f4
commit
eb3d1b90cd
17 files changed
+548
-31
No files matched your search
@@ -17,6 +17,7 @@ import { applyFilter, excludedBy, keptWhileEditing } from './filter'
|
||||
import { FilterBar } from './FilterBar'
|
||||
import { OverviewRuler } from './OverviewRuler'
|
||||
import { asksOf, historyOf, PATH_ASK_ANCHOR } from './qa'
|
||||
import { StickyLines } from './StickyLines'
|
||||
import { useChangeNav } from './useChangeNav'
|
||||
import { useDefinitionLinks } from './useDefinitionLinks'
|
||||
import { useDefinitionPeek } from './useDefinitionPeek'
|
||||
@@ -27,6 +28,7 @@ import { useRepoLinks } from './useRepoLinks'
|
||||
import { useReveal } from './useReveal'
|
||||
import { useRewrite } from './useRewrite'
|
||||
import { useScrollPosition } from './useScrollPosition'
|
||||
import { useStickySettings } from './useStickyScroll'
|
||||
import { collapsedIds } from './viewState'
|
||||
import { onMessage, postMessage, viewState } from './vscode'
|
||||
|
||||
@@ -62,6 +64,8 @@ export function App(): ReactElement | null {
|
||||
const [diff, setDiff] = useState<DocumentDiff | null>(null)
|
||||
/** 收到消息时读眼下哪些块在编辑(消息回调只在挂载时注册一次) */
|
||||
const editing = useRef(view.editing)
|
||||
/** 块树所在的页面内容:钉住外层卡片时在这里面量 */
|
||||
const content = useRef<HTMLDivElement>(null)
|
||||
// 解析失败的文档没有块,不标差异;删掉的块按块筛选同样藏
|
||||
const changes = useMemo(() => doc && doc.error === undefined
|
||||
? diffTree(doc.blocks, diff, doc.version, removed => !excludedBy(removed.block, excluded))
|
||||
@@ -91,9 +95,10 @@ export function App(): ReactElement | null {
|
||||
useReveal(doc, reveal, collapsed, unshown, covered, show, expand, revealed)
|
||||
useDefinitionLinks(doc?.version)
|
||||
const peek = useDefinitionPeek(doc?.version)
|
||||
const sticky = useStickySettings()
|
||||
useRepoLinks()
|
||||
useRepoImages()
|
||||
const nav = useChangeNav(stops, collapsed, expand)
|
||||
const nav = useChangeNav(stops, collapsed, expand, sticky)
|
||||
const occurrencePage = useMemo((): OccurrencePage | undefined => doc
|
||||
? {
|
||||
blocks: doc.blocks,
|
||||
@@ -202,7 +207,7 @@ export function App(): ReactElement | null {
|
||||
)}
|
||||
/>
|
||||
{changes && diff && <OverviewRuler stops={stops} lineCount={diff.lineCount} onPick={nav.goTo} />}
|
||||
<div className="mx-auto box-content max-w-[880px] px-6 py-5">
|
||||
<div ref={content} className="mx-auto box-content max-w-[880px] px-6 py-5">
|
||||
{qa.state.available && (
|
||||
<div className="mb-4">
|
||||
<AskThread
|
||||
@@ -241,6 +246,7 @@ export function App(): ReactElement | null {
|
||||
? <BlockLevel blocks={doc.blocks} tree={tree} />
|
||||
: <div className="text-[var(--vscode-descriptionForeground)]">所有块都被筛选隐藏了。</div>}
|
||||
</div>
|
||||
{sticky?.enabled && <StickyLines content={content} blocks={doc.blocks} settings={sticky} />}
|
||||
<DefinitionPeekPopup shown={peek.shown} onJump={peek.jump} />
|
||||
</DisplayNameContext>
|
||||
</HighlightContext>
|
||||
|
||||
@@ -99,4 +99,9 @@ describe('挂在卡片子行上的子块', () => {
|
||||
expect(render({ '0.0': false })).toContain('启动服务')
|
||||
expect(render({ '0.0': false, '0/p1': true })).not.toContain('启动服务')
|
||||
})
|
||||
|
||||
it('画出来的子层记着它是哪张卡片的(钉住外层卡片时按它认):子行下面那层、卡片后面那层都是;收着时没有', () => {
|
||||
expect(render({})).not.toContain('data-owner')
|
||||
expect(render({ '0.0': false, '0': false }).match(/data-owner="0"/g)).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -129,7 +129,7 @@ function kindProps(block: BlockView | undefined): { 'data-kind'?: string, 'data-
|
||||
|
||||
/**
|
||||
* 一层代码块:左边一条竖线标出这一层,每块的子块紧跟在它下面、再往右缩进一层。被筛掉、被盖住的块不画,不留空位,竖线跟着变短。
|
||||
* owner 是这一层所属的块(顶层没有),悬停它时竖线换成它的种类色。
|
||||
* owner 是这一层所属的块(顶层没有,记在 data-owner 上,钉住外层卡片时按它认哪张卡片的子块画出来了),悬停它时竖线换成它的种类色。
|
||||
* 标差异时每张卡片左边外侧一条改动色条,删掉的块收成一行放回原来的位置(见 RemovedCard);「只看改动」时连着没变的块折成一条(UnchangedStrip)。
|
||||
* slotted:这一层是画在卡片属性表某一行下面的子块(BlockSlot),blocks 只有那几块。
|
||||
*/
|
||||
@@ -146,7 +146,7 @@ export function BlockLevel({ blocks, owner, tree, slotted = false }: { blocks: B
|
||||
.filter(item => item.after !== undefined && !listedIds.has(item.after))
|
||||
.map(item => <RemovedCard key={item.id} placed={item} tree={tree} />)
|
||||
return (
|
||||
<div className="cr-level flex gap-2" {...kindProps(owner)}>
|
||||
<div className="cr-level flex gap-2" {...kindProps(owner)} data-owner={owner?.id}>
|
||||
<div className="flex w-3 shrink-0 justify-center">
|
||||
<div className="cr-guide w-px" />
|
||||
</div>
|
||||
|
||||
@@ -1,26 +1,11 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { ChangeStop } from './diffView'
|
||||
import { useLayoutEffect, useState } from 'react'
|
||||
import { usePinnedHeight } from './useScrollPosition'
|
||||
|
||||
const CHANGE_NAMES = { add: '新增', mod: '修改', del: '删除' } as const
|
||||
|
||||
/** 钉在预览顶部的筛选栏有多高(它换行、多出第二行时跟着变):总览条从它下面开始。 */
|
||||
function usePinnedHeight(): number {
|
||||
const [height, setHeight] = useState(0)
|
||||
useLayoutEffect(() => {
|
||||
const bar = document.querySelector<HTMLElement>('.cr-filter-bar')
|
||||
if (!bar)
|
||||
return
|
||||
setHeight(bar.offsetHeight)
|
||||
const observer = new ResizeObserver(() => setHeight(bar.offsetHeight))
|
||||
observer.observe(bar)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
return height
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览右边缘的总览条:每处改动(见 ChangeStop)按它在文件里的行画一段刻度,新增绿、修改蓝、删除红(删掉的块一条细线),
|
||||
* 预览右边缘的总览条:从筛选栏下面贴到底,每处改动(见 ChangeStop)按它在文件里的行画一段刻度,新增绿、修改蓝、删除红(删掉的块一条细线),
|
||||
* 点刻度跳过去。lineCount 是新文件一共几行。
|
||||
*/
|
||||
export function OverviewRuler({ stops, lineCount, onPick }: { stops: readonly ChangeStop[], lineCount: number, onPick: (stop: ChangeStop) => void }): ReactElement {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { BlockView } from './types'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DisplayNameContext, nameMap } from './displayNames'
|
||||
import { PinnedHead } from './StickyLines'
|
||||
|
||||
vi.mock('./vscode', () => ({ postMessage: () => {}, onMessage: () => () => {}, viewState: { get: () => ({ editing: [], folds: {} }), update: () => {} } }))
|
||||
|
||||
function block(kind: string, extra: Partial<BlockView>): BlockView {
|
||||
return {
|
||||
id: '2',
|
||||
anchor: '2',
|
||||
kind,
|
||||
startLine: 0,
|
||||
endLine: 0,
|
||||
code: '',
|
||||
codeTokens: [],
|
||||
attributes: { kind },
|
||||
concepts: [{ id: kind, label: kind, icon: 'square-function' }],
|
||||
children: [],
|
||||
translation: [],
|
||||
...extra,
|
||||
} as BlockView
|
||||
}
|
||||
|
||||
function render(view: BlockView): string {
|
||||
return renderToStaticMarkup(
|
||||
<DisplayNameContext value={nameMap([{ offset: 40, length: 10, name: '加载配置', definition: true }])}>
|
||||
<PinnedHead block={view} />
|
||||
</DisplayNameContext>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('钉住的一行:卡片的头', () => {
|
||||
it('属性表的卡片:概念图标,标题是第一行(名称)的值,中文名照卡片上的换', () => {
|
||||
const html = render(block('function', {
|
||||
properties: [
|
||||
{ label: '名称', value: [{ text: 'loadConfig', role: 'function', offset: 40 }] },
|
||||
{ label: '说明', value: { markdown: '读配置文件' } },
|
||||
],
|
||||
} as Partial<BlockView>))
|
||||
expect(html).toContain('aria-label="function"')
|
||||
expect(html).toContain('data-name="加载配置"')
|
||||
expect(html).toContain('<span class="cr-original">loadConfig</span>')
|
||||
expect(html).not.toContain('名称')
|
||||
expect(html).not.toContain('读配置文件')
|
||||
})
|
||||
|
||||
it('一行译文的块:标题是译文', () => {
|
||||
const html = render(block('loop', { translation: [{ text: '遍历 ', role: 'keyword' }, { text: 'items', role: 'variable' }] }))
|
||||
expect(html).toContain('cr-role-keyword')
|
||||
expect(html).toContain('items')
|
||||
})
|
||||
|
||||
it('说明是 Markdown:只取第一行', () => {
|
||||
const html = render(block('comment', { translation: { markdown: '第一行\n第二行' } }))
|
||||
expect(html).toContain('第一行')
|
||||
expect(html).not.toContain('第二行')
|
||||
})
|
||||
|
||||
it('没有翻译插件、属性表是空的:只有图标', () => {
|
||||
const html = render(block('class', { properties: [] } as Partial<BlockView>))
|
||||
expect(html).toContain('cr-kind')
|
||||
expect(html).toContain('<p class="cr-code cr-sticky-title min-w-0 flex-1"></p>')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { ReactElement, RefObject } from 'react'
|
||||
import type { BlockView, StickyScrollSettings } from './types'
|
||||
import { useLayoutEffect, useMemo } from 'react'
|
||||
import { KindIcon } from './kindIcons'
|
||||
import { SegmentText } from './SegmentText'
|
||||
import { HighlightContext } from './useOccurrences'
|
||||
import { flash } from './useReveal'
|
||||
import { pinnedHeight, usePinnedHeight } from './useScrollPosition'
|
||||
import { STICKY_LINE_HEIGHT, stickyInset, useStickyLines } from './useStickyScroll'
|
||||
|
||||
/** 块 id → 块,连同各层子块 */
|
||||
function blocksById(blocks: readonly BlockView[]): Map<string, BlockView> {
|
||||
const byId = new Map<string, BlockView>()
|
||||
const visit = (list: readonly BlockView[]): void => {
|
||||
for (const block of list) {
|
||||
byId.set(block.id, block)
|
||||
visit(block.children)
|
||||
}
|
||||
}
|
||||
visit(blocks)
|
||||
return byId
|
||||
}
|
||||
|
||||
/**
|
||||
* 钉住的一行里卡片的头:折叠箭头那格留空、概念图标、标题,各自的位置同卡片上的,一行放不下省略。
|
||||
* 标题同卡片上的写法(中文名照换):属性表的第一行(名称)的值,或一行译文;Markdown 只取第一行。
|
||||
*/
|
||||
export function PinnedHead({ block }: { block: BlockView }): ReactElement {
|
||||
const title = 'properties' in block ? block.properties[0]?.value : block.translation
|
||||
return (
|
||||
<div className="flex h-full min-w-0 items-center gap-2.5 pr-2.5 pl-[9px]">
|
||||
<span className="w-3.5 shrink-0" />
|
||||
<KindIcon concepts={block.concepts} />
|
||||
<p className="cr-code cr-sticky-title min-w-0 flex-1">
|
||||
{title && ('markdown' in title
|
||||
? <span className="cr-role-comment">{title.markdown.split('\n', 1)[0].trim()}</span>
|
||||
: <SegmentText segments={title} />)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 钉在顶上的外层卡片(同 VS Code 编辑器的 Sticky Scroll,设置也用它的):滚进一张卡片的子块、卡片自己的开头已经滚到筛选栏后面时,
|
||||
* 筛选栏下面钉一行这张卡片的头(PinnedHead),外层的在上、里层的往下摞,每行和那张卡片左右对齐(越里层越往右);
|
||||
* 这张卡片的子块快滚完时,最里那行被下面的内容顶上去、从上一行下面滑走。哪几张见 pinnedLines,只按页面上画出来的算:
|
||||
* 折叠着的、被筛掉的、正在编辑的卡片下面没有子块,不钉。点一行把那张卡片滚到顶上(外层还钉着的几行下面)、闪几下。
|
||||
* 钉着的名字不画页面上的高亮(页面上找当前那一处只认卡片里的);跳到定义、悬停看卡片照旧。
|
||||
* content 是块树所在的页面内容,blocks 是这份文档的块。
|
||||
*/
|
||||
export function StickyLines({ content, blocks, settings }: { content: RefObject<HTMLElement | null>, blocks: readonly BlockView[], settings: StickyScrollSettings }): ReactElement | null {
|
||||
const top = usePinnedHeight()
|
||||
const lines = useStickyLines(content, settings.maxLineCount)
|
||||
const byId = useMemo(() => blocksById(blocks), [blocks])
|
||||
const count = lines.scopes.length
|
||||
const height = count * STICKY_LINE_HEIGHT + lines.offset
|
||||
|
||||
// 浏览器自己滚的时候(输入框拿到焦点、打字跟着光标走)也让开筛选栏和钉着的几行:问 AI 的输入框、块的编辑框不被盖住
|
||||
useLayoutEffect(() => {
|
||||
const page = document.documentElement
|
||||
page.style.scrollPaddingTop = `${top + height}px`
|
||||
return () => {
|
||||
page.style.scrollPaddingTop = ''
|
||||
}
|
||||
}, [top, height])
|
||||
|
||||
if (count === 0)
|
||||
return null
|
||||
|
||||
const jump = (id: string): void => {
|
||||
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))
|
||||
flash(card)
|
||||
}
|
||||
|
||||
return (
|
||||
<HighlightContext value={undefined}>
|
||||
<div className="cr-sticky" style={{ top, height }}>
|
||||
{lines.scopes.map((scope, index) => {
|
||||
const block = byId.get(scope.id)
|
||||
const last = index === count - 1
|
||||
return block && (
|
||||
<div
|
||||
key={scope.id}
|
||||
className="cr-sticky-line"
|
||||
data-last={last ? '' : undefined}
|
||||
style={{ top: index * STICKY_LINE_HEIGHT + (last ? lines.offset : 0), height: STICKY_LINE_HEIGHT }}
|
||||
onClick={() => jump(scope.id)}
|
||||
>
|
||||
<div className="h-full" style={{ marginLeft: scope.left, width: scope.right - scope.left }}>
|
||||
<PinnedHead block={block} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</HighlightContext>
|
||||
)
|
||||
}
|
||||
@@ -1246,3 +1246,38 @@ del.cr-diff-word {
|
||||
.cr-ruler-tick:focus-visible {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
}
|
||||
|
||||
/*
|
||||
* 钉在顶上的外层卡片(StickyLines.tsx,同 VS Code 编辑器的 Sticky Scroll):从筛选栏下沿往下摞,底色、悬停色、下沿的阴影同编辑器的
|
||||
* (--vscode-editorStickyScroll*,主题没给时用编辑器底色、控件阴影)。最里那行被顶上去时从上一行下面滑走(层级低一级),整块跟着变矮。
|
||||
* 层级在页面内容之上,低于总览条(5)、筛选栏(10)和它的下拉菜单、定义卡片浮窗(40)、下拉列表和悬停提示(50)
|
||||
*/
|
||||
.cr-sticky {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: 4;
|
||||
overflow: hidden;
|
||||
background: var(--vscode-editorStickyScroll-background, var(--vscode-editor-background));
|
||||
box-shadow: 0 4px 2px -2px var(--vscode-editorStickyScroll-shadow, var(--vscode-widget-shadow, rgb(0 0 0 / 36%)));
|
||||
}
|
||||
.cr-sticky-line {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
background: var(--vscode-editorStickyScroll-background, var(--vscode-editor-background));
|
||||
cursor: pointer;
|
||||
}
|
||||
.cr-sticky-line[data-last] {
|
||||
z-index: 0;
|
||||
}
|
||||
.cr-sticky-line:hover {
|
||||
background: var(--vscode-editorStickyScrollHover-background, var(--vscode-list-hoverBackground));
|
||||
}
|
||||
/* 标题一行放不下省略;空格照源码留着 */
|
||||
.cr-sticky-title {
|
||||
overflow: hidden;
|
||||
white-space: pre;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { PinnedLines, StickyScope } from './stickyScroll'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { insetAbove, lineLimit, pinnedLines, samePinned } from './stickyScroll'
|
||||
|
||||
const LINE = 24
|
||||
|
||||
function scope(id: string, depth: number, top: number, bottom: number): StickyScope {
|
||||
return { id, depth, top, bottom, left: depth * 20, right: 880 }
|
||||
}
|
||||
|
||||
function ids(lines: PinnedLines): string[] {
|
||||
return lines.scopes.map(item => item.id)
|
||||
}
|
||||
|
||||
describe('pinnedLines:钉住哪几张卡片', () => {
|
||||
it('卡片上沿还没滚到筛选栏后面:不钉;滚过去一点就钉', () => {
|
||||
expect(ids(pinnedLines([scope('3', 0, 0, 500)], LINE, 5))).toEqual([])
|
||||
expect(ids(pinnedLines([scope('3', 0, -1, 500)], LINE, 5))).toEqual(['3'])
|
||||
})
|
||||
|
||||
it('套着的几层:外层在上、里层在下,各在自己那一行;开头还没滚过它那一行的不钉', () => {
|
||||
const scopes = [scope('2', 0, -300, 800), scope('2.1', 1, -100, 400), scope('2.1.0', 2, 60, 300)]
|
||||
expect(pinnedLines(scopes, LINE, 5)).toEqual({ scopes: scopes.slice(0, 2), offset: 0 })
|
||||
})
|
||||
|
||||
it('里层的按它自己那一行(第 depth 行)算:上沿高过这一行顶才钉', () => {
|
||||
expect(ids(pinnedLines([scope('2', 0, -300, 800), scope('2.1', 1, 30, 400)], LINE, 5))).toEqual(['2'])
|
||||
expect(ids(pinnedLines([scope('2', 0, -300, 800), scope('2.1', 1, 20, 400)], LINE, 5))).toEqual(['2', '2.1'])
|
||||
})
|
||||
|
||||
it('同一层前一张的子块已经滚完、后一张的开头还没到:只钉外层', () => {
|
||||
const scopes = [scope('1', 0, -500, 900), scope('1.0', 1, -400, 10), scope('1.1', 1, 30, 600)]
|
||||
expect(ids(pinnedLines(scopes, LINE, 5))).toEqual(['1'])
|
||||
})
|
||||
|
||||
it('子块快滚完:最里那行被下面的内容往上顶', () => {
|
||||
expect(pinnedLines([scope('1', 0, -500, 900), scope('1.0', 1, -200, 40)], LINE, 5).offset).toBe(-8)
|
||||
expect(pinnedLines([scope('1', 0, -100, 10)], LINE, 5)).toMatchObject({ offset: -14 })
|
||||
})
|
||||
|
||||
it('子块的下沿正好到这一行顶:还钉着,整行顶到上一行下面', () => {
|
||||
expect(pinnedLines([scope('1', 0, -500, 900), scope('1.0', 1, -200, 24)], LINE, 5)).toMatchObject({ offset: -24 })
|
||||
})
|
||||
|
||||
it('最后一张子块同时是外层的结尾:里层先被顶走,再轮到外层被顶', () => {
|
||||
const both = pinnedLines([scope('1', 0, -500, 30), scope('1.0', 1, -200, 30)], LINE, 5)
|
||||
expect(both).toMatchObject({ offset: -18 })
|
||||
expect(ids(both)).toEqual(['1', '1.0'])
|
||||
const outer = pinnedLines([scope('1', 0, -500, 20), scope('1.0', 1, -200, 20)], LINE, 5)
|
||||
expect(outer).toMatchObject({ offset: -4 })
|
||||
expect(ids(outer)).toEqual(['1'])
|
||||
})
|
||||
|
||||
it('比 limit 套得深:留外层的 limit 行,同 VS Code', () => {
|
||||
const scopes = [scope('0', 0, -900, 900), scope('0.0', 1, -800, 800), scope('0.0.0', 2, -700, 700), scope('0.0.0.0', 3, -600, 600)]
|
||||
expect(ids(pinnedLines(scopes, LINE, 2))).toEqual(['0', '0.0'])
|
||||
expect(ids(pinnedLines(scopes, LINE, 0))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('lineLimit:最多钉几行', () => {
|
||||
it('设置里的行数,且不超过可视区域的四分之一(四舍五入,同 VS Code)', () => {
|
||||
expect(lineLimit(800, LINE, 5)).toBe(5)
|
||||
expect(lineLimit(300, LINE, 5)).toBe(3)
|
||||
expect(lineLimit(40, LINE, 5)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('insetAbove:卡片滚到顶上时它上面钉着多高', () => {
|
||||
it('外层的卡片各占一行,最多 limit 行;最外层的上面什么都没有', () => {
|
||||
expect(insetAbove(0, LINE, 5)).toBe(0)
|
||||
expect(insetAbove(3, LINE, 5)).toBe(72)
|
||||
expect(insetAbove(7, LINE, 5)).toBe(120)
|
||||
})
|
||||
})
|
||||
|
||||
describe('samePinned:钉的变没变', () => {
|
||||
const lines = pinnedLines([scope('1', 0, -500, 900), scope('1.0', 1, -200, 400)], LINE, 5)
|
||||
|
||||
it('同几张、同样的左右位置、推得一样多:没变', () => {
|
||||
expect(samePinned(lines, pinnedLines([scope('1', 0, -510, 890), scope('1.0', 1, -210, 390)], LINE, 5))).toBe(true)
|
||||
})
|
||||
|
||||
it('换了一张、往上推了、左右挪了:变了', () => {
|
||||
expect(samePinned(lines, pinnedLines([scope('1', 0, -500, 900), scope('1.1', 1, -200, 400)], LINE, 5))).toBe(false)
|
||||
expect(samePinned(lines, pinnedLines([scope('1', 0, -500, 900), scope('1.0', 1, -200, 40)], LINE, 5))).toBe(false)
|
||||
expect(samePinned(lines, { ...lines, scopes: [lines.scopes[0], { ...lines.scopes[1], left: 0 }] })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 钉在顶上的外层卡片(同 VS Code 编辑器的 Sticky Scroll):滚进一张卡片的子块、卡片自己的开头已经滚出去时,顶上钉一行它的图标和标题,
|
||||
* 外层的在上、里层的往下摞,每行一样高。竖直坐标都以筛选栏下沿为 0、往下为正(像素)。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 页面上一张能钉住的卡片:子块画出来了的(折叠着的、被筛掉的、正在编辑的卡片下面没有)。
|
||||
* top 是卡片上沿,bottom 是它子块最后的下沿(卡片比子块还靠下时取卡片的);depth 是它外面套着几张卡片;left、right 是卡片左右边(视口里的 x)
|
||||
*/
|
||||
export interface StickyScope {
|
||||
id: string
|
||||
depth: number
|
||||
top: number
|
||||
bottom: number
|
||||
left: number
|
||||
right: number
|
||||
}
|
||||
|
||||
/** 眼下钉着的卡片,从外到里;offset 是最里那行被下面的内容往上顶了多少(≤ 0,它那张卡片快滚完时) */
|
||||
export interface PinnedLines {
|
||||
scopes: StickyScope[]
|
||||
offset: number
|
||||
}
|
||||
|
||||
export const NO_PINNED: PinnedLines = { scopes: [], offset: 0 }
|
||||
|
||||
/**
|
||||
* 钉住哪几张,照 VS Code 的 `StickyScrollController.findScrollWidgetState`:按页面先后(外层在前)看每张,它那一行在 depth × lineHeight;
|
||||
* 卡片上沿已经在这一行顶上面、子块的下沿还没过这一行顶时钉住。子块的下沿高过这一行的下沿时,这一行跟着往上推。
|
||||
* 钉满 limit 行就不再往里看:套得深时留外层的,同 VS Code。scopes 要按页面先后排。
|
||||
*/
|
||||
export function pinnedLines(scopes: readonly StickyScope[], lineHeight: number, limit: number): PinnedLines {
|
||||
const pinned: StickyScope[] = []
|
||||
let offset = 0
|
||||
for (const scope of scopes) {
|
||||
if (pinned.length >= limit)
|
||||
break
|
||||
const top = scope.depth * lineHeight
|
||||
if (top <= scope.top || top > scope.bottom)
|
||||
continue
|
||||
pinned.push(scope)
|
||||
offset = Math.min(0, scope.bottom - (top + lineHeight))
|
||||
}
|
||||
return { scopes: pinned, offset }
|
||||
}
|
||||
|
||||
/** 最多钉几行:设置里的 maxLineCount,且不超过可视区域(筛选栏下面,高 viewHeight)的四分之一,同 VS Code,窗口矮时不让钉住的行占满 */
|
||||
export function lineLimit(viewHeight: number, lineHeight: number, maxLineCount: number): number {
|
||||
return Math.min(maxLineCount, Math.round(viewHeight / lineHeight / 4))
|
||||
}
|
||||
|
||||
/** 一张卡片滚到顶上时,它上面钉着多高:外层的卡片都钉住,最多 limit 行;它自己在这几行下面露出来 */
|
||||
export function insetAbove(depth: number, lineHeight: number, limit: number): number {
|
||||
return Math.min(depth, limit) * lineHeight
|
||||
}
|
||||
|
||||
/** 两次算出来钉的一样(同几张、左右位置一样、推得一样多):一样就不重画 */
|
||||
export function samePinned(a: PinnedLines, b: PinnedLines): boolean {
|
||||
return a.offset === b.offset && a.scopes.length === b.scopes.length && a.scopes.every((scope, index) => {
|
||||
const other = b.scopes[index]
|
||||
return scope.id === other.id && scope.left === other.left && scope.right === other.right
|
||||
})
|
||||
}
|
||||
@@ -408,11 +408,21 @@ export interface DocumentDiff {
|
||||
lineCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* VS Code 的 Sticky Scroll 设置(editor.stickyScroll.enabled / editor.stickyScroll.maxLineCount):预览滚进一张卡片的子块时把它钉在顶上,
|
||||
* 外层的在上、里层的在下,最多钉 maxLineCount 行。
|
||||
*/
|
||||
export interface StickyScrollSettings {
|
||||
enabled: boolean
|
||||
maxLineCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal)
|
||||
* definitionProbed:id 那次 probeDefinition 的回答,found 表示查得到定义
|
||||
* definitionPeeked:id 那次 peekDefinition / peekLink 的回答。没有 peek(查不到定义、语言没有代码阅读、没有块盖住定义处、指着的就是定义本身)就不弹浮窗
|
||||
* hoverDelay:VS Code 的 editor.hover.delay / editor.hover.hidingDelay(毫秒),鼠标停多久弹定义卡片、移开后多久收;ready 后来一次,设置改了再来
|
||||
* stickyScroll:VS Code 的 Sticky Scroll 设置(按这份文件取,语言自己的设置优先),见 StickyScrollSettings;ready 后来一次,设置改了再来
|
||||
* occurrencesFound:id 那次 findOccurrences 的回答,语言扩展没答(或没装)就是空的
|
||||
* filter:整个工作区共用的块筛选和「只看改动」开关;ready 后先于第一份文档来一次,之后任何一个预览改了都会来
|
||||
* qa:这份文件的问答;ready 后来一次,问答、正在答的问题变了再来
|
||||
@@ -435,6 +445,7 @@ export type ExtensionToWebview
|
||||
| { type: 'definitionProbed', id: number, found: boolean }
|
||||
| { type: 'definitionPeeked', id: number, peek?: DefinitionPeek }
|
||||
| { type: 'hoverDelay', delay: number, hidingDelay: number }
|
||||
| ({ type: 'stickyScroll' } & StickyScrollSettings)
|
||||
| { type: 'occurrencesFound', id: number, occurrences: Occurrence[] }
|
||||
| { type: 'moveOccurrence', step: 1 | -1 }
|
||||
| { type: 'displayNames', version: number, names: ShownName[] }
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
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 { flash } from './useReveal'
|
||||
import { measureBlocks, pinnedHeight } from './useScrollPosition'
|
||||
import { stickyInset } from './useStickyScroll'
|
||||
|
||||
/** 跳到的那处离筛选栏下沿留多少像素。 */
|
||||
const MARGIN = 8
|
||||
|
||||
/**
|
||||
* 「上一处 / 下一处」和点总览条的刻度:跳到一处改动。它在折叠着的块里时先展开那几层(expand),渲染好了再把它滚到筛选栏下面、闪几下。
|
||||
* 「上一处 / 下一处」和点总览条的刻度:跳到一处改动。它在折叠着的块里时先展开那几层(expand),渲染好了再把它滚到筛选栏下面
|
||||
* (钉着外层卡片时在钉着的几行下面,sticky 是 VS Code 的 Sticky Scroll 设置)、闪几下。
|
||||
* 接着走从哪算:跳完之后页面没滚过就从刚跳到的那处接着走,用户自己滚过了就按视口顶上那块的行找(见 nextStop)。
|
||||
*/
|
||||
export function useChangeNav(stops: readonly ChangeStop[], collapsed: ReadonlySet<string>, expand: (ids: string[]) => void): {
|
||||
export function useChangeNav(stops: readonly ChangeStop[], collapsed: ReadonlySet<string>, expand: (ids: string[]) => void, sticky: StickyScrollSettings | undefined): {
|
||||
move: (step: 1 | -1) => void
|
||||
goTo: (stop: ChangeStop) => void
|
||||
} {
|
||||
@@ -30,12 +33,12 @@ 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() - MARGIN)
|
||||
window.scrollTo(0, card.getBoundingClientRect().top + window.scrollY - pinnedHeight() - stickyInset(card, sticky) - MARGIN)
|
||||
flash(card)
|
||||
}
|
||||
jumped.current = { id: target.id, scrollY: window.scrollY }
|
||||
setTarget(null)
|
||||
}, [target, collapsed, expand])
|
||||
}, [target, collapsed, expand, sticky])
|
||||
|
||||
const move = useCallback((step: 1 | -1) => {
|
||||
if (stops.length === 0)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { blockPath } from './filter'
|
||||
import { revealPlan, scrollTopToShow, uncoveredPlan } from './reveal'
|
||||
import { blockForLine } from './scrollPosition'
|
||||
import { measureBlocks, pinnedHeight } from './useScrollPosition'
|
||||
import { stickyHeight } from './useStickyScroll'
|
||||
|
||||
/** 让一块或属性表里的一行闪几下(codeReader.css 的 [data-flash]);正在闪的同一处再跳一次时从头闪。 */
|
||||
export function flash(el: HTMLElement): void {
|
||||
@@ -24,10 +25,10 @@ export function flash(el: HTMLElement): void {
|
||||
el.setAttribute('data-flash', '')
|
||||
}
|
||||
|
||||
/** 把 el 滚进钉住的筛选栏下面的视口。已经整个看得见就不滚。beforeScroll 在真正滚动前拿到将要落到的位置。 */
|
||||
/** 把 el 滚进钉住的筛选栏和外层卡片下面的视口。已经整个看得见就不滚。beforeScroll 在真正滚动前拿到将要落到的位置。 */
|
||||
export function showInViewport(el: HTMLElement, beforeScroll?: (position: ScrollPosition) => void): void {
|
||||
const rect = el.getBoundingClientRect()
|
||||
const pinned = pinnedHeight()
|
||||
const pinned = pinnedHeight() + stickyHeight()
|
||||
const top = scrollTopToShow(
|
||||
{ top: rect.top + window.scrollY, bottom: rect.bottom + window.scrollY },
|
||||
window.scrollY,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { BlockBox } from './scrollPosition'
|
||||
import type { DocumentMessage } from './types'
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react'
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { anchorAt, scrollTopFor } from './scrollPosition'
|
||||
import { postMessage, viewState } from './vscode'
|
||||
|
||||
@@ -12,6 +12,21 @@ export function pinnedHeight(): number {
|
||||
return document.querySelector<HTMLElement>('.cr-filter-bar')?.offsetHeight ?? 0
|
||||
}
|
||||
|
||||
/** 筛选栏有多高,跟着它变(换行、多出第二行时):钉在它下面的东西(总览条、钉住的外层卡片)从这儿开始。 */
|
||||
export function usePinnedHeight(): number {
|
||||
const [height, setHeight] = useState(0)
|
||||
useLayoutEffect(() => {
|
||||
const bar = document.querySelector<HTMLElement>('.cr-filter-bar')
|
||||
if (!bar)
|
||||
return
|
||||
setHeight(bar.offsetHeight)
|
||||
const observer = new ResizeObserver(() => setHeight(bar.offsetHeight))
|
||||
observer.observe(bar)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
return height
|
||||
}
|
||||
|
||||
/** 页面上每块(BlockCard 带 data-line)的位置,按页面上的先后;被筛掉的块不在页面上,不算。 */
|
||||
export function measureBlocks(): BlockBox[] {
|
||||
const y = window.scrollY
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { RefObject } from 'react'
|
||||
import type { PinnedLines, StickyScope } from './stickyScroll'
|
||||
import type { StickyScrollSettings } from './types'
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
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 里能钉住的卡片:画出了子层的那些(子层带 data-owner,挂在子行上的子块那层在卡片里面),按页面先后;竖直坐标以筛选栏下沿为 0 */
|
||||
function measureScopes(root: HTMLElement): 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-id]')) {
|
||||
const id = card.dataset.id ?? ''
|
||||
const levelBottom = bottoms.get(id)
|
||||
if (levelBottom === undefined)
|
||||
continue
|
||||
const rect = card.getBoundingClientRect()
|
||||
scopes.push({ id, depth: depthOf(card), top: rect.top - base, bottom: Math.max(rect.bottom, levelBottom) - base, left: rect.left, right: rect.right })
|
||||
}
|
||||
return scopes
|
||||
}
|
||||
|
||||
/** VS Code 的 Sticky Scroll 设置:扩展端 ready 后先于第一份文档发来,设置改了再发;收到之前不钉。 */
|
||||
export function useStickySettings(): StickyScrollSettings | undefined {
|
||||
const [settings, setSettings] = useState<StickyScrollSettings | undefined>()
|
||||
useEffect(() => onMessage((msg) => {
|
||||
if (msg.type === 'stickyScroll')
|
||||
setSettings({ enabled: msg.enabled, maxLineCount: msg.maxLineCount })
|
||||
}), [])
|
||||
return settings
|
||||
}
|
||||
|
||||
/**
|
||||
* 眼下 content 里钉着哪几张卡片(见 pinnedLines),最多 maxLineCount 行(窗口矮时更少,见 lineLimit)。滚动、窗口大小变了、内容的大小变了、用它的组件重画了(页面状态变了)时排一次,
|
||||
* 每帧最多量一次、算一次(requestAnimationFrame);钉的没变不更新。只有用它的组件跟着重画,块树不动。
|
||||
*/
|
||||
export function useStickyLines(content: RefObject<HTMLElement | null>, maxLineCount: number): PinnedLines {
|
||||
const [lines, setLines] = useState(NO_PINNED)
|
||||
const schedule = useRef<() => void>(() => {})
|
||||
|
||||
useEffect(() => {
|
||||
let frame = 0
|
||||
const update = (): void => {
|
||||
frame = 0
|
||||
const root = content.current
|
||||
const next = root ? pinnedLines(measureScopes(root), STICKY_LINE_HEIGHT, limitOf(maxLineCount)) : NO_PINNED
|
||||
setLines(prev => samePinned(prev, next) ? prev : next)
|
||||
}
|
||||
schedule.current = () => {
|
||||
if (frame === 0)
|
||||
frame = requestAnimationFrame(update)
|
||||
}
|
||||
const onChange = (): void => schedule.current()
|
||||
const observer = new ResizeObserver(onChange)
|
||||
if (content.current)
|
||||
observer.observe(content.current)
|
||||
window.addEventListener('scroll', onChange, { passive: true })
|
||||
window.addEventListener('resize', onChange)
|
||||
onChange()
|
||||
return () => {
|
||||
cancelAnimationFrame(frame)
|
||||
observer.disconnect()
|
||||
window.removeEventListener('scroll', onChange)
|
||||
window.removeEventListener('resize', onChange)
|
||||
schedule.current = () => {}
|
||||
}
|
||||
}, [content, maxLineCount])
|
||||
|
||||
// 展开、折叠、筛选、换文档都会让用它的组件跟着页面重画:画完再量(大小没变时 ResizeObserver 不报)
|
||||
useLayoutEffect(() => schedule.current())
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
/** 一张卡片滚到顶上时要让出的高度:它上面会钉着的外层卡片(Sticky Scroll 关着时没有)。 */
|
||||
export function stickyInset(card: Element, settings: StickyScrollSettings | undefined): number {
|
||||
return settings?.enabled ? insetAbove(depthOf(card), STICKY_LINE_HEIGHT, limitOf(settings.maxLineCount)) : 0
|
||||
}
|
||||
|
||||
/** 眼下钉着的几行占多高(StickyLines 画的 .cr-sticky),没钉时是 0。 */
|
||||
export function stickyHeight(): number {
|
||||
return document.querySelector<HTMLElement>('.cr-sticky')?.offsetHeight ?? 0
|
||||
}
|
||||
Reference in new issue
Block a user