代码编辑器选区与光标操作

引言

选区(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 同时移动,形成光标移动

二、选区与文档模型的交互

选区位置需要与文档文本模型关联:

// 文档模型
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 等
  • 撤销重做:选区变化也需要纳入撤销栈