本文目标
- 通过编辑器基础 api selection、range 的实际应用场景,实战加深对基础 api 的理解
- 实际项目中遇到的 contenteditable 替换 textarea 技术总结
1. 为什么要使用 contenteditable 替换 textarea
首先先说明一点,如非必要,请勿使用 contenteditable 元素替换 textarea!!!在使用此方案之前,必须再三问自己,这 contenteditable 咱就必须要用吗?接触了,可就踏入富文本编辑器的坑了,你将始终不得劲。
切入正题,要想正确判断自己是否非 contenteditable 不可,必须要明白 textarea 有什么问题是不能解决的,这里列出几点,可能也不是所有原因:
- textarea 元素不会根据文本内容自适应调整高度,这也是很多人使用 contenteditable 进行替换的最大的原因。倒也不是 textarea 万群无法做到,目前市面上一些组件库,如 element-plus, ant design 等,都会有 autosize 类型的多行文本输入组件,npm 也可以安装 autosize 包帮助 textarea 做到自适应高度,普通场景可以优先考虑这种现成的方案。但它们的使用肯能存在问题,其基本原理都是要实现对 dom 的监听,动态设置 dom 的高度,如果你的使用场景是表格单元格内的编辑,多个单元格同时使用,表格过大时,针对大量 dom 的操作会急剧拉低性能,甚至导致页面卡死现象(笔者面临的即为这种场景),此时可能就非用不可了。
- textarea 元素内只能简单支持纯文本,如果你真想实现什么 link 自动高亮,输入框艾特人员
@member
组件等,简单的 textarea 也是不太能满足的,不过此时很建议大家直接使用例如 prosemirror、tiptap 这样的编辑器来实现了,毕竟完全自己动手做,也相当于实现了一个超级无敌丐丐丐版的编辑器啊,成本高,bug 多,可能还有很多自己想不到的坑,都是说多了是泪系列。
这里是前端前辈张鑫旭大佬写的两篇关于 contenteditable 替换 textarea 的文章了,但基于 css 的方案目前由于兼容性,还是不能用于实际项目,但也是一些思路,大家可以参考。
2. 实现 RichTextarea 组件
这里给组件起名为 RichTextarea 是因为我们会以富文本技术来实现 textarea 标签的替换。
2.1 开始前必备知识
我们将会以 webcomponent 的形式来开发对应的组件,采用的库是 atomico 这个库,hooks API 与 React 基本一致,有 React 的基础可以直接上手,采用它的原因是,这个组件并不想与任何框架深度绑定,最好可以跨框架使用,编译后的代码要尽量小,甚至可以不编译。其实在学习工作中,我们可以先别争 Vue、React、Svelte 框架,别太过于陷入框架学习,这些框架、库就是个工具,在合适的场景使用合适的工具即可。
除此之外更重要的是需要理解浏览器 API Selection 与 Range,它们是实现以 contenteditable 为基础的富文本编辑器底层最核心的内容,没有之一。下图截取自 MDN 文档,之前实现富文本编辑器的核心 API execCommand
已被弃用,它很大一部分命令都是用来操作选区内容的,但当前 Selection 与 Range 的 api 也有能力来修改文档内容了。因此,如果是刚接触富文本的小伙伴,execCommand 可以作为了解,核心还是要理解 Seelction 与 Range API。这里也推荐我之前的一篇相关文章 《点亮富文本编辑器的魔力》:Selection与Range解密
2.2 项目准备
我们使用 vite 创建一个 Vanilla
原生项目(选择 typescript),并安装 atomito。
shell
# 1. 创建项目,选择 Vanilla Typescript
npm create vite@latest
# 2. 安装 atomico
npm i atomico
添加基础代码,使用 p 标签,添加 contenteditable
属性
tsx
// src/rich-textarea/index.ts
import { html, c, Component, Props } from 'atomico';
import { RichTextareaProps } from './types';
// 样式定义
const style = /*css*/`
.rich-textarea {
border: 1px solid #646cffaa;
border-radius: 4px;
outline: none;
padding: 6px 4px;
line-height: 1.2;
margin: 12px 0;
white-space: pre-inline;
caret-color: #646cffaa;
}
`
// 组件定义
const RichTextarea: Component<Props<RichTextareaProps>> = (props) => {
return html`
<host>
<style>${style}</style>
<p class="rich-textarea" contenteditable>
${props.value}
</p>
</host>
`
}
// atomico 组件的 props 需要像 vue 一样,显示定义,上面定义了 RichTextareaProps 类型仅用于类型提示
RichTextarea.props = {
value: {
type: String,
reflect: true,
value: ''
}
}
customElements.define('rich-textarea', c(RichTextarea))
使用方式
ts
// src/setup-rich-textarea.ts
import './rich-textarea/index';
export function setupRichTextarea(el: HTMLElement | null) {
if (!el) return;
el.innerHTML = /*html*/`
<h3>实现 contenteditable 替换 textarea</h3>
<div>
<rich-textarea value="hello world"></rich-textarea>
</div>
`
}
// src/main.ts
import { setupRichTextarea } from './setup-rich-textarea'
import './style.css'
document.querySelector<HTMLDivElement>('#app')!.innerHTML = /*html*/`
<div>
<div id="rich-textarea-wrapper"></div>
</div>
`
setupRichTextarea(document.getElementById('rich-textarea-wrapper'))
2.3 问题分析与解决
上述代码添为 p 标签添加了 contenteditable
属性,使得标签可以像 input textarea 元素一样可以直接编辑。但它目前有什么问题?与 textarea 有什么不同?
contenteditable 元素可以展示富文本内容(html标签),但 textarea 只能输入纯文本,我们主要也是解决这个问题。那就必须明白以目前的代码,什么时候会产生富文本?产生富文本的场景还是比较多样的,这里举几个例子:
- 换行
- 粘贴其他富文本
- 拖拽其他富文本进来
- ......
要解决这个问题,最好直接拦截用户输入的内容,通过我们处理后手动添加到输入框内。
2.4 深入了解输入事件
下方图中输入了 h
之后,使用鼠标点击 好
进行输入,观察触发的事件可以发现,必然触发的事件有 beforeinput
与 input
事件,input
事件触发的时候,内容已经输入到了输入框中,因此,拦截输入事件,需要从 beforeinput
入手。对于 beforeinput 与 input 事件触发后,获得的 event
均为 InputEvent
实例,InputEvent
实例详情可以关注 MDN文档。
在当前需求中,需要重点关注的是 InputEvent
实例中的 inputType
、isCompositing
、data
属性:
inputType
: 这是上述三个重点关注属性中的重中之重,可以通过 Input Events Level 1 查看所有的类型,也可以通过 MDN InputEvent 文档 中的 Result 来查看不同的输入方式对应哪种输入类型。完全拦截输入后,我们可能需要针对不同的输入类型来修改最终 DOM 中的文本,不一定所有的输入类型都需要实现,一些冷门操作可以根据业务需求决定是否立刻实现。isComposing
:表示当前是否处于合成字符输入状态,什么是合成字符?即中文拼音(或韩文日文等)输入状态,例如上方图中中文状态下输入了 h, 但是还没按回车将最后的内容输入到输入框的状态。除此之外,还有对应的compositionstar
t 与compositionend
事件表示当前是否进入或退出了合成输入状态。如果有人实际场景中遇到过在文本框中输入内容会触发搜索,但是中文拼音输入状态下不应该触发搜索,此时可以用这个属性结合 composition 事件来判断输入状态进行解决。data
: data 表示当前输入的文本内容,我们的原理也主要是通过 data 获取到输入进来的文本,与之前文本进行组合,生成完整文本呈现给用户。
2.5 实现流程分析
首先我们先看一下 beforeinput input 以及 dom 中文本变化的整个流程:
在输入过程中,会先触发 beforeinput
事件,此时文本内容还没有真正输入到输入框中,之后,input 输入框中内容变化,最终触发 input
事件。因此,如果是要对 input 事件进行 preventDefault
可能你会发现根本阻止不了正常的输入进行。目前的大致处理流程就变成了这样:
2.6 实现细节
确定重点关注的输入类型
确定我们需要重点关注的输入类型,其他类型的输入一律阻止,缩小干活的范围,如果你的业务需求要求还有其他的类型,可以按情况对那些输入类型进行放行(还是要强调,如果是很复杂的需求,请使用 posemirror 或 tiptap 等编辑器现有技术方案实现):
ts
/**
* 重点关注的输入类型
*/
const ALLOW_INPUT_TYPE = [
// 输入类型
'insertParagraph', // 输入新行 (直接按下 回车)
'insertLineBreak', // 输入换行符 (按下 shift + 回车)
'insertText', // 输入文本
'insertCompositionText', // 中文合成输入
'insertFromPaste', // 粘贴输入
'insertFromDrop', // 从别的地方拖拽输入,在 firefox 中尝试
// 删除类型
'deleteContentBackward', // 向前删除,即直接按下删除键
'deleteContentForward', // 向后删除,win 按下 delete 键,mac 按下 fn + delete
'deleteByCut', // 剪切,通过 ctrl + x 或 cmd + x 剪切
'deleteByDrag', // 从当前输入框中拖拽到其他地方
// 历史
'historyUndo', // ctrl + z 或 cmd + z
'historyRedo', // ctrl + shift + z 或 cmd + shift + z
]
// 在 onBeforeInput 中对非重点关注事件类型直接阻止
const onBeforeInput = (event: InputEvent) => {
const target = (event.target as HTMLElement);
const eventType = event.inputType;
// 非重点关注的事件类型直接阻止
if (!ALLOW_INPUT_TYPE.includes(eventType)) {
event.preventDefault();
return;
}
}
处理粘贴的内容
要想实现类似 textarea 输入纯文本的特性,首先第一条就是要阻止默认输入的富文本,在当前输入框中最可能带来大量富文本内容的事件就是 paste
事件。
通过观察发现,paste
事件在 beforeinput
之前触发,并且在 beforeinput
与 input
事件中 data
属性都是 null,按我们之前理想化的处理流程,尽管我们能够在 beforeinput
中阻止输入进行,但也拿不到真正粘贴进来的内容,只能在 paste 事件中获取到输入文本,因此,需要拦截 onpaste
事件,手动触发一个 beforeinput
事件,将输入的文本内容塞进 beforeinput 中。
ts
const onPaste = (event: ClipboardEvent) => {
// 阻止默认事件,否则系统会派发一个 data 为 null 的 beforeinput 与 input 事件
event.preventDefault();
// 获取输入的纯文本内容
const pasteText = event.clipboardData?.getData("text") || '';
// 手动触发一个 beforeinput 事件,虽然这里 inputType 可以随意填写,为了语义化以及后续排错等,还是按规定触发 insertFromPaste 类型的事件。
if (pasteText) {
event.target?.dispatchEvent(new InputEvent('beforeinput', {
inputType: 'insertFromPaste',
data: pasteText,
bubbles: true,
cancelable: true
}))
}
}
// onbeforeinput 中也增加对应的 paste 输入,可以打印查看当前是否可以获取到粘贴的文本
const onBeforeInput = (event: InputEvent) => {
// ....
if (eventType === 'insertFromPaste') {
event.preventDefault();
console.log('event', event)
return;
}
// ...
}
这里为什么没有触发 input 事件?
因为手动触发的 beforeinput
以及 input
都不会增加浏览器的真实输入行为,即仅仅只是事件触发了,不会导致真正有内容插进来。并且按之前的分析,input 事件是在 dom 内容修改后才出发的,这里出发的时机也是错的。
将内容插入到 dom 中
在输入内容过程中,需要考虑光标的状态,如果当前有选中内容,则选中内容应被删除后再插入新的内容,如果是光标,则直接插入即可。真正插入内容,我们需要借助 Selection api 进行操作,通过 selection 先删除当前选择的文本,再将内容转为文本节点插入。
ts
export function insertContentIntoEditor(content: string) {
const selection = window.getSelection();
if (!selection || !selection.rangeCount) return false;
if (!selection.isCollapsed) {
// 将已选中内容删除,删除后,selection 的属性会自动更新,后续不必重新获取 selection
selection.deleteFromDocument();
}
// 根据已有第一个 range ,clone 创建一个新的 range
const range = selection.getRangeAt(0).cloneRange();
// 移除当前所有选区
selection.removeAllRanges();
// 创建待插入的文本节点
const textNode = document.createTextNode(content);
// 插入新的文本节点
range.insertNode(textNode);
// 光标聚焦到尾部
range.collapse();
// 将新的 range 添加到选区
selection.addRange(range);
return true;
}
// 定义后续更新值的函数,目前先不实现,仅仅打印当前结果
const updateValue = () => {
console.log('update value, current value is:', editorRef.current?.innerText)
}
const onBeforeInput = (event: InputEvent) => {
// ...
// 更新 onBeforeInput 中的拦截实现
if (eventType === 'insertFromPaste' && event.data) {
const result = insertContentIntoEditor(event.data);
if (result) {
updateValue()
}
return;
}
// ...
}
处理换行符
默认的换行在 chrome
与 firefox
中的行为是不一致的,为了使我们的输入框中结果不论何时都保持一致,需要对换行进行拦截。
浏览器默认换行现状(目前就观察 chorme 与 firefox 两大主流浏览器)
浏览器 | 输入回车 | 输入shift+回车 |
---|---|---|
Chrome | <div><br/></div> |
1. 在段落结尾输入:插入两个\n 2. 非段落尾部:插入一个 \n |
Firefox | 段落尾部输入回车:<br/><br/> 双 br 非段落尾部输入回车:<br/> 单 br |
与回车效果一致 |
我们可以看到,按下回车,输入的内容都是不相同的,为了统一输入内容,我们以 Firefox 的效果为标准。如果在段落尾按回车,则插入两个\n
,否则插入一个 \n
,shift + 回车效果一致。
我们可以先写一些工具函数,帮助我们获取当前光标的位置,如果对 Selection 与 Range API 还不太了解,请先查看上篇文章 《点亮富文本编辑器的魔力》:Selection与Range解密
ts
// 是否是文本节点
function isTextNode(node: unknown): node is Text {
return !!node && node instanceof Text;
}
// 是否是 br 节点
function isBrNode(node: unknown): node is HTMLBRElement {
return !!node && node instanceof HTMLBRElement;
}
// 是否处于输入框内
function isRichTextarea(node: unknown): node is HTMLElement {
return !!((node instanceof HTMLElement) && node.dataset.richTextarea);
}
// 根据 anchorNode 向父级查找输入框节点
function findRichTextarea(node: Node | null) {
if (!node) return null;
if (isRichTextarea(node)) {
return node;
}
return findRichTextarea(node.parentNode)
}
/**
* 获取光标位置
*
* @returns
*/
export function getCursorPosition() {
const selection = window.getSelection();
/**
* 1. 返回默认值 0 的情况:
* - selection 不存在
* - 当前不存在被选择的内容
* - 当前选区有被选中文本,而非光标状态
*/
if (!selection || !selection.rangeCount || !selection.isCollapsed) return 0;
const anchorNode = selection.anchorNode;
const textareaNode = findRichTextarea(anchorNode);
/**
* 2. 光标不在输入框内,返回默认值
*/
if (!textareaNode) return 0;
/**
* 3. 获取光标位置
* 分析:
* - 当前 textarea 内只会存在文本节点与 br 标签,如果 range 选中的是 br 节点,
* 则 anchorNode 为 br 的父节点,即 textarea 节点,range 选中内容为节点时,
* offset 计算单位是同级节点数量;
* - 如果选中内容是文本内容,anchorNode 则是 TextNode
* - 需要计算当前位置之前,所有文本长度与br数量之和
*/
if (isRichTextarea(anchorNode)) {
let pos = 0;
const childNodes = textareaNode.childNodes;
const anchorOffset = selection.anchorOffset;
for (let i = 0; i < anchorOffset; i++) {
const child = childNodes[i];
if (isTextNode(child)) {
pos += child.length
} else if (isBrNode(child)) {
pos += 1;
}
}
return pos;
}
if (isTextNode(anchorNode)) {
let pos = 0;
const childNodes = textareaNode.childNodes;
const anchorOffset = selection.anchorOffset;
for (let i = 0; i < childNodes.length; i++) {
const child = childNodes[i];
// 当前光标刚好在文本节点上
if (child === anchorNode) {
pos += anchorOffset;
return pos;
}
pos += isTextNode(child) ? child.length : 1;
}
}
// 这里的返回基本不会执行,但是为了 ts 的类型安全,返回一个数字
return 0;
}
除此之外,还需要改造一下之前的 insertContentIntoEditor
兼容插入 \n
问题,在首次输入回车创建新的行时,会创建两个换行符 \n\n
,正常情况下,我们通过移动键盘左右键之后,光标是不会停留在最后一个 \n
后的。
为什么第一次要插入两个 \n
?其实是因为在紧跟文本后,如果只有一个 \n
或者 \br
,浏览器默认会折叠这个换行,我们看到的内容在展示上是没有换行的,所以需要插入两个换行,浏览器吞掉一个,还能展示剩下的一个。
ts
// 添加一个参数 isFirstCreateNewLine
export function insertContentIntoEditor(content: string, isFirstCreateNewLine = false) {
// ...
// 光标聚焦到尾部,这里如果我们插入的是两个 \n, 需要将光标向前移一位,因为实际通过键盘移动光标的时候,是不会将光标移动到最后一个 \n 之后的
if (isFirstCreateNewLine) {
range.setStart(textNode, 1);
range.setEnd(textNode, 1);
} else {
range.collapse();
}
//...
}
处理拖拽输入
通过拖拽可以进行输入以及删除内容,firefox 操作比较简单,chrome (笔者目前使用的电脑 Mac M1Pro 无法拖拽)。如果是从当前输入框中拽拽内容到其他输入框,从当前输入框中删除内容,我们可以不做处理。但如果是从其他输入框拖拽进来的内容,那可能就有说到了,如果其他输入框是富文本输入框,拖拽进来的内容则有可能包含各种标签,这里我们就需要处理,获取到纯文本,将其插入到合适为止。
在 onBeforeInput
中补充以下代码
ts
const onBeforeInput = (event: InputEvent) => {
// ...
// 通过拖拽输入
if (eventType === 'insertFromDrop') {
event.preventDefault();
// 从 dataTransfer 中获取到当前拖进来的文本
const dropData = event.dataTransfer?.getData('text') || '';
if (dropData) {
// 文本内容存在,则将其插入
const result = insertContentIntoEditor(dropData);
if (result) {
updateValue()
}
}
return;
}
// ...
}
输入历史 undo redo
到目前为止,我们的输入框其实已经跟 textarea 的效果差不多了,但还有个比较大的问题,通过 selection 操作视图其实也是修改了 dom,关于 dom 的修改是不会被记录在浏览器历史中的,因此,我们要自行实现操作历史 undo redo,思路入下:
要实现 undo redo,我们需要自行实现一个历史栈,其中存储输入历史,在输入框第一次 focus 时,将输入框已有的内容直接增加到历史栈中,否则回复不到第一次的状态;之后输入内容时,拦截ctrl+z
,ctrl+shift+z
, 直接阻止浏览器默认事件(因为我们要自行实现历史栈),但此时,我们还需要手动触发一个 beforeinput 事件,其中 inputType 需要是 historyUndo
与 historyRedo
,之后在 beforeinput 事件中拦截 history 的操作,调整栈指针,获取对应数据并恢复。这里要注意,我们需要删除之前所有对 updateValue
的调用,然后增加一个 dispatchInnerInputEvent
方法用来触发 input 事件,原因是我们在 beforeinput 中拦截了一些事件,调用了 preventDefault
并更新了输入框内容,此时却没有派发新的 input,所以需要补上。之后,在 input 事件中,过滤掉类型为 historyUndo
与 historyRedo
的数据,其他类型更新输入框值之后,统一将其加入历史栈中。
触发 input 事件的函数如下(同时先删除迁的 updateValue 函数的调用,后续直接在 onInput 中调用即可):
ts
// 触发 input 事件
const dispatchInnerInputEvent = (event: InputEvent, inputType: string, data: string | null = null) => {
// 使用 requestAnimationFrame 也是等 dom 内容更新后我们再出发,使之与浏览器默认的触发顺序一致
requestAnimationFrame(() => {
event.target?.dispatchEvent(new InputEvent('input', {
inputType,
bubbles: event.bubbles,
cancelable: event.cancelable,
data,
}))
})
}
// import platform from 'platform';
// 判断是否为苹果系产品,这里使用了 platform 这个库
const isApplePlatform = () => ['iOS', 'OS X'].includes(platform.os?.family || '')
// 拦截 ctrl + z 与 ctrl + shift +z
const onKeydown = (event: KeyboardEvent) => {
// 苹果系产品,拦截 cmd + z / cmd + shift + z
// 其他产品,拦截 ctrl + z / ctrl + shift + z
const ctrlKey = isApplePlatform() ? event.metaKey : event.ctrlKey;
if (event.code === 'KeyZ' && ctrlKey && !event.shiftKey) {
event.preventDefault();
const textareaNode = event.target as HTMLElement;
// 触发 undo 类型的 beforeinput
textareaNode.dispatchEvent(new InputEvent('beforeinput', {
data: null,
inputType: 'historyUndo'
}))
return;
}
if (event.code === 'KeyZ' && ctrlKey && event.shiftKey) {
event.preventDefault();
const textareaNode = event.target as HTMLElement;
// 触发 redo 类型的 beforeinput
textareaNode.dispatchEvent(new InputEvent('beforeinput', {
data: null,
inputType: 'historyRedo'
}))
return;
}
}
事件拦截了之后,我们需要先实现自己的历史栈
ts
// 栈元素,包含输入框内容,以及光标位置,恢复栈中数据时,也需要恢复光标位置
export interface EditorStackItem {
content: string;
pos: number;
};
// 历史栈
export class EditorStack {
// stackSize: 表示当前栈最多能恢复多少次
constructor(private stackSize = 20) {}
// 栈历史数据
private histories: EditorStackItem[] = [];
// 当前数据索引指针,调用 undo redo 恢复数据时,只需要移动指针,不需要创建两个数组分别保存内容
private index: number = -1;
// 当前栈大小
get size() {
return this.histories.length;
}
// 向栈中增加数据
push(item: EditorStackItem) {
// 指针指向栈顶的时候,表明当前栈存满了,此时,需要把第一条历史记录删掉,再入栈
if (this.index + 1 === this.stackSize) {
this.histories = this.histories.slice(1, this.index + 1);
} else {
// 每次入栈,需要把栈顶超过 index 的内容都删掉,他们本身应该是 redo 时候要恢复的数据,
// 但 undo 几次后,重新输入,他们是应该被丢弃的
this.histories = this.histories.slice(0, this.index + 1);
}
// 入栈
this.histories.push(item);
// 更新指针位置
this.index = this.histories.length - 1;
}
// 执行 undo,将索引向前移动,获取到前一条历史数据后返回
undo(){
// 如果已经恢复到了第一条数据,就不能再向前恢复
if (this.index <= 0) {
return null;
}
this.index--;
const item = this.histories[this.index];
return item;
}
// 执行 redo, 将索引向后移动,获取后一条历史数据后返回,如果在最后一条了,就返回 null,没办法 redo 了
redo() {
if (this.index + 1 < this.histories.length) {
this.index++;
return this.histories[this.index]
}
return null;
}
}
有了历史栈,我们还需要真正恢复历史栈的函数,用来拿到 undo redo 给的数据后,进行数据恢复,但是需要明确的是,我们需要回复的内容有两个,第一个是输入框的值,第二个是光标位置。恢复输入框的值比较简单,通过 dom.innerText 设置值就行,但是恢复光标位置,还需要借助 Selection API
ts
// 移动光标到指定位置
export function moveCursorTo(textareaNode: Node, pos: number) {
const selection = window.getSelection();
if (!selection) return;
const childNodes = textareaNode.childNodes;
// 创建一个新的 range
const range = document.createRange()
// acc 用来记录遍历过了多少个字符
let acc = 0;
// 遍历输入框的 childNodes 节点,要明白,当前输入框的内容已经被我们控制到只有文本节点与 br 节点了
for(let i = 0; i < childNodes.length; i++) {
const child = childNodes[i];
// 处理文本节点
if (isTextNode(child)) {
if (acc + child.length >= pos) {
const offset = pos - acc;
// 设置光标位置
range.setStart(child, offset)
range.setEnd(child, offset)
break;
}
acc += child.length;
}
// 处理 br 节点
if (isBrNode(child)) {
if (acc + 1 === pos) {
// 设置光标位置
range.setStartAfter(child)
range.setEndAfter(child)
break;
}
acc += 1;
}
}
// 删除已有的 range
if (selection.rangeCount) {
selection.removeAllRanges()
}
// 更新 range
selection.addRange(range)
}
// 执行 undo 操作
export function undoHistory(stack: EditorStack, textareaNode: HTMLElement) {
// 获取历史栈中数据
const item = stack.undo();
if (!item) return false;
// 通过 innerText 更新数据
textareaNode.innerText = item.content;
// 恢复光标位置
moveCursorTo(textareaNode, item.pos);
return true;
}
// 执行 redo 操作
export function redoHistory(stack: EditorStack, textareaNode: HTMLElement) {
// 获取历史栈中数据
const item = stack.redo();
if (!item) return false;
// 通过 innerText 更新数据
textareaNode.innerText = item.content;
console.log('redo: ', item.content)
// 恢复光标位置
moveCursorTo(textareaNode, item.pos);
return true;
}
有了这一系列操作方法,我们可以回到组件中补充之前我们分析的流程中的其他内容了
ts
// 初始化创建 EditorStack 栈,组件的 props 需要增加一个 historyStackSize 属性,方便自行控制历史栈的大小,默认为 20
const editorHistory = useRef<EditorStack>(new EditorStack(props.historyStackSize));
const onBeforeInput = (event: InputEvent) => {
// ...
// beforeinput 中拦截历史操作,执行上述方法进行历史数据恢复
if (['historyUndo', 'historyRedo'].includes(eventType)) {
event.preventDefault();
if (eventType === 'historyUndo') {
undoHistory(editorHistory.current!, textareaNode);
} else {
redoHistory(editorHistory.current!, textareaNode);
}
// 触发 input 事件
dispatchInnerInputEvent(event, eventType)
return;
}
}
// input 事件中,除了 操作 history 的事件,其余更新值的时候,都直接进栈
const onInput = (event: InputEvent) => {
if (!['historyUndo', 'historyRedo'].includes(event.inputType)) {
editorHistory.current?.push({
content: (event.target as HTMLElement).innerText,
pos: getCursorPosition()
})
}
updateValue()
}
// 第一次 foucs 时,需要向栈中添加初始数据,否则无法恢复到第一条数据
const onFocus = (event: FocusEvent) => {
// 使用 requestAnimationFrame 是因为刚 focus 时,获取到的 pos 是不准确的
requestAnimationFrame(() => {
const textareaNode = event.target as HTMLElement;
if (!editorHistory.current!.size) {
editorHistory.current?.push({
content: textareaNode.innerText,
pos: getCursorPosition()
})
}
})
}
效果展示
到此,我们主要的功能已经实现,虽然还差很多细节,如组件的 props 的补充,组件 change 事件的触发,这个可以直接放在 updateValue 函数㕜实现,以及组件 props 的 value 更新后,更新输入框内容,这些都是框架中的一些普通处理了,非核心内容。我们可以预览一下当前的效果:
2.6 roadmap
虽然核心功能实现了,但还有很多细节,上面也提到了几个,所以在这里列出几个后续优化的 内容
- 1. 当前的历史栈需要增加防抖,对于每输入一个字符就入栈一次,与原生实现由出入
- 2. bug修复:当内容从其他输入框直接拖进来的时候,undo 失效
- 3. 组件向外触发正常输入框的事件:input, change, focus, blur, keydown, keyup 等
- 4. 组件 value 值更新后,组件内需要修改视图
- 5. 封装提供 vue,react,svelte 等框架版本,目前组件已经跨框架,不过在使用 vue 等框架时,还可以进一步封装为对应组件
3. 小结
本文主要聚焦于目前 contenteditable 替换 textarea 组件的实际场景,结合上一篇文章 《点亮富文本编辑器的魔力》:Selection与Range解密 中讲解的 Selection 与 Range 基础,进行了一次代码实战。分析了实现 contenteditable 替换 textarea 时遇到的一些问题,以及对应的解决方案,并手把手通过代码较高完成度实现了 textarea 的替代品。使用了 atomico 库(类 react),将 RichTextarea 封装成了跨框架的 webcomponent。当然,还是建议大家,能用 textarea 的时候,就别换 contentediable,就算上面实现的组件已经可以很大程度上替换 textarea,但如果你有一些特殊场景,可能还需要解决其他问题,成本较高。如果有必要,使用 prosemirror 或 tiptap 进行组件封装也未尝不是一个更好的选择。
后续计划:将会分享一些 prosemirrror 与 tiptap 相关的文章。
期待与你下次相见!
See you next time!