Solidjs+CodeMirror构建跨框架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,
}
}
...
成品
转载自:https://juejin.cn/post/7220309530910982202