光标与选区是编辑器最核心的交互元素,几乎所有文本编辑操作都围绕它们展开。本文详细介绍光标渲染、选区管理、多选区实现与坐标系统,帮助你构建完善的编辑器交互体系。
光标数据模型
位置表示
编辑器内部使用偏移量(offset)来表示光标位置,这是最简单的表示方式。
// 光标位置表示
class Cursor {
constructor(this.offset = 0, this. affinity = 'none') {}
}
// 选区表示:起始和结束位置
class Selection {
constructor(this.anchor, this.head) {}
get isEmpty() { return this.anchor === this.head; }
get start() { return Math.min(this.anchor, this.head); }
get end() { return Math.max(this.anchor, this.head); }
}
坐标转换
需要在行/列坐标与偏移量之间进行转换。
// 行/列坐标系统
class Position {
constructor(this.line, this.column) {}
}
// 坐标与偏移量转换
class PositionMapper {
constructor(this.lineStarts) {} // 每行起始偏移量数组
offsetToPosition(offset) {
let lo = 0, hi = this.lineStarts.length - 1;
while (lo < hi) {
let mid = Math.ceil((lo + hi) / 2);
if (this.lineStarts[mid] > offset) hi = mid - 1;
else lo = mid;
}
const line = lo;
const column = offset - this.lineStarts[line];
return new Position(line, column);
}
positionToOffset(pos) {
if (pos.line >= this.lineStarts.length) return this.lineStarts.pop();
return this.lineStarts[pos.line] + pos.column;
}
}
光标渲染
光标DOM结构
/* 光标渲染的DOM结构 */
class="line">
Hello
world
class="cursor">
/* 光标CSS样式 */
.cursor {
display: inline-block;
width: 2px;
height: 1.2em;
background: var(--cursor-color, var(--accent));
vertical-align: text-bottom;
animation: cursor-blink 1s step-end infinite;
position: absolute;
}
.cursor.block {
width: 1ch;
background: transparent;
border: 1px solid var(--accent);
border-radius: 2px;
}
@keyframes cursor-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
光标定位计算
// 计算光标在视口中的位置
class CursorRenderer {
getCursorCoordinates(offset, editor) {
const coords = this.getOffsetCoordinates(offset, editor);
const lineHeight = editor.getLineHeight();
return {
x: coords.left,
y: coords.top,
height: lineHeight
};
}
getOffsetCoordinates(offset, editor) {
const textNode = this.getTextNodeAtOffset(offset);
const range = document.createRange();
range.setStart(textNode.node, textNode.offset);
range.collapse(true);
const rect = range.getBoundingClientRect();
const editorRect = editor.dom.getBoundingClientRect();
return {
left: rect.left - editorRect.left + editor.scrollLeft,
top: rect.top - editorRect.top + editor.scrollTop
};
}
}
选区管理
选区渲染
选区通过高亮显示的DOM元素来实现。
/* 选区DOM结构 */
class="line">
He class="selection">l class="selection">lo
/* 选区CSS样式 */
.selection {
background: rgba(52, 211, 153, 0.3);
border-radius: 2px;
}
.selection.primary {
background: rgba(52, 211, 153, 0.4);
}
选区操作
// 选区操作
class SelectionManager {
constructor(this.editor) {
this.selections = []; // 多个选区
this.primaryIndex = 0;
}
// 设置选区
setSelection(anchor, head) {
this.selections = [new Selection(anchor, head)];
this.primaryIndex = 0;
this.render();
}
// 扩展选区
extendSelection(newHead) {
const sel = this.selections[this.primaryIndex];
sel.head = newHead;
this.render();
}
// 添加新选区(Shift+点击)
addSelection(anchor, head) {
this.selections.push(new Selection(anchor, head));
this.primaryIndex = this.selections.length - 1;
this.render();
}
// 获取所有选区对应的文本
getSelectionsText() {
return this.selections.map(s => {
return this.editor.text.slice(s.start, s.end);
});
}
}
多选区支持
多选区创建
// 多选区创建方式
class MultiCursorHandler {
// Ctrl+D: 选择下一个匹配词
selectNextMatch(editor) {
const currentSel = editor.selection.primary;
const selectedText = editor.text.slice(currentSel.start, currentSel.end);
if (!selectedText) return;
const nextOffset = editor.text.indexOf(selectedText, currentSel.end);
if (nextOffset !== -1) {
editor.selection.addSelection(nextOffset, nextOffset + selectedText.length);
}
}
// Alt+点击: 列选择模式
handleAltClick(editor, event) {
const line = Math.floor((event.clientY - editor.rect.top) / editor.lineHeight);
const column = Math.floor((event.clientX - editor.rect.left) / editor.charWidth);
const offset = editor.positionToOffset(line, column);
if (editor.selection.isEmpty) {
editor.selection.setSelection(offset, offset);
} else {
editor.selection.addSelection(offset, offset);
}
}
}
多选区编辑
// 多选区同时编辑
class MultiCursorEditor {
// 同时在所有选区插入文本
insertText(text) {
const selections = editor.selection.selections;
const sortedSelections = [...selections].sort((a, b) => b.start - a.start);
for (const sel of sortedSelections) {
editor.text.insert(sel.start, text);
const delta = text.length - (sel.end - sel.start);
// 更新其他选区位置
for (const other of selections) {
if (other.start > sel.start) {
other.start += delta;
other.head += delta;
}
}
}
editor.selection.render();
}
// 同时删除
deleteSelection() {
const selections = editor.selection.selections;
const sortedSelections = [...selections].sort((a, b) => b.start - a.start);
for (const sel of sortedSelections) {
if (!sel.isEmpty) {
editor.text.delete(sel.start, sel.end);
}
}
// 合并相邻选区
editor.selection.normalize();
}
}
光标导航
基础移动
// 光标基础移动
class CursorNavigator {
moveLeft(editor) {
const pos = editor.selection.primary.head;
if (pos > 0) {
editor.selection.setSelection(pos - 1, pos - 1);
}
}
moveRight(editor) {
const pos = editor.selection.primary.head;
if (pos < editor.text.length) {
editor.selection.setSelection(pos + 1, pos + 1);
}
}
moveUp(editor) {
const current = editor.offsetToPosition(editor.selection.primary.head);
if (current.line > 0) {
const newOffset = editor.positionToOffset(current.line - 1, current.column);
editor.selection.setSelection(newOffset, newOffset);
}
}
moveDown(editor) {
const current = editor.offsetToPosition(editor.selection.primary.head);
const newOffset = editor.positionToOffset(current.line + 1, current.column);
editor.selection.setSelection(newOffset, newOffset);
}
}
智能光标移动
// 智能光标移动:跳过单词
class SmartCursorNavigator {
// 移动到单词开头
moveWordStart(editor) {
const pos = editor.selection.primary.head;
const text = editor.text;
// 跳过前导空白
let i = pos;
while (i < text.length && isWhitespace(text[i])) i++;
// 找到单词边界
if (i > pos) {
editor.selection.setSelection(i, i);
} else {
// 已在单词开头,跳到前一个单词
i = pos - 1;
while (i > 0 && isWhitespace(text[i])) i--;
while (i > 0 && !isWhitespace(text[i - 1])) i--;
editor.selection.setSelection(i, i);
}
}
isWhitespace(char) {
return /[\s\t\n]/.test(char);
}
}
选区状态同步
与系统剪贴板同步
// 选区变化时同步到系统剪贴板
class SelectionClipboardSync {
constructor(this.editor) {
this.setupListeners();
}
setupListeners() {
this.editor.on('selectionchange', () => {
const text = this.editor.selection.primaryText;
if (text) {
navigator.clipboard.writeText(text);
}
});
}
}
选区可见性保证
// 确保选区可见:滚动到光标位置
class SelectionVisibility {
ensureVisible(editor) {
const cursor = editor.selection.primary.head;
const coords = editor.cursorRenderer.getCursorCoordinates(cursor);
// 检查是否在可视区域
const viewport = {
top: editor.scrollTop,
bottom: editor.scrollTop + editor.clientHeight,
left: editor.scrollLeft,
right: editor.scrollLeft + editor.clientWidth
};
if (coords.y < viewport.top) {
editor.scrollTop = coords.y;
} else if (coords.y + coords.height > viewport.bottom) {
editor.scrollTop = coords.y + coords.height - editor.clientHeight;
}
}
}
总结
- 数据模型:使用偏移量表示位置,行列坐标用于显示和用户交互
- 光标渲染:通过绝对定位的DOM元素实现,支持多种样式
- 选区管理:支持单选区和多选区,需处理位置同步
- 多选区编辑:同时在多个位置进行插入、删除等操作
- 智能导航:实现单词、行、代码块级别的智能光标移动
- 状态同步:保持选区状态与系统剪贴板、视图滚动同步
完善的光标与选区系统是编辑器用户体验的基础,需要仔细处理各种边界情况。