手机端聊天框实现图片表情连续插入
概述
本文介绍如何在手机端聊天框中实现图片表情的连续插入功能,包括在文字中间插入表情和换行显示等功能。通过自定义富文本编辑器实现,解决了使用 document.execCommand 命令导致的焦点问题和页面跳动问题。
效果展示
直接显示表情,且可以在文字中间插入表情,包括换行显示。

技术难点
要想实现文字图片混排,需要用到富文本编辑技术(即添加 contenteditable="true" 属性),但是使用 document.execCommand 命令会让文本框获得焦点,使表情选择框隐藏。如果在插入之后使文本框失去焦点,在苹果手机会因为软键盘弹出收起而导致页面跳动。
为了解决这个问题,只能抛弃 execCommand 命令,把图片直接插入到指定位置。
TIP
富文本编辑器会单独写一篇记录,聊天框使用的只是一个简易版富文本编辑器。
核心API
编辑器相关API
| API | 说明 |
|---|---|
| contenteditable | 设计模式,设置元素是否是可编辑 |
| document.execCommand | 当一个HTML文档切换到设计模式时,允许运行命令来操纵可编辑内容区域的元素 |
光标操作API
| API | 说明 |
|---|---|
| Window.getSelection() | 获取用户选择的文本范围或光标的当前位置 |
| Range | 表示一个包含节点与文本节点的一部分的文档片段 |
节点操作API
| API | 说明 |
|---|---|
| Node.insertBefore() | 在一个节点之前插入一个拥有指定父节点的子节点 |
| Node.appendChild() | 将一个节点附加到指定父节点的子节点列表的末尾处 |
| Text.splitText() | 根据指定的偏移量将一个 Text 节点分割成前后两个独立的兄弟节点 |
| Node.nextSibling | 返回其父节点的 childNodes 列表中紧跟在其后面的节点 |
事件处理API
| API | 说明 |
|---|---|
| element.onpaste | 粘贴事件(需要格式化粘贴的内容,不然会有html) |
实现方案
本示例使用 Vue 框架,如需使用其他框架请自行转换,关键代码都是原生 JS 实现。
HTML结构
html
<div>
<div class="editorBox">
<!-- 输入框 -->
<div
class="editor"
ref="chatTextarea"
id="chatTextarea"
contenteditable="true"
@paste="editorPaste"
@blur="editorBlur"
@focus="editorFocus"
@input="editorInput">
</div>
<!-- 表情开关 -->
<div class="icon" @click="openEmoticon">表情</div>
<!-- 发送按钮 -->
<div class="send" @click="submitSend">发送</div>
</div>
<!-- 表情区域 -->
<div class="emoticonBox" v-show="emoticonShow">
<img
class="emoji"
v-for="(v,k) in emojiData"
:src="'static/emoticon/emoji/'+v+'.png'"
@click="chooseEmoji(k)"
:key="k" />
</div>
</div>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
JavaScript实现
javascript
// ...
export default {
data() {
return {
// ...
emoticonShow: false,
emojiData: {}, // 自己的表情数据
currentSelection: {
startContainer: null,
startOffset: 0,
endContainer: null,
endOffset: 0
},
}
},
mounted() {
// ...
this.init();
},
methods: {
// 初始化编辑器
init(){
const editor = this.$refs.chatTextarea;
// 光标位置为开头
this.currentSelection = {
startContainer: editor,
startOffset: 0,
endContainer: editor,
endOffset: 0
}
// 设置光标位置
this.restorerange();
},
// 备份当前光标位置
backuprange() {
let selection = window.getSelection();
if (selection.rangeCount > 0) {
let range = selection.getRangeAt(0);
this.currentSelection = {
startContainer: range.startContainer,
startOffset: range.startOffset,
endContainer: range.endContainer,
endOffset: range.endOffset
};
}
},
// 设置光标位置
restorerange() {
if(this.currentSelection){
let selection = window.getSelection();
selection.removeAllRanges();
let range = document.createRange();
range.setStart(this.currentSelection.startContainer, this.currentSelection.startOffset);
range.setEnd(this.currentSelection.endContainer, this.currentSelection.endOffset);
// 向选区中添加一个区域
selection.addRange(range);
}
},
// 插入text文本
inserText(text) {
// 插入前先恢复上次的光标位置
this.restorerange();
document.execCommand('insertText', false, text);
},
// 插入html片段
insertHTML(html) {
// 插入前先恢复上次的光标位置
this.restorerange();
document.execCommand('insertHTML', false, html);
},
// 粘贴事件处理
editorPaste(e){
// 阻止默认事件
e.preventDefault();
const clp = e.clipboardData || e.originalEvent && e.originalEvent.clipboardData;
// 获取纯文本与富文本
let plainText, htmlText;
if (clp == null) {
plainText = window.clipboardData && window.clipboardData.getData('text');
} else {
plainText = clp.getData('text/plain');
htmlText = clp.getData('text/html');
}
// 某些ios手机获取不到富文本,需要处理一下
if (!htmlText && plainText) {
htmlText = '<div>' + plainText.replace(/[\n\r]|[\r\n]|[\r]|[\n]/g,'</div><div>') + '</div>';
}
if (!htmlText) {
return;
}
// 聊天框只使用纯文本,富文本编辑器会用到html
this.inserText(plainText);
// 备份当前光标
this.backuprange();
},
// 失去焦点处理
editorBlur(){
// 备份当前光标位置
this.backuprange();
},
// 获取焦点处理
editorFocus(){
// 处理获取到焦点的逻辑
// ...
// 隐藏表情区域
this.emoticonShow = false;
},
// 输入中处理
editorInput(){
// 备份当前光标位置
this.backuprange();
},
// 打开表情
openEmoticon(){
this.emoticonShow = true;
},
// 选择插入表情/图片(核心方法)
chooseEmoji(key){
const node = new Image();
node.src = '你需要插入的图片地址';
// 判断当前光标位置是否在文本节点中
if(this.currentSelection.startContainer.nodeType == 3){
// 如果是文本节点,拆分文字
const newNode = this.currentSelection.startContainer.splitText(this.currentSelection.startOffset);
// 设置光标开始节点为拆分之后节点的父级节点
this.currentSelection.startContainer = newNode.parentNode;
// 在拆分后得到的节点之前插入图片
this.currentSelection.startContainer.insertBefore(node, newNode);
} else {
// 非文本节点
if(this.currentSelection.startContainer.childNodes.length){
// 如果光标开始节点下有子级,获取到光标位置的节点
const beforeNode = this.currentSelection.startContainer.childNodes[this.currentSelection.startOffset];
// 插入
this.currentSelection.startContainer.insertBefore(node, beforeNode);
} else {
// 如果光标开始节点下没有子级,直接插入
this.currentSelection.startContainer.appendChild(node);
}
}
// 获取插入的节点所在父级下的位置
const index = Array.from(this.currentSelection.startContainer.childNodes).indexOf(node);
this.currentSelection.startOffset = index + 1;
this.currentSelection.endOffset = index + 1;
// 视图滚动带完全显示出来
node.scrollIntoView(false);
},
// 发送消息
submitSend(){
const html = this.$refs.chatTextarea.innerHTML;
// 发送逻辑
},
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
CSS样式
按照设计稿自行添加样式。
核心功能解析
光标位置管理
- 备份光标位置:在失去焦点、输入内容、粘贴等操作时,通过
backuprange()方法备份当前光标位置。 - 恢复光标位置:在插入内容前,通过
restorerange()方法恢复上次的光标位置。
表情插入逻辑
- 文本节点处理:当光标位于文本节点中时,使用
splitText()方法拆分文本,然后在拆分点插入图片。 - 非文本节点处理:当光标位于非文本节点时,根据是否有子节点分别使用
insertBefore()或appendChild()方法插入图片。 - 光标位置更新:插入图片后,更新光标位置到图片后面,确保后续输入位置正确。
粘贴处理
- 阻止默认粘贴行为:通过
e.preventDefault()阻止默认粘贴行为。 - 获取粘贴内容:从剪贴板获取纯文本和富文本内容。
- 处理iOS兼容性:某些iOS设备无法获取富文本,需要手动将纯文本转换为HTML格式。
- 插入纯文本:聊天框只使用纯文本内容,避免HTML标签干扰。
TIP
主要逻辑都有详细注释,如有更好的实现方式或问题,欢迎留言交流,我会持续更新~

