深圳幻海软件技术有限公司 欢迎您!

提升Web输入体验!JS 如何自动配对标点符号?

2023-02-28

在写作编辑中,有很多需要成对出现的标点符号,比如引号、括号、书名号等,如下所示:为了方便输入,某些输入法自带了标点自动配对功能。什么意思呢?比如输入一个前括号,自动补全后括号,然后光标位于中间。下面是小米手机自带输入法的演示:标点自动配对不仅仅是输入法,大部分编辑器也实现了类似的功能,比如vscod

在写作编辑中,有很多需要成对出现的标点符号,比如引号、括号、书名号等,如下所示:

为了方便输入,某些输入法自带了标点自动配对功能。什么意思呢?比如输入一个前括号,自动补全后括号,然后光标位于中间。下面是小米手机自带输入法的演示:

标点自动配对

不仅仅是输入法,大部分编辑器也实现了类似的功能,比如 vscode:

vscode标点自动配对

那么,这么好用的特性,如何让 web 中的输入框也能支持呢?

一、实现原理

原理其实非常简单,可以分为以下几个步骤:

  • 检测输入的内容,如果是以上标点符号就下一步
  • 根据输入的标点,自动补全与之对应的后半部分
  • 将光标移到两个标点之间

是不是非常好理解呢?但是,里面的细节远不止这些,涉及到非常多的比较生僻的原生方法,一起看看如何实现的吧

二、检测输入的内容

这里检测的是在键盘按下的时候,需要知道当前按下的是什么字符,所以一开始我想到了用keydown方法

editor.addEventListener("keydown", (ev) => {
 console.log(ev.key, ev.code)
})
  • 1.
  • 2.
  • 3.

在keydown方法中,与键值相关的属性有ev.key和ev.code,如下:

看似好像没啥问题,可以通过ev.key区分具体输入的是什么字符。其实还有很多问题,比如无法区分中英文标点输入。

举个例子:在中英文下分别输入方括号。

可以看到,两者的ev.key和ev.code是完全一样的!

还有更离谱的,在中文输入法下,某些标点是依次出现的,比如中文的单双引号,按一次是上引号“,再按一次是下引号”,还有半括号,按一次是「,再按一次是『等等,像这类输入就更加没法判断了。

为啥会这样呢?因为这些标点都在一个按键上,keydown事件反应的是和键盘相关的属性,如下:

  • 一个按键上密密麻麻的塞下了4个标点符号。

所以,我们需要用别的方式来检测输入的内容。

在这里,可以用input事件来监听,ev.data表示当前输入的字符。

editor.addEventListener("input", (ev) => {
 console.log(ev.data)
})
  • 1.
  • 2.
  • 3.

注意,这里是字符,也就是真正输入到页面的文字,如下:

需要注意的是,在windows中文输入法下,input 会触发两次,如下:

这是由于在 windows 中文输入法下,标点输入也和普通拼音输入一样,有候选词的过程,就像这样:

所以解决这个问题也很简单,用compositionend事件就可以了,表示候选结束之后

editor.addEventListener("compositionend", (ev) => {
 console.log(ev.data)
})
  • 1.
  • 2.
  • 3.

因此,兼容 windows 和 Mac OS的完整写法应该是这样

const input = function(ev){
  if (ev.inputType === "insertText" || ev.type === 'compositionend') {
    console.log(ev)
  }
}

editor.addEventListener('compositionend', input)
editor.addEventListener('input', input)
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.

因为我们只检测标点符号,所以也无需担心重复触发的问题。

三、两种输入框

接下来就是具体的匹配实现了,在此之前先搞清楚两种类型的输入框。

一种是原生默认的表单输入框input和textarea。

<input type="text">
<textarea></textarea>
  • 1.
  • 2.

还有一种是手动给元素添加属性contenteditable="true",或者 CSS 属性 -webkit-user-modify。

<div contenteditable="true">yux阅文前端</div>
  • 1.

或者

div{
    -webkit-user-modify: read-write;
}
  • 1.
  • 2.
  • 3.

为啥要分这两种呢?因为这两种类型的光标处理方式完全不一样。

四、表单输入框

先来看表单输入框,这里以textarea为例:

<textarea></textarea>
  • 1.

首先我们需要罗列一下需要匹配的标点符号,包含中英文。

const quotes = {
  "'": "'",
  '"': '"',
  "(": ")",
  "(": ")",
  "【": "】",
  "[": "]",
  "《": "》",
  "「": "」",
  "『": "』",
  "{": "}",
  "“": "”",
  "‘": "’",
};
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.

接下来,根据前面提到的检测输入内容的方法来自动补全标点,在原生输入框中,可以用setRangeText方法来手动插入内容。

  • HTMLInputElement.setRangeText() - Web APIs | MDN (mozilla.org)[3]
const input = function(ev){
  const quote = quotes[ev.data];
  if (quote && (ev.inputType === "insertText" || ev.type === 'compositionend')) {
    this.setRangeText(quote)
  }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

效果如下:

是不是非常容易呢?不过还有些问题,比如中文的引号,就有些奇怪。

QQqj">

为啥会这样呢?原因在于,中文的上引号和下引号是依次出现的,也就是说第一次按是上引号,第二次按就是下引号了,完全是由系统输入法决定的,无法修改(英文不存在这个问题,因为上引号和下引号是相同的)。

中文的上引号和下引号依次出现。

那么,如何解决这个问题呢?我想到的方式是这样的,对上引号和下引号分别进行处理。如果是上引号,就按照前面的思路进行处理;如果是下引号,就将光标往前移动一位,然后补全上引号,示意如下

具体实现就是,在罗列的标点符号添加下引号,并且添加标识,标识这些符号需要特殊处理。

const quotes = {
  // 添加中文下引号映射
  "”": "“",
  "’": "‘",
};
const quotes_reverse = ["”", "’"];
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

然后如果是下引号,需要将光标往左移动一位,可以用到setSelectionRange方法,这个方法可以手动设置选区的位置,当前光标的位置可以通过两个属性selectionStart、selectionEnd来获取。

  • HTMLInputElement.setSelectionRange() - Web APIs | MDN (mozilla.org)[4]

补全标点之后还需要将光标移动到两者之间,具体实现如下:

const input = function(ev){
  const quote = quotes[ev.data];
  if (quote && (ev.inputType === "insertText" || ev.type === 'compositionend')) {
    const reverse = quotes_reverse.includes(ev.data);
    if (reverse) {
      this.setSelectionRange(this.selectionStart - 1, this.selectionEnd - 1)
    }
    this.setRangeText(quote)
    if (reverse) {
      this.setSelectionRange(this.selectionStart + 1, this.selectionEnd + 1)
    }
  }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.

这样就完美支持中文标点符号了。

完整代码可以访问:textarea-auto-quotes(codepen.io)[5]或者textarea-auto-quotes (juejin.cn)[6]

五、富文本输入框

下面来看一种更普遍的输入框,富文本编辑器。

<div id="editor" contenteditable="true">yux阅文前端</div>
  • 1.

思路其实和前面纯文本一致,只是光标的处理方式不同。

首先,向光标处加入内容,需要在range对象下处理,用到一个insertNode的方法,注意,这个方法需要传入一个 node 节点,纯字符需要用createTextNode创建。

  • Range.insertNode() - Web APIs | MDN (mozilla.org)[7]

具体实现如下:

const selection = document.getSelection();
const input = function(ev){
  const quote = quotes[ev.data];
  if (quote && (ev.inputType === "insertText" || ev.type === 'compositionend')) {
    const newQuote = document.createTextNode(quote);
    const range = selection.getRangeAt(0);
    range.insertNode(newQuote);
  }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.

效果如下:

可以看到,插入的标点符号被自动选中了,这是默认行为。那么,如何让光标定位到两者之间呢?这里可以用到setEndBefore方法,可以设置选区的结束点位置。

  • Range.setEndBefore() - Web APIs | MDN (mozilla.org)[8]
const selection = document.getSelection();
const input = function(ev){
  const quote = quotes[ev.data];
  if (quote && (ev.inputType === "insertText" || ev.type === 'compositionend')) {
    const newQuote = document.createTextNode(quote);
    const range = selection.getRangeAt(0);
    range.insertNode(newQuote);
    range.setEndBefore(newQuote); // 将光标移动到newQuote之前
  }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.

Before表示“之前”,所以选区的结束点在新生成的字符之前,光标自然就移到两者之间了。

然后来处理中文引号的问题,同样是需要特殊处理,将光标往左移动一位,可以用到setStart和setEnd方法,表示设置选区的起始点。

  • Range.setStart() - Web APIs | MDN (mozilla.org)[9]
  • Range.setEnd() - Web APIs | MDN (mozilla.org)[10]

具体实现如下

const input = function(ev){
  const quote = quotes[ev.data];
  if (quote && ev.inputType === "insertText") {
    const newQuote = document.createTextNode(quote);
    const range = selection.getRangeAt(0);
    const reverse = quotes_reverse.includes(ev.data);
    if (reverse) {
      const { startContainer, startOffset, endContainer, endOffset } = range;
      range.setStart(startContainer, startOffset - 1);
      range.setEnd(endContainer, endOffset - 1);
    }
    range.insertNode(newQuote);
    if (reverse) {
      range.setStartAfter(newQuote);
    } else {
      range.setEndBefore(newQuote);
    }
  }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.

这样富文本也支持中英文标点自动配对了。

还有一点小细节可以优化,在开发者工具中可以看到,新添加的标点都是一个个独立的#text,导致把整个文本分割成立很多的小片段,如下:

打印一下子节点。

这里都是纯文本,有办法合并一下吗?当然也有,用到的方法是normalize,可以将子节点“规范化”。

  • Node.normalize() - Web APIs | MDN (mozilla.org)[11]
const input = function(ev){
  const quote = quotes[ev.data];
  if (quote && ev.inputType === "insertText") {
    // 规范化子节点
    range.commonAncestorContainer.normalize();
  }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.

现在看下效果(注意观察控制台的字符)。

打印子节点也只有一个了。

完整代码可以查看:contenteditable-auto-quotes(codepen.io)[12] 或者 contenteditable-auto-quotes(juejin.cn)[13]。

六、整合成公共方法

以上案例是针对具体某一个元素实现,如果有多个输入框,可能会有点麻烦,所以有必要整合一下,实现一个更为通用的方法。

首先,我们可以把事件监听放在document上,而不是具体的某个输入框。

document.addEventListener('compositionend', commonInput)
document.addEventListener('input',  commonInput)
  • 1.
  • 2.

这里用了一个commonInput来处理表单输入框和富文本的情况。

function commonInput(ev) {
  const tagName = ev.target.tagName;
  if (tagName === 'TEXTAREA' || tagName === 'INPUT') {
    inputTextArea.call(ev.target, ev)
  } else {
    input.call(ev.target, ev)
  }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.

注意,这里的this指向问题,使用call  指向了当前编辑的输入框ev.target。

然后inputTextArea和input分别表示前面表单输入和富文本的具体处理。

下面是完整代码,你可以直接粘贴到任意控制台进行试用,相当于一个polyfill。

(function(){
  /*
        * @desc: 自动匹配标点符号
        * @email: yanwenbin1991@live.com
        * @author: XboxYan
        */
  const quotes = {
    "'": "'",
    '"': '"',
    "(": ")",
    "(": ")",
    "【": "】",
    "[": "]",
    "《": "》",
    "「": "」",
    "『": "』",
    "{": "}",
    "“": "”",
    "‘": "’",
    "”": "“",
    "’": "‘",
  };

  const quotes_reverse = ["”", "’"];
  const selection = document.getSelection();
  function commonInput(ev) {
    const tagName = ev.target.tagName;
    if (tagName === 'TEXTAREA' || tagName === 'INPUT') {
      inputTextArea.call(ev.target, ev)
    } else {
      input.call(ev.target, ev)
    }
  }
  document.addEventListener('compositionend', commonInput)
  document.addEventListener('input',  commonInput)

  function inputTextArea(ev){
    const quote = quotes[ev.data];
    if (quote && (ev.inputType === "insertText" || ev.type === 'compositionend')) {
      const reverse = quotes_reverse.includes(ev.data);
      if (reverse) {
        this.setSelectionRange(this.selectionStart - 1, this.selectionEnd - 1)
      }
      this.setRangeText(quote)
      if (reverse) {
        this.setSelectionRange(this.selectionStart + 1, this.selectionEnd + 1)
      }
    }
  }
  function input(ev){
    const quote = quotes[ev.data];
    if (quote && ev.inputType === "insertText") {
      const newQuote = document.createTextNode(quote);
      const range = selection.getRangeAt(0);
      const reverse = quotes_reverse.includes(ev.data);
      if (reverse) {
        const { startContainer, startOffset, endContainer, endOffset } = range;
        range.setStart(startContainer, startOffset - 1);
        range.setEnd(endContainer, endOffset - 1);
      }
      range.insertNode(newQuote);
      if (reverse) {
        range.setStartAfter(newQuote);
      } else {
        range.setEndBefore(newQuote);
      }
      range.commonAncestorContainer.normalize();
    }
  }
})()
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.
  • 48.
  • 49.
  • 50.
  • 51.
  • 52.
  • 53.
  • 54.
  • 55.
  • 56.
  • 57.
  • 58.
  • 59.
  • 60.
  • 61.
  • 62.
  • 63.
  • 64.
  • 65.
  • 66.
  • 67.
  • 68.
  • 69.
  • 70.

实战一下,以下是某网站的一个评论输入框,在控制台注入以上代码后,也能够完美支持自动匹配标点。

七、总结和说明

想不到一个小小的功能居然包含了这么多不常见的API,下面总结一下:

  • 自动配对标点符号可以很好的提升输入体验;
  • keydown事件无法区分中英文输入法,也无法区分同一按键对应的多个标点符号;
  • input事件可以通过ev.data检测当前输入的字符;
  • windows 操作系统下输入中文标点符号会触发两次input,原因是和普通中文一样,触发了候选框;
  • windows 操作系统下可以通过compositionend事件来实现,有效避免了两次触发的情况;
  • 原生表单输入框和contenteditable可编辑元素的光标处理方式完全不一样,需要分开处理;
  • 中文标点有点特殊,中文的上引号和下引号在同一按键上,输入的时候是依次出现的,无法修改;
  • 如果是上引号,就在光标处插入下引号;如果是下引号,就将光标往前移动一位,然后补全上引号;
  • 在原生输入框中,可以用setRangeText方法来手动插入内容;
  • 在富文本输入框中,可以用insertNode方法来手动插入内容,文本需要用createTextNode创建;
  • 在原生输入框中,可以用到setSelectionRange方法手动设置选区的位置;
  • 在富文本输入框中,可以用setStart和setEnd方法手动设置选区的位置;

整体实现从代码量看,其实并不多,主要是一些和 DOM相关的API,看着好像有些陌生。为啥会觉得陌生呢?当然是平时没有用到过,这和现在的大环境是密接相关的,vue和react这些框架虽然给开发者提供了很多便利,不过也使得离原生,离DOM越来越远,这样就导致很多原生API压根就没见过,这何尝不是一种损失呢?

参考资料

[1]Web 中的“选区”和“光标”: https://juejin.cn/post/7068232010304585741

[2]Web 中的“选区”和“光标”: https://juejin.cn/post/7068232010304585741#heading-1

[3]HTMLInputElement.setRangeText() - Web APIs | MDN (mozilla.org): https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/setRangeText

[4]HTMLInputElement.setSelectionRange() - Web APIs | MDN (mozilla.org): https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/setSelectionRange

[5]textarea-auto-quotes(codepen.io): https://codepen.io/xboxyan/pen/zYWzVGB

[6]textarea-auto-quotes (juejin.cn): https://code.juejin.cn/pen/7123852466059378702

[7]Range.insertNode() - Web APIs | MDN (mozilla.org): https://developer.mozilla.org/en-US/docs/Web/API/Range/insertNode

[8]Range.setEndBefore() - Web APIs | MDN (mozilla.org): https://developer.mozilla.org/en-US/docs/Web/API/Range/setEndBefore

[9]Range.setStart() - Web APIs | MDN (mozilla.org): https://developer.mozilla.org/en-US/docs/Web/API/Range/setStart

[10]Range.setEnd() - Web APIs | MDN (mozilla.org): https://developer.mozilla.org/en-US/docs/Web/API/Range/setEnd

[11]Node.normalize() - Web APIs | MDN (mozilla.org): https://developer.mozilla.org/en-US/docs/Web/API/Node/normalize

[12]contenteditable-auto-quotes(codepen.io): https://codepen.io/xboxyan/pen/QWmgXML

[13]contenteditable-auto-quotes(juejin.cn): https://code.juejin.cn/pen/7123851982644707336