乐趣区

关于前端:使用可编辑div实现插入内容表情识别敏感词

要实现的需要成果: 须要实现 插入指定文本 信息、插入表情符号 动静检索敏感词 并标红提醒

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">
    <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
        :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>
      <div class="banned-word_container" v-if="bannedWord">
        <div v-html="bannedWordTip"></div>
      </div>
    </div>
  </div>
</template>

<script>
import deepClone from 'lodash/cloneDeep'
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 = deepClone(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>
退出移动版