编辑器探索 - minimap 实现分析

有求职需求的小伙伴,可以传送带直达...

一、背景介绍

Web 编辑器是开发者在编写代码时必不可少的工具,本文将针对 monaco 编辑器的 dom 结构进行介绍并重点讲述其中 minimap 的实现原理。

二、编辑器的 dom 结构

先来看一下monaco 编辑器的基本dom 结构:

  • overflow-guard 编辑器的主要区域:
  • margin: 左侧的代码行号区域,上面列有代码行号,折叠代码图标以及加断点标记。
  • editor-scrollable: 主要代码工作区域。
  • scroll-decoration: 滚动阴影装饰窄条,当开始滚动时出现在顶部。
  • textarea:鼠标输入框,是一种可以自由控制光标位置的技巧,使用原生 textarea 光标能力在指定位置呈现光标闪烁状态。
  • overlayWidgets: 可以渲染任意内容小部件,例如编辑器内的查找框即是一个 Overlay Widget。
  • minimap: 右侧的缩略代码小地图。
  • overflowingContentWidgets :编辑器 hover 展示内容,如中文错误说明。
  • shadow-root-host:与右键相关的内容。

了解了 monaco 编辑器的基本 dom 结构之后,再来详细看一下其中 minimap 相关的内容。

三、Monaco editor 中 minimap 原理

3.1 minimap 简介

minimap 即表示代码的缩略图,一般置于编辑器右侧,可缩略展示整体代码结构,并支持一些拖动、点击等能力,实现代码的快速移动和定位。

  • 滚动:缩略图与编辑器内容进行同步滚动
  • 拖拽:缩略图中的滑块支持拖拽能力,拖拽时编辑器内容同步移动
  • 点击:缩略图的非滑块区域,通过点击操作定位到对应的代码位置,编辑器同步移动

minimap 的dom 结构如下所示:

xml 复制代码
<div data-mprt="8" class="minimap slider-mouseover">
  	<div class="minimap-shadow-hidden" ></div>
		<canvas></canvas>
    <canvas class="minimap-decorations-layer"></canvas>
		<div class="minimap-slider""></div>
</div>
  • minimap-shadow-hidden: 主要起UI装饰作用
  • canvas:绘制整个代码的缩略图
  • canvas:minimap-decorations-layer, 绘制语法错误的行,如图中标红的两行,起到装饰的作用
  • minimap-slider:minimap 上的半透明罩滑块,如图中灰色区域,表示当前代码在整个文档中的滚动位置,并提供相应的事件能力。

3.2 minimap 的绘制流程

了解了 minimap 的基本dom 结构之后,我们接下来介绍的是 minimap 的整体绘制流程。主要包含 代码缩略图、装饰器缩略图和半透明遮罩层的绘制过程。

3.2.1 绘制代码缩略图

  1. 触发渲染

每次更改内容触发代码缩略图的重新渲染,为了提高性能,monaco 只重新渲染更改部分内容,避免多次重复渲染。

ini 复制代码
// needed 为需要重新渲染的行数据
const [_dirtyY1, _dirtyY2, needed] = InnerMinimap._renderUntouchedLines(imageData, startLineNumber, endLineNumber, minimapLineHeight, this._lastRenderData);
、、、、、

// 循环所有行内容,只有更新内容的行才会进行重新渲染
for (let lineIndex = 0, lineCount = endLineNumber - startLineNumber + 1; lineIndex < lineCount; lineIndex++) {
  if (needed[lineIndex]) {
    InnerMinimap._renderLine(imageData, renderBackground, background.a, useLighterFont, renderMinimap, minimapCharWidth, tokensColorTracker, foregroundAlpha, charRenderer, dy, innerLinePadding, tabSize, lineInfo[lineIndex], fontScale, minimapLineHeight);
  }
  renderedLines[lineIndex] = new MinimapLine(dy);
}
  1. 渲染字符

代码缩略图主要是对主体编辑器内容进行映射,编辑器中承载的是各种字符,这里是采用了将各种字符映射为对应像素信息的方式:

因为生成缩略图需要耗费大量的计算资源(像素级计算),因此预先将 ascii 码 32 到 126 之间的字符的像素数据,按照两种尺寸(宽1px高2px,和宽2px高4px)提前准备到一个 map 里,使用时快速访问。

css 复制代码
export const prebakedMiniMaps = {
    1: once(() => decodeData('0000511D6300CF609C709645A78432005642574171487021003C451900274D35D762755E8B629C5BA856AF57BA649530C167D1512A272A3F6038604460398526BCA2A968DB6F8957C768BE5FBE2FB467CF5D8D5B795DC7625B5DFF50DE64C466DB2FC47CD860A65E9A2EB96CB54CE06DA763AB2EA26860524D3763536601005116008177A8705E53AB738E6A982F88BAA35B5F5B626D9C636B449B737E5B7B678598869A662F6B5B8542706C704C80736A607578685B70594A49715A4522E792')),
    2: once(() => decodeData('000000000000000055394F383D2800008B8B1F210002000081B1CBCBCC820000847AAF6B9AAF2119BE08B8881AD60000A44FD07DCCF107015338130C00000000385972265F390B406E2437634B4B48031B12B8A0847000001E15B29A402F0000000000004B33460B00007A752C2A0000000000004D3900000084394B82013400ABA5CFC7AD9C0302A45A3E5A98AB000089A43382D97900008BA54AA087A70A0248A6A7AE6DBE0000BF6F94987EA40A01A06DCFA7A7A9030496C32F77891D0000A99FB1A0AFA80603B29AB9CA75930D010C0948354D3900000C0948354F37460D0028BE673D8400000000AF9D7B6E00002B007AA8933400007AA642675C2700007984CFB9C3985B768772A8A6B7B20000CAAECAAFC4B700009F94A6009F840009D09F9BA4CA9C0000CC8FC76DC87F0000C991C472A2000000A894A48CA7B501079BA2C9C69BA20000B19A5D3FA89000005CA6009DA2960901B0A7F0669FB200009D009E00B7890000DAD0F5D092820000D294D4C48BD10000B5A7A4A3B1A50402CAB6CBA6A2000000B5A7A4A3B1A8044FCDADD19D9CB00000B7778F7B8AAE0803C9AB5D3F5D3F00009EA09EA0BAB006039EA0989A8C7900009B9EF4D6B7C00000A9A7816CACA80000ABAC84705D3F000096DA635CDC8C00006F486F266F263D4784006124097B00374F6D2D6D2D6D4A3A95872322000000030000000000008D8939130000000000002E22A5C9CBC70600AB25C0B5C9B400061A2DB04CA67001082AA6BEBEBFC606002321DACBC19E03087AA08B6768380000282FBAC0B8CA7A88AD25BBA5A29900004C396C5894A6000040485A6E356E9442A32CD17EADA70000B4237923628600003E2DE9C1D7B500002F25BBA5A2990000231DB6AFB4A804023025C0B5CAB588062B2CBDBEC0C706882435A75CA20000002326BD6A82A908048B4B9A5A668000002423A09CB4BB060025259C9D8A7900001C1FCAB2C7C700002A2A9387ABA200002626A4A47D6E9D14333163A0C87500004B6F9C2D643A257049364936493647358A34438355497F1A0000A24C1D590000D38DFFBDD4CD3126'))
};

每个基础字符对应的基础像素信息在 canvas 中渲染结果:基础信息可以理解为每一个字符对应的每个像素的亮度,通过各个像素的不同亮度来区分不同的字符特征。

其中 once 函数为一个公共的方法,保证传入的函数只会执行一次,并记录下原始函数的执行结果,之后再次调用该函数时,会直接返回上一次记录的结果,而不会再次执行原始函数。避免了性能浪费。

针对字符像素内容的计算,也只需要在开始时计算一次就可以了,后面可以直接应用结果。

ini 复制代码
export function once(fn) {
    const _this = this;
    let didCall = false;
    let result;
    return function () {
        if (didCall) {
            return result;
        }
        didCall = true;
        result = fn.apply(_this, arguments);
        return result;
    };
}

使用 MinimapCharRenderer 渲染每一行中每一个基础字符。

一个字符是一个矩形,通过循环这个矩形中的每一个像素,计算出输出图像中该像素的最终颜色值,最终得到整个字符内容的数据结构。这里每个像素的最终颜色值是读取实际代码中该字符的前景色、背景色以及缩略字符中每个像素数据中的亮度值,叠加计算而成。

ini 复制代码
renderChar(target, dx, dy, chCode, color, foregroundAlpha, backgroundColor, backgroundAlpha, fontScale, useLighterFont, force1pxHeight) {
  // 获取字符在map中的索引
  const charIndex = getCharIndex(chCode, fontScale);
  、、、、
  const dest = target.data;  
  、、、、
  for (let y = 0; y < renderHeight; y++) {
    let column = row;
    for (let x = 0; x < charWidth; x++) {
      // 将颜色信息叠加
      const c = (charData[sourceOffset++] / 255) * (foregroundAlpha / 255);
      dest[column++] = backgroundR + deltaR * c;
      dest[column++] = backgroundG + deltaG * c;
      dest[column++] = backgroundB + deltaB * c;
      dest[column++] = destAlpha;
    }
    row += destWidth;
  }
}

当渲染内容为中文或其他不在 map 预先设置范围内的字符时,getCharIndex()方法会将超出范围的索引映射回有效范围内的索引,即在较小的字体比例下,可以使用任何 ASCII 字符替代超出索引范围的字符。

kotlin 复制代码
// 较小的字体下,可以使用任意 ASCII 字符替代超出索引范围的字符
export const getCharIndex = (chCode, fontScale) => {
    chCode -= 32 /* START_CH_CODE */;
    if (chCode < 0 || chCode > 96 /* CHAR_COUNT */) {
        if (fontScale <= 2) {
            return (chCode + 96 /* CHAR_COUNT */) % 96 /* CHAR_COUNT */;
        }
        return 96 /* CHAR_COUNT */ - 1; // unknown symbol
    }
    return chCode;
};

3.2.2 绘制装饰器缩略图

装饰器缩略图即如下图所示标红区域内容内容:

  • 创建一个与 minimap 高度相同的画布(minimap-decorations-layer),并在其中绘制装饰器。
  • 获取当前可见区域内的装饰器信息,然后按照一定的优先级(zIndex)对这些装饰器进行排序。装饰内容主要包括选中行和报错行。
kotlin 复制代码
renderDecorations(layout) {
    if (this._renderDecorations) {
        this._renderDecorations = false;
        // 获取选取和装饰器相关信息,根据 z-index 优先级排序
        const selections = this._model.getSelections();
        selections.sort(Range.compareRangesUsingStarts);
        const decorations = this._model.getMinimapDecorationsInViewport(layout.startLineNumber, layout.endLineNumber);
        decorations.sort((a, b) => (a.options.zIndex || 0) - (b.options.zIndex || 0));
        、、、、
        // 绘制装饰器
        const highlightedLines = new ContiguousLineMap(layout.startLineNumber, layout.endLineNumber, false);
      	// 行装饰器
        this._renderSelectionLineHighlights(canvasContext, selections, highlightedLines, layout, lineHeight);
        this._renderDecorationsLineHighlights(canvasContext, decorations, highlightedLines, layout, lineHeight);
        const lineOffsetMap = new ContiguousLineMap(layout.startLineNumber, layout.endLineNumber, null);
      	// 行内选中或报错内容的装饰器
        this._renderSelectionsHighlights(canvasContext, selections, lineOffsetMap, layout, lineHeight, tabSize, characterWidth, canvasInnerWidth);
        this._renderDecorationsHighlights(canvasContext, decorations, lineOffsetMap, layout, lineHeight, tabSize, characterWidth, canvasInnerWidth);
    }
}

_renderDecorationsLineHighlights:循环处理绘制行装饰器。

  • 如果同一行已经有更高优先级的装饰生效,则不再处理这个装饰内容。
  • 每一次绘制一行的矩形
ini 复制代码
_renderDecorationsLineHighlights(canvasContext, decorations, highlightedLines, layout, lineHeight) {
        const highlightColors = new Map();
        // Loop backwards to hit first decorations with higher `zIndex`
        for (let i = decorations.length - 1; i >= 0; i--) {
            const decoration = decorations[i];
            const minimapOptions = decoration.options.minimap;

            const startLineNumber = Math.max(layout.startLineNumber, decoration.range.startLineNumber);
            const endLineNumber = Math.min(layout.endLineNumber, decoration.range.endLineNumber);
						、、、、
            const decorationColor = minimapOptions.getColor(this._theme.value);
						、、、、
            canvasContext.fillStyle = highlightColor;
            for (let line = startLineNumber; line <= endLineNumber; line++) {
              	// 同一行已经有更高优先级的装饰生效,则不再处理这个装饰内容
                if (highlightedLines.has(line)) {
                    continue;
                }
                highlightedLines.set(line, true);
                const y = (startLineNumber - layout.startLineNumber) * lineHeight;
              	// 装饰器是一个矩形
                canvasContext.fillRect(MINIMAP_GUTTER_WIDTH, y, canvasContext.canvas.width, lineHeight);
            }
        }
    }

3.2.3 绘制半透明罩*

半透明罩层相当于minimap 的滚动条。

kotlin 复制代码
this._sliderMouseDownListener = dom.addStandardDisposableListener(this._slider.domNode, 'mousedown', (e) => {
		// 处理拖动
     this._startSliderDragging(e.buttons, e.posx, e.posy, e.posy, this._lastRenderData.renderedLayout);
});

startMonitoring 是一个公共的监视鼠标事件的方法。其中使用截流方式对鼠标拖动事件进行处理。回调函数为下面的 handleMouseMove, 将代码区域滑动到指定位置

javascript 复制代码
_startSliderDragging(initialButtons, initialPosX, initialPosY, posy, initialSliderState) {
  			// 显示滑块
        this._slider.toggleClassName('active', true);
        const handleMouseMove = (posy, posx) => {
            、、、、
            this._model.setScrollTop(initialSliderState.getDesiredScrollTopFromDelta(mouseDelta));
        };
				、、、
        this._sliderMouseMoveMonitor.startMonitoring(this._slider.domNode, initialButtons, standardMouseMoveMerger, (mouseMoveData) => handleMouseMove(mouseMoveData.posy, mouseMoveData.posx), () => {
            this._slider.toggleClassName('active', false);
        });
    

dom.addStandardDisposableListener: 公共的DOM 节点添加事件监听器方法。

3.3 minimap 的事件

缩略图的拖动、滚动等事件触发后,会同时触发编辑器的滚动,编辑器内容的滚动也会触发缩略图中滑块的位置改变。

3.3.1 点击跳转

点击 minimap 实现代码跳转,监听其点击事件,得到鼠标在 minimap 中的偏移量,计算其对应行号,使用 revealLineNumber 方法将代码区域滚动到指定行。

ini 复制代码
this._mouseDownListener = dom.addStandardDisposableListener(this._domNode.domNode, 'mousedown', (e) => {
    e.preventDefault();
    // 计算偏移量
    const minimapLineHeight = this._model.options.minimapLineHeight;
    const internalOffsetY = (this._model.options.canvasInnerHeight / this._model.options.canvasOuterHeight) * e.browserEvent.offsetY;
    const lineIndex = Math.floor(internalOffsetY / minimapLineHeight);
  	// 过界点击即为最后一行
    lineNumber = Math.min(lineNumber, this._model.getLineCount());
    this._model.revealLineNumber(lineNumber);
});

3.3.2 拖动

点击半透明罩层事件,监听鼠标移动,计算鼠标在垂直方向上的偏移量,即当前垂直位置与初始垂直位置之间的差值,重新设置滚动条的滚动位置。

javascript 复制代码
_startSliderDragging(initialButtons, initialPosX, initialPosY, posy, initialSliderState) {
  			// 滑块活跃,颜色改变
        this._slider.toggleClassName('active', true);
        const handleMouseMove = (posy, posx) => {
            const mouseOrthogonalDelta = Math.abs(posx - initialPosX);
						....
            const mouseDelta = posy - initialPosY;
            this._model.setScrollTop(initialSliderState.getDesiredScrollTopFromDelta(mouseDelta));
        };
        if (posy !== initialPosY) {
            handleMouseMove(posy, initialPosX);
        }
  			// 监视全局鼠标移动事件,鼠标移动时触发handleMouseMove函数,
        this._sliderMouseMoveMonitor.startMonitoring(this._slider.domNode, initialButtons, standardMouseMoveMerger, (mouseMoveData) => handleMouseMove(mouseMoveData.posy, mouseMoveData.posx), () => {
            this._slider.toggleClassName('active', false);
        });
    }

四、其他实现 minimap 的方法

minimap 能力不仅可以应用在编辑器中,在网页中也可以使用。下面介绍几种在网页中实现 minimap 的方法。

4.1 其他编辑器 minimap 实现

常见的其他 web 编辑器如 code-mirror 和 ace-editor 本身均不支持 minimap 能力,需要用户自己进行扩展。

4.2 网页 minimap 实现

  1. 使用 iframe 承载html 内容
css 复制代码
# 这里获取整个document下的html
let html = document.documentElement.outerHTML
# 将html内容写入到目标iframe中
targetIframe.wirte(html)

通过在 iframe 上增加滑块及其他点击、滚动、拖拽等事件,可以实现网页的 minimap 效果,可以承载 html 上的任意内容,如实绘制。

  1. 网页 minimap 实现:CSS element() 函数

css element 函数可以将网站中的某部分当作图片渲染,但是这个属性目前只有 firfox 浏览器支持。我们可以简单看一下它的原理。

css 复制代码
<div id="minimap"></div>
<div id="article"> <!-- content --> </div>

#minimap {
  background: rgba(254,213,70,.1) -moz-element(#article) no-repeat center / contain;
  position: fixed; right: 10px; top: 10px; /* more style */
}

上面的代码实现了获取 article 中内容,并将其转换为图片,作为 minimap 的背景被渲染出来,利用这个特性,可以实现 minimap 的展示。

背景准备好后,可以在其顶部添加一个滑块,它将用于操作小地图滚动。并增加点击、滚动、拖拽等事件,实现整体效果。

  1. 其他网页端 minimap npm 包: pagemap , xivimap ,可以直接应用。

五、总结

本文主要介绍了 monaco-editor 的 dom 结构特征并重点讲述了其中 minimap 的实现过程。minimap 呈现了代码的缩略样式并提供了一些事件以快速定位代码。其主要依赖 canvas 实现绘制。同时介绍了一些在网页中实现 minimap 的方式。

六、参考资料

  1. www.jianshu.com/p/8016cde22...
  2. css-tricks.com/using-the-l...
  3. developer.mozilla.org/zh-CN/docs/...

七、往期回顾

团队招聘

我们是快手数平前端团队,主要负责快手大数据平台中从采集,加工,消费整条链路相关产品的前端建设工作,涉及到生产、分析、流量、AB、专题等多个领域,目标是建设更自助,更快速,更先进的数据产品。

来到这里你能接触到:

  1. 快手数据平台是如何通过先进技术支持起 EB 级别数据量的;
  2. 日均百亿级别的日志上报的埋点基础设施的是如何设计与迭代的;
  3. 超过百万代码量的独立项目带来的工程化挑战;
  4. 2D/3D可视化、在线编辑器、电子表格等细分领域的探索;
  5. 自研工具产品来提升团队效率,用数据来说话;
  6. ......

我们希望能你:

  1. 有坚实的计算机/前端基础,不只是 MVVM 工程师;
  2. 有一定的 B 端项目/可视化相关的经验更佳,或非常热爱;
  3. 能发现问题,分析问题,并解决问题;
  4. 敢于创新,善于沟通,时刻关注前沿技术;
  5. 【加分项】有一定产品/交互/设计 sense;
  6. 【加分项】开源项目/社区;

团队氛围:

  1. 来这里你能接触到大数据中各个领域的专家,大家都非常 nice,没有架子,随时坦诚沟通,互相学习;
  2. 技术很重要,业务更重要,定期的培训分享都少不了;
  3. 团队氛围融洽,就事论事,风清气正,拒绝勾心斗角;
  4. 团队年轻有活力,既要工作好,也要玩的好; 加入我们,一起做点好玩的!

投递邮箱

感兴趣的同学,欢迎投寄简历: 609413629@qq.com

相关推荐
LuciferHuang3 小时前
震惊!三万star开源项目竟有致命Bug?
前端·javascript·debug
GISer_Jing3 小时前
前端实习总结——案例与大纲
前端·javascript
天天进步20153 小时前
前端工程化:Webpack从入门到精通
前端·webpack·node.js
姑苏洛言4 小时前
编写产品需求文档:黄历日历小程序
前端·javascript·后端
知识分享小能手5 小时前
Vue3 学习教程,从入门到精通,使用 VSCode 开发 Vue3 的详细指南(3)
前端·javascript·vue.js·学习·前端框架·vue·vue3
姑苏洛言5 小时前
搭建一款结合传统黄历功能的日历小程序
前端·javascript·后端
hackchen5 小时前
Go与JS无缝协作:Goja引擎实战之错误处理最佳实践
开发语言·javascript·golang
你的人类朋友6 小时前
🤔什么时候用BFF架构?
前端·javascript·后端
知识分享小能手6 小时前
Bootstrap 5学习教程,从入门到精通,Bootstrap 5 表单验证语法知识点及案例代码(34)
前端·javascript·学习·typescript·bootstrap·html·css3