[Milkdown] editor Integration Guide

preface

Recently, due to business needs, a WYSIWYG Markdown editor was integrated. After Google searched, I saw Milkdown. At that time, I was attracted by its appearance as soon as I saw the interface. After experiencing his convenient syntax, I immediately decided to use this editor.
However, it was not so smooth in the process of use. The documents were confused. There were few search related tutorials at home and abroad. 99% of them were basically just a brief introduction, and they were stuck to and from the same post 😂
👉Bye, Typora! This Markdown artifact is amazing!👈

Pit stepping process and application example

Here, take Vue 2 as an example. The use methods of Vue 3 and other frameworks are similar. Please refer to Vue 3.x integration example Configure it

1. Simple initialization

get into Integration example You will see this sentence: creating a component is very simple.

<template>
    <div ref="editor"></div>
</template>

<script>
    import { defaultValueCtx, Editor, rootCtx } from '@milkdown/core';
    import { nord } from '@milkdown/theme-nord';
    import { commonmark } from '@milkdown/preset-commonmark';
    export default {
        name: 'Editor',
        mounted() {
            Editor.make()
                .config((ctx) => {
                    ctx.set(rootCtx, this.$refs.editor);
                    ctx.set(defaultValueCtx, "# Hello Milkdown💗");
                })
                .use(nord)
                .use(commonmark)
                .create();
        },
    };
</script>

<style></style>

After introducing plug-ins and a skilled CV, you will find that there is no hair on the interface except for a few thin words - yes, all functional plug-ins need to be installed separately. When I want to try to get the toolbar at the top, the first pit comes at this time, and you will find the document Plug in directory There is no menu we want. After a search, we can Github warehouse The plug-in was found in.

npm i @milkdown/plugin-menu

2. Example of complete body configuration

Effect reference Official website example

import { defaultValueCtx, Editor, editorViewOptionsCtx, rootCtx } from '@milkdown/core';
import { clipboard } from '@milkdown/plugin-clipboard';
import { cursor } from '@milkdown/plugin-cursor';
import { diagram } from '@milkdown/plugin-diagram';
import { emoji } from '@milkdown/plugin-emoji';
import { history } from '@milkdown/plugin-history';
import { indent } from '@milkdown/plugin-indent';
import { listener, listenerCtx } from '@milkdown/plugin-listener';
import { math } from '@milkdown/plugin-math';
import { menu } from '@milkdown/plugin-menu';
import { prism } from '@milkdown/plugin-prism';
import { slash } from '@milkdown/plugin-slash';
import { tooltip } from '@milkdown/plugin-tooltip';
import { upload } from '@milkdown/plugin-upload';
import { gfm } from '@milkdown/preset-gfm';
import { nord } from '@milkdown/theme-nord';

export const createEditor = (
    root: HTMLElement | null,
    defaultValue: string,
    readOnly: boolean | undefined,
) => {
    const editor = Editor.make()
        .config((ctx) => {
            ctx.set(rootCtx, root);
            ctx.set(defaultValueCtx, defaultValue);
            ctx.set(editorViewOptionsCtx, { editable: () => !readOnly });
        })
        .use(nord)
        .use(gfm)
        .use(listener)
        .use(clipboard)
        .use(history)
        .use(cursor)
        .use(prism)
        .use(diagram)
        .use(tooltip)
        .use(math)
        .use(emoji)
        .use(indent)
        .use(upload)
        .use(slash);

    if (!readOnly) {
        editor.use(menu());
    }

    return editor;
};

3. Topic switching

The editor initialization must specify a topic. The official website example gives the nord topic

import { nord } from "@milkdown/theme-nord";

During my local test, the theme was dark. I searched the document for a long time and found no relevant information. After consulting the source code, I learned that I switched to bright theme or dark theme:

// Bright color theme
import { nordLight } from "@milkdown/theme-nord";
// Dark theme 
import { nordDark } from "@milkdown/theme-nord";

4. Customize uploaded pictures

Well, another one Plug in directory No but Warehouse There are plug-ins in. Install NPM I @ milkdown / plugin upload using:

<template>
    <div ref="editor"></div>
</template>

<script>
    import { Editor } from '@milkdown/core';
    // ...
    import { upload } from '@milkdown/plugin-upload';
    export default {
        name: 'Editor',
        mounted() {
            Editor.make()
                // ...
                .use(upload)
                .create();
        },
    };
</script>

However, if you read README carefully, you will find a small line under the title: Upload image when drop for milkdown
Yes, it only supports dragging local pictures into the edit box, which is not exactly what we want. What we want is to select local pictures and insert them at the current cursor position when we click the insert picture button in the menu bar.

4.1 customize menu bar

To modify the default event of the upload picture button, first we need to customize the menu bar. Because @ milkdown / plugin menu does not support partial configuration, you can only pass in the full menu object and modify it locally.

The default menu configuration is shown in: https://github.com/Saul-Mirone/milkdown/blob/main/packages/plugin-menu/src/default-config.ts#L56

In the configuration, we find the button to be modified:

// ...
{
    type: 'button',
    icon: 'image',
    key: InsertImage,
},
// ...

You can see that menu items achieve specific functions by calling command key. The next step is to write user-defined instructions to upload pictures.

4.2 custom instructions

Official documents: https://milkdown.dev/#/zh-hans/commands

Reference example:

import { Editor, rootCtx, createCmdKey, commandsCtx, CommandsReady } from "@milkdown/core";
import { nord } from "@milkdown/theme-nord";
import { commonmark } from "@milkdown/preset-commonmark";
import { menu } from "@milkdown/plugin-menu";

export default {
  methods: {
    initEditorWithCustomCmdKey() {
      const CustomInsertImage = createCmdKey();
      const uploadImgCmd = () => async ctx => {
        // wait for command manager ready
        await ctx.wait(CommandsReady);
        const commandManager = ctx.get(commandsCtx);
        commandManager.create(CustomInsertImage, () => this.uploadImageHandler);
      };

      Editor.make()
        // ...
        .use(
          menu({
            config: [
              // Other menu configurations are omitted, but they need to be written down!
              [
                {
                  type: "button",
                  icon: "image",
                  key: this.CustomInsertImage, // Use custom Key
                },
                // ...
              ],
            ],
          })
        )
        .create()
        .then(editor => (this.editor = editor));
    },
    uploadImageHandler() {
      // Upload picture processing function
    },
  },
};

4.3 complete code

<template>
  <div ref="editor"></div>
</template>

<script>
import { Editor, rootCtx, createCmdKey, commandsCtx, CommandsReady } from "@milkdown/core";
import { nord } from "@milkdown/theme-nord";
import { commonmark } from "@milkdown/preset-commonmark";
import { menu } from "@milkdown/plugin-menu";

export default {
  data() {
    return {
      editor: null,
    };
  },
  methods: {
    initEditorWithCustomCmdKey() {
      const CustomInsertImage = createCmdKey();
      const uploadImgCmd = () => async ctx => {
        // wait for command manager ready
        await ctx.wait(CommandsReady);
        const commandManager = ctx.get(commandsCtx);
        commandManager.create(CustomInsertImage, () => this.uploadImageHandler);
      };

      Editor.make()
        .config(ctx => {
          ctx.set(rootCtx, this.$refs.editor);
        })
        .use(nord)
        .use(commonmark)
        .use(uploadImgCmd)
        .use(
          menu({
            config: [
              // Other menu configurations are omitted, but they need to be written down!
              [
                {
                  type: "button",
                  icon: "image",
                  key: this.CustomInsertImage, // Use custom Key
                },
                // ...
              ],
            ],
          })
        )
        .create()
        .then(editor => (this.editor = editor));
    },
    uploadImageHandler() {
      // Select pictures locally
      let input = document.createElement("input");
      input.type = "file";
      input.click();
      input.addEventListener("change", e => {
        this.readAsDataURL(e.target.files[0]).then(url => {
          const view = this.editor.action(ctx => ctx.get(editorViewCtx));

          let tr = view.state.tr;
          if (!tr.selection.empty) tr.deleteSelection();

          // Insert to current location
          this.editor.action(ctx => {
            const view = ctx.get(editorViewCtx);
            const parser = ctx.get(parserCtx);
            const doc = parser(`![image](${url})`);
            if (!doc) return;
            const state = view.state;
            view.dispatch(
              state.tr.replace(tr.selection.from, tr.selection.from, new Slice(doc.content, 0, 0))
            );
          });
        });
      });
    },
    readAsDataURL(file) {
      var reader = new FileReader();
      return new Promise(function (accept, fail) {
        reader.onload = function () {
          return accept(reader.result);
        };
        reader.onerror = function () {
          return fail(reader.error);
        };
        return reader.readAsDataURL(file);
      });
    },
  },
};
</script>

5. Insert markdown

5.1 pit stepping process

Since transformation is involved, I've been reading about Parser The first sentence of the page reads:

The parser is used to convert markdown into UI elements.

I thought this must be what I was looking for. As expected, I still didn't understand how to use it after reading it through three times. I even downloaded and installed remark parse according to the conversion steps of the official website, converted Markdown to AST, and got stuck in how to pass it to the Milkdown parser. The document was not written at all. Hello! 🙄

Parser conversion steps:

  1. Markdown will be passed to remark-parse And convert to AST.
  2. This remark AST will be traversed by the milkdown parser. The milkdown parser is generated through the definition of node and mark. The milkdown parser converts the AST into a prosemirror node tree.
  3. The generated node tree will be rendered by prosemirror and the corresponding UI elements will be generated.

I had no choice but to ask the developer directly. Unexpectedly, I got a reply from the developer in less than ten minutes:

Good guy, another function that has not been written in the document but has been encapsulated. It took two minutes to toss about all morning 😪

Links shared by authors in the figure: https://github.com/Saul-Mirone/milkdown/blob/main/gh-pages/component/MilkdownEditor/MilkdownEditor.tsx#L32

5.2 realization

In fact, you only need to get the editor object, then use Action to get the context of the editor runtime, and then introduce the parser parserCtx encapsulated by the author for us from @ milkdown/core without telling us. After parsing, you can dynamically insert Markdown into the editor.

<template>
  <div ref="editor"></div>
  <button @click="insertMarkdown('# Hello Milkdown🎈')">insertMarkdown</button>
</template>

<script>
import { Editor, rootCtx, editorViewCtx, parserCtx } from "@milkdown/core";
import { nord } from "@milkdown/theme-nord";
import { commonmark } from "@milkdown/preset-commonmark";
import { Slice } from "@milkdown/prose";

export default {
  data() {
    return {
      editor: null,
    };
  },
  methods: {
    initEditor() {
      Editor.make()
        .config(ctx => {
          ctx.set(rootCtx, this.$refs.editor);
        })
        .use(nord)
        .use(commonmark)
        .create()
        .then(editor => (this.editor = editor));
    },
    insertMarkdown(markdown) {
      const view = this.editor.action(ctx => ctx.get(editorViewCtx));

      let tr = view.state.tr;
      if (!tr.selection.empty) tr.deleteSelection();

      this.editor.action(ctx => {
        const parser = ctx.get(parserCtx);
        const doc = parser(markdown);
        if (!doc) return;
        const state = view.state;
        view.dispatch(
          state.tr.replace(tr.selection.from, tr.selection.from, new Slice(doc.content, 0, 0))
        );
      });
    },
  },
};
</script>

6. Cursor position

6.1 get the current cursor position

In fact, this function has been implemented in the above code. I'm afraid that the new friends in the pit don't know, so I'll take it out and explain it. First, the previous architecture diagram:

What we need is to get the Editor View object, and then get the selection information from it

const view = this.editor.action(ctx => ctx.get(editorViewCtx));
const tr = view.state.tr;
console.log(tr.selection.from);  // Cursor start position int
console.log(tr.selection.to); // Insert end position int

6.2 insert content at cursor position

import { editorViewCtx } from "@milkdown/core";
import { Slice } from "@milkdown/prose";

const view = this.editor.action(ctx => ctx.get(editorViewCtx));
const tr = view.state.tr;
view.dispatch(tr.replace(tr.selection.from, tr.selection.from, new Slice(...));

The core is the replace function, which receives three parameters (from, to, slice), namely, the insertion start position, the end position and the insertion content slice. For example:

  • replace(0, 0, Slice) will be inserted in the document header by default
  • replace(0, state.doc.content.size, Slice) will replace all contents of the document and insert new contents
  • replace(state.doc.content.size, state.doc.content.size, Slice) will be inserted at the end of the document by default
  • replace(tr.selection.from, tr.selection.from, Slice) will insert new content at the cursor position

7. Other issues

7.1 menu custom configuration does not take effect

If you direct CV Menu plugin warehouse README In the example, you will find that it doesn't work, and a little change is needed in the syntax.
README example: 🙅‍♂️

Editor.make().use(
    menu(
        [
            {
                type: 'select',
                text: 'Heading',
                options: [
                    { id: '1', text: 'Large Heading' },
                    { id: '2', text: 'Medium Heading' },
                    { id: '3', text: 'Small Heading' },
                ],
                disabled: (view) => {
                    const { state } = view;
                    const setToHeading = (level: number) => setBlockType(state.schema.nodes.heading, { level })(state);
                    return !(setToHeading(1) || setToHeading(2) || setToHeading(3));
                },
                onSelect: (id) => [TurnIntoHeading, Number(id)],
            },
        ],
        // ...
    ),
);

Practical and easy to use: 🙋‍♂️

Editor.make().use(
    menu({
      config: [
        [
          {
            type: "select",
            text: "title",
            options: [
              { id: "1", text: "Primary title" },
              { id: "2", text: "Secondary title" },
              { id: "3", text: "Tertiary title" },
            ],
            onSelect: id => [TurnIntoHeading, Number(id)],
          },
        ],
        // ...
      ],
    })
  )
  .create();

7.2 to be added

In addition, if you think it is too difficult to get started, you can try it Vditor It is also a WYSIWYG (WYSIWYG) Markdown editor

Keywords: Front-end github Vue.js markdown

Added by catreya on Sun, 09 Jan 2022 10:12:38 +0200