模拟富文本--实现输入@加载下拉选项功能

需求背景

最近接到一个需求:在文本框中可以插入超链(方便查看某个关键词包含的详细信息),并且对该需求进行优化,最后要呈现的效果为前端检测用户输入,在用户输入某个字符时(如@)在当前输入的光标处插入筛选框(筛选项为可选择的关键词,且支持模糊筛选);因为文本域不支持插入超链接,所以最后选择使用富文本来实现(功能类似于知乎写文章时的@功能)

效果

img

技术调研

注:技术调研的过程主要调研了三款富文本(CKEditor、wangEditor、Quill),涉及了这几个方面:是否开源、Star(Github)、是否还在维护、文档是否齐全(important)、大小、插件数量、兼容性

使用感受

CKEditor :

​ 文档为英文的,上手使用较慢;

​ 对应功能的demo几乎找不到对于刚接触使用的人来说很不友好;

​ 虽然界面挺简约好看但对于我来说用着很痛苦~.~

wangEditor:

​ 轻量(804kb),支持ie6+;

​ 不支持使用JS在光位置插入内容;

​ 上手快,每个功能多数都有对应的demo代码;

​ 插件功能较少,功能比较单一,实现额外的功能,需要自己扩展;

Quill:

​ 轻量(300kb),上手使用较快;

​ 插入select标签只能将option标签的文本内容渲染到编辑器中

需求实现

使用原生属性(contentEditable)实现

原因:

​ 以上调研的富文本编辑器,在使用过程中,自定义功能使用不是很方便,实现过程耗时太长,时间成本太高(可能是没怎么用 过),并且这个需求要实现的功能所依赖的为可识别html文本的编辑器,而contentEditable全局属性即可达到目的,综上原因最后选择使用原生属性(contentEditable)实现。

需求项目所用的技术栈

Vue + Element

主要实现思路

首先给想要实现可编辑的元素添加contentEditable属性;其次监听键盘事件,当用户输入’[‘ 时在光标处插入一个空标签,并获取空标签的位置,用来作为将要插入的下拉框select位置,最后选择完要插入的链接选项后,移除多余的空标签

具体实现过程

注:最下面有该功能组件完整代码(封装后的)

组件UI层

1
2
3
4
5
6
7
8
9
10
11
12
13
<div id="editor"></div>
<div id="selectEle">
<el-autocomplete
size="small"
ref="inputRef"
v-model="selectVal"
class="inline-input"
@select="selectChange"
placeholder="请输入内容"
:fetch-suggestions="remoteMethod"
@keyup.native="textInput($event)"
></el-autocomplete>
</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
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
let editor = document.getElementById("editor")
editor.contentEditable = true
// 监听键盘事件
editor.addEventListener('keydown', e => {
if(e.keyCode === 219) {
setTimeout(() => {
for(let i = 0; i < editor.children.length; i++) {
// 敲回车时会生成空的div元素
editor.children[i].style.display = 'inline'
}
// 在光标处插入空标签方便定位,
// 可能还有其他方法,目前只想到这个
_this.insertTag();
// 获取空的p标签元素
let sDom = document.getElementById('insertTag')
// console.dir(sDom)
let top = sDom.offsetTop - 0 - 6
let left = sDom.offsetLeft - 0 + 6
let select = document.getElementById('selectEle')
select.style.display = 'block'
select.style.top = top + 'px'
select.style.left = left + 'px'
_this.selectVal = ''
_this.$refs.inputRef.focus()
})
}
}, false)
// 上面代码用到的函数
insertTag() {
document.execCommand("insertHTML", false, `<p id='insertTag' style="display:inline"></p>`)
},

// 创建链接
createLink(val) {
let insertTagEle = document.getElementById('insertTag')
// 插入链接节点
if(typeof val === "object") {
let alink = document.createElement("a");
let hrefStr = "www.baidu.com"
alink.href = hrefStr
alink.target = '_blank'
alink.innerText= val.value
alink.style.color = '#429EFD'
alink.style.cursor = 'pointer'
alink.contentEditable = false
insertTagEle.parentNode.insertBefore(alink, insertTagEle.nextSibling);
let endStr = document.createTextNode(']')
alink.parentNode.insertBefore(endStr, alink.nextSibling)
}else {
let endStr = document.createTextNode(val + ']')
insertTagEle.parentNode.insertBefore(endStr, insertTagEle.nextSibling)
}
// 移除节点
insertTagEle.parentNode.removeChild( insertTagEle );
},

使用(父组件)

1
2
3
4
5
<richTxt :insertBI='indTypeAll' :htmlStr="form.definition || ''" @txtChange="contentChange"></richTxt>

contentChange(html) {
this.form.definition = html
},

组件完整代码

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
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
<template>
<div class="richTxtJS">
<div id="editor"></div>
<div id="selectEle">
<el-autocomplete
size="small"
ref="inputRef"
v-model="selectVal"
class="inline-input"
@select="selectChange"
placeholder="请输入内容"
:fetch-suggestions="remoteMethod"
@keyup.native="textInput($event)"
></el-autocomplete>
</div>
</div>
</template>

<script>
export default {
// insertBI为下拉项
props: ['insertBI', 'htmlStr'],
data() {
return {
selectVal: '',
selectOpts: [],
html: '',
indTypeAll: [],
}
},
methods: {
init() {
let _this = this
let editor = document.getElementById("editor")
editor.contentEditable = true
setTimeout(_ => {
// console.log(_this.htmlStr)
editor.innerHTML = _this.htmlStr
}, 1000)
// 监听点击事件
let select = document.getElementById('selectEle')
editor.addEventListener('click', e => {
select.style.display = 'none'
// 移除节点
let insertTagEle = document.getElementById('insertTag')
if(insertTagEle) {
insertTagEle.parentNode.removeChild( insertTagEle );
}
}, false)
console.dir(editor)
// 防止敲回车浏览器默认提交表单--导致页面刷新
editor.onsubmit = function() {
return false;
}

// 监听键盘事件
editor.addEventListener('keydown', e => {
if(e.keyCode === 219) {
setTimeout(() => {
for(let i = 0; i < editor.children.length; i++) {
// 敲回车时会生成空的div元素
editor.children[i].style.display = 'inline'
}
// 在光标处插入空标签方便定位,
// 可能还有其他方法,目前只想到这个
_this.insertTag();
// 获取空的p标签元素
let sDom = document.getElementById('insertTag')
// console.dir(sDom)
let top = sDom.offsetTop - 0 - 6
let left = sDom.offsetLeft - 0 + 6

let select = document.getElementById('selectEle')
select.style.display = 'block'
select.style.top = top + 'px'
select.style.left = left + 'px'
_this.selectVal = ''
_this.$refs.inputRef.focus()
})
}
this.sendHtml()
}, false)

// 阻止子元素上触发父元素事件
select.addEventListener('click', e => {
e.stopPropagation()
}, false)
select.addEventListener('keydown', e => {
e.stopPropagation()
}, false)
},
selectChange(val) {
let select = document.getElementById('selectEle')
select.style.display = 'none'

let editor = document.getElementById("editor")
editor.focus()

this.createLink(val)

this.selectVal = ''
},

textInput(e) {
if(e.code == 'Enter') {
let str = this.selectVal
if(str !== ''){
this.selectChange(str)
}
}
},
remoteMethod(queryString, cb) {
// console.log(this.insertBI)
this.insertBI.forEach((el, i) => {
this.insertBI[i].value = el.name_cn
})
// this.selectOpts
let restaurants = this.insertBI
let results = queryString ? restaurants.filter(this.createFilter(queryString)) : restaurants;
// 调用 callback 返回建议列表的数据
cb(results);
},
createFilter(queryString) {
return (restaurant) => {
return (restaurant.value.toLowerCase().indexOf(queryString.toLowerCase()) !== -1);
};
},

insertTag() {
document.execCommand("insertHTML", false, `<p id='insertTag' style="display:inline"></p>`)
},

// 创建链接
createLink(val) {
let insertTagEle = document.getElementById('insertTag')

// 插入链接节点
if(typeof val === "object") {
let alink = document.createElement("a");
let hrefStr = "http://"+localStorage.getItem('me_host') + '/#/DetailBI' + '?id=' + val.id + '&target=_blank&product=快报'
// let hrefStr = 'http://localhost:8089/#/DetailBI' + '?id=' + val.id + '&target=_blank&product=快报'
alink.href = hrefStr
alink.target = '_blank'
alink.innerText= val.value
alink.style.color = '#429EFD'
alink.style.cursor = 'pointer'
alink.contentEditable = false

insertTagEle.parentNode.insertBefore(alink, insertTagEle.nextSibling);

let endStr = document.createTextNode(']')
alink.parentNode.insertBefore(endStr, alink.nextSibling)
}else {
// let txtEl = document.createElement("p");
// txtEl.innerText = val
// txtEl.style.display = 'inline'
// insertTagEle.parentNode.insertBefore(alink, insertTagEle.nextSibling);
let endStr = document.createTextNode(val + ']')
insertTagEle.parentNode.insertBefore(endStr, insertTagEle.nextSibling)
}

// 移除节点
insertTagEle.parentNode.removeChild( insertTagEle );

this.sendHtml()

// 将光标位置设置到末尾
this.set_focus()
},

set_focus() {
let el=document.getElementById('editor');
el.focus();
let range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
let sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
},
sendHtml() {
setTimeout(_ => {
let select = document.getElementById('selectEle')
this.$emit('txtChange', editor.innerHTML);
}, 500)
},
},
mounted() {
//初始化编辑器
this.init()
},
}
</script>
<style scoped>
.richTxtJS {
/* background: #fff; */
}
#editor {
/* width: 600px; */
/* height: 200px; */
max-width: 800px;
min-height: 80px;
border:solid 1px #ccc;
/* margin-top: 20px; */
position: relative;
padding: 0px 10px;
background: #fff;
}
#selectEle {
display: none;
position: absolute;
max-width: 160px;
}
</style>
<style>
.richTxtJS .alinkElement {
position: relative;
/* display: -webkit-inline-box; */
display: inline;
color: #429EFD;
cursor: pointer;
white-space: normal
}
.richTxtJS .aTagSty {
position: absolute;
top: -35px;
left: 0;
border: 1px solid #ccc;
box-shadow: 0px 0px 5px #ddd;
color: #444;
padding: 0px 8px;
white-space: nowrap;
background: #fff;
font-size: 12px;
display: none;
cursor: pointer;
height: 30px;
line-height: 30px;
}
.richTxtJS #selectEle {
display: none;
position: absolute;
max-width: 160px;
}
</style>