领导说能不能咱也搞个像Vscode的智能提示...

8,605 阅读11分钟

标题党身份暴露,本文主要讲了web中如何实现一个轻量的编辑器,纯纯个人探索过程,如有大神欢迎探讨,轻喷,文末有效果演示

为什么要做?

本人就职于一家金融公司,IT小组就是为金融研究员们做一些工作提效的工具(话说回来做web的谁不是呢,工具人)。研究员们过去的日常工作都在 Excel上完成,繁琐低效,我们的任务就是把这件事搬到web上,从而也就有了“公式计算”的需求。

后端的Java兄弟支持了一套公式引擎,可以将用户输入的合法公式文本解析出来,去数据库提取数据运算后返回给前端。例如一些四则运算:

idx(10009) - idx(10010)

idx(10009) * 0.01

idx(10009) / 100

idx(10009) ^ 2

(idx(10009) - idx(10010)) /100

这表示寻找ID为xxxx的数据,进行运算,还有一些进阶的运算公式:

grow(idx(10009)) // 表示获取指定指标序列的环比增长率序列,即本期除以上期再减1
yoy(idx(10009)) // 获取指标序列的同比增长率序列,即本期除以去年同期再减1

前端之前就是提供一个textarea供用户输入,也不对用户输入内容做出校验,实际使用中用户输入很长很长的公式时,他自己都不确定是否完全符合语法故而无法计算,使用体验极差

和领导的想法不谋而合:

这要是像咱们程序员写代码有个智能提示就好了

开工!

1. 很明显是个富文本编辑器啊

是的,要支持用户输入文本,还要智能着色,前端来看就是需要把用户输入内容变成一段内联style样式的HTML文本。 考虑到扩展性,此处未考虑采用任何开源富文本编辑器,那么手撸一个富文本编辑器需要什么呢?

  1. 接受用户输入,HTML内天然想到TextArea,天然支持输入文本,文本长了还能换行。
  2. Textarea无法处理丰富的内联样式,内联样式必然是一段HTML才好。

根据以上两点,此时一个想法迅速产生,一个div和一个textarea做成完全重叠,让textarea接受用户输入,通过一系列转换后变成HTML段落,通过innerHTML方法放在div中表现丰富的样式。此时需要注意两点:消除文字叠加的丑陋效果,textarea和div的文字摞在一起显示太反人类了,textarea的CSS添加一条color: transparent;直接隐形,解决。由于样式导致的光标不对齐,div中由于有了丰富的样式,文字可能变胖变歪,总之和textarea的裸文本会逐渐不对齐,textarea中的光标可能看起来插在了某个字上...此处需要一个字体方面的考虑font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;设为等宽字体,这样每个字的横向空间都是相同的,后续实现出来确实没有出现光标奇怪的问题。

实现过程发现,textarea并不理想,因为一些内置默认样式(可能还有别的原因记不清了...),与div无法很好地对齐,最后采用了contenteditable div来接受用户输入。

为了使元素可编辑,你所要做的就是在html标签上设置 "contenteditable" 属性,它几乎支持所有的HTML元素。下面是一个简单的示例,创建一个 "contenteditable" 属性为 "true" 的div元素,用户就可以编辑其内容了。—— 引用自MDN Web Doc

<div contenteditable="true">
  This text can be edited by the user.
</div>

下文就将可编辑的div成为editdiv,负责展示样式的称为cssdiv

2. 把两个div内容变化关联起来

本文包含一些vue3代码,不过核心逻辑与框架无关。

首先在editdiv中监听input事件,拿到内容后一番处理使其包含样式,使用innerHTML赋值给cssdivcssdiv不需要做内容变化监听,完全被动接受内容即可。

3. 变漂亮

所谓的“一番处理”究竟是怎么处理呢?回头去看看公式的内容是不是很像程序语言的函数啊,函数名表示功能,括号接受参数,那么如果像IDE一样对文本做出代码高亮的效果,如果用户输入内容不符合语法规则,那么着色异常,一看就有问题。这个自己写太复杂了得不偿失,Google了一下代码高亮,除了highlight.js(下文简称hljs)基本没有别的答案,许多在线IDE都用它来实现代码高亮。 基于词法分析,hljs可以做到根据不同编程语言实现准确的代码着色,并给出一段包含样式的HTML文本,本文直接选择了JavaScript风格(其实大部分语言都行)。

// 监听editdiv的input事件
function formulaChange(e: InputEvent) {
    const el = formulaCodeRef.value!; // 这是cssdiv
    const text = (e.target as HTMLDivElement)!.innerText; // 拿到editdiv文本
    el.innerHTML = text; //先直接给到cssdiv
    hljs.highlightElement(el); // hljs对cssdiv进行DOM劫持,处理其内容后自动渲染好彩色的文本
    // 到这一步漂亮的文本已经出现了
    setSuggestions(e); // 实现代码智能提示功能
    nextTick(() => {
      //实时给出当前光标位置
      emit('updateEndOffset', window.getSelection()!.getRangeAt(0).endOffset);
    });
}

此处引入另外一个知识点,HTML5的冷门DOM:Pre

 pre元素表示预定义格式文本。在该元素中的文本通常按照原文件中的编排,以等宽字体的形式展现出来,文本中的空白符(比如空格和换行符)都会显示出来。(紧跟在 <pre> 开始标签后的换行符也会被省略) —— 引用自MDN Web Doc

简单讲就是pre标签适合表现一段有格式的文本:

<pre>
Text in a pre element
is displayed in a fixed-width
font, and it preserves
both      spaces and
line breaks
</pre>

渲染出来就是(当然不会自带黑色背景)

Text in a pre element
is displayed in a fixed-width
font, and it preserves
both      spaces and
line breaks

因此cssdiv也改用了pre标签,这也是hljs的要求,所以核心DOM代码如下:

<div class="relative overflow-visible">
    <div
      class="formula-editor focus:outline-blue-600 rounded-md"
      ref="formulaEditorRef"
      contenteditable
      @input="formulaChange"
      @blur="updateFormula"
    ></div>
    <!--你能看到的的文本就是pre里的,language-javascript告诉hljs使用javascript-->
    <pre class="language-javascript code rounded-sm" ref="formulaCodeRef"></pre>
  </div>

CSS:

// 这就是那个editdiv
.formula-editor {
    // 编辑器背景黑色,光标搞了个接近白色
    caret-color: #b8b8b8;
    position: absolute;
    overflow: hidden;
    // 支持竖向变长变短
    resize: vertical;
    width: 100%;
    height: 7.5rem;
    z-index: 9;
    padding: 4px 11px;
    font-size: 16px;
    line-height: 24px;
    color: transparent;
    white-space: wrap;
    word-wrap: break-word;
    word-break: break-all;
    font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
  }
// 这就是pre标签
  .code {
    padding: 4px 11px;
    position: absolute;
    //连续的空白符会被保留。在遇到换行符或者<br>元素,才会换行
    white-space: pre-wrap;
    //强制分割换行
    word-wrap: break-word;
    // 对于non-CJK (CJK 指中文/日文/韩文) 文本,可在任意字符间断行
    word-break: break-all;
    font-size: 16px;
    line-height: 24px;
    top: 0;
    text-align: left;
    width: 100%;
    // pre只负责漂亮,不要响应任何鼠标事件,很重要
    pointer-events: none;
  }

总之就是通过一些字体大小,行高,换行规则来使两个容器的内容保持相同格式,这样光标就不会出现在奇怪的位置,pre标签内容天然等宽,editdiv记得务必做成等宽字体。使用z-index: 9;来让editdiv在顶层接受鼠标键盘事件。pointer-events: none;让pre标签安安静静的做个美男子。

4. 猜猜想要的公式

重头戏智能提示来了。首先这里想到的就是预备一个数组,包含了所有的内部公式例如grow,sum``idx这些,拿到用户输入文本,去匹配数组,生成一个建议列表,用户使用Tab按键选择,回车确定。 这个建议列表当然是要挂一个DOM上去了:

// 获取自动补全单词列表DOM
  function setSuggestionsDOM(e: Ref<HTMLDivElement | undefined>) {
    suggestionsDOM.value = e.value!;
    suggestionsDOMDisplay = getComputedStyle(e.value!).getPropertyValue('display');
    suggestionsDOM.value.style.display = 'none';
  }

此处在节点渲染时就挂载上去,但通过display:none;先隐藏,同时保留下原来的display值,可能是flex,可能是block等等,之后要还原给DOM。

建议列表的实现效果是拿到字符‘s’或‘su’,就给出建议‘sum’和‘sum_acc’等正则匹配到的文本,但此处注意,用户选了智能提示的单词后,也会触发input事件,故而需要一个autoComplate(Boolean)来区分一下是用户手打字还是程序自动补全。在input事件中,根据当前光标位置向前倒数,直到匹配到非英文字符,差不多这就是一个待自动补全的残缺单词了。

那么问题来了,或许你也一早就想问,怎么让那个建议列表跟随光标呢,这里我也想到找一找浏览器的API,硬是没有...(如果你知道可以写在评论区),所及生生根据光标位置和字宽、DOM宽高等信息,计算出一个合理的位置,当然这都要得益于使用了等宽字体。此处当然是在每次触发input事件时计算。

function setSuggestions(e: InputEvent) {
    // 建议列表的DOM
    const sugDomStlye = suggestionsDOM.value!.style;
    // 识别为自动补全模式则return(不然无限循环了)
    if (autoComplate.value) {
      sugDomStlye.display = 'none';
      return;
    }
    // 获得当前光标位置
    endOffset = window.getSelection()!.getRangeAt(0).endOffset;
    // 拿到文本
    const text = (e.target as HTMLDivElement)!.innerText;
    // 括号和非英文字符不管,return
    if (['(', '[', '{'].includes(text.charAt(endOffset)) || /[^a-zA-Z]/i.test(e.data!)) {
      sugDomStlye.display = 'none';
      return;
    }
    // 获得待匹配的子字符串
    let str = '';
    startOffset = endOffset - 1;
    // 从光标位置往前倒数,匹配到字符串开头或非字母字符
    while (startOffset !== -1 && /[a-zA-Z]/i.test(text.charAt(startOffset))) {
      str = `${text.charAt(startOffset)}${str}`;
      startOffset -= 1;
    }
    // 此时就拿到了一个用户手敲的字符串,没有就return
    startOffset += 1;
    if (str.length === 0) {
      sugDomStlye.display = 'none';
      return;
    }
    // 去内置公式列表里匹配,简单粗暴使用字符串的include方法
    fnList.value = allFnToken.filter((s) => s.includes(str.trim()));
    if (fnList.value.length > 0) {
      //这一段就是计算光标相对于屏幕的位置
      const el = e.target as HTMLDivElement;
      const { x, y, width } = el.getBoundingClientRect();
      const singleLineLen = parseInt(width / 8.8);
      const left = (endOffset % singleLineLen) * 8.8 + 14 + x;
      const top = (parseInt(endOffset / singleLineLen) + 1) * 24 + y;
      Object.assign(sugDomStlye, {
        left: `${left}px`,
        top: `${top}px`,
        display: suggestionsDOMDisplay,
      });
      show.value = true;
      window.addEventListener('keypress', keyboardListener);
    } else {
      sugDomStlye.display = 'none';
      show.value = false;
      window.removeEventListener('keypress', keyboardListener);
    }
  }

当建议列表出现时,开始监听键盘事件,keyboardListener中实现了用户按下tab切换建议列表中的单词,按下回车执行自动补全。关于tab切换,这里需要了解一些关于浏览器键盘切换焦点的知识,重点是HTML的tabindex属性。

tabindex指示其元素是否可以聚焦,以及它是否/在何处参与顺序键盘导航(通常使用Tab键,因此得名)。它接受一个整数作为值,具有不同的结果,具体取决于整数的值 —— 引用自MDN Web Doc

  • tabindex=负值 (通常是tabindex=“-1”),表示元素是可聚焦的,但是不能通过键盘导航来访问到该元素,用JS做页面小组件内部键盘导航的时候非常有用。
  • tabindex="0" ,表示元素是可聚焦的,并且可以通过键盘导航来聚焦到该元素,它的相对顺序是当前处于的DOM结构来决定的。
  • tabindex=正值,表示元素是可聚焦的,并且可以通过键盘导航来访问到该元素;它的相对顺序按照tabindex 的数值递增而滞后获焦。如果多个元素拥有相同的 tabindex,它们的相对顺序按照他们在当前DOM中的先后顺序决定。

根据键盘序列导航的顺序,值为 0 、非法值、或者没有 tabindex 值的元素应该放置在 tabindex 值为正值的元素后面。 所以建议列表的关键也是加一个tabindex属性:

<span
    v-for="item in fnList"
    :key="item"
    :data-suggestion="item"
    tabIndex="0"
    class="pl-1 focus:bg-primary focus:text-white leading-4 text-gray-500"
    >{{ item }}
</span>

这样你就可以按tab让当前聚焦元素切换到这些建议列表了。

async function keyboardListener(e: KeyboardEvent) {
    // 获得当前聚焦元素,也就是用户按tab选的单词
    const activeElement = document.activeElement as HTMLElement;
    const str = activeElement.dataset.suggestion;
    // 响应一下回车键,注意要拦截默认事件,不然你将会插入一个回车换行符。
    if (str && e.key === 'Enter') {
      const dom = inputDOM.value!;
      const text = dom.innerText;
      const replaceText = fnMap[str].value;
      const str1 = text.substring(0, startOffset);
      const str2 = text.substring(endOffset);
      dom.innerText = `${str1}${replaceText}${str2}`;
      // 触发input原生事件
      const evt = document.createEvent('HTMLEvents');
      evt.initEvent('input', true, true);
      autoComplate.value = true;
      dom.dispatchEvent(evt);
      dom.focus();
      const focusDOM = window.getSelection()!.getRangeAt(0)!;
      // 设置光标位置
      focusDOM.setStart(dom.childNodes[0], startOffset + fnMap[str].endOffset);
      focusDOM.collapse(true);
      // 阻止输入回车字符
      e.preventDefault();
      autoComplate.value = false;
    }
  }

这里需要用createEventdispatchEvent去模拟一个input事件,把自动补全的文本添加到DOM中,你可能会想到直接innerText好了,不行,别忘了前面我们是监听input事件,才把用户输入文本同步到pre标签中,所以此处也要触发input事件。最后需要把光标位置设置在合理的位置,此处补全单词也会自动加个括号,光标应是出现括号中间更为便捷。

浏览器中并非是直接给出“光标”的概念,而是“选择对象”,也就是你用鼠标在文本段落中可以拉选一段文本,右键复制等等那个操作,focusDOM.setStart表示设置这个选择对象的起点,focusDOM.collapse(true)表示起点终点重合,看起来也就是修改了光标的位置。

以上,基本就实现了一个支持自动补全内置语法的编辑器,个人认为实现光标附近挂一个DOM的方式仍然过于笨拙,如有更好思路欢迎讨论。效果演示:

公式.gif