Solidjs构建跨框架markdown编辑器

解释标题:跨框架是指该项目有原生 js 支持,可以灵活的整合到其他框架中去

项目已开源,欢迎大家讨论交流学习进步,我还是在校大学生,只是初学者,文章旨在解释我的实现方法和思想以及源代码中的一些方法,希望大家能给予一些帮助和改进建议

前言

很多例如博客等文章内容类项目都需要一个合适的前端编辑器,网上 vue,react 的 markdown 的编辑器框架很多,也有像 wangEditor 之类的开箱即用富文本编辑器,但大都很臃肿,很难去自定义一些内容。就比如说,本人的一个博客的项目,由于使用了一些自定义的标签渲染语法(拓展了 markdown)希望能在编辑器中得到体现(不论实在编码还是预览中),用这些现成的编辑器去实现还是比较困难的,于是想着自己制作一个简单的 markdown 编辑器,于是就有了以下要求。

  • 支持原生 js,方便引入其他框架
  • 尽可能的轻便
  • 预览功能支持自定义(直接暴露出去一个方法,让用户可以设置渲染的引擎 eg markedjs、markdown-it)

选择 Solidjs 的原因是,直接使用原生 js 的话操作 dom 麻烦代码屎山(我还在学习中),Solidjs 虽然生态很差,但是构建这类小组件还是很方便的,最重要的是性能逼近原生,打包体积很小(项目整合 CodeMirror 以及它的一些插件打包完也只有108k gzip)

实现过程

项目初始化

使用 vite+solidjs+eslint 初始化项目,修改 vite.config.js 打包 lib 模式

import { resolve } from "node:path";
import { defineConfig } from "vite";
import solidPlugin from "vite-plugin-solid";
import dts from "vite-plugin-dts";

export default defineConfig({
  plugins: [solidPlugin(), dts({ include: "./src" })],
  css: {
    modules: {
      scopeBehaviour: "local",
      generateScopedName: "gedi_[hash:5]",
    },
  },
  build: {
    lib: {
      entry: resolve(__dirname, "./src/index.ts"),
      name: "MdEditor",
      fileName: (format) => `editor.${format}.js`, // 打包后的文件名
    },
  },
});

引入一些依赖 codemirror5、lodash(这个工具库太好用了)、iconify…

我们还需要修改入口文件和 index.html 方便我们打包或者测试,因为我们打包的是库项目,而不是界面应用,所以这不是一个自运行的项目,而需要引入和调用。注意我们暴露出去了两个方法,一个是给原生 js 用的,一个是给 solidjs 直接引入的(原生 js 的方法其实也只是对后者的简单封装)。

简单封装 codemirror5

如果你不需要修改自带的 codemirror 的 markdown 模式的话,那直接引入就好了

import CodeMirror from "codemirror";

import "codemirror/addon/scroll/simplescrollbars"; // 滚动条

import "./codemirror.scss"; // 核心样式
import "./blackboard.css"; // 暗色模式的样式
import "./simplescrollbars.scss"; // 滚动条的样式

然后我们就需要来修改 markdown 模式了,我们找到 node_modules 目录下 codemirror 的 mode 里的 markdown.js,将代码复制过来转成 ts 的格式(这一步很麻烦,我们只需要CodeMirror.defineMode这部分代码),代码可能比较难理解,通过对照官方文档,修改它判定 quote 的部分。我们这里让 codemirror 判定 blockquote 时需要识别到空格才高亮,并支持>! 这种模式(我的项目里的彩色 quote 的功能)

...
} else if (
      // blockquote
      state.indentation <= maxNonCodeIndentation &&
      stream.eat('>')
    ) {
      // 识别空格以及特定字符
      if (
        (['i', '!', '@', 'y', 'x'].includes(stream.string[1]) &&
          stream.string[2] === ' ') ||
        stream.string[1] === ' '
      )
        state.quote = firstTokenOnLine ? 1 : state.quote + 1
      if (modeCfg.highlightFormatting) state.formatting = 'quote'
      stream.eatSpace()
      return getType(state)
...

再美化美化样式,我们修改完成。

整合 codemirror5 到核心组件

我们再核心组件 MdEditor.tsx 里引入并使用 codemirror,只需要再 onMount 时将 codemirror 挂载到我们的指定 dom 即可。

...
 onMount(() => {
    if (!$element) return
    const $editor = $element.querySelector(`.${styles.editor}`) as HTMLElement
    const $preview = $element.querySelector(
      `.${styles['preview-content']}`,
    ) as HTMLElement

    if ($editor && $preview) {
      const cm = CodeMirror($editor, {
        mode: 'markdown',
        lineWrapping: true,
        value: props.value,
        scrollbarStyle: 'overlay',
      })
...

Toolbar 组件

我们还需要 Toolbar 组件来实现工具栏和各种功能,以及 tooltip 和 dropdown 来实现提示下拉框,这里全部基于 solidjs 构建。

toolbar 由一个个 toolbarItem 组成,它包含以下属性和方法

export interface ToolbarItem {
  title: string; // 名字,显示在tooltip
  icon: string; // 图标,这里使用iconify的图标
  action?: (inst: MdEditorInstType, itemInst: ToolbarItemInst) => void; // 点击时运行
  // 下拉菜单的内容,可以是一个个toolbarItem,也可以是自定义的内容,并提供一个方法它将在dom渲染完毕后执行
  menu?:
    | ToolbarItem[]
    | {
        innerHTML: string;
        onMount: (inst: MdEditorInstType, itemInst: ToolbarItemInst) => void;
      };
}

我们可以建立一些简单的 item

export const undoItem: ToolbarItem = {
  title: "回退",
  icon: "solar:undo-left-round-linear",
  action(inst) {
    const cm = inst.cm;
    cm.undo();
    cm.refresh();
    cm.focus();
  },
};

export const redoItem: ToolbarItem = {
  title: "重做",
  icon: "solar:undo-right-round-linear",
  action(inst) {
    const cm = inst.cm;
    cm.redo();
    cm.refresh();
    cm.focus();
  },
};

export const emptyItem: ToolbarItem = {
  title: "清空",
  icon: "solar:eraser-linear",
  action(inst) {
    const cm = inst.cm;
    cm.setValue("");
    cm.refresh();
    cm.focus();
  },
};

一些复杂的 item 同样也能够实现,比如表情

const $emotions = `<ul id="g-panel-emotions-TyUh" class="g-panel-content-emotion"><li>😀</li><li>😃</li><li>😄</li><li>😁</li><li>😆</li><li>😅</li>...太长了略
</ul>`;

export const emoItem: ToolbarItem = {
  title: "表情",
  icon: "solar:emoji-funny-circle-linear",
  menu: {
    innerHTML: $emotions,
    onMount(inst) {
      const cm = inst.cm;
      const $panel = inst.$element.querySelector(
        "#g-panel-emotions-TyUh"
      ) as HTMLUListElement;

      if ($panel) {
        $panel.addEventListener("click", (e) => {
          const $el = e.target as HTMLElement;

          if ($el.tagName === "LI") {
            cm.replaceSelection($el.textContent || "");
            cm.refresh();
            cm.focus();
          }
        });
      }
    },
  },
};

当然实现这样的前提是我们需要提供两个实例一个是编辑器的实例,让 item 能够操作编辑器,一个是 item 本身的实例让 item 能够控制自己的 tooltip、active 之类的

预览功能

按照要求预览功能由用户来自定义渲染器,默认情况下它只会输出编辑器里的 value,我们需要用户提供这样一个方法, 它会在编辑器启动预览功能时执行,就是将编辑器的 value 通过该方法生成新的字符串并输入 preview dom 的 innerHtml。这样我们就实现了预览功能。

handelPreview: (v: string) => string;

其他

还有不少没提到的都在开源仓库里了,注释有待完善,但项目并不复杂

我们还需要暴露出去一些方法,让用户获得动态设置编辑器主题、内容和获取编辑器 value 的功能。我们可以在 main.tsx 发现我是如何暴露这些方法的

...
// 暴露给原生js使用,这里其实也就是对MdEditor的原生化封装
export function Editor(config: params) {
  if (!config.target) return
  const [theme, setTheme] = createSignal(config.theme)
  const [value, setVal] = createSignal('')
  render(
    () => (
      <MdEditor
        onChange={config.onChange}
        handelPreview={config.handelPreview}
        height={config.height}
        theme={theme()}
        value={value()}
      />
    ),
    config.target,
  )

  return {
    setTheme,
    setVal,
  }
}
...

成品

demo: https://g-mero.github.io/solidjs-md-editor/

demo

评论
正在加载评论组件...