创新文本输入:掌握 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 之后,使用鼠标点击 好 进行输入,观察触发的事件可以发现,必然触发的事件有 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, 但是还没按回车将最后的内容输入到输入框的状态。除此之外,还有对应的 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 之前触发,并且在 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!

相关推荐
zttbee27 分钟前
npm install卡顿
前端·npm·node.js
Amos_Web31 分钟前
Rspack 源码解析(二十七):AsyncWasmParserAndGenerator 如何把二进制变成异步模块
前端·rust
西红柿炖牛腩35440 分钟前
机房电脑不能联网,怎么离线把课件里的 3D 模型压小?Draco / Meshopt 参数清单
前端·javascript·3d·性能优化·大屏端
爱喝水的小周42 分钟前
实验十三 Vue 过渡与动画—电商后台消息中心实现
前端·javascript·vue.js
hunterandroid1 小时前
Navigation Component 工程落地:从单 Activity 架构到深度链接配置
android·前端
无责任此方_修行中1 小时前
如何不用括号调用 JS 的函数?
前端·javascript·node.js
kyriewen1 小时前
我数了 647 条大厂前端 JD:AI 不再只是加分项
前端·程序员·ai编程
无责任此方_修行中1 小时前
Deno 的八年:一场没逃出 Node 引力的运行时起义
前端·javascript·node.js
java1234_小锋1 小时前
【技术专题】HTML5 网页技术 - HTML5 语义化标签
前端·html·html5
涛涛ing1 小时前
2026年,你的JavaScript计数逻辑可以删了:CSS刚刚学会了自己数数
前端