前言
在我写自己的项目中,需要使用富文本编辑器来编辑文本,之前对于文本编辑的了解只停留在 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 字符串和 ProseMirror
的 schema
创建一个 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
的编辑器状态对象。它使用指定的模式和插件来配置编辑器状态,并可以选择设置初始文档:
- 使用
EditorState.create
方法创建一个新的编辑器状态对象。 - 在
create
方法的配置对象中,指定doc
选项为options.doc
,用于设置编辑器的初始文档。 - 指定
schema
选项为传入的pmSchema
,以定义编辑器的模式。 - 指定
plugins
选项为一组插件,用于扩展编辑器的功能。这里使用了以下插件:history()
:用于支持撤销和重做操作。keymap({...})
:用于定义键盘快捷键映射,以便对编辑器进行格式化和操作。baseKeymap
:提供了一组基本的键盘快捷键映射,例如回车和删除。
- 返回创建的编辑器状态对象。
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
Add Menu
"菜"看起来已经做好了,但似乎我们跳过了"调味"这一步骤,本文以 "切换 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
完整示例已部署在my-prosemirror-editor.netlify.app
最终效果: