|
<template> |
|
<div v-html="html" ref="content" @keyup="keyup" contenteditable :class="containerClassName" /> |
|
</template> |
|
<style scoped> |
|
.hashtags-line-container { |
|
padding: 10px; |
|
border: solid rgba(0, 0, 0, 0.5) 1px; |
|
border-radius: 4px; |
|
transition: 0.25s; |
|
margin: 6px; |
|
} |
|
.hashtags-line-container:hover { |
|
border: solid rgba(0, 0, 0, 0.87) 1px; |
|
} |
|
.hashtags-line-container:focus { |
|
border-color: transparent; |
|
outline: solid rgb(75, 95, 247) 2px; |
|
} |
|
</style> |
|
<style> |
|
.hashtag-class { |
|
color: rgb(75, 95, 247); |
|
} |
|
</style> |
|
<script> |
|
const HASHTAG_REGEXP = /#+([a-zA-Z0-9_]+)/gi |
|
const REGEXP_LESSTHAN = /</g |
|
const SCAPED_KEY_CODES = [ |
|
8, // Backspace |
|
13, // Enter |
|
] |
|
|
|
const highlightHashtags = (text, hashtagClass, validHashtags) => { |
|
const tagStart = `<span class="${hashtagClass}">` |
|
const html = text.replace(HASHTAG_REGEXP, (hashtag) => { |
|
if ((validHashtags && validHashtags.includes(hashtag.substr(1).toLowerCase())) || !validHashtags) { |
|
return `${tagStart}${hashtag}</span>` |
|
} |
|
return hashtag |
|
}) |
|
return html |
|
} |
|
|
|
const getTree = (node, container) => { |
|
if (node == container) return null |
|
const nodeIndex = Array.prototype.indexOf.call(node.parentNode.childNodes, node) |
|
if (node.parentNode === container) { |
|
return [nodeIndex] |
|
} |
|
return [nodeIndex, ...getTree(node.parentNode, container)] |
|
} |
|
|
|
const getChild = (container, path) => { |
|
try { |
|
let elem = container |
|
path.forEach((index) => { |
|
elem = elem.childNodes[index] |
|
}) |
|
return elem |
|
} catch (e) { |
|
return null |
|
} |
|
} |
|
|
|
export default { |
|
props: { |
|
hashtagClass: { type: String, default: 'hashtag-class' }, |
|
validHashtags: { type: Array, default: null }, |
|
containerClassName: { type: String, default: 'hashtags-line-container' }, |
|
value: { type: String, default: '' }, |
|
}, |
|
data() { |
|
return { |
|
open: true, |
|
html: '', |
|
} |
|
}, |
|
methods: { |
|
keyup($event) { |
|
const { keyCode } = $event |
|
if (SCAPED_KEY_CODES.includes(keyCode)) { |
|
// On backspace |
|
return |
|
} |
|
const vm = this |
|
const el = vm.$refs.content |
|
const { anchorOffset, anchorNode } = window.getSelection() |
|
const treePrototype = getTree(anchorNode, el) |
|
const tree = treePrototype ? treePrototype.reverse() : [] |
|
const text = el.innerText |
|
vm.render(text) |
|
vm.$emit('input', text) |
|
vm.$nextTick(() => { |
|
setTimeout(() => { |
|
const selection = window.getSelection() |
|
const range = document.createRange() |
|
let node = treePrototype ? getChild(el, tree) : el |
|
if (!node) return |
|
if (anchorOffset > node.textContent.length) { |
|
// Jump to the next node |
|
const nodeIndex = Array.prototype.indexOf.call(node.parentNode.childNodes, node) |
|
if (node.parentNode.childNodes[nodeIndex + 1]) { |
|
node = node.parentNode.childNodes[nodeIndex + 1] |
|
} else { |
|
const parentNodeIndex = Array.prototype.indexOf.call( |
|
node.parentNode.parentNode.childNodes, |
|
node.parentNode |
|
) |
|
const siblingNode = node.parentNode.parentNode.childNodes[parentNodeIndex + 1] |
|
if (siblingNode) node = siblingNode |
|
} |
|
if (node) range.setStart(node, 1) |
|
} else { |
|
range.setStart(node, anchorOffset) |
|
} |
|
range.collapse(true) |
|
selection.removeAllRanges() |
|
selection.addRange(range) |
|
el.focus() |
|
}, 1) |
|
}) |
|
}, |
|
render(text = '') { |
|
const vm = this |
|
const scapedText = text.replace(REGEXP_LESSTHAN, '<') |
|
vm.html = highlightHashtags(scapedText, vm.hashtagClass, vm.validHashtags) |
|
}, |
|
}, |
|
watch: { |
|
value(newValue) { |
|
this.render(newValue) |
|
}, |
|
}, |
|
mounted() { |
|
this.render(this.value) |
|
}, |
|
} |
|
</script> |