做了一个 byd 编辑器插件,用户再也不汪汪叫了。。。

server/2024/9/29 21:16:58/

引言

大家好,我是程序员 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 有两个组件:EditorViewer。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"><Editorvalue={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. 完整流程如下:

  1. Markdown 文本被解析为AST
  2. Markdown AST 可以通过多种注释插件进行操作
  3. Markdown AST 转换为 HTML AST
  4. 出于安全原因,HTML AST 已被清理
  5. HTML AST 可以被多个rehype 插件操纵
  6. HTML AST 被字符串化为 HTML
  7. 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-ignoreprocessor.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, "&lt;").replace(/>/g, "&gt;");}});}),};
}

上述代码中,我们通过遍历抽象语法树中的 HTML 类型节点,解析其中的内容,并根据标签类型决定是否需要转义。特定的标签如 imgbrptext 等不会被转义,如果 img 标签包含有效的 src 属性,也会被保留原样。对于不在排除列表中的标签,会将 <> 转义为 &lt;&gt;

添加代码块插件(支持复制、折叠):

ByteMD 默认渲染的出来的就是最简单的 HTML,代码块是被解析成 pre > code 标签, 因此是不带任何额外功能的,我们希望在代码块的右上角有个复制代码的按钮,在左上角有个折叠的按钮,类似掘金的代码块,效果如下:

我们通过 rehypeviewerEffect 来实现以上效果,再通过 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-headerconst 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 端也运用了多种新颖的技术与功能,如:沉浸式刷题、海报生成、消息系统、用户编辑器内支持调整图片大小等,后面我还会持续给大家分享面试鸭中某些功能的实现方式,


http://www.ppmy.cn/server/124653.html

相关文章

【linux】进度条

&#x1f525;个人主页&#xff1a;Quitecoder &#x1f525;专栏&#xff1a;linux笔记仓 目录 01.屏幕缓冲区换行&#xff08;LF, \n&#xff09;和回车&#xff08;CR, \r&#xff09;换行回车在屏幕缓冲区中的作用代码块1&#xff1a;代码块2&#xff1a; 02.进度条优化版…

2024年7天自学网络安全(黑客技术)进阶手册。

&#x1f91f; 基于入门网络安全/黑客打造的&#xff1a;&#x1f449;黑客&网络安全入门&进阶学习资源包 前言 什么是网络安全 网络安全可以基于攻击和防御视角来分类&#xff0c;我们经常听到的 “红队”、“渗透测试” 等就是研究攻击技术&#xff0c;而“蓝队”、…

PHP include和require的区别

1. 基本概念 include 和 require 是PHP中用于在当前文件中包含&#xff08;或插入&#xff09;另一个文件内容的两个语句。它们的主要目的是代码复用&#xff0c;通过包含&#xff08;或引用&#xff09;外部文件的方式&#xff0c;使得PHP代码更加模块化和易于管理。然而&…

python中的assert语句

1.什么是assert 程序运行过程中,所有变量的当前值组合构成了“状态“; 每执行一段程序,状态就发生变化;如果程序有逻辑错误的bug,必定在处变量值的组合不符合预期,处于错误状态; 将对变量的预期写为断言,可以定位复杂的逻辑错误。 语法: assert <表达式> [, …

使用Postman工具接口测试

文章目录 一、接口1.1 接口的概念1.2 接口的类型 二、接口测试2.1 概念2.2 原理2.3 特点 三、HTTP协议3.1 http协议简介3.2 URL格式3.3 HTTP请求3.3.1 请求行3.3.2 请求头3.3.3 请求体 3.4 HTTP响应3.4.1 状态行3.4.2 响应头3.4.3 响应体 3.4 传统风格接口3.5 RESTful风格接口 …

python --qt5(webview)/防多开/套壳网页/多次点击激活旧窗口

pyqtwebengine5.12 PyQt55.12class MyWindow(QMainWindow):def __init__(self):super(MyWindow, self).__init__()self.browser QWebEngineView(self) # 如果不写self则新生成一个窗口self.browser.setWindowTitle(技术领域占比分析)self.browser.setWindowIcon(QIcon(LOGO_P…

关于 SQL 的 JOIN 操作

关于 SQL 的 JOIN 操作 在关系型数据库中&#xff0c;数据通常分布在多个表中。为了进行有效的数据检索&#xff0c;我们需要从不同的表中组合数据&#xff0c;这时就需要使用 JOIN 操作。本文将深入探讨 SQL 中不同类型的 JOIN 及其用法&#xff0c;以帮助你在数据库查询中更…

Nginx反向代理配置支持websocket

一、官方文档 WebSocket proxying 为了将客户端和服务器之间的连接从HTTP/1.1转换为WebSocket&#xff0c;使用了HTTP/1.1中可用的协议切换机制&#xff08;RFC 2616: Hypertext Transfer Protocol – HTTP/1.1&#xff09;。 然而&#xff0c;这里有一个微妙之处:由于“升级”…