共计 16323 个字符,预计需要花费 41 分钟才能阅读完成。
近期因为产品迭代,须要新增一个评论性能,且须要反对插入自定义表情。评论性能很多人一开始跟我一样,第一个想到的就是用 textarea,然而 textarea 是不反对的插入图片的,因为咱们的表情包是以图片的模式插入文本中的,所以这里是应用 HTML5 的新个性 contenteditable,让 div 里的内容变成可编辑的。
demo 地址
先看下效果图:
组件性能:
- 反对插入自定义表情
- 字符验证,超出局部主动切割(以字符进行计算,不是以长度进行计算,一个中文 2 个字符,一个字母 1 个字符,一个表情 4 个字符), 因为咱们公司业务是应用字符进行字数统计,所以验证是用字符,须要用 length 能够自行批改
- 在光标地位精确插入表情
遇到的问题:(先列出问题,前面再写解决办法)
- 如果有对显示的内容进行强制更新时(如插入表情包,或者字符切割),光标会跑到文本最后面
- 无奈间接利用 vue 进行双向绑定,无奈用 v -model 与 v -html 进行数据双向绑定
- 定位光标地位,如果是在输出时就点击表情包 应该插入到最光标地位,如果不是在输出时候点击表情应该插入到文本到最初面
- 进行文本字节计算时对表情的解决,表情占位符如何保障惟一
- 超出字数限度如何切割 当超出的字符是表情的时候怎么切割
- 按下回车会插入 <div><br></div> 标签,或者会用 <div> 标签包裹住文本
- 按下空格符会有 ,以字符进行计算的话空格符 会被当成 6 个字符,实际上空格代表 1 个
兼容性问题:
- IE9 以及局部 Safari createContextualFragment 不兼容该办法
- window.getSelection,window.getSelection().getRangeAt 这两个办法的兼容性,因为咱们的业务不须要兼容低版本的 IE 所以这里我就没做兼容
开始实现
html 局部于 css 局部比较简单就不过多赘述,先不贴代码了,文末看残缺代码或者间接下载残缺小 demo 运行看看
参数
既然是封装成组件,那就须要从父组件那里接管一下数据
submitCmtLoading: { // 是否正在提交,如果是正在提交就不须要反复提交了
type: Boolean,
default: false
},
limtText: { // 限度的字符数,默认是 0,0 是不须要限度
type: Number,
default: 0
},
iconWidth: { // 插入的表情宽度
type: Number,
dafault: 24
},
iconHeight: { // 插入的表情高度
type: Number,
dafault: 24
},
cmt_show: { // 是否显示组件
type: Boolean,
default: true
},
iconList: { // 表情包列表,以数组的模式且是必传项
type: Array,
required: true
},
placeholder: { // 提醒语
type: String,
default: "踊跃回复可吸引更多人评论"
},
info: { // 信息,这个看业务需要,如果须要对具体项在子组件中进行解决能够传递这个
type: Object
}
因为还没贴上 html 代码,下文中呈现的 this.$refs.cmt_input 都代表输入框,既带属性 contenteditable 的元素
数据
data: function() {
return {
content: "", // 评论输出的内容
widFouce: "", // 用于定位输入框失焦点前的光标地位
rangeFouce: "", // 用于定位输入框失焦点前的光标地位
iconShow: false, // 是否显示表情列表
isSubmit: false // 是否提交 如果输出内容为空是不给提交的
};
},
watch: {
cmt_show: { // 当组件显示时须要将光标定位到文末,不然第一次点击表情包会报错
handler(value) {if (value) {this.$nextTick(() => {this.toLast(this.$refs.cmt_input); // 将光标定位到文末
});
}
},
immediate: true
}
},
在表情列表开展的时候,点击其余非表情列表区域要让这个表情列表隐没。
mounted() {
const self = this;
document
.getElementsByClassName("g-doc")[0]
.addEventListener("click", self.setHideClick); // g-doc 是根节点,当然也能够换成 body 啥的,然而在组件销毁的时候要把工夫也给移除掉
self.$once("hook:beforeDestroy", () => {document.getElementsByClassName("g-doc")[0] &&
document
.getElementsByClassName("g-doc")[0]
.removeEventListener("click", self.setHideClick);
});
},
// setHideClick 办法,放在 method 里的
setHideClick(event) {
let target = event.srcElement;
let nameList = ["icon_item", "icon_list", "icon_box"]; // 这个三个类名是整个表情包列表
if (!nameList.includes(target.className) &&
!nameList.includes(target.parentElement.className)
) { // 如果不是表情包列表区域就敞开表情包列表面板
this.iconShow = false;
}
},
办法
几个简略点的办法,不过多的解说
** 问题:如果有对显示的内容进行强制更新时(如插入表情包,或者字符切割),光标会跑到文本最后面
解决办法:每次进行更新操作后调一次 toLast(obj)办法,让光标回到最初面,当然了,如果光标不在最初面的就不须要调这个办法了 **
toLast(obj) {
// 将光标移到最初,obj 为输入框节点
if (window.getSelection) {let winSel = window.getSelection();
winSel.selectAllChildren(obj);
winSel.collapseToEnd();}
},
blurInput() { // 失焦时触发的办法,失焦的时候保留光标地位
this.getFouceInput();},
getFouceInput() { // 保留光标地位
this.widFouce = window.getSelection();
this.rangeFouce = this.widFouce.getRangeAt(0);
},
showIconListPlane() { // 显示或者敞开表情包列表
// 显示表情包列表
if (!this.iconShow) { // 如果是关上,要保留一下光标地位
this.getFouceInput();}
this.iconShow = !this.iconShow;
},
submitCmt() {
// 提交评论
const self = this;
let text = this.$refs.cmt_input.innerHTML;
let length = this.getCharLen(this.paseText(text).text);
if (self.submitCmtLoading || !self.isSubmit) return;
if (self.cmt_text == "") {self.$emit("submitError", "评论为空"); // 不合乎规定时则抛出谬误的办法 submitError
return;
} else if (this.limtText && length > this.limtText) {self.$emit("submitError", "评论超出字数"); // 不合乎规定时则抛出谬误的办法 submitError
return;
}
self.$emit("submitSuccess", text, self.info); // 验证通过则抛出正确的办法,且将文本与信息同时传递给父组件
}
字符验证
接下来讲一下字符验证的函数,因为光标相干的逻辑里有用到局部字符验证,所以先讲一下字符相干的办法。
判断文本占了多少个字符,这里传进来的文本是通过解决的,因为如果没解决过的文本里如果带表情包,而表情包是以 img 标签存在文本里的这样会占据很多个字符,所以会对表情包做一个占位符解决,让一个表情包只占 4 个字符,前面会讲这个。当初先看下判断字符个数的办法,因为如果是输出空格会变成 所以在这个办法里也会对这个进行解决
getCharLen(sSource) {
// 空格 要当 1 个字符算,所以最初要给每个空格减去 5
// 获取字符长度
var l = 0;
var schar;
for (var i = 0; (schar = sSource.charAt(i)); i++) {l += schar.match(/[^\x00-\xff]/) != null ? 2 : 1;
}
let nbsp = sSource.match(/ /gi);
if (nbsp) {
let len = nbsp.length;
l = l - len * 5;
}
return l;
},
应用占位符来代替表情包,因为 1 个表情包为 4 个字节,所以用 4 个数字来占位。这里应用随机获取工夫戳的 4 为数,而后与文本进行比拟,如果文本中存在,则递归再次获取,直到文本中不存在这 4 个数字。
对于回车键会让文本中增加 div 与 br 标签,因为咱们的评论是不容许手动换行的,所以把 div 标签与 br 标签都替换成两个空格“”留神不是
这也就解决了下面说的这三个问题
-
进行文本字节计算时对表情的解决,表情占位符如何保障惟一
- 按下回车会插入 <div><br></div> 标签,或者会用 <div> 标签包裹住文本
- 按下空格符会有 ,以字符进行计算的话空格符 会被当成 6 个字符,实际上空格代表 1 个
getRandomFour(str) {
// 在工夫戳里取随机 4 位数作为 key,且如果文本中蕴含 key 则递归
let timeStr = new Date().getTime() + "";
let result = "";
for (let i = 0; i < 4; i++) {let nums = Math.floor(Math.random() * 13);
result = result + timeStr[nums];
}
return str.indexOf(result) == -1 ? result : this.getRandomFour(str);
},
paseText(str) {
// 解析内容,把图片全副用占位替换掉 跟换行相干(div,br)全副改成两个空格
let str1 = str.replace(/<div>|<\/div>|<br>/gi, " ");
let imgReg = /<img[^>]*src[=\'\"\s]+([^\"\']*)[\"\']?[^>]*>/gi;
let imgMatch = str1.match(imgReg);
let key = this.getRandomFour(str1);
let isReplace = false;
if (imgMatch) {
isReplace = true;
for (let i = 0; i < imgMatch.length; i++) {str1 = str1.replace(imgMatch[i], key);
}
}
return {
isReplace, // 这个是有没有进行占位符替换的标示,如果没有前面就不须要进行还原操作
key,
text: str1
};
},
reductionStr(sourceText, text, key) {
// 解析内容,把图片全副用占位替换掉 sourceText:原文本 text:替换后的文本 key:占位符标示
let imgReg = /<img[^>]*src[=\'\"\s]+([^\"\']*)[\"\']?[^>]*>/gi;
let imgMatch = sourceText.match(imgReg);
let result = text;
if (imgMatch) {for (let i = 0; i < imgMatch.length; i++) {result = result.replace(key, imgMatch[i]);
}
}
return result;
},
当超出限度时进行文本切割,留神:如果是图片占位符,须要整个进行切割,不能切割局部,不然就会产生多余的文本 如 超出 1 个字符,然而最初为表情包,而表情包为 4 个数字的占位符,要切割的时候要把 4 个数字一起移除掉,不能只切除一个这样前面就跟 key 值匹配不上了。
这也就解决下面说的这个问题
- 超出字数限度如何切割 当超出的字符是表情的时候怎么切割
setCmtTextByLimit(sSource, key) {
let self = this;
// 文字切割 如果超出文字限度须要切割
if (typeof sSource !== "string") return;
var str = changeLast(sSource, key); // 先判断最初是否为表情包
if (this.getCharLen(str) <= this.limtText) return str;
while (this.getCharLen(str) > this.limtText) {
str =
4 + str.lastIndexOf(key) == str.length
? str.substring(0, str.length - 4)
: str.substring(0, str.length - 1);
}
function changeLast(strl, key) {
// 始终切割到最初一个不为表情包或不超出限度为止
while (4 + strl.lastIndexOf(key) == strl.length &&
self.getCharLen(strl) > self.limtText
) {strl = sSource.substring(0, sSource.length - 4);
}
return strl;
}
return str;
},
输出与光标
输出时要把文本实时传给父组件,且输出时要进行字符判断,超出时就不能输出。当然先要进行是否验证的判断,如果不须要验证则省略前面的一系列步骤,
changeText(e) {
// 表情包插入时不触发该办法,只有输出时会触发
// 判断字数,要先把自定义表情改成占位符,一个自定义表情按俩 2 个字符算
let text = e.srcElement.innerHTML;
let emitText = text;
if (this.limtText) {let textObj = this.paseText(text); // 文本替换
let len = this.getCharLen(textObj.text); // 获取字符长度
if (len > this.limtText) {let str = this.setCmtTextByLimit(textObj.text, textObj.key); // 字符切割
emitText = textObj.isReplace
? this.reductionStr(text, str, textObj.key)
: str; // 如果有进行替换 要将文本还原
e.srcElement.innerHTML = emitText;
this.toLast(e.srcElement); // 进行强制批改内容后 要把光标定位到最初
}
}
this.isSubmit = emitText.length ? true : false; // 输入框不为空则能够提交
this.$emit("changeText", emitText);
},
插入表情时,须要先进行判断加上 4 个字符是否超出字符限度,如果超出了就不给插入。而且这里要判断上一次光标的状态,如果上一次的光标是在输入框里,那么就在光标地位插入表情且将光标地位定位至该表情前方,如果上一次的光标不在输入框里,那么就间接在文末插入表情包。对于创立节点 createContextualFragment 在 IE9 跟局部 safari 浏览器中不兼容,能够改成 createElement 来进行创立,当然也能够都写成 createElement,这里写 createContextualFragment 只是我懒得改哈哈哈。
解决问题:
- 定位光标地位,如果是在输出时就点击表情包 应该插入到最光标地位,如果不是在输出时候点击表情应该插入到文本到最初面
- IE9 以及局部 Safari createContextualFragment 不兼容该办法
insertIcon(url) { // 插入表情,url 为表情地址
// 判断是否超出字数,如果超出不给插入
const self = this;
this.isSubmit || (this.isSubmit = true);
const length = this.getCharLen(this.paseText(this.$refs.cmt_input.innerHTML).text,
);
if (this.limtText && length + 4 > this.limtText) return;
const img = `<img src='${url}' width=${this.iconWidth} height=${this.iconHeight} />`;
// 兼容性判断 如果不兼容不往下执行,尽管说不兼容 IE9 以下,然而还是做一下判断 不便前面灵便管制
if (window.getSelection && window.getSelection().getRangeAt) {
const winSn = this.widFouce;
let range = this.rangeFouce;
// 要判断的光标状态,如果上一次光标不在输入框里,须要手动定位到输入框里
if (
winSn.focusNode.className !== 'content_edit'
&& winSn.focusNode.parentElement.className !== 'content_edit'
) {winSn.selectAllChildren(self.$refs.cmt_input); // 选中输入框里的元素
winSn.collapseToEnd(); // 定位光标至文末
range = winSn.getRangeAt(0);
}
range.collapse(false);
let node;
if (range.createContextualFragment) {
// 兼容 IE9 跟 safari,以下为创立 img 节点
node = range.createContextualFragment(img);
} else {const tempDom = document.createElement('div');
tempDom.innerHTML = img;
node = tempDom;
}
const dom = node.firstChild;
range.insertNode(dom); // 将表情包节点增加进文本
const clRang = range.cloneRange(); // 复制 range 对象,留神这里复制的不是援用,所以在复制的对象上做批改不会影响到原对象
clRang.setStartAfter(dom); // 设置光标位于表情节点的前方,到这里文本里的表情还是选中状态,这里设置的是克隆后的 range
winSn.removeAllRanges(); // 移除选中状态
winSn.addRange(clRang); // 将克隆的 range 增加进去
self.$emit('changeText', self.$refs.cmt_input.innerHTML); // 因为监听 input,无奈监听到表情包的输出,所以这里要再向父组件再抛出一次办法
} else {console.log('不兼容~');
}
},
还有一个问题就是无奈间接利用 vue 进行双向绑定,无奈用 v -model 与 v -html 进行数据双向绑定:
因为输入框是通过 contenteditable 来实现,所以没方法用 v -model 进行文本内容的双向绑定,应用 v -html 也是没方法进行双向绑定的。所以这里是通过监听输出的 input 办法与插入表情包的办法来进行实时向上传递最新文本。
html 代码
<!-- 父组件 -->
<Comment
:info="info"
:submitCmtLoading="submitCmtLoading"
:limtText="limtText"
:iconWidth="iconWidth"
:iconHeight="iconHeight"
:cmt_show="cmt_show"
:iconList="iconList"
@changeText="changeText"
@submitError="submitError"
@submitSuccess="submitSuccess"
/>
<!-- 子组件 -->
<div v-if="cmt_show" class="cmt_box">
<div
ref="cmt_input"
class="content_edit"
contenteditable="true"
:placeholder="placeholder"
@focus="changeHandle"
@input="changeText"
@blur="blurInput"
v-html="content"
></div>
<div class="cmt_handle">
<img
id="emoticon_icon"
v-if="iconList.length"
src="//www1.pconline.com.cn/20200929/pgc/cmt/icon.png"
@click.stop="showIconListPlane"
/>
<div
@click="submitCmt()"
:class="['btn_submit', isSubmit ?'btn_submit_y':'']"
>
公布
</div>
<div v-show="iconShow" class="icon_list">
<ul class="icon_box">
<li
class="icon_item"
v-for="(item, index) in iconList"
:key="`icon${index}`"
@click="insertIcon(item)"
>
<img :src="item" />
</li>
</ul>
</div>
</div>
</div>
组件残缺代码
<template>
<div v-if="cmt_show" class="cmt_box">
<div
ref="cmt_input"
class="content_edit"
contenteditable="true"
:placeholder="placeholder"
@focus="changeHandle"
@input="changeText"
@blur="blurInput"
v-html="content"
></div>
<div class="cmt_handle">
<img
id="emoticon_icon"
v-if="iconList.length"
src="//www1.pconline.com.cn/20200929/pgc/cmt/icon.png"
@click.stop="showIconListPlane"
/>
<div
@click="submitCmt()"
:class="['btn_submit', isSubmit ?'btn_submit_y':'']"
>
公布
</div>
<div v-show="iconShow" class="icon_list">
<ul class="icon_box">
<li
class="icon_item"
v-for="(item, index) in iconList"
:key="`icon${index}`"
@click="insertIcon(item)"
>
<img :src="item" />
</li>
</ul>
</div>
</div>
</div>
</template>
<script>
export default {
name: "Comment",
props: {
submitCmtLoading: {
type: Boolean,
default: false
},
limtText: {
type: Number,
default: 0
},
iconWidth: {
type: Number,
dafault: 24
},
iconHeight: {
type: Number,
dafault: 24
},
cmt_show: {
type: Boolean,
default: true
},
iconList: {
type: Array,
required: true
},
placeholder: {
type: String,
default: "踊跃回复可吸引更多人评论"
},
info: {type: Object}
},
data: function() {
return {
content: "",
widFouce: "",
rangeFouce: "",
iconShow: false,
isSubmit: false
};
},
watch: {
cmt_show: {handler(value) {if (value) {this.$nextTick(() => {console.log("this.$refs.cmt_input", this.$refs.cmt_input);
this.toLast(this.$refs.cmt_input);
});
}
},
immediate: true
}
},
mounted() {
const self = this;
document.getElementById("app").addEventListener("click", self.setHideClick);
self.$once("hook:beforeDestroy", () => {document.getElementById("app") &&
document
.getElementById("app")
.removeEventListener("click", self.setHideClick);
});
},
methods: {setHideClick(event) {
let target = event.srcElement;
let nameList = ["icon_item", "icon_list", "icon_box"];
if (!nameList.includes(target.className) &&
!nameList.includes(target.parentElement.className)
) {this.iconShow = false;}
},
changeHandle() {console.log("blur");
},
changeText(e) {
// 表情包插入时不触发该办法,只有输出时会触发
// 判断字数,要先把自定义表情改成占位符,一个自定义表情按俩 2 个字符算
let text = e.srcElement.innerHTML;
let emitText = text;
if (this.limtText) {let textObj = this.paseText(text);
let len = this.getCharLen(textObj.text);
if (len > this.limtText) {let str = this.setCmtTextByLimit(textObj.text, textObj.key);
emitText = textObj.isReplace
? this.reductionStr(text, str, textObj.key)
: str;
e.srcElement.innerHTML = emitText;
this.toLast(e.srcElement);
}
}
this.isSubmit = emitText.length ? true : false;
this.$emit("changeText", emitText);
},
paseText(str) {
// 解析内容,把图片全副用占位替换掉 跟换行相干(div,br)全副改成两个空格
let str1 = str.replace(/<div>|<\/div>|<br>/gi, " ");
// eslint-disable-next-line no-useless-escape
let imgReg = /<img[^>]*src[=\'\"\s]+([^\"\']*)[\"\']?[^>]*>/gi;
let imgMatch = str1.match(imgReg);
let key = this.getRandomFour(str1);
let isReplace = false;
if (imgMatch) {
isReplace = true;
for (let i = 0; i < imgMatch.length; i++) {str1 = str1.replace(imgMatch[i], key);
}
}
return {
isReplace,
key,
text: str1
};
},
/**
* @description: 还原评论内容
*/
reductionStr(sourceText, text, key) {
// 解析内容,把图片全副用占位替换掉
// eslint-disable-next-line no-useless-escape
let imgReg = /<img[^>]*src[=\'\"\s]+([^\"\']*)[\"\']?[^>]*>/gi;
let imgMatch = sourceText.match(imgReg);
let result = text;
if (imgMatch) {for (let i = 0; i < imgMatch.length; i++) {result = result.replace(key, imgMatch[i]);
}
}
return result;
},
getRandomFour(str) {
// 在工夫戳里取随机 4 位数作为 key,且如果文本中蕴含 key 则递归
let timeStr = new Date().getTime() + "";
let result = "";
for (let i = 0; i < 4; i++) {let nums = Math.floor(Math.random() * 13);
result = result + timeStr[nums];
}
return str.indexOf(result) == -1 ? result : this.getRandomFour(str);
},
getCharLen(sSource) {
// 空格 要当 1 个字符算,所以最初要给每个空格减去 5
// 获取字符长度
var l = 0;
var schar;
for (var i = 0; (schar = sSource.charAt(i)); i++) {
// eslint-disable-next-line no-control-regex
l += schar.match(/[^\x00-\xff]/) != null ? 2 : 1;
}
let nbsp = sSource.match(/ /gi);
if (nbsp) {
let len = nbsp.length;
l = l - len * 5;
}
return l;
},
setCmtTextByLimit(sSource, key) {
let self = this;
// 文字切割 如果超出文字限度须要切割
if (typeof sSource !== "string") return;
var str = changeLast(sSource, key);
if (this.getCharLen(str) <= this.limtText) return str;
while (this.getCharLen(str) > this.limtText) {
str =
4 + str.lastIndexOf(key) == str.length
? str.substring(0, str.length - 4)
: str.substring(0, str.length - 1);
}
function changeLast(strl, key) {
// 始终切割到最初一个不为表情包或不超出限度为止
while (4 + strl.lastIndexOf(key) == strl.length &&
self.getCharLen(strl) > self.limtText
) {strl = sSource.substring(0, sSource.length - 4);
}
return strl;
}
return str;
},
showIconListPlane() {
// 显示表情包列表
if (!this.iconShow) {this.getFouceInput();
}
this.iconShow = !this.iconShow;
// iconShow
},
getFouceInput() {this.widFouce = window.getSelection();
this.rangeFouce = this.widFouce.getRangeAt(0);
},
toLast(obj) {
// 将光标移到最初
if (window.getSelection) {let range = window.getSelection();
range.selectAllChildren(obj);
range.collapseToEnd();}
},
insertIcon(url) {
// 判断是否超出字数,如果超出不给插入
const self = this;
this.isSubmit || (this.isSubmit = true);
let length = this.getCharLen(this.paseText(this.$refs.cmt_input.innerHTML).text
);
if (this.limtText && length + 4 > this.limtText) return;
const img = `<img src='${url}' width=${this.iconWidth} height=${this.iconHeight} />`;
// 兼容性判断 如果不兼容不往下执行,尽管说不兼容 IE9 以下,然而还是做一下判断 不便前面灵便管制
if (window.getSelection && window.getSelection().getRangeAt) {
let winSn = this.widFouce,
range = this.rangeFouce;
// 要判断的光标状态
if (
winSn.focusNode.className !== "content_edit" &&
winSn.focusNode.parentElement.className !== "content_edit"
) {winSn.selectAllChildren(self.$refs.cmt_input);
winSn.collapseToEnd();
range = winSn.getRangeAt(0);
}
range.collapse(false);
let node;
if (range.createContextualFragment) {
// 兼容 IE9 跟 safari
node = range.createContextualFragment(img);
} else {let tempDom = document.createElement("div");
tempDom.innerHTML = img;
node = tempDom;
}
let dom = node.firstChild;
range.insertNode(dom);
let clRang = range.cloneRange();
clRang.setStartAfter(dom);
winSn.removeAllRanges();
winSn.addRange(clRang);
self.$emit("changeText", self.$refs.cmt_input.innerHTML);
} else {console.log("不兼容~");
}
},
blurInput() {this.getFouceInput();
},
submitCmt() {
// 提交评论
const self = this;
let text = this.$refs.cmt_input.innerHTML;
let length = this.getCharLen(this.paseText(text).text);
if (self.submitCmtLoading || !self.isSubmit) return;
if (self.cmt_text == "") {self.$emit("submitError", "评论为空");
return;
} else if (this.limtText && length > this.limtText) {self.$emit("submitError", "评论超出字数");
return;
}
self.$emit("submitSuccess", text, self.info);
}
}
};
</script>
<style lang="scss" scoped>
.cmt_box {
width: 510px;
height: 180px;
background-color: #ffffff;
border-radius: 2px;
border: solid 1px #ececec;
padding: 14px;
box-sizing: border-box;
margin: auto;
.content_edit {
width: 100%;
height: 120px;
outline: none;
border: none;
text-align: left;
font-size: 14px;
&:empty::before {
color: #cccccc;
content: attr(placeholder);
}
}
.cmt_handle {
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
#emoticon_icon {
width: 21px;
height: 21px;
display: block;
flex-shrink: 0;
cursor: pointer;
}
.btn_submit {
width: 68px;
height: 32px;
background-color: #cccccc;
border-radius: 16px;
text-align: center;
line-height: 32px;
color: #ffffff;
font-size: 14px;
cursor: pointer;
&.btn_submit_y {background-color: #f95354;}
}
.icon_list {
position: absolute;
top: 40px;
left: -10px;
width: 280px;
border-radius: 2px;
background: #fff;
box-shadow: 0 5px 18px 0 rgba(0, 0, 0, 0.16);
padding: 15px;
&::before {
content: "";
width: 0;
height: 0;
display: block;
padding: 0;
position: absolute;
top: -10px;
left: 10px;
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-bottom: 10px solid #fff;
}
.icon_box {
list-style: none;
display: flex;
justify-content: start;
align-items: center;
flex-wrap: wrap;
padding: 0;
margin: 0;
.icon_item {
padding: 5px;
text-align: center;
cursor: pointer;
> img {
width: 30px;
height: 30px;
}
}
}
}
}
}
</style>
最初再附上 demo 地址啦
自己前端小学生,欢送交换斧正~