背景与问题
最近在将 XEditor 插件兼容到 Typecho 1.3 时,我再次遇到了 Vditor 编辑器的一些功能限制。Vditor 虽然功能强大,但在某些细节实现上文档不够完善,公共方法也不够丰富。特别是当我需要判断光标是否位于行首时,发现官方提供的 getCursorPosition
方法返回的是像素坐标而非文本位置。
{
top: 10.32432
left: 10
}
这样的返回值对于判断光标在文本中的实际位置几乎没有帮助。翻阅 issue #1628 发现有人提出过类似需求,但官方解决方案并不明确。于是,我决定自己实现这一功能。
解决方案设计
第一步:获取光标所在节点
首先需要获取光标当前所在的 DOM 节点。通过浏览器原生的 Selection API 可以做到这一点:
let cursorElement, cursorRelativeCols;
function updateCursorElement() {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
cursorElement = null;
return;
}
const range = selection.getRangeAt(0);
const selectedNode = range.startContainer;
// 处理文本节点和元素节点的差异
cursorElement = selectedNode.nodeType === Node.TEXT_NODE
? selectedNode.parentElement
: selectedNode;
cursorRelativeCols = range.startOffset;
}
这段代码会在光标位置变化时更新 cursorElement
和 cursorRelativeCols
两个变量,分别表示光标所在的元素节点和在当前文本节点中的偏移量。
第二步:绑定编辑器事件
为了让光标位置信息实时更新,需要在 Vditor 初始化时绑定相关事件:
new Vditor('vditor', {
// ... 其他配置
blur: updateCursorElement,
focus: updateCursorElement,
input: updateCursorElement
});
这样无论是通过键盘输入、鼠标点击还是失去/获得焦点,都会触发光标位置的更新。
第三步:分析编辑器DOM结构
观察 Vditor 生成的 HTML 结构,发现它使用特定的标记来表示换行和文本内容:
<div data-block="0">
<span data-type="text">
</span>
<span data-type="newline"><br><span style="display: none"></span></span>
<span class="vditor-sv__marker">!</span>
<!-- 更多内容... -->
</div>
关键点在于:
<span data-type="newline">
表示换行标记- 文本内容都被包裹在
<span>
元素中
第四步:实现行号列号计算
基于上述DOM结构,可以实现光标位置的计算:
getCursorPosition() {
const position = editorInstance.getCursorPosition();
if (!cursorElement) {
return {
...position,
line: 1
};
}
// 计算行号:统计前面的换行标记数量
let line = 0, prevEl = cursorElement.previousElementSibling;
while (prevEl) {
if (prevEl.getAttribute('data-type') === 'newline') {
line++;
}
prevEl = prevEl.previousElementSibling;
}
// 计算列号:统计当前行前面的字符数
let text = "";
let el = cursorElement.closest('span:not([data-type="newline"])');
let col = cursorRelativeCols;
if (el) {
// 向前遍历所有同级元素直到遇到换行标记
let prevSibling = el.previousElementSibling;
while (prevSibling && prevSibling.getAttribute('data-type') !== 'newline') {
text = prevSibling.textContent + text; // 累加前面的文本内容
prevSibling = prevSibling.previousElementSibling;
}
col = cursorRelativeCols + text.length;
}
return {
...position,
line: line + 1, // 行号1开始
column: col + 1 // 列号1开始
}
}
技术细节解析
行号计算原理
行号的计算基于以下逻辑:
- 从当前光标所在元素开始,向前遍历所有兄弟节点
- 每遇到一个
data-type="newline"
的元素,行号加1 - 最终行号为遇到的换行标记数加1(因为第一行前面没有换行标记)
这种方法与VIM等编辑器的行号定位思路类似,只是实现方式不同。
列号计算原理
列号计算更为复杂:
- 首先找到光标所在的文本元素(跳过换行标记)
- 向前遍历同级元素,累加所有非换行元素的文本内容长度
- 最后加上光标在当前文本节点中的偏移量
- 结果加1转为1开始索引
边界情况处理
代码中已经考虑了多种边界情况:
- 没有选中任何内容时(
!cursorElement
)默认返回第一行 - 正确处理了文本节点和元素节点的差异
- 通过
closest()
方法确保只计算当前行内的元素 - 使用
previousElementSibling
而非previousSibling
避免文本节点干扰
替代方案探讨
除了上述实现,还有其他可能的解决方案:
- 修改Vditor源码:直接添加行列号返回功能
- 使用contenteditable的Range API:更精确地控制选区
- 基于Markdown源码计算:同步编辑内容和原始Markdown文本的位置
- 使用第三方库:如CodeMirror或Monaco Editor的定位算法
不过考虑到与现有系统的集成成本,当前方案可能是最实用的。
总结
通过深入分析Vditor的DOM结构和结合浏览器原生API,我们成功实现了光标行列号的精确定位功能。这个方案不仅解决了判断光标是否在行首的需求,还为后续开发更多基于位置的编辑器功能奠定了基础。
最后
新版基于 Vditor 的 XEditor 插件也分享了,不过只有原生功能按钮,其他的要你自己实现了。
精选留言