引言
选区(Selection)与光标(Cursor)是编辑器最核心的交互元素。无论是简单的文本输入,还是高级的多光标编辑,都建立在选区管理的基础之上。本文详细介绍选区的数据结构、操作API 与实现原理。
一、选区的基本概念
在 Web 编辑器中,选区由 Range 对象表示,包含起始位置(anchor)和结束位置(head):
// 选区的基本结构
class Selection {
constructor(anchor, head) {
this.anchor = anchor; // 选区起点(固定端)
this.head = head; // 选区终点(活动端,可通过方向键移动)
}
// 获取选区的起点和终点(保证 min <= max)
getStart() { return Math.min(this.anchor, this.head); }
getEnd() { return Math.max(this.anchor, this.head); }
// 选区是否为空(光标位置)
isCollapsed() { return this.anchor === this.head; }
// 选区长度
getLength() { return Math.abs(this.head - this.anchor); }
}
anchor 与 head 的区别:
- 按住 Shift+方向键移动时,anchor 位置不变,head 移动到新位置
- 不按 Shift 时,anchor 与 head 同时移动,形成光标移动
- 按住 Shift+方向键移动时,anchor 位置不变,head 移动到新位置
- 不按 Shift 时,anchor 与 head 同时移动,形成光标移动
二、选区与文档模型的交互
选区位置需要与文档文本模型关联:
// 文档模型
class TextModel {
constructor(text) {
this.text = text; // 原始文本
this.lines = text.split('\n'); // 按行分割
}
// 位置(offset)转行列坐标
offsetToPosition(offset) {
let line = 0, col = 0, pos = 0;
for (let i = 0; i < this.lines.length; i++) {
if (pos + this.lines[i].length >= offset) {
col = offset - pos;
return { line: i, column: col };
}
pos += this.lines[i].length + 1; // +1 for \n
line++;
}
return { line: line - 1, column: this.lines[this.lines.length-1.length };
}
// 行列坐标转位置(offset)
positionToOffset(line, column) {
let offset = 0;
for (let i = 0; i < line && i < this.lines.length; i++) {
offset += this.lines[i].length + 1;
}
return offset + column;
}
// 获取选区对应的文本
getTextInRange(start, end) {
return this.text.slice(start, end);
}
// 在指定位置插入文本
insert(offset, text) {
this.text = this.text.slice(0, offset) + text + this.text.slice(offset);
this.lines = this.text.split('\n');
}
// 删除指定范围的文本
delete(start, end) {
this.text = this.text.slice(0, start) + this.text.slice(end);
this.lines = this.text.split('\n');
}
}
三、选区操作 API
编辑器需要提供丰富的选区操作接口:
// 编辑器核心操作
class Editor {
constructor(model) {
this.model = model;
this.selection = new Selection(0, 0);
}
// 设置选区
setSelection(anchor, head) {
this.selection = new Selection(anchor, head);
this.revealCursor();
}
// 设置光标位置
setCursor(offset) {
this.selection = new Selection(offset, offset);
this.revealCursor();
}
// 获取当前选区文本
getSelectedText() {
return this.model.getTextInRange(
this.selection.getStart(),
this.selection.getEnd()
);
}
// 选中全部
selectAll() {
this.setSelection(0, this.model.text.length);
}
// 选中当前行
selectLine() {
let pos = this.selection.getStart();
let { line } = this.model.offsetToPosition(pos);
let start = this.model.positionToOffset(line, 0);
let end = this.model.positionToOffset(line + 1, 0);
if (line === this.model.lines.length - 1) {
end = this.model.text.length;
}
this.setSelection(start, end - 1);
}
// 选中当前单词
selectWord() {
let pos = this.selection.getStart();
let text = this.model.text;
let start = pos, end = pos;
// 向前找单词边界
while (start > 0 && isWordChar(text[start - 1])) start--;
// 向后找单词边界
while (end < text.length && isWordChar(text[end])) end++;
this.setSelection(start, end);
}
}
四、光标移动操作
方向键移动是最常用的操作:
// 光标移动
class CursorMovement {
moveLeft(editor) {
let pos = editor.selection.getStart();
if (pos > 0) pos--;
editor.setCursor(pos);
}
moveRight(editor) {
let pos = editor.selection.getEnd();
if (pos < editor.model.text.length) pos++;
editor.setCursor(pos);
}
moveUp(editor) {
let pos = editor.selection.getStart();
let { line, column } = editor.model.offsetToPosition(pos);
if (line > 0) {
line--;
let newCol = Math.min(column, editor.model.lines[line].length);
pos = editor.model.positionToOffset(line, newCol);
editor.setCursor(pos);
}
}
moveDown(editor) {
let pos = editor.selection.getStart();
let { line, column } = editor.model.offsetToPosition(pos);
if (line < editor.model.lines.length - 1) {
line++;
let newCol = Math.min(column, editor.model.lines[line].length);
pos = editor.model.positionToOffset(line, newCol);
editor.setCursor(pos);
}
}
moveToLineStart(editor) {
let pos = editor.selection.getStart();
let { line } = editor.model.offsetToPosition(pos);
let newPos = editor.model.positionToOffset(line, 0);
editor.setCursor(newPos);
}
moveToLineEnd(editor) {
let pos = editor.selection.getStart();
let { line } = editor.model.offsetToPosition(pos);
let newPos = editor.model.positionToOffset(line, editor.model.lines[line].length);
editor.setCursor(newPos);
}
moveWordLeft(editor) {
let pos = editor.selection.getStart();
let text = editor.model.text;
while (pos > 0 && !isWordChar(text[pos - 1])) pos--;
while (pos > 0 && isWordChar(text[pos - 1])) pos--;
editor.setCursor(pos);
}
moveWordRight(editor) {
let pos = editor.selection.getEnd();
let text = editor.model.text;
while (pos < text.length && !isWordChar(text[pos])) pos++;
while (pos < text.length && isWordChar(text[pos])) pos++;
editor.setCursor(pos);
}
}
五、多选区与多光标
现代编辑器支持多光标编辑,大幅提升效率:
// 多选区管理
class MultiSelection {
constructor() {
this.selections = []; // 多个选区
}
// 添加一个新光标(在指定位置)
addCursor(offset) {
this.selections.push(new Selection(offset, offset));
}
// 添加与当前选区相似的多个选区
addSelectionsForAllOccurrences(editor, pattern) {
const text = editor.model.text;
const regex = new RegExp(pattern, 'g');
let match;
while ((match = regex.exec(text)) !== null) {
this.addCursor(match.index);
}
}
// 在所有选区位置执行相同操作
modifySelections(editor, callback) {
this.selections.forEach(sel => {
editor.selection = sel;
callback(editor);
});
}
// 选中所有光标位置的相同单词(Alt+Click 多选逻辑)
selectWordAtCursor(editor) {
let pos = editor.selection.getStart();
let { line, column } = editor.model.offsetToPosition(pos);
let lineText = editor.model.lines[line];
// 找到当前单词
let start = column, end = column;
while (start > 0 && isWordChar(lineText[start - 1])) start--;
while (end < lineText.length && isWordChar(lineText[end])) end++;
let word = lineText.slice(start, end);
if (!word) return;
// 找到所有相同单词位置
let regex = new RegExp(`\\b${escapeRegex(word)}\\b`, 'g');
let match;
while ((match = regex.exec(text)) !== null) {
this.selections.push(new Selection(match.index, match.index + word.length));
}
}
}
多选区编辑的关键是保持选区列表与文档模型同步,每次修改后需要重新计算所有选区的位置。
六、选区的撤销重做
选区变化也需要纳入撤销栈:
// 带选区的撤销栈项
class UndoManager {
constructor() {
this.stack = [];
this.position = -1;
}
// 记录操作(包含选区)
push(textDelta, selectionBefore, selectionAfter) {
// 剪掉当前位置之后的所有项
this.stack = this.stack.slice(0, this.position + 1);
this.stack.push({ textDelta, selectionBefore, selectionAfter });
this.position++;
}
// 撤销
undo(editor) {
if (this.position < 0) return;
let item = this.stack[this.position];
// 应用反向变化
applyInverse(editor, item.textDelta);
// 恢复选区
editor.selection = item.selectionBefore;
this.position--;
}
// 重做
redo(editor) {
if (this.position >= this.stack.length - 1) return;
this.position++;
let item = this.stack[this.position];
// 重新应用变化
applyDelta(editor, item.textDelta);
// 恢复选区
editor.selection = item.selectionAfter;
}
}
七、视觉渲染
选区需要在编辑区域正确渲染:
// 选区渲染(在 DOM 中创建高亮元素)
function renderSelections(editor, container) {
// 清空旧的选区渲染
container.querySelectorAll('.selection-highlight').forEach(el => el.remove());
for (let sel of editor.selections) {
let start = sel.getStart();
let end = sel.getEnd();
// 计算行号和列号
let startPos = editor.model.offsetToPosition(start);
let endPos = editor.model.offsetToPosition(end);
// 创建高亮元素(使用 CSS 实现)
let highlight = createSelectionElement(startPos, endPos);
container.appendChild(highlight);
}
// 渲染光标
renderCursor(editor);
}
实际实现中,通常使用 CSS background-color 或 box-shadow 来渲染选区,通过绝对定位计算选区的像素位置。
八、总结
- 选区结构:anchor + head,支持正向和反向选区
- 位置转换:offset(字符偏移)与 position(行列坐标)的相互转换
- 基本操作:设置光标、设置选区、选中当前行/单词
- 光标移动:方向键、词边界、行首尾等移动逻辑
- 多选区:多个选区同时编辑,支持 Alt+Click、Ctrl+D 等
- 撤销重做:选区变化也需要纳入撤销栈