前端富文本练习
匡思进背景
word和wps将桌面客户端中的富文本编辑器做到了极致, 但他们的设计初衷就是做一款单机的文件处理软件, 对于多人协作等需求的支持并不能较好的支持
对于一些简单的场景, 我们也不需要word所提供的全部功能, 而是需要支持更多互联网数据格式, 存储本分更加方便, 能供提供多人协同编辑功能的轻量级富文本编辑器
基于浏览器的富文本编辑器就是在这样的设计思路下产生的,其中最典型的代表产品有 Google Docs
这些基于浏览器的富文本编辑器都有以下特点:
- 利用 Web 技术开发,需要在浏览器环境中使用;
- 功能相对 Word 更加简单,只保留了最常用的富文本编辑功能;
- 支持图片、附件、视频、音频、地图等多种互联网资源;
- 可以将文档备份在网盘中,实现多端同步;
- 文档可以分享查看,可以进行多人实时协同编辑。 当然基于浏览器的富文本编辑器,也是经过了几轮的技术迭代和创新,才到了今天这种百花齐放的局面。
基于浏览器的富文本编辑器的四要素
在现代的浏览器框架下,利用 Web 技术开发一款富文本编辑器,一般采用经典 MVC 模型,根据数据模型渲染视图,视图操作通过控制器修改数据模型。具体要解决以下四个问题:
模型:
模型包含内存模型和存储模型。存储模型是数据存储、同步和备份时的模型,需要考虑带宽、存储体积、模型序列化效率、模型正确性验证效率等因素。内存模型则是数据渲染时的模型,结构一般比存储模型复杂,会在存储模型的基础上添加其他渲染时需要用到的属性。
渲染:
渲染指如何将内存模型渲染成 Web 页面。所有的基于浏览器富文本编辑器都将内存模型渲染成为了 HTML 页面。但是它们在排版上的策略略有不同,大多数编辑器都采用了基于 HTML 和 CSS 的排版方式,也有少数编辑器自己实现了排版引擎,例如 Google Docs。
编辑:
编辑指如何提供编辑区域让用户在编辑区域编辑文档,以及如何感知用户编辑区域的编辑动作通知控制器以修改数据模型。浏览器提供了 contentEditable 的属性可以把元素变为可编辑状态,大部分编辑器都是以这个思路进行编辑的,并且它们可以拦截 contentEditable 元素的事件,将事件通知给控制器。也有少数编辑器自己实现了编辑区域和事件系统,例如 Google Docs。
指令:
指控制器根据收到的编辑区域的编辑动作,生成对应指令修改内存模型,内存模型得以更新完成循环。这部分与数据模型相关,如果数据模型是 HTML,编辑器可以通过 execCommand 直接修改 HTML 数据,如果是自定义数据,监听或拦截编辑区域上的事件,也可以推测意图生成出修改数据模型的指令。通过指令修改数据,可以更方便的实现撤销重做、历史版本恢复、协同编辑等功能。
基于浏览器的富文本编辑器的技术演进
以往
完全基于浏览器 API 设计, 数据模型直接采用 HTML 数据, 渲染用原生HTML, 编辑区域用 contentEditable生成, 通过 exeCommand执行浏览器自带的修改 HTML数据的指令
基于execommand 的一个简单编辑器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145
| <!DOCTYPE html> <html lang="en">
<head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> <style> body { display: flex; justify-content: center; align-items: center; }
.multi_text_container { width: 60%; height: 80vh; /* 使用视口高度的百分比 */ border: 1px solid #eee; /* 增加边框便于观察 */ }
.header_tool_bar { width: 100%; height: 50px; background-color: #f5f5f5; display: flex; gap: 1px; /* 减小间距,让项目更紧凑 */ padding: 0; /* 移除内边距 */ margin: 0; /* 移除外边距 */ justify-content: center; margin-bottom: 10px; }
.tool-item { flex: 1; width: auto; height: 50px; background-color: #fff; border: 1px solid #f5f5f5; display: flex; /* 使内容居中 */ justify-content: center; /* 水平居中 */ align-items: center; /* 垂直居中 */ cursor: pointer; /* 鼠标悬停显示手型 */ box-sizing: border-box; /* 确保边框包含在宽度内 */ }
.tool-item:hover { background-color: #f0f0f0; /* 悬停效果 */ }
.edit-container { width: 100%; height: calc(100% - 50px); background-color: #fff; }
.edit-area { border: 1px solid #f5f5f5; width: 100%; height: 100%; box-sizing: border-box; /* 确保边框包含在尺寸内 */ padding: 10px; /* 增加内边距,提升编辑体验 */ } </style> </head>
<body> <div class="multi_text_container"> <div class="header_tool_bar" id="toolbar"> <div class="tool-item" data-command="bold">加粗</div> <div class="tool-item" data-command="italic">斜体</div> <div class="tool-item" data-command="underline">下划线</div> <div class="tool-item" data-command="undo">撤销</div> <div class="tool-item" data-command="redo">重做</div> </div> <div class="edit-container"> <div class="edit-area" contenteditable="true" /> </div> <script> const editArea = document.querySelector('.edit-area');
//placeholder功能 editArea.addEventListener('focus', () => { if (editArea.innerHTML.trim() === '<div style="color: #999;">请输入内容</div>') { editArea.innerHTML = ''; }
//初始化占位符 updatePlaceholder()
function updatePlaceholder() { if (editArea.innerHTML.trim() === '') { editArea.innerHTML = '<div style="color: #999;">请输入内容</div>'; } } })
//工具栏 const toolbar = document.querySelector('#toolbar') toolbar.addEventListener('click', (e) => { const toolItem = e.target.closest('.tool-item') if (!toolItem) return
const command = toolItem.dataset.command if (!command) return
//聚焦编辑区域 editArea.focus()
const success = document.execCommand(command, false, null) if (!success) { console.log("false") } else { console.log("success") }
updateToolStates() })
//更新工具按钮状态函数 function updateToolStates() { ['bold', 'italic', 'underline'].forEach(command => { const toolItem = toolbar.querySelector(`[data-command="${command}"]`) const isActive = document.queryCommandState(command) toolItem.classList.toggle('active', isActive) }) } </script> </body>
</html>
|
execommand缺点
W3C 已停止维护, 未来随时可能会被移除
- 同一条命令在不同浏览器里, 生成的
HTML 完全不同 1
| document.execCommand('bold')
|
可能生成
1 2 3 4 5
| <b>文本</b> <!-- 或 --> <strong>文本</strong> <!-- 或 --> <span style="font-weight: bold">文本</span>
|
- 不适用于复杂场景, 对
selection/range支持很差
- 无法支持线代编辑需求(不能做到自定义格式规则, 精确控制撤销栈, 协同编辑, markdown输出 等功能)
现代
选取 Selection 与 Range
Selection和Range是 WebAPI 中的两个重要接口,用于处理用户在网页中的元素选择。这两个接口提供了对文档选区内容精准控制的API,可以用来创建富文本编辑器、处理用户选择、实现自定义的文本操作等。
Selection
Selection 对象表示用户选择的文本范围或插入符号的当前位置。它代表页面中的文本选区,可能横跨多个元素。文本选区由用户拖拽鼠标经过文字而产生。要获取用于检查或修改的 Selection 对象,请调用 window.getSelection()。
相关术语
锚点(anchor : 锚点指的是一个选区的起始点。当鼠标框选某个区域时,鼠标点击的那一瞬间锚点的位置即确定了,拖动时不会变化。
焦点(focus): 选区的焦点是选区的终点,当鼠标框选某个区域时,鼠标松开的一瞬间焦点即确定了,焦点位置随拖动变化。
范围(range): 范围指文档中连续的一部分。一个范围可能包含整个节点,也可能只包含某个节点的一部分。用户一般只能选择一个返回,但是也有可能会选择多个范围,例如使用Control+Click选择多个区域(Chrome 禁止了这个操作)。「范围」会被作为 Range对象返回。
实例属性
anchorNode****: 返回选区的锚点
anchorOffset****: 返回一个数字代表起锚点内的偏移量 (待补充)
focusNode****: 返回选区的焦点
focusOffset****: 返回一个数字代表焦点内的偏移量 (待补充)
isCollapsed****: 返回一个布尔值,判断选区起点和终点是否在同一位置。
**rangeCount****:**返回选区中Range对象的个数。通常是 1,但是某些浏览器中可能允许选择多个范围。
常用方法
**getRange(index)****:**返回选区中指定的Range对象。通常使用getRangeAt(0)来获取当前选区。
**addRange(range)****:**向选区中添加一个Range对象,如果已经存在范围,新的范围会合并。
**removeRange(range)****:**从选区中删除一个Range对象。
removeAllRanges() / **empty()****:**删除选区的所有Range对象
extend(node, [offset])****: 将选区的焦点移动到指定的点(节点+偏移量 可选)。选区的锚点不会移动,选区从锚点开始到新的焦点,不管方向。
**collapse(node, [offset])****:**将选区收起到指定点。即锚点和焦点设置为相同的值。
collapseToStart()****: 将选区收起到锚点。
**collapseToEnd()****:**将选区收起到焦点。
Range
实例属性
**collapsed****:返回一个布尔值,代表Range的起点和终点是否在同一位置。
**startContainer****:返回Range对象开始所在的Node
**startOffset****:返回Range在starContainer中的起始位置
endContainer 返回Range对象结束的Node
**endOffset****:返回Range在endContainer中的结束位置
常用方法
**setStart(node, offset)****:设置Range的开始位置为指定节点的指定偏移量
**setEnd(node, offset)****:设置Range的结束位置为指定节点的指定偏移量
**selectNode(node)****:将Range设置为包含整个Node
insertNode(node)****:在Range的起始位置**插入新的一个节点
**cloneContents()****:将Range中的所有Node对象复制为一个DocumentFragment对象
**extractContents()****:*删除Range的内容并返回一个包含了被删除内容的DocumentFragment对象
**deleteContents()****:删除Range包含的Document内容,与extractContents不同,他不会返回包含删除内容的对象
**selectNodeContents(node)****:将Range设置为包含指定Node的所有内容
**cloneRange()****:创建并返回一个与当前Range完全相同的副本对象,按值拷贝,并非按引用
**collapse([tostart])****:传入一个布尔值(可选,默认false),如果为 true 则折叠Range到start节点,否则折叠到end节点
文字加粗demo
监听按钮的点击事件 -> 触发事件, 调用bold函数 -> 保存所选范围 -> 获取最近的父元素的标签 -> 合并多余”strong” -> 创建一个新的”strong” 标签 -> 将原有节点加入到新的光标内, 并删除原DOM中的节点 -> 将新节点合并到DOM树中 -> 处理光标位置 -> 移除原来所有选区 -> 将已经处理好的选区作为唯一选区
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199
| <!DOCTYPE html> <html lang="en">
<head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> <style> body { display: flex; justify-content: center; align-items: center; }
.multi_text_container { width: 60%; height: 80vh; /* 使用视口高度的百分比 */ border: 1px solid #eee; /* 增加边框便于观察 */ }
.header_tool_bar { width: 100%; height: 50px; background-color: #f5f5f5; display: flex; gap: 1px; /* 减小间距,让项目更紧凑 */ padding: 0; /* 移除内边距 */ margin: 0; /* 移除外边距 */ justify-content: center; margin-bottom: 10px; }
.tool-item { flex: 1; width: auto; height: 50px; background-color: #fff; border: 1px solid #f5f5f5; display: flex; /* 使内容居中 */ justify-content: center; /* 水平居中 */ align-items: center; /* 垂直居中 */ cursor: pointer; /* 鼠标悬停显示手型 */ box-sizing: border-box; /* 确保边框包含在宽度内 */ }
.tool-item:hover { background-color: #f0f0f0; /* 悬停效果 */ }
.edit-container { width: 100%; height: calc(100% - 50px); background-color: #fff; }
.edit-area { border: 1px solid #f5f5f5; width: 100%; height: 100%; box-sizing: border-box; /* 确保边框包含在尺寸内 */ padding: 10px; /* 增加内边距,提升编辑体验 */ } </style> </head>
<body> <div class="multi_text_container"> <div class="header_tool_bar" id="toolbar"> <div class="tool-item" data-command="bold">加粗</div> <div class="tool-item" data-command="italic">斜体</div> <div class="tool-item" data-command="underline">下划线</div> <div class="tool-item" data-command="undo">撤销</div> <div class="tool-item" data-command="redo">重做</div> </div> <div class="edit-container"> <div class="edit-area" contenteditable="true"></div> </div> <script> const editArea = document.querySelector('.edit-area'); const toolbar = document.querySelector('#toolbar'); let savedRange = null; function saveSelection() { const sel = window.getSelection(); if (!sel.rangeCount) return; const range = sel.getRangeAt(0); // 必须确保选区在 editArea 内 if (!editArea.contains(range.commonAncestorContainer)) return; savedRange = range.cloneRange(); } function restoreSelection() { if (!savedRange) return false; const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(savedRange); return true; } // 用户选中文字后,保存选区 editArea.addEventListener('mouseup', saveSelection); editArea.addEventListener('keyup', saveSelection); editArea.addEventListener('blur', saveSelection); // 用 mousedown 阻止按钮抢走焦点(关键) toolbar.addEventListener('mousedown', (e) => { e.preventDefault(); });
// 获取元素最近的父元素 function closestTag(node, tagName, root) { let el = node.nodeType === 1 ? node : node.parentElement; tagName = tagName.toUpperCase();
while (el && el !== root) { if (el.tagName === tagName) return el; el = el.parentElement; } return null; } //拆标签 function unwrap(el) { const parent = el.parentNode; while (el.firstChild) parent.insertBefore(el.firstChild, el); parent.removeChild(el); parent.normalize(); // 合并相邻文本节点 }
const bold = () => { // 恢复选区(否则 focus 后选区容易丢) if (!restoreSelection()) return; const sel = window.getSelection(); if (!sel.rangeCount) return; const range = sel.getRangeAt(0); if (range.collapsed) return; // 没选中文字就不做 //如果选取起点/终点都在同一个<strong>里, 就认为是"已加粗", 执行取消 const startStrong = closestTag(range.startContainer, 'strong', editArea) const endStrong = closestTag(range.endContainer, 'strong', editArea)
if (startStrong && startStrong === endStrong) { unwrap(startStrong) saveSelection() return }
//否则加粗 const strong = document.createElement('strong'); strong.appendChild(range.extractContents()); //将选取内的所有节点添加到一个fragment内, 并从DOM中删除原有节点 range.insertNode(strong); // 光标放到 strong 后 range.collapse(false); // 若设置为true, 则 sel.removeAllRanges(); sel.addRange(range); // 更新保存的 range(方便连续操作) saveSelection(); }; const commandMap = { bold }; toolbar.addEventListener('click', (e) => { const toolItem = e.target.closest('.tool-item'); if (!toolItem) return; const command = toolItem.dataset.command; const handler = commandMap[command]; if (!handler) return; // 不要 editArea.focus() 了(它会弄丢选区) handler(); }); </script> </body>
</html>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134
| <template> <el-dialog :close-on-click-modal="false" :model-value="visible" :title="title" style="height: 76%" width="65%" @close="handleClose" > <div class="search-container"> <el-input v-model="searchStr" clearable @input="handleSearch" @keyup.enter.native="handleSearch"> <template #prefix> <i slot="prefix" class="iconfont icon-sousuo"></i> </template> </el-input> </div>
<div class="history-container"> <div v-for="item in data" :key="item.id"> <HistoryItem :data="item"></HistoryItem> </div> </div>
<div class="pagination-container"> <Pagination :limit="pageInfo.pageSize" :page="pageInfo.currentPage" :total="pageInfo.total" @pagination="handlePage" /> </div> </el-dialog> </template>
<script lang="ts" setup> import { reactive } from "vue"; import HistoryItem from "./item.vue"; import { useChatStore } from "@/store/modules/chat";
const chatMessageStore = useChatStore();
const emit = defineEmits(["handleClose"]);
const props = defineProps({ visible: { type: Boolean, required: true, default: false }, title: { type: String, required: true, default: "" } });
// 历史数据 const data = shallowRef();
// 搜索关键词 const searchStr = ref("");
// 分页 const pageInfo = reactive({ currentPage: 1, pageSize: 10, total: 0 });
watch( () => props.visible, val => { if (val) { // 打开时重置分页(可按需去掉) pageInfo.currentPage = 1; getList(); } else { searchStr.value = ""; data.value = []; } } );
const handlePage = (val: any) => { pageInfo.currentPage = val.page; pageInfo.pageSize = val.limit; getList(); };
const handleSearch = (val: any) => { pageInfo.currentPage = 1; pageInfo.pageSize = 10; getList(); };
const getList = async () => { let res: any = await chatMessageStore.handleHistoryMessage( { page: pageInfo.currentPage, size: pageInfo.pageSize }, searchStr.value ); data.value = res.list; pageInfo.total = res.total; };
const handleClose = () => { emit("handleClose"); }; </script> <style lang="scss" scoped> @use "@/assets/style/scss/index.scss" as *;
.el-dialog { position: relative; /* 让弹窗内的元素可以使用绝对定位 */ }
.history-container { // max-height: calc(70vh - 120px); height: calc(70vh - 120px); overflow-y: auto; padding: 5px; margin-top: 5px; @include scroll-bar(); }
.pagination-container { display: flex; justify-content: center; padding: 5px 0; } </style>
|