代码编辑器光标与选区设计完全指南

编辑器核心交互设计

光标与选区是编辑器最核心的交互元素,几乎所有文本编辑操作都围绕它们展开。本文详细介绍光标渲染、选区管理、多选区实现与坐标系统,帮助你构建完善的编辑器交互体系。

光标数据模型

位置表示

编辑器内部使用偏移量(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;
        }
    }
}

总结

完善的光标与选区系统是编辑器用户体验的基础,需要仔细处理各种边界情况。