解释标题:跨框架是指该项目有原生 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/