富文本编辑基础核心

富文本开发核心

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

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

contentenditable基础

  • contenteditle 属性:

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

创建一个编辑的div元素。

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

创建多个可编辑区域

HTML 复制代码
<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>

文字加粗和斜体

HTML 复制代码
 <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>

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

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

使用JSON Context

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

定义(上面HTML描述):

js 复制代码
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 事件

js 复制代码
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')
}

将html dom 转换为AST

ts 复制代码
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)
  }

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

在这里插入图片描述

添加事件

tsx 复制代码
  <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>

实现逻辑

tsx 复制代码
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,不传全量)

安装

bash 复制代码
npm install fast-json-patch
核心功能
1. 生成 Patch(比较两个 JSON 对象)
js 复制代码
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(将操作应用到目标对象)
js 复制代码
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. 创建可观察对象(自动记录变更)
js 复制代码
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

点击撤销

tsx 复制代码
  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 支持协作编辑能力 等优势

安装依赖
bash 复制代码
# 核心包(必须) 
npm install @tiptap/react @tiptap/pm 
# 常用扩展(按需安装)  包含:段落、标题、加粗、斜体、列表、链接、历史记录等常用功能。
npm install @tiptap/starter-kit
基础使用
tsx 复制代码
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>
  );
}
功能
协作编辑 @tiptap/extension-collaboration + WebSocket
图片上传 @tiptap/extension-image
表格 @tiptap/extension-table
代码块 @tiptap/extension-code-block
字体颜色 @tiptap/extension-text-style + @tiptap/extension-color
相关推荐
weibkreuz6 小时前
收集表单数据@10
开发语言·前端·javascript
liulilittle7 小时前
XDP VNP虚拟以太网关(章节:一)
linux·服务器·开发语言·网络·c++·通信·xdp
王林不想说话7 小时前
提升工作效率的Utils
前端·javascript·typescript
我不是8神7 小时前
Qt 知识点全面总结
开发语言·qt
Ralph_Y7 小时前
多重继承与虚继承
开发语言·c++
今晚务必早点睡7 小时前
写一个Python接口:发送支付成功短信
开发语言·python
weixin_584121437 小时前
vue内i18n国际化移动端引入及使用
前端·javascript·vue.js
jghhh017 小时前
基于C#实现与三菱FX系列PLC串口通信
开发语言·算法·c#·信息与通信
ada7_7 小时前
LeetCode(python)22.括号生成
开发语言·数据结构·python·算法·leetcode·职场和发展
asdzx677 小时前
解锁 PDF 内容:如何用 Python 从 PDF 中快速提取文本
经验分享