阅读 75

如何用 ProseMirror 解析和渲染 markdown

前言

上一篇我们简单搭建了一个 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 编辑器,接下来就看大家的发挥了。


作者:夜焱辰
链接:https://juejin.cn/post/7169143405287571492

文章分类
代码人生
版权声明:本站是系统测试站点,无实际运营。本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 XXXXXXo@163.com 举报,一经查实,本站将立刻删除。
相关推荐