在输入框中支持语法高亮

8/7/2022, 9:01:49 PM

前言

语法高亮,是代码编辑器的重要功能,不仅可以有效提高代码可读性,还能在代码编译运行前做一些简单的语法校验和提示,降低运行出错的几率。

一般支持以下功能

  • 根据关键字显示不同的颜色和字体。

  • 对一些低级的语法错误能给出有效提示

搜索引擎搜索计算器,会有很多网页版的计算器,功能比较简单,支持一些预置函数,交互一般也是点选的方式,约束性较强,不允许用户自由输入,用户体验一般。而允许自由输入的不支持公式和运算符高亮,很容易产生低级的输入错误。

下面在公式计算器的输入框中实现语法高亮,包括

  • 公式名高亮及运算符高亮
  • 括号颜色匹配
  • 公式名错误提示

正文

主要原理

根据用户输入代码做词法分析,不同token标记不同颜色,最终用span + style在编辑区显示高亮效果,一个token对应一个span。

效果如下

 

实现思路

  1. 编辑器选择
    input+div or  div+contenteditable
  2. 公式名、运算符定义
    需要配置公式名和运算符,不同类型高亮的颜色。
  3. 编译器核心逻辑实现
    根据配置对输入代码进行词法分析,生成Tokens,根据配置进行公式名校验和添加颜色属性、括号匹配,生成中间代码
  4. 渲染
    根据中间代码生成html模板并在输入区渲染。

编辑器选择

编辑器选择

两种方案

  1. input+div input在z-index下层处理用户输入,div添加css事件穿透在上层负责渲染高亮效果

  2. div + contenteditable属性 添加 contenteditable 属性,在同一个dom下进行输入和替换渲染

 

这里采用第一种,编辑区和高亮渲染区最好隔离,因为高亮的处理需在用户输入内容后马上进行编译重新渲染了,一个dom同时负责处理编辑和反复的重新渲染,一是会有性能问题,二是每次重新渲染都会导致光标丢失,需要手动维护selection。(也可以采用双div,即div+contenteditable + div)

下面实现中采用双div,即 div + contenteditable(editor) + div(render),下层输入也采用div,因为input需要处理样式保证和上层div一致,以免出现文字无法对齐,光标被遮盖等。

<div id="app">
  <div class="editor"  contenteditable="true" autocapitalize="off"></div>
  <div class="render"></div>
</div>
<style>
  #app {
    width: 300px;
    height: 300px;
    background-color: #f9f9ff;
    border-radius: 4px;
    position: relative;
    caret-color: #333;
    border: 1px solid transparent;
  }
  .render {
    position: absolute;
    top: 0;
    bottom: 0;
    left: 0;
    right: 0;
    pointer-events: none;
    background: none;
    overflow: auto;
    white-space: pre-wrap;
    overflow-wrap: break-word;
    letter-spacing: 1px;
  }
  .editor {
    overflow: auto;
    white-space: pre-wrap;
    overflow-wrap: break-word;
    letter-spacing: 1px;
    outline: none;
  }
</style>

 

公式名运算符定义,颜色配置

const formulas = ['SUM', 'AVERAGE', 'MAX', 'MIN', 'ROUND']
const operators = ['+', '-', '*', '/', '?', '&', '>', '=', '<', '!']
const colors = {
    formula: 'purple',
    grapheme: 'grey',
    sign: '#6CA6CD'
}

 

编译器核心逻辑实现

因为不涉及语法分析等复杂的过程,不需要生成AST,仅做词法分割,对Tokens进行组装重新渲染即可

  • 定义状态机
    const state = {
        initial: Symbol('initial'),
        sign: Symbol('sign'),
        grapheme: Symbol('grapheme'),
        leftParen: Symbol('leftParen'),
        rightParen: Symbol('rightParen'),
    }
  • 词法分割

    主要根据上一步定义的状态对用户的输入进行切割

    1. initial:初始状态

    2. sign: 运算符状态

    3. grapheme:普通字符状态

    4. leftParen:左括号状态,遇到左括号代表chars存储的是一个公式名

    5. rightParen: 右括号状态,代表公式的结

    function tokenize(code) {
      code = code + ' '
      const tokens = [] // 存储Tokens
      const chars = [] // 当前已处理未切割的字符
      let currentState = state.initial // 当前状态
      while (code) {
        const char = code[0]
        switch (currentState) {
          case state.initial:
            if (isGrapheme(char)) {
              chars.push(char)
              currentState = state.grapheme
            } else if (char === '(') {
              tokens.push({
                type: 'formula',
                value: chars.join('')
              })
              chars.length = 0
              chars.push(char)
              currentState = state.leftParen
            } else if (char === ')') {
              tokens.push({
                type: 'grapheme',
                value: chars.join('')
              })
              chars.length = 0
              chars.push(char)
              currentState = state.rightParen
            } else if(isSign(char)) {
              tokens.push({
                type: 'grapheme',
                value: chars.join('')
              })
              chars.length = 0
              chars.push(char)
              currentState = state.rightParen
            }
            code = code.slice(1)
            break
          case state.grapheme:
            if (isGrapheme(char)) {
              chars.push(char)
              currentState = state.grapheme
            } else if (char === '(') {
              tokens.push({
                type: 'formula',
                value: chars.join('')
              })
              chars.length = 0
              chars.push(char)
              currentState = state.leftParen
            } else if (char === ')') {
              tokens.push({
                type: 'grapheme',
                value: chars.join('')
              })
              chars.length = 0
              chars.push(char)
              currentState = state.rightParen
            } else if(isSign(char)) {
              tokens.push({
                type: 'grapheme',
                value: chars.join('')
              })
              chars.length = 0
              chars.push(char)
              currentState = state.sign
            }
            code = code.slice(1)
            break
          case state.leftParen:
            tokens.push({
              type: 'paren',
              value: chars.join('')
            })
            chars.length = 0
            chars.push(char)
            if (isGrapheme(char)) {
              currentState = state.grapheme
            } else if (char === '(') {
              currentState = state.leftParen
            } else if (char === ')') {
              currentState = state.rightParen
            } else if(isSign(char)) {
              currentState = state.sign
            }
            code = code.slice(1)
            break
          case state.rightParen:
            tokens.push({
              type: 'paren',
              value: chars.join('')
            })
            chars.length = 0
            chars.push(char)
            if (isGrapheme(char)) {
              currentState = state.grapheme
            } else if (char === '(') {
              currentState = state.leftParen
            } else if (char === ')') {
              currentState = state.rightParen
            } else if(isSign(char)) {
              currentState = state.sign
            }
            code = code.slice(1)
            break
          case state.sign:
            tokens.push({
              type: 'sign',
              value: chars.join('')
            })
            chars.length = 0
            chars.push(char)
            if (isGrapheme(char)) {
              currentState = state.grapheme
            } else if (char === '(') {
              currentState = state.leftParen
            } else if (char === ')') {
              currentState = state.rightParen
            } else if(isSign(char)) {
              currentState = state.sign
            }
            code = code.slice(1)
            break
          }
    
        }
    
      return tokens
        
    }​

    其中用到的几个工具函数

    function isGrapheme(char) { // 判断字母组成的无含义词,包括纯英文字组,数字
      return (char >= 'a' && char <= 'z') ||
      return (char >= 'a' && char <= 'z') ||
      (char >= 'A' && char <= 'Z') ||
      (char !== ' ' && !isNaN(char) && !Number.isNaN(char))
    }
    
    function isSign(char) { // 判断运算符
      return signs.includes(char)
    }

     

  • 添加颜色标记和公式名校验
    function marker(tokens) {
        const parenStack = [] // 栈结构记录左右括号关系,左括号对应对应的右括号添加一致的颜色
        for (let i = 0; i < tokens.length; i++) {
          const t = tokens[i]
            switch (t.type) {
              case 'paren':
                if (t.value === '(') {
                  t.color = getParenColor(i)
                  parenStack.push(t.color)
                } else {
                  t.color = parenStack[parenStack.length - 1]
                  parenStack.pop()
                }
                break;
              case 'formula':
                t.color = colors[formula]
                if (!formulas.includes(t.value)) { // 判断公式名是否符合预置的公式
                  t.error = `invalidate formula: ${t.value}`
                }
                break
              case 'grapheme':
                t.color = colors[grapheme]
                break
              case 'sign':
                t.color = colors[sign]
                break
              default:
                break;
            }
        }
    }​

    上述标记完成后进入下一步,生成html模板并重新渲染高亮显示区 这里括号的颜色需要随机生成,但又不能完全随机,保证已渲染的左括号对应点的右括号被删除后,重新编译渲染后左括号的颜色保持不变。

    const curParenColors = [] // 缓存已添加过的颜色
    function getParenColor(index) {
      if (curParenColors[index]) {
        return curParenColors[index]
      }
      let color = '#'
      for (let i = 0; i < 6; i++) {
        color += Math.floor(Math.random()*16).toString(16)
        
      }
      curParenColors[index] = color
      return color
    }

     

  • 渲染高亮
    渲染部分因为本身dom结构不复杂,仅进行整体替换即可
    const errors = []
    function render(abs) {
      let template = ''
      abs.forEach(b => {
        if (b.error) {
            errors.push(b)
        }
        template += `<span style="color:${b.color};">${b.value}</span>`
      })
      // 渲染高亮模板
      renderDom.innerHTML = template
      errorDom.innerHtml = errors.map(b => b.error).join('、')
    }​