✨ feat(vscode): 代码阅读滚进卡片的子块时把外层卡片钉在顶上,同 VS Code 的 Sticky Scroll

This commit is contained in:
cruldra committed 2026-10-08 07:17:26 +08:00
1 parent 5ce2e904f4
commit eb3d1b90cd
17 files changed
+548 -31

No files matched your search

+15 -3
View File
@@ -239,7 +239,7 @@
- 标了改动的块默认展开(函数、类本来默认收起子块),用户点过的照他的。
- 筛选栏第二行(高 30px,只有有差异数据时才有):比较图标、「对比:<基准>」、「·」、统计(各按改动色,没有的那种不写,都没有写「没有改动」),右边「只看改动」开关、上一处 / 下一处(↑ ↓,悬停提示、`aria-label`)。基准里没有这个文件时只写基准。
- 只看改动:每一层连着没变的块折成一条「… N 块没变 点开」(虚线框),被筛掉的块不算也不打断,删掉的块在哪儿就在哪儿断开;改过的块照常,它下面没变的子块同样折;点开一段就恢复这一段(筛选一变重新折)。开关按工作区记住(`BlockFilter`,`workspaceState` 的 `superpowers.codeReader.onlyChanges`),所有预览共用;跳到定义落在折起来的块上时临时露出来。
- 上一处 / 下一处:在各处改动(新增的最外面那块、自己改了的修改块、删掉的块,照页面先后,被筛掉的不算)之间跳,展开折住它的块,滚到筛选栏下面、闪几下;跳完没滚过页面就接着刚跳到的那处走,自己滚过了按视口顶上那块的行找;到头绕回另一头。
- 上一处 / 下一处:在各处改动(新增的最外面那块、自己改了的修改块、删掉的块,照页面先后,被筛掉的不算)之间跳,展开折住它的块,滚到筛选栏下面(外层还钉着的卡片下面,见「钉住外层卡片」)、闪几下;跳完没滚过页面就接着刚跳到的那处走,自己滚过了按视口顶上那块的行找;到头绕回另一头。
- 总览条:预览右边缘 14px 宽、从筛选栏下面贴到底,每处改动按它在新文件里的行画一段刻度(修改块只画它自己那几行,删掉的块一条 2px 细线),点刻度跳过去(同上一处 / 下一处)。
- 文档刚改、差异还没重算时:块按记号认,没动过的块照样带色条;卡片里的标记按下标认,只在页面上正是差异算的那一版时画。
- 颜色:取 VS Code 编辑器行号旁的改动色带 `--vscode-editorGutter-addedBackground` / `modifiedBackground` / `deletedBackground`(主题没给时用 Dark Modern 的默认值),行内增删的字用 `--vscode-diffEditor-insertedTextBackground` / `removedTextBackground`;整行底色是改动色的 14%,新增卡片底色 10%。
@@ -506,7 +506,7 @@
2. 定义是属性表里某一行 / 子行讲的东西(参数、导入项、一条语句声明几个变量时的某一个、类的泛型参数、对象字面量一项的键——TypeScript 对象的属性名就是定义,嵌套几层都算)→ 只闪这一行 / 子行。范围是翻译插件从 facts 取的节点(`PropertyRow.defines`、`PropertyItem.defines`;字典 / 对象的项标的是键,展开项不标),定义处落在范围里就算(`*args` 的定义在 `args` 上、解构参数里的名字都对得上)。
3. 其余(定义在一行译文里,例如循环变量;翻译插件没填 `defines`)→ 显示着定义处那个名字的块,名字在属性行的子行里时展开那一行(在嵌套的子行里时连它上面各层一起展开);页面上找不到这个名字就取包含定义那一行的块。
- 同一文件原地跳、跳进另一个文件的预览,规则一样。
- 滚过去:目标所在块的祖先块折叠着就展开,目标在折叠的属性行子行里(参数、导入项、字典 / 对象的项)就展开那一行,在嵌套的子行里就连它上面折叠着的各层子行一起展开。目标已在视口里不滚,否则滚到视口正中(比视口高就顶部对齐)。然后目标的边框和底色往聚焦色(`--vscode-focusBorder`)闪三下,共约 1.5 秒后复原;同一处再跳一次从头闪;系统开了「减少动态效果」时改成静止高亮 1.5 秒。目标是属性行 / 子行但块正在编辑(页面上没有属性表)时闪整块。
- 滚过去:目标所在块的祖先块折叠着就展开,目标在折叠的属性行子行里(参数、导入项、字典 / 对象的项)就展开那一行,在嵌套的子行里就连它上面折叠着的各层子行一起展开。目标已在视口里(筛选栏和钉住的外层卡片下面)不滚,否则滚到视口正中(比视口高就顶部对齐)。然后目标的边框和底色往聚焦色(`--vscode-focusBorder`)闪三下,共约 1.5 秒后复原;同一处再跳一次从头闪;系统开了「减少动态效果」时改成静止高亮 1.5 秒。目标是属性行 / 子行但块正在编辑(页面上没有属性表)时闪整块。
- VS Code 里没有这种语言的定义提供者(shell):语言扩展答不上时,同一文件里的名字由语言插件认(`LanguageAdapter.symbols`,见「Shell 脚本」),跳到用到的那处之前最近的定义。
- 不按修饰键单击名字是高亮它的各处出现(见下一节);⌘ / Ctrl + 点击名字不会触发块的编辑切换、折叠,也不动高亮。
@@ -521,6 +521,16 @@
- 卡片只读:同页面上那张卡片(类、函数默认收起子块,能在浮窗里折叠展开,只记在浮窗里),块树不带编辑那部分(`BlockTreeState.edit`),没有编辑、问 AI、重写注释的按钮,不标差异、不筛选。浮窗里的名字下标是那个文件的,页面上管名字的几处都不认它们(浮窗根上标 `data-peek`):不按 ⌘ / Ctrl 变链接、不能跳,单击不高亮也不清页面上的高亮,不画页面上的高亮,不再弹卡片;注释里不带路径的引用不成链接(引用按注释所在的文件找,页面只替自己这份文件问),仓库路径链接照样能点。
- 按住 ⌘ / Ctrl 变链接、⌘ / Ctrl + 点击跳转、单击高亮各处出现都照旧;在浮窗外按下鼠标(单击名字、⌘ + 点击)先收起浮窗。
### 钉住外层卡片(Sticky Scroll)
- 同 VS Code 编辑器的 Sticky Scroll:滚进一张卡片的子块、卡片自己的开头已经滚到筛选栏后面时,筛选栏下面钉一行这张卡片的头;套着几层就摞几行,外层在上、里层在下。只认页面上画出来的:子块画出来了的卡片才钉,折叠着的、被筛掉的、正在编辑的、「只看改动」折起来的卡片下面没有子块,不钉;挂在子行上、展开着的子块也算它那张卡片的子块。和语言无关(只看块树),标差异的页面(未提交的更改、审查工单)一样。
- 钉哪几张(`stickyScroll.ts` 的 `pinnedLines`,照 VS Code 的 `StickyScrollController.findScrollWidgetState`):每行一样高(24px),卡片那一行在筛选栏下沿往下 n × 行高,n 是它外面套着几张卡片;卡片上沿高过它那一行顶、子块的下沿还没过这一行顶时钉住。子块快滚完、下沿高过这一行的下沿时,这一行跟着被顶上去,从上一行下面滑走(层级低一级),整块跟着变矮;下一张同层卡片接上来时它已经滑走。
- 行数:`editor.stickyScroll.enabled` 关着不钉;最多钉 `editor.stickyScroll.maxLineCount` 行,且不超过筛选栏下面可视区域的四分之一(四舍五入,同 VS Code);套得比这深时留外层的,同 VS Code。设置由扩展端按这份文件读(语言自己的设置优先),`ready` 后发给页面,改了再发,页面不写死;没收到之前不钉。
- 一行的样子(`StickyLines.tsx`):和那张卡片左右对齐(越里层越往右),折叠箭头那格留空、概念图标、标题,位置同卡片上的;标题同卡片上的写法(属性表第一行(名称)的值或一行译文,中文名照换、定义处后面淡淡写原名;Markdown 只取第一行),一行放不下省略。底色 `--vscode-editorStickyScroll-background`、悬停 `--vscode-editorStickyScrollHover-background`、下沿阴影 `--vscode-editorStickyScroll-shadow`,同编辑器(主题没给时用编辑器底色、列表悬停色、控件阴影)。
- 点一行:把那张卡片滚到顶上,正好在外层还钉着的几行下面(外层各占一行,最多同上面的上限),闪几下(同跳到定义)。行里的名字照旧:⌘ / Ctrl + 点击跳到定义、悬停看定义卡片;单击名字高亮它的各处出现,同时滚过去;钉住的行里不画高亮(页面上找当前那一处只认卡片里的)。
- 层级:在页面内容之上,低于总览条、筛选栏和它的下拉菜单、定义卡片浮窗、下拉列表和悬停提示。页面的 `scroll-padding-top` 是筛选栏加钉着的几行的高度:浏览器自己滚的时候(输入框拿到焦点、打字跟着光标走)让开它们,问 AI 的输入框、块的编辑框不被盖住。跳到定义、键盘跳高亮时「已经看得见就不滚」按钉着的几行下面算;上一处 / 下一处改动把卡片放在外层还钉着的几行下面。从文本编辑器切过来、记滚动位置仍只按筛选栏算(同编辑器里顶上那几行被钉住的行盖着),来回切不漂。
- 滚动、窗口大小变了、页面内容的大小变了、页面状态变了(展开、折叠、筛选、换文档)时排一次,每帧最多量一次、算一次(`requestAnimationFrame`),钉的没变不重画;只有钉住的那几行跟着重画,块树不动。
### 高亮名字的各处出现
- 能点的名字和「跳到定义」一样:带源码下标的段(属性表、一行译文、字典 / 对象各层子行的键和值,点分模块名每一节各算各的)。「编辑」状态的块里的源码、Markdown 里的名字不算,既不能点也不被高亮。
@@ -2010,6 +2020,7 @@ type BlockView = BlockViewBase & ({ translation: PreviewText, table?: PreviewTab
- `{ type: 'definitionProbed', id, found }`:回答第 `id` 次 `probeDefinition`。
- `{ type: 'definitionPeeked', id, peek? }`:回答第 `id` 次 `peekDefinition` / `peekLink`。`peek` 是 `{ path, line, block: BlockView, names: ShownName[] }`:定义所在的文件(从工作区文件夹算起)、定义处那一行(0 起)、那块卡片连同子块、卡片上显示成中文名的名字(下标是那个文件的);没有卡片时不带。见「悬停看定义卡片」。
- `{ type: 'hoverDelay', delay, hidingDelay }`:VS Code 的 `editor.hover.delay` / `editor.hover.hidingDelay`(毫秒,按这份文件读),`ready` 后发一次,设置改了再发。
- `{ type: 'stickyScroll', enabled, maxLineCount }`:VS Code 的 `editor.stickyScroll.enabled` / `editor.stickyScroll.maxLineCount`(按这份文件读,语言自己的设置优先),`ready` 后发一次,设置改了再发;见「钉住外层卡片」。
- `{ type: 'occurrencesFound', id, occurrences: { start, end, kind: 'text' | 'read' | 'write' }[] }`:回答第 `id` 次 `findOccurrences`,`[start, end)` 是源码下标,`kind` 同 `DocumentHighlightKind`;语言扩展没答就是空数组。
- `{ type: 'moveOccurrence', step: 1 | -1 }`:两条命令发来,跳到下一处 / 上一处出现。
- `{ type: 'displayNames', version, names: ShownName[] }`:version 这份文档里显示成中文名的名字,查到一步发一步;页面只收自己眼下这个版本的。
@@ -2176,7 +2187,7 @@ vscode/src/codeReader/
editor/occurrences.ts 查名字的各处出现(vscode.executeDocumentHighlights)
editor/occurrenceNavigation.ts 跳到下一处 / 上一处高亮:命令、上下文键
index.ts registerCodeReader(context): CodeReaderApi
vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vite 第二个入口;块右上角的图标按钮在 BlockTools.tsx,悬停提示在 src/components/ui/tooltip.tsx(和看板共用,页面根上挂一次),块的概念图标(图标名 → 图标组件只在 kindIcons.tsx;内置种类 → 图标名在 contract.ts 的 KIND_ICONS,宿主写进概念列表)在 kindIcons.tsx,属性表在 PropertyGrid.tsx,子行排成表格还是列表在 subRows.ts,着色的词和框在 SegmentText.tsx,中文名换字在 displayNames.ts,Markdown 渲染在 markdown.ts / TextPreview.tsx(不带路径的引用的行内规则也在 markdown.ts),仓库链接和引用的点击、问坏没坏在 repoLink.ts / useRepoLinks.ts,悬停看定义卡片什么时候弹、收、放在哪在 definitionPeek.ts、接到页面上在 useDefinitionPeek.ts、浮窗和只读的卡片在 DefinitionPeekPopup.tsx,筛选栏在 FilterBar.tsx,筛选规则在 filter.ts,编辑时盖住哪些块、点「编辑」退出哪些块在 editing.ts,高亮各处出现的规则在 occurrences.ts、接到页面上在 useOccurrences.ts,问 AI 的问答区在 AskThread.tsx、状态和规则在 qa.ts、贴图拖图的规则在 qaImages.ts、收发在 useQa.ts;代码差异:页面上怎么标(块 id、删掉的块挂哪、只看改动折哪几段、各处改动、上一处 / 下一处跳到哪)在 diffView.ts,筛选栏第二行和新文件横幅在 DiffBar.tsx,卡片里的增删在 DiffText.tsx(Markdown 的增删在 markdown.ts 的 renderMarkdownDiff),总览条在 OverviewRuler.tsx,跳到一处改动在 useChangeNav.ts)
vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vite 第二个入口;块右上角的图标按钮在 BlockTools.tsx,悬停提示在 src/components/ui/tooltip.tsx(和看板共用,页面根上挂一次),块的概念图标(图标名 → 图标组件只在 kindIcons.tsx;内置种类 → 图标名在 contract.ts 的 KIND_ICONS,宿主写进概念列表)在 kindIcons.tsx,属性表在 PropertyGrid.tsx,子行排成表格还是列表在 subRows.ts,着色的词和框在 SegmentText.tsx,中文名换字在 displayNames.ts,Markdown 渲染在 markdown.ts / TextPreview.tsx(不带路径的引用的行内规则也在 markdown.ts),仓库链接和引用的点击、问坏没坏在 repoLink.ts / useRepoLinks.ts,悬停看定义卡片什么时候弹、收、放在哪在 definitionPeek.ts、接到页面上在 useDefinitionPeek.ts、浮窗和只读的卡片在 DefinitionPeekPopup.tsx,筛选栏在 FilterBar.tsx,筛选规则在 filter.ts,编辑时盖住哪些块、点「编辑」退出哪些块在 editing.ts,高亮各处出现的规则在 occurrences.ts、接到页面上在 useOccurrences.ts,问 AI 的问答区在 AskThread.tsx、状态和规则在 qa.ts、贴图拖图的规则在 qaImages.ts、收发在 useQa.ts;代码差异:页面上怎么标(块 id、删掉的块挂哪、只看改动折哪几段、各处改动、上一处 / 下一处跳到哪)在 diffView.ts,筛选栏第二行和新文件横幅在 DiffBar.tsx,卡片里的增删在 DiffText.tsx(Markdown 的增删在 markdown.ts 的 renderMarkdownDiff),总览条在 OverviewRuler.tsx,跳到一处改动在 useChangeNav.ts;钉住外层卡片:钉哪几张、顶上去多少在 stickyScroll.ts,量页面、收设置在 useStickyScroll.ts,钉住的几行在 StickyLines.tsx)
```
## 测试
@@ -2197,6 +2208,7 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit
- 行替换:多行块替换后缩进正确。
- 跳到定义:TS / Python 译文和属性表里的名字、点分模块名每一节、模块字符串带下标且指回源码原文,关键字、运算符、说明词不带(前面有中文注释也对得上);查到的定义 → 不动 / 原地 / 别的文件 / 挑选框,别的文件用哪种编辑器(`editor/openTarget.test.ts`:有代码阅读的用预览、带位置时滚到那一行闪定义处,没有的用默认的打开方式选中名字,打不开成文本的用默认的打开方式);webview 里按下标找目标块、要展开的祖先块和属性行,滚动位置。悬停看定义卡片:取哪块(`editor/definitionPeek.test.ts`:Python 源码走完整的语言插件 + 翻译插件,类名 → 类那块连同子块,参数 → 所在的函数,变量 → 变量那块,块外面 → 没有,挂在子行上的子块去掉 `slot`;卡片上的中文名只带画出来的、长度对得上的,别的文件只认定义本身),什么时候弹、收(`definitionPeek.test.ts`:停够延迟才问、没到时间移开不问、没有卡片不弹、迟到的回答丢掉、链接问 `peekLink` 点标题发 `openRepoLink`、没收到延迟不弹、移开过 `hidingDelay` 才收、移进浮窗不收、扫过别的名字不往后推、浮窗里不弹别的、换成新名字的卡片、Esc / 滚动后不再弹、换文档版本收起;指到符号的链接才弹;放在下方 / 上方 / 空间大的那边、钳在视口内),只读的卡片(`DefinitionPeekPopup.test.tsx`:类收起子块、没有编辑 / 问 AI / 重写按钮、中文名用卡片带的、引用不成链接;`markdown.test.ts` 的 `peek`)。闪哪一处(TS、Python 源码走完整的语言插件 + 翻译插件 + 视图数据):参数 → 它那个子行,导入项 → 它那一项,多变量声明里的一个 → 它的名称行,类的泛型参数 → 「类型参数」那一行,函数名 / 类名 / 单个变量名(含解构模式)→ 整块,循环变量 → 所在的块,翻译插件没填 `defines` → 整块(照样展开属性行);翻译插件给参数、导入项、声明的变量、泛型参数标的范围指回源码原文;块自己的名字只有函数、类和单变量声明有。
- 高亮各处出现:段的源码范围和出现重叠才亮、边界相接不算、没下标的不亮,讲别处名字的段(`refers`)按那个名字算、自己照抄的位置不算(`occurrences.test.ts` 的 `sourceOf`),点分模块名每一节各按自己的范围认(真实的 Python 译文和属性表:定义处按写入、用到处按读取);写入 → 强高亮色,读取、按文字 → 普通高亮色,同时碰上按写入;单击名字问、点空白处清、点按钮 / 链接 / 筛选栏不动、名字在按钮里照样问,拖选、双击第二下、带修饰键的点击不动,⌘ / Ctrl + 点击照旧发跳到定义、不问出现处;回答连同名字显示、空回答不亮、换名字时回答到前旧的留着、迟到的回答丢掉、答过「没有」再点照样再问、清掉后才到的回答丢掉、换文档版本或翻译清掉而同版本同翻译重发不清、没有文档时不问。键盘跳(`occurrences.test.ts`、`reveal.test.ts`、`editor/occurrenceNavigation.test.ts`):回答照源码先后排、点中的那处是当前那一处、「2/5」和「N 处」;往后 / 往前、到头接着跳,找不到的跳过、总数照旧、一处都找不到不动,点中的不在回答里从点中的地方数起;只有当前那一处描框;能不能跳(高亮、焦点、输入框)第一次必报、之后变了才报;页面上找第一处显示着它的地方、要展开的块 / 属性行 / 子行、跳过筛掉 / 编辑 / 盖住的块、Markdown 不算;`package.json` 的两条命令、默认键、`when`、命令面板,扩展和页面的代码里不写键、不拦键,上下文键有一个预览能跳就真、变了才设,命令发给能跳的预览、没有就发给活动的;按键那一刻的位置跳转时回一次、隔太久 / 点过鼠标 / 没按过键不回,跳完守住跳到的位置、过了 `KEY_SCROLL_MS` 或又按键、点鼠标不守。
- 钉住外层卡片(`stickyScroll.test.ts`、`StickyLines.test.tsx`、`BlockTree.test.tsx`):上沿刚滚过它那一行才钉、里层按自己那一行算,外层在上里层在下,同层前一张滚完、后一张还没到只钉外层;子块快滚完时最里那行被顶上去、正好到行顶时整行顶到上一行下面,最后一张子块同时是外层的结尾时里层先走、再顶外层;比上限深时留外层的;最多行数按设置和可视区域的四分之一;滚到顶上时上面钉着多高;钉的没变不重画。一行里是概念图标和标题:属性表第一行的值(中文名照换、不带属性名、不带说明)、一行译文、Markdown 只取第一行、空属性表只有图标。画出来的子层记着它是哪张卡片的,收着时没有。
- 中文名:
- 语言插件认写法(`languages/pythonNames.test.ts`、`languages/typescriptNames.test.ts`,真实解析):Python 注解 `Annotated`(`typing.Annotated`、`t.Comment`,f-string、别的调用不算)、装饰器(函数、类)、行尾记号(多行参数表、`*args`、`self.x`、变量、字段、枚举成员,记号前面有别的字,记号的范围)、单独成行 / 那一行没有定义的不算、函数那一行给参数、docstring 的谷歌写法和 Sphinx 写法(带类型、星号、续行、别的段落不算)、第一小句和 12 个字的限制、明写的记号盖过说明文字、没取的记号照样去掉;TypeScript 装饰器(类、`@d export class`、字段、构造函数和方法的参数、方法隔着别的装饰器、名字最后一节)、行尾 `//` 记号(参数、变量、枚举成员、类字段、接口属性,块注释不算)、JSDoc `@param`(类型、短横、可选、属性不算、export 函数、方法、箭头函数变量、类字段)、JavaScript 默认值参数;预筛和各方言的文件。
- 记号不是说明(`nameResolution.test.ts`,走 `readBlockViews`):Python、TypeScript 枚举成员的行尾记号去掉、前面的字照留、只有记号的没有说明列。
+11
View File
@@ -97,12 +97,22 @@ export interface QaMessage {
running: RunningAsk[]
}
/**
* 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 表示 VS Code 查得到定义
* 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 后发一次,问答、正在答的问题变了再发
@@ -126,6 +136,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[] }
@@ -263,6 +263,8 @@ class Preview {
workspace.onDidChangeConfiguration((e) => {
if (this.ready && e.affectsConfiguration('editor.hover', document))
this.postHoverDelay()
if (this.ready && e.affectsConfiguration('editor.stickyScroll', document))
this.postStickyScroll()
}),
// 页面没 ready 时收不到,等它 ready 再随第一份文档之前补发
filter.subscribe((state) => {
@@ -539,6 +541,7 @@ class Preview {
// 先于文档发:第一份文档按筛选画,不会先整份露出来再藏
this.post({ type: 'filter', ...this.filter.current })
this.postHoverDelay()
this.postStickyScroll()
this.postQa()
void this.postProfiles()
await this.push()
@@ -662,6 +665,12 @@ class Preview {
this.post({ type: 'hoverDelay', delay: hover.get<number>('delay', 300), hidingDelay: hover.get<number>('hidingDelay', 300) })
}
/** VS Code 的 Sticky Scroll 设置(按这份文件取,语言自己的设置优先):页面按它把外层卡片钉在顶上。 */
private postStickyScroll(): void {
const sticky = workspace.getConfiguration('editor.stickyScroll', this.document)
this.post({ type: 'stickyScroll', enabled: sticky.get<boolean>('enabled', true), maxLineCount: sticky.get<number>('maxLineCount', 5) })
}
/**
* 回答 webview 第 id 次要定义卡片:offset 处名字的第一处定义所在那块(见 peekAt);指着的名字就是定义本身时没有。
* 下标属于别的文档版本时不答,webview 换了文档版本会收起浮窗
+8 -2
View File
@@ -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
})
}
+11
View File
@@ -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
}