如何實現一個能精確同步滾動的Markdown編輯器

語言: CN / TW / HK

簡介

隨著 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 ,具體實現上有點差異,可能還有其他實現方式,歡迎留言探討。

線上 demohttps://wanglin2.github.io/markdown_editor_sync_scroll_demo/

原始碼倉庫: https://github.com/wanglin2/markdown_editor_sync_scroll_demo