如何实现一个可以精准同步滚动的Markdown编辑器
介绍随着Markdown越来越流行,Markdown编辑器也越来越多。除了所见即所得的实时预览编辑器,其他的Markdown编辑器通常会将源代码和预览分两栏显示,像这样:这种方式一般都有同步滚动的功能,比如当编辑区域滚动时,预览区域会随之滚动,反之亦然,方便两边对比查看。如果你在多个平台上使用过Markdown编辑器,你可能会发现有些平台的编辑器滚动非常准确,比如掘金、segmentfault、CSDN等,而有些平台的编辑器在图片很多的时候会两边同步滚动。偏差会比较大,比如开源中国(底层使用开源的editor.md),51CTO等。另外还有少数平台连同步滚动的功能都没有(再见).不精确的同步滚动实现起来比较简单,只需要遵循一个等式://滚动距离与总可滚动距离之比等于scrollHeight-previewArea.clientHeight)那么如何让同步滚动更加精准,可以参考bytemd,实现的核心是使用unified来预测详细信息,看下面的分解。unified简介Unified是一个使用语法树对文本内容进行解析、检查、转换和序列化的接口,可以处理Markdown、HTML和自然语言。它是一个库,作为一个独立的执行接口,负责执行者的角色,调用其生态相关的插件来完成特定的任务。同时,统一也代表着一种生态。要完成上述文字处理任务,需要配合其生态中的各种插件。截至目前,其生态内已拥有超过300款插件!鉴于数量众多,很容易在其庞大的生态中迷失方向,可谓劝化生态。Unified主要有四个生态:remark、rehype、retext、redot。这四个生态系统都有自己的生态系统。此外,它们还包括一些处理语法树的工具和其他构造相关的工具。统一执行流程大家都应该很熟悉,分为三个阶段:1.Parse将输入解析成语法树,mdast负责定义规范,另外创建remark、rehype等处理器。2、将上一步Transform生成的语法树传递给各个插件进行修改、检查、转换等工作。3.Stringify这一步将从处理过的语法树中重新生成文本内容。unified的独特之处在于它允许在一个处理流程中实现不同格式之间的转换,因此可以满足我们文章的需求,即将Markdown语法转换为HTML语法,我们将在其生态中使用remark(parseMarkdown),重新炒作(解析HTML)。具体使用remark生态下的remark-parse插件,将输入的Markdown文本转化为Markdown语法树,再使用remark-rehype桥接插件,将Markdown语法树转化为HTML语法树,最后使用rehype-stringify插件将HTML语法树转换为HTML字符串。构建基本结构本文项目是使用Vue3构建的。对于编辑器,我们使用CodeMirror,对于MarkdowntoHTML,我们使用上一节介绍的统一,并安装相关依赖:npmicodemirrorunifiedremark-parseremark-rehyperehype-stringify然后基本结构和逻辑是很简单,模板部分:
js部分:import{onMounted,ref}from"vue";importCodeMirrorfrom"codemirror";import"codemirror/mode/markdown/markdown.js";import"codemirror/lib/codemirror.css";import{unified}from"unified";importremarkParsefrom"remark-parse";importremarkRehypefrom"remark-rehype";importrehypeStringifyfrom"rehype-stringify";//CodeMirroreditorinstanceleteditor=null;//编辑区容器节点consteditorArea=ref(null);//预览区容器节点constpreviewArea=ref(null);//markdow从n转换而来的html字符串consthtmlStr=ref("");//编辑器文本改变后转换constonChange=(instance)=>{unified().use(remarkParse)//将markdown转换为语法树.use(remarkRehype)//将markdown语法树转换成html语法树,转换后可以使用rehype相关插件。use(rehypeStringify)//将html语法树转换为htmlstring.process(instance.doc.getValue())//输入编辑器的文本内容。then((file)=>{//将转换后的html插入预览区节点htmlStr.value=String(file);},(error)=>{throwerror;});};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语法树数据:lettreeData=null//自定义插件,获取HTML语法树constcustomPlugin=()=>(树,文件)=>{console.log(树);treeData=tree;//保存到treeData变量};unified().use(remarkParse).use(remarkRehype).use(customPlugin)//我们的插件在remarkRehype插件之后使用.use(rehypeStringify)//...看在输出处:接下来我们监听编辑区的滚动事件,在事件回调函数中打印预览区的语法树数据和生成的DOM节点数据:editor.on("scroll",onEditorScroll);//编辑区滚动事件constonEditorScroll=()=>{computedPosition();};//计算位置信息constcomputedPosition=()=>{console.log(treeData,treeData.children.length);console.log(previewArea.value.childNodes,previewArea.value.childNodes.length);};打印结果:注意控制台输出的语法树的节点与实际的DOM节点是一一对应的当然,仅仅对应是不够的。DOM节点可以通过DOM相关属性获取其高度信息。对于语法树中的某个节点,我们还需要能够在编辑器中获取它的高度信息。这个可以依靠两点来实现,一是语法树提供了某个节点的定位信息:二是CodeMirror提供了获取某行高度的接口:所以我们可以获取到该节点的高度信息在通过某个节点的起始行查看CodeMirror文档,并测试:constcomputedPosition=()=>{console.log('----------------')treeData.children.forEach((child,index)=>{//如果节点类型不是element然后跳过if(child.type!=="element"){return;}letoffsetTop=editor.heightAtLine(child.position.start.line,'local');//设置local返回相对于编辑器本身的坐标,还有两个选项:window,pageconsole.log(child.children[0].value,offsetTop);});};可以看到第一个节点的offsetTop是80,为什么不是0呢。其实在上面CodeMirror文档的截图中有说明,返回的高度是行底部到文档顶部的距离,所以获取一行顶部的高度相当于获取上一行底部的高度,所以减去行数1:letoffsetTop=editor.heightAtLine(child.position.start.line-1,'local');在编辑区和预览区都可以获取到节点的高度之后,我们接下来就可以这样做了,当编辑区触发滚动后,先计算两个区域所有元素的高度信息,然后获取编辑区当前滚动的距离,找出当前滚动到哪个节点,因为两边的节点是一一对应的,所以可以在预览区找到对应节点的高度,最后letthepreviewareascrolltothisheight://新增两个变量保存节点的位置信息leteditorElementList=[];letpreviewElementList=[];constcomputedPosition=()=>{//获取预览区容器节点下的所有子节点letpreviewChildNodes=previewArea.value.childNodes;//清空数组editorElementList=[];预览元素列表=[];//遍历所有子节点treeData.children.forEach((child,index)=>{if(child.type!=="element"){return;}letoffsetTop=editor.heightAtLine(child.position.start.line-1,"local");//保存两侧节点的位置信息editorElementList.push(offsetTop);previewElementList.push(previewChildNodes[index].offsetTop);//预览的容器节点previewArea区域需要设置位置});};constonEditorScroll=()=>{computedPosition();//获取编辑器滚动信息leteditorScrollInfo=editor.getScrollInfo();//找出当前滚动的节点的索引letscrollElementIndex=null;for(leti=0;i
=0){//设置预览区域的滚动距离为对应节点的offsetToppreviewArea.value.scrollTop=previewElementList[scrollElementIndex];}};效果如下:修复节点内滚动不同步的问题,可以看到跨节点滚动更准确。但是如果一个节点的高度比较大,在节点右边滚动是不会同步滚动的:原因很简单,我们的同步滚动目前只精确到某个节点,只要滚动不超过节点,那么计算出来的scrollElementIndex是不变的,右边的滚动当然也不会变这个问题的解决方法也很简单。还记得文章开头介绍的非精确滚动原理吗?这里我们也可以这样计算:编辑区当前的滚动距离已知,当前滚动到的节点顶部距离文档顶部的距离也是已知的,那么可以计算出它们的差值,然后下一个节点的offsetTop值减去当前节点的offsetTop值就可以计算出当前节点的高度,那么差值与节点高度的比值也可以计算为:预览区对应的节点也是如此,它们的比例应该是相等的,所以等式如下:=(previewArea.value.scrollTop-previewElementList[scrollElementIndex])/(previewElementList[scrollElementIndex+1]-previewElementList[scrollElementIndex])计算previewArea.value.scrollTopa的值根据这个等式,最终代码:constonEditorScroll=()=>{computedPosition();让editorScrollInfo=editor.getScrollInfo();让scrollElementIndex=null;for(leti=0;i=0){//编辑区滚动距离与当前滚动到的节点的offsetTop的差值value与当前节点高度的比例//根据等比计算预览区域应该滚动到previewArea的位置.value.scrollTop=ratio*(previewElementList[scrollElementIndex+1]-previewElementList[scrollElementIndex])+previewElementList[scrollElementIndex];}};效果如下:修复两边不同时滚动到底部的问题。同步滚动基本上是非常准确的。但是还有一个小问题,就是当编辑区已经滚动到最后,而预览区还没有:这是合乎逻辑的,但不合理,所以当一侧滚动到最后时,我们让另一侧同样结束:constonEditorScroll=()=>{computedPosition();让editorScrollInfo=editor.getScrollInfo();让scrollElementIndex=null;//...//编辑区已经滚动到底部,所以预览区也直接滚动到底部previewArea.value.scrollHeight-previewArea.value.clientHeight;返回;}if(scrollElementIndex>=0){//...}}效果如下:改进预览区滚动时编辑区同步滚动最后我们改进一下预览区触发滚动的逻辑,编辑区跟随滚动,监听滚动预览区域中的事件:让previewScrollTop=previewArea.value.scrollTop;//找出当前滚动到的元素索引letscrollElementIndex=null;for(leti=0;i=previewArea.value.scrollHeight-previewArea.value.clientHeight){editor.scrollTo(0,editorScrollInfo.height-editorScrollInfo.clientHeight);返回;){letratio=(previewScrollTop-previewElementList[scrollElementIndex])/(previewElementList[scrollElementIndex+1]-previewElementList[scrollElementIndex]);让editorScrollTop=ratio*(editorElementList[scrollElementIndex+1]-editorElementList[scrollElementIndex])+editorElementList[scrollElementIndex];editor.scrollTo(0,和editorScroll}Top;逻辑基本相同),效果如下:问题又来了,我们的鼠标已经停止滚动了,但是还在继续滚动,原因也很简单,因为双方都绑定了滚动事件,所以互相触发跟随滚动,导致死循环,解决方法也很简单,我们设置一个变量记录当前触发滚动的是哪一侧,另一侧不执行回调逻辑:"htmlStr"@scroll="onPreviewScroll"@mouseenter="currentScrollArea='preview'"> letcurrentScrollArea=ref("");constonEditorScroll=()=>{if(currentScrollArea.value!=="编辑器”){返回;}//...}//预览区滚动事件constonPreviewScroll=()=>{if(currentScrollArea.value!=="preview"){返回;}//...}最后,我们添加了对表格和代码块的支持,并添加了主题样式,当当当,一个简单的Markdown编辑器诞生了:总结本文使用CodeMirror,统一实现了一个可以精确同步滚动的Markdown编辑器.这个想法来自bytemd。具体实现有点不同,可能还有其他实现方式。欢迎留言讨论在线demo:https://wanglin2.github.io/markdown_editor_sync_scroll_demo/源码仓库:https://github.com/wanglin2/markdown_editor_sync_scroll_demo