如何實現一個能精確同步滾動的Markdown編輯器
簡介
隨著 Markdown
越來越流行, Markdown
編輯器也越來越多,除去所見即所得的實時預覽編輯器外,通常其他 Markdown
編輯器都會採用原始碼和預覽雙欄顯示的方式,就像這樣:
這種方式一般會有一個同步滾動的功能,比如在編輯區域滾動時,預覽區域會隨著滾動,反之亦然,方便兩邊對照檢視,如果你用過多個平臺的 Markdown
編輯器的話可能會發現有的平臺編輯器同步滾動非常精確,比如掘金、 segmentfault
、 CSDN
等,而有的平臺編輯器當圖片比較多的情況下同步滾動兩邊會偏差會比較大,比如開源中國(底層使用的是開源的 editor.md )、 51CTO
等,另外還有少數平臺則連同步滾動的功能都沒有(再見)。
不精確的同步滾動實現起來比較簡單,遵循一個等式即可:
// 已滾動距離與總的可滾動距離的比值相等 editorArea.scrollTop / (editorArea.scrollHeight - editorArea.clientHeight) = previewArea.scrollTop / (previewArea.scrollHeight - previewArea.clientHeight)
那麼如何才能讓同步滾動精確一點呢,我們可以參考 bytemd ,實現的核心就是使用 unified ,預知詳細資訊,且看下文分解。
unified簡介
unified
是一個通過使用語法樹來進行解析、檢查、轉換和序列化文字內容的介面,可以處理 Markdown
、 HTML
和自然語言。它是一個庫,作為一個獨立的執行介面,負責執行器的角色,呼叫其生態上相關的外掛完成具體任務。同時 unified
也代表一個生態,要完成前面說的文字處理任務需要配合其生態下的各種外掛,截止到目前,它生態中的外掛已經有三百多個!鑑於數量實在太多,很容易迷失在它龐大的生態裡,可謂是勸退生態。
unified
主要有四個生態: remark 、 rehype 、 retext 、 redot ,這四個生態下又有各自的生態,此外還包括處理語法樹的一些工具、其他構建相關的工具。
unified
的執行流程說出來我們應該都比較熟悉,分為三個階段:
1. Parse
將輸入解析成語法樹, mdast 負責定義規範, remark
和 rehype
等處理器否則建立。
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
構建。
編輯器我們使用 CodeMirror , Markdown
轉 HTML
我們使用上一節介紹的 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
不同於其他 Markdown
轉 HTML
開源庫(比如 markdown-it 、 marked 、 showdown )的優點就顯示出來了,一是因為它基於 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); }); };
可以看到第一個節點的 offsetTop
為 80
,為什麼不是 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
編輯器就誕生了:
總結
本文通過使用 CodeMirror
和 unified
實現了一個能精確同步滾動的 Markdown
編輯器,思路來自於 bytemd
,具體實現上有點差異,可能還有其他實現方式,歡迎留言探討。
線上 demo
: https://wanglin2.github.io/markdown_editor_sync_scroll_demo/
原始碼倉庫: https://github.com/wanglin2/markdown_editor_sync_scroll_demo
- 35歲危機?內捲成程式設計師代名詞了…
- 線上文字實體抽取能力,助力應用解析海量文字資料
- 不買排名,不去SEO,如何做到登上谷歌搜尋首頁?
- HtmlParse:一款超輕量級的HTML檔案解析和爬取工具
- 五款當下超火熱的相親交友APP測評
- 盡一份孝心,為家人做一個老人防摔報警系統
- 作為軟體工程師,給年輕時的自己的建議(下)
- 技術分享| 淺談排程平臺設計
- 組態介面推陳出新:打造新一代再生水廠工藝二維組態系統
- 平頭哥 芯事訪談 | 全志科技CTO丁然:影片、AI市場爆發,RISC-V生態需要產業一起努力
- IDEA SSM Maven實現商品管理系統(超詳細SSM整合專案)
- 如何為迴歸測試選擇測試用例?
- 前端必學——函數語言程式設計(五)
- 40篇學完C語言——(第八篇)【指標陣列以及指向指標的指標】
- 焱融看|非結構化資料場景下,資料湖到底有多香?
- 低程式碼開發的未來~
- Docker容器:將帶UI的程式直接轉為Web應用,so easy
- PHP 基於 SW-X 框架,搭建WebSocket伺服器(二)
- 低程式碼開發的前後端聯調——APICloud Studio 3 API管理工具結合資料雲3.0使用教程
- 揭祕華為雲GaussDB(for Influx)最佳實踐:hint查詢