基于 ProseMirror 的富文本编辑器 demo

前言

在我写自己的项目中,需要使用富文本编辑器来编辑文本,之前对于文本编辑的了解只停留在 textarea 上,后来了解到基于 contentEditable 属性的 ProseMirror,记录一下 prosemirror 的学习过程并写一个 demo。

项目仓库:github.com/ljq0226/my-...

部署地址:my-prosemirror-editor.netlify.app/

介绍

ProseMirror 是一个基于 ContentEditable 的所见即所得 HTML 编辑器,功能强大,支持协作编辑和自定义文档模式 prosemirror 库由多个单独的模块组成,它的优点:模块化,可扩展,可插拔,缺点:不是开箱即用,需要基于 prosemirror 生态拓展开发(如 TipTap)。

核心模块:

prosemirror 核心有四个模块:

  • prosemirror-model:定义编辑器的文档模型,用来描述编辑器内容的数据结构。
  • prosemirror-state:描述编辑器整体状态,包括文档数据、选择等。
  • prosemirror-view:UI组件,用于将编辑器状态展现为可编辑的元素,处理用户交互。
  • prosemirror-transform:修改文档的事务方法。

Demo

构建项目

使用 vite 搭建项目: pnpm create vite my-prosemirror --template react-ts

shell 复制代码
cd my-prosemirror
pnpm install

在这我们使用 Tailwind CSS 写样式,具体配置见官网

tsx 复制代码
//App.tsx
function App() {
  return (
    <div className="h-screen w-screen p-[200px] bg-slate-300">
      <h1 className="text-center text-2xl">My-ProseMirror-Editor</h1>
      <div className="h-[800px] bg-white w-full">

      </div>
    </div>
  )
}
export default App

pnpm run dev 启动后效果如下:

代码仓库:commit-0

定义 Schema

在 ProseMirror 中,Schema(模式)定义了编辑器中可用的节点(nodes)和标记(marks)的结构和行为。模式描述了文档的结构以及每个节点和标记的属性、解析规则和渲染规则。

ts 复制代码
import { Schema } from "prosemirror-model";

export const schema = new Schema({
  nodes: {
    //顶级节点,表示整个文档。它可以包含多个 block 类型的子节点。
    doc: {
      content: "block+"
    },
    //段落节点,用于表示段落文本。它可以包含多个inline类型的子节点。
    //具有align 属性,用于指定对齐方式。parseDOM定义了如何从 DOM 元素解析为节点
    // ,toDOM定义了如何将节点渲染为 DOM 元素。
    paragraph: {
      content: "inline*",
      group: "block",
      attrs: {
        align: { default: "left" }
      },
      parseDOM: [
        {
          tag: "p",
          getAttrs(dom) {
            if (typeof dom === "string") {
              return false;
            }
            return {
              align: dom.style.textAlign || "left"
            };
          }
        }
      ],
      toDOM(node) {
        const { align } = node.attrs;
        if (!align || align === "left") {
          return ["p", 0];
        }
        return ["p", { style: `text-align: ${align}` }, 0];
      }
    },
    //文本节点,用于表示文本内容。它是inline类型的节点。
    text: {
      group: "inline"
    }
  },
  //标记:描述每个元素的解析规则和渲染规则
  marks: {
    em: {
      parseDOM: [{ tag: "i" }, { tag: "em" }, { style: "font-style=italic" }],
      toDOM() {
        return ["em", 0];
      }
    },

    strong: {
      parseDOM: [
        { tag: "strong" },
        {
          tag: "b",
          getAttrs: (node: string | HTMLElement) =>
            typeof node !== "string" &&
            node.style.fontWeight !== "normal" &&
            null
        },
        {
          style: "font-weight",
          getAttrs: (value: string | HTMLElement) =>
            typeof value === "string" &&
            /^(bold(er)?|[5-9]\d{2,})$/.test(value) &&
            null
        }
      ],
      toDOM() {
        return ["strong", 0];
      }
    },
    underline: {
      parseDOM: [{ tag: "u" }],
      toDOM() {
        return ["u", 0];
      }
    },
  }
});

Nodes(节点):

  • doc:顶级节点,表示整个文档。它可以包含多个 block 类型的子节点。
  • paragraph:段落节点,用于表示段落文本。它可以包含多个 inline 类型的子节点。具有 align 属性,用于指定对齐方式。parseDOM 定义了如何从 DOM 元素解析为节点,toDOM 定义了如何将节点渲染为 DOM 元素。
  • text:文本节点,用于表示文本内容。它是 inline 类型的节点。

Marks(标记):

  • strong:加粗标记,用于表示加粗文本。parseDOM 定义了如何从 DOM 元素解析为标记,toDOM 定义了如何将标记渲染为 DOM 元素。
  • underline:下划线标记,用于表示下划线文本。parseDOM 定义了如何从 DOM 元素解析为标记,toDOM 定义了如何将标记渲染为 DOM 元素。

通过定义节点和标记,并指定它们的属性、解析规则和渲染规则,可以在 ProseMirror 编辑器中创建具有丰富格式和结构的文档。

Editor 组件

首先我们先创建一个 Editor 组件,并引入到 App.tsx 中:

tsx 复制代码
export type Props = {
  initialHtml: string;
  onChangeHtml: (html: string) => void;
};

const Editor = ({ initialHtml, onChangeHtml }: Props) => {

  return (
    <div className="relative">
      <div/>
    </div>
  );
}
export default Editor

此时,页面并没有任何效果,因为我们还没有创建 EditorView,步骤如下:

  • 安装依赖 pnpm install prosemirror-model prosemirror-state prosemirror-view prosemirror-keymap prosemirror-history prosemirror-commands

  • createDoc

根据给定的 HTML 字符串和 ProseMirrorschema 创建一个 Doc类型的节点。

ts 复制代码
import { Schema, Node, DOMParser } from "prosemirror-model";

const createDoc = <T extends Schema>(html: string, pmSchema: T) => {
  const element = document.createElement("div");
  element.innerHTML = html;
  return DOMParser.fromSchema(pmSchema).parse(element);
};
  • createState

该函数的作用是创建一个 ProseMirror 的编辑器状态对象。它使用指定的模式和插件来配置编辑器状态,并可以选择设置初始文档:

  1. 使用 EditorState.create 方法创建一个新的编辑器状态对象。
  2. create 方法的配置对象中,指定 doc 选项为 options.doc,用于设置编辑器的初始文档。
  3. 指定 schema 选项为传入的 pmSchema,以定义编辑器的模式。
  4. 指定 plugins 选项为一组插件,用于扩展编辑器的功能。这里使用了以下插件:
    • history():用于支持撤销和重做操作。
    • keymap({...}):用于定义键盘快捷键映射,以便对编辑器进行格式化和操作。
    • baseKeymap:提供了一组基本的键盘快捷键映射,例如回车和删除。
  5. 返回创建的编辑器状态对象。
ts 复制代码
import { Schema, Node, DOMParser } from "prosemirror-model";
import { EditorState } from "prosemirror-state";
import { undo, redo, history } from "prosemirror-history";
import { keymap } from "prosemirror-keymap";
import { baseKeymap, toggleMark } from "prosemirror-commands";

const createPmState = <T extends Schema>(
  pmSchema: T,
  options: { doc?: Node } = {}
) => {
  return EditorState.create({
    doc: options.doc,
    schema: pmSchema,
    plugins: [
      history(),
      keymap({
        "Mod-z": undo,
        "Mod-y": redo,
        "Mod-Shift-z": redo
      }),
      keymap({
        "Mod-b": toggleMark(pmSchema.marks.strong),
        "Mod-i": toggleMark(pmSchema.marks.em),
        "Mod-u": toggleMark(pmSchema.marks.underline)
      }),
      keymap({
        Enter: baseKeymap["Enter"],
        Backspace: baseKeymap["Backspace"]
      }),
    ]
  });
};
  • 绑定EditorView
tsx 复制代码
//Editor.tsx
import { useEffect, useRef} from "react";
import { EditorView } from "prosemirror-view";
import { schema } from "../schema";
...

const Editor = ({ initialHtml, onChangeHtml }: Props) => {
 const elContentRef = useRef<HTMLDivElement | null>(null);
  const editorViewRef = useRef<EditorView>();

  useEffect(() => {
    //1.创建 doc 对象
    const doc = createDoc(initialHtml, schema);
    //2.创建 prosemirror state
    const state = createPmState(schema, { doc });
	//3.创建 EditorView 视图实例
    const editorView = new EditorView(elContentRef.current, {
      state,
    //处理编辑器中的事务(transaction),并在每次事务应用后更新编辑器的状态,并调用 onChangeHtml 回调函数通知外部编辑器内容的变化。
      dispatchTransaction(transaction) {
        const newState = editorView.state.apply(transaction);
        editorView.updateState(newState);
        onChangeHtml(editorView.dom.innerHTML);
      },
    });
    editorViewRef.current = editorView;
    return () => {
      editorView.destroy();
    };
  }, []);
  return (
    <div className="relative">
      <div ref={elContentRef} />
    </div>
}

此时我们需要把 Editor 组件挂载到 App.tsx 组件当中,一并显示原生 HTML:

tsx 复制代码
//App.tsx
import { useState } from "react";
import Editor from "./components/Editor";

const INITIAL_HTML = `
<p style="text-align: center">He<strong>llo!</strong>__<span style="font-size: 20px;"><span style="color: orange">World</span></span></p>
<p><u><span style="font-size: 24px">你好啊</span></u><em><u>阿萨德</u></em></p>
<p><a href="https://google.com" target="_blank">Google</a></p>
<p style="text-align: right"><a href="https://github.com/ljq0226/my-prosemirror" target="_blank">GitHub</p>
`;
function App() {
  const [html, setHtml] = useState(INITIAL_HTML);
  return (
    <div className="h-screen w-screen p-[200px] bg-slate-300">
      <h1 className="text-center text-2xl">My-ProseMirror-Editor</h1>
      <div className="h-[800px] bg-white w-full py-10 px-8">
        <Editor
          initialHtml={html}
          onChangeHtml={(newHtml: string) => {
            setHtml(newHtml);
          }}
        />
        <div className="w-full h-1 bg-black" />
        <div>
          <h3>原生HTML</h3>
          <div>{html}</div>
        </div>
        <div className="w-full h-1 bg-black" />
        <div>
          <h3>渲染后</h3>
          <div
            dangerouslySetInnerHTML={{
              __html: html
            }}
          />
        </div>
      </div>
    </div>
  )
}

export default App

效果如下:

代码仓库:commit1

"菜"看起来已经做好了,但似乎我们跳过了"调味"这一步骤,本文以 "切换 underline"和"设置字体大小"为例:

EditorMenu 菜单栏

创建 EditorMenu.tsx,并加入到 Editor.tsx 中,

tsx 复制代码
//EditorMenu.tsx
import { EditorView } from "prosemirror-view";
import { toggleMark, setBlockType } from "prosemirror-commands";
import { schema } from "../../schema";
import { isActiveMark } from "./isActiveMark";
export type EditorMenuProps = {
  editorView: EditorView;
};
export const EditorMenu: FC<EditorMenuProps> = ({editorView}) => {
  return (
   <div className="border">
    <button
        className="w-6 h-6 bg-[#efefef] border border-black"
        style={{
          fontWeight: isActiveMark(
            editorView.state,
            schema.marks.underline
          )
            ? "bold"
            : undefined
        }}
        onClick={() => {
          toggleMark(schema.marks.underline)(
            editorView.state,
            editorView.dispatch,
            editorView
          );
          editorView.focus();
        }}
      >
        U
    </button>
    <MenuItemFontSize
        editorState={editorView.state}
        onSetFontSize={(fontSize) => {
          addMark(schema.marks.size, { fontSize })(
            editorView.state,
            editorView.dispatch,
            editorView
          );
          editorView.focus();
        }}
        onResetFontSize={() => {
          removeMark(schema.marks.size)(
            editorView.state,
            editorView.dispatch,
            editorView
          );
          editorView.focus();
        }}
    />
    </div>
 )
}

MenuItemFontSize:

tsx 复制代码
import { FC, useMemo } from "react";
import { EditorState } from "prosemirror-state";
import { getSelectionMark } from "../getSelectionMark";
import { schema } from "../../../schema";

export type MenuItemFontSizeProps = {
  editorState: EditorState;
  onSetFontSize: (fontSize: string) => void;
  onResetFontSize: () => void;
};

const FONT_SIZE_LIST = ["12px", "16px", "24px"];

export const MenuItemFontSize: FC<MenuItemFontSizeProps> = ({ editorState, onResetFontSize, onSetFontSize }) => {
  const selectedFontSize = useMemo(() => {
    const mark = getSelectionMark(editorState, schema.marks.size);
    return mark ? mark.attrs.fontSize : "16px";
  }, [editorState]);
  return (
    <select
      value={selectedFontSize}
      onChange={(event) => {
        const fontSize = event.currentTarget.value;
        if (fontSize === "16px") {
          onResetFontSize();
        } else {
          onSetFontSize(fontSize);
        }
      }}
    >
      {FONT_SIZE_LIST.map((fontSize) => (
        <option key={fontSize} value={fontSize}>
          {fontSize}
        </option>
      ))}
    </select>
  );
};
tsx 复制代码
//app.tsx
  return (
    <div className="relative">
      {editorViewRef.current && (
        <EditorMenu editorView={editorViewRef.current} />
      )}
      <div ref={elContentRef} />
    </div>
  );

执行操作介绍

toggleMark 和自定义的 addMark 函数分别处理不同类型的操作需求:

  • toggleMark(切换状态,如切换加粗、斜体等): toggleMark 是 ProseMirror 库提供的一个命令函数,用于在选定的文本上切换指定类型的标记。它会检查当前文本是否已经应用了该标记,如果已经应用则会将其移除,如果未应用则会添加该标记。toggleMark 函数的作用是在切换标记的状态,例如切换加粗、斜体等。
  • addMark(指定状态,如处理颜色、字体大小等) 自定义的 addMark 函数是根据业务需求自行定义的,用于向选定的文本添加指定类型的标记。它会将指定的标记应用于选定的文本范围,不会检查标记是否已经存在或进行切换。addMark 函数的作用是直接添加指定的标记,无论当前文本是否已经应用了该标记。

add为例: addMark 函数的作用是创建一个 ProseMirror 命令,用于向选定的文本添加指定类型的标记。该命令会根据选择的情况,在光标位置或选区范围内进行标记的添加,并在需要时更新编辑器状态。 用于创建一个 ProseMirror 命令(Command),用于向选定的文本添加标记(mark)

ts 复制代码
//addMark.ts
import { MarkType, Attrs } from "prosemirror-model";
import { Command, TextSelection } from "prosemirror-state";
import { markApplies } from "./markApplies";
/**
 * https://github.com/ProseMirror/prosemirror-commands/blob/1.3.1/src/commands.ts#L505-L538
 */
export const addMark = (
  markType: MarkType,
  attrs: Attrs | null = null
): Command => {
//接受两个参数:`state`(编辑器状态)和 `dispatch`(派发函数)。
  return (state, dispatch) => {
  //1. 首先,从编辑器状态的选择(`state.selection`)中获取相关信息,包括是否为空选区(`empty`)、光标位置(`$cursor`)和选区范围(`ranges`)。
    const { empty, $cursor, ranges } = state.selection as TextSelection;
    //2. 检查是否满足添加标记的条件,即如果是空选区且没有光标,或者标记不适用于给定的文档范围,则返回 `false`。
    if ((empty && !$cursor) || !markApplies(state.doc, ranges, markType))
      return false;

    if (dispatch) {
    //如果存在光标位置(即非空选区),则使用 `state.tr.addStoredMark` 方法,在当前位置添加指定的标记(使用 `markType.create` 创建标记对象)。
      if ($cursor) {
        dispatch(state.tr.addStoredMark(markType.create(attrs)));
      } else {
      //否则遍历选区范围,并使用 `tr.addMark` 方法,在每个范围内添加指定的标记。在添加标记之前,还会根据节点内容的前后空白进行调整,以确保标记应用的正确性。
        let tr = state.tr;
        for (let i = 0; i < ranges.length; i++) {
          const { $from, $to } = ranges[i];
          let from = $from.pos,
            to = $to.pos;
          const start = $from.nodeAfter,
            end = $to.nodeBefore;
          const spaceStart =
            start && start.isText ? /^\s*/.exec(start.text!)![0].length : 0;
          const spaceEnd =
            end && end.isText ? /\s*$/.exec(end.text!)![0].length : 0;
          if (from + spaceStart < to) {
            from += spaceStart;
            to -= spaceEnd;
          }
          tr = tr.addMark(from, to, markType.create(attrs));
        }
        dispatch(tr.scrollIntoView());
      }
    }
    return true;
  };
};

这样我们就可以对文本进行 切换下划线和设置字体大小了,代码仓库:commit2

进一步完善commit3commit4

完整示例已部署在my-prosemirror-editor.netlify.app

最终效果:

参考文章:

相关推荐
崔庆才丨静觅8 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60619 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了9 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅9 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅9 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅10 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment10 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅10 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊10 小时前
jwt介绍
前端
爱敲代码的小鱼10 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax