diff --git a/docs/JSON预览器代码折叠技术博客.md b/docs/JSON预览器代码折叠技术博客.md new file mode 100644 index 0000000..c8de166 --- /dev/null +++ b/docs/JSON预览器代码折叠技术博客.md @@ -0,0 +1,121 @@ +# 给 JSON 预览器加上代码折叠:一次"投影层"的扩展实践 + +> 这篇博客记录 bench 项目 JSON 预览组件(`json-text-view.vue`)新增代码块折叠的完整过程:为什么折叠在虚拟滚动上不是"再渲染一个树"的问题,VS Code 和 CodeMirror 6 的折叠机制长什么样,以及把折叠嫁接到"等高行虚拟滚动 + model 层选区"架构上的具体改法。是前一篇《给两万行 JSON 做一个不卡、可选中、还能拖拽选行的预览器》的续篇,目标读者不变——想理解"编辑器类组件"设计思路的工程师(也就是我自己的学习笔记)。 + +## 一、问题:两万行的 JSON,少一半是"看不见的括号" + +前一篇的预览器解决了"卡"和"选不中",但 `/api/log/token` 两万行的响应里,真正有信息量的键值对可能只占一半——剩下的一半是结构性的括号行和被嵌套展开的深层字段。读数据时人脑做的事情和折叠功能完全一致:**盯着顶层 key,跳过暂时不关心的子树**。 + +所以目标是 VS Code 式的代码块折叠:点击折叠箭头,一个对象/数组收成一行 `{…} N 行`;再点开,原样展开。听起来像"给列表加个树形控件",但对虚拟滚动组件来说,这个需求击中了架构的一个隐藏假设—— + +**现有架构里,"可视坐标 = model 坐标"是一个 1:1 的隐含等式。** 行号 gutter 显示的数字、指针几何换算出的行号、选区存储的行号、滚动高度公式里的行数,全部是同一个数。折叠一旦介入,这个等式就断了:model 里第 100 行,可能在屏幕上第 80 行的位置(前面 20 行被折叠藏掉了)。折叠功能的全部工作,就是把这个隐含等式改造成显式映射,并且不能改坏任何一处旧换算。 + +## 二、先看标准答案:VS Code 与 CodeMirror 6 怎么做 + +动手前先考证了两家主流编辑器的源码级实现,结论是它们共享同一个模式,只是实现层不同。 + +**VS Code / Monaco 的折叠是"隐藏行模型"。** 折叠范围就是 `(startLine, endLine)` 行号对;`HiddenRangeModel` 从折叠状态派生出隐藏区间,view model 把隐藏行**从视图流里移除**——不是 CSS 藏起来,是真的不渲染。语义上有一个关键细节:折叠头的 start 行永远可见,隐藏的是 `start+1 .. end`。折叠后 gutter 仍然显示 model 行号(跳号显示),折叠箭头画在首行行号槽,首行行尾追加 `…`。折叠视口上方的块时,`StableEditorScrollState` 会先记下"视口顶部是哪一行(model 行号) + 视口内像素偏移",折叠重排后反推新的 scrollTop——所以你折叠眼前的块,内容纹丝不动。 + +**CodeMirror 6 的折叠是"替换装饰"。** 折叠集合是 editor state 的一部分(`StateField`),`foldEffect/unfoldEffect` 事务驱动;被折叠的区间用 `Decoration.replace` 收成一个占位 widget(默认 `…`,可自定义)。它和 CM6 自带的 viewport 虚拟渲染天然兼容:替换后可视行数变少,viewport 按新高度算就是了。还有个有趣的细节:**光标移进折叠区时,该折叠自动清除**——编辑器必须保证光标永远落在可见文档上。 + +**两者的公共抽象,正好就是我们要的:** 折叠范围(model 坐标)+ 折叠位集合 + 可见性投影(隐藏行不进视图流)。状态永远存 model 坐标,渲染永远是投影——和前一篇选区设计的原则完全同构。 + +JSON 查看器生态(Chrome DevTools、JSON Hero 之类)的交互惯例也顺带考证了:折叠箭头在键名处、折叠后行尾显示摘要、默认全展开、没有快捷键依赖。这些直接决定了我们的交互清单。 + +## 三、折叠范围怎么算:括号栈,别用缩进 + +VS Code 对通用文本用"缩进推导"算折叠范围(逐行比较缩进层级),但对 JSON 有专门的实现:官方 `vscode-json-languageservice` 的 `jsonFolding.ts` 用的是**词法扫描 + 括号配对栈**。 + +我们选括号栈,而且比它还简单。依据是 pretty-print JSON 的两条铁律: + +1. **每行至多一个结构括号**(开括号在行尾,或整行就是闭括号) +2. **字符串值不会跨行**(`JSON.stringify` 不产生多行字符串,字符串里的 `{` 被引号包裹) + +于是逐行扫描只需判断两端: + +``` +行尾(trimEnd 后)是 { 或 [ → 开块,压栈 {startLine, kind} +行首(首个非空白)是 } 或 ] → 弹栈,产出 fold{ startLine, endLine: closeLine, depth, hiddenLineCount } +``` + +单遍 O(n),两万行不到 1ms。三个细节值得记: + +**end 是闭括号行本身,不是"闭括号前一行"。** LSP 的 `FoldingRange` 文档写的是"end line 是闭括号前一行",但那是 **0-based 记法**——0-based 的"闭括号前一行"换到 1-based 恰好就是闭括号行。我第一版按字面理解实现了 1-based 的"前一行",结果折叠后每个对象都留下一个孤儿 `}` 行,被自己写的测试当场抓住。混用 0/1-based 行号体系是编辑器领域的经典坑,值得写进注释。 + +**单行块不产出折叠。** `{"a": 1}` 占一行,折叠它没有任何意义;只有 `closeLine > startLine + 1` 才产出。 + +**非法结构不抛错。** 闭括号比开括号多时弹空栈,跳过该行继续扫。调用方(视图层)会用 `props.json === null` 把非 JSON 响应挡在扫描之外,这里是第二道保险。 + +## 四、嫁接到虚拟滚动:八个接入点 + +`visibleModel: number[]` 是新增的核心数据结构——**可见性投影**:未被折叠隐藏的 model 行号按序排列。无折叠时它就是 `[1..N]` 的恒等投影;折叠 20 行后,它比 lineList 短 20。有了它,原架构里所有"行数"的消费者逐个换算: + +| 接入点 | 原实现 | 折叠后 | +|---|---|---| +| 可视窗口计算 | 输入 model 总行数 | 输入 `visibleModel.length`(输出变成可视索引区间) | +| 窗口行渲染 | `lineList[index]`,行号 `index+1` | `visibleModel[index]` 反查 model 行号,再取原文 | +| 撑高 spacer | model 行数 × 20px | **可视行数 × 20px**(滚动条随之缩短,这个细节很重要) | +| gutter 宽度 | model 总行数 | **不变**——跳号显示,最大行号还是 model 的 | +| 指针 → 行号 | 几何换算直出 | 先可视索引,再 `visibleModel` 反查(隐藏行不在投影里,天然不会被点到) | +| 边缘自动滚动 | 同上 | 同上 | +| v-for 的 key | model 行号 | **不变**(Vue 复用节点稳定) | +| 选区高亮判断 | model 行号 | **不变**(隐藏行不渲染,高亮自动正确) | + +这张表里最微妙的是 **gutter 宽度不动**:折叠后可见的最大行号没变(还是 model 最后一行),宽度按它算,折叠展开时行号列不会抖动。 + +而窗口计算、spacer 高度换用可视行数,意味着**滚动条的长短随折叠实时变化**——折叠一半内容,滚动条变长一倍。这正是 Chrome DevTools / VS Code 的手感。 + +## 五、三个真正的难点 + +### 5.1 折叠视口上方的块,屏幕为什么会跳 + +折叠让"锚点行之前"的可视行数变少,如果 scrollTop 不动,锚点行会向上瞬移。VS Code 的解法(StableEditorScrollState)在等高行体系下可以做得非常轻: + +``` +折叠前:anchor = 视口顶部那行的 model 行号,delta = scrollTop − 可视索引 × 20 +折叠中:改折叠位 → 重建 visibleModel +折叠后:scrollTop = 锚点的新可视索引 × 20 + delta +``` + +锚点**必须是 model 行号**——折叠改变的是 model 行的可视索引,不是行本身。另有一个边角:锚点行恰好落在被折叠的区间里时,先把它钳制到折叠头行(它必然可见),补偿基准不失效。jsdom 测这条链路还顺带踩了两个环境坑:fake timers 下 `scrollTop` 是只读的(用 getter/setter 模拟),赋值不触发 scroll 事件(真实浏览器会,测试里手动补派发)。 + +### 5.2 选区划过隐藏区,存哪、停在谁身上 + +选区真相在 model 层(前一篇的设计),这一原则不变;变化的是**写入时的钳制**:拖拽把选区划进隐藏区,端点停在折叠头行上(Monaco `adjustSelections` 的语义:收缩,而不是自动展开折叠——那是编辑器的需求,查看器收缩更符合阅读直觉)。 + +顺带修掉一个存量隐患:原 `syncDomSelection` 里 model 行号和可视索引是混用的(无折叠时恰好等价,所以没暴露)。折叠上线后这个隐患变成必然错位,一并改为"钳制 → 反查可视索引 → 与窗口求交集"。 + +### 5.3 非 JSON 响应:折叠特性整体关门 + +`props.json === null` 时预览器降级为纯文本展示。raw 文本没有可靠的括号结构(HTML 里的 `{` 会骗过扫描器),所以折叠三件套(扫描、chevron、摘要)全部以 `hasFolds` 门控。这也是测试抓出来的:第一版 chevron 的占位 span 在纯文本行上也渲染了,行高虽不受影响,但"非 JSON 页面出现幽灵元素"不可接受。 + +## 六、交互层:抄谁,不抄谁 + +按生态惯例取舍后的清单: + +- **抄 VS Code**:chevron 在 gutter 侧(hover 显示、已折叠常显)、折叠后行尾摘要、跳号显示 model 行号 +- **抄 JSON 工具系**:摘要带数量——但我们显示的是"隐藏行数"(`{…} 8 行`)而不是 keys 数。理由:查看器的对象可能巨大,"8 行"直接预告了展开后的篇幅,对"要不要展开"这个决策更实用 +- **不抄**:快捷键(查看器没有键盘编辑流)、hover 展开预览(无生态先例)、折叠态持久化(工具"刷新即重置"的哲学)、默认折叠(生态一致地默认全展开) +- **自创一处**:"折叠全部"只收 depth=1 的顶层块——日志 JSON 最常见的诉求是"把每条日志收成一行",嵌套折叠位保留不动,展开顶层即可恢复原状 + +## 七、复盘:投影层的可扩展性,是被验证出来的 + +这次扩展让前一篇结尾的原则经受了一次实战检验: + +**"真相只放一份,放在离 DOM 最远的那层"——但真相不止一个坐标系。** + +model 坐标系(行号、内容、选区)和 view 坐标系(可视索引、scrollTop、spacer 高度)在无折叠时是同一个,折叠功能把它们拆开并用 `visibleModel` 显式连接。整个扩展过程没有动 model 层一行代码,所有改动集中在"投影的生成规则"和"两个坐标系的换算点"上——八个接入点里,四个是"把 model 行数换成可视行数"的一行改动,两个是反查方向的颠倒,真正的新逻辑只有折叠事务和钳制。 + +测试方面有个值得一提的节奏:第一版实现带着"end = 闭括号前一行"的语义偏差上线,**单测当场红了两条**;视图层第一版带着 SFC 里的非法 `continue` 编译失败,**vitest 的编译环节先于 type-check 报错**。纯函数先行、TDD 锁死语义,这套流程在这类"几何换算密集"的组件上回报极高——坐标系换算的 bug 靠肉眼 review 几乎不可能发现,但一个精确的期望值数组就能锁死它。 + +如果要继续往编辑器方向走,折叠是继选区之后第二块拼图。下一步的自然延伸:折叠态进 store 的序列化(Monaco/CM6 都支持持久化)、按 depth 分层折叠(实现已留好 `depth` 字段)、以及把"行号选区"升级为"(行,列) 坐标"——那时 model 层才真正需要为列而设计。 + +--- + +## 附:代码与测试索引 + +- `src/lib/json-text-processor.ts` — 纯函数层新增:`scanJsonFolds`(括号栈扫描)、`buildVisibleProjection`(可见性投影)、`findVisibleIndex`(行号反查)、`clampLineToVisible`(选区钳制) +- `src/components/json-text-view.vue` — 视图层:投影接线(八接入点)、折叠事务(scrollTop 补偿)、chevron 与行尾摘要 +- `src/components/endpoint-panel.vue` — meta 行「折叠全部 / 展开全部」按钮 +- `docs/JSON预览器代码折叠调研.md` — 机制考证全文(VS Code/CM6 源码结论、交互惯例清单) +- 测试:纯函数 20 用例 + 视图层 6 用例(折叠渲染、chevron 交互、选区隔离、补偿无跳变、状态重置、非 JSON 降级) diff --git a/src/components/endpoint-panel.vue b/src/components/endpoint-panel.vue index 4998e5e..f8c00d1 100644 --- a/src/components/endpoint-panel.vue +++ b/src/components/endpoint-panel.vue @@ -9,7 +9,7 @@ */ import { useClipboard } from '@vueuse/core'; -import { computed } from 'vue'; +import { computed, ref, watch } from 'vue'; import { findEndpointByPath } from '@/api/endpoints'; import { useManualEndpointQuery } from '@/api/endpoint-queries'; @@ -58,6 +58,28 @@ function handleSend(): void { sendRequest(); } +/* ---------- 折叠全部 / 展开全部(JsonTextView defineExpose 通道) ---------- */ + +const jsonTextViewRef = ref | null>(null); +const foldable = ref(false); + +// hasFolds 是 JsonTextView 内部的 computed,经 watch 桥接为面板侧响应式标志 +watch( + () => jsonTextViewRef.value?.hasFolds, + (value) => { + foldable.value = value === true; + }, + { immediate: true }, +); + +function collapseAllFolds(): void { + jsonTextViewRef.value?.collapseAll(); +} + +function expandAllFolds(): void { + jsonTextViewRef.value?.expandAll(); +} + /* ---------- 响应区操作:复制 / 下载 ---------- */ const { @@ -150,6 +172,12 @@ function downloadResponseAsFile(): void { {{ responseData.networkError }} + + 折叠全部 + + + 展开全部 + 下载 - +
{{ requiresKey ? '填写 sk-key 后点击「发送请求」' : '点击「发送请求」查看站点配置' }} diff --git a/src/components/json-text-view.spec.ts b/src/components/json-text-view.spec.ts index 9fe8662..817d7c5 100644 --- a/src/components/json-text-view.spec.ts +++ b/src/components/json-text-view.spec.ts @@ -165,3 +165,158 @@ describe('json-text-view 虚拟滚动', () => { wrapper.unmount(); }); }); + +/* ---------- 代码块折叠(实施计划 §6 视图层测试) ---------- */ + +describe('json-text-view 代码块折叠', () => { + /** 9 行单条目 JSON(含嵌套对象): + * 1 { / 2 "data": [ / 3 { / 4 "id": 1 / 5 "model_name": "m" / 6 "quota": 2500 / 7 } / 8 ] / 9 } + * folds: start=3/end=7(depth=3)、start=2/end=8(depth=2)、start=1/end=9(depth=1) + */ + const singleEntryJson = { data: [{ id: 1, model_name: 'm', quota: 2500 }] }; + + it('全部折叠:spacer 高度缩短为折叠头行数 × 20px,gutter 跳号显示 model 行号,折叠头行带摘要', async () => { + const wrapper = mount(JsonTextView, { + props: { json: singleEntryJson, rawText: '' }, + attachTo: document.body, + }); + expect(wrapper.find('.scroll-spacer').attributes('style')).toContain('height: 180px'); // 9 行 + + await (wrapper.vm as unknown as { collapseAll: () => void }).collapseAll(); + await wrapper.vm.$nextTick(); + + // depth=1 顶层块折叠后:可视行只剩折叠头(行 1),其余全部隐藏 + const lines = wrapper.findAll('.line'); + expect(lines).toHaveLength(1); + expect(wrapper.find('.scroll-spacer').attributes('style')).toContain('height: 20px'); + expect(lines[0]!.find('.gutter').text()).toBe('1'); // model 行号 + // 折叠头行尾摘要:对象块 → {…} + 隐藏行数 8 + expect(lines[0]!.find('.fold-summary').text()).toContain('{…}'); + expect(lines[0]!.find('.fold-summary').text()).toContain('8 行'); + // chevron 已折叠态常显 + expect(lines[0]!.find('.fold-chevron').classes()).toContain('fold-chevron-collapsed'); + wrapper.unmount(); + }); + + it('点击 chevron 折叠中间块:隐藏区间行从 DOM 消失、折叠头保留、再点击展开恢复', async () => { + const wrapper = mount(JsonTextView, { + props: { json: singleEntryJson, rawText: '' }, + attachTo: document.body, + }); + const findLineByGutter = (no: string) => + wrapper.findAll('.line').find((line) => line.find('.gutter').text() === no); + + // 行 2 是数组折叠头("data": [),点击其 chevron 折叠 2..8 + const line2 = findLineByGutter('2')!; + expect(line2.find('.fold-chevron').exists()).toBe(true); + await line2.find('.fold-chevron').trigger('click'); + await wrapper.vm.$nextTick(); + + const gutters = wrapper.findAll('.line').map((line) => line.find('.gutter').text()); + expect(gutters).toEqual(['1', '2', '9']); // (2, 8] 全部隐藏(含行 8 闭括号) + expect(findLineByGutter('2')!.find('.fold-summary').text()).toContain('[…]'); + + // 再点击 chevron 展开:恢复全量 9 行 + await findLineByGutter('2')!.find('.fold-chevron').trigger('click'); + await wrapper.vm.$nextTick(); + expect(wrapper.findAll('.line')).toHaveLength(9); + expect(wrapper.find('.scroll-spacer').attributes('style')).toContain('height: 180px'); + wrapper.unmount(); + }); + + it('chevron / 摘要点击不触发 gutter 选区(mousedown.stop 隔离)', async () => { + const wrapper = mount(JsonTextView, { + props: { json: singleEntryJson, rawText: '' }, + attachTo: document.body, + }); + const line2 = wrapper.findAll('.line').find((line) => line.find('.gutter').text() === '2')!; + + await line2.find('.fold-chevron').trigger('click'); + await document.body.dispatchEvent(new MouseEvent('mouseup')); + await wrapper.vm.$nextTick(); + + // 无任何行进入选中态 + expect(wrapper.findAll('.line-selected')).toHaveLength(0); + expect(window.getSelection()!.toString()).toBe(''); + wrapper.unmount(); + }); + + it('滚动位置折叠补偿:折叠视口上方的块后,视口顶部锚点行的内容不变(无跳变)', async () => { + vi.useFakeTimers(); + try { + // 60 条目 ≈ 308 行(4 行外壳 + 60×5 行/条目 + 1),滚动目标有充足纵深 + const text = makeJsonText(60); + const wrapper = mount(JsonTextView, { + props: { json: JSON.parse(text), rawText: text }, + attachTo: document.body, + }); + const container = wrapper.find('.json-text-view'); + + // 滚到 2000px(可视行 100 起)——深处条目中部 + // fake timers 下 jsdom 不派发布局滚动,scrollTop 需用可写 getter/setter 模拟真实行为 + let currentScrollTop = 0; + Object.defineProperty(container.element, 'scrollTop', { + configurable: true, + get: () => currentScrollTop, + set: (value: number) => { + currentScrollTop = value; + }, + }); + container.element.scrollTop = 2000; + await container.trigger('scroll'); + vi.advanceTimersByTime(34); // 节流补执行 + await wrapper.vm.$nextTick(); + + const firstGutterBefore = wrapper.findAll('.line')[0]!.find('.gutter').text(); + expect(Number(firstGutterBefore)).toBeGreaterThan(95); // 前提:锚点在深处 + + // 折叠一个视口上方的顶层块:第二条目 { 在行 8(depth=1),8 ≤ 100 在视口上方 + await (wrapper.vm as unknown as { toggleFold: (line: number) => Promise }).toggleFold( + 8, + ); + // 补偿把 scrollTop 写为 1920;真实浏览器会因 scrollTop 变化派发 scroll 事件, + // 这里手动补派发 + 推进节流 trailing,让 scrollTopPx 跟随到补偿后的位置 + await container.trigger('scroll'); + vi.advanceTimersByTime(34); + await wrapper.vm.$nextTick(); + + // 补偿断言:视口顶部仍停在锚点行(model 100,可视索引 96)——内容不跳变 + const firstGutterAfter = wrapper.findAll('.line')[0]!.find('.gutter').text(); + expect(firstGutterAfter).toBe(firstGutterBefore); + expect(container.element.scrollTop).toBe(1920); // 96 × 20px(隐藏 4 行后的锚点位置) + wrapper.unmount(); + } finally { + vi.useRealTimers(); + } + }); + + it('props.json 变化:折叠态清空,恢复全量渲染', async () => { + const wrapper = mount(JsonTextView, { + props: { json: singleEntryJson, rawText: '' }, + attachTo: document.body, + }); + await (wrapper.vm as unknown as { collapseAll: () => void }).collapseAll(); + await wrapper.vm.$nextTick(); + expect(wrapper.findAll('.line')).toHaveLength(1); // 折叠生效 + + await wrapper.setProps({ json: { alpha: 1 }, rawText: '' }); + await wrapper.vm.$nextTick(); + + // 新数据(3 行)不应继承旧折叠位:全量渲染 + const lines = wrapper.findAll('.line'); + expect(lines).toHaveLength(3); + expect(wrapper.find('.scroll-spacer').attributes('style')).toContain('height: 60px'); + expect(lines.map((line) => line.find('.gutter').text())).toEqual(['1', '2', '3']); + wrapper.unmount(); + }); + + it('非 JSON 响应:无 chevron、无摘要(折叠特性整体关闭)', () => { + const wrapper = mount(JsonTextView, { + props: { json: null, rawText: '502' }, + attachTo: document.body, + }); + expect(wrapper.findAll('.fold-chevron')).toHaveLength(0); + expect(wrapper.findAll('.fold-summary')).toHaveLength(0); + wrapper.unmount(); + }); +}); diff --git a/src/components/json-text-view.vue b/src/components/json-text-view.vue index 72c89d2..7cc94ff 100644 --- a/src/components/json-text-view.vue +++ b/src/components/json-text-view.vue @@ -8,19 +8,26 @@ * 实现要点: * - DOM 恒定:只渲染可视窗口 ± overscan 的行(json-text-processor.computeVisibleRowWindow) * - 懒高亮:仅对窗口内行做 token 切词(结构化 span),转义与着色在模板层,无 v-html - * - 滚动条:撑高度用总行数 × 行高的占位 div,窗口偏移用 translateY + * - 滚动条:撑高度用「可视行数 × 行高」的占位 div,窗口偏移用 translateY + * - 代码块折叠:括号栈扫描 fold + collapsedSet 投影剔除隐藏行;折叠事务带 scrollTop 补偿 + * (锚点 = 视口顶部 model 行号,折叠视口上方块时视口不跳变);选区端点写前钳制 */ import { useElementSize, useThrottleFn } from '@vueuse/core'; -import { computed, ref } from 'vue'; +import { computed, nextTick, ref, watch } from 'vue'; import { buildJsonLineSegments, + buildVisibleProjection, + clampLineToVisible, computeGutterWidth, computeVisibleRowWindow, + findVisibleIndex, formatJsonOrRawText, JSON_VIEW_ROW_HEIGHT_PX, + scanJsonFolds, splitTextIntoLines, + type JsonFoldRange, } from '@/lib/json-text-processor'; const props = defineProps<{ @@ -35,6 +42,92 @@ const props = defineProps<{ const formattedText = computed(() => formatJsonOrRawText(props.json, props.rawText)); const lineList = computed(() => splitTextIntoLines(formattedText.value)); +/* ---------- 代码块折叠(model 层状态,调研见 docs/JSON预览器代码折叠调研.md) ---------- + * + * 三层模型:folds(扫描产物)+ collapsedSet(折叠位)+ visibleModel(可见性投影)。 + * 隐藏语义同 VS Code hidesLine:start < line ≤ end(折叠头行可见、闭括号随块隐藏)。 + * 非 JSON 响应(props.json === null)不扫描——raw 文本无可靠括号结构。 + */ +const foldList = computed(() => (props.json === null ? [] : scanJsonFolds(lineList.value))); +const collapsedStartLines = ref>(new Set()); + +/** 折叠头行 → fold 查找表(chevron / 行尾摘要渲染用) */ +const foldByStartLine = computed( + () => new Map(foldList.value.map((fold) => [fold.startLine, fold])), +); + +/** 可见性投影:可视槽位 → model 行号(1-based)。无折叠时为恒等投影。 */ +const visibleModel = computed(() => + buildVisibleProjection(lineList.value.length, foldList.value, collapsedStartLines.value), +); + +const hasFolds = computed(() => foldList.value.length > 0); + +/** + * 折叠事务:改折叠位 + scrollTop 补偿(VS Code StableEditorScrollState 等价实现)。 + * 锚点是 model 行号——折叠只改变 model 行的可视索引,不改变行本身; + * 锚点行被本次折叠藏住时,先钳制到折叠头行,保证补偿基准仍可见。 + */ +async function toggleFold(startLine: number): Promise { + const container = scrollContainerRef.value; + const nextCollapsed = new Set(collapsedStartLines.value); + if (nextCollapsed.has(startLine)) { + nextCollapsed.delete(startLine); + } else { + nextCollapsed.add(startLine); + } + + // 折叠前捕获锚点:视口顶部可视行(model 行号)+ 视口内像素偏移 + let anchorModelLine: number | null = null; + let deltaPx = 0; + if (container && container.scrollTop > 0) { + const topVisIdx = Math.floor(container.scrollTop / JSON_VIEW_ROW_HEIGHT_PX); + const rawLine = visibleModel.value[topVisIdx]; + if (rawLine !== undefined) { + anchorModelLine = clampLineToVisible(rawLine, foldList.value, nextCollapsed); + deltaPx = container.scrollTop - topVisIdx * JSON_VIEW_ROW_HEIGHT_PX; + } + } + + collapsedStartLines.value = nextCollapsed; + + // DOM 更新后按新投影反查锚点可视索引,恢复滚动位置 + await nextTick(); + if (container && anchorModelLine !== null) { + const newVisIdx = findVisibleIndex(visibleModel.value, anchorModelLine); + if (newVisIdx >= 0) { + container.scrollTop = newVisIdx * JSON_VIEW_ROW_HEIGHT_PX + deltaPx; + } + } +} + +/** 选区端点写入前的钳制包装:划进隐藏区的行停在折叠头行(Monaco adjustSelections 语义) */ +function clampSelectionLine(modelLine: number): number { + return clampLineToVisible(modelLine, foldList.value, collapsedStartLines.value); +} + +/** 全部折叠:收起所有顶层块(depth = 1)——嵌套块随父块隐藏,符合直觉 */ +function collapseAll(): void { + collapsedStartLines.value = new Set( + foldList.value.filter((fold) => fold.depth === 1).map((fold) => fold.startLine), + ); +} + +/** 全部展开 */ +function expandAll(): void { + collapsedStartLines.value = new Set(); +} + +/** 切换响应(props.json 变化)时重置折叠态:新数据的 folds 语义已变,旧折叠位失效 */ +watch( + () => props.json, + () => { + collapsedStartLines.value = new Set(); + }, +); + +defineExpose({ collapseAll, expandAll, hasFolds, toggleFold }); + /* ---------- 容器尺寸与滚动位置(VueUse) ---------- */ const scrollContainerRef = ref(null); @@ -55,13 +148,17 @@ const handleScroll = useThrottleFn( /* ---------- 可视窗口(纯函数,阶段二已单测锁死) ---------- */ const visibleWindow = computed(() => - computeVisibleRowWindow(lineList.value.length, scrollTopPx.value, containerHeightPx.value), + computeVisibleRowWindow(visibleModel.value.length, scrollTopPx.value, containerHeightPx.value), ); -/** 窗口内行的渲染模型(行号 + 完整片段序列:token 与 plain 交替,括号不丢) */ +/** 窗口内行的渲染模型(行号 = model 行号,折叠后 gutter 跳号显示;完整片段序列:token 与 plain 交替,括号不丢) */ interface RenderableLine { lineNumber: number; segments: ReturnType; + /** 该行是否为某个 fold 的折叠头(渲染 chevron 与行尾摘要) */ + foldAtLine: JsonFoldRange | null; + /** 折叠头行当前是否处于折叠态(决定 chevron 方向与摘要显隐) */ + isFoldCollapsed: boolean; } const visibleLineList = computed(() => { @@ -71,14 +168,21 @@ const visibleLineList = computed(() => { index <= visibleWindow.value.endIndex; index += 1 ) { - const lineText = lineList.value[index]; - if (lineText === undefined) continue; - lines.push({ lineNumber: index + 1, segments: buildJsonLineSegments(lineText) }); + const modelLine = visibleModel.value[index]; + const lineText = modelLine === undefined ? undefined : lineList.value[modelLine - 1]; + if (modelLine === undefined || lineText === undefined) continue; + const foldAtLine = foldByStartLine.value.get(modelLine) ?? null; + lines.push({ + lineNumber: modelLine, + segments: buildJsonLineSegments(lineText), + foldAtLine, + isFoldCollapsed: foldAtLine !== null && collapsedStartLines.value.has(foldAtLine.startLine), + }); } return lines; }); -const totalHeightPx = computed(() => lineList.value.length * JSON_VIEW_ROW_HEIGHT_PX); +const totalHeightPx = computed(() => visibleModel.value.length * JSON_VIEW_ROW_HEIGHT_PX); const gutterWidth = computed(() => computeGutterWidth(lineList.value.length)); /* ---------- 行选区(model 层锚点,编辑器 model/view 分离的微缩版) ---------- @@ -113,24 +217,25 @@ function isLineSelected(lineNumber: number): boolean { } /** 同步真实 DOM Selection:让 Ctrl+C 直接可用。 - * anchor/active 行可能不在窗口内(DOM 不存在),用窗口内实际渲染的边缘行近似。 + * 选区端点是 model 行号:先钳制到可见(划进隐藏区停在折叠头行),再转可视索引, + * 与可视窗口求交集后索引窗口内实际渲染的边缘行(anchor/active 行可能不在窗口内)。 */ function syncDomSelection(): void { const range = selectedLineRange.value; const container = scrollContainerRef.value; if (!range || !container) return; const [start, end] = range; + const clampedStart = clampSelectionLine(start); + const clampedEnd = clampSelectionLine(end); const { startIndex, endIndex } = visibleWindow.value; - // 选区与可视窗口的交集边缘(1-based 行号 → 渲染索引) - const viewStartLine = startIndex + 1; - const viewEndLine = endIndex + 1; - const selStartLine = Math.max(start, viewStartLine); - const selEndLine = Math.min(end, viewEndLine); - if (selStartLine > selEndLine) return; + // 可视索引(0-based)与可视窗口的交集(窗口起止也是可视索引) + const selStartVisIdx = Math.max(findVisibleIndex(visibleModel.value, clampedStart), startIndex); + const selEndVisIdx = Math.min(findVisibleIndex(visibleModel.value, clampedEnd), endIndex); + if (selStartVisIdx > selEndVisIdx) return; const lineElements = container.querySelectorAll('.line'); - const startEl = lineElements[selStartLine - viewStartLine]; - const endEl = lineElements[selEndLine - viewStartLine]; + const startEl = lineElements[selStartVisIdx - startIndex]; + const endEl = lineElements[selEndVisIdx - startIndex]; if (!startEl || !endEl) return; const domSelection = window.getSelection(); @@ -148,8 +253,8 @@ function handleGutterMouseDown(lineNumber: number, event: MouseEvent): void { // 只响应主键,避免与右键菜单/中键冲突 if (event.button !== 0) return; event.preventDefault(); // 防止浏览器默认文本拖选与我们的状态机冲突 - selectionAnchorLine.value = lineNumber; - selectionActiveLine.value = lineNumber; + selectionAnchorLine.value = clampSelectionLine(lineNumber); + selectionActiveLine.value = clampSelectionLine(lineNumber); syncDomSelection(); const finish = (): void => { @@ -162,7 +267,7 @@ function handleGutterMouseDown(lineNumber: number, event: MouseEvent): void { const onMove = (moveEvent: MouseEvent): void => { const lineAtPointer = resolveLineFromPointer(moveEvent.clientY); if (lineAtPointer === null) return; - selectionActiveLine.value = lineAtPointer; + selectionActiveLine.value = clampSelectionLine(lineAtPointer); syncDomSelection(); updateEdgeAutoScroll(moveEvent.clientY); }; @@ -170,15 +275,18 @@ function handleGutterMouseDown(lineNumber: number, event: MouseEvent): void { document.addEventListener('mouseup', finish, { once: true }); } -/** 指针 y 坐标 → 行号(1-based);基于容器几何而非 event.target(拖拽中 target 可能是任意节点) */ +/** 指针 y 坐标 → model 行号(1-based);基于容器几何而非 event.target(拖拽中 target 可能是任意节点)。 + * 几何换算先得可视索引,再经投影反查 model 行号——隐藏行不在投影里,天然不会被命中。 + */ function resolveLineFromPointer(clientY: number): number | null { const container = scrollContainerRef.value; if (!container) return null; const rect = container.getBoundingClientRect(); const offsetY = clientY - rect.top + container.scrollTop; - const lineIndex = Math.floor(offsetY / JSON_VIEW_ROW_HEIGHT_PX); - if (lineIndex < 0 || lineIndex >= lineList.value.length) return null; - return lineIndex + 1; + const visIdx = Math.floor(offsetY / JSON_VIEW_ROW_HEIGHT_PX); + if (visIdx < 0 || visIdx >= visibleModel.value.length) return null; + const modelLine = visibleModel.value[visIdx]; + return modelLine === undefined ? null : modelLine; } /* ---------- 拖到容器边缘的自动滚动(编辑器标配) ---------- */ @@ -236,17 +344,16 @@ function updateEdgeAutoScroll(pointerClientY: number): void { } container.scrollTop += speed; // 自动滚动后按新的 scrollTop 推进选区(与滚轮/滚动条共用 scrollTopPx 同源链路) - const lineAtEdge = - speed < 0 - ? Math.max(1, Math.floor(container.scrollTop / JSON_VIEW_ROW_HEIGHT_PX) + 1) - : Math.min( - lineList.value.length, - Math.floor( - (container.scrollTop + container.clientHeight - 1) / JSON_VIEW_ROW_HEIGHT_PX, - ) + 1, - ); - selectionActiveLine.value = lineAtEdge; - syncDomSelection(); + const topVisIdx = Math.max(0, Math.floor(container.scrollTop / JSON_VIEW_ROW_HEIGHT_PX)); + const bottomVisIdx = Math.min( + visibleModel.value.length - 1, + Math.floor((container.scrollTop + container.clientHeight - 1) / JSON_VIEW_ROW_HEIGHT_PX), + ); + const lineAtEdge = speed < 0 ? visibleModel.value[topVisIdx] : visibleModel.value[bottomVisIdx]; + if (lineAtEdge !== undefined) { + selectionActiveLine.value = clampSelectionLine(lineAtEdge); + syncDomSelection(); + } edgeScrollRafId = requestAnimationFrame(step); }; edgeScrollRafId = requestAnimationFrame(step); @@ -273,6 +380,16 @@ function updateEdgeAutoScroll(pointerClientY: number): void { @mousedown="handleGutterMouseDown(line.lineNumber, $event)" >{{ line.lineNumber }} + {{ line.isFoldCollapsed ? '›' : '⌄' }} + + {{ line.foldAtLine.kind === 'object' ? '{…}' : '[…]' }} + {{ line.foldAtLine.hiddenLineCount }} 行 @@ -311,6 +437,44 @@ function updateEdgeAutoScroll(pointerClientY: number): void { user-select: none; } +/* 折叠 chevron 列:占位透明保持行号与内容的间距恒定;可折叠行 hover 显示、已折叠常显 */ +.fold-chevron { + display: inline-block; + width: 1.5ch; + text-align: center; + user-select: none; + cursor: pointer; + color: var(--td-text-color-placeholder); + visibility: hidden; +} + +.fold-chevron-placeholder { + cursor: default; +} + +.line:hover > .fold-chevron { + visibility: visible; +} + +.fold-chevron-collapsed { + visibility: visible; + color: var(--td-brand-color); + font-weight: 700; +} + +/* 折叠头行行尾摘要:hover 提示可点击展开 */ +.fold-summary { + margin-left: 8px; + font-size: 11.5px; + color: var(--td-text-color-placeholder); + cursor: pointer; + user-select: none; +} + +.fold-summary:hover { + color: var(--td-brand-color); +} + /* model 层选区高亮:重渲染不丢(DOM ::selection 会随虚拟滚动窗口变化失效) */ .line.line-selected { background: var(--td-brand-color-light-focus, rgba(0, 82, 217, 0.18));