如何手搓一个实时渲染+双栏同步滚动的Markdown编辑器

1,950 阅读7分钟

在每一次写文档的时候,我都很好奇Markdown语法是如何渲染出来的以及怎么样实现一个Markdown编辑器? 所以我就用Vue(React也是一样的逻辑)手搓了一个简单的在线版的Markdown编辑器具备实时渲染、同步滚动、预览以及导出功能,如果你也好奇,欢迎你继续阅读。

PS:代码在这里

成品首页

image.png

需求分析

想必大家在使用Markdown编辑器的时候,已经见过各式各样的了;我这里一般使用的是两个:

  • VsCode的插件提供的Markdown功能
  • Typora

其中的双栏同步编辑器就是VsCode中的功能,如下:

www.alltoall.net_录屏2024-12-24_17.26.21_XQh0pZVIwi.gif

主要功能点就两个:

  • 实时渲染
  • 同步滚动

所以接下来,我们就来解决这两个问题,手搓一个编辑器出来

步骤

这里一共分为了以下三步:

  • 实现编辑区
  • 实现MD渲染
  • 同步滚动

那就依次来处理吧。

实现编辑区

首先就是需要实现一个编辑区了,如果你不考虑样式的问题并且这个编辑区的事件都由你自己来处理,那你可以直接搞一个div,给他加上contenteditable属性,你就可以得到一个可以编辑的div

如果你一直在这个框里面回车,你会发现样式错乱;并且采用这种方式还需要自己去处理保存事件、文本变动事件等等等

所以本次实现最后没有采用这种方式。


最后采用的是monaco-editor这个插件,也就是VsCode的编辑器;当然这里你可以使用任意的编辑器,只要能达到目的就行。

image.png

这个插件的基本用法是:

  • 引入插件
  • 配置参数
  • 指定一个渲染载体给这个实例
const codeRef = ref<HTMLDivElement | null>(null);
let editorInstance: monaco.editor.IStandaloneCodeEditor | null = null;
// 初始化编辑器
const initEditor = () => {
  if (!codeRef.value) return;

  editorInstance = monaco.editor.create(codeRef.value, {
    value: props.fileCode,
    language: 'markdown',
    theme: 'vs',
  });

  // 监听代码变化事件
  editorInstance.onDidChangeModelContent(() => {
    const value = editorInstance?.getValue() || '';
    emit('update:fileCode', value);
  });

  // 监听滚动事件
  editorInstance.onDidScrollChange(() => {
    if (isSyncing) return;
    isSyncing = true;
    if (editorInstance && viewRef.value) {
      const scrollTop = editorInstance?.getScrollTop();
      const scrollHeight = editorInstance?.getScrollHeight();
      const clientHeight = editorInstance?.getDomNode()?.clientHeight || 1; // 可视区域高度
      const scrollPercentage = scrollTop / (scrollHeight - clientHeight); // 计算滚动百分比
      viewRef.value.scrollTop = scrollPercentage * (viewRef.value.scrollHeight - viewRef.value.clientHeight);
    }
    isSyncing = false;
  });
};

// 销毁编辑器实例
const destroyEditor = () => {
  editorInstance?.dispose();
};

// 设置编辑器内容
const setEditorValue = (value: string) => {
  editorInstance?.setValue(value);
};

实现MD渲染

编辑区实现之后,接下来要做的就是把MD语法的文本渲染成MD样式;在这里我采用的是remark来进行的渲染工作;这个库比较优秀的是:它采用的是插件的思想,可以自由选择需要的特性。

image.png

这里有一个官方用法示例:

import rehypeStringify from 'rehype-stringify'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import {unified} from 'unified'

const processor = unified()
  .use(remarkParse)
  .use(remarkRehype, {allowDangerousHtml: true})
  .use(rehypeStringify)

const value = '# Pluto\n\n**Pluto** (minor-planet designation: *13434…'
const file = await processor.process(value)

console.log(String(file))

然后你用的是Vue你就用v-htmlReact你就用dangerouslySetInnerHTML,这样就能实现渲染。

同步滚动

ok,渲染MD也搞定了,那么接下来就是准备实现两侧的同步滚动了。想必你读到这句话,你的脑子里面就已经有了解决方案;是的,原理就是控制两侧的scrollTop

假如你想到了这一点,那么还有一个要解决的问题就是,如何控制同时触发,也就是当把左侧滚动控制右侧和右侧滚动控制左侧的逻辑都写完之后,如何避免循环触发?想必你也能一下就想到,给一个控制变量就行了。

那么前面两个问题都比较简单,在你实现之后,你会发现,假如两边高度不一致,那么两侧的滚动的内容不一致


所以我这里是想讲,同步滚动的核心在于按比例滚动,这里我给出项目中的代码,注意看注释的部分:

// 监听滚动事件
  editorInstance.onDidScrollChange(() => {
    if (isSyncing) return;
    isSyncing = true;
    if (editorInstance && viewRef.value) {
      const scrollTop = editorInstance?.getScrollTop();
      const scrollHeight = editorInstance?.getScrollHeight();
      const clientHeight = editorInstance?.getDomNode()?.clientHeight || 1; // 可视区域高度
      const scrollPercentage = scrollTop / (scrollHeight - clientHeight); // 计算滚动百分比
      viewRef.value.scrollTop = scrollPercentage * (viewRef.value.scrollHeight - viewRef.value.clientHeight);
    }
    isSyncing = false;
  });

装修一下

到了这里,你已经完成了一个编辑器最初始的功能了,实时渲染、同步滚动都被你完成了,想要让它好用一些还需要做一些工作。

样式

这里有两个地方需要处理:

  • 代码效果
  • 最终渲染的样式

上面的基础步骤渲染出来的最终样式中,代码是没有着色的,所以你需要给你的remark加一个插件:

import rehypeHighlight from 'rehype-highlight';

xxx.use(rehypeHighlight);

这样一来,你的代码就美观了;

image.png


那么这里还有一个待解决的就是渲染的最终样式,上面的步骤之后,你会发现渲染出来的效果很生硬;

解决方式很简单,引入一个现成的CSS就ok,这里我引入的是GitHub风格的CSS,使用它有两步:

  1. 引入文件
import 'github-markdown-css/github-markdown-light.css';
  1. 将要渲染的元素上加一个类名:markdown-body

这样的话,就完成了基础目标:

www.alltoall.net_录屏2024-12-24_15.58.27_ancNTsL7GK.gif

丰富一下

基础目标是实现了,但是这个基础版无法给人使用,得给他丰富一下,装饰一下门面以及增加一些方便用户使用的功能

界面设计

这里我参考了Arya - 在线 Markdown 编辑器的界面风格,所以现在主页面长这个样子:

image.png

增加了工具栏,从左到右依次是:

  • 语法提示:提示Markdown语法
  • 上传MD文件:读取本地文件进行处理
  • 清除编辑区:清除编辑区
  • 预览+导出:弹框预览,PDF导出+MD文件导出

语法提示

这里就是简单的使用一个侧边栏来进行Markdown语法提示,设计了一下展示的效果:

image.png

上传文件

这里的逻辑是:限制上传的文件类型为MD,然后读取文件内容,更新编辑器。

const handleFile = (e: Event) => {
  const file = (e.target as HTMLInputElement).files?.[0];
  Modal.confirm({
    title: '检测到有文件导入,是否覆盖当前内容?',
    icon: createVNode(InfoCircleFilled),
    content: createVNode('div', {style: 'color:red;'}, '请注意:点击确定会覆盖编辑区内容'),
    okText: '确认',
    cancelText: '取消',
    onOk() {
      file?.text().then((res) => {
        fileCode.value = res;
        panelRef.value?.setEditorValue(res);
        message.success('文件导入成功');
      });
    },
    onCancel() {
      message.info('已取消');
    },
  });
};

清除编辑区

这里的逻辑就是简单的清空内容。

const handleClose = () => {
  if (fileCode.value) {
    fileCode.value = '';
    panelRef.value?.setEditorValue('');
    message.success('面板清除成功');
  } else {
    message.warning('编辑区无内容,无需清除');
  }
};

预览+导出

这里预览的逻辑就是使用dialog来讲渲染结果呈现出来,跟之前的逻辑没有差异;所以这一部分,主要讲一下导出的逻辑。

image.png


  1. MD导出

这里的导出的逻辑是把内容转为blob对象:

const blob = new Blob([props.code], {type: 'text/markdown;charset=utf-8'});

后面我使用的file-saver插件进行的导出:

import {saveAs} from 'file-saver';

saveAs(blob, 'newMD.md');
  1. PDF导出

这里使用的是html2pdf.js前端插件进行的导出,主要逻辑如下:

const exportPDF = async () => {
  const options = {
    margin: [10, 10, 10, 10], // 设置边距,顺序为 [上, 左, 下, 右],单位为 mm
    filename: 'document.pdf', // 导出的文件名
    image: {type: 'jpeg', quality: 1.0}, // 图片格式和质量
    html2canvas: {
      scale: 3, // 提高导出清晰度(默认 1)
      useCORS: true, // 允许跨域加载图片
    },
    jsPDF: {
      unit: 'mm', // 单位('pt', 'mm', 'cm', 'in')
      format: 'a4', // 页面格式(如 'a3', 'a4', 'letter')
      orientation: 'portrait', // 页面方向:'portrait'(竖版)或 'landscape'(横版)
      pagebreak: {
        mode: ['avoid-all', 'css', 'legacy'], // 避免跨页
        before: '.page-break', // 在这些元素之前分页
        after: '.page-break', // 在这些元素之后分页
        avoid: 'p, h1, h2, h3, h4, h5, h6, table, pre, code, li', // 避免这些元素被分割
      },
    },
  };
  return html2pdf().from(preViewRef.value).set(options).save();
};

注意:这里有一个坑,看上面的配置的pagebreak属性,作用是避免内容断页;经过实测之后,其实还是会断页。因为没有接入后端,所以PDF导出存在瑕疵,如下所示:

Snipaste_2024-12-12_11-16-09.png

PS:自己实现的时候,可以接入后端来处理这个瑕疵。

总结

👏ok,以上就是如何手搓一个Markdown编辑器的主要步骤了,如果你感兴趣,欢迎你自己去动手去实现一下!也欢迎你使用我部署在GitHub的线上版本->点击这里

这里要说的一点是,文中对于如何使用GitHub的Page功能进行部署没有交代,我这里使用的gh-pages插件进行的处理,如果你clone了我的项目,运行deploy命令之前,请修改自己的GitHub地址

🍺 最后,如果这篇文章对你有帮助,也欢迎你点赞、收藏、转发

我是李仲轩,下一篇文章再见吧!👋