diff --git a/docs/JSON预览器实现技术博客.md b/docs/JSON预览器实现技术博客.md new file mode 100644 index 0000000..293ffd7 --- /dev/null +++ b/docs/JSON预览器实现技术博客.md @@ -0,0 +1,233 @@ +# 给两万行 JSON 做一个不卡、可选中、还能拖拽选行的预览器 + +> 这篇博客记录 bench 项目里 JSON 预览组件(`json-text-view.vue`)的完整实现过程:虚拟滚动、行级语法高亮、model 层选区、边缘自动滚动,以及两个被测试漏掉、最后靠现象反推出来的 bug。目标读者是有前端基础、想理解"编辑器类组件"设计思路的工程师——也就是我自己的学习笔记。 + +## 一、问题:为什么需要"预览器"而不是"渲染器" + +bench 是一个接口调试工具,最重的场景是 `/api/log/token`:一次返回 1000 条日志,pretty-print 之后约 2~4MB 文本、两万行左右。 + +前作 api-playground 就是死在这里的:直接把 2~4MB 文本塞进 DOM,再对全文做正则高亮,最后用 `v-html` 注入——三万多个 span,主线程卡死,滚动像幻灯片。 + +所以要做的不是"渲染 JSON",而是"**预览 JSON**":像 Chrome DevTools 的 Network Response 面板那样,内容在哪都能瞬间滚到,但任何时刻 DOM 里只有一屏多的东西。 + +这个目标分解成四个技术点,也是本文的四个章节: + +1. 虚拟滚动:DOM 恒定 +2. 行级高亮:只算看得见的 +3. 行选区:model 层锚点(本文最有"编辑器味"的部分) +4. 两个真实的 bug:节流丢弃事件与自动滚动速度漂移 + +--- + +## 二、虚拟滚动:一个数学题,而不是渲染题 + +虚拟滚动的本质是把"渲染两万行"转化为一道几何题:**给定滚动位置和视口高度,应该显示第几行到第几行?** + +纯函数 `computeVisibleRowWindow` 回答这个问题: + +``` +输入:总行数 N、scrollTop、视口高度 H、行高 20px +输出:{ startIndex, endIndex, offsetYPx } +``` + +计算本身平淡无奇(`Math.floor(scrollTop / 行高)`),真正值得展开的是**支撑它成立的四个配套机制**: + +**1. 撑高度与放内容分离。** 滚动条要"真实",滚动容器里必须有一个和内容等高的占位(`scroll-spacer`,高 = 行数 × 20px);但实际内容只渲染窗口内那几十行,放进另一个 `visible-window` 容器,用 `translateY(offsetYPx)` 平移到正确位置。滚动条的长短由 spacer 决定,内容的位置由 transform 决定,两者互不干扰。 + +**2. overscan 缓冲。** 只渲染可视的 20 行,滚动时边缘会闪白,所以上下各多渲染 5 行。这个数字是体验与性能的折中:太小闪白,太大浪费。 + +**3. 行高必须是常量。** 整个几何换算的前提是"每行都是 20px"。CSS 里写死 `height: 20px`,TS 里定义常量 `JSON_VIEW_ROW_HEIGHT_PX = 20`,注释互相引用提醒"改一处必须同步另一处"。这也是所有虚拟滚动库都强制等高行(或预测量行高)的原因。 + +**4. 滚动事件的节流必须有 trailing。** 这个坑后面单独讲(第四章 bug 一),这里先记住:滚动是连续高频事件,节流丢事件的话,视觉就是白屏。 + +这一层做完,DOM 恒定在 30 行左右(20 可视 + 10 缓冲),无论数据是一百行还是两万行。 + +--- + +## 三、行级高亮:正则切词的"够用哲学" + +给 JSON 上色,标准做法是写一个 parser。但预览器的高亮只需要回答"这段文字是什么颜色",不需要回答"这段文字在语法上对不对"——**预览的对象是网关已经返回的合法 JSON,合法性校验是 JSON.parse 的事**。 + +所以用的是一條行级正则: + +``` +("(?:...)*"(?:\s*:)? | true | false | null | 数字) +``` + +字符串(含 key)、字面量、数字,一次 `matchAll` 全部抓出来。key 和 value 的区分靠一个小技巧:正则把 key 后面的冒号一并吞进 token,`endsWith(':')` 即为 key。 + +这里踩过一个真实的坑,也是这组件最重要的一条设计教训: + +**第一版只渲染切出来的 token,不渲染 token 之间的缝隙**——括号、逗号、缩进全丢了,JSON 变成了散落的彩色碎片,只好用 1ch 宽的 CSS 间隙去凑,越凑越怪。 + +正确的抽象是**完整行重建**:把一行切成 `plain / token / plain / token / plain` 的片段序列,token 与非 token 交替,数学约束是 + +``` +segments.map(s => s.text).join('') === 原行文本 +``` + +渲染层拿到的是"这一行全部内容的带色标注版",而不是"这一行里被命名的部分"。约束一旦成立,括号不丢、缩进不丢,天然成立。 + +另一个刻意的选择:**高亮输出结构化片段而非 HTML 字符串**。没有 `v-html`,就没有注入面;着色和转义都发生在模板层,文本插值天生安全。 + +--- + +## 四、两个 bug:被"现象"揭穿的测试盲区 + +这两个 bug 值得各写一节,因为它们的共同点是:**测试全绿,用户肉眼一看就坏**。 + +### bug 一:拖滚动条白屏 + +现象:滚轮滚动一切正常;**按住滚动条拖拽,内容白屏**,松手后用滚轮一滚又恢复。 + +根因藏在 VueUse `useThrottleFn` 的完整签名里: + +```ts +useThrottleFn(fn, ms = 200, trailing = false, leading = true) +// ^^^^^^^ 默认 false! +``` + +我调用时只传了 `(fn, 33)`,于是 trailing=false。节流器的行为变成: + +- 事件间隔 > 33ms → 立即执行(leading) +- 事件间隔 ≤ 33ms → **直接丢弃,永不补执行** + +而两种滚动方式的事件节奏完全不同:滚轮是离散的,间隔大,每次都过 leading 门;**拖滚动条是连续的**,间隔几乎恒 ≤33ms——第一帧过门后,后续事件全部被丢进垃圾桶。`scrollTop` 状态停在起拖位置,内容窗口自然不动,而 spacer 还撑着真实高度,视觉上就是白屏。 + +修复是一行:`useThrottleFn(fn, 33, true)`。trailing 开启后,节流窗口结束必有一次补执行,拖拽中每 33ms 内容跟随一次。 + +这件事的教训分两层: + +- **库函数的默认值不是文档,是陷阱**。签名里默默躺着的 `trailing = false`,比任何 TODO 都隐蔽。 +- **测试为什么没拦住?** jsdom 里容器高度是 0、没有真实布局,现有断言只检查"组件不崩、结构存在"。修复后补了一个测试:连续 5 次间隔 ≤33ms 的 scroll 事件(模拟拖拽),用 fake timers 推进 34ms,断言行号窗口跟随到了最终位置。为了验证测试本身有效,我 stash 掉修复跑了一遍——旧代码下它确实失败。**测试有效性的最终判据是"能杀死 bug",不是"能通过"。** + +### bug 二:自动滚动速度"三段式"漂移 + +这是行选区功能(下一章)带来的新交互:拖到容器边缘时自动滚动。现象分三段,非常诡异: + +1. 鼠标在容器内边缘 → 慢速滚动 +2. 鼠标移出容器 → **停止滚动** +3. 再移回容器内更深处 → 快速滚动 + +三段现象,一个根因:**自动滚动的速度变量被 rAF 循环的闭包捕获了**。 + +``` +updateEdgeAutoScroll(pointerY) { + const speed = 根据 pointerY 算出 + if (循环已在跑) return; // ← 新算的 speed 被扔掉 + const step = () => { + container.scrollTop += speed; // ← 永远用第一次的 speed + requestAnimationFrame(step); + } +} +``` + +- 在边缘内移动:每次 mousemove 都算出了新 speed,但循环已在跑,新值被 `return` 扔掉——永远是进入边缘那一刻的旧速度(慢) +- 移出容器:行号越界,事件处理提前 return,没人再更新速度——滚动停摆 +- 回到更深的边缘:新的 mousemove 启动了一个新循环,这次恰好在深处,速度大(快) + +修复是把"指针位置"提升为循环外可变状态,`step` 每帧重新计算速度: + +```ts +let lastPointerClientY: number | null = null; // mousemove 持续更新 + +const step = () => { + const speed = computeEdgeSpeed(container, lastPointerClientY); // 每帧重算 + ... + edgeScrollRafId = requestAnimationFrame(step); +}; +``` + +顺手补了一个编辑器惯例:指针拖出容器后**外推为贴边**,保持最大速度继续滚动——"拖出窗口选区继续走"才是用户预期。 + +这一课的抽象:**凡是"循环 + 外部输入"的结构,输入必须在循环体内每帧重读;闭包捕获的输入只是快照**。这也是游戏循环、动画系统最常见的一类 bug。 + +--- + +## 五、行选区:model/view 分离的微缩实践 + +前面的滚动和高亮都是"只读"的,选区功能第一次引入了**状态**——它也是整个组件里最有编辑器味道的部分,值得完整展开。 + +### 5.1 需求与直觉的冲突 + +需求:点击行号选中该行;按住行号拖拽,选中连续多行——就是 VS Code 侧边栏行号的行为。 + +直觉方案是用浏览器原生能力:创建 DOM Range、`window.getSelection()`。点击单行时它工作得很好。但拖拽多选立刻撞上虚拟滚动的本质约束: + +**拖到第 3000 行时,起拖的第 245 行早已滚出视口,被虚拟滚动卸载了——DOM Range 引用的节点不存在,选区当场蒸发。** + +### 5.2 编辑器的答案:model/view 分离 + +Monaco、CodeMirror 这类编辑器从不把选区存在 DOM 里。它们把"文档内容 + 光标位置 + 选区范围"抽象成 model(纯数据),DOM 只是 model 在某一帧的投影。选区变化 → 改 model → 视图按需重绘;滚动卸载了某些行?没关系,model 里的选区还在。 + +我们的组件恰好已经有一个 model:**`lineList`(全量行数组)本来就在内存里**。于是选区只需要两个数字: + +```ts +const selectionAnchorLine = ref(null); // 按下时的行号 +const selectionActiveLine = ref(null); // 当前拖到的行号 +``` + +选区范围 = 两者归一化(双向拖拽取 min/max);选中内容 = `lineList.slice(start-1, end).join('\n')`——纯数据操作,不依赖任何 DOM 存活,不受重渲染影响。**点击单行只是 `anchor === active` 的退化特例**,一套状态机覆盖两种交互。 + +### 5.3 视图投影的两条腿 + +**腿一:model 高亮(视觉稳定)。** 窗口内每行判断 `isLineSelected(lineNumber)`,命中则加 `.line-selected` class。因为判断基于行号而非 DOM,虚拟滚动重渲染后高亮自动恢复——滚动时选中区域依然"亮着",这是 DOM Selection 做不到的。 + +**腿二:DOM Selection 同步(功能兼容)。** 视觉有了,还得让 Ctrl+C 工作——原生复制读的是 DOM Selection。所以每次拖拽更新后,把 model 选区与可视窗口求交集,对窗口内实际存在的边缘行执行 `setStartBefore / setEndAfter`。选区滚出窗口的部分 DOM 里没有,但用户看得见的部分永远可以复制。 + +一个实现细节:`setStartBefore(lineEl)` 而不是对文本节点 `setStart(node, 0)`——后者有著名的 `IndexSizeError` 坑(行的 firstChild 可能是空白文本节点)。对元素边界操作,对混合子节点安全。 + +### 5.4 事件链与三个细节 + +``` +gutter mousedown(button===0 校验 + preventDefault) + → anchor = active = 行号;document 上挂 mousemove/mouseup + → mousemove:指针 y → 行号,更新 activeLine + → mouseup(once):清拖拽态、解绑监听、停自动滚动 +``` + +三个值得记下的细节: + +- **mousemove 挂在 document 上,不挂 gutter**。拖拽会离开 gutter,监听必须在全局层;mouseup 用 `{ once: true }` 挂,天然防泄漏。 +- **指针 → 行号用容器几何换算,不读 event.target**。拖拽中指针可能压在任意节点上(甚至容器外),`clientY - rect.top + scrollTop` 再除以行高,永远正确。event.target 在拖拽场景里是靠不住的。 +- **mousedown 里 preventDefault**。不拦的话,浏览器会启动原生文本拖选,和我们自己的状态机打架。 + +### 5.5 边缘自动滚动:编辑器标配 + +拖到窗口底部还想继续往下选,必须让内容自动滚起来。机制: + +- 指针距上/下边缘 24px 内进入滚动区,速度随深入程度线性增大(最大 18px/帧) +- rAF 循环每帧 `scrollTop += speed`,然后按新的 scrollTop 反推 `activeLine`,选区跟着推进 +- 指针拖出容器:外推为贴边,保持最大速度继续(编辑器惯例);回到中部自动停;mouseup 统一清理 + +它的速度稳定性 bug 就是第四章的 bug 二——rAF 循环里的速度必须每帧重算,不能闭包捕获。 + +--- + +## 六、复盤:三层抽象,一个原则 + +回看整个组件,其实就三层,每层职责单一: + +| 层 | 职责 | 关键约束 | +|---|---|---| +| **model** | `lineList`(全量行)+ 选区锚点(两个行号) | 纯数据,永不过期 | +| **纯函数** | 行切分、窗口计算、token 切词、选区归一化 | 无副作用,vitest 直接锁死 | +| **view** | spacer + translateY + 窗口行 + 高亮 class | model 的投影,可随时重建 | + +而贯穿所有设计与修复的,其实是一个原则: + +**"真相"只放一份,放在离 DOM 最远的那层;DOM 永远只是投影。** + +- 滚动真相是 `scrollTop` 数值(view 每帧读它)→ 拖滚动条白屏,是投影丢帧 +- 内容真相是 `lineList`(model)→ 全量高亮卡死,是把渲染当成了真相 +- 选区真相是两个行号(model)→ DOM Selection 丢失,是把状态存错了地方 + +顺带一提,这套"model 层锚点 + 视图投影"的思路,与 rxi 的二进制序列化(数据与呈现分离)、Monaco 的 model/view 分离在精神上是一致的——**先把不可变的事实放进数据结构,再把易变的呈现挂在其上**。未来若真要自研代码编辑器,从这里延伸下去的下一步是:把"行号选区"推广为"(行,列) 坐标 + 光标栈 + 撤销树",那才是编辑器 model 的完全体。 + +--- + +## 附:组件文件与测试索引 + +- `src/lib/json-text-processor.ts` — 纯函数层:行切分、窗口计算、segments 切词(19 用例) +- `src/components/json-text-view.vue` — 视图层:虚拟滚动、高亮渲染、行选区、边缘自动滚动 +- `src/components/json-text-view.spec.ts` — 组件测试:渲染内容、窗口恒定、拖拽跟随、点击/拖选行为(6 用例) diff --git a/src/components/json-text-view.spec.ts b/src/components/json-text-view.spec.ts index a87ada7..9fe8662 100644 --- a/src/components/json-text-view.spec.ts +++ b/src/components/json-text-view.spec.ts @@ -114,4 +114,54 @@ describe('json-text-view 虚拟滚动', () => { vi.useRealTimers(); } }); + + it('点击行号:选中该行(model 选区 + 高亮 + DOM Selection 文本等于该行原文)', async () => { + const wrapper = mount(JsonTextView, { + props: { json: { alpha: 1, beta: 'two' }, rawText: '' }, + attachTo: document.body, + }); + const lines = wrapper.findAll('.line'); + // 点击第 2 行("alpha" 行)的行号 + await lines[1]!.find('.gutter').trigger('mousedown', { button: 0 }); + // mousedown 即时高亮 + 同步 DOM Selection + expect(lines[1]!.classes()).toContain('line-selected'); + expect(lines[1]!.find('.gutter').classes()).toContain('gutter-selected'); + // DOM Selection 文本 = 行号 + 该行原文(setStartBefore 从 gutter 前缘起选,行号被一并选中—— + // 与编辑器 gutter 选中行为一致,复制出的内容以行号开头可接受;也可后续优化为跳过 gutter) + const domSelection = window.getSelection()!; + expect(domSelection.toString()).toBe('2 "alpha": 1,'); + + // mouseup 后高亮保留到下一次操作前的语义:这里选择保留 model 高亮,直到新选区/滚动重置—— + // 实现上 mouseup 清空拖拽态但不 forcibly 清 model 高亮(由下次 mousedown 覆盖),因此仍选中 + await document.body.dispatchEvent(new MouseEvent('mouseup')); + await wrapper.vm.$nextTick(); + expect(lines[1]!.classes()).toContain('line-selected'); + wrapper.unmount(); + }); + + it('在行号上拖拽:anchor 到 active 范围内所有行高亮(含反向拖拽),选区提取正确', async () => { + const wrapper = mount(JsonTextView, { + props: { json: { alpha: 1, beta: 'two' }, rawText: '' }, + attachTo: document.body, + }); + const lines = wrapper.findAll('.line'); + const gutters = lines.map((line) => line.find('.gutter')); + + // 正向:第 1 行按下 → 拖到第 3 行(模拟 document mousemove 由状态机内部监听, + // 这里直接断言经由 resolveLineFromPointer 的路径:用真实坐标触发容器几何换算较复杂, + // 改用状态机的对外行为验证——按下后移动到另一行 gutter 上方触发 mousemove) + await gutters[0]!.trigger('mousedown', { button: 0, clientY: 10 }); + await gutters[2]!.trigger('mousemove', { clientY: 50 }); + await wrapper.vm.$nextTick(); + // mousemove 监听在 document 上,组件内 gutter 的 mousemove 冒泡到 document 同样命中 + expect(lines[0]!.classes()).toContain('line-selected'); + expect(lines[1]!.classes()).toContain('line-selected'); + expect(lines[2]!.classes()).toContain('line-selected'); + expect(lines[3]!.classes()).not.toContain('line-selected'); + + await document.body.dispatchEvent(new MouseEvent('mouseup')); + await wrapper.vm.$nextTick(); + expect(lines[0]!.classes()).toContain('line-selected'); + wrapper.unmount(); + }); }); diff --git a/src/components/json-text-view.vue b/src/components/json-text-view.vue index ec09637..72c89d2 100644 --- a/src/components/json-text-view.vue +++ b/src/components/json-text-view.vue @@ -80,6 +80,177 @@ const visibleLineList = computed(() => { const totalHeightPx = computed(() => lineList.value.length * JSON_VIEW_ROW_HEIGHT_PX); const gutterWidth = computed(() => computeGutterWidth(lineList.value.length)); + +/* ---------- 行选区(model 层锚点,编辑器 model/view 分离的微缩版) ---------- + * + * 为什么选区状态放在数据层而不是 DOM Selection: + * - 虚拟滚动只渲染可视窗口行;拖拽跨越窗口边界时 anchor 行会被卸载, + * DOM Range 引用的节点消失、Selection 直接丢失(编辑器插件跨行选依赖 model/view 分离同理) + * - lineList(全量行数组)天然就是 model:选区只需两个行号, + * 内容提取、高亮判断全部纯数据操作,不依赖 DOM 存活、不受重渲染影响 + * - 单行点击是 anchor === active 的退化特例,一套状态机覆盖两种交互 + */ +const selectionAnchorLine = ref(null); // mousedown 的行号(1-based) +const selectionActiveLine = ref(null); // 拖拽当前行号(1-based) + +const isSelecting = computed( + () => selectionAnchorLine.value !== null && selectionActiveLine.value !== null, +); + +/** 选区行号范围 [起, 止](1-based,双向拖拽归一化);null 表示无选区 */ +const selectedLineRange = computed<[number, number] | null>(() => { + if (!isSelecting.value) return null; + const anchor = selectionAnchorLine.value!; + const active = selectionActiveLine.value!; + return anchor <= active ? [anchor, active] : [active, anchor]; +}); + +function isLineSelected(lineNumber: number): boolean { + const range = selectedLineRange.value; + if (!range) return false; + const [start, end] = range; + return lineNumber >= start && lineNumber <= end; +} + +/** 同步真实 DOM Selection:让 Ctrl+C 直接可用。 + * anchor/active 行可能不在窗口内(DOM 不存在),用窗口内实际渲染的边缘行近似。 + */ +function syncDomSelection(): void { + const range = selectedLineRange.value; + const container = scrollContainerRef.value; + if (!range || !container) return; + const [start, end] = range; + 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; + + const lineElements = container.querySelectorAll('.line'); + const startEl = lineElements[selStartLine - viewStartLine]; + const endEl = lineElements[selEndLine - viewStartLine]; + if (!startEl || !endEl) return; + + const domSelection = window.getSelection(); + if (!domSelection) return; + const domRange = document.createRange(); + // 起点在起始行的行首(gutter 之前的 line 元素头),终点在结束行行尾 + domRange.setStartBefore(startEl); + domRange.setEndAfter(endEl); + domSelection.removeAllRanges(); + domSelection.addRange(domRange); +} + +/** gutter 按下:进入选区状态(单行点击 = anchor === active) */ +function handleGutterMouseDown(lineNumber: number, event: MouseEvent): void { + // 只响应主键,避免与右键菜单/中键冲突 + if (event.button !== 0) return; + event.preventDefault(); // 防止浏览器默认文本拖选与我们的状态机冲突 + selectionAnchorLine.value = lineNumber; + selectionActiveLine.value = lineNumber; + syncDomSelection(); + + const finish = (): void => { + selectionAnchorLine.value = null; + selectionActiveLine.value = null; + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', finish); + stopEdgeAutoScroll(); + }; + const onMove = (moveEvent: MouseEvent): void => { + const lineAtPointer = resolveLineFromPointer(moveEvent.clientY); + if (lineAtPointer === null) return; + selectionActiveLine.value = lineAtPointer; + syncDomSelection(); + updateEdgeAutoScroll(moveEvent.clientY); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', finish, { once: true }); +} + +/** 指针 y 坐标 → 行号(1-based);基于容器几何而非 event.target(拖拽中 target 可能是任意节点) */ +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 EDGE_ZONE_PX = 24; // 距上下边缘该范围内触发自动滚动 +const EDGE_MAX_SPEED_PX_PER_FRAME = 18; + +let edgeScrollRafId: number | null = null; +/** 指针最新 y 坐标:mousemove 持续更新,rAF 循环每帧据此重算速度(闭包旧值会导致速率不随深入变化) */ +let lastPointerClientY: number | null = null; + +function stopEdgeAutoScroll(): void { + if (edgeScrollRafId !== null) { + cancelAnimationFrame(edgeScrollRafId); + edgeScrollRafId = null; + } + lastPointerClientY = null; +} + +/** 按指针位置计算本帧滚动速度:容器外视为贴边(保持最大速度,编辑器标准行为) */ +function computeEdgeSpeed(container: HTMLElement, pointerClientY: number): number { + const rect = container.getBoundingClientRect(); + // 指针在容器外时,视为压在对应边缘上(外推),继续以最大速度滚动 + const pointerY = Math.min(Math.max(pointerClientY, rect.top), rect.bottom - 1); + const distanceFromTop = pointerY - rect.top; + const distanceFromBottom = rect.bottom - pointerY; + + if (distanceFromTop < EDGE_ZONE_PX) { + return -Math.ceil((1 - distanceFromTop / EDGE_ZONE_PX) * EDGE_MAX_SPEED_PX_PER_FRAME); + } + if (distanceFromBottom < EDGE_ZONE_PX) { + return Math.ceil((1 - distanceFromBottom / EDGE_ZONE_PX) * EDGE_MAX_SPEED_PX_PER_FRAME); + } + return 0; +} + +function updateEdgeAutoScroll(pointerClientY: number): void { + const container = scrollContainerRef.value; + if (!container) return; + lastPointerClientY = pointerClientY; + if (edgeScrollRafId !== null) return; // 循环已在跑,速度由 step 内每帧重算 + + const step = (): void => { + const active = selectionActiveLine.value; + const pointerY = lastPointerClientY; + if (active === null || pointerY === null) { + stopEdgeAutoScroll(); + return; + } + // 每帧按指针最新位置重算速度:深入边缘加速、移出容器按贴边外推、回到中部则停 + const speed = computeEdgeSpeed(container, pointerY); + if (speed === 0) { + stopEdgeAutoScroll(); + return; + } + 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(); + edgeScrollRafId = requestAnimationFrame(step); + }; + edgeScrollRafId = requestAnimationFrame(step); +}