背景:
网页需要显示和发送带 emoji 表情的文本消息(为方便理解, 以 whatsapp 为例, 实际开发中待定)
同时, 要求不同系统打开网页时, 看到的都是同一套 emoji , 避免同一个 emoji 在不同电脑上显示不同
概述:
- 引入 twemoji 库文件
- 把网页版 wa 的 emoji 全部复制下来
- 新增 emoji 组件, 点击表情图标弹出表情框, 框内显示与 wa 一致
- 点选框中表情, 根据点击前光标在输入框(contentEditable 的 div)的位置, 插入 twemoji.parse 转换过的表情(图片)
- 给各处可能显示 twemoji 的 div 加上特定 class(比如 twemoji-convert), 在程序主界面(Main.vue)新增 MutationObserver , 在 DOM 变化时选取此类 class 元素, 使用 twemoji.parse 转换元素, 使显示 emoji
实现过程:
-
引入 twemoji
<!-- Start twemoji 库文件 --> <script src="https://twemoji.maxcdn.com/v/13.1.0/twemoji.min.js" integrity="sha384-gPMUf7aEYa6qc3MgqTrigJqf4gzeO6v11iPCKv+AP2S4iWRWCoWyiR+Z7rWHM/hU" crossorigin="anonymous"></script> <!-- End twemoji 库文件 -->
-
复制 wa emoji
在网页版 whatsapp 上聊天, 一栏栏地点选表情, 发送, 复制下来, 此时接收到的内容已经是字符了, 把这些字符按顺序提取为数组;
这个需要耐心, 这些个字符千奇百怪, 有的字符电脑系统不支持不能渲染出来, 有的字符后面需要接一个空格, 有的字符看上去只有一位但实际占了多位, 最多的还是由多种字符组合显示成一个表情的(字符人, 可加修饰字符: 性别, 发型, 职业, with another one …), 千万别弄错了
-
新增 emoji 组件
渲染表情部分由全局的 MutationObserver 负责(twemoji.parse)
选中表情部分如下:
// 点击选中 emoji handleClickEmoji(e) { // 取选中的 emoji DOM 标签 let emojiImg; if (e.target.classList.contains('emoji-item')) { emojiImg = e.target.querySelector('img.emoji'); } else if (e.target.classList.contains('emoji')) { emojiImg = e.target; } // 取标签上的 alt (实体字符, twemoji 转换后自带)传给外部 if (emojiImg) { this.$emit('checkEmoji', emojiImg.getAttribute('alt')); } }
-
输入框接收选中表情, 加入到输入框中
输入框 div
<!-- 因为正常 textarea 无法显示 emoji img , 现在将输入框改为 contentEditable div --> <div :contentEditable="true" ref="sendMsg" @click="save_range" @keyup="save_range" @keydown="inputOnKeyDown" @paste="handlePaste" :placeholder="$t('chat.inputbox')" :class="{'waInputDiv__disabled': inputDisabled}" class="waInputDiv"></div>
输入框 div 相关事件
// inputOnKeyDown 处理回车, ctrl 等事件, 与表情主逻辑无关, 略过 // 离开焦点时先保存状态(光标等信息) save_range() { let range = null; if (window.getSelection) { const sel = window.getSelection(); if (sel.getRangeAt && sel.rangeCount) { range = sel.getRangeAt(0); } } else if (document.selection && document.selection.createRange) { range = document.selection.createRange(); } this.lastEditRange = range; } // 粘贴内容到可编辑 div (参考 https://www.zhangxinxu.com/wordpress/2016/01/contenteditable-plaintext-only/) handlePaste(e) { e.preventDefault(); let text; if (window.clipboardData && window.clipboardData.setData) { // IE text = window.clipboardData.getData('text'); } else { text = (e.originalEvent || e).clipboardData.getData('text/plain'); } if (document.body.createTextRange) { let textRange; if (document.selection) { textRange = document.selection.createRange(); } else if (window.getSelection) { const sel = window.getSelection(); const range = sel.getRangeAt(0); // 创建临时元素,使得TextRange可以移动到正确的位置 const tempEl = document.createElement('span'); tempEl.innerHTML = '&#FEFF;'; range.deleteContents(); range.insertNode(tempEl); textRange = document.body.createTextRange(); textRange.moveToElementText(tempEl); tempEl.parentNode.removeChild(tempEl); } textRange.text = text; textRange.collapse(false); textRange.select(); } else { // Chrome之类浏览器 document.execCommand('insertText', false, text); } }
选中表情相关事件
// 接收"选中 emoji 表情"事件 handleCheckEmoji(val) { // 获取待插入表情 Node let dom_insert = document.createElement('span'); dom_insert.innerHTML = twemoji.parse(val); dom_insert = dom_insert.childNodes[0]; // 插入 Node 到输入框 this.insertInputMsg(dom_insert); } // 插入 emoji 表情到输入框 insertInputMsg(val) { // 获取待插入结点 let dom_insert; if (val instanceof Node) { // 是 Node 结点, 不用做处理 dom_insert = val; } else { // 否则当做文本结点处理 dom_insert = document.createTextNode(String(val || '')); } // 获取编辑框对象 const dom_input = this.$refs.sendMsg; // 编辑框设置焦点 dom_input.focus(); // 获取选定对象 let selection = null; if (window.getSelection) { selection = window.getSelection(); } else if (window.document.getSelection) { selection = window.document.getSelection(); } else if (window.document.selection) { selection = window.document.selection.createRange().text; } // 如果获取不到, 退出流程 if (!selection) { this.$Message.error(this.$t('whatsapp_manage.browserError')); return false; } // 判断是否有最后光标对象存在 if (this.lastEditRange) { // 存在最后光标对象,选定对象清除所有光标并添加最后光标还原之前的状态 selection.removeAllRanges(); selection.addRange(this.lastEditRange); } // 根据所在位置的不同以不同的方式插入结点 if (this.lastEditRange) { // 有光标对象, 直接插入 this.lastEditRange.insertNode(dom_insert); } else if (selection.anchorNode == dom_input) { // 焦点就在文本框, 则直接 append node 到最后 dom_input.appendChild(dom_insert); } else if (selection.anchorNode.nodeName != '#text') { // 焦点在非文本结点, 则插入到焦点节点后面 dom_input.insertBefore(dom_insert, selection.anchorNode.nextSibling); } // 创建新的光标对象 const range = document.createRange(); // 光标对象的范围界定为新建的内容节点 range.setStartAfter(dom_insert); // 插入空格, 否则光标可能不显示 // dom_input.insertBefore(document.createTextNode(' '), dom_insert.nextSibling); // range.setStart(dom_insert.nextSibling, 1); // 使光标开始和光标结束重叠 range.collapse(true); // 清除选定对象的所有光标对象 selection.removeAllRanges(); // 插入新的光标对象 selection.addRange(range); // 无论如何都要记录最后光标对象 this.lastEditRange = selection.getRangeAt(0); }
-
主界面监听 DOM 变动, twemoji.parse 转化指定 class 元素内部的实体字符为表情
mounted() { // 监听 DOM 变化, 变化时使用 twemoji 库转化 emoji 实体字符为 twemoji emoji this.observer = new MutationObserver(function(mutations, observe) { const domList = document.querySelectorAll('.twemoji-convert'); for (let i = 0; i < domList.length; i++) { twemoji.parse(domList[i]); } }); this.observer.observe(document.body, { 'childList': true, 'characterData': true, 'subtree': true }); }
补充:
配合 Vue 使用时, 表情和 emoji 混杂的文本, 使用 twemoji.parse 后会破坏 vue 的响应式监听, 导致视图不随数据的更新而更新; 解决方法 — 给需要更新的地方加上 key , key 上绑定原数据, 这样, 当原数据变化时, 组件会重新渲染
另外, 如果使用虚拟滚动表格, 表格内有 emoji 文字, 仍然有问题, 滚动后其他纯文字单元格也显示成了带 emoji 的那个单元格内容, 暂未解决