contenteditable元素的光标控制,自制emoji插件

申光临
2023-12-01

最近有个自定义表情插件要放入移动端页面,折腾了几天,有了些成果,分享下。

关键两点点就是contenteditable元素光标设置,和保持该元素的输入状态。

先上几个API吧

getSelection ,他表示一个选中的文档片段,就是鼠标拖拽后蓝底白字的部分。包含了锚点Anchor,焦点Focus,范围range等信息

Range 是核心,range对象包含了片段的起点、终点、长度、节点等信息,也提供了操作改片段的几个方法。可以由getSelection()..getRangeAt(0)获得,也可以自己创建createRange()。创建的选区自然是为了添加表情到光标位置(重要)。

range.insertNode(node) 向range对象起点添加节点,添加文本节点用insertNodeContent()——这个两种节点都能添。

range.collapse(boolean) 折叠选区,由于range添加节点内容后是选中状态(蓝底白字),需要折叠range来恢复输入状态,接收一个布尔值,true则向起点折叠,false向终点。其实光标的闪烁就是range的起点终点保持重合了(比较重要)。

selection.removeAllRanges() 和 selection.addRange(range) 分别是移除选区所有range 和添加新的range。这两个方法是为了保持selection的更新,否则你用getRangeAt(0)获取到的range都是之前的(比较重要)。

接下来是事件函数封装,看完整组件代码(不熟悉Vue的,JS里的this可以当成window,看注释吧)

注意:不要使用fastclick,IOS上触发不了blur事件,BUG。可自行用touch事件代替click,如果有好用的类似插件,还请留下链接,谢谢。

<template>
  <div :class="'footer ' + (!isWriting ? 'footer-hide' : '')" @click.self="footerBlur">
    <div class="main-position">
      <div class="main">
        <div :class="'left' + (isWriting ? ' writing' : '')">
          <!-- 编辑器元素 -->
          <div class="content" ref="content"
            contenteditable="true"
            @blur="contentBlur"
            @click="contentFoucs">
          </div>
          <div class="emoji" @click="showPackage">
            <i class="iconfont icon-laugh"></i>
          </div>
          <div class="send" @click="sendComment">发送</div>
        </div>
        <!-- 表情包 -->
        <div class="emoji-package" v-show="show" @click="addImage">
          <div class="page1">
            <img :src="'https://res.wx.qq.com/mpres/htmledition/images/icon/emotion/' + n + '.gif'"
              alt="" v-for="n in 21" :key="n" />
          </div>
          <div class="page2">
            <img :src="'https://res.wx.qq.com/mpres/htmledition/images/icon/emotion/' + (n + 21) + '.gif'"
              alt="" v-for="n in 21" :key="n" />
          </div>
        </div>
        <div class="center" @click="startWrite" v-show="!isWriting">
          <p v-if="content">
            <span class="sketch">[草贴]</span>
            <span v-html="content"></span>
          </p>
          <p v-else>写评论</p>
        </div>
        <div class="right" v-show="!isWriting">
          <div class="share" @click="doShare">
            <i class="iconfont icon-share"></i>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'footerbar',
  data () {
    return {
      content: '',
      preContent: '写评论',
      show: false,
      range: {},
      selection: {},
      isWriting: false,
      items: 40
    }
  },
  methods: {
    contentBlur () {
      /**
       * 每次焦点移出都要保存一次当前 range ,后面好放回来
       * 由于输入框以外的点击都会使输入框失去焦点,不知道会有什么操作,故勤保存
       */
      this.range = this.selection.getRangeAt(0)
    },
    startWrite () {
      this.isWriting = true
      if (this.content) {
        // 如果focus前有内容, 光标设置到之前保存的位置 setCursor
        this.setCursor()
      } else {
        // 开始评论,输入框自动聚焦
        this.$refs.content.focus()
        this.contentFoucs()
      }
    },
    contentFoucs (e) {
      /**
       * range更新到点击的位置
       * 点到图片就根据点击位置和图片大小设置一个合理的位置(前或后)
       */
      let node = e ? e.target : {}
      this.range = this.selection.getRangeAt(0)
      if (node.tagName === 'IMG') {
        this.setCursor(node, e.offsetX < node.width / 2)
      }
    },
    showPackage () {
      this.show = !this.show
      this.setCursor()
    },
    addImage (e) {
      /** 
       * 点击emoji图片后,复制节点添到当前range里并设置光标和更新range 
       * 若是点到其他地方则设置到之前的位置
       */
      if (e.target.tagName === 'IMG') {
        let node = e.target.cloneNode(true)
        this.range.insertNode(node)
        this.setCursor(node, false)
      } else {
        this.setCursor()
      }
    },
    setCursor (node, before) {
      /**
       * node 为传入的节点,不传则foucs到之前保存的位置
       * before 控制折叠方向
       */
      if (node) {
        // 需要新建一个range来添加内容
        let range = document.createRange()
        range.selectNode(node)
        range.collapse(!!before)
        this.selection.removeAllRanges()
        this.selection.addRange(range)
        // 更新 range
        this.range = range
      } else {
        this.selection.removeAllRanges()
        // 使用之前的
        this.selection.addRange(this.range)
      }
    },
    footerBlur () {
      // 退出输入状态,保存内容
      this.show = false
      this.isWriting = false
      this.content = this.$refs.content.innerHTML
    },
    sendComment () {
      this.content = this.$refs.content.innerHTML
      if (!this.content) { return }
      this.$emit('comment', this.content)
      this.show = false
      this.isWriting = false
      this.content = ''
      this.$refs.content.innerHTML = ''
    },
    doCollect () {
    },
    doShare () {
    }
  },
  mounted () {
    // 先得到selection,并创建一个range
    this.selection = document.getSelection()
    this.range = document.createRange()
  }
}
</script>

<style lang="stylus" scoped>
div.footer-hide
  top auto
.footer
  position fixed
  top 0
  // top calc(100% - 1rem)
  bottom 0
  left 0
  width 100%
  .main-position
    height .8rem
    line-height .8rem
    position absolute
    bottom 0
    left 0
    width 100%
    .main
      display flex
      border-top 1px solid #eee
      // height 100%
      position absolute
      width 100%
      bottom 0
      background #fff
      align-items flex-end
      .emoji-package
        border-top 1px solid #ddd
        position absolute
        right 0
        bottom 100%
        background #ffffff
        width 100vw
        height 2.4rem
        overflow-x scroll
        user-select none
        >div
          height 100%
          display grid
          grid-template-columns 1fr 1fr 1fr 1fr 1fr 1fr 1fr
          justify-items center
          align-items center
      div.writing
        width 100%
        max-height none
      .left
        display flex
        width 0
        max-height .8rem
        background #fafafa
        overflow hidden
        position relative
        align-items flex-end
        .emoji
          float right
          width 1rem
          text-align center
          line-height 30px
          .iconfont
            font-size .5rem
        .send
          width 1rem
          text-align center
          padding-right .2rem
          font-size .32rem
          color #666
        .content
          flex 1
          align-self stretch
          background #fff
          width 100%
          min-height 100%
          max-height 5em
          overflow-x hidden
          overflow-y scroll
          outline none
          padding .1rem .2rem
          box-sizing border-box
          line-height 1.4
          border 1px solid #ddd
          border-radius 3px
          word-break break-all
          >img
            width 1.4em
      .center
        height 100%
        padding .1rem .2rem .1rem .3rem
        flex 1
        overflow hidden
        box-sizing border-box
        .sketch
          color #f33
        p
          background #eee
          color #999
          height 100%
          border-radius .7rem
          line-height .6rem
          padding 0 .2rem
          overflow hidden
          text-overflow ellipsis
          white-space nowrap
      .right
        width 1.8rem
        display flex
        height 100%
        position relative
        text-align center
        >div
          width .8rem
          .iconfont
            font-size .4rem
</style>

 

 类似资料: