写在开头
Hello,各位好呀!☔☔☔
今是2025年05月04日,此刻大雨倾盆,五一假期即将不足,然而已连下两天雨了,后面也全是雨,难,太难了。😩
小编已经在宿舍躺平两天了,品茗(泡了点菊花茶喝)、听雨(雨的确大)、抚琴(网抑云)......
假期出不了门,是憾事,但,好在五一第一天小编就已经出去"疯"玩了一天,如:
特种兵见面玩耍,也算是感受了假期的氛围哈。
那么,回到正题,本次要分享的是关于富文本光标与拖动的那点事,效果如下,请诸君按需食用哈。
需求背景
在近期的业务开发中,遇上了一个富文本相关的需求。通常情况下,面对这类需求,大家可能会在 Quill.js、wangEditor、TinyMCE 等优秀的富文本插件中权衡选择,这些富文本库功能强大,能满足绝大多数场景的需求。
特别推荐使用 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>
效果:
好了,这样咱们就非常快速简单的完成了一个基础的富文本编辑器!😋
第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();
}
});
效果:
第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();
});
效果:
第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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
🤔关于document.execCommand()
document.execCommand()
已经被标记为废弃(Deprecated)。虽然目前大多数浏览器仍然支持这个API,但它可能随时会噶了。。。
document.execCommand()
存在的问题:
- 浏览器实现不一致:同样的命令在不同浏览器中可能有不同的行为。例如,加粗文本时,有些浏览器会使用
<b>
标签,而其他浏览器可能使用<strong>
标签或添加CSS样式。 - DOM 结构不可控:使用
execCommand()
修改内容时,可能会破坏现有的 DOM 结构。例如,选中<span>
内的文本并加粗,可能会导致<span>
标签被覆盖。 - 功能支持不完整:某些命令在特定浏览器中不受支持,如
insertBrOnReturn
等参数指令在多种浏览器中无效。
相信你比较关心的是有什么替代方案❗(小编也是)
目前来说还没有一个完全标准化的官方替代 API ,但小编个人觉得有以下这下方案:
- 使用现代富文本编辑器库。
- Quill.js
- wangEditor
- TinyMCE
- CKEditor
- Draft.js(React)
- 自定义实现。
- 仍然使用
contentEditable
,但不依赖execCommand()
。 - 创建抽象文档模型,使用JSON描述文档内容和编辑操作。
- 使用新的 Clipboard API 替代剪贴板操作。
- 等待新标准。
- W3C正在开发 Input Events Level 2 标准,可能会成为
execCommand()
的修订版和改进版。 ContentEditable
规范也在开发中,但对于我们这个简单的富文本编辑器示例,使用document.execCommand()
是可以接受的,因为它简单直观,而且在大多数现代浏览器中仍然可用。
至此,本篇文章就写完啦,撒花撒花。