
做了一个 byd 编辑器插件,用户再也不汪汪叫了。。。
引言
大家好,我是程序员 K.N, 一个试图用代码和世界重新打结的前端小白~
先叠个甲,byd = ByteMD,小小的标题党一下,各位看官老爷轻喷。
前段时间,我们团队做了个面试刷题工具——面试鸭,而我也作为一名前端开发参与了该项目的开发。
在这个工具中,有一个流畅、简洁的 Markdown 编辑器,该编辑器所见即所得,支持代码高亮、代码块复制,标签解析、数学公式、流程图、对齐方式、图片自定义大小(仿语雀)、图片放大预览等功能。
今天,我将手把手带大家揭秘,这个 Markdown 编辑器是如何实现的,助你也能打造同款功能!
一、ByteMD 是啥?
其实这款编辑器是基于 ByteMD 实现的,它是字节开源的一款轻量编辑器,是使用 Svelte 构建的 Markdown 编辑器组件,它也可以用于其他框架,例如 React、Vue 和 Angular。
具有以下特性:
1)轻量级且与框架无关
2)易于扩展:ByteMD 有一个插件系统来扩展基本的 Markdown 语法,
3)默认安全:ByteMD 正确处理跨站点脚本(XSS) 攻击,例如 <script> 和 <img onerror>。无需引入额外的 DOM 清理步骤。
4)SSR 兼容:ByteMD 可以在服务器端渲染(SSR) 环境中使用,无需额外配置。
相关链接:
ByteMD 开源地址:https://github.com/pd4d10/bytemd
demo 示例:https://bytemd.js.org/playground/
二、快速集成 ByteMD
1、环境准备:
Node.js 16 及以上
2、基本使用
安装 ByteMD 相关依赖
▼powershell复制代码npm install bytemd
▼powershell复制代码npm instal @bytemd/react
安装 gfm(表格支持)插件、highlight 代码高亮插件
▼tsx复制代码npm install @bytemd/plugin-gfm @bytemd/plugin-highlight
引入 ByteMD 汉化包
▼tsx复制代码# 引入中文包 import zhHans from 'bytemd/locales/zh_Hans.json
用法
ByteMD 有两个组件:Editor 和 Viewer。Editor 是 Markdown 编辑器; View 通常用于显示呈现的 Markdown 结果,无需编辑。在使用组件之前,还要导入CSS文件以确保样式正确:
▼tsx复制代码import 'bytemd/dist/index.css'
封装自定义的 Editor 和 Viewer 组件
接下来需要对官方的 Editor 和 Viewer 进行封装, 以提高组件的通用性。
新建 MdEditor 组件,示例写法如下:
▼tsx复制代码import type { FC } from "react"; import { Editor } from "@bytemd/react"; import gfm from "@bytemd/plugin-gfm"; import gfmLocale from "@bytemd/plugin-gfm/locales/zh_Hans.json"; import highlight from "@bytemd/plugin-highlight"; import locale from "bytemd/locales/zh_Hans.json"; import "bytemd/dist/index.css"; import "./index.css"; interface Props { value?: string; onChange?: (v: string) => void; placeholder?: string; } const plugins = [ gfm({ locale: gfmLocale, }), highlight(), ]; /** * Markdown 编辑器 */ const MdEditor: FC<Props> = (props) => { const { value = "", onChange, placeholder } = props; return ( <div className="md-editor"> <Editor value={value || ""} placeholder={placeholder} editorConfig={{ // 不显示行数 lineNumbers: false, autofocus: false, }} mode="split" locale={locale} plugins={plugins} onChange={onChange} /> </div> ); }; export default MdEditor;
页面中使用
▼tsx复制代码import "./App.css"; import MdEditor from "@/components/MdEditor"; import { useState } from "react"; function App() { const [value, setValue] = useState<string>(""); return ( <> <MdEditor value={value} onChange={setValue} /> </> ); } export default App;
这样,就能得到一个基本的编辑器了,大家可以在光标中输入看看有没有实现所见即所得呢?但是右上角多了一个 GitHub 的图标,咱们把它隐藏起来,主打的就是一个简洁~
▼css复制代码/*隐藏 github 图标*/ .bytemd-toolbar-icon.bytemd-tippy.bytemd-tippy-right:last-child { display: none; }
3、ByteMD 插件配置
安装插件
官方支持的插件已经有不少,但对于一款体验良好的编辑器来说,我觉得还不够,除了使用以下列表中的插件外,我们还需要拓展其他插件,且听我娓娓道来 ~
官方插件列表如下:
| 插件名 | 插件功能 |
|---|---|
| @bytemd/plugin-breaks | 默认md渲染时硬换行需要双空格或者双回车, 该插件确保正常回车即可硬换行 |
| @bytemd/plugin-frontmatter | 解析元数据 |
| @bytemd/plugin-gemoji | 解析gemoji表情 |
| @bytemd/plugin-gfm | 支持GFM(自动链接文字、删除、表格、任务列表) |
| @bytemd/plugin-highlight | 代码高亮 |
| @bytemd/plugin-highlight-ssr | 代码高亮ssr版本 |
| @bytemd/plugin-math | 支持数学公式 |
| @bytemd/plugin-math-ssr | 支持数学公式ssr版本 |
| @bytemd/plugin-medium-zoom | 支持点击图片放大预览 |
| @bytemd/plugin-mermaid | 支持流程图 |
我们把几个常用的插件都安装上,在 plugins 中导入我们所需的插件:
▼tsx复制代码import type { FC } from "react"; import { Editor } from "@bytemd/react"; import gfm from "@bytemd/plugin-gfm"; import gfmLocale from "@bytemd/plugin-gfm/locales/zh_Hans.json"; import gemoji from "@bytemd/plugin-gemoji"; import highlight from "@bytemd/plugin-highlight"; import math from "@bytemd/plugin-math"; import mathLocale from "@bytemd/plugin-math/locales/zh_Hans.json"; import mermaid from "@bytemd/plugin-mermaid"; import mermaidLocale from "@bytemd/plugin-mermaid/locales/zh_Hans.json"; import mediumZoom from "@bytemd/plugin-medium-zoom"; import locale from "bytemd/locales/zh_Hans.json"; import "bytemd/dist/index.css"; import "highlight.js/styles/vs.css"; import "github-markdown-css/github-markdown-light.css"; import "./index.css"; const plugins = [ gfm({ locale: gfmLocale, }), gemoji(), highlight(), math({ locale: mathLocale, }), mermaid({ locale: mermaidLocale, }), mediumZoom(), ];
自定义插件
ByteMD 使用 remark 和 rehype 生态系统来处理 Markdown. 完整流程如下:
- Markdown 文本被解析为AST
- Markdown AST 可以通过多种注释插件进行操作
- Markdown AST 转换为 HTML AST
- 出于安全原因,HTML AST 已被清理
- HTML AST 可以被多个rehype 插件操纵
- HTML AST 被字符串化为 HTML
- HTML 渲染后的一些额外 DOM 操作
这里借用下官方描述的流程图:
2、5、7步骤是通过 ByteMD 插件 API 进行用户定制的。官方文档中用了一个 plugin-math 插件作为例子解释了我们该如何编写插件,接下来,我带大家来自定义实现几个实用的插件,包括:居中插件、标签解析插件、代码块复制插件等。
添加对齐方式插件:
给输入的文本、图片、链接等进行对齐方式设置,实现原理就是通过给某个元素包裹上一个 p 标签,并通过 align 属性设置其在文本框内的位置。
代码如下:
1)给这三个对齐方式设置对应的 icon ,这里可以直接套用我的 svg。
▼typescript复制代码export const ALIGN_CENTER = ` <svg t="1719248469954" class="icon-symbol" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4377"> <path d="M96 128h832v96H96zM96 576h832v96H96zM224 352h576v96H224zM224 800h576v96H224z" p-id="4378"></path> </svg>`; export const ALIGN_LEFT = ` <svg width="24" height="24" t="1719248152373" class="icon-symbol" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4230"> <path d="M96 128h832v96H96zM96 576h832v96H96zM96 352h576v96H96zM96 800h576v96H96z" p-id="4231"></path> </svg>`; export const ALIGN_RIGHT = ` <svg t="1719248528798" class="icon-symbol" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4524"> <path d="M96 128h832v96H96zM96 576h832v96H96zM352 352h576v96H352zM352 800h576v96H352z" p-id="4525"></path> </svg>`;
2)编写插件代码
▼typescript复制代码import type { BytemdPlugin } from 'bytemd'; import zh_Hans from './localels/zh_Hans.json'; import { ALIGN_LEFT, ALIGN_CENTER, ALIGN_RIGHT } from './icon'; export interface AlignPluginOptions { locale?: Record<string, string>; } /** * 对齐方式插件 */ export default function alignPlugin(options?: AlignPluginOptions): BytemdPlugin { const locale = { ...zh_Hans, ...options?.locale } as typeof zh_Hans; return { actions: [ { title: locale.alignType, icon: ALIGN_CENTER, handler: { type: 'dropdown', actions: [ { title: locale.alignTypeLeft, icon: ALIGN_LEFT, handler: { type: 'action', click: (ctx) => { ctx.wrapText('<p align="left">', '</p>'); ctx.editor.focus(); }, }, }, { title: locale.alignTypeCenter, icon: ALIGN_CENTER, handler: { type: 'action', click: (ctx) => { ctx.wrapText('<p align="center">', '</p>'); ctx.editor.focus(); }, }, }, { title: locale.alignTypeRight, icon: ALIGN_RIGHT, handler: { type: 'action', click: (ctx) => { ctx.wrapText('<p align="right">', '</p>'); ctx.editor.focus(); }, }, }, ], }, }, ], }; }
通过 ByteMD 的接口定义返回一个 actions 数组,即可定义这个工具栏下具有的操作。
添加标签解析插件
在输入 HTML 标签语法的时候,往往都会给 HTML 标签添加标签语法,如:<div> ,如果不添加,则会直接渲染,来看看在掘金的效果:
那有没有一种办法,在我复制大量代码,或是标签时,能够将该标签直接转为文本呢?又不写特定的标签语法
还真有,该插件就是可以允许一些标签直接编译成文本的,目的是转义 HTML 标签,防止某些未经允许的 HTML 内容被直接渲染。可以帮助防止某些不安全或不需要的 HTML 标签在渲染时生效,从而增强内容的安全性。
效果如下:
代码如下:
▼typescript复制代码import type { BytemdPlugin } from "bytemd"; import { visit } from "unist-util-visit"; export default function escapeHtmlTags(): BytemdPlugin { return { remark: (processor) => // @ts-ignore processor.use(() => (treeNode) => { visit(treeNode, "html", (node) => { // 排除的标签列表 const excludeTags = ["img", "br", "p", "text"]; // 解析HTML标签,检查是否包含src属性且src属性有值 const parser = new DOMParser(); const doc = parser.parseFromString(node.value, "text/html"); const allElements = doc.body.getElementsByTagName("*"); let shouldEscape = true; for (const el of allElements as any) { if ( excludeTags.includes(el.tagName.toLowerCase()) && (el.tagName.toLowerCase() !== "img" || el.getAttribute("src")) ) { shouldEscape = false; break; } } if (shouldEscape) { node.value = node.value.replace(/</g, "<").replace(/>/g, ">"); } }); }), }; }
上述代码中,我们通过遍历抽象语法树中的 HTML 类型节点,解析其中的内容,并根据标签类型决定是否需要转义。特定的标签如 img、br、p 和 text 等不会被转义,如果 img 标签包含有效的 src 属性,也会被保留原样。对于不在排除列表中的标签,会将 < 和 > 转义为 < 和 >。
添加代码块插件(支持复制、折叠):
ByteMD 默认渲染的出来的就是最简单的 HTML,代码块是被解析成 pre > code 标签, 因此是不带任何额外功能的,我们希望在代码块的右上角有个复制代码的按钮,在左上角有个折叠的按钮,类似掘金的代码块,效果如下:
我们通过 rehype 和 viewerEffect 来实现以上效果,再通过 css 给这个代码添加一些样式
代码如下:
▼typescript复制代码import type { BytemdPlugin } from "bytemd"; import { visit } from "unist-util-visit"; // 复制的方法,直接使用浏览器的 API 即可实现复制 const copyToClipboard = async (text: string) => { if (navigator.clipboard) { try { await navigator.clipboard.writeText(text); console.log("当前代码已复制到剪贴板"); } catch (err) { console.error("复制代码失败,请手动复制"); console.error("复制失败!", err); } } else { const textarea = document.createElement("textarea"); textarea.value = text; document.body.appendChild(textarea); textarea.select(); try { document.execCommand("copy"); document.body.removeChild(textarea); message.success("已复制到剪贴板"); } catch (err) { document.body.removeChild(textarea); message.error("复制代码失败,请手动复制"); console.error("无法复制到剪贴板", err); } } }; // 一些图标 const clipboardCheckIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy-check"><path d="m12 15 2 2 4-4"/><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`; const successTip = `<span style="font-size: 0.90em;">复制成功!</span>`; const foldBtn = `<svg t="1726055300369" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2293" width="1em" height="1em"><path d="M232 392L512 672l280-280z" fill="#707070" p-id="2294"></path></svg>`; const newSvgIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 2L2 12h10l0 10l10-10h-10z" /></svg>`; export default function codeCopy(): BytemdPlugin { return { rehype: (processor) => processor.use(() => (tree: any) => { visit(tree, "element", (node) => { if (node.tagName === "pre") { const codeNode = node.children.find((child: any) => child.tagName === "code"); const language = codeNode?.properties?.className ?.find((cls: any) => cls.startsWith("language-")) ?.replace("language-", "") || "text"; if (codeNode) { node.children.unshift({ type: "element", tagName: "div", properties: { className: ["code-block-extension-header"], }, children: [ { type: "element", tagName: "div", properties: { className: ["code-block-extension-headerLeft"], }, children: [ { type: "element", tagName: "div", properties: { className: ["code-block-extension-foldBtn"], }, children: [ { type: "text", value: "▼", }, ], }, { type: "element", tagName: "span", properties: { className: ["code-block-extension-lang"], }, children: [{ type: "text", value: language }], }, ], }, { type: "element", tagName: "div", properties: { className: ["code-block-extension-headerRight"], style: "cursor: pointer;", }, children: [ { type: "element", tagName: "div", properties: { className: ["code-block-extension-copyCodeBtn"], style: "filter: invert(0.5); opacity: 0.6;", }, children: [{ type: "text", value: "复制代码" }], }, ], }, ], }); node.properties = { ...node.properties, }; } } }); }), viewerEffect({ markdownBody }) { const copyButtons = markdownBody.querySelectorAll(".code-block-extension-copyCodeBtn"); const foldButtons = markdownBody.querySelectorAll(".code-block-extension-foldBtn"); copyButtons.forEach((button) => { button.addEventListener("click", () => { const pre = button.closest("pre"); const code = pre?.querySelector("code")?.textContent || ""; copyToClipboard(code); const tmp = button.innerHTML; button.innerHTML = clipboardCheckIcon + successTip; setTimeout(() => { button.innerHTML = tmp; }, 1500); }); }); // 处理折叠按钮的点击事件,实现旋转 foldButtons.forEach((foldButton) => { foldButton.addEventListener("click", () => { foldButton.classList.toggle("code-block-extension-fold"); // 切换折叠类名 // 找到最近的 pre 标签 const pre = foldButton.closest("pre"); if (pre) { if (pre.style.paddingTop === "1em") { pre.style.paddingTop = "3em"; // 恢复原来的 padding } else { pre.style.paddingTop = "1em"; // 设置 padding 为 0 } } // 在 pre 标签下找到 code 标签 const code = pre?.querySelector("code"); // 切换 code 标签的类名 if (code) { code.classList.toggle("code-block-extension-fold"); } // 在 pre 标签下找到 code-block-extension-header const headerElement = pre?.querySelector(".code-block-extension-header"); // 切换 code-block-extension-header 的类名 if (headerElement) { headerElement.classList.toggle("code-block-extension-fold"); } }); }); }, }; }
通过 rehype 解析和修改 Markdown 的语法树结构,使用 unist-util-visit 遍历 pre 标签,并向其子元素中插入额外的复制和折叠功能的 HTML 代码块。同时,在页面加载后为按钮元素添加事件监听,实现交互效果。
渲染代码块时,通过为每个 pre 标签一个头部区域,该区域包括显示语言类型的文本、复制按钮和折叠按钮。用户点击复制按钮后,代码会被复制到剪贴板,按钮会显示复制成功的提示。
折叠按钮用于收起或展开代码块,点击后代码块的内容和相关样式会发生相应变化。通过 classList 和样式的动态切换实现折叠效果。我们需要把以下 css 代码加入封装好的 MDViewer 组件样式中。
▼css复制代码/* 修改复制代码栏处的样式 */ .md-viewer .markdown-body pre { position: relative; overflow: auto; line-height: 1.75; padding-top: 3em; } .code-block-extension-header { display: flex; user-select: none; height: 28px; align-items: center; justify-content: space-between; margin-bottom: 3px; position: absolute; top: 0; left: 0; width: 100%; font-size: 1em; background-color: rgb(248, 248, 248); box-shadow: 0px 4px 5px -6px #888888; padding: 0.5em 1em; } .code-block-extension-header.code-block-extension-fold { box-shadow: none; margin-bottom: 0; } .code-block-extension-headerLeft { display: flex; align-items: center; } .code-block-extension-headerLeft > .code-block-extension-foldBtn { display: flex; align-items: center; justify-content: center; width: 10px; height: 10px; color: #707070; /* 初始颜色 */ margin-right: 8px; } .code-block-extension-headerLeft > .code-block-extension-foldBtn:hover { cursor: pointer; color: #1890ff; /* 悬停时变为蓝色 */ } .code-block-extension-headerLeft > .code-block-extension-foldBtn.code-block-extension-fold { transform: rotate(-90deg); } pre > code.code-block-extension-fold { display: none !important; }
插件使用:
最后,我们需要将以上写的三个插件,都补充到 plugins 中:
▼tsx复制代码const plugins = [ // 对齐插件 alignPlugin(), gfm({ locale: gfmLocale, }), gemoji(), highlight(), math({ locale: mathLocale, }), mermaid({ locale: mermaidLocale, }), mediumZoom(), allowHtmlTags(), codeCopy(), ];
注意:这里的 codeCopy() 插件我们添加至 MDViewer 组件的 plugins 即可,否则会影响用户编辑时的体验,影响编辑器的性能。
至此,我们就拥有了一款轻量、简洁、实用的编辑器了!可以随心所欲的集成到任何 React 项目中,一款支持代码高亮、emoji 解析、数学公式、流程图、图片预览放大,对齐设置、标签解析、代码块复制折叠等功能的插件,集成到面试项目中也是不可多得的加分点!
结语
目前 面试鸭 已有几百个企业面试题库,8000 多道面试真题,不仅能够帮助大家利用闲暇时间背八股、面试题提升自身外,面试鸭 web 端也运用了多种新颖的技术与功能,如:沉浸式刷题、海报生成、消息系统、用户编辑器内支持调整图片大小等
后面我还会持续给大家分享面试鸭中某些功能的实现方式,感兴趣的欢迎各位来面试鸭:mianshiya.com 中体验各种功能鸭!遇到 bug 也欢迎各位砸我身上呢!
