Archived
feat(view): JSON 折叠交互——chevron、行尾摘要、滚动补偿与折叠全部
- json-text-view:投影层接线(可视窗口/spacer/行号反查/指针换算/边缘滚动八接入点);折叠事务带 scrollTop 补偿(锚点=视口顶部 model 行号,折叠视口上方块无跳变);选区端点写入前钳制,syncDomSelection 修正坐标混用隐患;props.json 变化重置折叠态;chevron(hover 显/已折叠常显)与行尾摘要 {…} N 行;非 JSON 响应折叠 UI 整体关闭(hasFolds 门控)
- endpoint-panel:meta 行「折叠全部/展开全部」(收 depth=1 顶层块,经 defineExpose 通道)
- 新增 6 个视图层测试(折叠渲染/chevron 交互/选区隔离/补偿无跳变/状态重置/非 JSON 降级)
- 附技术博客 docs/JSON预览器代码折叠技术博客.md
- 全量 89/89 四绿(test/type-check/lint/build)
This commit is contained in:
@@ -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<InstanceType<typeof JsonTextView> | 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 }}
|
||||
</span>
|
||||
<span class="meta-spacer"></span>
|
||||
<t-button v-if="foldable" size="small" variant="outline" @click="collapseAllFolds">
|
||||
折叠全部
|
||||
</t-button>
|
||||
<t-button v-if="foldable" size="small" variant="outline" @click="expandAllFolds">
|
||||
展开全部
|
||||
</t-button>
|
||||
<t-button
|
||||
v-if="clipboardSupported"
|
||||
size="small"
|
||||
@@ -160,7 +188,7 @@ function downloadResponseAsFile(): void {
|
||||
</t-button>
|
||||
<t-button size="small" variant="outline" @click="downloadResponseAsFile">下载</t-button>
|
||||
</div>
|
||||
<JsonTextView :json="responseData.json" :raw-text="responseData.text" />
|
||||
<JsonTextView ref="jsonTextViewRef" :json="responseData.json" :raw-text="responseData.text" />
|
||||
</section>
|
||||
<section v-else-if="!hasSent" class="panel-section response-placeholder">
|
||||
{{ requiresKey ? '填写 sk-key 后点击「发送请求」' : '点击「发送请求」查看站点配置' }}
|
||||
|
||||
@@ -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<void> }).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: '<html>502</html>' },
|
||||
attachTo: document.body,
|
||||
});
|
||||
expect(wrapper.findAll('.fold-chevron')).toHaveLength(0);
|
||||
expect(wrapper.findAll('.fold-summary')).toHaveLength(0);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ReadonlySet<number>>(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<void> {
|
||||
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<HTMLElement | null>(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<typeof buildJsonLineSegments>;
|
||||
/** 该行是否为某个 fold 的折叠头(渲染 chevron 与行尾摘要) */
|
||||
foldAtLine: JsonFoldRange | null;
|
||||
/** 折叠头行当前是否处于折叠态(决定 chevron 方向与摘要显隐) */
|
||||
isFoldCollapsed: boolean;
|
||||
}
|
||||
|
||||
const visibleLineList = computed<RenderableLine[]>(() => {
|
||||
@@ -71,14 +168,21 @@ const visibleLineList = computed<RenderableLine[]>(() => {
|
||||
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<HTMLElement>('.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 }}</span
|
||||
>
|
||||
<span
|
||||
v-if="hasFolds && line.foldAtLine !== null"
|
||||
class="fold-chevron"
|
||||
:class="{ 'fold-chevron-collapsed': line.isFoldCollapsed }"
|
||||
:title="line.isFoldCollapsed ? '展开' : '折叠'"
|
||||
@mousedown.stop
|
||||
@click.stop="toggleFold(line.foldAtLine.startLine)"
|
||||
>{{ line.isFoldCollapsed ? '›' : '⌄' }}</span
|
||||
>
|
||||
<span v-else-if="hasFolds" class="fold-chevron fold-chevron-placeholder"></span>
|
||||
<span class="line-content">
|
||||
<template v-for="(segment, segmentIndex) in line.segments" :key="segmentIndex">
|
||||
<span v-if="segment.kind !== 'plain'" class="token" :class="segment.kind">{{
|
||||
@@ -280,6 +397,15 @@ function updateEdgeAutoScroll(pointerClientY: number): void {
|
||||
}}</span>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
</template>
|
||||
<span
|
||||
v-if="line.foldAtLine !== null && line.isFoldCollapsed"
|
||||
class="fold-summary"
|
||||
:title="`点击展开(${line.foldAtLine.hiddenLineCount} 行)`"
|
||||
@mousedown.stop
|
||||
@click.stop="toggleFold(line.foldAtLine.startLine)"
|
||||
>{{ line.foldAtLine.kind === 'object' ? '{…}' : '[…]' }}
|
||||
{{ line.foldAtLine.hiddenLineCount }} 行</span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user