const simEditor = (function () {
/**
* 处理函数
* @callback ReplacerCallback
* @param {{src: string,alt:string}} e
*/
/**
* 表情替换
* @param {string} content 文本内容
* @param {Object} srcMap 表情映射
* @param {ReplacerCallback} replacer 处理函数
* @returns
*/
function replaceEmotion(
content,
srcMap,
replacer = e => {
return e
}
) {
const pattern = /\[[\u4e00-\u9fa5_a-zA-Z]+\]/g
let emotions = content.match(pattern)
emotions = Array.from(new Set(emotions))
let str = content
str = str.replace(pattern, s => {
if (emotions.indexOf(s) > -1 && srcMap[s]) {
return replacer({ src: srcMap[s], alt: s })
} else {
return s
}
})
return str
}
/**
* 缓存光标
* @param {Node} editNode 编辑节点
*/
function editCacheRange(editNode) {
if (window.getSelection) {
const sel = window.getSelection()
if (sel.getRangeAt && sel.rangeCount) {
editNode._cache_range = sel.getRangeAt(0)
}
} else if (document.selection && document.selection.createRange) {
editNode._cache_range = document.selection.createRange()
}
}
// 暴力清除标签样式
function html2text(html) {
if (!html) {
return ''
}
const enterRegExp = /<\/(p|div)>/g
// img标签alt的值
const altRegExp = /<img.*?alt="(.*?)">/ig
const text = html
.replace(enterRegExp, '\n')
.replace(/\\r/g, '')
.replace(altRegExp, '$1')
.replace(/<[^>]+>/g, '')
return text.trim()
}
/**
* 文本渲染过滤器
* @param {string} text 文本
* @param {{[alt:string]:[src:string]}} txt2imgMap 文本2图片
* @returns {string}
*/
const mgsFilters = (text, txt2imgMap) => {
txt2imgMap = txt2imgMap || {
'[猫猫]': 'https://img.yzcdn.cn/vant/cat.jpeg'
}
return replaceEmotion(text, txt2imgMap, ({ src, alt }) => {
return `<img src="${src}" alt="${alt}" />`
})
}
/**
* 插入信息到编辑器
* @param {Node} node 节点
* @param {string} html html文本
*/
function insertHtml(node, html) {
const content = document.createElement('p')
content.innerHTML = html || '<br>'
node.innerHTML = ''
node.appendChild(content)
}
/**
* 获取粘贴文本
* @param {Event} e 粘贴事件
* @returns {string}
*/
function getPasteText(e) {
if (!(e.clipboardData && e.clipboardData.items)) {
return
}
const clipboardData = e.clipboardData // 暂不考虑 originalEvent 的情况
let pasteText = ''
if (clipboardData == null) {
pasteText = window.clipboardData && window.clipboardData.getData('text')
} else {
pasteText = clipboardData.getData('text/plain')
}
return pasteText
}
/**
* 剔除编辑区容器的 attribute 变化中的非 contenteditable 变化
* @param {MutationRecord[]} mutations
* @param {Node} tar 编辑区容器的 DOM 节点
*/
function mutationsFilter(mutations, tar) {
// 剔除编辑区容器的 attribute 变化中的非 contenteditable 变化
return mutations.filter(({ type, target, attributeName }) => {
return (
type !== 'attributes' ||
(type === 'attributes' &&
(attributeName === 'contenteditable' || target !== tar))
)
})
}
/**
* 节点变化监听
* @param {Node} node 节点
* @param {{onChange:MutationCallback}} op 选项
* @returns
*/
function observeNode(node, op = {}) {
const { onChange } = op
// 观察器的配置(需要观察什么变动)
const config = {
subtree: true, // 子节点树变化
childList: true, // 节点变化
attributes: true, // 属性变化
attributeOldValue: true,
characterData: true, // 节点内容或文本变化
characterDataOldValue: true
}
// 创建一个观察器实例并传入回调函数
const _nodeObserver = new MutationObserver((mutationsList, observer) => {
onChange && onChange(mutationsList, observer)
})
// 以上述配置开始观察目标节点
_nodeObserver.observe(node, config)
return _nodeObserver
}
/**
* 插入元素到编辑器
* @param {Node} editNode 编辑节点对象
* @param {Node|string} insertNode 插入节点对象
* @param {{enterNodeName:string,isEnter:boolean,insertType:string}} op 选项 enterNodeName:换行标签,大写,isEnter 是否是换行,insertType:插入类型text/html
*/
function insertNodeAtCaret(editNode, insertNode, op = {}) {
const { enterNodeName = 'p', isEnter = false } = op
let sel, range, inEditRange
if (window.getSelection) {
sel = window.getSelection()
if (sel.getRangeAt && sel.rangeCount) {
range = sel.getRangeAt(0)
inEditRange = range && (range.endContainer === editNode || editNode.contains(range.endContainer))
}
// 取出缓存range
if (!inEditRange && editNode._cache_range) {
range = editNode._cache_range
inEditRange = range && (range.endContainer === editNode || editNode.contains(range.endContainer))
}
if (!inEditRange) {
const editRange = document.createRange()
editRange.selectNode(editNode)
range = editRange
range.selectNodeContents(editNode)
// 编辑器有内容就光标就移到最后
if (editNode.lastChild) {
range.selectNodeContents(editNode.lastChild)
range.setStart(editNode.lastChild, 1)
range.setEnd(editNode.lastChild, 1)
inEditRange = true
}
}
// 把选中范围清空
range.deleteContents()
// 是否在文本里
const isInText = range.endContainer.nodeName === '#text'
// 是否在换行标签里
const isInEnter = inEditRange && range.endContainer.nodeName === enterNodeName.toUpperCase()
// 光标范围里的元素
const rangeNodes = range.endContainer.childNodes
// 只有父节点是否是编辑对象
const isPEditNode = range.endContainer.parentNode === editNode
let endContainer = range.endContainer
// 在文本中,需要把范围设置到P标签上
if (!isPEditNode && isInText) {
endContainer = range.endContainer.parentNode
}
// 只是换行,换行内容存在'<br>'
const justBr = endContainer.innerHTML === '<br>'
// 编辑器是否空
const isEmptyEditNode = !editNode.innerHTML
if (isEnter) {
if (isEmptyEditNode) { // 存在空元素
range.insertNode(insertNode)
range.setStartAfter(insertNode)
insertNode = insertNode.cloneNode(true)
} else if (isInText) { // 在文本
const textLen = range.endContainer.textContent.length
const parentNodes = range.endContainer.parentNode.childNodes
const parentNodeLen = parentNodes.length
const index = [...parentNodes].indexOf(range.endContainer)
if (textLen <= range.endOffset && index === parentNodeLen - 1) {
range.setStartAfter(endContainer)
} else if (range.endOffset === 0 && index === 0) {
range.setEndBefore(endContainer)
} else {
insertNode = document.createTextNode('\n')
}
} else if (isInEnter) { // 在换行标签里
if (!justBr && range.endOffset === 0) {
range.setEndBefore(range.endContainer)
} else if (!justBr && rangeNodes.length > range.endOffset) {
// 在图片标签之间的换行
insertNode = document.createTextNode('\n')
} else {
// 把范围后移一个单位
range.setStartAfter(range.endContainer)
}
}
} else {
// 净空br, 再插入
if (justBr) {
range.endContainer.innerHTML = ''
}
// 插入标签是否在块元素里
const isBlockInsertNode = ['P', 'DIV'].indexOf(insertNode.nodeName) > -1
// 当前在块元素里
if (isInEnter) {
if (isBlockInsertNode) {
// 当前空行只有br内容
if (justBr) {
range.endContainer.innerHTML = insertNode.innerHTML
range.setEnd(range.endContainer, range.endContainer.childNodes.length)
insertNode = null
} else {
range.setStartAfter(endContainer)
}
}
} else {
if (!isBlockInsertNode && !isInText) {
const _p = document.createElement('p')
_p.appendChild(insertNode)
insertNode = _p
}
}
}
// 插入元素
if (insertNode) {
range.insertNode(insertNode)
// 换行、空行特殊处理
if (isEnter || isEmptyEditNode) {
range.selectNodeContents(insertNode)
}
}
// 清空范围,但并不删除内容
range.collapse(false)
sel.removeAllRanges()
sel.addRange(range)
// // 存一下光标
// edit._cache_range = range
} else if (document.selection && document.selection.createRange) {
// 吧啦吧啦吧啦兼容没有做
// range = document.selection.createRange()
// range.text = text;
}
}
const funcs = {
insertNodeAtCaret,
observeNode,
mutationsFilter,
getPasteText,
mgsFilters,
insertHtml,
replaceEmotion
}
/**
* 初始化编辑器
* @param {Node} node 编辑器节点
* @param {{onChange:(txt)=>void,onPaste:(e:Event)=>void|boolean,textFilters:(text:string)=>string}} op 选项,
* onChange:变化监听,onPaste:粘贴事件,textFilters,自定义文本过滤
* @returns {funcs}
*/
function initEdit(node, op = {}) {
if (initEdit) {
// return
}
const onChange = op.onChange
const onPaste = op.onPaste
const textFilters = op.textFilters || mgsFilters
// 改变光标缓存
function _editChangeCacheRange() {
editCacheRange(node)
}
// 存在光标就清空,并重新记录新光标
['keyup', 'focus', 'click'].forEach((key) => {
node.removeEventListener(key, _editChangeCacheRange)
node.addEventListener(key, _editChangeCacheRange)
})
const _pasteEvent = (e) => {
e.preventDefault()
let text = onPaste && onPaste(e)
if (!text) {
text = getPasteText(e)
text = textFilters(html2text(text))
}
if (text) {
// 创建内容模板元素
const content = document.createElement('template')
content.innerHTML = text
// 只要内容片段就行DocumentFragment
insertNodeAtCaret(node, content.content)
}
return false
}
// 拦截粘贴
node.removeEventListener('paste', _pasteEvent)
node.addEventListener('paste', _pasteEvent)
// 监听节点
observeNode(node, {
onChange(mutationsList, observer) {
// const datas = mutationsFilter(mutationsList, observer.target)
// 空的时候保留一个<p><br><p>
const isEmpty = !node.innerHTML
if (isEmpty) {
node.innerHTML = ''
const br = document.createElement('p')
br.innerHTML = '<br>'
insertNodeAtCaret(node, br)
} else {
// trim() 去掉头尾的空白符,换行、空格、制表符等
if (onChange) {
const text = html2text(node.innerHTML)
onChange(text)
}
// 存光标这步很关键
}
editCacheRange(node)
}
})
/**
* 插入元素到编辑器
* @param {HTMLElement} insertNode 插入节点对象
* @param {boolean} isEnter 是否是换行
* @param {string} enterNodeName 换行标签,大写
*/
const _insertNodeAtCaret = (
insertNode,
op = {}) => {
return insertNodeAtCaret(node, insertNode, op)
}
const _insertHtml = (html) => {
const _html = textFilters(html2text(html))
return insertHtml(node, _html)
}
const _funcs = {
...funcs,
insertNodeAtCaret: _insertNodeAtCaret,
insertHtml: _insertHtml,
html: _insertHtml
}
return _funcs
}
return {
...funcs,
initEdit
}
})()
const inEnter = document.querySelector('#inEnter');
const inImg = document.querySelector('#inImg');
const inText = document.querySelector('#inText');
const showMsg = document.querySelector('#showMsg');
const showText = document.querySelector('#showText');
const edit = document.querySelector('.edit')
let mgsValue = '输入点什么...������\n哈哈哈哈[猫猫]'
function setMsg(text) {
showText.innerHTML = text
showMsg.innerHTML = simEditor.mgsFilters(text)
}
const editObj = simEditor.initEdit(edit, {
onChange: (text) => {
setMsg(text)
}
})
// 初始化内容
editObj.html(mgsValue)
inEnter.addEventListener('click', () => {
const br = document.createElement('p')
br.innerHTML = '<br>'
// const br = document.createTextNode('\n') // 纯\n换行
// const br = document.createElement('br') // 纯br换行
editObj.insertNodeAtCaret(br, { isEnter: true })
})
inText.addEventListener('click', () => {
const text = document.createTextNode('哈哈')
editObj.insertNodeAtCaret(text)
})
inImg.addEventListener('click', () => {
const img = document.createElement('img');
img.src = 'https://img.yzcdn.cn/vant/cat.jpeg';
// img.src = 'data:image/gif;base64,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'
img.alt = '[猫猫]';
editObj.insertNodeAtCaret(img)
})
<div>信息显示器:</div>
<div class="show-msg" id="showMsg"></div>
<div>纯文本:</div>
<div class="show-txt" id="showText"></div>
<div contenteditable="true" tabindex="1" class="edit"></div>
<div class="cc">
<button id="inEnter">插入换行</button>
<button id="inText">插入哈哈</button>
<button id="inImg">插入图片</button>
</div>
<!-- <div class="mgsbox">
<textarea name="msg" id="mgsTxt" cols="50" rows="5"></textarea>
</div> -->
body {
background: #fff;
}
.edit {
width: 200px;
padding: 10px;
margin-top: 20px;
white-space: pre-wrap;
background-color: #f5f5f5
}
.edit:focus {
outline: none
}
.edit img {
vertical-align: text-bottom;
padding: 0 1px;
width: 50px;
height: 50px;
}
.edit p {
margin: 0;
}
.cc {
padding: 20px 0
}
.show-msg {
width: 200px;
min-height: 40px;
padding: 10px;
border-radius: 6px;
white-space: pre-wrap;
color: #fff;
background-color: #3EB575
}
.show-msg img {
vertical-align: text-bottom;
padding: 0 1px;
width: 50px;
height: 50px;
}
.show-txt {
width: 200px;
min-height: 40px;
padding: 10px;
border-radius: 6px;
white-space: pre-wrap;
color: #fff;
background-color: #999
}