Vue @user 组件

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

复制代码

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

最近折腾 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 来展现实时的结果。
在明白了剪贴板数据的存放位置之后,就能够编写代码来处理它们了。因为咱们的富文本输入框比较简单,因此只须要处理两类数据便可,其一是普通的文本类型数据,包括 emoji 表情;其二则是图片类型数据。

新建 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文档里面找到,在这里咱们只须要使用 insertTextinsertImage 便可。

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 的插入。

插入 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 节点里面使用了。首先在节点的 keyupclick 事件里记录光标位置:

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 仅仅完成了输入框最基础的部分,关于复制粘贴还有不少细节要处理(好比把别处的行内样式也复制了进来等等),在这里就不一一展开了,感兴趣的读者能够自行研究,更欢迎和我留言交流~

相关文章
相关标签/搜索