富文本编辑基础核心

28 阅读7分钟

富文本开发核心

富文本编辑器的开发核心在于支持多样化的文本操作和功能,包括:

  • 文本格式化:加粗,斜体,下划线,颜色,背景色等多种文本样式。
  • 多媒体插入:图片,视频,文本,连接等。
  • 撤销重做:用户在编辑过程中随时撤销和重做操作。

contentenditable基础

  • contenteditle 属性:

    • 是HTML属性,可以使任何元素变为可编辑状态。通过设置contentEditable="true",用户可以直接在元素内输入或删除文本。
    • 常用于构建富文本编辑器的编辑区域,简单高效。

创建一个编辑的div元素。

<div id="editor-content" contentEditable="true">
  <p>
      这里是一个可编辑层
    </p>
</div>

创建多个可编辑区域

<div id="editor-content" contentEditable="true">
  <div contentEditable="true">
      <div contentEditable="true">1</div>
      <div contentEditable="true">2</div>
      <div contentEditable="true">3</div>
    </div>
</div>

8204bd03-7042-4182-a880-cd47c8d2d443.png

文字加粗和斜体

 <div contentEditable="true">
      <div contentEditable="true">1</div>
      <div contentEditable="true">2</div>
      <div contentEditable="true">3</div>
      <i>斜体<span>元素</span></i>
      <div contentEditable="true">4</div>
      <b>加粗</b>
    </div>

7ba4ab43-0d58-4edd-a7ab-ee9787c224d1.png

HTML字符串作为富文本存储的性能问题

  1. 析与序列化开销大。
  2. 存储效率低
  3. 修改与操作困难
  4. Diff与合并性能差

使用JSON Context

JSON Context是结构化数据表示(通常为树状JSON对象),核心是用“节点类型+属性+子节点”的模型抽象富文本内容,完美解决HTML的性能痛点

定义(上面HTML描述):

const doc = {
  type: 'doc',
  content: [
    { type: 'paragraph', content: [{ type: 'text', text: '1' }] },
    { type: 'paragraph', content: [{ type: 'text', text: '2' }] },
    { type: 'paragraph', content: [{ type: 'text', text: '3' }] },
    {
      type: 'paragraph',
      content: [
        { type: 'text', text: '斜体', marks: [{ type: 'italic' }] },
        {
          type: 'text',
          text: '元素',
          marks: [{ type: 'italic' }]
        }
      ]
    },
    { type: 'paragraph', content: [{ type: 'text', text: '3' }] },
    {
      type: 'paragraph',
      content: [
        { type: 'text', text: '加粗', marks: [{ type: 'bold' }] }
      ]
    }
  ]
};

给富文本添加 onInput 事件

const  handleInput = (e:InputEvent) => {
  const target = e.currentTarget as HTMLElement;
  console.log(target.innerHTML)
  // DOMParser 是浏览器原生 API,用于将字符串解析为 完整的 HTML 文档对象(Document)。
  const domParser = new DOMParser();
  const doc = domParser.parseFromString(target.innerHTML, "text/html");

  console.log(target.innerHTML,'target.innerHTML')
  console.log(doc.body.childNodes,'doc')
}

ff3b4897-220a-4239-9049-0916eb2bd73f.png

将html dom 转换为AST

type TextStyle = {
    bold?: boolean; // 加粗
    italic?: boolean;  // 斜体
    underline?: boolean; // 下划线
  };
  
  /**
   * 关于AST 节点定义
   */
  type ASTNode = {
    // 
    type: "text" | "paragraph" | "mention" | "custom" |'heading';
    content?: string;
    style?: TextStyle;
     attrs?:any;
    children?: ASTNode[];
    data?: any;
  };
  /**
   * @param html html字符串
   * 将html dom 转换为AST
   */
 export  function parseHtml (html:string) {
     // DOMParser 是浏览器原生 API,用于将字符串解析为 完整的 HTML 文档对象(Document)。
    const parse = new DOMParser()
    const doc = parse.parseFromString(html,'text/html')
    // nodes doc.body 的所有直接子节点
    const nodes = doc.body.childNodes
    console.log(nodes,'doc')
    /**
     * 
     * @param node 节点
     * 遍历每一个node,将 dom node 转换为 AST
     * 
     */
    function parseNode (node:Node):ASTNode | null {
      console.log(node)
      // 如果是文本节点
      if(node.nodeType === Node.TEXT_NODE){
        return {
          type: "text",
          content: node.textContent||'', // 文本节点内容
          style: {}, // 样式
          data: {}
        }
      }
      // 如果节点是元素节点
      if(node.nodeType === Node.ELEMENT_NODE){
        const element = node as HTMLElement
        // 小写的元素名称
        const tagName = element.tagName.toLowerCase()
        // 获取元素属性
        const data = element.dataset
        // 获取元素样式
        const style = element.style

        console.log(data,style)
        // 判断不同元素节点,返回不同的AST节点
        switch(tagName){
           case "h1":
            return {
                type:'heading',
                attrs:{
                    level:1
                },
                content:element.textContent||'',
            }
            case "h2":
                return {
                    type:'heading',
                    attrs:{
                        level:2
                    },
                    content:element.textContent||'',
                }

                case "p":
                    return {
                      type: "paragraph",
                      content:element.textContent||'',
                    }
                

                default:
                    break
        }
      }
      return null
    }
    return  Array.from(doc.body.childNodes).map(parseNode)
  }

129a908c-697b-4bc2-b19f-06f23629fc4b.png

鼠标选中文字通过按钮添加文字样式

778fdef9-eb95-4293-a118-afaa549e2c33.png

添加事件

  <div>
      <button onClick={()=>format('bold')}>加粗</button>
      <button onClick={()=>format('italic')}>斜体</button>
      <button>@</button>
      <div contentEditable="true" onInput={handleInput}>
        <h1>我是H1</h1>
        <h2>我是H2</h2>
        <p>元素</p>
        444
      </div>
    </div>

实现逻辑


const format = (commod: 'bold' | 'italic') => {
  // 1. 获取当前页面的 Selection 对象(表示用户选中的文本范围或光标位置)
  const selection = window.getSelection();
  
  // 如果没有选区(例如在非浏览器环境或选区为空),直接退出
  if (!selection) return;

  // 2. 获取选区中的第一个 Range(通常只有一个,除非跨多区域选择)
  // Range 表示文档中连续的一段内容(可跨节点)
  const range = selection.getRangeAt(0);
  console.log(range, 'range'); // 用于调试:查看当前选区的 Range 对象

  // 3. 创建一个新元素来包裹格式化后的文本
  // - 如果是 'bold',创建 <b> 标签;如果是 'italic',创建 <i> 标签
  const formattedText = document.createElement(commod === 'bold' ? 'b' : 'i');
  
  // 将选中的纯文本内容设置为新元素的文本内容
  // ⚠️ 注意:这会丢失原有 HTML 结构(如嵌套标签)!
  formattedText.textContent = range.toString();

  // 4. 删除原选区中的内容(包括文本和任何内联元素)
  range.deleteContents();

  // 5. 将新创建的格式化元素插入到原选区位置
  range.insertNode(formattedText);

  // ✅ 可选增强:将光标移动到格式化文本之后,提升用户体验
  // selection.removeAllRanges();
  // const newRange = document.createRange();
  // newRange.setStartAfter(formattedText);
  // newRange.collapse(true);
  // selection.addRange(newRange);
  
  // 富文本编辑器不存在受控组件
};

Selection+Range+Compiler ange接口

Range 表示文档中连续的一段内容(可跨节点),是操作 DOM 的底层单位。

常用属性

属性说明
startContainer / startOffset起始容器节点和偏移
endContainer / endOffset结束容器节点和偏移
commonAncestorContainer起止节点的最近公共祖先

常用方法

方法说明
cloneContents()克隆选中内容(返回 DocumentFragment)
extractContents()提取并删除选中内容
deleteContents()仅删除选中内容
insertNode(node)在 Range 起始处插入节点
surroundContents(newNode)用新节点包裹 Range 内容(要求 Range 是“可包裹”的)
selectNode(node) / selectNodeContents(node)选中整个节点或其内容
setStart(node, offset) / setEnd(...)手动设置边界

Selection 接口

Selection对象表示用户或脚本在文档中选中的文本范围。它通常与光标或鼠标选择操作相关联。

Selection 表示用户在页面中选中的文本范围(或光标位置)。可通过 window.getSelection() 获取。

常用属性

属性说明
anchorNode / anchorOffset选择起点的节点和偏移
focusNode / focusOffset选择终点的节点和偏移
isCollapsed是否为光标(无选中文本)
rangeCount包含的 Range 数量(通常为1)

常用方法

方法说明
getRangeAt(0)获取当前选区的 Range 对象
removeAllRanges()清除所有选区
addRange(range)添加一个 Range 到选区
collapse(node, offset)将选区折叠为光标
deleteFromDocument()删除选中的内容

富文本内容更新

通常文本内容比较多。如果每次更新数据,用全量更新导致页面卡顿。这个时候就只能对比新旧AST树来增量更新。 fast-json-patch 是一个高性能的 JavaScript 库,用于生成和应用 JSON Patch(RFC 6902)操作,常用于:

  • 状态同步(如富文本协作编辑、实时协同)
  • 撤销/重做(Undo/Redo)
  • 高效传输数据变更(只传 diff,不传全量)

安装

npm install fast-json-patch

 核心功能

1. 生成 Patch(比较两个 JSON 对象)
import { compare } from 'fast-json-patch';

const obj1 = { name: "Alice", age: 30 };
const obj2 = { name: "Alice", age: 31, city: "Beijing" };

const patch = compare(obj1, obj2);
console.log(patch);
// 输出:
// [
//   { op: "replace", path: "/age", value: 31 },
//   { op: "add", path: "/city", value: "Beijing" }
// ]

compare(a, b) 返回将 a 变成 b 所需的操作序列。

2.应用 Patch(将操作应用到目标对象)

import { applyPatch } from 'fast-json-patch';

const doc = { title: "Hello" };
const patch = [{ op: "replace", path: "/title", value: "Hi" }];

const result = applyPatch(doc, patch, true); // 第三个参数:是否 mutate 原对象
console.log(result.newDocument); // { title: "Hi" }

⚠️ 默认 applyPatch 不会修改原对象(返回新对象)。
若设 mutate: true(第三个参数为 true),则直接修改原对象。


3. 创建可观察对象(自动记录变更)

import { observe, generate } from 'fast-json-patch';

const doc = { count: 0 };
const observer = observe(doc); // 开启监听

doc.count = 1;
doc.flag = true;

const patches = generate(observer); // 获取自 observe 以来的所有变更
console.log(patches);
// [
//   { op: "replace", path: "/count", value: 1 },
//   { op: "add", path: "/flag", value: true }
// ]

历史记录栈与Undo/Redo

349d837d-006b-4a71-94c4-43071d1364dc.png

点击撤销

89f89f50-2780-4b74-9a61-0a68dab661c9.png

  import {compare} from "fast-json-patch"
  const [ast, setAST] = useState<ASTNode[]>([]); // 当前 AST 状态
  const [undoStack, setUndoStack] = useState<ASTNode[][]>([]); // 撤销栈
  const [redoStack, setRedoStack] = useState<ASTNode[][]>([]); // 重做栈
  const handleInput = () => {
    if (!editorRef.current) return;

    const html = editorRef.current.innerHTML;
    const newAST = parseHTML(html);

    // 🔄 生成 patch:从旧 AST 到新 AST 的变更
    const patches = compare(ast, newAST);
    console.log('JSON Patch:', patches);

    // 💾 保存当前状态到撤销栈
    setUndoStack((prevStack) => [...prevStack, ast]);

    // 🚫 清空重做栈(因为重新开始)
    setRedoStack([]);

    // 🔁 更新 AST
    setAST(newAST);
    
  };
  
    // ⏪ 撤销操作
      const undo = () => {
        if (undoStack.length === 0) return;

        const prevAST = undoStack[undoStack.length - 1];
             // 将当前状态推入重做栈
        setRedoStack(prev => [...prev, ast]);
       
    
        // 设置回上一个状态
        setAST(prevAST);
        setUndoStack((prevStacl)=> prevStacl.slice(0, -1));
   
      };
    
      // ⏩ 重做操作
      const redo = () => {
        if (redoStack.length === 0) return;
    
        const nextAST = redoStack[redoStack.length - 1];
        const newRedoStack = redoStack.slice(0, -1);
    
        // 推入撤销栈
        setUndoStack(prev => [...prev, ast]);
    
        // 应用重做状态
        setAST(nextAST);
        setRedoStack(newRedoStack);

   
      };
  1. 每操作一步,把当前状态推入撤销栈。
  2. 点击撤销:取撤销栈最后一步 → 设为当前状态 → 删除撤销栈最后一步 → 当前原状态推入重做栈。
  3. 点击重做:取重做栈最后一步 → 设为当前状态 → 删除重做栈最后一步 → 当前原状态推入撤销栈。

Tiptap

Tiptap,一款为开发富文本编辑器为生的框架

Tiptap 是一个基于 ProseMirror 的现代化、可扩展的 React/Vue/原生 JavaScript 富文本编辑器框架,具有 无头(headless)设计TypeScript 支持协作编辑能力 等优势

安装依赖

# 核心包(必须) 
npm install @tiptap/react @tiptap/pm 
# 常用扩展(按需安装)  包含:段落、标题、加粗、斜体、列表、链接、历史记录等常用功能。
npm install @tiptap/starter-kit

基础使用

import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import React,{useState, useEffect, useRef} from "react";
export default function RichTextEditor() {
  const editor = useEditor({
    extensions: [StarterKit],
    content: '<p>开始编辑...</p>',
  });

  if (!editor) return null;

  return (
    <div>
      <div style={{ marginBottom: '10px', padding: '8px', border: '1px solid #ccc' }}>
        <button onClick={() => editor.chain().focus().toggleBold().run()}>
          <strong>B</strong>
        </button>
        <button onClick={() => editor.chain().focus().toggleItalic().run()}>
          <em>I</em>
        </button>
        <button onClick={() => editor.chain().focus().toggleBulletList().run()}>
          •
        </button>
        <button onClick={() => editor.chain().focus().undo().run()}>↩️ Undo</button>
        <button onClick={() => editor.chain().focus().redo().run()}>↪️ Redo</button>
      </div>

      <EditorContent editor={editor} />
    </div>
  );
}

b8f3164c-f903-4566-824d-9f77b43ea858.png

功能
协作编辑@tiptap/extension-collaboration + WebSocket
图片上传@tiptap/extension-image
表格@tiptap/extension-table
代码块@tiptap/extension-code-block
字体颜色@tiptap/extension-text-style + @tiptap/extension-color