引言
大家好,我是程序员 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 相关依赖
npm install bytemd
npm instal @bytemd/react
安装 gfm(表格支持)插件、highlight 代码高亮插件
npm install @bytemd/plugin-gfm @bytemd/plugin-highlight
引入 ByteMD 汉化包
# 引入中文包
import zhHans from 'bytemd/locales/zh_Hans.json
用法
ByteMD 有两个组件:Editor
和 Viewer
。Editor 是 Markdown 编辑器; View
通常用于显示呈现的 Markdown 结果,无需编辑。在使用组件之前,还要导入CSS文件以确保样式正确:
import 'bytemd/dist/index.css'
封装自定义的 Editor 和 Viewer 组件
接下来需要对官方的 Editor 和 Viewer 进行封装, 以提高组件的通用性。
新建 MdEditor 组件,示例写法如下:
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;
页面中使用
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 的图标,咱们把它隐藏起来,主打的就是一个简洁~
/*隐藏 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 中导入我们所需的插件:
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。
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)编写插件代码
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 标签在渲染时生效,从而增强内容的安全性。
效果如下:
代码如下:
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 给这个代码添加一些样式
代码如下:
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 组件样式中。
/* 修改复制代码栏处的样式 */
.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
中:
const plugins = [
// 对齐插件
alignPlugin(),
gfm({
locale: gfmLocale,
}),
gemoji(),
highlight(),
math({
locale: mathLocale,
}),
mermaid({
locale: mermaidLocale,
}),
mediumZoom(),
allowHtmlTags(),
codeCopy(),
];
注意:这里的 codeCopy() 插件我们添加至 MDViewer 组件的 plugins 即可,否则会影响用户编辑时的体验,影响编辑器的性能。
至此,我们就拥有了一款轻量、简洁、实用的编辑器了!可以随心所欲的集成到任何 React 项目中,一款支持代码高亮、emoji 解析、数学公式、流程图、图片预览放大,对齐设置、标签解析、代码块复制折叠等功能的插件,集成到面试项目中也是不可多得的加分点!
结语
目前 面试鸭 web 端也运用了多种新颖的技术与功能,如:沉浸式刷题、海报生成、消息系统、用户编辑器内支持调整图片大小等,后面我还会持续给大家分享面试鸭中某些功能的实现方式,