# 开发一套 MDX 可预览编辑器

# 初衷

我们在写博客的时候, 经常使用 markdown 语法书写, 再通过线上的播客网站转化成 html 页面, 渲染出好看的文章, 但是市面上好用且可定制化的编辑器少之又少, 且不完全满足开发的需求, 所以想自己开发一套用于使用

# 目标

  1. 支持编辑和预览
  2. 支持编辑预览区范围的拖拽, 支持编辑预览区域布局修改
  3. 支持白天, 夜间模式的切换
  4. 支持预览区域设置成手机模式, 且可以自定义宽高
  5. 支持 MDX, 可以自定义 React 组件
  6. 支持 css, 可以自定义样式
  7. 拓展预览区域的整体主题切换, 支持代码块的主题切换
  8. 支持导出功能
  9. 支持打包成桌面端

# 框架

# 开始

这里只做整体的实现思路介绍, 某些部分的功能可能会直接带过或者贴代码展示, 具体的细节可以看源码或自行探索

因为项目中功能点较多, 如果一一描述, 篇幅较长, 所以摘取一些有难度的地方做分析

先看下预览

# 布局

布局

布局采用的是上下布局 顶部的组件包含左侧的标题和操作按钮, 右侧是按钮组, 包含设置, 视图切换, 白天夜间切换 下面的部分包含左侧的代码编辑区和右侧的预览区域 左侧的代码编辑区, 支持 tab 切换, 包含 MDX,CSS,JSX 的编辑

编辑区的视图切换支持左右, 上下, 预览, 手机预览几种模式切换, 左右,上下拖拽功能使用的是 react-split-pane (opens new window), 也可以使用allotment (opens new window), 在使用 react-split-pane 过程中, 如果使用的是 react18 版本 会报错, 报错的问题也就是 typescript 的类型声明有误, 我这里采用了 issues 解决方案 patch-package (opens new window) 的方式做了解决

# 代码编辑区

代码编辑这里使用了 vscode Web 版 Monaco Editor (opens new window)

在左侧编辑区组件初始化的时候执行, 只执行一次

export function createMonacoEditor({
  container,
  initialContent,
  onChange,
  onScroll,
}: CreateMonacoEditorProps) {
  const disposables: any[] = []; // 销毁列表

  window.MonacoEnvironment = {
    getWorkerUrl: (_moduleId, label) => {
      const v = `?v=${
        require("monaco-editor/package.json?fields=version").version
      }`;
      if (label === "css" || label === "tailwindcss")
        return `_next/static/chunks/css.worker.js${v}`;
      if (label === "html") return `_next/static/chunks/html.worker.js${v}`;
      if (label === "typescript" || label === "javascript")
        return `_next/static/chunks/ts.worker.js${v}`;
      return `_next/static/chunks/editor.worker.js${v}`;
    },
  };

  // 覆盖默认的格式化功能, 使用 prettier 替代
  disposables.push(registerDocumentFormattingEditProviders());

  // 设置 markdown 的 模型 用来生成预览
  const html = setupMarkdownMode(
    initialContent.html,
    () => {
      onContentChange();
    },
    () => editor
  );
  disposables.push(html);
  // 设置 css 的 模型 用来生成预览
  const css = setupCssMode(
    initialContent.css,
    () => {
      onContentChange();
    },
    () => editor
  );
  disposables.push(css);
  // 设置 js 的 模型 用来生成预览
  const config = setupJavascriptMode(
    initialContent.config,
    () => {
      onContentChange();
    },
    () => editor
  );
  disposables.push(config);

  // 配置主题
  initMonacoTheme();

  // 初始化编辑器
  const editor = monaco.editor.create(container, {
    theme: getTheme() === "dark" ? "dark" : "light",
    wordWrap: "on", // 文本换行配置
    lineHeight: monacoConfig.lineHeight,
    fontSize: monacoConfig.fontSize,
    minimap: {
      enabled: false, // 隐藏
    },
    fixedOverflowWidgets: true, // 我的编辑器整体宽度较小,而提示项的宽度较大,导致提示框的一部分被覆盖。查了一下issues,没有直接把提示框限定在编辑器范围内的配置项。但有一个相关的配置项,设置为true后,可以把隐藏的部分显示出来
    unicodeHighlight: {
      ambiguousCharacters: false, // 取消 unicode ASCII编码 字符串高亮问题
    },
  });

  disposables.push(editor);

  // 初始化快捷键
  initKeyBindings(editor);

  // 设置 ctrl+s 快捷键 = 格式化代码
  updateKeyBinding(
    editor,
    "editor.action.formatDocument",
    monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS
  );

  // 监听编辑器滚动事件, 获取滚动代码的 startLineNumber 用于右侧预览区同步滚动
  editor.onDidScrollChange((e) => {
    if (!e.scrollTopChanged) return;
    const currentModel = editor.getModel();
    if (currentModel === html.getModel()) {
      const { startLineNumber } = editor.getVisibleRanges()[0];
      onScroll(startLineNumber);
    }
  });

  // 整合模型
  const models = { html, css, config };

  return {
    editor,
    models,
    dispose: () => {
      disposables.forEach((disposable) => disposable.dispose());
    },
  };
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104

# 这里我们说下如何 覆盖默认的格式化功能, 使用 prettier 替代

  1. 使用 monaco.languages.registerDocumentFormattingEditProvider 6421 行 (opens new window) 覆盖默认的格式化功能

以 markdown 为例

monaco.languages.registerDocumentFormattingEditProvider(
  "markdown",
  formattingEditProvider
);

const formattingEditProvider = {
  // 固定格式
  async provideDocumentFormattingEdits(
    model: monaco.editor.ITextModel,
    options: monaco.languages.FormattingOptions,
    _token: monaco.CancellationToken
  ) {
    if (!prettierWorker) {
      // 新建 web worker
      prettierWorker = createWorkerQueue(PrettierWorker)
    }
    // src/workers/prettier.worker.js 内部初始化
    // emit 是在 src/utils/workers.ts 配置
    const { canceled, error, pretty } = (await prettierWorker.emit({
      text: model.getValue(),
      language: model.getLanguageId(),
    })) as any
    if (canceled || error) return []
    return [
      {
        range: model.getFullModelRange(),
        text: pretty,
      },
    ]
  },
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

createWorkerQueue

// 生成执行队列
export function createWorkerQueue(NewWorker: WebpackWorker) {
  // 本地有跨域问题
  // const worker = new Worker(new URL(workerPath, import.meta.url))
  const worker = new NewWorker();
  const queue = new PQueue({ concurrency: 1 });
  return {
    worker,
    emit(data: any) {
      queue.clear();
      const _id = Math.random()
        .toString(36)
        .substring(2, 5);
      worker.postMessage({ _current: _id });
      return queue.add(
        () =>
          new Promise((resolve) => {
            function onMessage(event: any) {
              if (event.data._id !== _id) return;
              worker.removeEventListener("message", onMessage);
              resolve(event.data);
            }
            worker.addEventListener("message", onMessage);
            worker.postMessage({ ...data, _id });
          })
      );
    },
    terminate() {
      worker.terminate();
    },
  };
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

我们看下 prettierWorker 初始化了什么

import prettier from "prettier/standalone";

// 格式化代码
const options = {
  markdown: async () => ({
    parser: "markdown",
    plugins: [await import("prettier/parser-markdown")],
    printWidth: 10000,
  }),
};

let current;

addEventListener("message", async (event) => {
  if (event.data._current) {
    current = event.data._current;
    return;
  }

  function respond(data) {
    setTimeout(() => {
      if (event.data._id === current) {
        postMessage({ _id: event.data._id, ...data });
      } else {
        postMessage({ _id: event.data._id, canceled: true });
      }
    }, 0);
  }

  const opts = await options[event.data.language]();

  try {
    respond({
      pretty: prettier.format(event.data.text, opts),
    });
  } catch (error) {
    respond({ error });
  }
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

执行步骤

  1. 首先使用 monaco 注册覆盖对应语言的 format 方法
  2. 使用 createWorkerQueue 传入 PrettierWorker 注册一个 worker
  3. createWorkerQueue 返回的参数调用 emit 方法调用 worker.postMessage 并监听 message
  4. emit 的 worker.postMessage 触发 PrettierWorker 的 addEventListener("message"), 调用 prettier 的 format 方法格式化指定的语言代码 将格式化代码 pretty 通过 postMessage 传回给 createWorkerQueue 的监听方法 message, 再通过 emit 成功回调传递给 formattingEditProvider
  5. monaco 的 format 方法拿到格式化结果按格式返回 渲染到编辑器中

至此, 实现了覆盖 monaco 编辑器默认的格式化代码行为

# 预览区

预览区要处理的工作比较多

  1. 引入 mdx, 支持将左侧的代码通过 mdxjs 编译成 html 代码块
  2. 支持 md 的额外功能, 包括表格, 数学符号, 代码块主题, react 组件渲染
  3. css 样式的加载
  4. 手机模式的预览和可拖拽控制宽高
Last Updated: 8/15/2023, 6:33:34 PM