vue@user组件

一个@user的Vue组件,仿微博聊天@user

Web聊天工具的富文本输入框

Github地址

难点:

  • 获取位置,在合适的地方插入@列表
  • 文本插入,找到对应的位置插入选中的文本

Github

获取键盘码【键盘ASCII码】

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!DOCTYPE html>
<html>

<head>
<title></title>
<meta charset="utf-8" />
<script src="http://libs.baidu.com/jquery/2.0.0/jquery.min.js"></script>
<script language="javascript" type="text/javascript">
$(function() {
$(document).keydown(function(e) {
$('span').html(e.which);
})
});
</script>
</head>

<body>

<span></span>

</body>

</html>

获取textarea输入的坐标位置,距离textarea左上角


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
<!DOCTYPE html>
<html>

<head>
<meta charset="UTF-8">
<title></title>
<script src="http://libs.baidu.com/jquery/2.0.0/jquery.min.js"></script>
<style type="text/css">
* {
padding: 0px;
margin: 0px;
}
#main{
width: 200px;
height: 150px;
position: relative;
}
textarea {
width: 200px;
height: 150px;
resize: none;
position: absolute;
left: 0px;
top: 0px;
}
.test{
width: 5px;
height: 5px;
position: absolute;
left: 0px;
top: 0px;
background: red;
}

</style>
<script type="text/javascript">
(function() {
var properties = [
'direction', // RTL support
'boxSizing',
'width', // on Chrome and IE, exclude the scrollbar, so the mirror div wraps exactly as the textarea does
'height',
'overflowX',
'overflowY', // copy the scrollbar for IE

'borderTopWidth',
'borderRightWidth',
'borderBottomWidth',
'borderLeftWidth',
'borderStyle',

'paddingTop',
'paddingRight',
'paddingBottom',
'paddingLeft',

// https://developer.mozilla.org/en-US/docs/Web/CSS/font
'fontStyle',
'fontVariant',
'fontWeight',
'fontStretch',
'fontSize',
'fontSizeAdjust',
'lineHeight',
'fontFamily',

'textAlign',
'textTransform',
'textIndent',
'textDecoration', // might not make a difference, but better be safe

'letterSpacing',
'wordSpacing',

'tabSize',
'MozTabSize'

];

var isBrowser = (typeof window !== 'undefined');
var isFirefox = (isBrowser && window.mozInnerScreenX != null);

function getCaretCoordinates(element, position, options) {
if(!isBrowser) {
throw new Error('textarea-caret-position#getCaretCoordinates should only be called in a browser');
}

var debug = options && options.debug || false;
if(debug) {
var el = document.querySelector('#input-textarea-caret-position-mirror-div');
if(el) el.parentNode.removeChild(el);
}

var div = document.createElement('div');
div.id = 'input-textarea-caret-position-mirror-div';
document.body.appendChild(div);

var style = div.style;
var computed = window.getComputedStyle ? window.getComputedStyle(element) : element.currentStyle; // currentStyle for IE < 9
var isInput = element.nodeName === 'INPUT';

style.whiteSpace = 'pre-wrap';
if(!isInput)
style.wordWrap = 'break-word';

style.position = 'absolute';
if(!debug)
style.visibility = 'hidden';

properties.forEach(function(prop) {
if(isInput && prop === 'lineHeight') {
if(computed.boxSizing === "border-box") {
var height = parseInt(computed.height);
var outerHeight =
parseInt(computed.paddingTop) +
parseInt(computed.paddingBottom) +
parseInt(computed.borderTopWidth) +
parseInt(computed.borderBottomWidth);
var targetHeight = outerHeight + parseInt(computed.lineHeight);
if(height > targetHeight) {
style.lineHeight = height - outerHeight + "px";
} else if(height === targetHeight) {
style.lineHeight = computed.lineHeight;
} else {
style.lineHeight = 0;
}
} else {
style.lineHeight = computed.height;
}
} else {
style[prop] = computed[prop];
}
});
if(isFirefox) {
if(element.scrollHeight > parseInt(computed.height))
style.overflowY = 'scroll';
} else {
style.overflow = 'hidden';
}
div.textContent = element.value.substring(0, position);
if(isInput)
div.textContent = div.textContent.replace(/\s/g, '\u00a0');
var span = document.createElement('span');
span.textContent = element.value.substring(position) || '.';
div.appendChild(span);
var coordinates = {
top: span.offsetTop + parseInt(computed['borderTopWidth']),
left: span.offsetLeft + parseInt(computed['borderLeftWidth']),
height: parseInt(computed['lineHeight'])
};
if(debug) {
span.style.backgroundColor = '#aaa';
} else {
document.body.removeChild(div);
}
return coordinates;
}

if(typeof module != 'undefined' && typeof module.exports != 'undefined') {
module.exports = getCaretCoordinates;
} else if(isBrowser) {
window.getCaretCoordinates = getCaretCoordinates;
}
}());
</script>
</head>

<body>
<div id="main">
<textarea cols="30" rows="10" wrap="hard"></textarea>

<div class="test"></div>
</div>
</body>
<script type="text/javascript">
document.querySelector('textarea').addEventListener('input', function(e) {
console.log( e.keyCode)
var caret = getCaretCoordinates(this, this.selectionEnd);
$(".test").css({
left: caret.left+'px',
top: caret.top+'px'
})
console.log('(top, left, height) = (%s, %s, %s)', caret.top, caret.left, caret.height);
})
</script>

</html>

获取textarea鼠标在字符串的位置


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
<html>

<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no" />
<title>JS设置及获取Textarea的光标位置</title>
<script>
var isIE = !(!document.all); //是不是IE

function posCursor() {
var start = 0,
end = 0;
var oTextarea = document.getElementById("textarea");
if(isIE) {
//selection 当前激活选中区,即高亮文本块,和/或文当中用户可执行某些操作的其它元素。
//createRange 从当前文本选中区中创建 TextRange 对象,
//或从控件选中区中创建 controlRange 集合。
var sTextRange = document.selection.createRange();

//判断选中的是不是textarea对象
if(sTextRange.parentElement() == oTextarea) {
//创建一个TextRange对象
var oTextRange = document.body.createTextRange();
//移动文本范围以便范围的开始和结束位置能够完全包含给定元素的文本。
oTextRange.moveToElementText(oTextarea);

//此时得到两个 TextRange
//oTextRange文本域(textarea)中文本的TextRange对象
//sTextRange是选中区域文本的TextRange对象

//compareEndPoints方法介绍,compareEndPoints方法用于比较两个TextRange对象的位置
//StartToEnd 比较TextRange开头与参数TextRange的末尾。
//StartToStart比较TextRange开头与参数TextRange的开头。
//EndToStart 比较TextRange末尾与参数TextRange的开头。
//EndToEnd 比较TextRange末尾与参数TextRange的末尾。

//moveStart方法介绍,更改范围的开始位置
//character 按字符移动
//word 按单词移动
//sentence 按句子移动
//textedit 启动编辑动作

//这里我们比较oTextRange和sTextRange的开头,的到选中区域的开头位置
for(start = 0; oTextRange.compareEndPoints("StartToStart", sTextRange) < 0; start++) {
oTextRange.moveStart('character', 1);
}
//需要计算一下\n的数目(按字符移动的方式不计\n,所以这里加上)
for(var i = 0; i <= start; i++) {
if(oTextarea.value.charAt(i) == '\n') {
start++;
}
}

//再计算一次结束的位置
oTextRange.moveToElementText(oTextarea);
for(end = 0; oTextRange.compareEndPoints('StartToEnd', sTextRange) < 0; end++) {
oTextRange.moveStart('character', 1);
}
for(var i = 0; i <= end; i++) {
if(oTextarea.value.charAt(i) == '\n') {
end++;
}
}
}
} else {
start = oTextarea.selectionStart;
end = oTextarea.selectionEnd;
}
document.getElementById("start").value = start;
document.getElementById("end").value = end;
}

function moveCursor() {
var oTextarea = document.getElementById("textarea");
var start = parseInt(document.getElementById("start").value);
var end = parseInt(document.getElementById("end").value);
if(isNaN(start) || isNaN(end)) {
alert("位置输入错误");
}
if(isIE) {
var oTextRange = oTextarea.createTextRange();
var LStart = start;
var LEnd = end;
var start = 0;
var end = 0;
var value = oTextarea.value;
for(var i = 0; i < value.length && i < LStart; i++) {
var c = value.charAt(i);
if(c != '\n') {
start++;
}
}
for(var i = value.length - 1; i >= LEnd && i >= 0; i--) {
var c = value.charAt(i);
if(c != '\n') {
end++;
}
}
oTextRange.moveStart('character', start);
oTextRange.moveEnd('character', -end);
//oTextRange.collapse(true);
oTextRange.select();
oTextarea.focus();
} else {
oTextarea.select();
oTextarea.selectionStart = start;
oTextarea.selectionEnd = end;
}
}
</script>

<body>
<table border="1" cellspacing="0" cellpadding="0">
<tr>
<td>start: <input type="text" id="start" size="3" value="0" /></td>
<td>end: <input type="text" id="end" size="3" value="0" /></td>
</tr>
<tr>
<td colspan="2">
<textarea id="textarea"
onKeydown="posCursor()"
onKeyup="posCursor()"
onmousedown="posCursor()"
onmouseup="posCursor()"
onfocus="posCursor()"
rows="14"
cols="50">虞美人
春花秋月何时了,往事知多少。
小楼昨夜又东风,故国不堪回首月明中!
雕l栏玉砌应犹在,只是朱颜改。
问君能有几多愁?恰似一江春水向东流。
</textarea>
</td>
</tr>
<tr>
<td></td>
<td>
<input type="button" onClick="moveCursor()" value="设置光标位置" />
</td>
</tr>
</table>
</body>

</html>

Code

App.js 入口文件

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
<template>
<div id="app">
<at :members="members" name-key="name" v-model="html">

<!--<template slot="item" scope="s">
<span v-text="s.item.name"></span>
</template>-->

<template slot="embeddedItem" slot-scope="s">
<a target="_blank" :href="s.current.avatar" class="tag">@{{ s.current.name }}</a>
</template>

<template slot="item" slot-scope="s">
<span v-text="s.item.name"></span>
</template>

<div class="editor" contenteditable></div>
</at>
<br/>
</div>
</template>

<script>
import At from './At.vue'
let members = ["小明", "小花", "小浩", "小刚", "小龙", "小木", "小三"];
members = members.map((v, i) => {
return {
avatar: 'https://weibo.com',
name: v
}
})

export default {
components: {
At
},
name: 'app',
data() {
return {
members,
html: ''//`<div>深度学习模型训练的过程本质是对weight(即参数W)进行更新,这需要每个参数有相应的初始值。</div><div>对一些简单的机器学习模型,或当optimization function是convex function时. </div>`
}
}
}
</script>

<style>
* {
padding: 0px;
margin: 0px;
border: 0px;
}

li {
list-style: none;
}

ul,
ol {
list-style-type: none;
}

select,
input,
img,
select {
vertical-align: middle;
}

img {
border: none;
display: inline-block
}

i {
font-style: normal
}

a {
text-decoration: none;
-webkit-appearance: none;
}
#app{
margin-top: 0px;
}
.editor {

width: 400px;
height: 200px;
overflow: auto;

white-space: pre-wrap;
border: solid 1px rgba(0, 0, 0, .5);
}
</style>

At.vue

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
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
<template>
<div ref="wrap" class="atwho-wrap" @input="handleInput()" @keydown="handleKeyDown">

<div v-if="atwho" class="atwho-panel" :style="style">
<ul class="atwho-view atwho-ul">
<li v-for="(item, index) in atwho.list"
class="atwho-li"
:key="index"
:class="isCur(index) && 'atwho-cur'"
:ref="isCur(index) && 'cur'"
:data-index="index"
@mouseenter="handleItemHover"
@click="handleItemClick">
<slot name="item" :item="item">
<span v-text="itemName(item)"></span>
</slot>
</li>
<li>
<span>展开更多群成员</span>
<img src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBzdGFuZGFsb25lPSJubyI/PjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+PHN2ZyB0PSIxNTQ1NDgyNjY3NzY4IiBjbGFzcz0iaWNvbiIgc3R5bGU9IiIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHAtaWQ9IjEwODYiIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB3aWR0aD0iMTYiIGhlaWdodD0iMTYiPjxkZWZzPjxzdHlsZSB0eXBlPSJ0ZXh0L2NzcyI+PC9zdHlsZT48L2RlZnM+PHBhdGggZD0iTTMzNi43MzMgMTE5LjY2N2wtNTYuMjc4IDU1LjcyIDMzNC44NTcgMzM3LjE1NC0zMzcuNjczIDMzNC4zMTUgNTUuODAyIDU2LjE4NCAzOTMuOTQ0LTM5MC4wNDB6IiBwLWlkPSIxMDg3Ij48L3BhdGg+PC9zdmc+"/>
</li>
</ul>
</div>

<span v-show="false" ref="embeddedItem">
<slot name="embeddedItem" :current="currentItem"></slot>
</span>

<slot></slot>
</div>
</template>

<script>
import {
closest,
getOffset,
getPrecedingRange,
getRange,
applyRange,
scrollIntoView,
getAtAndIndex
} from './util'

export default {
props: {
value: {
type: String,
default: null
},
at: {
type: String,
default: null
},
ats: {
type: Array,
default: () => ['@']
},
suffix: { //插入字符链接
type: String,
default: ' '
},
loop: {
type: Boolean,
default: true
},
allowSpaces: {
type: Boolean,
default: true
},
tabSelect: {
type: Boolean,
default: false
},
avoidEmail: {
type: Boolean,
default: true
},
hoverSelect: {
type: Boolean,
default: true
},
members: {
type: Array,
default: () => []
},
nameKey: {
type: String,
default: ''
},
filterMatch: {
type: Function,
default: (name, chunk, at) => {
return name.toLowerCase()
.indexOf(chunk.toLowerCase()) > -1
}
},
deleteMatch: {
type: Function,
default: (name, chunk, suffix) => {
return chunk === name + suffix
}
}
},

data() {
return {
bindsValue: this.value != null,
customsEmbedded: false,
atwho: null
}
},
computed: {
atItems() {
return this.at ? [this.at] : this.ats
},

currentItem() {
if(this.atwho) {
return this.atwho.list[this.atwho.cur];
}
return '';
},

style() {
if(this.atwho) {
const {
list,
cur,
x,
y
} = this.atwho

const {
wrap
} = this.$refs

if(wrap) {
const offset = getOffset(wrap)
const left = x + 'px'
const top = y + 25+ 'px' //25是行高
return {
left,
top
}
}
}
return null
}
},
watch: {
'atwho.cur' (index) {
if(index != null) { // cur index exists
this.$nextTick(() => {
this.scrollToCur()
})
}
},
members() {
this.handleInput(true)
},
value(value, oldValue) {
if(this.bindsValue) {
this.handleValueUpdate(value)
}
}
},
mounted() {
if(this.$scopedSlots.embeddedItem) {
this.customsEmbedded = true
}
if(this.bindsValue) {
this.handleValueUpdate(this.value)
}
},

methods: {
itemName(v) {
const {
nameKey
} = this
return nameKey ? v[nameKey] : v
},
isCur(index) {
return index === this.atwho.cur
},
handleValueUpdate(value) {
const el = this.$el.querySelector('[contenteditable]')
if(value !== el.innerHTML) {
el.innerHTML = value
}
},

handleItemHover(e) {
if(this.hoverSelect) {
this.selectByMouse(e)
}
},
handleItemClick(e) {
this.selectByMouse(e)
this.insertItem()
},
handleDelete(e) {
const range = getPrecedingRange()
if(range) {
if(this.customsEmbedded && range.endOffset >= 1) {
let a = range.endContainer.childNodes[range.endOffset] ||
range.endContainer.childNodes[range.endOffset - 1]
if(!a || a.nodeType === Node.TEXT_NODE && !/^\s?$/.test(a.data)) {
return
} else if(a.nodeType === Node.TEXT_NODE) {
if(a.previousSibling) a = a.previousSibling
} else {
if(a.previousElementSibling) a = a.previousElementSibling
}
let ch = [].slice.call(a.childNodes)
ch = [].reverse.call(ch)
ch.unshift(a)
let last;
[].some.call(ch, c => {
if(c.getAttribute && c.getAttribute('data-at-embedded') != null) {
last = c
return true
}
})
if(last) {
e.preventDefault()
e.stopPropagation()
const r = getRange()
if(r) {
r.setStartBefore(last)
r.deleteContents()
applyRange(r)
this.handleInput()
}
}
return
}

const {
atItems,
members,
suffix,
deleteMatch,
itemName
} = this
const text = range.toString()
const {
at,
index
} = getAtAndIndex(text, atItems)

if(index > -1) {
const chunk = text.slice(index + at.length)
const has = members.some(v => {
const name = itemName(v)
return deleteMatch(name, chunk, suffix)
})
if(has) {
e.preventDefault()
e.stopPropagation()
const r = getRange()
if(r) {
r.setStart(r.endContainer, index)
r.deleteContents()
applyRange(r)
this.handleInput()
}
}
}
}
},
handleKeyDown(e) {
const {
atwho
} = this
if(atwho) {
if(e.keyCode === 38 || e.keyCode === 40) { // ↑/↓
if(!(e.metaKey || e.ctrlKey)) {
e.preventDefault()
e.stopPropagation()
this.selectByKeyboard(e)
}
return
}
if(e.keyCode === 13 || (this.tabSelect && e.keyCode === 9)) { // enter or tab
this.insertItem()
e.preventDefault()
e.stopPropagation()
return
}
if(e.keyCode === 27) { // esc
this.closePanel()
return
}
}

// 为了兼容ie ie9~11 editable无input事件 只能靠keydown触发 textarea正常
// 另 ie9 textarea的delete不触发input
const isValid = e.keyCode >= 48 && e.keyCode <= 90 || e.keyCode === 8
if(isValid) {
setTimeout(() => {
this.handleInput()
}, 50)
}

if(e.keyCode === 8) { //删除
this.handleDelete(e)
}
},

handleInput(keep) {
const el = this.$el.querySelector('[contenteditable]')
this.$emit('input', el.innerHTML)

const range = getPrecedingRange()
if(range) {
const {
atItems,
avoidEmail,
allowSpaces
} = this

let show = true
const text = range.toString()

const {
at,
index
} = getAtAndIndex(text, atItems)

if(index < 0) show = false
const prev = text[index - 1]

const chunk = text.slice(index + at.length, text.length)

if(avoidEmail) {
// 上一个字符不能为字母数字 避免与邮箱冲突
// 微信则是避免 所有字母数字及半角符号
if(/^[a-z0-9]$/i.test(prev)) show = false
}

if(!allowSpaces && /\s/.test(chunk)) {
show = false
}

// chunk以空白字符开头不匹配 避免`@ `也匹配
if(/^\s/.test(chunk)) show = false

if(!show) {
this.closePanel()
} else {
const {
members,
filterMatch,
itemName
} = this
if(!keep && chunk) { // fixme: should be consistent with AtTextarea.vue
this.$emit('at', chunk)
console.log('at',chunk);
}
const matched = members.filter(v => {
const name = itemName(v)
return filterMatch(name, chunk, at)
})
if(matched.length) {
this.openPanel(matched, range, index, at)
} else {
this.closePanel()
}
}
}
},

closePanel() {
if(this.atwho) {
this.atwho = null
}
},
openPanel(list, range, offset, at) {
const fn = () => {
const r = range.cloneRange()
r.setStart(r.endContainer, offset + at.length) // 从@后第一位开始
// todo: 根据窗口空间 判断向上或是向下展开
const rect = r.getClientRects()[0]
this.atwho = {
range,
offset,
list,
x: rect.left,
y: rect.top-5,
cur: 0 // todo: 尽可能记录
}
}
if(this.atwho) {
fn()
} else { // 焦点超出了显示区域 需要提供延时以移动指针 再计算位置
setTimeout(fn, 10)
}
},

scrollToCur() {
const curEl = this.$refs.cur[0]
const scrollParent = curEl.parentElement.parentElement
scrollIntoView(curEl, scrollParent)
},
selectByMouse(e) {
const el = closest(e.target, d => {
return d.getAttribute('data-index')
})
const cur = +el.getAttribute('data-index')
this.atwho = {
...this.atwho,
cur
}
},
selectByKeyboard(e) {
const offset = e.keyCode === 38 ? -1 : 1
const {
cur,
list
} = this.atwho
const nextCur = this.loop ?
(cur + offset + list.length) % list.length :
Math.max(0, Math.min(cur + offset, list.length - 1))
this.atwho = {
...this.atwho,
cur: nextCur
}
},

// todo: 抽离成库并测试
insertText(text, r) {
r.deleteContents()
const node = r.endContainer
if(node.nodeType === Node.TEXT_NODE) {
const cut = r.endOffset
node.data = node.data.slice(0, cut) +
text + node.data.slice(cut)
r.setEnd(node, cut + text.length)
} else {
const t = document.createTextNode(text)
r.insertNode(t)
r.setEndAfter(t)
}
r.collapse(false) // 参数在IE下必传
applyRange(r)
},

insertHtml(html, r) {
r.deleteContents()
const node = r.endContainer
var newElement = document.createElement('span')

newElement.appendChild(document.createTextNode(' '))
newElement.appendChild(this.htmlToElement(html))
newElement.appendChild(document.createTextNode(' '))
newElement.setAttribute('data-at-embedded', '')
newElement.setAttribute("contenteditable", false)

if(node.nodeType === Node.TEXT_NODE) {
const cut = r.endOffset
var secondPart = node.splitText(cut);
node.parentNode.insertBefore(newElement, secondPart);
r.setEndBefore(secondPart)
} else {
const t = document.createTextNode(suffix)
r.insertNode(newElement)
r.setEndAfter(newElement)
r.insertNode(t)
r.setEndAfter(t)
}
r.collapse(false) // 参数在IE下必传
applyRange(r)
},

insertItem() {
const {
range,
offset,
list,
cur
} = this.atwho
const {
suffix,
atItems,
itemName,
customsEmbedded
} = this
const r = range.cloneRange()
const text = range.toString()
const {
at,
index
} = getAtAndIndex(text, atItems)

const start = customsEmbedded ? index : index + at.length
r.setStart(r.endContainer, start)

// hack: 连续两次 可以确保click后 focus回来 range真正生效
applyRange(r)
applyRange(r)
const curItem = list[cur]

if(customsEmbedded) {
const html = this.$refs.embeddedItem.innerHTML
this.insertHtml(html, r);
} else {
const t = itemName(curItem) + suffix
this.insertText(t, r);
}

this.$emit('insert', curItem)
console.log('insert', curItem);

this.handleInput()
},
htmlToElement(html) {
var template = document.createElement('template');
html = html.trim(); // Never return a text node of whitespace as the result
template.innerHTML = html;
return template.content.firstChild;
}
}
}
</script>

<style lang="less" scoped="scoped">
.atwho-wrap {
font-size: 12px;
color: #333;
position: relative;
.atwho-panel {
position: absolute;
&.test{
width: 2px;
height: 2px;
background: red;
}
.atwho-inner {
position: relative;
}
}
.atwho-view {
color: black;
z-index: 11110 !important;
border-radius: 2px;
box-shadow: 0 0 10px 0 rgba(101, 111, 122, .5);
position: absolute;
cursor: pointer;
background-color: rgba(255, 255, 255, .94);
width: 170px;
max-height: 312px;
&::-webkit-scrollbar {
width: 11px;
height: 11px;
}
&::-webkit-scrollbar-track {
background-color: #F5F5F5;
}
&::-webkit-scrollbar-thumb {
min-height: 36px;
border: 2px solid transparent;
border-top: 3px solid transparent;
border-bottom: 3px solid transparent;
background-clip: padding-box;
border-radius: 7px;
background-color: #C4C4C4;
}
}
.atwho-ul {
list-style: none;
padding: 0;
margin: 0;
li {
box-sizing: border-box;
display: block;
height: 25px;
padding: 2px 10px;
white-space: nowrap;
display: flex;
align-items: center;
justify-content: space-between;
&.atwho-cur {
background: #f2f2f5;
color: #eb7350;
}
span {
overflow: hidden;
text-overflow: ellipsis;
}
img {
height: 13px;
width: 13px;
}
}
}
}
</style>

util.js

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

export function scrollIntoView(el, scrollParent) {
if(el.scrollIntoViewIfNeeded) {
el.scrollIntoViewIfNeeded(false) // alignToCenter=false
} else {
const diff = el.offsetTop - scrollParent.scrollTop
if(diff < 0 || diff > scrollParent.offsetHeight - el.offsetHeight) {
scrollParent = scrollParent || el.parentElement
scrollParent.scrollTop = el.offsetTop
}
}
}

export function applyRange(range) {
const selection = window.getSelection()
if(selection) { // 容错
selection.removeAllRanges()
selection.addRange(range)
}
}
export function getRange() {
const selection = window.getSelection()
if(selection && selection.rangeCount > 0) {
return selection.getRangeAt(0)
}
}

export function getAtAndIndex(text, ats) {
return ats.map((at) => {
return {
at,
index: text.lastIndexOf(at)
}
}).reduce((a, b) => {
return a.index > b.index ? a : b
})
}


export function getOffset(element, target) {
target = target || window
var offset = {
top: element.offsetTop,
left: element.offsetLeft
},
parent = element.offsetParent;
while(parent != null && parent != target) {
offset.left += parent.offsetLeft;
offset.top += parent.offsetTop;
parent = parent.offsetParent;
}
return offset;
}

export function closest(el, predicate) {
do
if(predicate(el)) return el;
while (el = el && el.parentNode);
}
// http://stackoverflow.com/questions/15157435/get-last-character-before-caret-position-in-javascript
// 修复 "空格+表情+空格+@" range报错 应设(endContainer, 0)
// stackoverflow上的这段代码有bug
export function getPrecedingRange() {
const r = getRange()
if(r) {
const range = r.cloneRange()
range.collapse(true)
range.setStart(range.endContainer, 0)
return range
}
}

js获取光标位置

获取光标位置,设置光标位置

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
/**
* 获取光标位置
* @param {DOMElement} element 输入框的dom节点
* @return {Number} 光标位置
*/
export const getCursorPosition = (element) => {
let caretOffset = 0
const doc = element.ownerDocument || element.document
const win = doc.defaultView || doc.parentWindow
const sel = win.getSelection()
if (sel.rangeCount > 0) {
const range = win.getSelection().getRangeAt(0)
const preCaretRange = range.cloneRange()
preCaretRange.selectNodeContents(element)
preCaretRange.setEnd(range.endContainer, range.endOffset)
caretOffset = preCaretRange.toString().length
}
return caretOffset
}

/**
* 设置光标位置
* @param {DOMElement} element 输入框的dom节点
* @param {Number} cursorPosition 光标位置的值
*/
export const setCursorPosition = (element, cursorPosition) => {
const range = document.createRange()
range.setStart(element.firstChild, cursorPosition)
range.setEnd(element.firstChild, cursorPosition)
const sel = window.getSelection()
sel.removeAllRanges()
sel.addRange(range)
}

JS获取文本框焦点光标位置、选中起始位置、终止位置、选择内容

扩展知识

js获取光标位置

1.概念和原理

DOM中并没有直接获取光标位置的方法,那么我们只能间接来获取光标位置。DOM支持获取光标选中的范围,我们可以以此为切入点,来获取或定位光标的位置。当选取范围起始点和结束点一样时,就是光标插入的位置。

1.1 术语

anchor(瞄点):选区起点。

focus(焦点):选区终点。

range(范围):选区范围,包含整个节点或节点的一部分。

1.2 Selection

Selection:Selection对象表示用户选择的文本范围或插入符号的位置。

经过实验发现Selection选取的节点范围都是块级节点。input和texteare并不能作为Selection的节点

Selection对象存在于window对象上,可以通过window.getSelection()获取示例。

属性:

anchorNode:选区起点的节点。

anchorOffset:选区的起点位置。

focusNode:选区终点的节点。

focusOffset:选区的终点位置。

isCollapsed:起点和终点是否重叠。

rangeCount:选区包含的range数目。

方法

getRangeAt(index):获取指定的选取范围。

addRange(range):将一个范围添加到Selection对象中。

removeRange():移出指定的范围。

removeAllRanges():移出所有range对象。

collapse(parentNode,offset):将光标移动到parentNode节点的offset位置。

collapseToStart():取消当前选区,并把光标定位在原选区的最开始处,如果此时光标所处的位置是可编辑的,且它获得了焦点,则光标会在原地闪烁。

collapseToEnd():取消当前选区,并将光标定位到原选取的最末位。如果此时光标所处的位置是可编辑的,且它获得了焦点,则光标会在原地闪烁。

extend(node,offset):将终点位置移动到node节点的offset位置。

modify(alter,direction,granularity):通过alter方式(move/extend)来改变光标位置,移动方向为direction(left/right),移动单位为granularity

containsNode(aNode,aPartlyContained):判断aNode是否包含在Selection中。aPartlyContained为false表示全包含,为true表示只要部分包含即可。

toString():放回当前Selection对象的字符串。

1.3 Range

Range对象表示一个Selection的选择范围,一个Selection可以包含多个Range

获取对象

document.createRange():创建一个Range。

selection.getRangeAt(index):获取指定的Range。

属性

collapsed:判断起始位置是否重合。

endContaniner:range终点节点。

endOffset:range的终点位置。

startContaniner:ranstartge起点节点。

startOffset:range的起点位置。

commonAncestorContainer:包含起始点的节点。

方法

setStart(startNode,startOffset):设置范围在startNode的起始位置为startOffset。

setEnd(endNode,endOffset):设置范围在endNode的起始位置为endOffset。

selectNode(referenceNode):设置range的节点为referenceNode。

selectNodeContents(referenceNode):设置range的内容为referenceNode。

collapse(toStart):向边界点折叠range,即是设置光标位置,toStart默认为false,表示光标定位在节点末尾。true表示光标定位在节点起点。

cloneContents():克隆一个range的内容片段。

deleteContents():删除range的内容片段。

extractContents():将range的内容从文档树移动到文档片段中。

insertNode(newNode):在range的其实位置插入新的节点。

surroundContents(newNode):将range对象的内容移动到新的节点中。

cloneRange():克隆一个range对象。

detach():释放当前range。

1.4 input/textarea

在html5中,可输入性表单元素(input/textarea)都存在以下属性。不支持IE6/7。

  • selectionDirection:forward | backward | none,选区方向
  • selectionEnd:选区终点位置
  • selectionStart:选区起点位置

setSelectionRange(selectionStart, selectionEnd, [selectionDirection]):设置获取焦点的输入性元素的选区范围。

2.获取光标位置

2.1 可编辑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
//获取当前光标位置
const getCursortPosition = function (element) {
var caretOffset = 0;
var doc = element.ownerDocument || element.document;
var win = doc.defaultView || doc.parentWindow;
var sel;
if (typeof win.getSelection != "undefined") {//谷歌、火狐
sel = win.getSelection();
if (sel.rangeCount > 0) {//选中的区域
var range = win.getSelection().getRangeAt(0);
var preCaretRange = range.cloneRange();//克隆一个选中区域
preCaretRange.selectNodeContents(element);//设置选中区域的节点内容为当前节点
preCaretRange.setEnd(range.endContainer, range.endOffset); //重置选中区域的结束位置
caretOffset = preCaretRange.toString().length;
}
} else if ((sel = doc.selection) && sel.type != "Control") {//IE
var textRange = sel.createRange();
var preCaretTextRange = doc.body.createTextRange();
preCaretTextRange.moveToElementText(element);
preCaretTextRange.setEndPoint("EndToEnd", textRange);
caretOffset = preCaretTextRange.text.length;
}
return caretOffset;
}

获取光标的位置是先通过获取鼠标的选取范围,然后克隆该选取范围,修改克隆范围的结束位置,这样克隆的范围就只剩下起点到结束点的内容,光标之后的内容被截取扔掉了。所以可以通过剩余内容的长度来确定光标位置。之所以要克隆一个选取范围出来,是为了避免修改光标结束位置时影响到原先内容。

2.2 input/textarea获取光标位置

1
2
3
4
5
6
7
8
9
10
11
12
//输入框获取光标
const getPosition = function (element) {
let cursorPos = 0;
if (document.selection) {//IE
var selectRange = document.selection.createRange();
selectRange.moveStart('character', -element.value.length);
cursorPos = selectRange.text.length;
} else if (element.selectionStart || element.selectionStart == '0') {
cursorPos = element.selectionStart;
}
return cursorPos;
}

3.设置光标位置

3.1 可编辑div设置光标位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//设置光标位置
const setCaretPosition = function (element, pos) {
var range, selection;
if (document.createRange)//Firefox, Chrome, Opera, Safari, IE 9+
{
range = document.createRange();//创建一个选中区域
range.selectNodeContents(element);//选中节点的内容
if(element.innerHTML.length > 0) {
range.setStart(element.childNodes[0], pos); //设置光标起始为指定位置
}
range.collapse(true); //设置选中区域为一个点
selection = window.getSelection();//获取当前选中区域
selection.removeAllRanges();//移出所有的选中范围
selection.addRange(range);//添加新建的范围
}
else if (document.selection)//IE 8 and lower
{
range = document.body.createTextRange();//Create a range (a range is a like the selection but invisible)
range.moveToElementText(element);//Select the entire contents of the element with the range
range.collapse(false);//collapse the range to the end point. false means collapse to end rather than the start
range.select();//Select the range (make it the visible selection
}
}

3.2 input/textarea获取光标位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 设置光标位置
function setCaretPosition(textDom, pos){
if(textDom.setSelectionRange) {
// IE Support
textDom.focus();
textDom.setSelectionRange(pos, pos);
}else if (textDom.createTextRange) {
// Firefox support
var range = textDom.createTextRange();
range.collapse(true);
range.moveEnd('character', pos);
range.moveStart('character', pos);
range.select();
}
}

4.示例

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
<html>

<head>
<title>光标测试</title>
<meta charset="UTF-8"/>
<style>
p {
display: flex;
flex-direction: row;
}

.btn {
height: 24px;
margin: 0 10px;
}

.edit-div {
display: inline-block;
width: 225px;
border: 1px solid #decdcd;
}
</style>
<script>

function getCursortPosition(e) {
var eleP = e.target.parentNode; //获取父级元素
var pos = 0;
if (e.target.nodeName == "DIV") {
pos = getDivPosition(e.target);
} else {
pos = getPosition(e.target);
}

var spanEle = (eleP.childNodes)[7];
spanEle.innerText = pos;
}

//可编辑div获取坐标
const getDivPosition = function (element) {
var caretOffset = 0;
var doc = element.ownerDocument || element.document;
var win = doc.defaultView || doc.parentWindow;
var sel;
if (typeof win.getSelection != "undefined") {//谷歌、火狐
sel = win.getSelection();
if (sel.rangeCount > 0) {//选中的区域
var range = win.getSelection().getRangeAt(0);
var preCaretRange = range.cloneRange();//克隆一个选中区域
preCaretRange.selectNodeContents(element);//设置选中区域的节点内容为当前节点
preCaretRange.setEnd(range.endContainer, range.endOffset); //重置选中区域的结束位置
caretOffset = preCaretRange.toString().length;
}
} else if ((sel = doc.selection) && sel.type != "Control") {//IE
var textRange = sel.createRange();
var preCaretTextRange = doc.body.createTextRange();
preCaretTextRange.moveToElementText(element);
preCaretTextRange.setEndPoint("EndToEnd", textRange);
caretOffset = preCaretTextRange.text.length;
}
return caretOffset;
}

//输入框获取光标
const getPosition = function (element) {
let cursorPos = 0;
if (document.selection) {//IE
var selectRange = document.selection.createRange();
selectRange.moveStart('character', -element.value.length);
cursorPos = selectRange.text.length;
} else if (element.selectionStart || element.selectionStart == '0') {
cursorPos = element.selectionStart;
}
return cursorPos;
}
</script>
</head>

<body>
<p>
<label>输入框测试:</label>
<input type="text" style="width:220px" onclick="getCursortPosition(event);" />
<span>光标位置:</span>
<span></span>
</p>
<p>
<label>文本框测试:</label>
<textarea rows="5" style="width:220px" onclick="getCursortPosition(event);"></textarea>
<span>光标位置:</span>
<span></span>
</p>
<div>
<label>可编辑div:</label>
<div contenteditable="true" class="edit-div" onclick="getCursortPosition(event);"></div>
<span>光标位置:</span>
<span></span>
</div>
</body>

</html>

效果图:


JS获取文本框焦点光标位置、选中起始位置、终止位置、选择内容


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
<!DOCTYPE html>
<html>

<head>
<meta charset="UTF-8">
<title></title>
</head>

<body>

<script>
function getSelectPosition(oTxt) {
var nullvalue = -1;
var selectStart; //选中开始位置
var selectEnd; //选中结束位置
var position; //焦点位置
var selectText; //选中内容
if(oTxt.setSelectionRange) { //非IE浏览器
selectStart = oTxt.selectionStart;
selectEnd = oTxt.selectionEnd;
if(selectStart == selectEnd) {
position = oTxt.selectionStart;
selectStart = nullvalue;
selectEnd = nullvalue;
} else {
position = nullvalue;
}
selectText = oTxt.value.substring(selectStart, selectEnd);
} else { //IE
var range = document.selection.createRange();
selectText = range.text;
range.moveStart("character", -oTxt.value.length);
position = range.text.length;
selectStart = position - (selectText.length);
selectEnd = selectStart + (selectText.length);
if(selectStart != selectEnd) {
position = nullvalue;
} else {
selectStart = nullvalue;
selectEnd = nullvalue;
}
}
document.getElementById("txt1").value = position;
document.getElementById("txt2").value = selectStart;
document.getElementById("txt3").value = selectEnd;
document.getElementById("txt4").value = selectText;
}
</script>
<input type="text" id="txt" value="abcdefghijklmn"
onclick="getSelectPosition(this);">
点击文本框内容触发事件<br/>

焦点位置:<input type="text" id="txt1" value=""><br/>
选中起始位置:<input type="text" id="txt2" value="">
选中结束位置<input type="text" id="txt3" value=""><br/>
选中内容: <input type="text" id="txt4" value="">

</body>

</html>

enter键发送,ctrl+enter换行


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
var keyEnter = document.querySelector('.rongcloud-text');
// 监听事件 键盘按下触发
keyEnter.addEventListener('keydown', function (e) {
e = e || window.event;
// console.log($(".rongcloud-text").val());
var keyCode = e.keyCode || e.which || e.charCode;
var ctrlKey = e.ctrlKey || e.metaKey;
// 判断 ctrl+enter 换行
if (ctrlKey && keyCode == 13) {
var str = $(".rongcloud-text").val();
$(".rongcloud-text").val(str + "\n");
} else if (keyCode == 13) {
// 阻止提交自动换行
e.preventDefault();
// 获取发送按钮id,调用 发送按钮事件
document.getElementById("rong-sendBtn").click();
}
})

// 获取 文本域 并清空空格
var $textarea = $(".rongcloud-text").val().replace(/[ ]/g, "");
// 获取 发送按钮
var $sendBtn = $("#rong-sendBtn");

// 判断当 文本域length == 0,设置按钮颜色
if ($textarea.length == 0) {
// #0099ff
$sendBtn.css('background', 'red');
}

// 当 文本域输入信息 弹起时 给 发送按钮 设置背景颜色(提醒用户进入编辑状态)
$(".rongcloud-text").keyup(function () {
var currentText = $(this).val().replace(/[ ]/g, "");
//console.log(key);
if (currentText.length >= 1) {
$sendBtn.css('background', 'orange');
} else {
$sendBtn.css('background', 'blue');
}
});

/* 点击 发送按钮 注册单击事件 */
$('body').on('click', "#rong-sendBtn", function () {
// 获取 文本域 输入内容
var contentVal = $('.rongcloud-text').val();
// 当信息为空时,不能发空白信息
if (contentVal == "") {
alert("不能发送空白信息");
return false;
}
// 长度 小于 return
if (contentVal.length < 1) {
return false;
}

// 调用 发送函数
sendMssage(targetId, contentVal, doctorName, doctorId);

// 清空文本域的值
$(".rongcloud-text").val("");
});

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
<!-- 右边聊天窗口 部分 -->
<div id="rcs-main" class="rongcloud-kefuListBox rongcloud-container clearfix">
<!-- 左边 -->
<div class="rongcloud-kefuList rongcloud-imList">
<div class="rongcloud-rong-header rongcloud-blueBg">
<div class="rongcloud-toolBar rongcloud-headBtn">
<div class="rongcloud-voice"></div>
<div class="rongcloud-sprite rongcloud-people"></div> <span class="rongcloud-recent">最近联系人</span>
<div class="rongcloud-sprite rongcloud-arrow-down cursor-pointer"></div>
</div>
</div>
<div class="rongcloud-content">
<div class="rongcloud-netStatus" style="display:none">
<div class="rongcloud-sprite"></div> <span>连接断开,请刷新重连</span> </div>
<div class="rcs-conversation-list">
<!-- 会话列表每一个会话class="rong-conversation"会话列表中的每一个会话此类名不能被修改-->
<div class="rong-conversation" _cid="bb" _mcount="0">
<div class="rongcloud-ext">
<p class="rongcloud-attr"><i class="rongcloud-sprite rongcloud-no-remind"></i></p>
</div>
<div class="rongcloud-photo"><img class="rongcloud-img" src="http://7xo1cb.com1.z0.glb.clouddn.com/rongcloudkefu2.png" alt=""></div>
<div class="rongcloud-info">
<h3 class="rongcloud-nickname"> <span class="rongcloud-nickname_text">用户:小轩轩</span> </h3>
</div>
</div>
<div class="rong-conversation" _cid="2" _mcount="0">
<div class="rongcloud-ext">
<p class="rongcloud-attr"><i class="rongcloud-sprite rongcloud-no-remind"></i></p>
</div>
<div class="rongcloud-photo"><img class="rongcloud-img" src="http://7xo1cb.com1.z0.glb.clouddn.com/rongcloudkefu2.png" alt=""></div>
<div class="rongcloud-info">
<h3 class="rongcloud-nickname"> <span class="rongcloud-nickname_text">用户:小机智</span> </h3>
</div>
</div>

</div>
</div>
</div>

<!-- 右边 -->
<div class="rcs-chat-wrapper rcs-chat-im-wrapper">
<div class="rongcloud-kefuChat">
<div id="header" class="rongcloud-rong-header rongcloud-blueBg rongcloud-online">
<div class="rongcloud-infoBar rongcloud-pull-left">
<div class="rongcloud-infoBarTit"> <span class="rongcloud-kefuName">用户:小轩轩</span> </div>
</div>
<div class="rongcloud-toolBar rongcloud-headBtn rongcloud-pull-right">
<div class="rongcloud-voice"></div>
<a class="rongcloud-kefuChatBoxHide rongcloud-sprite" title="隐藏" onclick="minimize()"></a>
<a class="rongcloud-kefuChatBoxClose rongcloud-sprite" title="结束对话" onclick="endConversation()"></a>
</div>
</div>
<!-- style="display:none;" -->
<div class="rongcloud-outlineBox rcs-connect-status">
<div class="rongcloud-sprite"></div> <span>连接断开,请刷新重连</span> </div>
<div id="rcs-message-list" class="rcs-message-box">
<!-- message: 消息list的页面 -->

<div class="rongcloud-Messages-history" style="display: block;"><b>查看历史消息</b></div>

<div class="historyBox">
</div>
<div class="rong-message-list">
</div>

<div class="rongcloud-Messages-history" style="display: block;"><b>查看历史消息</b></div>
<div class="rongcloud-Messages-date"> <b>14:42</b> </div>
<!-- 我 -->
<div class="rongcloud-Message rongcloud-clearfix rongcloud-Message-send " id="rcs-templte-message-text">
<div>
<div class="rongcloud-Message-header">
<img class="rongcloud-img rongcloud-Message-avatar rongcloud-avatar" src="http://7xo1cb.com1.z0.glb.clouddn.com/rongcloudkefu2.png"
alt="">
<div class="rongcloud-Message-author rongcloud-clearfix">
<a class="rongcloud-author"></a>
</div>
</div>
</div>
<div class="rongcloud-Message-body">
<div class="rongcloud-Message-text">
<pre class="rongcloud-Message-entry">新年快乐</pre>
</div>
</div>
</div>

<!-- 用户 -->
<div class="rongcloud-Message rongcloud-clearfix " id="rcs-templte-message-text">
<div>
<div class="rongcloud-Message-header">
<img class="rongcloud-img rongcloud-Message-avatar rongcloud-avatar" src="http://7xo1cb.com1.z0.glb.clouddn.com/rongcloudkefu2.png"
alt="">
<div class="rongcloud-Message-author rongcloud-clearfix">
<a class="rongcloud-author"> 用户:小轩轩 </a>
</div>
</div>
</div>
<div class="rongcloud-Message-body">
<div class="rongcloud-Message-text">
<pre class="rongcloud-Message-entry">你好</pre>
</div>
</div>
</div>
<div class="rongcloud-emptyBox">暂时没有新消息</div>
</div>
<div id="rcs-chat-box" class="rongcloud-rong-footer">
<div class="rongcloud-footer-con">

</div>
<!-- 请输入文字 发送 -->
<div class="rongcloud-footer-input">
<div class="rongcloud-footer-textarea">
<textarea class="rongcloud-text rongcloud-grey" placeholder="请输入文字..."></textarea>
</div>
<button type="button" style="background-color: #0099ff;" class="rongcloud-rong-btn rongcloud-rong-send-btn" id="rong-sendBtn">发送</button>
</div>
</div>
</div>
</div>
</div>

增加动态文本插入,enter发送,ctrl+enter换行

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
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
<template>
<div ref="wrap" class="atwho-wrap" @input="handleInput" @keydown="handleKeyDown">
<div v-if="atwho" class="atwho-panel" :style="style">
<ul class="atwho-view atwho-ul">
<li v-for="(item, index) in atwho.list" class="atwho-li" :key="index" :class="isCur(index) && 'atwho-cur'" :ref="isCur(index) && 'cur'" :data-index="index" @mouseenter="handleItemHover" @click="handleItemClick">
<span v-text="item"></span>
</li>
<li>
<span>展开更多群成员</span>
<img src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBzdGFuZGFsb25lPSJubyI/PjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+PHN2ZyB0PSIxNTQ1NDgyNjY3NzY4IiBjbGFzcz0iaWNvbiIgc3R5bGU9IiIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHAtaWQ9IjEwODYiIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB3aWR0aD0iMTYiIGhlaWdodD0iMTYiPjxkZWZzPjxzdHlsZSB0eXBlPSJ0ZXh0L2NzcyI+PC9zdHlsZT48L2RlZnM+PHBhdGggZD0iTTMzNi43MzMgMTE5LjY2N2wtNTYuMjc4IDU1LjcyIDMzNC44NTcgMzM3LjE1NC0zMzcuNjczIDMzNC4zMTUgNTUuODAyIDU2LjE4NCAzOTMuOTQ0LTM5MC4wNDB6IiBwLWlkPSIxMDg3Ij48L3BhdGg+PC9zdmc+" />
</li>
</ul>
</div>
<textarea ref="textarea" class="editor textarea-content" @keyup="getCursorRect($event)" @keydown="getCursorRect($event)" @mousedown="getCursorRect($event)" @mouseup="getCursorRect($event)" @focus="getCursorRect($event)" placeholder="按下Enter发送内容/ Command+Enter换行"></textarea>
</div>
</template>

<script>
import getCaretCoordinates from 'textarea-caret'
export default {
props: {
value: { //输入框初始值
type: String,
default: ''
},
suffix: { //插入字符链接
type: String,
default: ' '
},
loop: { //上下箭头循环
type: Boolean,
default: true
},
avoidEmail: { //@前不能是字符
type: Boolean,
default: true
},
hoverSelect: { //悬浮选中
type: Boolean,
default: true
},
members: { //选择框选项列表
type: Array,
default: () => []
},
nameKey: {
type: String,
default: ''
}
},

data() {
return {
atItems: ['@'],
bindsValue: this.value != null,
atwho: null
}
},
computed: {
style() {
if(this.atwho) {
const {
list,
cur,
x,
y
} = this.atwho
const {
wrap
} = this.$refs
const el = this.$el.querySelector('textarea')
if(wrap) {
const left = x + el.offsetLeft - el.scrollLeft + 'px'
const top = y + el.offsetTop - el.scrollTop + 25 + 'px'
return {
left,
top
}
}
}
return null
}
},
watch: {
members() {
this.handleInput(true)
},
value(value, oldValue) {
this.handleValueUpdate(value)
}
},
mounted() {
this.handleValueUpdate(this.value)
},
methods: {
getAtAndIndex(text, ats) {
return ats.map((at) => {
return {
at,
index: text.lastIndexOf(at)
}
}).reduce((a, b) => {
return a.index > b.index ? a : b
})
},
isCur(index) {
return index === this.atwho.cur
},
handleValueUpdate(value) { //更新textarea的值
const el = this.$el.querySelector('textarea')
if(value !== el.value) {
el.value = value
}
},
handleItemHover(e) {
if(this.hoverSelect) {
this.selectByMouse(e)
}
},
handleItemClick(e) {
this.selectByMouse(e)
this.insertItem()
},
handleKeyDown(e) {
const {
atwho
} = this;
if(atwho) {
if(e.keyCode === 38 || e.keyCode === 40) { // ↑/↓
if(!(e.metaKey || e.ctrlKey)) {
e.preventDefault()
e.stopPropagation()
this.selectByKeyboard(e)
}
return
}
if(e.keyCode === 13) { // enter
this.insertItem()
e.preventDefault()
e.stopPropagation()
return
}
if(e.keyCode === 27) { // esc
this.closePanel()
return
}
}
// 为了兼容ie ie9~11 editable无input事件 只能靠keydown触发 textarea正常
// 另 ie9 textarea的delete不触发input
const isValid = e.keyCode >= 48 && e.keyCode <= 90 || e.keyCode === 8
if(isValid) {
setTimeout(() => {
this.handleInput()
}, 50)
}
if(e.keyCode === 8) { //删除del键
//this.handleDelete(e)
}

if((e.keyCode === 13)) { //回车
if(/macintosh|mac os x/i.test(navigator.userAgent)) { //是不mac
if(!e.metaKey) { //Mac没有按command
this.$emit("enterSend", e)
return
}
} else {
if(!e.ctrlKey) { //win没有按command
this.$emit("enterSend", e)
return
}
}

const el = this.$el.querySelector('textarea') //按下command
if(!el.value.replace(/(^\s*)|(\s*$)/g, "").length) {
console.log('没有输入有效字符不可换行');
return;
}
this.$emit('input', el.value + '\n')

}
},

handleInput(event) {
const el = this.$el.querySelector('textarea')
this.$emit('input', el.value) //更新父组件
const text = el.value.slice(0, el.selectionEnd)
if(text) {
const {
atItems,
avoidEmail
} = this
let show = true
const {
at,
index
} = this.getAtAndIndex(text, atItems)
if(index < 0) show = false
const prev = text[index - 1] //上一个字符
const chunk = text.slice(index + at.length, text.length)
if(avoidEmail) { //上一个字符不能为字母数字 避免与邮箱冲突,微信则是避免 所有字母数字及半角符号
if(/^[a-z0-9]$/i.test(prev)) show = false
}

if(/^\s/.test(chunk)) show = false //chunk以空白字符开头不匹配 避免`@ `也匹配
if(!show) {
this.closePanel()
} else {
const {
members,
filterMatch
} = this
if(!event) { // fixme: should be consistent with At.vue
this.$emit('at', chunk)
}

const matched = members.filter(v => {
return v.toString().indexOf(chunk) > -1
})

if(matched.length) {
this.openPanel(matched, chunk, index, at)
} else {
this.closePanel()
}
}
} else {
this.closePanel()
}
},

closePanel() {
if(this.atwho) {
this.atwho = null
}
},
openPanel(list, chunk, offset, at) { //打开Atuser列表 matched, chunk, index, at 过滤数组,匹配项,匹配项index,'@'
const fn = () => {
const el = this.$el.querySelector('textarea')
const atEnd = offset + at.length // 从@后第一位开始
const rect = getCaretCoordinates(el, atEnd)
this.atwho = {
chunk,
offset,
list,
atEnd,
x: rect.left,
y: rect.top - 4,
cur: 0, // todo: 尽可能记录
}
}
if(this.atwho) {
fn()
} else { // 焦点超出了显示区域 需要提供延时以移动指针 再计算位置
setTimeout(fn, 10)
}
},

selectByMouse(e) {
function closest(el, predicate) { //遍历直到有data-index为止
do {
if(predicate(el)) return el;
} while (el = el && el.parentNode);
}

const el = closest(e.target, d => {
return d.getAttribute('data-index')
})

const cur = +el.getAttribute('data-index')
this.atwho = {
...this.atwho,
cur
}
},
selectByKeyboard(e) {
const offset = e.keyCode === 38 ? -1 : 1
const {
cur,
list
} = this.atwho
const nextCur = this.loop ?
(cur + offset + list.length) % list.length :
Math.max(0, Math.min(cur + offset, list.length - 1))
this.atwho = {
...this.atwho,
cur: nextCur
}
},

// todo: 抽离成库并测试
insertText(text, el) {
const start = el.selectionStart
const end = el.selectionEnd
el.value = el.value.slice(0, start) +
text + el.value.slice(end)
const newEnd = start + text.length
el.selectionStart = newEnd
el.selectionEnd = newEnd
},
insertItem() {
const {
chunk,
offset,
list,
cur,
atEnd
} = this.atwho
const {
suffix,
atItems
} = this
const el = this.$el.querySelector('textarea')
const text = el.value.slice(0, atEnd)
const {
at,
index
} = this.getAtAndIndex(text, atItems)
const start = index + at.length // 从@后第一位开始
el.selectionStart = start
el.focus() // textarea必须focus回来
const curItem = list[cur]
const t = '' + curItem + suffix
this.insertText(t, el)
this.$emit('insert', curItem) //插入字符
this.handleInput()
},
getCursorRect(e) {
console.log(e.type)
var isIE = !(!document.all); //是不是IE
var start = 0,
end = 0;
var oTextarea = this.$el.querySelector("textarea");
if(isIE) {
var sTextRange = document.selection.createRange();

//判断选中的是不是textarea对象
if(sTextRange.parentElement() == oTextarea) {
//创建一个TextRange对象
var oTextRange = document.body.createTextRange();
//移动文本范围以便范围的开始和结束位置能够完全包含给定元素的文本。
oTextRange.moveToElementText(oTextarea);

//此时得到两个 TextRange
//oTextRange文本域(textarea)中文本的TextRange对象
//sTextRange是选中区域文本的TextRange对象

//compareEndPoints方法介绍,compareEndPoints方法用于比较两个TextRange对象的位置
//StartToEnd 比较TextRange开头与参数TextRange的末尾。
//StartToStart比较TextRange开头与参数TextRange的开头。
//EndToStart 比较TextRange末尾与参数TextRange的开头。
//EndToEnd 比较TextRange末尾与参数TextRange的末尾。

//moveStart方法介绍,更改范围的开始位置
//character 按字符移动
//word 按单词移动
//sentence 按句子移动
//textedit 启动编辑动作

//这里我们比较oTextRange和sTextRange的开头,的到选中区域的开头位置
for(start = 0; oTextRange.compareEndPoints("StartToStart", sTextRange) < 0; start++) {
oTextRange.moveStart('character', 1);
}
//需要计算一下\n的数目(按字符移动的方式不计\n,所以这里加上)
for(var i = 0; i <= start; i++) {
if(oTextarea.value.charAt(i) == '\n') {
start++;
}
}

//再计算一次结束的位置
oTextRange.moveToElementText(oTextarea);
for(end = 0; oTextRange.compareEndPoints('StartToEnd', sTextRange) < 0; end++) {
oTextRange.moveStart('character', 1);
}
for(var i = 0; i <= end; i++) {
if(oTextarea.value.charAt(i) == '\n') {
end++;
}
}
}
} else {
start = oTextarea.selectionStart;
end = oTextarea.selectionEnd;
}
this.$emit("cursorRect", {
start,
end
}); //获取鼠标当前在字符串位置
}
}
}
</script>

<style lang="less" scoped="scoped">
.atwho-wrap {
width: 100%;
font-size: 12px;
color: #333;
position: relative;
.atwho-panel {
position: absolute;
&.test {
width: 2px;
height: 2px;
background: red;
}
.atwho-inner {
position: relative;
}
}
.atwho-view {
color: black;
z-index: 11110 !important;
border-radius: 2px;
box-shadow: 0 0 10px 0 rgba(101, 111, 122, .5);
position: absolute;
cursor: pointer;
background-color: rgba(255, 255, 255, .94);
width: 170px;
max-height: 312px;
&::-webkit-scrollbar {
width: 11px;
height: 11px;
}
&::-webkit-scrollbar-track {
background-color: #F5F5F5;
}
&::-webkit-scrollbar-thumb {
min-height: 36px;
border: 2px solid transparent;
border-top: 3px solid transparent;
border-bottom: 3px solid transparent;
background-clip: padding-box;
border-radius: 7px;
background-color: #C4C4C4;
}
}
.atwho-ul {
list-style: none;
padding: 0;
margin: 0;
li {
box-sizing: border-box;
display: block;
height: 25px;
padding: 2px 10px;
white-space: nowrap;
display: flex;
align-items: center;
justify-content: space-between;
&.atwho-cur {
background: #f2f2f5;
color: #eb7350;
}
span {
overflow: hidden;
text-overflow: ellipsis;
}
img {
height: 13px;
width: 13px;
}
}
}
.editor {
width: 100%;
color: blue;
height: 160px;
display: block;
box-sizing: border-box;
padding: 8px;
font-size: 14px;
background: white;
}
}
</style>
</style>
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
<template>
<div class="atuser">
<at :members="members" @enterSend="send" v-model="inputcontent" @cursorRect="cursorRect">
</at>
</div>
</template>

<script>
import at from './atuser.vue'
export default {
data() {
return {
members: [123, 12, 1234, 12345, "小花", "小花华", "小三"],
inputcontent: "" //用户输入内容初始值

};
},
components: {
at,
},
methods: {
send(e) { //回车发送
console.log(e)
},
cursorRect(val){
console.log("当前光标点击位置:")
console.log(val)
}
}
}
</script>

<style scoped="scoped" lang="less">
.atuser {
width: 700px;
height: 160px;
border: 1px solid red;
.editor{
width: 700px;
height: 160px;
overflow: hidden;
border: 0px;
outline: none;
resize: none;
-webkit-appearance: none;
}
}
</style>