创新文本输入:掌握 contenteditable 替代 textarea 的艺术

本文目标

  • 通过编辑器基础 api selection、range 的实际应用场景,实战加深对基础 api 的理解
  • 实际项目中遇到的 contenteditable 替换 textarea 技术总结

1. 为什么要使用 contenteditable 替换 textarea

首先先说明一点,如非必要,请勿使用 contenteditable 元素替换 textarea!!!在使用此方案之前,必须再三问自己,这 contenteditable 咱就必须要用吗?接触了,可就踏入富文本编辑器的坑了,你将始终不得劲。

切入正题,要想正确判断自己是否非 contenteditable 不可,必须要明白 textarea 有什么问题是不能解决的,这里列出几点,可能也不是所有原因:

  1. textarea 元素不会根据文本内容自适应调整高度,这也是很多人使用 contenteditable 进行替换的最大的原因。倒也不是 textarea 万群无法做到,目前市面上一些组件库,如 element-plus, ant design 等,都会有 autosize 类型的多行文本输入组件,npm 也可以安装 autosize 包帮助 textarea 做到自适应高度,普通场景可以优先考虑这种现成的方案。但它们的使用肯能存在问题,其基本原理都是要实现对 dom 的监听,动态设置 dom 的高度,如果你的使用场景是表格单元格内的编辑,多个单元格同时使用,表格过大时,针对大量 dom 的操作会急剧拉低性能,甚至导致页面卡死现象(笔者面临的即为这种场景),此时可能就非用不可了。
  2. textarea 元素内只能简单支持纯文本,如果你真想实现什么 link 自动高亮,输入框艾特人员 @member 组件等,简单的 textarea 也是不太能满足的,不过此时很建议大家直接使用例如 prosemirror、tiptap 这样的编辑器来实现了,毕竟完全自己动手做,也相当于实现了一个超级无敌丐丐丐版的编辑器啊,成本高,bug 多,可能还有很多自己想不到的坑,都是说多了是泪系列。

这里是前端前辈张鑫旭大佬写的两篇关于 contenteditable 替换 textarea 的文章了,但基于 css 的方案目前由于兼容性,还是不能用于实际项目,但也是一些思路,大家可以参考。

div模拟textarea文本域轻松实现高度自适应---张鑫旭

小tip: 如何让contenteditable元素只能输入纯文本---张鑫旭

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 只能输入纯文本,我们主要也是解决这个问题。那就必须明白以目前的代码,什么时候会产生富文本?产生富文本的场景还是比较多样的,这里举几个例子:

  1. 换行
  2. 粘贴其他富文本
  3. 拖拽其他富文本进来
  4. ......

要解决这个问题,最好直接拦截用户输入的内容,通过我们处理后手动添加到输入框内。

2.4 深入了解输入事件

下方图中输入了 h 之后,使用鼠标点击 进行输入,观察触发的事件可以发现,必然触发的事件有 beforeinputinput 事件,input 事件触发的时候,内容已经输入到了输入框中,因此,拦截输入事件,需要从 beforeinput 入手。对于 beforeinput 与 input 事件触发后,获得的 event 均为 InputEvent 实例,InputEvent 实例详情可以关注 MDN文档

​ 在当前需求中,需要重点关注的是 InputEvent 实例中的 inputTypeisCompositingdata 属性:

  • inputType: 这是上述三个重点关注属性中的重中之重,可以通过 Input Events Level 1 查看所有的类型,也可以通过 MDN InputEvent 文档 中的 Result 来查看不同的输入方式对应哪种输入类型。完全拦截输入后,我们可能需要针对不同的输入类型来修改最终 DOM 中的文本,不一定所有的输入类型都需要实现,一些冷门操作可以根据业务需求决定是否立刻实现。
  • isComposing:表示当前是否处于合成字符输入状态,什么是合成字符?即中文拼音(或韩文日文等)输入状态,例如上方图中中文状态下输入了 h, 但是还没按回车将最后的内容输入到输入框的状态。除此之外,还有对应的 compositionstart 与 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 之前触发,并且在 beforeinputinput 事件中 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;
  }
  // ...
}

处理换行符

默认的换行在 chromefirefox 中的行为是不一致的,为了使我们的输入框中结果不论何时都保持一致,需要对换行进行拦截。

浏览器默认换行现状(目前就观察 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 需要是 historyUndohistoryRedo,之后在 beforeinput 事件中拦截 history 的操作,调整栈指针,获取对应数据并恢复。这里要注意,我们需要删除之前所有对 updateValue 的调用,然后增加一个 dispatchInnerInputEvent 方法用来触发 input 事件,原因是我们在 beforeinput 中拦截了一些事件,调用了 preventDefault 并更新了输入框内容,此时却没有派发新的 input,所以需要补上。之后,在 input 事件中,过滤掉类型为 historyUndohistoryRedo 的数据,其他类型更新输入框值之后,统一将其加入历史栈中。

触发 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!

相关推荐
速盾cdn5 分钟前
速盾:vue的cdn是干嘛的?
服务器·前端·网络
四喜花露水37 分钟前
Vue 自定义icon组件封装SVG图标
前端·javascript·vue.js
前端Hardy1 小时前
HTML&CSS: 实现可爱的冰墩墩
前端·javascript·css·html·css3
web Rookie1 小时前
JS类型检测大全:从零基础到高级应用
开发语言·前端·javascript
Au_ust1 小时前
css:基础
前端·css
帅帅哥的兜兜1 小时前
css基础:底部固定,导航栏浮动在顶部
前端·css·css3
工业甲酰苯胺1 小时前
C# 单例模式的多种实现
javascript·单例模式·c#
yi碗汤园1 小时前
【一文了解】C#基础-集合
开发语言·前端·unity·c#
就是个名称1 小时前
购物车-多元素组合动画css
前端·css
编程一生2 小时前
回调数据丢了?
运维·服务器·前端