123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510 |
- <!--
- This code is a complete adaptation of the work done here
- https://github.com/SyedWasiHaider/vue-highlightable-input
- -->
- <template>
- <div class="env-input-container">
- <div
- ref="editor"
- :placeholder="placeholder"
- class="env-input"
- :class="styles"
- contenteditable="true"
- @keydown.enter.prevent="$emit('enter', $event)"
- @keyup="$emit('keyup', $event)"
- @click="$emit('click', $event)"
- @keydown="$emit('keydown', $event)"
- ></div>
- </div>
- </template>
- <script>
- import { defineComponent } from "@nuxtjs/composition-api"
- import IntervalTree from "node-interval-tree"
- import debounce from "lodash/debounce"
- import isUndefined from "lodash/isUndefined"
- import { tippy } from "vue-tippy"
- import { aggregateEnvs$ } from "~/newstore/environments"
- import { useReadonlyStream } from "~/helpers/utils/composables"
- const tagsToReplace = {
- "&": "&",
- "<": "<",
- ">": ">",
- }
- export default defineComponent({
- props: {
- value: {
- type: String,
- default: "",
- },
- placeholder: {
- type: String,
- default: "",
- },
- styles: {
- type: String,
- default: "",
- },
- },
- setup() {
- const aggregateEnvs = useReadonlyStream(aggregateEnvs$)
- return {
- aggregateEnvs,
- }
- },
- data() {
- return {
- internalValue: "",
- htmlOutput: "",
- debouncedHandler: null,
- highlight: [
- {
- text: /(<<\w+>>)/g,
- style:
- "cursor-help transition rounded px-1 focus:outline-none mx-0.5",
- },
- ],
- highlightEnabled: true,
- highlightStyle: "",
- caseSensitive: true,
- fireOn: "keydown",
- fireOnEnabled: true,
- }
- },
- watch: {
- aggregateEnvs() {
- this.processHighlights()
- },
- 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() {
- if (this.fireOnEnabled)
- this.$refs.editor.addEventListener(this.fireOn, this.handleChange)
- this.internalValue = this.value
- this.processHighlights()
- },
- methods: {
- handleChange() {
- this.debouncedHandler = debounce(function () {
- if (this.internalValue !== this.$refs.editor.textContent) {
- this.internalValue = this.$refs.editor.textContent
- this.processHighlights()
- }
- }, 5)
- this.debouncedHandler()
- },
- processHighlights() {
- if (!this.highlightEnabled) {
- this.htmlOutput = this.internalValue
- if (this.intervalTree !== this.value) {
- this.$emit("input", this.internalValue)
- this.$emit("change", this.internalValue)
- }
- return
- }
- const intervalTree = new IntervalTree()
- let highlightPositions = []
- const sortedHighlights = this.normalizedHighlights()
- if (!sortedHighlights) return
- for (let i = 0; i < sortedHighlights.length; i++) {
- const highlightObj = sortedHighlights[i]
- let indices = []
- if (highlightObj.text) {
- if (typeof highlightObj.text === "string") {
- indices = this.getIndicesOf(
- highlightObj.text,
- this.internalValue,
- isUndefined(highlightObj.caseSensitive)
- ? this.caseSensitive
- : highlightObj.caseSensitive
- )
- indices.forEach((start) => {
- const end = start + highlightObj.text.length - 1
- this.insertRange(start, end, highlightObj, intervalTree)
- })
- }
- if (
- Object.prototype.toString.call(highlightObj.text) ===
- "[object RegExp]"
- ) {
- indices = this.getRegexIndices(
- highlightObj.text,
- this.internalValue
- )
- indices.forEach((pair) => {
- this.insertRange(pair.start, pair.end, highlightObj, intervalTree)
- })
- }
- }
- if (
- highlightObj.start !== undefined &&
- highlightObj.end !== undefined &&
- highlightObj.start < highlightObj.end
- ) {
- const start = highlightObj.start
- const end = highlightObj.end - 1
- 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)
- )
- const envVar = this.internalValue
- .substring(position.start, position.end + 1)
- .slice(2, -2)
- result += `<span class="${highlightPositions[k].style} ${
- this.aggregateEnvs.find((k) => k.key === envVar)?.value === undefined
- ? "bg-red-400 text-red-50 hover:bg-red-600"
- : "bg-accentDark text-accentContrast hover:bg-accent"
- }" v-tippy data-tippy-content="${this.getEnvName(
- this.aggregateEnvs.find((k) => k.key === envVar)?.sourceEnv
- )} <kbd>${this.getEnvValue(
- this.aggregateEnvs.find((k) => k.key === envVar)?.value
- )}</kbd>">${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.$nextTick(() => {
- this.renderTippy()
- })
- if (this.internalValue !== this.value) {
- this.$emit("input", this.internalValue)
- this.$emit("change", this.internalValue)
- }
- },
- renderTippy() {
- const tippable = document.querySelectorAll("[v-tippy]")
- tippable.forEach((t) => {
- tippy(t, {
- content: t.dataset["tippy-content"],
- theme: "tooltip",
- popperOptions: {
- modifiers: {
- preventOverflow: {
- enabled: false,
- },
- hide: {
- enabled: false,
- },
- },
- },
- })
- })
- },
- insertRange(start, end, highlightObj, intervalTree) {
- 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 }]
- if (
- Object.prototype.toString.call(this.highlight) === "[object Array]" &&
- this.highlight.length > 0
- ) {
- const globalDefaultStyle =
- typeof this.highlightStyle === "string"
- ? this.highlightStyle
- : Object.keys(this.highlightStyle)
- .map((key) => key + ":" + this.highlightStyle[key])
- .join(";") + ";"
- const regExpHighlights = this.highlight.filter(
- (x) => (x === Object.prototype.toString.call(x)) === "[object RegExp]"
- )
- 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 []
- }
- regex = RegExp(regex)
- const indices = []
- 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,
- }
- } else if (document.selection) {
- 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,
- }
- }
- },
- // 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()
- range.setStart(containerEl, 0)
- range.collapse(true)
- const nodeStack = [containerEl]
- let node
- let foundStart = false
- let stop = false
- 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()
- }
- },
- getEnvName(name) {
- if (name) return name
- return "choose an environment"
- },
- getEnvValue(value) {
- if (value) return value
- return "not found"
- },
- },
- })
- </script>
- <style lang="scss" scoped>
- .env-input-container {
- @apply relative;
- @apply inline-grid;
- @apply flex-1;
- }
- [contenteditable] {
- @apply select-text;
- @apply text-secondaryDark;
- @apply font-medium;
- &:empty {
- line-height: 1.9;
- &::before {
- @apply text-secondaryDark;
- @apply opacity-25;
- @apply pointer-events-none;
- content: attr(placeholder);
- }
- }
- }
- .env-input {
- @apply flex;
- @apply items-center;
- @apply justify-items-start;
- @apply whitespace-nowrap;
- @apply overflow-x-auto;
- @apply overflow-y-hidden;
- @apply resize-none;
- @apply focus:outline-none;
- @apply transition;
- }
- .env-input::-webkit-scrollbar {
- @apply hidden;
- }
- </style>
|