代码编辑器选区与剪贴板完全指南

选区和剪贴板是编辑器中最基础也是最常用的功能。本文详细介绍选区管理和剪贴板操作的设计与实现。

选区数据结构

首先定义选区的核心数据结构:

/* 位置定义 */
interface Position {
    line: number;      // 行号(从0开始)
    column: number;    // 列号(从0开始)
}

/* 选区定义 */
interface Selection {
    start: Position;
    end: Position;
    direction: 'forward' | 'backward';
}

/* 选区管理器 */
class SelectionManager {
    constructor(document) {
        this.document = document;
        this.selections: Selection[] = [];
    }

    getSelection(index = 0) {
        return this.selections[index];
    }

    getAllSelections() {
        return [...this.selections];
    }

    setSelection(selection: Selection) {
        this.selections = [selection];
    }
}

选区坐标计算

将屏幕坐标转换为文档坐标:

/* 坐标转换核心逻辑 */
class CoordinateMapper {
    constructor(editor) {
        this.editor = editor;
    }

    /* 屏幕像素坐标 -> 文档逻辑坐标 */
    screenToDocument(screenX: number, screenY: number): Position {
        /* 先计算在第几行 */
        const lineHeight = this.editor.getLineHeight();
        const visibleLines = this.editor.getVisibleLineCount();
        const scrollTop = this.editor.getScrollTop();

        let line = Math.floor((scrollTop + screenY) / lineHeight);
        line = Math.max(0, Math.min(line, visibleLines - 1));

        /* 计算该行的字符列位置 */
        const lineStartX = this.getLineStartX(line) - this.editor.getScrollLeft();
        let column = this.getColumnFromX(line, screenX - lineStartX);

        return { line, column };
    }

    /* 文档逻辑坐标 -> 屏幕像素坐标 */
    documentToScreen(position: Position): { left: number, top: number } {
        const lineHeight = this.editor.getLineHeight();
        const charWidth = this.editor.getCharWidth();

        return {
            left: position.column * charWidth,
            top: position.line * lineHeight
        };
    }
}

剪贴板管理器

实现系统剪贴板的读写:

/* 剪贴板管理器 */
class ClipboardManager {
    constructor(editor) {
        this.editor = editor;
    }

    /* 读取剪贴板 */
    async read(): Promise<string> {
        try {
            const text = await navigator.clipboard.readText();
            return text;
        } catch (err) {
            /* 兼容旧版浏览器 */
            return this.readFromTextArea();
        }
    }

    /* 写入剪贴板 */
    async write(text: string): Promise<void> {
        try {
            await navigator.clipboard.writeText(text);
        } catch (err) {
            /* 兼容旧版浏览器 */
            this.writeToTextArea(text);
        }
    }

    /* 兼容旧版的剪贴板读写 */
    readFromTextArea(): string {
        const textarea = this.editor.getHiddenTextarea();
        textarea.select();
        return document.execCommand('copy') ? textarea.value : '';
    }
}

剪切复制粘贴

实现基本的编辑操作:

/* 编辑命令处理器 */
class EditCommandHandler {
    constructor(editor) {
        this.editor = editor;
        this.clipboard = new ClipboardManager(editor);
    }

    /* 剪切:复制到剪贴板并删除选区 */
    async cut() {
        const selection = this.editor.getSelection();
        if (!selection || selection.isEmpty()) return false;

        /* 获取选区文本 */
        const text = this.editor.getTextInRange(selection);

        /* 复制到剪贴板 */
        await this.clipboard.write(text);

        /* 删除选区 */
        this.editor.deleteRange(selection);

        return true;
    }

    /* 复制:复制选区到剪贴板 */
    async copy() {
        const selection = this.editor.getSelection();
        if (!selection || selection.isEmpty()) return false;

        const text = this.editor.getTextInRange(selection);
        await this.clipboard.write(text);

        return true;
    }

    /* 粘贴:从剪贴板插入文本 */
    async paste() {
        const text = await this.clipboard.read();
        if (!text) return false;

        const selection = this.editor.getSelection();
        this.editor.replaceText(selection ?? this.editor.getCursorPosition(), text);

        return true;
    }
}

选区渲染

在编辑器中渲染选区高亮:

/* 选区渲染器 */
class SelectionRenderer {
    render(selections: Selection[]) {
        const layer = this.getSelectionLayer();
        layer.clearChildren();

        for (const selection of selections) {
            const highlight = this.createHighlightElement(selection);
            layer.appendChild(highlight);
        }
    }

    createHighlightElement(selection: Selection): HTMLElement {
        const start = this.documentToScreen(selection.start);
        const end = this.documentToScreen(selection.end);

        const el = document.createElement('div');
        el.className = 'selection-highlight';

        /* 处理单行选区和多行选区 */
        if (selection.start.line === selection.end.line) {
            /* 单行 */
            el.style.left = `${start.left}px`;
            el.style.top = `${start.top}px`;
            el.style.width = `${end.left - start.left}px`;
            el.style.height = `${lineHeight}px`;
        } else {
            /* 多行:创建多个区域 */
            this.renderMultiLineSelection(el, selection);
        }

        return el;
    }
}

注意事项

剪贴板操作需要处理各种边界情况,如空选区、 многобайтовые字符等。现代浏览器推荐使用 Clipboard API,但需要处理权限问题。

总结

  • 选区需要支持多个选区和方向
  • 坐标转换是选区操作的基础
  • 剪贴板需要兼容新旧浏览器
  • 选区渲染需要处理单行和多行情况