<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: 300px;
}
.editor {
width: 400px;
height: 200px;
overflow: auto;
white-space: pre-wrap;
border: solid 1px rgba(0, 0, 0, .5);
}
</style>
复制代码
<template>
<div ref="wrap" class="atwho-wrap" @input="handleInput()" @keydown="handleKeyDown">
<div v-if="atwho" class="atwho-panel" :style="style">
<div class="atwho-inner">
<div class="atwho-view">
<ul class="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>群成员</li>
</ul>
</div>
</div>
</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
}
},
scrollRef: {
type: String,
default: ''
}
},
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 scrollLeft = this.scrollRef ? document.querySelector(this.scrollRef).scrollLeft : 0
const scrollTop = this.scrollRef ? document.querySelector(this.scrollRef).scrollTop : 0
const left = x + scrollLeft + window.pageXOffset - offset.left + 'px'
const top = y + scrollTop + window.pageYOffset - offset.top + [this.members.length+2]*27+ 'px'
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 - 4,
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 {
position: relative;
.atwho-panel {
position: absolute;
.atwho-inner {
position: relative;
}
}
.atwho-view {
color: black;
z-index: 11110 !important;
border-radius: 6px;
box-shadow: 0 0 10px 0 rgba(101, 111, 122, .5);
position: absolute;
bottom: 0;
left: -0.8em;
cursor: default;
background-color: rgba(255, 255, 255, .94);
width: 170px;
max-height: 312px;
overflow-y: auto;
&::-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 {
display: block;
box-sizing: border-box;
height: 27px;
padding: 0 12px;
white-space: nowrap;
display: flex;
align-items: center;
padding: 0 4px;
padding-left: 8px;
&.atwho-cur {
background: #5BB8FF;
color: white;
}
span {
overflow: hidden;
text-overflow: ellipsis;
}
img {
height: 100%;
width: auto;
-webkit-transform: scale(.8);
}
}
}
}
</style>
复制代码
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
}
}
复制代码
最近折腾 Websocket,打算开发一个聊天室应用练练手。在应用开发的过程当中发现能够插入 emoji ,粘贴图片的富文本输入框其实蕴含着许多有趣的知识,因而便打算记录下来和你们分享。javascript
仓库地址:chat-input-boxhtml
预览地址:codepenvue
首先来看看 demo 效果: java
传统的输入框都是使用 <textarea>
来制做的,它的优点是很是简单,但最大的缺陷倒是没法展现图片。为了可以让输入框可以展现图片(富文本化),咱们能够采用设置了 contenteditable="true"
属性的 <div>
来实现这里面的功能。node
简单建立一个 index.html
文件,而后写入以下内容:git
<div class="editor" contenteditable="true">
<img src="https://static.easyicon.net/preview/121/1214124.gif" alt="">
</div>
复制代码
打开浏览器,就能看到一个默认已经带了一张图片的输入框:github
接下来的任务,就是思考如何直接经过 control + v
把图片粘贴进去了。web
任何经过“复制”或者 control + c
所复制的内容(包括屏幕截图)都会储存在剪贴板,在粘贴的时候能够在输入框的 onpaste
事件里面监听到。canvas
document.querySelector('.editor').addEventListener('paste', (e) => {
console.log(e.clipboardData.items)
})
复制代码
而剪贴板的的内容则存放在 DataTransferItemList
对象中,能够经过 e.clipboardData.items
访问到:浏览器
DataTransferItemList
前的小箭头,会发现对象的
length
属性为0。说好的剪贴板内容呢?其实这是 Chrome 调试的一个小坑。在开发者工具里面,
console.log
出来的对象是一个引用,会随着原始数据的改变而改变。因为剪贴板的数据已经被“粘贴”进输入框了,因此展开小箭头之后看到的
DataTransferItemList
就变成空的了。为此,咱们能够改用
console.table
来展现实时的结果。
新建 paste.js
文件:
const onPaste = (e) => {
// 若是剪贴板没有数据则直接返回if (!(e.clipboardData && e.clipboardData.items)) {
return
}
// 用Promise封装便于未来使用returnnewPromise((resolve, reject) => {
// 复制的内容在剪贴板里位置不肯定,因此经过遍从来保证数据准确for (let i = 0, len = e.clipboardData.items.length; i < len; i++) {
const item = e.clipboardData.items[i]
// 文本格式内容处理if (item.kind === 'string') {
item.getAsString((str) => {
resolve(str)
})
// 图片格式内容处理
} elseif (item.kind === 'file') {
const pasteFile = item.getAsFile()
// 处理pasteFile// TODO(pasteFile)
} else {
reject(newError('Not allow to paste this type!'))
}
}
})
}
export default onPaste
复制代码
而后就能够在 onPaste
事件里面直接使用了:
document.querySelector('.editor').addEventListener('paste', async (e) => {
const result = await onPaste(e)
console.log(result)
})
复制代码
上面的代码支持文本格式,接下来就要对图片格式进行处理了。玩过 <input type="file">
的同窗会知道,包括图片在内的全部文件格式内容都会储存在 File
对象里面,这在剪贴板里面也是同样的。因而咱们能够编写一套通用的函数,专门来读取 File
对象里的图片内容,并把它转化成 base64
字符串。
为了更好地在输入框里展现图片,必须限制图片的大小,因此这个图片处理函数不只可以读取 File
对象里面的图片,还可以对其进行压缩。
新建一个 chooseImg.js
文件:
/**
* 预览函数
*
* @param {*} dataUrl base64字符串
* @param {*} cb 回调函数
*/functiontoPreviewer (dataUrl, cb) {
cb && cb(dataUrl)
}
/**
* 图片压缩函数
*
* @param {*} img 图片对象
* @param {*} fileType 图片类型
* @param {*} maxWidth 图片最大宽度
* @returns base64字符串
*/functioncompress (img, fileType, maxWidth) {
let canvas = document.createElement('canvas')
let ctx = canvas.getContext('2d')
const proportion = img.width / img.height
const width = maxWidth
const height = maxWidth / proportion
canvas.width = width
canvas.height = height
ctx.fillStyle = '#fff'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(img, 0, 0, width, height)
const base64data = canvas.toDataURL(fileType, 0.75)
canvas = ctx = nullreturn base64data
}
/**
* 选择图片函数
*
* @param {*} e input.onchange事件对象
* @param {*} cb 回调函数
* @param {number} [maxsize=200 * 1024] 图片最大致积
*/functionchooseImg (e, cb, maxsize = 200 * 1024) {
const file = e.target.files[0]
if (!file || !/\/(?:jpeg|jpg|png)/i.test(file.type)) {
return
}
const reader = new FileReader()
reader.onload = function () {
const result = this.result
let img = new Image()
if (result.length <= maxsize) {
toPreviewer(result, cb)
return
}
img.onload = function () {
const compressedDataUrl = compress(img, file.type, maxsize / 1024)
toPreviewer(compressedDataUrl, cb)
img = null
}
img.src = result
}
reader.readAsDataURL(file)
}
exportdefault chooseImg
复制代码
关于使用
canvas
压缩图片和使用FileReader
读取文件的内容在这里就不赘述了,感兴趣的读者能够自行查阅。
回到上一步的 paste.js
函数,把其中的 TODO()
改写成 chooseImg()
便可:
const imgEvent = {
target: {
files: [pasteFile]
}
}
chooseImg(imgEvent, (url) => {
resolve(url)
})
复制代码
回到浏览器,若是咱们复制一张图片并在输入框中执行粘贴的动做,将能够在控制台看到打印出了以 data:image/png;base64
开头的图片地址。
通过前面两个步骤,咱们后已经能够读取剪贴板中的文本内容和图片内容了,接下来就是把它们正确的插入输入框的光标位置当中。
对于插入内容,咱们能够直接经过 document.execCommand
方法进行。关于这个方法详细用法能够在MDN文档里面找到,在这里咱们只须要使用 insertText
和 insertImage
便可。
document.querySelector('.editor').addEventListener('paste', async (e) => {
const result = await onPaste(e)
const imgRegx = /^data:image\/png;base64,/const command = imgRegx.test(result) ? 'insertImage': 'insertText'document.execCommand(command, false, result)
})
复制代码
可是在某些版本的 Chrome 浏览器下,insertImage
方法可能会失效,这时候即可以采用另一种方法,利用 Selection
来实现。而以后选择并插入 emoji 的操做也会用到它,所以不妨先来了解一下。
当咱们在代码中调用 window.getSelection()
后会得到一个 Selection
对象。若是在页面中选中一些文字,而后在控制台执行 window.getSelection().toString()
,就会看到输出是你所选择的那部分文字。
与这部分区域文字相对应的,是一个 range
对象,使用 window.getSelection().getRangeAt(0)
便可以访问它。range
不只包含了选中区域文字的内容,还包括了区域的起点位置 startOffset
和终点位置 endOffset
。
咱们也能够经过 document.createRange()
的办法手动建立一个 range
,往它里面写入内容并展现在输入框中。
对于插入图片来讲,要先从 window.getSelection()
获取range
,而后往里面插入图片。
document.querySelector('.editor').addEventListener('paste', async (e) => {
// 读取剪贴板的内容
const result = await onPaste(e)
const imgRegx = /^data:image\/png;base64,/
// 若是是图片格式(base64),则经过构造range的办法把<img>标签插入正确的位置
// 若是是文本格式,则经过document.execCommand('insertText')方法把文本插入
if (imgRegx.test(result)) {
const sel = window.getSelection()
if (sel && sel.rangeCount === 1 && sel.isCollapsed) {
const range = sel.getRangeAt(0)
const img = new Image()
img.src = result
range.insertNode(img)
range.collapse(false)
sel.removeAllRanges()
sel.addRange(range)
}
} else {
document.execCommand('insertText', false, result)
}
})
复制代码
这种办法也能很好地完成粘贴图片的功能,而且通用性会更好。接下来咱们还会利用 Selection
,来完成 emoji 的插入。
不论是粘贴文本也好,仍是图片也好,咱们的输入框始终是处于聚焦(focus)状态。而当咱们从表情面板里选择 emoji 表情的时候,输入框会先失焦(blur),而后再从新聚焦。因为 document.execCommand
方法必须在输入框聚焦状态下才能触发,因此对于处理 emoji 插入来讲就没法使用了。
上一小节讲过,Selection
可让咱们拿到聚焦状态下所选文本的起点位置 startOffset
和终点位置 endOffset
,若是没有选择文本而仅仅处于聚焦状态,那么这两个位置的值相等(至关于选择文本为空),也就是光标的位置。只要咱们可以在失焦前记录下这个位置,那么就可以经过 range
把 emoji 插入正确的地方了。
首先编写两个工具方法。新建一个 cursorPosition.js
文件:
/**
* 获取光标位置
* @param {DOMElement} element 输入框的dom节点
* @return {Number} 光标位置
*/
export const getCursorPosition = (element) => {
let caretOffset = 0const 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)
}
复制代码
有了这两个方法之后,就能够放入 editor 节点里面使用了。首先在节点的 keyup
和 click
事件里记录光标位置:
let cursorPosition = 0
const editor = document.querySelector('.editor')
editor.addEventListener('click', async (e) => {
cursorPosition = getCursorPosition(editor)
})
editor.addEventListener('keyup', async (e) => {
cursorPosition = getCursorPosition(editor)
})
复制代码
记录下光标位置后,即可经过调用 insertEmoji()
方法插入 emoji 字符了。
insertEmoji (emoji) {
const text = editor.innerHTML
// 插入 emoji
editor.innerHTML = text.slice(0, cursorPosition) + emoji + text.slice(cursorPosition, text.length)
// 光标位置后挪一位,以保证在刚插入的 emoji 后面
setCursorPosition(editor, this.cursorPosition + 1)
// 更新本地保存的光标位置变量(注意 emoji 占两个字节大小,因此要加1)
cursorPosition = getCursorPosition(editor) + 1// emoji 占两位
}
复制代码
文章涉及的代码已经上传到仓库,为了简便起见采用 VueJS
处理了一下,不影响阅读。最后想说的是,这个 Demo 仅仅完成了输入框最基础的部分,关于复制粘贴还有不少细节要处理(好比把别处的行内样式也复制了进来等等),在这里就不一一展开了,感兴趣的读者能够自行研究,更欢迎和我留言交流~