做了一个 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 相关依赖

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">
      <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. 完整流程如下:

  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。

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, "&lt;").replace(/>/g, "&gt;");
          }
        });
      }),
  };
}

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

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

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

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值