选区和剪贴板是编辑器中最基础也是最常用的功能。本文详细介绍选区管理和剪贴板操作的设计与实现。
选区数据结构
首先定义选区的核心数据结构:
/* 位置定义 */
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,但需要处理权限问题。
总结
- 选区需要支持多个选区和方向
- 坐标转换是选区操作的基础
- 剪贴板需要兼容新旧浏览器
- 选区渲染需要处理单行和多行情况