欢送关注微信公众号前端侦探
舒适提醒:在浏览本文之前,能够先回顾这篇文章:Web 中的“选区”和“光标”,有很多你可能不太晓得的原生 API,以下内容是对该内容的实际使用

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

为了不便输出,某些输入法自带了标点主动配对性能。什么意思呢?比方输出一个前括号,主动补全后括号,而后光标位于两头。上面是小米手机自带输入法的演示:

不仅仅是输入法,大部分编辑器也实现了相似的性能,比方 vscode

那么,这么好用的个性,如何让 web 中的输入框也能反对呢?

一、实现原理

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

  1. 检测输出的内容,如果是以上标点符号就下一步
  2. 依据输出的标点,主动补全与之对应的后半局部
  3. 将光标移到两个标点之间

是不是十分好了解呢?然而,外面的细节远不止这些,波及到十分多的比拟生僻的原生办法,一起看看如何实现的吧

二、检测输出的内容

这里检测的是在键盘按下的时候,须要晓得以后按下的是什么字符,所以一开始我想到了用keydown办法

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

keydown办法中,与键值相干的属性有ev.keyev.code,如下

看似如同没啥问题,能够通过ev.key辨别具体输出的是什么字符。其实还有很多问题,比方无奈辨别中英文标点输出

举个例子:在中英文下别离输出方括号

能够看到,两者的ev.keyev.code是齐全一样的!

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

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

一个按键上稀稀拉拉的塞下了4个标点符号

所以,咱们须要用别的形式来检测输出的内容。

在这里,能够用input事件来监听,ev.data示意以后输出的字符

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

留神,这里是字符,也就是真正输出到页面的文字,如下

须要留神的是,在windows中文输入法下,input 会触发两次,如下

这是因为在 windows 中文输入法下,标点输出也和一般拼音输入一样,有候选词的过程,就像这样

所以解决这个问题也很简略,用compositionend事件就能够了,示意候选完结之后

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

因而,兼容 windowsMac OS的残缺写法应该是这样

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

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

三、两种输入框

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

一种是原生默认的表单输入框inputtextarea

<input type="text"><textarea></textarea>

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

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

或者

div{    -webkit-user-modify: read-write;}

为啥要分这两种呢?因为这两种类型的光标解决形式齐全不一样

更多详情能够参考之前这篇文章:Web 中的“选区”和“光标”

四、表单输入框

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

<textarea></textarea>

首先咱们须要列举一下须要匹配的标点符号,蕴含中英文

const quotes = {  "'": "'",  '"': '"',  "(": ")",  "(": ")",  "【": "】",  "[": "]",  "《": "》",  "「": "」",  "『": "』",  "{": "}",  "“": "”",  "‘": "’",};

接下来,依据后面提到的检测输出内容的办法来主动补全标点,在原生输入框中,能够用setRangeText办法来手动插入内容

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

成果如下

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

为啥会这样呢?起因在于,中文的上引号和下引号是顺次呈现的,也就是说第一次按是上引号,第二次按就是下引号了,齐全是由零碎输入法决定的,无奈批改(英文不存在这个问题,因为上引号和下引号是雷同的)

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

具体实现就是,在列举的标点符号增加下引号,并且增加标识,标识这些符号须要非凡解决

const quotes = {  // 增加中文下引号映射  "”": "“",  "’": "‘",};const quotes_reverse = ["”", "’"];

而后如果是下引号,须要将光标往左挪动一位,能够用到setSelectionRange办法,这个办法能够手动设置选区的地位,以后光标的地位能够通过两个属性selectionStartselectionEnd来获取

HTMLInputElement.setSelectionRange() - Web APIs | MDN (mozilla.org)

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

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)    }  }}

这样就完满反对中文标点符号了

残缺代码能够拜访:textarea-auto-quotes(codepen.io)

五、富文本输入框

上面来看一种更广泛的输入框,富文本编辑器

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

思路其实和后面纯文本统一,只是光标的解决形式不同。

首先,向光标处退出内容,须要在range对象下解决,用到一个insertNode的办法,留神,这个办法须要传入一个 node 节点,纯字符须要用createTextNode创立

Range.insertNode() - Web APIs | MDN (mozilla.org)

具体实现如下

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);  }}

成果如下

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

Range.setEndBefore() - Web APIs | MDN (mozilla.org)
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之前  }}

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

而后来解决中文引号的问题,同样是须要非凡解决,将光标往左挪动一位,能够用到setStartsetEnd办法,示意设置选区的起始点

Range.setStart() - Web APIs | MDN (mozilla.org)

Range.setEnd() - Web APIs | MDN (mozilla.org)

具体实现如下

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);    }  }}

这样富文本也反对中英文标点主动配对了

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

打印一下子节点

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

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

当初看下成果(留神察看控制台的字符)

打印子节点也只有一个了

残缺代码能够查看:contenteditable-auto-quotes(codepen.io) 或者 contenteditable-auto-quotes(juejin.cn)

六、整合成公共办法

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

首先,咱们能够把事件监听放在document上,而不是具体的某个输入框

document.addEventListener('compositionend', commonInput)document.addEventListener('input',  commonInput)

这里用了一个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)  }}
留神,这里的this指向问题,应用call 指向了以后编辑的输入框ev.target

而后inputTextAreainput别离示意后面表单输出和富文本的具体解决

上面是残缺代码,你能够间接粘贴到任意控制台进行试用,相当于一个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();    }  }})()

实战一下,以下是某网站的一个评论输入框,在控制台注入以上代码后,也可能完满反对主动匹配标点

七、总结和阐明

想不到一个小小的性能竟然蕴含了这么多不常见的API,上面总结一下

  1. 主动配对标点符号能够很好的晋升输出体验
  2. keydown事件无奈辨别中英文输入法,也无奈辨别同一按键对应的多个标点符号
  3. input事件能够通过ev.data检测以后输出的字符
  4. windows 操作系统下输出中文标点符号会触发两次input,起因是和一般中文一样,触发了候选框
  5. windows 操作系统下能够通过compositionend事件来实现,无效防止了两次触发的状况
  6. 原生表单输入框和contenteditable可编辑元素的光标解决形式齐全不一样,须要离开解决
  7. 中文标点有点非凡,中文的上引号和下引号在同一按键上,输出的时候是顺次呈现的,无奈批改
  8. 如果是上引号,就在光标处插入下引号;如果是下引号,就将光标往前挪动一位,而后补全上引号
  9. 在原生输入框中,能够用setRangeText办法来手动插入内容
  10. 在富文本输入框中,能够用insertNode办法来手动插入内容,文本须要用createTextNode创立
  11. 在原生输入框中,能够用到setSelectionRange办法手动设置选区的地位
  12. 在富文本输入框中,能够用setStartsetEnd办法手动设置选区的地位

整体实现从代码量看,其实并不多,次要是一些和 DOM相干的API,看着如同有些生疏。为啥会感觉生疏呢?当然是平时没有用到过,这和当初的大环境是密接相干的,vuereact这些框架尽管给开发者提供了很多便当,不过也使得离原生,离DOM越来越远,这样就导致很多原生API压根就没见过,这何尝不是一种损失呢?

最初,如果感觉还不错,对你有帮忙的话,欢送点赞、珍藏、转发❤❤❤

欢送关注微信公众号前端侦探