div+contenteditable 实现富文本发布框的小结

12,577 阅读5分钟

效果图和实现的功能

实现的效果图如下,主要实现的功能有

  • 表情的插入
  • 插入话题之后部分文字选中
  • 文本框高度自适应
  • 发送消息,获取发送框中的纯文本内容
  • placeholder的实现
  • 输入文字的计数面板

代码地址传送门 代码实现Vue

灵感来源

“你想做的一定有人做了,你一定不是第一个遇到这个问题的人”——这句话对80%(二八分布)的人是有效的,我也从中获益不少。

我的第一份参考案例是qq空间的说说发布框&&webqq的消息发送框,从中的收获有以下几点

  • 可以使用div+contenteditable实现消息发送框
  • 在Chrome中使用button标签来高亮被@的用户,在Firefox中使用img标签来高亮被@的用户(绝妙)这里不同标签的使用很讲究,考虑了浏览器兼容性。
  • 插入话题之后部分文字选中,提高用户体验,这里还参考了张鑫旭老师的博客_新浪微博插入话题后部分文字选中的js实现
    第二份参考案例是掘金的动态发布框,收获如下
  • 右下角显示还能够输入的字数
  • placeholder的实现

实现的一些细节

contenteditable

我在拜读张鑫旭老师的文章翻译-你必须知道的28个HTML5特征、窍门和技术的时候第一次接触到可以通过div+contenteditable替代textarea实现一个编辑框。之后再阅读了div模拟textarea文本域轻松实现高度自适应发现了这个属性的强大之处。 另外想要网页上的元素能够高亮,首先你需要一个可以被赋予CSS的HTML标签选中它,然后去改变这个标签才能够完成这个任务,传统的发布框使用textarea,内嵌标签极其困难,可以说是不行,但是div不同,内嵌标签是家常便饭,掘金和qq空间的成功案例就不用多说。

关于contenteditable属性的特性可以去上面的两个链接中查阅,总之是会把设置了这个属性的标签和里面的子标签都设置为可编辑属性。

palceholder的实现

inputtextarea这些标签自带placeholder属性,但是div没有,要实现就需要通过JS和CSS来模拟。 通过两层div实现,在外层通过监听编辑框中是否存在文字来选择是否展示placeholderplaceholder通过伪元素和绝对定位实现,脱离标准文档流浮于编辑框之上

    <div
      class="edit-panel"
      :class="{'show-placeholder' : showPlaceholder}"
      :placeholder="placeholder"
    >
      <div contenteditable="true" ref="editor" class="editor"></div>
      <span class="count" :class="{'font-red':textCount < 0}">{{ textCount }}</span>
    </div>
.edit-panel {
  position: relative;
  width: 100%;
  height: auto;
  font-size: 14px;
  line-height: 20px;
  border: 1px solid;
}
.show-placeholder::before {
  content: attr(placeholder);
  position: absolute;
  top: 4px;
  left: 8px;
  color: #555;
  pointer-events: none;
}

面板计数

于上面placeholder实现异曲同工

.edit-panel .count {
  position: absolute;
  color: #555;
  right: 1rem;
  bottom: 0.5rem;
  user-select: none;
  pointer-events: none;
}

文本框高度自适应

这里只需要设置min-heightmax-height就行

插入表情

插入表情的时候不能想当然的使用dom操作插入一个img标签,这里对比一下掘金实现的功能和qq空间实现的功能。我发现掘金插入表情的时候输入框会闪烁一下,而qq空间的不会。我合理的猜想掘金是通过dom操作来插入表情的,然后是记录了插入之前的range对象,在插入之后还原range这样就不会丢失光标位置,range对象是用来控制和获取当前光标选取的内容的。详细参考MDN——Range。qq空间则是通过其他的方式来插入表情,类似于其他的富文本编辑器,在插入的时候不会闪烁一下。我这里去探索了一下,使用下面的这个方法插入,通过创建一个dom片段,然后用range对象插入到编辑框中,这样也不会丢失光标。

这里需要注意,只要操作DOM的方式不对,光标位置就会错位,良好的用户体验就是光标位置保持不变

function insertHtmlAtCaret (html) {
  var sel, range, frag
  if (window.getSelection) {
    sel = window.getSelection()
    if (sel.getRangeAt && sel.rangeCount) {
      range = sel.getRangeAt(0)
      range.deleteContents()
      var el = document.createElement('div')
      el.innerHTML = html
      frag = document.createDocumentFragment()
      var node
      var lastNode
      while ((node = el.firstChild)) {
        lastNode = frag.appendChild(node)
      }
      range.insertNode(frag)
      if (lastNode) {
        range = range.cloneRange()
        range.setStartAfter(lastNode)
        range.collapse(true)
        sel.removeAllRanges()
        sel.addRange(range)
      }
    }
  }
}

插入话题之后部分文字选中

这里需要去理解一下range对象中四个重要的属性startContainerstartOffsetendContainerendOffset。在不同情况下指代的意思是不一样的,我这里就是轻描淡写的提一下,我理解的不是很透彻就不误导大家了。

addTopic (event) {
      this.$refs.editor.focus()
      insertHtmlAtCaret('#')
      insertHtmlAtCaret('请输入一个话题')
      insertHtmlAtCaret('#')
      var range = window.getSelection().getRangeAt(0)
      console.log(range)
      range.selectNodeContents(range.startContainer.childNodes[range.startOffset - 2])
    }

获取纯文本内容

直接使用textContent是不行的,这样获取不到img标签中的内容,加上之后会用button或者input[type=button]去实现一些高亮功能,这里需要自己去定义一个获取纯文本内容的方法。我实现的比较简单

function getDomValue (elem) {
  var res = ''
  Array.from(elem.childNodes).forEach((child) => {
    if (child.nodeName === '#text') {
      res += child.nodeValue
    } else if (child.nodeName === 'BR') {
      res += '\n'
    } else if (child.nodeName === 'BUTTON') {
      res += getDomValue(child)
    } else if (child.nodeName === 'IMG') {
      res += child.alt
    } else if (child.nodeName === 'DIV') {
      res += '\n' + getDomValue(child)
    }
  })
  return res
}

注意点和展望

  • 富文本编辑框需要考虑很多XSS的问题,赋值粘贴时标签的过滤等
  • 浏览器兼容性的问题,range对象操作的不同,contenteditable属性表现出来的问题
  • 统一插入文本的样式,否则输入文字的时候会沿用前面的样式
  • @用户高亮显示的浏览器兼容问题

感谢

我能完成这个功能要感谢@炒饭君的帮助。实习期间给力很大的帮助。