要实现的需要成果: 须要实现 插入指定文本 信息、插入表情符号 、 动静检索敏感词 并标红提醒
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">
<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>