编辑器探索 - 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

相关推荐
gnip13 分钟前
包管理工具的发展
前端
前端工作日常1 小时前
H5 实时摄像头 + 麦克风:完整可运行 Demo 与深度拆解
前端·javascript
韩沛晓1 小时前
uniapp跨域怎么解决
前端·javascript·uni-app
前端工作日常1 小时前
以 Vue 项目为例串联eslint整个流程
前端·eslint
程序员鱼皮1 小时前
太香了!我连夜给项目加上了这套 Java 监控系统
java·前端·程序员
Rubin932 小时前
TS 相关
javascript
该用户已不存在2 小时前
这几款Rust工具,开发体验直线上升
前端·后端·rust
前端雾辰2 小时前
Uniapp APP 端实现 TCP Socket 通信(ZPL 打印实战)
前端
无羡仙2 小时前
虚拟列表:怎么显示大量数据不卡
前端·react.js
云水边2 小时前
前端网络性能优化
前端