背景
最近在研究编辑器偶尔看到了ByteMD挺有意思的,于是产生了兴趣,便研究了一下源码,想解决一下下面的疑惑。
ByteMD源码地址: github.com/bytedance/b…
Demo地址: bytemd.js.org/playground/
通过本文可以主要能够揭开如下的疑惑。
-
如何实现左右两边同步滚动的?
-
如何设计的如此灵活,自定义插件的实现方式?
-
Svelet框架入门与探索。【已经作为独立文章】
前置知识
-
编辑器是基于 CodeMirror 来实现的。 CodeMirror教程
-
unified是一个使用语法树处理文本的接口。它支持remark (Markdown)、retext(自然语言)和rehype (HTML),并允许在格式之间进行处理。
如果不太了解的同学可以先去了解下。
自定义插件的实现方式?
unified是一个通过使用语法树来进行解析、检查、转换和序列化文本内容的接口,可以处理Markdown、HTML和自然语言。它作为一个独立的执行接口,负责执行器的角色,调用其生态上相关的插件完成具体任务。
周边生态中的插件已经有300+,可以说是异常的健壮。
ByteMD编辑器的,左侧编辑区域是一个Markdown编辑器,右边是预览区,用于显示生成的HTML文件。
中间的处理过程如下:
-
将MarkDown文本解析为 Markdown AST
-
Markdown AST 可以被一些必须的 remark plugins 进行操作
-
Markdown AST 被转化为 HTML AST
-
HTML AST 会进行安全化处理
-
HTML AST 会被一些 rehype plugins进行处理加工
-
HTML AST 被生成 HTML 字符串
-
HMTL文本被渲染后还能被一些插件进行操作
用官方的图进行表达如下:
OK,我们可以自定义一些插件(函数),这些插件会被注入在红色标记的节点中,在文本转化过程中会执行相应的插件逻辑,进而影响最终HTML的生成。
如何实现精确的同步滚动?
过程相当巧妙,思路是:
-
建立起 左侧编辑器 与 右侧预览页面 中的一级节点 在各自可滚动距离中所占的 位置比例。一一对应关系。
-
一侧滚动,通过滚动测计算出最顶行在左侧节点所占的比例, 计算出在另一侧对应节点间的比例。
-
最终计算出另一侧滚动的距离。
上面看上去会有点迷,下面是具体的核心逻辑代码:
- 计算左右两边顶层关键节点的在编辑器中位置(是个小于1数字)
无论是左边引起的滚动,还是右边引起的滚动,都会调用这个函数。
const updateBlockPositions = throttle(() => {
editPs = []
previewPs = []
// Get an {left, top, width, height, clientWidth, clientHeight} object that represents the current scroll position, the size of the scrollable area, and the size of the visible area (minus scrollbars).
const scrollInfo = editor.getScrollInfo()
/*
{
"left": 0, // 编辑器左边滚动
"top": 12, // 编辑器向上滚动
"height": 2090, // 编辑器内容总高度
"width": 584, // 宽度
"clientHeight": 347, //用户可见区的高度
"clientWidth": 584 //用户视图区宽度
}
*/
const body = previewEl.childNodes[0] // 获得预览的dom
if (!(body instanceof HTMLElement)) return
// hast是html语法抽象, 从preview的插件中取出来的
const leftNodes = hast.children.filter(
(v): v is Element => v.type === 'element'
)
// 实际的预览下的html
const rightNodes = [...body.childNodes].filter(
(v): v is HTMLElement => v instanceof HTMLElement
)
for (let i = 0; i < leftNodes.length; i++) { // 左边和右边一定是一样的
const leftNode = leftNodes[i]
const rightNode = rightNodes[i]
// if there is no position info, move to the next node
if (!leftNode.position) {
continue
}
// 已滚动距离与总的可滚动距离的比值相等
const left =
editor.heightAtLine(leftNode.position.start.line - 1, 'local') /
(scrollInfo.height - scrollInfo.clientHeight)
const right =
(rightNode.offsetTop - body.offsetTop) /
(previewEl.scrollHeight - previewEl.clientHeight)
if (left >= 1 || right >= 1) {
break
}
editPs.push(left)
previewPs.push(right)
}
// 同时都滚动到底部
editPs.push(1)
previewPs.push(1)
// console.log(editPs, previewPs);
}, 1000)
1.1 左侧编辑器节点获得是从hast中(html的ast),里面自身带有节点的位置信息,因此通过编辑器就可以获得。
const left = editor.heightAtLine(leftNode.position.start.line - 1, 'local') /
(scrollInfo.height - scrollInfo.clientHeight)
得到左侧 某个节点 在左侧编辑器中可滚动的百分比。
1.2 对于已经生成的HTML,节点获得方式
const rightNodes = [...body.childNodes].filter(
(v): v is HTMLElement => v instanceof HTMLElement
)
同样 得到 与左侧 某个节点 在 右侧预览界面中可滚动的百分比。
const right =
(rightNode.offsetTop - body.offsetTop) /
(previewEl.scrollHeight - previewEl.clientHeight)
1.3 最后同时滚动到底部
// 同时都滚动到底部
editPs.push(1)
previewPs.push(1)
这样我们得到了,同一个节点 在 分别在 左边和右边 在可滚动距离中的位置信息。
思考:位置的变化,是在编辑的时候触发的,为什么会在滚动时去计算呢?
- 左侧编辑器滚动引起的右侧滚动
const info = editor.getScrollInfo()
const leftRatio = info.top / (info.height - info.clientHeight) // 左侧当前的滚动比例
const startIndex = findStartIndex(leftRatio, editPs) // 找到Node的开始位置
// 算出来滚动距离
const rightRatio =
((leftRatio - editPs[startIndex]) *
(previewPs[startIndex + 1] - previewPs[startIndex])) /
(editPs[startIndex + 1] - editPs[startIndex]) +
previewPs[startIndex]
// const rightRatio = rightPs[startIndex]; // for testing
previewEl.scrollTo(
0,
rightRatio * (previewEl.scrollHeight - previewEl.clientHeight)
)
说明: 介绍之前需要先了解下 下面的两个函数。
const info = editor.getScrollInfo() // 是CodeMirror的API,调用后的结果如下
/*
{
"left": 0, // 编辑器左边滚动
"top": 12, // 编辑器向上滚动
"height": 2090, // 编辑器内容总高度
"width": 584, // 宽度
"clientHeight": 347, //用户可见区的高度
"clientWidth": 584 //用户视图区宽度
}
*/
// findStartIndex 判断当前行是落在哪个区间内了。
function findStartIndex(num: number, nums: number[]) {
let startIndex = nums.length - 2 // 因为是最后一个是同步到底
for (let i = 0; i < nums.length; i++) {
if (num < nums[i]) {
startIndex = i - 1
break
}
}
startIndex = Math.max(startIndex, 0) // ensure >= 0
return startIndex
}
核心步骤:
-
leftRatio: 表示 左侧编辑器最顶一行 在 编辑器可滚动距离的位置比例。
-
然后根据leftRatio 找到出最顶行所处的节点startIndex。
-
然后计算出rightRatio 方法如下:
-
开始行在节点中的位置比例 =(左侧顶行位置 – 所在节点的开始)/ (所在节点结束位置 – 所在节点开始位置)
-
右侧对应行的位置(rightRatio) = 开始行在节点中的位置比例 * (对应的预览节点的结束位置 – 对应的预览节点开始位置)+ 对应节点开始位置。 具体公式如下:
const rightRatio =
((leftRatio - editPs[startIndex]) *
(previewPs[startIndex + 1] - previewPs[startIndex])) /
(editPs[startIndex + 1] - editPs[startIndex]) +
previewPs[startIndex]
- 最后计算滚动距离rightRatio * (previewEl.scrollHeight – previewEl.clientHeight)
- 右侧编辑器滚动
currentBlockIndex = findStartIndex( // 最顶部的占位符号
previewEl.scrollTop / (previewEl.scrollHeight - previewEl.offsetHeight),
previewPs
)
// 这里怎么又算一遍呢
const rightRatio =
previewEl.scrollTop / (previewEl.scrollHeight - previewEl.clientHeight)
const startIndex = findStartIndex(rightRatio, previewPs)
const leftRatio =
((rightRatio - previewPs[startIndex]) *
(editPs[startIndex + 1] - editPs[startIndex])) /
(previewPs[startIndex + 1] - previewPs[startIndex]) +
editPs[startIndex]
if (isNaN(leftRatio)) {
return
}
const info = editor.getScrollInfo()
// 调用编辑器的滚动
editor.scrollTo(0, leftRatio * (info.height - info.clientHeight))
右侧引起左侧的滚动,思路和左侧是一致的。
小结
源码使用Svelte来写的,也不复杂,很适新手去学习, 并且双侧同步的滚动方法设计的很巧妙。