实践篇:如何用 ProseMirror 解析和渲染 markdown

3,635 阅读5分钟

前言

上一篇我们简单搭建了一个 electron 应用的开发环境,启动后,窗口加载了一个网页。现在我们要加入我们的主体功能 —— Markdown。在这里,我借助 ProseMirror 来开发编辑器。我们先简单学习一下如何用 ProseMirror 搭建一个富文本编辑器。

ProseMirror

简易编辑器

ProseMirror 有几个必要的模块:

  1. prosemirror-model 定义了编辑器的 Document Model, 它用来描述编辑器的内容.
  2. prosemirror-state 提供了一个描述编辑器完整状态的单一数据结构, 包括编辑器的选区操作, 和一个用来处理从当前 state 到下一个 state 的一个叫做 transaction 的系统.
  3. prosemirror-view 用来将给定的 state 展示成相对应的可编辑元素显示在编辑器中, 同时处理用户交互.
  4. prosemirror-transform 包含了一种可以被重做和撤销的修改文档的功能, 它是 prosemirror-statetransaction 功能的基础, 这使得撤销操作历史记录和协同编辑成为可能.

其他还有一些模块,如 prosemirror-collabprosemirror-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>
  );
};

image.png

这里的 schema 我就直接用的 prosemirror-schema-basic 提供,支持的 NodeMark 不多。

上面这个编辑器还很简陋,像输入回车键换行等常见的操作都还没有。我们可以借助 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),
        ],
      }),
    });
  }, []);

如图所示,我加入了 keymaphistory,现在这个编辑器支持 redo undo 了。

Plugins

prosemirror-state

prosemirror-state 提供了编写插件的能力,上面用的 keymaphistory 都是 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);
                }
              ),
            ],
          }),
        ],
      }),
    });

md-strong.gif

prosemirror-inputrules 还提供了一个 command —— undoInputRule, 它可以将我们刚刚输入的 hello 重新变成 **hello**,我们结合 historykeymap 来使用。

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);
                }
              ),
            ],
          }),
        ],
      }),
    });

undo-inputrule.gif

可以看到,按键 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 这个模块中还提供了很多内置的扩展,大家可以根据需要直接使用。

image.png

添加扩展

我们先添加一个 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>
  );
};

当你需要自定义渲染编辑器的菜单时,你就需要使用 EditorComponentRemirror 组件不接受 children 时默认会使用该组件渲染。这里 Menu 菜单就简单写了个按钮,后续我们再优化。

image.png

总结

基本上,参考 Remirror 的插件写法或者直接使用 Remirror 就可以简单构建出一个 Markdown 编辑器,接下来就看大家的发挥了。