如何用 ProseMirror 解析和渲染 markdown
前言
上一篇我们简单搭建了一个 electron 应用的开发环境,启动后,窗口加载了一个网页。现在我们要加入我们的主体功能 —— Markdown。在这里,我借助 ProseMirror 来开发编辑器。我们先简单学习一下如何用 ProseMirror 搭建一个富文本编辑器。
ProseMirror
简易编辑器
ProseMirror 有几个必要的模块:
prosemirror-model 定义了编辑器的 Document Model, 它用来描述编辑器的内容.
prosemirror-state 提供了一个描述编辑器完整状态的单一数据结构, 包括编辑器的选区操作, 和一个用来处理从当前 state 到下一个 state 的一个叫做
transaction的系统.prosemirror-view 用来将给定的 state 展示成相对应的可编辑元素显示在编辑器中, 同时处理用户交互.
prosemirror-transform 包含了一种可以被重做和撤销的修改文档的功能, 它是
prosemirror-state库transaction功能的基础, 这使得撤销操作历史记录和协同编辑成为可能.
其他还有一些模块,如 prosemirror-collab、prosemirror-gapcursor 等,使用频率也很高,具体看需求。
现在我们简单地使用上面几个模块来构建一个编辑器。
import { EditorState } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import { Schema, DOMParser } from "prosemirror-model"; import { schema } from "prosemirror-schema-basic"; import { useEffect, useRef } from "react"; import React from "react"; export const Editor = () => { const schemaIntance = new Schema({ nodes: schema.spec.nodes, marks: schema.spec.marks, }); const view = useRef<EditorView>(); useEffect(() => { view.current = new EditorView(document.getElementById("editor"), { state: EditorState.create({ doc: DOMParser.fromSchema(schemaIntance).parse( document.getElementById("content")! ), schema: schemaIntance }), }); }, []); return ( <div> <div id="content" style={{ height: 0, overflow: "hidden" }}> Hello ProseMirror </div> <div id="editor"></div> </div> ); }; 复制代码这里的 schema 我就直接用的 prosemirror-schema-basic 提供,支持的 Node 和 Mark 不多。
上面这个编辑器还很简陋,像输入回车键换行等常见的操作都还没有。我们可以借助 ProseMirror 的其他模块继续增强编辑器的能力。官方已经提供了一些常用的模块:
prosemirror-history 该模块实现了 redo/undo 操作。
prosemirror-keymap 该模块可以将键名映射到 command 类型的函数。
prosemirror-commands 这个包提供了很多基本的编辑 commands, 包括在编辑器中按照你的期望映射 enter 和 delete 按键的行为。
import { undo, redo, history } from "prosemirror-history"; import { keymap } from "prosemirror-keymap"; import { baseKeymap } from "prosemirror-commands"; // ... useEffect(() => { view.current = new EditorView(document.getElementById("editor"), { state: EditorState.create({ doc: DOMParser.fromSchema(schemaIntance).parse( document.getElementById("content")! ), schema: schemaIntance, plugins: [ history(), keymap({ "Mod-z": undo, "Mod-y": redo }), keymap(baseKeymap), ], }), }); }, []); 复制代码如图所示,我加入了 keymap 和 history,现在这个编辑器支持 redo undo 了。
Plugins
prosemirror-state
prosemirror-state 提供了编写插件的能力,上面用的 keymap 和 history 都是 Plugin。
import { Plugin } from "prosemirror-state" function keydownHandler(bindings){ return function(view, event) { // ... } } const keymap = function(bindings){ return new Plugin({ props: { handleKeyDown: keydownHandler(bindings) } }) } 复制代码keymap 插件去除其他复杂的逻辑,核心的部分就是上图所示。
prosemirror-inputrules
prosemirror-inputrules 这个模块可以配置匹配规则,当我们输入的文本符合条件时,可以替换成别的文本或者节点, 或者用 transaction 做更复杂的事情。它内置一些 inputrule,比如 emDash, 连续输入两个 - 会替换成一个 ——,
我们想要实现的 Markdown 是像 Typora 的那种,这里需要借助 prosemirror-inputrules 。我们以输入 **hello** 自动变成 hello 为例
import { inputRules, InputRule } from "prosemirror-inputrules"; // ... view.current = new EditorView(document.getElementById("editor"), { state: EditorState.create({ doc: DOMParser.fromSchema(schemaIntance).parse( document.getElementById("content")! ), schema: schemaIntance, plugins: [ inputRules({ rules: [ new InputRule( /\*\*[^\*]{1,}\*\*$/, (state: EditorState, match, from, to, text) => { const mark = state.schema.mark("strong"); const str = match[0].substring(2, match[0].length - 2); const node = state.schema.text(str, [mark]); return state.tr.replaceWith(from, to, node); } ), ], }), ], }), }); 复制代码prosemirror-inputrules 还提供了一个 command —— undoInputRule, 它可以将我们刚刚输入的 hello 重新变成 **hello**,我们结合 history 和 keymap 来使用。
import { undo, redo, history } from "prosemirror-history"; import { keymap } from "prosemirror-keymap"; import { baseKeymap, chainCommands } from "prosemirror-commands"; import { inputRules, InputRule, undoInputRule, } from "prosemirror-inputrules"; //... view.current = new EditorView(document.getElementById("editor"), { state: EditorState.create({ doc: DOMParser.fromSchema(schemaIntance).parse( document.getElementById("content")! ), schema: schemaIntance, plugins: [ history(), keymap({ "Mod-z": chainCommands(undoInputRule, undo), "Mod-y": redo, }), keymap(baseKeymap), inputRules({ rules: [ new InputRule( /\*\*[^\*]{1,}\*\*$/, (state: EditorState, match, from, to, text) => { const mark = state.schema.mark("strong"); const str = match[0].substring(2, match[0].length - 2); const node = state.schema.text(str, [mark]); return state.tr.replaceWith(from, to, node); } ), ], }), ], }), }); 复制代码可以看到,按键 CommandOrCtrl+z 可以回到之前的状态,再重新修改。
Remirror
前面,我们都是用 ProseMirror 来构建编辑器的。现在,我要给大家介绍一个很好用的 react 库 —— Remirror。
Remirror 是基于 ProseMirror 的用于构建跨平台富文本编辑器的 React 工具包,它可以和 react 很好的配合工作。Remirror 同样不是像 Draft.js 那样是一个开箱即用的方案。
Install
npm add remirror @remirror/react @remirror/pm 复制代码
Create Manager
开始初始化一个 remirror 编辑器之前,我们要先创建一个 manager,它用来控制编辑器的行为。 manager 只提供了最基础基础的功能,需要配合 extensions 才能发挥作用。这里我使用了 MarkdownExtension 扩展,它给我们提供了简单的解析 markdown 的功能。
import React from "react"; import { MarkdownExtension, BoldExtension } from "remirror/extensions"; import { Remirror, useRemirror } from "@remirror/react"; import "remirror/styles/all.css"; const Editor = () => { const { manager, state } = useRemirror({ extensions: () => [new MarkdownExtension()], content: "", selection: "start", stringHandler: "markdown" }); return ( <div className="remirror-container"> <Remirror manager={manager} initialContent={state} /> </div> ); }; export default function App() { return <Editor />; } 复制代码content 用于提供初始化的内容,你可以试试 content: "a"。codesandbox
如图所示,remirror/extensions 这个模块中还提供了很多内置的扩展,大家可以根据需要直接使用。
添加扩展
我们先添加一个 BoldExtension 看效果。codesandbox
import React from "react"; import { MarkdownExtension, BoldExtension } from "remirror/extensions"; import { Remirror, useRemirror } from "@remirror/react"; import "remirror/styles/all.css"; const Editor = () => { const { manager, state } = useRemirror({ extensions: () => [new MarkdownExtension(), new BoldExtension()], content: "", selection: "start", stringHandler: "markdown" }); return ( <div className="remirror-container"> <Remirror manager={manager} initialContent={state} /> </div> ); }; export default function App() { return <Editor />; } 复制代码尝试输入 **bold**,可以看到编辑器自动将它转成了 bold
自定义扩展
@remirror 的扩展写起来是很方便的,我们简单来看一下 @remirror/extension-bold 的具体实现。 remirror 的扩展用到了很多 @remirror/core 提供的装饰器。
export interface BoldOptions { weight?: Static<FontWeightProperty>; } @extension<BoldOptions>({ defaultOptions: { weight: undefined }, staticKeys: ['weight'], }) export class BoldExtension extends MarkExtension<BoldOptions> { get name() { return 'bold' as const; } createTags() { return [ExtensionTag.FormattingMark, ExtensionTag.FontStyle]; } createMarkSpec(extra: ApplySchemaAttributes, override: MarkSpecOverride): MarkExtensionSpec { return { ...override, attrs: extra.defaults(), parseDOM: [ { tag: 'strong', getAttrs: extra.parse, }, // This works around a Google Docs misbehavior where // pasted content will be inexplicably wrapped in `<b>` // tags with a font-weight normal. { tag: 'b', getAttrs: (node) => isElementDomNode(node) && node.style.fontWeight !== 'normal' ? extra.parse(node) : false, }, { style: 'font-weight', getAttrs: (node) => isString(node) && /^(bold(er)?|[5-9]\d{2,})$/.test(node) ? null : false, }, ...(override.parseDOM ?? []), ], toDOM: (node) => { const { weight } = this.options; if (weight) { return ['strong', { 'font-weight': weight.toString() }, 0]; } return ['strong', extra.dom(node), 0]; }, }; } createInputRules(): InputRule[] { return [ markInputRule({ regexp: /(?:\*\*|__)([^*_]+)(?:\*\*|__)$/, type: this.type, ignoreWhitespace: true, }), ]; } @command(toggleBoldOptions) toggleBold(selection?: PrimitiveSelection): CommandFunction { return toggleMark({ type: this.type, selection }); } @command() setBold(selection?: PrimitiveSelection): CommandFunction { return ({ tr, dispatch }) => { const { from, to } = getTextSelection(selection ?? tr.selection, tr.doc); dispatch?.(tr.addMark(from, to, this.type.create())); return true; }; } @command() removeBold(selection?: PrimitiveSelection): CommandFunction { return ({ tr, dispatch }) => { const { from, to } = getTextSelection(selection ?? tr.selection, tr.doc); if (!tr.doc.rangeHasMark(from, to, this.type)) { return false; } dispatch?.(tr.removeMark(from, to, this.type)); return true; }; } @keyBinding({ shortcut: NamedShortcut.Bold, command: 'toggleBold' }) shortcut(props: KeyBindingProps): boolean { return this.toggleBold()(props); } } 复制代码这个里面的一些写法,看起来是不是和前面我们直接用 ProseMirror 实现的有些类似,实际就是用的那些模块。
渲染编辑器和菜单
import { BoldExtension, HeadingExtension, ItalicExtension, MarkdownExtension, PlaceholderExtension, } from "remirror/extensions"; // ... const Menu = () => <button onClick={() => alert("button b")}>B</button>; export const Markdown = () => { const { manager, state } = useRemirror({ extensions: () => [ new PlaceholderExtension({ placeholder: "请输入文字", }), new BoldExtension(), new ItalicExtension(), new MarkdownExtension(), new HeadingExtension(), ], content: "", selection: "start", stringHandler: "markdown", }); return ( <div className="remirror-container"> <Remirror manager={manager} initialContent={state} hooks={hooks}> <Menu /> <EditorComponent /> </Remirror> </div> ); }; 复制代码当你需要自定义渲染编辑器的菜单时,你就需要使用 EditorComponent,Remirror 组件不接受 children 时默认会使用该组件渲染。这里 Menu 菜单就简单写了个按钮,后续我们再优化。
总结
基本上,参考 Remirror 的插件写法或者直接使用 Remirror 就可以简单构建出一个 Markdown 编辑器,接下来就看大家的发挥了。
作者:夜焱辰
链接:https://juejin.cn/post/7169143405287571492