要实现的需求效果: 需要实现插入指定文本信息、插入表情符号、动态检索敏感词并标红提示
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>
使用div
的contenteditable
属性可将该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>
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。