要实现的需求效果: 需要实现插入指定文本信息、插入表情符号动态检索敏感词并标红提示

image.png

1. 开始

1.1 实现可编辑div

<div class="text-message">
    <div class="message-input_container">
      <div
        v-html="innerText"
        :placeholder="placeholder"
        ref="messagInput"
        class="message-input"
        :class="{'disabled': disabled}"
        enterkeyhint="send"
        :maxLength="10"
        :contenteditable="!disabled">
      </div>
      <div :class="['input-limit', (value?value.length:0)>maxLength && 'input-limit_beyond']" v-if="maxLength">{{value?value.length:0}}/{{maxLength}}</div>
    </div>
</div>

使用divcontenteditable属性可将该div设置为可编辑!
添加一个length展示当前输入的内容是否在最大可输入范围


1.2 添加一个插入指定文本的标签

<div class="text-message">
    <div class="warp-border-header" v-if="text && text.length">
        <span class="insert-text" @click="insertSpecialText(text)">{{text}}            </span>
    </div>
    // ... 可编辑div
</div>

通过传入参数判断是否需要展示 插入按钮


1.3 添加emoji表情

<a-popover
        :autoAdjustOverflow="false"
        v-model="emojiVisible"
        :getPopupContainer="()=>parentNode"
        trigger="click"
        @visibleChange="hideEmojiSelect">
        <div slot="content" class="emoji-content">
          <a-carousel ref="emojiCarousel" :afterChange="changeEnd">
            <div v-for="(item,index) in emojiPageList" :key="index" class="emoji-page">
              <span class="emoji-item" v-for="(e,i) in item" :key="i" @click="insertEmoji(e.content)">{{ e.content }}</span>
            </div>
            <div slot="customPaging">
              <span class="carousel-circle"></span>
            </div>
          </a-carousel>
        </div>
        <a-icon type="smile" :class="['icon-emoji', disabled && 'icon-emoji_disabled']" />
      </a-popover>

使用antD-vue的弹窗组件实现点击 icon 展示弹窗进行选择表情;此处的表情内容实用 表情文字 不用其他库!例如

export const emojiList = [
  { content: '😀' },
  { content: '😃' },
  { content: '😄' },
  { content: '😁' }
]

1.4 添加敏感词展示区域

<div
      ref="emojiBox"
      class="warp-border-footer"
      id="emoji-parent"
      v-wheel="changeEmojiList"
      wheel-disabled="0">
      // ... 表情区域
      <div class="banned-word_container" v-if="bannedWord">
        <div v-html="bannedWordTip"></div>
      </div>
    </div>

敏感词使用自定义引入方式

export const bannedWordList = [
  '借贷协议',
  '返佣',
  '佣金'
]
computed: {
    // 违禁词
    bannedWord() {
      if (!this.value) return '';
      let bannedWord = ''
      new Set(bannedWordList).forEach(item => {
        if (this.value.includes(item)) {
          bannedWord += bannedWord ? `、${item}` : item
        }
      })
      return bannedWord
    },
    // 违禁词提示
    bannedWordTip() {
      return this.bannedWordTipHtml(this.bannedWord)
    }
  },

x. 整体代码

<template>
  <div class="text-message">
    va---{{value}}
    <div class="warp-border-header" v-if="text && text.length">
      <template>
        <span class="insert-text" @click="insertSpecialText(text)">{{text}}</span>
      </template>
    </div>
    <div class="message-input_container">
      <div
        v-html="innerText"
        :placeholder="placeholder"
        ref="messagInput"
        class="message-input"
        :class="{'disabled': disabled}"
        enterkeyhint="send"
        @keydown="limit"
        @input="changeValue"
        @blur="limitLength"
        @focus="handelFocus"
        :maxLength="10"
        :contenteditable="!disabled">
      </div>
      <div :class="['input-limit', (value?value.length:0)>maxLength && 'input-limit_beyond']" v-if="maxLength">{{value?value.length:0}}/{{maxLength}}</div>
    </div>
    <div
      ref="emojiBox"
      class="warp-border-footer"
      id="emoji-parent"
      v-wheel="changeEmojiList"
      wheel-disabled="0">
      <!-- :autoAdjustOverflow="false" 表情弹窗是否固定 -->
      <a-popover
        arrowPointAtCenter
        v-model="emojiVisible"
        :getPopupContainer="()=>parentNode"
        trigger="click"
        @visibleChange="hideEmojiSelect">
        <div slot="content" class="emoji-content">
          <a-carousel ref="emojiCarousel" :afterChange="changeEnd">
            <div v-for="(item,index) in emojiPageList" :key="index" class="emoji-page">
              <span class="emoji-item" v-for="(e,i) in item" :key="i" @click="insertEmoji(e.content)">{{ e.content }}</span>
            </div>
            <div slot="customPaging">
              <span class="carousel-circle"></span>
            </div>
          </a-carousel>
        </div>
        <a-icon type="smile" :class="['icon-emoji', disabled && 'icon-emoji_disabled']" />
      </a-popover>
      <div class="banned-word_container" v-if="bannedWord">
        <div v-html="bannedWordTip"></div>
      </div>
    </div>
  </div>
</template>

<script>
import { cloneDeep } from 'lodash'

const emojiList = [
  { content: '😀' },
  { content: '😃' },
  { content: '😄' },
  { content: '😁' }
]

const bannedWordList = [
  '借贷协议',
  '返佣',
  '佣金',
  '0元领',
  '直播福利']

export default {
  name: 'TextMessage',
  model: {
    prop: 'value',
    event: 'change'
  },
  props: {
    value: {
      type: String,
      default: ''
    },
    /**
     * 插入内容
     *  [{
     show: '输入框顶部显示内容,没有默认选取当前数组content值',
     content: '输入框内显示内容',
     prefix: '选填,包裹字符,不填默认选取prefix字段,格式和prefix格式一样'
     }] / [ '字符串,输入框顶部和内部都为该字符串,包裹字符默认选取prefix字段' ]
     */
    text: {
      // eslint-disable-next-line
      type: Array | String,
      default: () => []
    },
    placeholder: {
      type: String,
      default: '输入内容,shift+enter换行'
    },
    maxLength: {
      type: Number,
      default: 1000
    },
    prefix: { // 替换占位符
      type: Array,
      default: () => ['{', '}']
    },
    // 是否禁用
    disabled: {
      type: Boolean,
      default: false
    },
    free: { // 自由插入
      type: Boolean,
      default: true
    },
    // 违禁词提示
    bannedWordTipHtml: {
      type: Function,
      default: (bannedWord) => {
        return `敏感词提醒:文本中包含
        <span class="banned-word">${bannedWord}</span>
        关键词,可能会被浏览器风控,请谨慎使用`
      }
    }
  },
  directives: {
    wheel: {
      inserted(el, binding) {
        // let timer = null
        const { value } = binding;
        let disabled = false
        el.addEventListener('wheel', (e) => {
          if (Math.abs(e.deltaX) > 1) {
            // clearTimeout(timer)
            // timer = setTimeout(() => {
            //   const isNext = e.deltaX > 0
            //   value(isNext)
            // }, 100)
            disabled = el.getAttribute('wheel-disabled')
            if (disabled === '1') return;
            el.setAttribute('wheel-disabled', '1')
            const isNext = e.deltaX > 0
            value(isNext)
          }
        })
      }
    }
  },
  created() {
    this.handleEmojiList()
  },
  data() {
    return {
      // 输入框内容
      innerText: this.value,
      // 控制光标跑到最前边
      isChange: true,
      // 控制emoji弹窗显示
      emojiVisible: false,
      // emoji父级弹窗
      parentNode: null,
      // emoji列表
      emojiList,
      // emoji分页列表
      emojiPageList: [],
      // 光标位置
      rangeOfInputBox: null,
      rangeStart: null,
      rangeEnd: null,
      // emoji弹窗
      emojiCarouselDisabled: false,
      // emoji滚动定时器
      timer: null
      // 内容改变定时器
      // changeTimer: null,
      // 是否标红
      // isValue: true
    }
  },
  computed: {
    // 违禁词
    bannedWord() {
      if (!this.value) return '';
      let bannedWord = ''
      new Set(bannedWordList).forEach(item => {
        if (this.value.includes(item)) {
          bannedWord += bannedWord ? `、${item}` : item
        }
      })
      return bannedWord
    },
    // 违禁词提示
    bannedWordTip() {
      return this.bannedWordTipHtml(this.bannedWord)
    }
  },
  watch: {
    value() {
      if (this.isChange) {
        let content = this.value
        // console.log(content);
        bannedWordList.forEach(item => {
          content = content.split(item).join(`<span style="color:#f5222d">${item}</span>`)
        })
        this.innerText = content
      }
    }
  },
  methods: {
    /**
     *
     * @description emoji弹窗滑动
     */
    changeEmojiList(isNext) {
      // this.emojiCarouselDisabled = true
      this.$refs.emojiCarousel[isNext ? 'next' : 'prev']()
    },
    changeEnd() {
      clearTimeout(this.timer)
      this.timer = setTimeout(() => {
        this.$refs.emojiBox.setAttribute('wheel-disabled', '0')
      }, 300)
      // this.emojiCarouselDisabled = false
    },
    /**
     *
     * @description 插入特殊文本
     */
    insertSpecialText(text) {
      if (this.disabled) return;
      const content = `${(text.prefix && text.prefix[0]) || this.prefix[0] || ''}${text.content || text}${(text.prefix && text.prefix[1]) || this.prefix[1] || ''}`
      this.insertEmoji(content, this.free)
    },
    /**
     *
     * @param {emoji: 插入的内容, isFree: 是否自由插入}
     * @description 插入emoji表情
     */
    insertEmoji(emoji, isFree = true, isFocus = true) {
      console.log(isFocus);
      const emojiEl = document.createTextNode(emoji);
      if (!this.rangeOfInputBox) {
        this.rangeOfInputBox = new Range();
        this.rangeOfInputBox.selectNodeContents(this.$refs.messagInput);
        // 设为非折叠状态
        this.rangeOfInputBox.collapse(false);
        this.$refs.messagInput.appendChild(emojiEl)
        return this.changeValue()
      }
      if (this.$refs.messagInput.innerText.length < this.maxLength) {
        // let startLength = this.rangeStart
        const edit = this.$refs.messagInput
        const sel = document.getSelection();
        const range = document.createRange();
        range.selectNode(edit);
        if (isFree && this.$refs.messagInput.childNodes.length) {
          const startP = this.getInsertPosition(this.rangeStart)
          const endP = this.getInsertPosition(this.rangeEnd)
          range.collapse(this.rangeStart === this.rangeEnd);
          range.setStart(startP[0], startP[1]);
          range.setEnd(endP[0], endP[1]);
        } else {
          range.collapse(true);
          // range.setStart(edit.childNodes[0], 0);
          // range.setEnd(edit.childNodes[0], 0);
          range.setStart(edit, 0);
          range.setEnd(edit, 0);
        }
        sel.removeAllRanges();
        sel.addRange(range);
        this.rangeOfInputBox = sel.getRangeAt(0)
        // 判断是否折叠状态
        if (this.rangeOfInputBox.collapsed) {
          this.rangeOfInputBox.insertNode(emojiEl);
        } else {
          this.rangeOfInputBox.deleteContents();
          this.rangeOfInputBox.insertNode(emojiEl);
        }
      }
      this.emojiVisible = false
      this.changeValue()
      this.rangeOfInputBox.collapse(true)
      this.$refs.messagInput.blur()
    },
    /**
     * @description 禁用隐藏emoji图标框
     */
    hideEmojiSelect() {
      if (this.disabled) {
        this.emojiVisible = false
      }
    },
    /**
     * @description 处理emoji列表数据
     */
    handleEmojiList() {
      const eachPage = 72
      const page = Math.ceil(this.emojiList.length / eachPage)
      for (let i = 0; i < page; i++) {
        this.$set(this.emojiPageList, i, this.emojiList.slice(i * eachPage, (i + 1) * eachPage))
      }
    },
    /**
     * @description 限制字数长度
     */
    limitLength() {
      this.isChange = true
      if (this.$refs.messagInput.innerText.length > this.maxLength) {
        this.$refs.messagInput.innerHTML = this.$refs.messagInput.innerText.substr(0, this.maxLength)
        this.changeValue()
      }
      let content = this.$refs.messagInput.innerText
      new Set(bannedWordList).forEach(item => {
        content = content.split(item).join(`<span style="color:#f5222d">${item}</span>`)
      })
      this.innerText = content
    },
    /**
     * @description 改变value值
     */
    changeValue() {
      const value = cloneDeep(this.$refs.messagInput.innerText)
      this.$emit('change', value)
    },
    /**
     * @description 限制输入
     */
    limit(e) {
      if (![37, 38, 39, 40, 8].includes(e.keyCode) && this.$refs.messagInput.innerText.length >= this.maxLength) {
        e.preventDefault()
      }
    },
    /**
     * @description 获取光标位置
     */
    getEndFocus() {
      // 获取输入光标位置
      document.onselectionchange = () => {
        const selection = document.getSelection();
        if (selection.rangeCount > 0) {
          const range = selection.getRangeAt(0);
          if (this.$refs.messagInput?.contains(range.commonAncestorContainer)) {
            this.rangeOfInputBox = range;
            if (!this.$refs.messagInput.childNodes.length) return;
            const { startContainer, endContainer, startOffset, endOffset } = range
            const startP = this.getPosition(startContainer, startOffset)
            const endP = this.getPosition(endContainer, endOffset)
            this.rangeStart = startP
            this.rangeEnd = endP
          }
        }
      };
    },
    /**
     * @description 获取光标位置
     */
    getPosition(container, offset) {
      const childList = Array.from(this.$refs.messagInput.childNodes)
      let position = 0
      childList.some((item) => {
        const isSame = (container === item) || (container === item.childNodes[0])
        if (isSame) {
          position += offset
        } else {
          position += item.childNodes.length ? item.childNodes[0].length : item.length
        }
        return isSame
      })
      return position
    },
    /**
     * @description 获取插入时的位置
     */
    getInsertPosition(rangePosition) {
      const edit = this.$refs.messagInput
      // 获取文本框的子节点个数
      const childList = Array.from(edit.childNodes)
      let len = 0
      let i = null
      let p = null
      // 获取当前位置位于的子节点
      childList.some((item, index) => {
        const length = len
        len += item.childNodes.length ? item.childNodes[0].length : item.length
        const r = len >= rangePosition
        if (r) {
          p = rangePosition - (!index ? 0 : length)
          i = index
        }
        return r
      })
      // 返回子节点及其对应的位置
      return [childList[i].childNodes.length ? childList[i].childNodes[0] : childList[i], p]
    },
    /**
     * @description 获取焦点获取光标位置
     */
    handelFocus() {
      this.isChange = false
      this.getEndFocus()
    }
  },
  mounted() {
    // 获取emoji弹窗父元素位置 用来固定弹窗位置
    this.parentNode = this.$refs.emojiBox
    this.getEndFocus()
  }
}
</script>

<style lang="scss">
.has-error {
  .text-message .message-input {
    border-color: #f5222d;
    &:focus {
      border-color: #f5222d;
      box-shadow: 0 0 0 2px rgba(245,34,45,0.2);
    }
  }
}
.text-message {
  .warp-border-header {
    height: 36px;
    line-height: 36px;
    border: 1px solid #D9D9D9;
    border-radius: 4px 4px 0px 0px;
    border-bottom: 0;
    padding-left: 16px;
    .insert-text {
      color: #1AAD19;
      cursor: pointer;
      user-select: none;
    }
    .insert-text + .insert-text {
      margin: 0px 5px;
    }
  }
  .warp-border-footer {
    // height: 36px;
    line-height: 36px;
    border: 1px solid #D9D9D9;
    border-radius: 0px 0px 4px 4px;
    border-top: 0;
    background-color: #f7f7f7;
    // padding-left: 16px;
  }
  .ant-input-affix-wrapper {
    vertical-align: middle;
    textarea.ant-input {
      margin-bottom: 0px;
    }
  }
  .warp-border-context {
    ::v-deep .ant-input {
      border-radius: 0 0 4px 4px;
    }
  }
  .message-input {
    background-color: #fff;
    padding: 10px 10px 20px;
    min-height: 100px;
    line-height: 24px;
    letter-spacing: 1px;
    border: 1px solid #ccc;
    color: rgba(0,0,0,.85);
    z-index: 2;
    white-space: pre-line;
    word-wrap: break-word;
    border-radius: 2px;
    * {
      white-space: initial!important;
    }
    &.disabled{
      color: rgba(0, 0, 0, 0.25);
      background-color: #f5f5f5;
      cursor: not-allowed;
      opacity: 1;
    }
    &:focus-visible {
      // outline: #1D61EF auto 1px;
      outline: 0;
    }
    &:focus {
      border-color: #4786fc;
      border-right-width: 1px !important;
      outline: 0;
      box-shadow: 0 0 0 2px rgba(29,97,239,0.2);
      // border-radius: 4px;
    }
    &:empty:before{
      // 模拟palceholder
      content: attr(placeholder);
      color: #999999;
      font-size: 14px;
    }
    // &:focus:before{
    //   // 模拟palceholder 聚焦
    //   content: none;
    // }
  }
  .icon-emoji {
    font-size: 18px;
    color: rgba(0,0,0,.45);
    margin-left: 16px;
    cursor: pointer;
    user-select: none;
    &.icon-emoji_disabled {
      cursor: not-allowed;
      &:active {
        color: rgba(0,0,0,.45);
      }
    }
    &:active {
      color: rgba(0,0,0,.85)
    }
  }
  .emoji-page {
    padding-bottom: 10px;
  }
  .emoji-content {
    // background-color: #3a4d76;
    // width: 400px;
    width: 360px;
    height: 340px;
    overflow: hidden;
  }
  .carousel-circle {
    display: inline-block;
    width: 5px;
    height: 5px;
    border-radius: 50%;
    background-color: #e5e5e5;
  }
  .slick-active {
    .carousel-circle {
      background-color: #b2b2b2;
    }
  }
  .emoji-item {
    display: inline-block;
    width: 40px;
    height: 40px;
    // width: 30px;
    // height: 30px;
    // margin: 5px;
    line-height: 40px;
    text-align: center;
    font-size: 22px;
    color: #000;
    border-radius: 5px;
    user-select: none;
    cursor: pointer;
    &:hover {
      background-color: #efefef;
    }
    // img {
    //   display: inline-block;
    //   width: 25px;
    //   height: 25px;
    //   vertical-align: middle;
    // }
  }
  .message-input_container {
    position: relative;
  }
  .input-limit {
    position: absolute;
    bottom: -10px;
    right: 10px;
    font-size: 12px;
    user-select: none;
    line-height: 40px;
  }
  .input-limit_beyond {
    color: #f5222d;
  }
  .banned-word_container {
    padding: 8px 16px;
    border-top: 1px solid #D9D9D9;
    line-height: 20px;
  }
  .banned-word {
    color: #f5222d;
  }
}
</style>


songxianling1992
1.3k 声望3.6k 粉丝

当你不知道该选择哪条路的时候;可能你已经走了好一阵子了~