Archived
feat(view): 行号点击选中与拖拽多选(model 层选区)+ 实现技术博客
选区状态存 model 层(anchor/active 两个行号)而非 DOM Selection——虚拟滚动跨窗口边界时 DOM 被卸载,原生选区必丢;单行点击是 anchor===active 的退化特例。高亮走 .line-selected class(model 驱动,重渲染不丢),DOM Selection 仅作 Ctrl+C 同步目标(窗口内边缘行 setStartBefore/setEndAfter);拖到容器边缘 rAF 自动滚动,速度每帧按指针位置重算(闭包旧值会导致速率漂移),出界外推贴边保持滚动。新增 2 用例(点击选中含 DOM Selection 文本断言、拖拽范围高亮)。附实现技术博客 docs/JSON预览器实现技术博客.md。
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,6 +80,177 @@ const visibleLineList = computed<RenderableLine[]>(() => {
|
||||
|
||||
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<number | null>(null); // mousedown 的行号(1-based)
|
||||
const selectionActiveLine = ref<number | null>(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<HTMLElement>('.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);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -89,8 +260,19 @@ const gutterWidth = computed(() => computeGutterWidth(lineList.value.length));
|
||||
class="visible-window"
|
||||
:style="{ transform: `translateY(${visibleWindow.offsetYPx}px)` }"
|
||||
>
|
||||
<div v-for="line in visibleLineList" :key="line.lineNumber" class="line">
|
||||
<span class="gutter" :style="{ width: `${gutterWidth}ch` }">{{ line.lineNumber }}</span>
|
||||
<div
|
||||
v-for="line in visibleLineList"
|
||||
:key="line.lineNumber"
|
||||
class="line"
|
||||
:class="{ 'line-selected': isLineSelected(line.lineNumber) }"
|
||||
>
|
||||
<span
|
||||
class="gutter"
|
||||
:class="{ 'gutter-selected': isLineSelected(line.lineNumber) }"
|
||||
:style="{ width: `${gutterWidth}ch` }"
|
||||
@mousedown="handleGutterMouseDown(line.lineNumber, $event)"
|
||||
>{{ line.lineNumber }}</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">{{
|
||||
@@ -129,6 +311,16 @@ const gutterWidth = computed(() => computeGutterWidth(lineList.value.length));
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* model 层选区高亮:重渲染不丢(DOM ::selection 会随虚拟滚动窗口变化失效) */
|
||||
.line.line-selected {
|
||||
background: var(--td-brand-color-light-focus, rgba(0, 82, 217, 0.18));
|
||||
}
|
||||
|
||||
.gutter.gutter-selected {
|
||||
color: var(--td-brand-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.line-content {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user