Revamp URLField
This commit is contained in:
@@ -1,177 +1,411 @@
|
|||||||
|
<!--
|
||||||
|
This code is a complete adaptation of the work done here
|
||||||
|
https://github.com/SyedWasiHaider/vue-highlightable-input
|
||||||
|
-->
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="editor" contenteditable class="url-field" spellcheck="false"></div>
|
<div class="url-field-container">
|
||||||
|
<div ref="editor" class="url-field" contenteditable="true"></div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import IntervalTree from "node-interval-tree"
|
||||||
|
import debounce from "lodash/debounce"
|
||||||
|
import isUndefined from "lodash/isUndefined"
|
||||||
|
|
||||||
|
const tagsToReplace = {
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
value: { type: String, default: null },
|
value: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
cacheValue: null,
|
internalValue: "",
|
||||||
unwatchValue: null,
|
htmlOutput: "",
|
||||||
|
debouncedHandler: null,
|
||||||
|
highlight: [
|
||||||
|
{
|
||||||
|
text: /(<<\w+>>)/g,
|
||||||
|
style: "VAR",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
highlightEnabled: true,
|
||||||
|
highlightStyle: "",
|
||||||
|
caseSensitive: true,
|
||||||
|
fireOn: "keydown",
|
||||||
|
fireOnEnabled: true,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
watch: {
|
||||||
|
highlightStyle() {
|
||||||
|
this.processHighlights()
|
||||||
|
},
|
||||||
|
highlight() {
|
||||||
|
this.processHighlights()
|
||||||
|
},
|
||||||
|
value() {
|
||||||
|
if (this.internalValue !== this.value) {
|
||||||
|
this.internalValue = this.value
|
||||||
|
this.processHighlights()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
highlightEnabled() {
|
||||||
|
this.processHighlights()
|
||||||
|
},
|
||||||
|
caseSensitive() {
|
||||||
|
this.processHighlights()
|
||||||
|
},
|
||||||
|
htmlOutput() {
|
||||||
|
const selection = this.saveSelection(this.$refs.editor)
|
||||||
|
this.$refs.editor.innerHTML = this.htmlOutput
|
||||||
|
this.restoreSelection(this.$refs.editor, selection)
|
||||||
|
},
|
||||||
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.$refs.editor.addEventListener("input", this.updateEditor)
|
if (this.fireOnEnabled)
|
||||||
this.$refs.editor.textContent = this.value || ""
|
this.$refs.editor.addEventListener(this.fireOn, this.handleChange)
|
||||||
|
this.internalValue = this.value
|
||||||
this.cacheValue = this.value || ""
|
this.processHighlights()
|
||||||
|
|
||||||
this.unwatchValue = this.$watch(
|
|
||||||
() => this.value,
|
|
||||||
(newVal) => {
|
|
||||||
if (this.$refs.editor && this.cacheValue !== newVal)
|
|
||||||
this.$refs.editor.textContent = newVal || ""
|
|
||||||
this.updateEditor()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
this.updateEditor()
|
|
||||||
},
|
|
||||||
beforeDestroy() {
|
|
||||||
this.unwatchValue()
|
|
||||||
this.$refs.editor.removeEventListener("input", this.updateEditor)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
renderText(text) {
|
handleChange() {
|
||||||
const fixedText = text.replace(/(\r\n|\n|\r)/gm, "").trim()
|
this.debouncedHandler = debounce(function () {
|
||||||
const parseMap = this.parseURL(fixedText)
|
if (this.internalValue !== this.$refs.editor.textContent) {
|
||||||
|
this.internalValue = this.$refs.editor.textContent
|
||||||
const convertSpan = document.createElement("span")
|
this.processHighlights()
|
||||||
|
}
|
||||||
const output = parseMap.map(([start, end, protocol]) => {
|
}, 5)
|
||||||
convertSpan.textContent = fixedText.substring(start, end + 1)
|
this.debouncedHandler()
|
||||||
return `<span class='highlight-${protocol}'>${convertSpan.innerHTML}</span>`
|
|
||||||
})
|
|
||||||
|
|
||||||
return output.join("")
|
|
||||||
},
|
},
|
||||||
parseURL(text) {
|
processHighlights() {
|
||||||
const map = []
|
if (!this.highlightEnabled) {
|
||||||
const regex = /<<\w+>>/
|
this.htmlOutput = this.internalValue
|
||||||
|
this.$emit("input", this.internalValue)
|
||||||
let match
|
return
|
||||||
let index = 0
|
|
||||||
while ((match = text.substring(index).match(regex))) {
|
|
||||||
map.push([index, index + (match.index - 1), "TEXT"])
|
|
||||||
map.push([
|
|
||||||
index + match.index,
|
|
||||||
index + match.index + match[0].length - 1,
|
|
||||||
"VAR",
|
|
||||||
])
|
|
||||||
index += match.index + match[0].length
|
|
||||||
|
|
||||||
if (index >= text.length - 1) break
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (text.length > index && !text.substring(index).match(regex)) {
|
const intervalTree = new IntervalTree()
|
||||||
map.push([index, text.length, "TEXT"])
|
|
||||||
}
|
|
||||||
|
|
||||||
return map
|
let highlightPositions = []
|
||||||
},
|
const sortedHighlights = this.normalizedHighlights()
|
||||||
getTextSegments({ childNodes }) {
|
|
||||||
const textSegments = []
|
|
||||||
Array.from(childNodes).forEach((node) => {
|
|
||||||
switch (node.nodeType) {
|
|
||||||
case Node.TEXT_NODE:
|
|
||||||
textSegments.push({ text: node.nodeValue, node })
|
|
||||||
break
|
|
||||||
|
|
||||||
case Node.ELEMENT_NODE:
|
if (!sortedHighlights) return
|
||||||
textSegments.splice(
|
|
||||||
textSegments.length,
|
for (let i = 0; i < sortedHighlights.length; i++) {
|
||||||
0,
|
const highlightObj = sortedHighlights[i]
|
||||||
...this.getTextSegments(node)
|
let indices = []
|
||||||
|
|
||||||
|
if (highlightObj.text) {
|
||||||
|
if (typeof highlightObj.text === "string") {
|
||||||
|
indices = this.getIndicesOf(
|
||||||
|
highlightObj.text,
|
||||||
|
this.internalValue,
|
||||||
|
isUndefined(highlightObj.caseSensitive)
|
||||||
|
? this.caseSensitive
|
||||||
|
: highlightObj.caseSensitive
|
||||||
)
|
)
|
||||||
break
|
indices.forEach((start) => {
|
||||||
}
|
const end = start + highlightObj.text.length - 1
|
||||||
})
|
this.insertRange(start, end, highlightObj, intervalTree)
|
||||||
return textSegments
|
})
|
||||||
},
|
}
|
||||||
restoreSelection(absoluteAnchorIndex, absoluteFocusIndex) {
|
if (
|
||||||
const sel = window.getSelection()
|
Object.prototype.toString.call(highlightObj.text) ===
|
||||||
const textSegments = this.getTextSegments(this.$refs.editor)
|
"[object RegExp]"
|
||||||
let anchorNode = this.$refs.editor
|
) {
|
||||||
let anchorIndex = 0
|
indices = this.getRegexIndices(
|
||||||
let focusNode = this.$refs.editor
|
highlightObj.text,
|
||||||
let focusIndex = 0
|
this.internalValue
|
||||||
let currentIndex = 0
|
)
|
||||||
textSegments.forEach(({ text, node }) => {
|
indices.forEach((pair) => {
|
||||||
const startIndexOfNode = currentIndex
|
this.insertRange(pair.start, pair.end, highlightObj, intervalTree)
|
||||||
const endIndexOfNode = startIndexOfNode + text.length
|
})
|
||||||
if (
|
}
|
||||||
startIndexOfNode <= absoluteAnchorIndex &&
|
|
||||||
absoluteAnchorIndex <= endIndexOfNode
|
|
||||||
) {
|
|
||||||
anchorNode = node
|
|
||||||
anchorIndex = absoluteAnchorIndex - startIndexOfNode
|
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
startIndexOfNode <= absoluteFocusIndex &&
|
highlightObj.start !== undefined &&
|
||||||
absoluteFocusIndex <= endIndexOfNode
|
highlightObj.end !== undefined &&
|
||||||
|
highlightObj.start < highlightObj.end
|
||||||
) {
|
) {
|
||||||
focusNode = node
|
const start = highlightObj.start
|
||||||
focusIndex = absoluteFocusIndex - startIndexOfNode
|
const end = highlightObj.end - 1
|
||||||
}
|
|
||||||
currentIndex += text.length
|
|
||||||
})
|
|
||||||
|
|
||||||
sel.setBaseAndExtent(anchorNode, anchorIndex, focusNode, focusIndex)
|
this.insertRange(start, end, highlightObj, intervalTree)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
highlightPositions = intervalTree.search(0, this.internalValue.length)
|
||||||
|
highlightPositions = highlightPositions.sort((a, b) => a.start - b.start)
|
||||||
|
|
||||||
|
let result = ""
|
||||||
|
let startingPosition = 0
|
||||||
|
|
||||||
|
for (let k = 0; k < highlightPositions.length; k++) {
|
||||||
|
const position = highlightPositions[k]
|
||||||
|
result += this.safe_tags_replace(
|
||||||
|
this.internalValue.substring(startingPosition, position.start)
|
||||||
|
)
|
||||||
|
result +=
|
||||||
|
"<span class='" +
|
||||||
|
highlightPositions[k].style +
|
||||||
|
"'>" +
|
||||||
|
this.safe_tags_replace(
|
||||||
|
this.internalValue.substring(position.start, position.end + 1)
|
||||||
|
) +
|
||||||
|
"</span>"
|
||||||
|
startingPosition = position.end + 1
|
||||||
|
}
|
||||||
|
if (startingPosition < this.internalValue.length)
|
||||||
|
result += this.safe_tags_replace(
|
||||||
|
this.internalValue.substring(
|
||||||
|
startingPosition,
|
||||||
|
this.internalValue.length
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (result[result.length - 1] === " ") {
|
||||||
|
result = result.substring(0, result.length - 1)
|
||||||
|
result += " "
|
||||||
|
}
|
||||||
|
this.htmlOutput = result
|
||||||
|
this.$emit("input", this.internalValue)
|
||||||
},
|
},
|
||||||
updateEditor() {
|
insertRange(start, end, highlightObj, intervalTree) {
|
||||||
this.cacheValue = this.$refs.editor.textContent
|
const overlap = intervalTree.search(start, end)
|
||||||
|
const maxLengthOverlap = overlap.reduce((max, o) => {
|
||||||
|
return Math.max(o.end - o.start, max)
|
||||||
|
}, 0)
|
||||||
|
if (overlap.length === 0) {
|
||||||
|
intervalTree.insert(start, end, {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
style: highlightObj.style,
|
||||||
|
})
|
||||||
|
} else if (end - start > maxLengthOverlap) {
|
||||||
|
overlap.forEach((o) => {
|
||||||
|
intervalTree.remove(o.start, o.end, o)
|
||||||
|
})
|
||||||
|
intervalTree.insert(start, end, {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
style: highlightObj.style,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
normalizedHighlights() {
|
||||||
|
if (this.highlight == null) return null
|
||||||
|
if (
|
||||||
|
Object.prototype.toString.call(this.highlight) === "[object RegExp]" ||
|
||||||
|
typeof this.highlight === "string"
|
||||||
|
)
|
||||||
|
return [{ text: this.highlight }]
|
||||||
|
|
||||||
const sel = window.getSelection()
|
if (
|
||||||
const textSegments = this.getTextSegments(this.$refs.editor)
|
Object.prototype.toString.call(this.highlight) === "[object Array]" &&
|
||||||
const textContent = textSegments.map(({ text }) => text).join("")
|
this.highlight.length > 0
|
||||||
|
) {
|
||||||
|
const globalDefaultStyle =
|
||||||
|
typeof this.highlightStyle === "string"
|
||||||
|
? this.highlightStyle
|
||||||
|
: Object.keys(this.highlightStyle)
|
||||||
|
.map((key) => key + ":" + this.highlightStyle[key])
|
||||||
|
.join(";") + ";"
|
||||||
|
|
||||||
let anchorIndex = null
|
const regExpHighlights = this.highlight.filter(
|
||||||
let focusIndex = null
|
(x) => (x === Object.prototype.toString.call(x)) === "[object RegExp]"
|
||||||
let currentIndex = 0
|
)
|
||||||
|
const nonRegExpHighlights = this.highlight.filter(
|
||||||
|
(x) => (x === Object.prototype.toString.call(x)) !== "[object RegExp]"
|
||||||
|
)
|
||||||
|
return nonRegExpHighlights
|
||||||
|
.map((h) => {
|
||||||
|
if (h.text || typeof h === "string") {
|
||||||
|
return {
|
||||||
|
text: h.text || h,
|
||||||
|
style: h.style || globalDefaultStyle,
|
||||||
|
caseSensitive: h.caseSensitive,
|
||||||
|
}
|
||||||
|
} else if (h.start !== undefined && h.end !== undefined) {
|
||||||
|
return {
|
||||||
|
style: h.style || globalDefaultStyle,
|
||||||
|
start: h.start,
|
||||||
|
end: h.end,
|
||||||
|
caseSensitive: h.caseSensitive,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
"Please provide a valid highlight object or string"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) =>
|
||||||
|
a.text && b.text
|
||||||
|
? a.text > b.text
|
||||||
|
: a.start === b.start
|
||||||
|
? a.end < b.end
|
||||||
|
: a.start < b.start
|
||||||
|
)
|
||||||
|
.concat(regExpHighlights)
|
||||||
|
}
|
||||||
|
console.error("Expected a string or an array of strings")
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
safe_tags_replace(str) {
|
||||||
|
return str.replace(/[&<>]/g, this.replaceTag)
|
||||||
|
},
|
||||||
|
replaceTag(tag) {
|
||||||
|
return tagsToReplace[tag] || tag
|
||||||
|
},
|
||||||
|
getRegexIndices(regex, str) {
|
||||||
|
if (!regex.global) {
|
||||||
|
console.error("Expected " + regex + " to be global")
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
textSegments.forEach(({ text, node }) => {
|
regex = RegExp(regex)
|
||||||
if (node === sel.anchorNode) {
|
const indices = []
|
||||||
anchorIndex = currentIndex + sel.anchorOffset
|
let match = null
|
||||||
|
|
||||||
|
while ((match = regex.exec(str)) != null) {
|
||||||
|
indices.push({
|
||||||
|
start: match.index,
|
||||||
|
end: match.index + match[0].length - 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return indices
|
||||||
|
},
|
||||||
|
getIndicesOf(searchStr, str, caseSensitive) {
|
||||||
|
const searchStrLen = searchStr.length
|
||||||
|
|
||||||
|
if (searchStrLen === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
let startIndex = 0
|
||||||
|
let index
|
||||||
|
const indices = []
|
||||||
|
|
||||||
|
if (!caseSensitive) {
|
||||||
|
str = str.toLowerCase()
|
||||||
|
searchStr = searchStr.toLowerCase()
|
||||||
|
}
|
||||||
|
while ((index = str.indexOf(searchStr, startIndex)) > -1) {
|
||||||
|
indices.push(index)
|
||||||
|
startIndex = index + searchStrLen
|
||||||
|
}
|
||||||
|
return indices
|
||||||
|
},
|
||||||
|
saveSelection(containerEl) {
|
||||||
|
let start
|
||||||
|
|
||||||
|
if (window.getSelection && document.createRange) {
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection || selection.rangeCount === 0) return
|
||||||
|
const range = selection.getRangeAt(0)
|
||||||
|
const preSelectionRange = range.cloneRange()
|
||||||
|
preSelectionRange.selectNodeContents(containerEl)
|
||||||
|
preSelectionRange.setEnd(range.startContainer, range.startOffset)
|
||||||
|
start = preSelectionRange.toString().length
|
||||||
|
return {
|
||||||
|
start,
|
||||||
|
end: start + range.toString().length,
|
||||||
}
|
}
|
||||||
if (node === sel.focusNode) {
|
} else if (document.selection) {
|
||||||
focusIndex = currentIndex + sel.focusOffset
|
const selectedTextRange = document.selection.createRange()
|
||||||
|
const preSelectionTextRange = document.body.createTextRange()
|
||||||
|
preSelectionTextRange.moveToElementText(containerEl)
|
||||||
|
preSelectionTextRange.setEndPoint("EndToStart", selectedTextRange)
|
||||||
|
start = preSelectionTextRange.text.length
|
||||||
|
return {
|
||||||
|
start,
|
||||||
|
end: start + selectedTextRange.text.length,
|
||||||
}
|
}
|
||||||
currentIndex += text.length
|
}
|
||||||
})
|
},
|
||||||
|
// Copied but modifed slightly from: https://stackoverflow.com/questions/14636218/jquery-convert-text-url-to-link-as-typing/14637351#14637351
|
||||||
|
restoreSelection(containerEl, savedSel) {
|
||||||
|
if (!savedSel) return
|
||||||
|
if (window.getSelection && document.createRange) {
|
||||||
|
let charIndex = 0
|
||||||
|
const range = document.createRange()
|
||||||
|
|
||||||
this.$refs.editor.innerHTML = this.renderText(textContent)
|
range.setStart(containerEl, 0)
|
||||||
|
range.collapse(true)
|
||||||
|
|
||||||
this.restoreSelection(anchorIndex, focusIndex)
|
const nodeStack = [containerEl]
|
||||||
|
let node
|
||||||
|
let foundStart = false
|
||||||
|
let stop = false
|
||||||
|
|
||||||
this.$emit("input", this.$refs.editor.textContent)
|
while (!stop && (node = nodeStack.pop())) {
|
||||||
|
if (node.nodeType === 3) {
|
||||||
|
const nextCharIndex = charIndex + node.length
|
||||||
|
if (
|
||||||
|
!foundStart &&
|
||||||
|
savedSel.start >= charIndex &&
|
||||||
|
savedSel.start <= nextCharIndex
|
||||||
|
) {
|
||||||
|
range.setStart(node, savedSel.start - charIndex)
|
||||||
|
foundStart = true
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
foundStart &&
|
||||||
|
savedSel.end >= charIndex &&
|
||||||
|
savedSel.end <= nextCharIndex
|
||||||
|
) {
|
||||||
|
range.setEnd(node, savedSel.end - charIndex)
|
||||||
|
stop = true
|
||||||
|
}
|
||||||
|
charIndex = nextCharIndex
|
||||||
|
} else {
|
||||||
|
let i = node.childNodes.length
|
||||||
|
while (i--) {
|
||||||
|
nodeStack.push(node.childNodes[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sel = window.getSelection()
|
||||||
|
sel.removeAllRanges()
|
||||||
|
sel.addRange(range)
|
||||||
|
} else if (document.selection) {
|
||||||
|
const textRange = document.body.createTextRange()
|
||||||
|
textRange.moveToElementText(containerEl)
|
||||||
|
textRange.collapse(true)
|
||||||
|
textRange.moveEnd("character", savedSel.end)
|
||||||
|
textRange.moveStart("character", savedSel.start)
|
||||||
|
textRange.select()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.url-field {
|
.VAR {
|
||||||
@apply border-dashed;
|
|
||||||
@apply md:border-l;
|
|
||||||
@apply border-brdColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
.highlight-VAR {
|
|
||||||
@apply font-bold;
|
@apply font-bold;
|
||||||
@apply text-acColor;
|
@apply text-acColor;
|
||||||
}
|
}
|
||||||
|
.url-field-container {
|
||||||
.highlight-TEXT {
|
@apply inline-grid;
|
||||||
@apply overflow-auto;
|
|
||||||
@apply break-all;
|
|
||||||
|
|
||||||
height: 22px;
|
|
||||||
}
|
}
|
||||||
|
.url-field {
|
||||||
.highlight-TEXT::-webkit-scrollbar {
|
@apply border-dashed;
|
||||||
|
@apply border-brdColor;
|
||||||
|
@apply whitespace-nowrap;
|
||||||
|
@apply overflow-x-auto;
|
||||||
|
@apply resize-none;
|
||||||
|
@apply md:border-l;
|
||||||
|
}
|
||||||
|
.url-field::-webkit-scrollbar {
|
||||||
@apply hidden;
|
@apply hidden;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user