likes
comments
collection
share

Solidjs+CodeMirror构建跨框架markdown编辑器

作者站长头像
站长
· 阅读数 17

解释标题:跨框架是指该项目有原生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: g-mero.github.io/solidjs-md-…