可编辑区域文本插入

键盘输入分类

直接输入

输入的键直接落入可输入DOM元素,为直接输入。

E.g.英文输入。

间接输入

输入的键值不会直接落入可输入DOM元素,有一个中间态,为间接输入。

E.g.中文输入。

区分中英文输入

因为任何输入都会触发input,而输入中文的时候才触发compositionstartcompositionend,可以以此来区分中英文输入。

e.keyCode在中英文下不同的表现

e.keyCode在英文模式下输入,能获取正确的键值;
e.keyCode在中文模式下输入,键入任何值都输出229

Windows将所有未识别的设备输入都设置为VK_PROCESSKEY 229,浏览器的 event.keyCode复用了这一规范,因此在中文输入过程中,无论按下什么按键,返回的event.keyCode永远是229

输入的事件监听

因为任何输入都会触发input,而输入中文的时候才触发compositionstartcompositionend,可以以此来区分中英文输入。

监听input事件时,输入值时,e.data有值,删除值时,e.data === undefined,可以以此判断输入、删除。

compositionstartcompositionupdatecompositionend只能通过window.addEventListener('')监听,on*监听无效。

光标位置codepen示例

https://codepen.io/mihuartuanr/pen/MmGVdZ

自定义编辑器

保证输入的复杂度与灵活性,一般选用普通标签而非文本域做输入容器。

普通标签可编辑

contenteditable标签属性

属性值如下:

contenteditable=""
contenteditable="events"
contenteditable="caret"
// 纯文本输入
// 换行不会生成<div>,PC端或Android端使用'\n'判断,IOS使用inputType=== 'insertLineBreak'判断
// 复制黏贴不会带有格式
contenteditable="plaintext-only" 
contenteditable="true" // 换行会生成<div>包裹
contenteditable="false"

user-modifyCSS属性

user-modify可以在移动端使用,以及,只需要兼顾webkit内容的桌面网页项目。

-webkit-user-modify: read-only;  // 普通元素的默认状态
-webkit-user-modify: read-write; //可以输入富文本
-webkit-user-modify: write-only;
-webkit-user-modify: read-write-plaintext-only // 只可以输入纯文本

两种方式对比

  • contenteditableuser-modify的旧版本浏览器支持性差
  • contenteditable是归属于W3C标准,全浏览器支持;而user-modify浏览器有自己的实现,非标准,使用需要追加浏览器前缀-webkit--moz-...;
  • contenteditableuser-modify都可以实现对拷贝的富文本过滤格式;

插入内容

基本知识

两个概念选择selection范围range

range只有置于selection中才起作用

const selection = getSelection()
const range = selection.getRangeAt(0) // 获取光标位的选中范围
const range1 = new Range()  // 自定义选中范围,使用时需要selection.addRange(range1)
  • selection是管理range的集合,除了Firfox中rangeCount > 1,其它浏览器的实现,selection最多只有一个range
  • range是文本选择范围的起点和终点

range文本选择规则

  • 通过range.setStart(node, offset)range.setEnd(node, offset)设置范围,根据node节点的类型nodeType不同分属不同的情况
    • node为文本节点nodeType === 3,偏移量offset为文本中的位置。
    • node为元素节点nodeType === 1,偏移量offset为指定元素子节点node.childNodes的位置
    • 其中范围起点、终点的node允许不同节点
    • 其中范围起点、终点的定位位于偏移量offset之前
  • 通过console.log(range)即可查看选中的文本;静默调用toString()方法返回内容;
  • 通过range.startContainerrange.startOffset查看当前范围的起点归属元素及偏移量
  • 通过range.endContainerrange.endOffset查看当前范围的终点归属元素及偏移量
  • 通过range.insertNode(node),在范围的起始处将node插入文档
  • 通过range.extractContents()range.deleteContents从文档中删除范围内容
  • 通过range.surroundContents(node),自定义元素节点包裹选择的范围,选择的范围若有元素节点,元素节点必须闭合
  • 通过selection.empty()可以清空选择

使用selection.addRange(range)添加范围时,如果选择已存在,则首先使用selection.removeAllRanges()将其清空。然后添加范围。否则,除Firefox外的所有浏览器都将忽略新范围。 其中,通过range.setStartrange.setEnd调整范围的情况,不必考虑清空selection

Selection类型

selection.type

  • None: 当前没有选择。
  • Caret: 选区已折叠(即 光标在字符之间,并未处于选中状态)。
  • Range: 选择的是一个范围。

设置光标位置为某元素后

// 方式1. 只支持Android、PC
range.setStart(baseNode, 1)
range.setEnd(baseNode, 1)
range.collapse()

// 方式2. 只支持Android、PC
range.setEndAfter(baseNode)
selection.collapseToEnd()

// 方式3. 
selection.setPosition(node, offset)

// 方式4. 支持IOS,仅用于通过range.extractContents()提取的documentFrag文本
selection.removeAllRanges()
selection.addRange(range)
range.setStart(cloneNode, cloneNode.endOffset)
range.setEnd(cloneNode, cloneNode.endOffset)
selection.collapseToEnd()

// 方式4. 支持全平台,IOS不可用于通过range.extractContents()提取的documentFrag文本
selection.extend(baseNode, 1)
selection.collapseToEnd()

// 光标定位文本后:通过range.extractContents()提取的documentFrag文本
try {
  // 安卓端
  selection.extend(cloneNode, 1)
  selection.collapseToEnd()
} catch (e) {
  // Iphone端
  selection.removeAllRanges()
  selection.addRange(range)
  range.setStart(cloneNode, cloneNode.endOffset)
  range.setEnd(cloneNode, cloneNode.endOffset)
  selection.collapseToEnd()
}

代码示例

    const { selection, range } = this.lastSelection
    this.editableEle.focus()
    const textNode = range.startContainer
    range.setStart(textNode, range.endOffset)
    range.setEnd(textNode, range.endOffset)
    const spanNode1 = document.createTextNode(' ')
    const spanNode2 = document.createElement('span')
    spanNode2.className = 'tag'
    spanNode2.innerHTML = '#'
    let frag = document.createDocumentFragment(), lastNode = spanNode2
    frag.appendChild(spanNode1)
    frag.appendChild(spanNode2)
    range.insertNode(frag)
    // IPhone下有时候会报错,采用下方代码替代
    selection.extend(lastNode, 1)
    selection.collapseToEnd()

IOS下,在标签后紧跟着添加节点,selection.extend(node, 1)关闭范围报错解决方案

whetherEndTag (prefixer) {
   if (!!prefixer && !(prefixer.trim())) {
      // 半角、全角空格
      const selection = getSelection()
      const range = selection.getRangeAt(0)
      const node = range.startContainer
      if (node.parentNode && node.parentNode.className === 'tag') {
        range.setStart(node, range.endOffset - 1)
        range.setEnd(node, range.endOffset)
        const cloneNode = range.extractContents()
        range.setStartAfter(node.parentNode, selection.endOffset)
        range.setEndAfter(node.parentNode, selection.endOffset)
        range.collapse(true)
        range.insertNode(cloneNode)
        // 安卓、IOS不兼容
        try {
          // 安卓端
          selection.extend(cloneNode, 1)
          selection.collapseToEnd()
        } catch (e) {
          // Iphone端
          selection.removeAllRanges()
          selection.addRange(range)
          range.setStart(cloneNode, cloneNode.endOffset)
          range.setEnd(cloneNode, cloneNode.endOffset)
          selection.collapseToEnd()
        }
      }
    }
}

长度限制

直接输入模式下

  // E.g. 英文限制长度
  onKeyupListener (e) {
    this.check_charcount(e)
  },
  onKeydownListener (e) {
    this.check_charcount(e)
  },
  check_charcount (e, max = 100) {
    if(e.which != 8 && this.editableEle.textContent.length > max) {
      e.preventDefault()
    }
  }

间接输入模式:纯文本

  // 中英文,在input、compositionEnd事件中调用
  ...
  data () {
      return {
          CNEnd: true
      }
  }
  ...
  compositionstart (e) {
    this.CNEnd = false  
  },
  compositionend (e) {
      this.CNEnd = true
      this.limitInput(e)
  }
  ...
  limitInput(event) {
    let _words = this.editableEle.textContent
    let _this = this.editableEle
    if (this.CNEnd) {
      let num = _words.length

      if (num >= 100) {
        num = 100
        if (_this.spillOver) {
          event.target.innerText = this.fullContent
        } else {
          event.target.innerText = _words.substring(0, 100)
          _this.spillOver = true
          this.fullContent = _words.substring(0, 100)
        }
        Toast('100字以内。')
      } else {
        _this.spillOver = false
        this.fullContent = ''
      }
      const sel = window.getSelection()
      let range = document.createRange()
      range.selectNodeContents(this.editableEle)
      range.collapse(false)
      sel.removeAllRanges()
      sel.addRange(range)
    } else if (this.fullContent) {
      // 目标对象:超过100字时候的中文输入法
      // 原由:虽然不会输入成功,但是输入过程中字母依然会显现在输入框内
      // 弊端:谷歌浏览器输入法的界面偶尔会闪现
      event.target.innerText = this.fullContent
      this.CNEnd = true
    }
 }

间接输入模式:富文本

区别:在于fullContent的取值。

  limitInput(event) {
    let _words = this.editableEle.textContent
    let _this = this.editableEle
    if (this.CNEnd) {
      let num = _words.length

      if (num >= 100) {
        if (_this.spillOver) {
          event.target.innerHTML = this.fullContent
        } else {
          const selection = getSelection()
          const range = selection.getRangeAt(0)
          const lastNode = range.startContainer.parentNode
          lastNode.textContent = lastNode.textContent.slice(0, lastNode.textContent.length - (num - 100))
          event.target.innerHTML = this.editableEle.innerHTML
          _this.spillOver = true
          this.fullContent = this.editableEle.innerHTML
        }
        Toast('100字内。')
      } else {
        _this.spillOver = false
        this.fullContent = ''
      }
      const sel = window.getSelection()
      let range = document.createRange()
      range.selectNodeContents(this.editableEle)
      range.collapse(false)
      sel.removeAllRanges()
      sel.addRange(range)
      this.cacheCursorPos()
    } else if (this.fullContent) {
      // 目标对象:超过100字时候的中文输入法
      // 原由:虽然不会输入成功,但是输入过程中字母依然会显现在输入框内
      // 弊端:谷歌浏览器输入法的界面偶尔会闪现
      event.target.innerHTML = this.fullContent
      this.CNEnd = true
    }
  },

未知根源问题的解决方案

node.nextSibling.nodeType === 3

当获取元素节点的兄弟文本节点node.nextSibling时,元素节点必须要有文本内容,否则一堆世界未解之谜。

删除有样式的文本时(常见于插入回车),浏览器会自动生成<font>追加样式

  clearFontTag () {
    // 当删除时,浏览器自动添加font标签加样式
    const fontTag = this.editableEle.querySelector('font')
    if (fontTag) {
      const newNode = document.createTextNode(fontTag.textContent)
      this.editableEle.replaceChild(newNode, fontTag)
      const { selection } = this.lastSelection
      selection.extend(newNode, 1)
      selection.collapseToEnd()
      this.cacheCursorPos()
    }
  }

在带有样式的标签后回车,下一行浏览器自动带样式

  • 使用contenteditable="plaintext-only"创建可编辑区时,在带有样式的标签后回车,换行后仍在标签内
  • 使用contenteditable="true"创建可编辑区时,,在带有样式的标签后回车,浏览器自动在新行添加样式标签。

解决方案

  clearNewlineSideEffect () {
    const { range } = this.lastSelection
    const node = range.startContainer
    const baseNode = isAndroid ? node.parentNode : node
    if (baseNode.nodeType === 1 && baseNode.className === 'tag' && !/^#/.test(baseNode.textContent)) {
      const frag = document.createDocumentFragment()
      const textNode = document.createTextNode(baseNode.textContent)
      const brNode = document.createElement('br')
      frag.appendChild(textNode)
      frag.appendChild(brNode)
      baseNode.parentNode.replaceChild(frag, baseNode)
    }
  },

知识点补充

selection文本选择

selection也可以实现range部分功能的范围选择

  • selection.setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset)等同于对设置range.setStart(anchorNode, anchorOffset)range.setEnd(focusNode, focusOffset)
  • 通过selection.collapse(node, offset)等同于对同一node设置range.setStart(node, 0)range.setEnd(node, offset)
  • 通过selection.setPosition(node, offset)等同于对同一node设置同一偏移量range.setStart(node, offset)range.setEnd(node, offset)range.setStart(node, offset)range.collapse(true)
  • 通过selection.deleteFromDocument()从文档中删除所选择的内容

参考文档

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,001评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,210评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,874评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,001评论 1 291
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,022评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,005评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,929评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,742评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,193评论 1 309
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,427评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,583评论 1 346
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,305评论 5 342
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,911评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,564评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,731评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,581评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,478评论 2 352

推荐阅读更多精彩内容