如何实现一个能精确同步滚动的Markdown编辑器

1,526 阅读10分钟

简介

随着Markdown越来越流行,Markdown编辑器也越来越多,除去所见即所得的实时预览编辑器外,通常其他Markdown编辑器都会采用源代码和预览双栏显示的方式,就像这样:

这种方式一般会有一个同步滚动的功能,比如在编辑区域滚动时,预览区域会随着滚动,反之亦然,方便两边对照查看,如果你用过多个平台的Markdown编辑器的话可能会发现有的平台编辑器同步滚动非常精确,比如掘金、segmentfaultCSDN等,而有的平台编辑器当图片比较多的情况下同步滚动两边会偏差会比较大,比如开源中国(底层使用的是开源的 editor.md)、51CTO等,另外还有少数平台则连同步滚动的功能都没有(再见)。

不精确的同步滚动实现起来比较简单,遵循一个等式即可:

// 已滚动距离与总的可滚动距离的比值相等
editorArea.scrollTop / (editorArea.scrollHeight - editorArea.clientHeight) 
= 
previewArea.scrollTop / (previewArea.scrollHeight - previewArea.clientHeight)

那么如何才能让同步滚动精确一点呢,我们可以参考bytemd,实现的核心就是使用unified,预知详细信息,且看下文分解。

unified简介

unified是一个通过使用语法树来进行解析、检查、转换和序列化文本内容的接口,可以处理MarkdownHTML和自然语言。它是一个库,作为一个独立的执行接口,负责执行器的角色,调用其生态上相关的插件完成具体任务。同时unified也代表一个生态,要完成前面说的文本处理任务需要配合其生态下的各种插件,截止到目前,它生态中的插件已经有三百多个!鉴于数量实在太多,很容易迷失在它庞大的生态里,可谓是劝退生态。

unified主要有四个生态:remarkrehyperetextredot,这四个生态下又有各自的生态,此外还包括处理语法树的一些工具、其他构建相关的工具。

unified的执行流程说出来我们应该都比较熟悉,分为三个阶段:

1.Parse

将输入解析成语法树,mdast负责定义规范,remarkrehype等处理器否则创建。

2.Transform

上一步生成的语法树会被传递给各种插件,进行修改、检查、转换等工作。

3.Stringify

这一步会将处理后的语法树再重新生成文本内容。

unified的独特之处在于允许一个处理流程中进行不同格式之间的转换,所以能满足我们本文的需求,也就是将Markdown语法转换成HTML语法,我们会用到其生态中的remark(解析Markdown)、rehype(解析HTml)。

具体来说就是使用remark生态下的remark-parse插件来将输入的Markdown文本转换成Markdown语法树,然后使用remark-rehype桥接插件来将Markdown语法树转换成HTML语法树,最后使用rehype-stringify插件来将HTML语法树生成HTML字符串。

搭建基本结构

本文项目使用Vue3构建。

编辑器我们使用CodeMirrorMarkdownHTML我们使用上一节介绍的unified,安装一下相关依赖:

npm i codemirror unified remark-parse remark-rehype rehype-stringify

那么基本结构及逻辑就很简单了,模板部分:

<template>
  <div class="container">
    <div class="editorArea" ref="editorArea"></div>
    <div class="previewArea" ref="previewArea" v-html="htmlStr"></div>
  </div>
</template>

js部分:

import { onMounted, ref } from "vue";
import CodeMirror from "codemirror";
import "codemirror/mode/markdown/markdown.js";
import "codemirror/lib/codemirror.css";
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify";

// CodeMirror编辑器实例
let editor = null;
// 编辑区域容器节点
const editorArea = ref(null);
// 预览区域容器节点
const previewArea = ref(null);
// markdown转换成的html字符串
const htmlStr = ref("");

// 编辑器文本发生变化后进行转换工作
const onChange = (instance) => {
  unified()
    .use(remarkParse) // 将markdown转换成语法树
    .use(remarkRehype) // 将markdown语法树转换成html语法树,转换之后就可以使用rehype相关的插件
    .use(rehypeStringify) // 将html语法树转换成html字符串
    .process(instance.doc.getValue())// 输入编辑器的文本内容
    .then(
      (file) => {
        // 将转换后得到的html插入到预览区节点内
        htmlStr.value = String(file);
      },
      (error) => {
        throw error;
      }
    );
};

onMounted(() => {
  // 创建编辑器
  editor = CodeMirror(editorArea.value, {
    mode: "markdown",
    lineNumbers: true,
    lineWrapping: true,
  });
  // 监听编辑器文本修改事件
  editor.on("change", onChange);
});

监听到编辑器文本变化,就使用unified执行转换工作,效果如下:

图片名称

实现精确的同步滚动

基本实现原理

实现精确同步滚动的核心在于我们要能把编辑区域和预览区域两边的“节点”对应上,比如当编辑区域滚动到了一个一级标题处,我们要能知道在预览区域这个一级标题节点所在的位置,反之亦然。

预览区域的节点我们很容易获取到,因为就是普通的DOM节点,关键在于编辑区域的节点,编辑区域的节点是CodeMirror生成的,显然无法和预览区域的节点对应上,此时,unified不同于其他MarkdownHTML开源库(比如markdown-itmarkedshowdown)的优点就显示出来了,一是因为它基于AST,二是因为它是管道化,在不同插件之间流转的是AST树,所以我们可以写个插件来获取到这个语法树数据,另外预览区域的HTML是基于remark-rehype插件输出的HTML语法树生成的,所以这个HTML语法树显然是可以和预览区域的实际节点对应上的,这样,只要我们把自定义的插件插入到remark-rehype之后即可获取到HTML语法树数据:

let treeData = null
// 自定义插件,获取HTML语法树
const customPlugin = () => (tree, file) => {
  console.log(tree);
  treeData = tree;// 保存到treeData变量上
};
unified()
    .use(remarkParse)
    .use(remarkRehype)
    .use(customPlugin)// 我们的插件在remarkRehype插件之后使用
    .use(rehypeStringify)
	// ...

看一下输出结果:

图片名称

接下来我们监听一下编辑区域的滚动事件,并在事件回调函数里打印一下语法树数据以及生成的预览区域的DOM节点数据:

editor.on("scroll", onEditorScroll);

// 编辑区域的滚动事件
const onEditorScroll = () => {
  computedPosition();
};

// 计算位置信息
const computedPosition = () => {
  console.log(treeData, treeData.children.length);
  console.log(
    previewArea.value.childNodes,
    previewArea.value.childNodes.length
  );
};

打印结果:

图片名称

注意看控制台输出的语法树的节点和实际的DOM节点是一一对应的。

当然仅仅对应还不够,DOM节点能通过DOM相关属性获取到它的高度信息,语法树的某个节点我们也需要能获取到它在编辑器中的高度信息,这个能实现依赖两点,一是语法树提供了某个节点的定位信息:

图片名称

二是CodeMirror提供了获取某一行高度的接口:

图片名称

所以我们能通过某个节点的起始行获取该节点在CodeMirror文档里的高度信息,测试一下:

const computedPosition = () => {
  console.log('---------------')
  treeData.children.forEach((child, index) => {
    // 如果节点类型不为element则跳过
    if (child.type !== "element") {
      return;
    }
    let offsetTop = editor.heightAtLine(child.position.start.line, 'local');// 设为local返回的坐标是相对于编辑器本身的,其他还有两个可选项:window、page
    console.log(child.children[0].value, offsetTop);
  });
};

图片名称

可以看到第一个节点的offsetTop80,为什么不是0呢,上面CodeMirror的文档截图里其实有说明,返回的高度是这一行的底部到文档顶部的距离,所以要获取某行顶部所在高度相当于获取上一行底部所在高度,所以将行数减1即可:

let offsetTop = editor.heightAtLine(child.position.start.line - 1, 'local');

图片名称

编辑区和预览区都能获取到节点的所在高度之后,接下来我们就可以这样做,当在编辑区域触发滚动后,先计算出两个区域的所有元素的所在高度信息,然后再获取编辑区域当前的滚动距离,求出当前具体滚动到了哪个节点内,因为两边的节点是一一对应的,所以可以求出预览区域对应节点的所在高度,最后让预览区域滚动到这个高度即可:

// 新增两个变量保存节点的位置信息
let editorElementList = [];
let previewElementList = [];

const computedPosition = () => {
  // 获取预览区域容器节点下的所有子节点
  let previewChildNodes = previewArea.value.childNodes;
  // 清空数组
  editorElementList = [];
  previewElementList = [];
  // 遍历所有子节点
  treeData.children.forEach((child, index) => {
    if (child.type !== "element") {
      return;
    }
    let offsetTop = editor.heightAtLine(child.position.start.line - 1, "local");
    // 保存两边节点的位置信息
    editorElementList.push(offsetTop);
    previewElementList.push(previewChildNodes[index].offsetTop); // 预览区域的容器节点previewArea需要设置定位
  });
};

const onEditorScroll = () => {
  computedPosition();
  // 获取编辑器滚动信息
  let editorScrollInfo = editor.getScrollInfo();
  // 找出当前滚动到的节点的索引
  let scrollElementIndex = null;
  for (let i = 0; i < editorElementList.length; i++) {
    if (editorScrollInfo.top < editorElementList[i]) {
      // 当前节点的offsetTop大于滚动的距离,相当于当前滚动到了前一个节点内
      scrollElementIndex = i - 1;
      break;
    }
  }
  if (scrollElementIndex >= 0) {
    // 设置预览区域的滚动距离为对应节点的offsetTop
    previewArea.value.scrollTop = previewElementList[scrollElementIndex];
  }
};

效果如下:

图片名称

修复节点内滚动不同步的问题

可以看到跨节点滚动已经比较精准了,但是如果一个节点高度比较大,那么在节点内滚动右侧是不会同步滚动的:

图片名称

原因很简单,我们的同步滚动目前只精确到某个节点,只要滚动没有超出该节点,那么计算出来的scrollElementIndex都是不变的,右侧的滚动当然就不会有变化。

解决这个问题的方法也很简单,还记得文章开头介绍非精准滚动的原理吗,这里我们也可以这么计算:编辑区域当前的滚动距离是已知的,当前滚动到的节点的顶部距离文档顶部的距离也是已知的,那么它们的差值就可以计算出来,然后使用下一个节点的offsetTop值减去当前节点的offsetTop值可以计算出当前节点的高度,那么这个差值和节点高度的比值也就可以计算出来:

图片名称

对于预览区域的对应节点来说也是一样,它们的比值应该是相等的,所以等式如下:

(editorScrollInfo.top - editorElementList[scrollElementIndex]) /
      (editorElementList[scrollElementIndex + 1] -
        editorElementList[scrollElementIndex]) 
= 
(previewArea.value.scrollTop - previewElementList[scrollElementIndex]) / (previewElementList[scrollElementIndex + 1] -
          previewElementList[scrollElementIndex])

根据这个等式计算出previewArea.value.scrollTop的值即可,最终代码:

const onEditorScroll = () => {
  computedPosition();
  let editorScrollInfo = editor.getScrollInfo();
  let scrollElementIndex = null;
  for (let i = 0; i < editorElementList.length; i++) {
    if (editorScrollInfo.top < editorElementList[i]) {
      scrollElementIndex = i - 1;
      break;
    }
  }
  if (scrollElementIndex >= 0) {
    // 编辑区域滚动距离和当前滚动到的节点的offsetTop的差值与当前节点高度的比值
    let ratio =
      (editorScrollInfo.top - editorElementList[scrollElementIndex]) /
      (editorElementList[scrollElementIndex + 1] -
        editorElementList[scrollElementIndex]);
    // 根据比值相等计算出预览区域应该滚动到的位置
    previewArea.value.scrollTop =
      ratio *
        (previewElementList[scrollElementIndex + 1] -
          previewElementList[scrollElementIndex]) +
      previewElementList[scrollElementIndex];
  }
};

效果如下:

图片名称

修复两边没有同时滚动到底部的问题

同步滚动已经基本上很精确了,不过还有个小问题,就是当编辑区域已经滚动到底了,而预览区域没有:

图片名称

这是符合逻辑的,但是不符合情理,所以当一边滚动到底时我们让另一边也到底:

const onEditorScroll = () => {
  computedPosition();
  let editorScrollInfo = editor.getScrollInfo();
  let scrollElementIndex = null;
  // ...
  // 编辑区域已经滚动到底部,那么预览区域也直接滚动到底部
  if (
    editorScrollInfo.top >=
    editorScrollInfo.height - editorScrollInfo.clientHeight
  ) {
    previewArea.value.scrollTop =
      previewArea.value.scrollHeight - previewArea.value.clientHeight;
    return;
  }
  if (scrollElementIndex >= 0) {
      // ...
  }
}

效果如下:

图片名称

完善预览区滚动时编辑区同步滚动

最后让我们来完善一下在预览区域触发滚动,编辑区域跟随滚动的逻辑,监听一下预览区域的滚动事件:

<div class="previewArea" ref="previewArea" v-html="htmlStr" @scroll="onPreviewScroll"></div>
const onPreviewScroll = () => {
  computedPosition();
  let previewScrollTop = previewArea.value.scrollTop;
  // 找出当前滚动到元素索引
  let scrollElementIndex = null;
  for (let i = 0; i < previewElementList.length; i++) {
    if (previewScrollTop < previewElementList[i]) {
      scrollElementIndex = i - 1;
      break;
    }
  }
  // 已经滚动到底部
  if (
    previewScrollTop >=
    previewArea.value.scrollHeight - previewArea.value.clientHeight
  ) {
    let editorScrollInfo = editor.getScrollInfo();
    editor.scrollTo(0, editorScrollInfo.height - editorScrollInfo.clientHeight);
    return;
  }
  if (scrollElementIndex >= 0) {
    let ratio =
      (previewScrollTop - previewElementList[scrollElementIndex]) /
      (previewElementList[scrollElementIndex + 1] -
        previewElementList[scrollElementIndex]);
    let editorScrollTop =
      ratio *
        (editorElementList[scrollElementIndex + 1] -
          editorElementList[scrollElementIndex]) +
      editorElementList[scrollElementIndex];
    editor.scrollTo(0, editorScrollTop);
  }
};

逻辑基本是一样的,效果如下:

图片名称

问题又来了,我们鼠标已经停止滚动了,但是滚动却还在继续,原因也很简单,因为两边都绑定了滚动事件,所以互相触发跟随滚动,导致死循环,解决方式也很简单,我们设置一个变量来记录当前我们是在哪边触发滚动,另一边就不执行回调逻辑:

<div
     class="editorArea"
     ref="editorArea"
     @mouseenter="currentScrollArea = 'editor'"
     ></div>
<div
     class="previewArea"
     ref="previewArea"
     v-html="htmlStr"
     @scroll="onPreviewScroll"
     @mouseenter="currentScrollArea = 'preview'"
     ></div>
let currentScrollArea = ref("");

const onEditorScroll = () => {
  if (currentScrollArea.value !== "editor") {
    return;
  }
  // ...
}

// 预览区域的滚动事件
const onPreviewScroll = () => {
  if (currentScrollArea.value !== "preview") {
    return;
  }
  // ...
}

最后我们再对表格和代码块增加一下支持,以及增加上主题样式,当当当当,一个简单的Markdown编辑器就诞生了:

图片名称

总结

本文通过使用CodeMirrorunified实现了一个能精确同步滚动的Markdown编辑器,思路来自于bytemd,具体实现上有点差异,可能还有其他实现方式,欢迎留言探讨。

在线demowanglin2.github.io/markdown_ed…

源码仓库:github.com/wanglin2/ma…