若即若离:富文本编辑器中的拖动与光标跟随艺术📍

408 阅读10分钟

写在开头

Hello,各位好呀!☔☔☔

今是2025年05月04日,此刻大雨倾盆,五一假期即将不足,然而已连下两天雨了,后面也全是雨,难,太难了。😩

image.png

小编已经在宿舍躺平两天了,品茗(泡了点菊花茶喝)、听雨(雨的确大)、抚琴(网抑云)......

假期出不了门,是憾事,但,好在五一第一天小编就已经出去"疯"玩了一天,如:

97ef4271d07d351ffca4ddd774a2eaa.jpg

特种兵见面玩耍,也算是感受了假期的氛围哈。

那么,回到正题,本次要分享的是关于富文本光标与拖动的那点事,效果如下,请诸君按需食用哈。

0504-01.gif

需求背景

在近期的业务开发中,遇上了一个富文本相关的需求。通常情况下,面对这类需求,大家可能会在 Quill.jswangEditorTinyMCE 等优秀的富文本插件中权衡选择,这些富文本库功能强大,能满足绝大多数场景的需求。

特别推荐使用 Quill,小编大多时候遇上富文本需求都会选择使用它,特点我觉得就是好扩展。😉

然而,产品需求往往具有独特性,有时候,业务核心诉求并非现有插件能满足的。

就如本次需求中,对文本变色、加粗等常见富文本功能的要求较低,但却存在一个特殊需求 —— 需要实现从下拉框中拖动特定文案(标签),将其填充至富文本的任意位置,同时光标要能够实时跟随。此外,填充后的内容需作为一个整体,不允许被单独选中,删除操作也必须针对整个填充内容同步进行删除。

在这种情况下,小编开始的时候是考虑使用 Quill + 插件 进行二次开发,然后种种因素下,最终决定还是从零开始手动开发,这样既能更灵活地定制功能,又能确保实现过程高效可靠,精准满足业务需求。

需求大概就是这么一个需求,接下来,咱们一起来一步一步实现这一"独特"的富文本功能需求吧,GO!🏃

第1️⃣步:基础富文本编辑器的搭建

首先,咱们需要创建一个基础的富文本编辑器。在 HTML 中,我们可以使用 contenteditable 属性来让一个普通的 div 元素变成可编辑区域:

<!DOCTYPE html>
<html>
  <body>
    <div class="editor-container">
        <div class="editor" id="editor" contenteditable="true">
          在那年的夏天遇到你,我便知道,你是我生命中最重要的人。---橙某人
        </div>
    </div>
  </body>
</html>

熟悉小编的小伙伴应该知道,咱们还是老规则,在一个 .html 文件中完成所有功能。😋

全部完整的样式都在这里了,这不是本次的重点,就直接贴上来了:

<style>
.editor-container {
    position: relative;
    width: 600px;
    margin: 20px auto;
}
.toolbar {
    display: flex;
    gap: 5px;
    margin-bottom: 10px;
    padding: 5px;
    background-color: #f5f5f5;
    border: 1px solid #ddd;
    border-radius: 4px;
}
.toolbar button {
    padding: 5px 10px;
    background-color: #fff;
    border: 1px solid #ddd;
    border-radius: 3px;
    cursor: pointer;
}
.toolbar button:hover {
    background-color: #e9e9e9;
}
.editor {
    position: relative;
    min-height: 200px;
    padding: 10px;
    border: 1px solid #ddd;
    border-radius: 4px;
    background-color: #fff;
    overflow: auto;
}
.editor[contenteditable="true"] {
    outline: none;
}
.drag-handle {
    position: absolute;
    top: 0;
    right: 0;
    width: 20px;
    height: 20px;
    background-color: #ddd;
    cursor: move;
    border-radius: 0 4px 0 4px;
    display: flex;
    align-items: center;
    justify-content: center;
}
.drag-handle::before {
    content: "⋮";
    font-weight: bold;
}
/* 性格列表样式 */
.personality-list {
    position: absolute;
    top: 100%;
    left: 0;
    width: 200px;
    background-color: white;
    border: 1px solid #ddd;
    border-radius: 4px;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
    z-index: 100;
    display: none;
    padding: 5px 0;
    margin-top: 5px;
}
.personality-list.show {
    display: block;
}
.personality-item {
    padding: 8px 12px;
    cursor: move;
    border-bottom: 1px solid #f0f0f0;
    transition: background-color 0.2s;
}
.personality-item:last-child {
    border-bottom: none;
}
.personality-item:hover {
    background-color: #f5f5f5;
}
.personality-item.dragging {
    opacity: 0.5;
}
.personality-tag {
    display: inline-block;
    background-color: #8a2be2;
    color: white;
    padding: 2px 6px;
    border-radius: 4px;
    margin: 0 2px;
    cursor: pointer;
    user-select: none;
}
.personality-tag:hover {
    background-color: #7b1fa2;
}
.content-preview {
    margin-top: 20px;
    padding: 10px;
    border: 1px dashed #ddd;
    border-radius: 4px;
    background-color: #f9f9f9;
    white-space: pre-wrap;
    display: none;
}
.content-preview.show {
    display: block;
}
.content-tabs {
    display: flex;
    margin-bottom: 10px;
}
.content-tab {
    padding: 5px 10px;
    background-color: #eee;
    border: 1px solid #ddd;
    border-bottom: none;
    border-radius: 4px 4px 0 0;
    margin-right: 5px;
    cursor: pointer;
}
.content-tab.active {
    background-color: #f9f9f9;
    border-bottom: 1px solid #f9f9f9;
}
.editor-container.fullscreen {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  z-index: 9999;
  background-color: white;
  overflow: auto;
  display: flex;
  flex-direction: column;
}
.editor-container.fullscreen .toolbar {
  flex-shrink: 0;
}
.editor-container.fullscreen .editor {
  flex-grow: 1;
  overflow: auto;
}
#fullscreenBtn {
  margin-left: auto; /* 将按钮推到右侧 */
}
#fullscreenBtn.exit:after {
  content: "(退出)";
  font-size: 0.8em;
  margin-left: 3px;
}
</style>

然后,我们添加一些基本的编辑功能按钮:

<div class="editor-container">
    <div class="toolbar">
      <button onclick="execCommand('bold')">粗体</button>
      <button onclick="execCommand('italic')">斜体</button>
      <button onclick="execCommand('underline')">下划线</button>
      <button onclick="changeColor('#ff0000')">红色</button>
      <button onclick="changeColor('#0000ff')">蓝色</button>
      <button onclick="insertImage()">插入图片</button>
      <button onclick="insertEmoji()">插入表情</button>
    </div>
    <div class="editor" id="editor" contenteditable="true">
      在那年的夏天遇到你,我便知道,你是我生命中最重要的人。---橙某人
    </div>
</div>

接下来,我们实现这些按钮的功能,这里我们使用 document.execCommand 方法,虽然这个 API 已经被标记为废弃(文末有详细说明),但目前它在大多数浏览器中仍然可用,而且对于简单的富文本编辑功能来说足够了:

<script>
// 富文本编辑功能
function execCommand(command) {
  document.execCommand('StyleWithCSS', true, true);
  document.execCommand(command, false, null);
  document.getElementById('editor').focus();
}
function changeColor(color) {
  document.execCommand('StyleWithCSS', true, true);
  document.execCommand('foreColor', false, color);
  document.getElementById('editor').focus();
}
function insertImage() {
  const url = prompt('请输入图片URL', 'https://picsum.photos/200/300');
  if (url) {
    document.execCommand('insertImage', false, url);
  }
  document.getElementById('editor').focus();
}
function insertEmoji() {
  const selection = window.getSelection();
  const range = selection.getRangeAt(0);
  const emoji = document.createTextNode('😄');
  range.deleteContents();
  range.insertNode(emoji);
  range.setStartAfter(emoji);
  range.setEndAfter(emoji);
  selection.removeAllRanges();
  selection.addRange(range);
  document.getElementById('editor').focus();
}
</script>

效果:

0504-03.gif

好了,这样咱们就非常快速简单的完成了一个基础的富文本编辑器!😋

第2️⃣步:让编辑器可拖动与全屏

为了进一步提升用户体验,需求中还包含两项"人性化"的小功能:一是实现编辑器的自由拖拽,方便用户根据使用习惯调整其在页面中的位置;二是支持编辑器全屏放大,让用户在处理大量文本内容时拥有更宽敞的编辑空间,这两项需求...En...都十分合理。🙈

首先,我们先在 HTML 中添加一个全屏按钮与拖动手柄:

<div class="editor-container">
  <div class="toolbar">
      <!-- 工具栏按钮 -->
      <button id="fullscreenBtn">全屏</button>
  </div>
  <div class="editor" id="editor" contenteditable="true"><!-- 初始内容 --></div>
  <!-- 拖动权柄 -->
  <div class="drag-handle" id="dragHandle"></div>
</div>

实现拖动功能的逻辑代码:

class Draggable {
  constructor(el, handle) {
    this.el = el;
    this.handle = handle;
    this.isDragging = false;
    this.initialX = 0;
    this.initialY = 0;
    this.currentX = 0;
    this.currentY = 0;
    this.xOffset = 0;
    this.yOffset = 0;
    this.init();
  }
  init() {
    this.handle.addEventListener('mousedown', this.dragStart.bind(this));
    document.addEventListener('mousemove', this.drag.bind(this));
    document.addEventListener('mouseup', this.dragEnd.bind(this));
  }
  dragStart(e) {
    this.initialX = e.clientX - this.xOffset;
    this.initialY = e.clientY - this.yOffset;
    if (e.target === this.handle) {
      this.isDragging = true;
    }
  }
  drag(e) {
    if (this.isDragging) {
      e.preventDefault();
      this.currentX = e.clientX - this.initialX;
      this.currentY = e.clientY - this.initialY;
      this.xOffset = this.currentX;
      this.yOffset = this.currentY;
      this.setTranslate(this.currentX, this.currentY, this.el);
    }
  }
  dragEnd() {
    this.isDragging = false;
  }
  setTranslate(xPos, yPos, el) {
    el.style.transform = `translate3d(${xPos}px, ${yPos}px, 0)`;
  }
}

// 初始化拖动功能
const editorContainer = document.querySelector('.editor-container');
const dragHandle = document.getElementById('dragHandle');

new Draggable(editorContainer, dragHandle);

一个完整拖动相关的类,小编让AI帮忙写得,没什么毛病。🎉

全屏切换功能也挺简单的,三下五除二就搞定😁:

const fullscreenBtn = document.getElementById('fullscreenBtn');
fullscreenBtn.addEventListener('click', function() {
  if (editorContainer.classList.contains('fullscreen')) {
    // 退出全屏
    editorContainer.classList.remove('fullscreen');
    fullscreenBtn.classList.remove('exit');
    fullscreenBtn.textContent = '全屏';
    // 恢复原始位置和大小
    if (editorContainer.hasAttribute('data-original-transform')) {
      editorContainer.style.transform = editorContainer.getAttribute('data-original-transform');
      editorContainer.removeAttribute('data-original-transform');
    }
  } else {
    // 进入全屏
    if (editorContainer.style.transform) {
      editorContainer.setAttribute('data-original-transform', editorContainer.style.transform);
    }
    editorContainer.style.transform = 'none';
    editorContainer.classList.add('fullscreen');
    fullscreenBtn.classList.add('exit');
    fullscreenBtn.textContent = '退出全屏';
  }
  // 放大的时候自动聚焦
  document.getElementById('editor').focus();
});
// ESC键也可以退出全屏
document.addEventListener('keydown', function(e) {
  if (e.key === 'Escape' && editorContainer.classList.contains('fullscreen')) {
    fullscreenBtn.click();
  }
});

效果:

0504-04.gif

第3️⃣步:拖动功能和光标跟随

接下来这个是本章的核心部分!咱们需要实现从一个"性格列表"中拖动标签到编辑区域,并且让光标实现跟随移动结尾。

首先,我们需要添加一个性格的下拉框列表,下拉框弄起来还是比较麻烦的,原生的又丑😅,咱们先通过按钮点击显隐形式简单弄弄:

<button id="personalityBtn">性格</button>

<!-- 性格列表 -->
<div class="personality-list" id="personalityList">
  <div class="personality-item" draggable="true" data-personality="开朗活泼">开朗活泼</div>
  <div class="personality-item" draggable="true" data-personality="沉稳内敛">沉稳内敛</div>
  <div class="personality-item" draggable="true" data-personality="热情洋溢">热情洋溢</div>
  <div class="personality-item" draggable="true" data-personality="冷静理智">冷静理智</div>
  <div class="personality-item" draggable="true" data-personality="温柔体贴">温柔体贴</div>
  <div class="personality-item" draggable="true" data-personality="坚强勇敢">坚强勇敢</div>
</div>

注意,我们给每个性格项添加了 draggable="true" 属性,这是 HTML5 拖放 API 的关键部分,它告诉浏览器这个元素可以被拖动。

然后,咱们通过点击按钮显示/隐藏列表的功能:

const personalityBtn = document.getElementById('personalityBtn');
const personalityList = document.getElementById('personalityList');
personalityBtn.addEventListener('click', function() {
  personalityList.classList.toggle('show');
});
document.addEventListener('click', function(e) {
  if (!personalityList.contains(e.target) && e.target !== personalityBtn) {
    personalityList.classList.remove('show');
  }
});

然后,为"性格项"添加拖拽事件:

const personalityItems = document.querySelectorAll('.personality-item');

personalityItems.forEach(item => {
  // 拖拽开始
  item.addEventListener('dragstart', function(e) {
    // 用于在拖放操作期间存储和传输数据。
    e.dataTransfer.setData('text/plain', this.getAttribute('data-personality'));
    this.classList.add('dragging');
    
    // 设置拖拽图像,否则会产品一个原图像遮住光标
    const dragIcon = document.createElement('div');
    dragIcon.textContent = this.textContent;
    dragIcon.style.cssText = 'position: absolute; top: -1000px; opacity: 0;';
    document.body.appendChild(dragIcon);
    e.dataTransfer.setDragImage(dragIcon, 0, 0);
    setTimeout(() => document.body.removeChild(dragIcon), 0);
  });
  
  // 拖拽结束
  item.addEventListener('dragend', function() {
    this.classList.remove('dragging');
  });
});

e.dataTransfer.setData("text/plain", this.getAttribute("data-personality"));

e.dataTransfer 是HTML5拖放API的核心对象,它用于在拖放操作期间存储和传输数据,当用户开始拖拽一个性格标签时,将该标签的文本内容保存到 dataTransfer 对象中。

这样在拖拽结束后,编辑器可以通过 e.dataTransfer.getData("text/plain") 来获取这个文本内容,然后将这个文本内容插入到编辑器的光标位置。

然后,我们为编辑器添加拖拽事件:

// 编辑器拖拽事件
editor.addEventListener('dragover', function(e) {
  // 允许放置
  e.preventDefault();
  
  // 获取鼠标位置并设置光标
  const selection = window.getSelection();
  const range = document.caretRangeFromPoint(e.clientX, e.clientY);
  if (range) {
    selection.removeAllRanges();
    selection.addRange(range);
  }
});

这里的关键是 document.caretRangeFromPoint() 方法,它可以根据鼠标位置获取对应的文本范围,然后我们将选区设置到这个范围,实现光标跟随效果。

最后,咱们处理一下拖动放置事件,将性格标签插入到编辑区域:

editor.addEventListener('drop', function(e) {
  e.preventDefault();
  const personality = e.dataTransfer.getData('text/plain');
  // 在光标位置插入性格标签
  const selection = window.getSelection();
  if (selection.rangeCount > 0) {
    const range = selection.getRangeAt(0);
    // 创建性格标签元素
    const tagElement = document.createElement('span');
    tagElement.className = 'personality-tag';
    tagElement.textContent = personality;
    tagElement.setAttribute('contenteditable', 'false'); // 防止内部编辑
    // 添加点击事件用于删除
    tagElement.addEventListener('click', function() {
      if (confirm('是否删除此性格标签?')) {
        this.parentNode.removeChild(this);
        editor.focus();
      }
    });
    range.deleteContents();
    range.insertNode(tagElement);
    // 将光标移动到插入标签之后
    range.setStartAfter(tagElement);
    range.setEndAfter(tagElement);
    selection.removeAllRanges();
    selection.addRange(range);
  }
  // 隐藏性格列表
  personalityList.classList.remove('show');
  editor.focus();
});

效果:

0504-05.gif

第4️⃣步:获取富文本内容

最后,咱们需要添加一个功能,让用户能够获取富文本编辑器中的内容。虽然有现成的 API 可以获取编辑器的内容,但是要注意转义一下 HTML 标签噢。🙊

function showContent(type) {
  const editorContent = editor.innerHTML;
  let displayContent = '';
  if (type === 'html') {
    // 显示HTML内容,需要转义HTML标签以便显示
    displayContent = escapeHtml(editorContent);
  } else if (type === 'text') {
    // 显示纯文本内容
    displayContent = editor.textContent;
  }
  contentDisplay.textContent = displayContent;
}
// 转义HTML标签的辅助函数
function escapeHtml(html) {
  return html
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#039;');
}

🤔关于document.execCommand()

document.execCommand() 已经被标记为废弃(Deprecated)。虽然目前大多数浏览器仍然支持这个API,但它可能随时会噶了。。。

document.execCommand() 存在的问题:

  • 浏览器实现不一致:同样的命令在不同浏览器中可能有不同的行为。例如,加粗文本时,有些浏览器会使用 <b> 标签,而其他浏览器可能使用 <strong> 标签或添加CSS样式。
  • DOM 结构不可控:使用 execCommand() 修改内容时,可能会破坏现有的 DOM 结构。例如,选中 <span> 内的文本并加粗,可能会导致 <span> 标签被覆盖。
  • 功能支持不完整:某些命令在特定浏览器中不受支持,如 insertBrOnReturn 等参数指令在多种浏览器中无效。

image.png

相信你比较关心的是有什么替代方案❗(小编也是)

目前来说还没有一个完全标准化的官方替代 API ,但小编个人觉得有以下这下方案:

  1. 使用现代富文本编辑器库。
  • Quill.js
  • wangEditor
  • TinyMCE
  • CKEditor
  • Draft.js(React)
  1. 自定义实现。
  • 仍然使用 contentEditable ,但不依赖 execCommand()
  • 创建抽象文档模型,使用JSON描述文档内容和编辑操作。
  • 使用新的 Clipboard API 替代剪贴板操作。
  1. 等待新标准。
  • W3C正在开发 Input Events Level 2 标准,可能会成为 execCommand() 的修订版和改进版。
  • ContentEditable规范也在开发中,但对于我们这个简单的富文本编辑器示例,使用 document.execCommand() 是可以接受的,因为它简单直观,而且在大多数现代浏览器中仍然可用。




至此,本篇文章就写完啦,撒花撒花。

image.png