JavaScript markdown 編輯器實現雙屏同步滾動

前言

由於一直在使用 markdown 編輯器寫技術文章,所以對於編寫體驗很敏感。我發現各大社區的 markdown 編輯器基本都有同步滾動功能。隻不過有些做得好,有些做得馬馬虎虎。出於好奇,我就打算自己親自實現一下這個功能。

思考瞭一段時間,最後想出來瞭三種方案:

  • 百分比滾動
  • 雙屏同時渲染占用面積大的元素
  • 每一行的元素都賦上一個索引,根據索引來精確同步每一行的滾動高度

百分比滾動

假設現在正在滾動 a 屏,那 a 屏的滾動百分比計算方式為:a 屏的滾動高度 / a 屏的內容總高度,用代碼表示 a.scrollTop / a.scrollHeight。當滾動 a 屏時,需要手動同步 b 屏的滾動高度,也就是根據 a 屏的滾動百分比算出 b 屏的滾動高度:

a.onscroll = () => {
	b.scrollTo({ top: a.scrollTop / a.scrollHeight * b.scrollHeight })
}

原理就是這麼簡單,可惜實現效果不太好。

從上面的動圖可以看出,當我在第二個大標題處停留的時候,左右雙屏的內容是同步的。但當我滾動到第三個大標題時,左右雙屏的內容高度已經差瞭將近 300 像素瞭。所以說這個方案勉勉強強能用吧,聊勝於無。

雙屏同時渲染占用面積大的元素

雙屏內容高度不一致,是因為 markdown 同一個元素渲染後的高度和渲染前會有差別。例如一個圖片,用 markdown 寫就一行代碼的事,但渲染出來的圖片有大有小,高度幾十、幾百像素的都有。如果 markdown 的圖片代碼雙屏同時渲染,倒是能解決這個問題。

但是除瞭圖片仍然有不少元素渲染前後的高度是有差距的,雖然沒有圖片這麼誇張。譬如 h1 h2 這種,當文章內容越長,這種小差異帶來的問題會越來越大,導致雙屏內容高度的差距也會越來越大。所以說這種方案也不是很靠譜。

賦上一個索引

每一行的元素都賦上一個索引,根據索引來精確精確同步每一行的滾動高度

之前兩個方案都屬於勉強能用,不夠好。現在這個第三方案就比前面兩個強多瞭,幾乎能做到精確同步每一行的內容。具體怎麼做呢?

第一步,監聽 markdown 編輯框的內容變化,為每一個元素賦上一個索引,空行空文本除外。

當把編輯框的 HTML 傳給右邊的框渲染時,需要把 data-index 賦值給渲染後的元素。這樣就能通過 data-index 精確定位渲染前後的同一元素瞭。

第二步,根據 a 屏的元素滾動高度計算 b 屏上同一索引的元素滾動高度

在 a 屏進行滾動時,需要從上到下遍歷 a 屏的所有元素,並且找到第一個在屏幕內的元素。找到第一個在屏幕內的元素 這句話的意思是因為在滾動過程中,有些元素會因為滾動跑到屏幕外面(原來在屏幕內,滾動到屏幕外),這些元素我們是不需要計算的。

判斷一個元素是否在屏幕內:

// dom 是否在屏幕內
function isInScreen(dom) {
    const { top, bottom } = dom.getBoundingClientRect()
    return bottom >= 0 && top < window.innerHeight
}

除瞭判斷元素是否在屏幕內,還需要判斷這個元素在屏幕內的部分占整個元素高度的百分比。譬如說一個圖片的 markdown 字符串,由於滾動的原因,導致一半在屏幕內,一半在屏幕外。為瞭精確同步,那麼渲染後的圖片也必須有一半在屏幕內一半在屏幕外。

 計算元素在屏幕內的百分比代碼:

// dom 在當前屏幕展示內容的百分比
function percentOfdomInScreen(dom) {
	// 已經通過另一個函數 isInScreen() 確定瞭這個 dom 在屏幕內,所以隻需要計算它在屏幕內的百分比,而不需要考慮它是否在屏幕外
    const { height, bottom } = dom.getBoundingClientRect()
    if (bottom <= 0) return 0 // 不在屏幕內
    if (bottom >= height) return 1 // 完全在屏幕內
    return bottom / height // 部分在屏幕內
}

現在我們就可以從上到下遍歷 a 屏的所有元素,找到第一個在屏幕內的元素瞭:

// scrollContainer 即上面說的 a 屏,ShowContainer 是 b 屏
const nodes = Array.from(scrollContainer.children)
for (const node of nodes) {
    // 從上往下遍歷,找到第一個在屏幕內的元素
    if (isInScreen(node) && percentOfdomInScreen(node) >= 0) {
        const index = node.dataset.index
        // 根據滾動元素的索引,找到它在渲染框中對應的元素
        const dom = ShowContainer.querySelector(`[data-index="${index}"]`)

		// 獲取滾動元素在 a 屏中展示的內容百分比
		const percent = percentOfdomInScreen(node)
		// 計算這個對等元素在 b 屏中距離容器頂部的高度
        const heightToTop = getHeightToTop(dom)
        // 根據 percent 算出對等元素在 b 屏中需要隱藏的高度
        const domNeedHideHeight = dom.offsetHeight * (1 - percent)
		// scrollTo({ top: heightToTop }) 會把對等元素滾動到在 b 屏中恰好完全展示整個元素的位置
		// 然後再滾動它需要隱藏的高度 domNeedHideHeight,組合起來就是 scrollTo({ top: heightToTop + domNeedHideHeight })
        ShowContainer.scrollTo({ top: heightToTop + domNeedHideHeight })
        break
    }
}

從動圖來看,目前已經做到行內容的精確同步瞭。

踩坑

有一些元素渲染後會變成嵌套元素,例如表格 table,渲染後的內容層級為:

<table>
	<tbody>
		<tr>
			<td></td>
		</tr>
	</tbody>
</table>

按照目前的渲染邏輯,假如我寫瞭個表格:

|1|b|
...

那麼 |1|b| 上的 data-index 會對應到 table 上。

那這就會有個 bug,當 |1|b| 滾動到 50% 的時候,整個 table 也會滾動到 50%。

這個現象如下圖所示:

這和我們相要的效果不一樣。a 屏連一行的內容都沒滾完,b 屏整個內容已經滾動到一半瞭。

所以像這種嵌套的元素,在打 data-index 標記時,要把它打到真正的內容上。用表格 table 來做示例,就得把 data-index 的標記打在 tr 上。

這樣一來,同步滾動就正常瞭。同理,其他的嵌套元素也一樣(譬如 ul ol)。

到此這篇關於JavaScript markdown 編輯器實現雙屏同步滾動的文章就介紹到這瞭,更多相關JS markdown 雙屏同步滾動內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: