@人员功能,at人员功能

因为项目需要,产品要实现@人员功能,网上找了一大堆没有好用的,只能自己写了

准备工作

  1. npm install wangeditor (最好是^4.7.15这个版本,怕下载错的话直接在package.json中写上"wangeditor": "^4.7.15"然后在运行 npm i)
  2. npm install caret-pos
  3. browser.js(自己写的,如果不需要判断浏览器则不需要,代码我贴在下面了)

重要代码分析

在这里插入图片描述

这里的enterEv方法是重点,主要用来监听@字符,然后弹出渲染窗口,该方法下调用了一个 getPosition() 方法,只要用来获取光标位置,设置渲染弹窗的渲染位置,详细看代码中的getPosition()
//根据输入@的位置定位下拉框的位置
this.getPosition()

关键的创建容器是使用wangeditor来初始化的,文档看这里

接下来就是代码了

首先创建
首先创建一个单独的vue文件,抽离组件,文件位置看个人习惯

<template>
  <div class="at-someone-content">
    <div ref="editor" class="editor" :id="id" @keydown="enterEv($event)"></div>
    <div
      v-show="showFlag"
      class="at-someone"
      :style="{
        left: left + 'px',
        top: top + 'px',
        visibility: showFlag
      }"
    >
      <div class="at-someone-box" ref="atSomeoneBox">
        <a-input
          ref="searchInput"
          class="search-input"
          placeholder="请输入姓名"
          v-model.trim="searchKeyword"
          @input="handlerSearchInput"
          @blur="searchBlue"
        />
        <a-spin :loading="userLoading">
          <ul v-if="userList.length" class="mention-option-ul">
            <li
              v-for="(item, index) in userList"
              :key="index"
              class="mention-option-li"
              @click="selectPerson(item)"
            >
              <span class="mention-option-img-cont">
                <img v-if="item.avatar" class="mention-option-img" :src="item.avatar">
                <span v-else class="mention-option-img-name">{{ item.name[0] }}</span>
              </span>
              <span class="mention-option-name">{{ item.name }}</span>
              <span class="mention-option-deptname">{{ item.deptNames}}</span>
            </li>
          </ul>
          <div v-else class="top-team-search-empty">
            <img class="blank-img" src="@/assets/img/zanwushuju.svg">
            <small data-v-e44d7dbc="">未找到相关内容</small>
          </div>
        </a-spin>
      </div>
    </div>
  </div>
</template>

<script>
import E from "wangeditor"
import { Tools } from '@/utils/browser.js'
import { position, offset } from 'caret-pos'
import { getAllUsers } from '@/services/investment/index.js'
export default {
  name: "AtSomeone",
  props: {
    from: {
      type: String,
      default: ''
    },
    placeholder: {
      type: String,
      default: '添加项目跟进,记录重要进展(最多1000字)'
    },
    id: {
      type: String,
      default: 'editor'
    },
    record: {
      type: String,
      default: ''
    },
    height: {
      type: Number,
      default: 71
    },
  },
  data() {
    return {
      showFlag: 'hidden',
      userLoading: false,
      left: '',
      top: '',
      browserType: Tools.browserType(),
      editor: null,
      position: {
        range: '',
        selection: ''
      },
      userList: [],
      searchKeyword: '',
    }
  },
  mounted() {
    this.createEditor()
    this.handlerSearchInput()
  },
  methods: {
    reset() {
      this.editor.txt.clear()
    },
    searchBlue() {
      let time = setTimeout(() => {
        this.showFlag = 'hidden'
        this.searchKeyword = ''
        clearTimeout(time)
        time = null
      }, 300);
    },
    handlerSearchInput() {
      this.userLoading = true
      getAllUsers({
        data: {
          name: this.searchKeyword
        }
      }).then(res => {
        this.$nextTick(() => {
          this.userList = res
        })
      }).finally(() => {
        this.userLoading = false
      })
    },
    createEditor() {
      let editor = new E(`#${this.id}`)
      editor.config.onchange = (newHtml) => {
        // 获取纯文本
        const text = editor.txt.text().replace(/&nbsp;/gi, "")
        // 超出1000字处理
        if (text.length >= 1000) {
          // 因为是富文本编辑器,无法做截取只能提示
          this.$message.warning('最多可输入1000字!')
        }
        this.$emit('updataRecordHtml', newHtml, text)
        
      }
      // 配置触发 onchange 的时间频率,默认为 200ms
      editor.config.onchangeTimeout = 500; // 修改为 500ms
      editor.config.height = this.height
      editor.config.placeholder = this.placeholder
      //菜单置空
      editor.config.menus = []
      //创建
      editor.create()
      editor.txt.html(this.record)
      this.editor = editor
    },
    enterEv (e) {
      let ele = this.editor.$textElem.elems[0]
      //getSelection是另一种获取用户选择的文本范围或光标的当前位置的方法
      let selection = getSelection()
      //判断输入的是@符号
      if (((e.keyCode === 229 && e.key === '@') || (e.keyCode === 229 && e.code === 'Digit2') || e.keyCode === 50) && e.shiftKey)  {
        // 兼容
        e.preventDefault ? e.preventDefault() : e.returnValue = false
        this.position = {
          range: selection.getRangeAt(0),
          selection: selection
        }
        try {
          if (this.editor.$textElem.elems[0].firstChild.tagName === "BR") {
            this.editor.$textElem.elems[0].removeChild(this.editor.$textElem.elems[0].firstChild)
          }
          if (this.editor.$textElem.elems[0].firstChild.firstChild.tagName === "BR") {
            // 解决不输入内容直接@时候出现的<br>换行标签
            this.editor.$textElem.elems[0].firstChild.removeChild(this.editor.$textElem.elems[0].firstChild.firstChild)
          }
        } catch (e) {
          console.log(e)
        }
        //根据输入@的位置定位下拉框的位置
        this.getPosition()
        this.showFlag = 'visible'
        //下拉框搜索框自动获取焦点
        this.$nextTick(()=>{
          this.searchKeyword = ''
          this.$refs.searchInput.focus()
        })
        // this.handlerSearchInput()
      } else if (e.code === 'Backspace' || e.key === 'Backspace') {
        // e.preventDefault()
        //  let selection = getSelection()
        let range = selection.getRangeAt(0)
        let removeNode = null
        if (this.browserType === 'Firefox') {
          console.log(range)
          if (range.startContainer.className !== "at-text") {
            removeNode = range.startContainer.previousElementSibling
            console.dir('Firefox previousElementSibling',removeNode)
          }
          if (range.startContainer.parentElement.className !== "at-text" && range.startContainer.lastChild !== null) {
            removeNode = range.startContainer.lastElementChild.previousElementSibling
            console.dir('Firefox lastElementChild.previousElementSibling', removeNode)
          }
        }
        if (this.browserType === 'Chrome') {
          if (range.startContainer.textContent.length === 1 && range.startContainer.textContent.trim() === '') {
            removeNode = range.startContainer.previousElementSibling
            // console.dir(removeNode)
          }
          if (range.startContainer.parentNode.className === 'at-text') {
            e.preventDefault ? e.preventDefault() : e.returnValue = false
            removeNode = range.startContainer.parentNode
          }
        }
        if (this.browserType === "IE") {

          if (range.startContainer.nodeName !== "P" && range.startContainer.nodeValue.trim() === "" && range.startContainer.previousSibling.className === "at-text") {
            removeNode = range.startContainer.previousSibling
            console.log('parentElement',removeNode)
          }
          if (range.startContainer.parentNode.className === "at-text" && range.startContainer.nodeName === '#text' && range.startContainer.previousSibling == null) {
            removeNode = range.startContainer.parentNode
            console.log('parentNode', removeNode)
          }
        }
        if (removeNode) {
          // ele.removeChild(removeNode)
          ele.firstChild.removeChild(removeNode)
        }
        this.showFlag = 'hidden'
      }
    },
    selectPerson (item) {
      const {name,id} = item
      this.showFlag = 'hidden'
      //获取选区对象
      let selection = this.position.selection
      let range = this.position.range
  
      // 生成需要显示的内容,包括一个 span 和一个空格。
      let spanNode1 = document.createElement('span')
      let spanNode2 = document.createElement('span')
      spanNode1.className = 'at-text'
      spanNode1.style.color = '#3E74CA'
      spanNode1.innerHTML = '@' + name
      spanNode1.dataset.id = id
      //  设置@人的节点不可编辑
      spanNode1.contentEditable = false
      // spanNode2.innerHTML = ' '
      spanNode2.innerHTML = '&nbsp;'
  
      // 将生成内容打包放在 Fragment 中,并获取生成内容的最后一个节点,也就是空格。
      //创建一个新的空白的文档片段
      let frag = document.createDocumentFragment(),
          node, lastNode;
      frag.appendChild(spanNode1)
      while ((node = spanNode2.firstChild)) {
        lastNode = frag.appendChild(node)
      }
      // 将 Fragment 中的内容放入 range 中,并将光标放在空格之后。
      range.insertNode(frag)
      selection.collapse(lastNode, 1)
  
      //将当前的选区折叠到最末尾的一个点
      selection.collapseToEnd();
    },
    //获取@位置
    getPosition () {
      this.showFlag = 'visible'
      const ele = this.editor.$textElem.elems[0]
      const childEle = document.getElementsByClassName("at-someone")[0]
      // console.log(childEle)
      let parentW = ele.offsetWidth
      let parentH = ele.offsetHeight
      let childW = childEle.offsetWidth
      let childH = childEle.offsetHeight
      const pos = position(ele)
      const off = offset(ele)
      // 弹框偏移超出父元素的宽高
      if (parentW - pos.left < childW) {
        this.left = off.left - childW
      } else {
        this.left = off.left
      }
      if (childH + off.top + 20 > document.documentElement.clientHeight) {
        this.top = (childH + parentH+ off.top + off.height + 40) - document.documentElement.clientHeight
      } else {
        this.top = off.top+20
      }

      // 如果是消息归档页面则写死left
      if (['message'].includes(this.from)) {
        this.left = 40
      }
    },
  },
  beforeDestroy() {
    // 销毁编辑器
    this.editor.destroy()
    this.editor = null
  }
}
</script>

<style lang="scss" scoped>
.top-team-search-empty {
  display: flex;
  height: 200px;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  small {
    font-size: 12px !important;
    color: #c1c4cb;
    margin-top: 6px !important;
    display: block;
  }
}
.at-someone-content {
  width: 100%;
  // position: relative;
  :deep(.w-e-toolbar) {
    display: none;
  }
  :deep(.w-e-text-container) {
    border: 0 !important;
    z-index: 1 !important;
    .placeholder {
      left: 0;
      top: 0;
    }
    .w-e-text {
      padding: 0 !important;
      // min-height: 40px !important;
      p {
        margin: 0;
      }
    }
  }
  .search-input {
    border: 0 !important;
  }
  .at-someone {
    max-width: 260px;
    position: fixed;
    z-index: 5;
    border-radius: 3px;
    background: #fff;
    padding: 8px 0;
    border: 1px solid #E4E5EC;
    box-shadow: 0 4px 10px rgb(0 0 0 / 10%);
    // &::after {
    //   content: "";
    //   position: absolute;
    //   left: 14px;
    //   top: 0;
    //   display: block;
    //   box-sizing: border-box;
    //   width: 8px;
    //   height: 8px;
    //   transform: translate(-50%, -50%) rotate(45deg);
    //   z-index: 1;
    //   border: 1px solid #E4E5EB;
    //   border-right: 0;
    //   border-bottom: 0;
    //   background: #fff;
    //   border-top-left-radius: 2px;
    // }
    :deep(.arco-input-wrapper) {
      border-left: 0;
      border-top: 0;
      border-right: 0;
      border-color: #E4E5EC;
      padding: 0 16px;
      box-sizing: border-box;
      border-radius: 0;
      &:hover {
        background: #fff;
      }
    }
  }
}
</style>

组件引用,在需要用到的页面引入组件

import atSomeone from '@/components/atSomeone/index.vue'
components: {
   atSomeone
 },
<atSomeone
 ref="atSomeone"
  :from="from"
  :record="record"
  @updataRecordHtml="updataRecordHtml"
/>
GitHub 加速计划 / vu / vue
207.52 K
33.66 K
下载
vuejs/vue: 是一个用于构建用户界面的 JavaScript 框架,具有简洁的语法和丰富的组件库,可以用于开发单页面应用程序和多页面应用程序。
最近提交(Master分支:1 个月前 )
73486cb5 * chore: fix link broken Signed-off-by: snoppy <michaleli@foxmail.com> * Update packages/template-compiler/README.md [skip ci] --------- Signed-off-by: snoppy <michaleli@foxmail.com> Co-authored-by: Eduardo San Martin Morote <posva@users.noreply.github.com> 3 个月前
e428d891 Updated Browser Compatibility reference. The previous currently returns HTTP 404. 3 个月前
Logo

旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。

更多推荐