乐趣区

关于前端:markdown-编辑器实现双屏同步滚动

因为始终在应用 markdown 编辑器写技术文章,所以对于编写体验很敏感。我发现各大社区的 markdown 编辑器根本都有同步滚动性能。只不过有些做得好,有些做得随随便便。出于好奇,我就打算本人亲自实现一下这个性能。

思考了一段时间,最初想进去了三种计划:

  1. 百分比滚动
  2. 双屏同时渲染占用面积大的元素
  3. 每一行的元素都赋上一个索引,依据索引来准确同步每一行的滚动高度

百分比滚动

假如当初正在滚动 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)。

总结

残缺的代码我曾经放在 github 上了:

  • markdown-editor-sync-scroll-demo

还有在线 DEMO:

  • demo1
  • demo2
  • demo3
  • demo4
  • demo5
  • demo6

如果在线 DEMO 比较慢,能够克隆我的项目后间接关上 html 文件拜访。

退出移动版