EnvInput.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  1. <!--
  2. This code is a complete adaptation of the work done here
  3. https://github.com/SyedWasiHaider/vue-highlightable-input
  4. -->
  5. <template>
  6. <div class="env-input-container">
  7. <div
  8. ref="editor"
  9. :placeholder="placeholder"
  10. class="env-input"
  11. :class="styles"
  12. contenteditable="true"
  13. spellcheck="false"
  14. @keydown.enter.prevent="$emit('enter', $event)"
  15. @keyup="$emit('keyup', $event)"
  16. @click="$emit('click', $event)"
  17. @keydown="$emit('keydown', $event)"
  18. ></div>
  19. </div>
  20. </template>
  21. <script>
  22. import { defineComponent } from "@nuxtjs/composition-api"
  23. import IntervalTree from "node-interval-tree"
  24. import debounce from "lodash/debounce"
  25. import isUndefined from "lodash/isUndefined"
  26. import { tippy } from "vue-tippy"
  27. import { aggregateEnvs$ } from "~/newstore/environments"
  28. import { useReadonlyStream } from "~/helpers/utils/composables"
  29. const tagsToReplace = {
  30. "&": "&amp;",
  31. "<": "&lt;",
  32. ">": "&gt;",
  33. }
  34. export default defineComponent({
  35. props: {
  36. value: {
  37. type: String,
  38. default: "",
  39. },
  40. placeholder: {
  41. type: String,
  42. default: "",
  43. },
  44. styles: {
  45. type: String,
  46. default: "",
  47. },
  48. },
  49. setup() {
  50. const aggregateEnvs = useReadonlyStream(aggregateEnvs$)
  51. return {
  52. aggregateEnvs,
  53. }
  54. },
  55. data() {
  56. return {
  57. internalValue: "",
  58. htmlOutput: "",
  59. debouncedHandler: null,
  60. highlight: [
  61. {
  62. text: /(<<\w+>>)/g,
  63. style:
  64. "cursor-help transition rounded px-1 focus:outline-none mx-0.5",
  65. },
  66. ],
  67. highlightEnabled: true,
  68. highlightStyle: "",
  69. caseSensitive: true,
  70. fireOn: "keydown",
  71. fireOnEnabled: true,
  72. }
  73. },
  74. watch: {
  75. aggregateEnvs() {
  76. this.processHighlights()
  77. },
  78. highlightStyle() {
  79. this.processHighlights()
  80. },
  81. highlight() {
  82. this.processHighlights()
  83. },
  84. value() {
  85. if (this.internalValue !== this.value) {
  86. this.internalValue = this.value
  87. this.processHighlights()
  88. }
  89. },
  90. highlightEnabled() {
  91. this.processHighlights()
  92. },
  93. caseSensitive() {
  94. this.processHighlights()
  95. },
  96. htmlOutput() {
  97. const selection = this.saveSelection(this.$refs.editor)
  98. this.$refs.editor.innerHTML = this.htmlOutput
  99. this.restoreSelection(this.$refs.editor, selection)
  100. },
  101. },
  102. mounted() {
  103. if (this.fireOnEnabled)
  104. this.$refs.editor.addEventListener(this.fireOn, this.handleChange)
  105. this.internalValue = this.value
  106. this.processHighlights()
  107. },
  108. methods: {
  109. handleChange() {
  110. this.debouncedHandler = debounce(function () {
  111. if (this.internalValue !== this.$refs.editor.textContent) {
  112. this.internalValue = this.$refs.editor.textContent
  113. this.processHighlights()
  114. }
  115. }, 5)
  116. this.debouncedHandler()
  117. },
  118. processHighlights() {
  119. if (!this.highlightEnabled) {
  120. this.htmlOutput = this.internalValue
  121. if (this.intervalTree !== this.value) {
  122. this.$emit("input", this.internalValue)
  123. this.$emit("change", this.internalValue)
  124. }
  125. return
  126. }
  127. const intervalTree = new IntervalTree()
  128. let highlightPositions = []
  129. const sortedHighlights = this.normalizedHighlights()
  130. if (!sortedHighlights) return
  131. for (let i = 0; i < sortedHighlights.length; i++) {
  132. const highlightObj = sortedHighlights[i]
  133. let indices = []
  134. if (highlightObj.text) {
  135. if (typeof highlightObj.text === "string") {
  136. indices = this.getIndicesOf(
  137. highlightObj.text,
  138. this.internalValue,
  139. isUndefined(highlightObj.caseSensitive)
  140. ? this.caseSensitive
  141. : highlightObj.caseSensitive
  142. )
  143. indices.forEach((start) => {
  144. const end = start + highlightObj.text.length - 1
  145. this.insertRange(start, end, highlightObj, intervalTree)
  146. })
  147. }
  148. if (
  149. Object.prototype.toString.call(highlightObj.text) ===
  150. "[object RegExp]"
  151. ) {
  152. indices = this.getRegexIndices(
  153. highlightObj.text,
  154. this.internalValue
  155. )
  156. indices.forEach((pair) => {
  157. this.insertRange(pair.start, pair.end, highlightObj, intervalTree)
  158. })
  159. }
  160. }
  161. if (
  162. highlightObj.start !== undefined &&
  163. highlightObj.end !== undefined &&
  164. highlightObj.start < highlightObj.end
  165. ) {
  166. const start = highlightObj.start
  167. const end = highlightObj.end - 1
  168. this.insertRange(start, end, highlightObj, intervalTree)
  169. }
  170. }
  171. highlightPositions = intervalTree.search(0, this.internalValue.length)
  172. highlightPositions = highlightPositions.sort((a, b) => a.start - b.start)
  173. let result = ""
  174. let startingPosition = 0
  175. for (let k = 0; k < highlightPositions.length; k++) {
  176. const position = highlightPositions[k]
  177. result += this.safe_tags_replace(
  178. this.internalValue.substring(startingPosition, position.start)
  179. )
  180. const envVar = this.internalValue
  181. .substring(position.start, position.end + 1)
  182. .slice(2, -2)
  183. result += `<span class="${highlightPositions[k].style} ${
  184. this.aggregateEnvs.find((k) => k.key === envVar)?.value === undefined
  185. ? "bg-red-400 text-red-50 hover:bg-red-600"
  186. : "bg-accentDark text-accentContrast hover:bg-accent"
  187. }" v-tippy data-tippy-content="${this.getEnvName(
  188. this.aggregateEnvs.find((k) => k.key === envVar)?.sourceEnv
  189. )} <kbd>${this.getEnvValue(
  190. this.aggregateEnvs.find((k) => k.key === envVar)?.value
  191. )}</kbd>">${this.safe_tags_replace(
  192. this.internalValue.substring(position.start, position.end + 1)
  193. )}</span>`
  194. startingPosition = position.end + 1
  195. }
  196. if (startingPosition < this.internalValue.length)
  197. result += this.safe_tags_replace(
  198. this.internalValue.substring(
  199. startingPosition,
  200. this.internalValue.length
  201. )
  202. )
  203. if (result[result.length - 1] === " ") {
  204. result = result.substring(0, result.length - 1)
  205. result += "&nbsp;"
  206. }
  207. this.htmlOutput = result
  208. this.$nextTick(() => {
  209. this.renderTippy()
  210. })
  211. if (this.internalValue !== this.value) {
  212. this.$emit("input", this.internalValue)
  213. this.$emit("change", this.internalValue)
  214. }
  215. },
  216. renderTippy() {
  217. const tippable = document.querySelectorAll("[v-tippy]")
  218. tippable.forEach((t) => {
  219. tippy(t, {
  220. content: t.dataset["tippy-content"],
  221. theme: "tooltip",
  222. popperOptions: {
  223. modifiers: {
  224. preventOverflow: {
  225. enabled: false,
  226. },
  227. hide: {
  228. enabled: false,
  229. },
  230. },
  231. },
  232. })
  233. })
  234. },
  235. insertRange(start, end, highlightObj, intervalTree) {
  236. const overlap = intervalTree.search(start, end)
  237. const maxLengthOverlap = overlap.reduce((max, o) => {
  238. return Math.max(o.end - o.start, max)
  239. }, 0)
  240. if (overlap.length === 0) {
  241. intervalTree.insert(start, end, {
  242. start,
  243. end,
  244. style: highlightObj.style,
  245. })
  246. } else if (end - start > maxLengthOverlap) {
  247. overlap.forEach((o) => {
  248. intervalTree.remove(o.start, o.end, o)
  249. })
  250. intervalTree.insert(start, end, {
  251. start,
  252. end,
  253. style: highlightObj.style,
  254. })
  255. }
  256. },
  257. normalizedHighlights() {
  258. if (this.highlight == null) return null
  259. if (
  260. Object.prototype.toString.call(this.highlight) === "[object RegExp]" ||
  261. typeof this.highlight === "string"
  262. )
  263. return [{ text: this.highlight }]
  264. if (
  265. Object.prototype.toString.call(this.highlight) === "[object Array]" &&
  266. this.highlight.length > 0
  267. ) {
  268. const globalDefaultStyle =
  269. typeof this.highlightStyle === "string"
  270. ? this.highlightStyle
  271. : Object.keys(this.highlightStyle)
  272. .map((key) => key + ":" + this.highlightStyle[key])
  273. .join(";") + ";"
  274. const regExpHighlights = this.highlight.filter(
  275. (x) => (x === Object.prototype.toString.call(x)) === "[object RegExp]"
  276. )
  277. const nonRegExpHighlights = this.highlight.filter(
  278. (x) => (x === Object.prototype.toString.call(x)) !== "[object RegExp]"
  279. )
  280. return nonRegExpHighlights
  281. .map((h) => {
  282. if (h.text || typeof h === "string") {
  283. return {
  284. text: h.text || h,
  285. style: h.style || globalDefaultStyle,
  286. caseSensitive: h.caseSensitive,
  287. }
  288. } else if (h.start !== undefined && h.end !== undefined) {
  289. return {
  290. style: h.style || globalDefaultStyle,
  291. start: h.start,
  292. end: h.end,
  293. caseSensitive: h.caseSensitive,
  294. }
  295. } else {
  296. throw new Error(
  297. "Please provide a valid highlight object or string"
  298. )
  299. }
  300. })
  301. .sort((a, b) =>
  302. a.text && b.text
  303. ? a.text > b.text
  304. : a.start === b.start
  305. ? a.end < b.end
  306. : a.start < b.start
  307. )
  308. .concat(regExpHighlights)
  309. }
  310. console.error("Expected a string or an array of strings")
  311. return null
  312. },
  313. safe_tags_replace(str) {
  314. return str.replace(/[&<>]/g, this.replaceTag)
  315. },
  316. replaceTag(tag) {
  317. return tagsToReplace[tag] || tag
  318. },
  319. getRegexIndices(regex, str) {
  320. if (!regex.global) {
  321. console.error("Expected " + regex + " to be global")
  322. return []
  323. }
  324. regex = RegExp(regex)
  325. const indices = []
  326. let match = null
  327. while ((match = regex.exec(str)) != null) {
  328. indices.push({
  329. start: match.index,
  330. end: match.index + match[0].length - 1,
  331. })
  332. }
  333. return indices
  334. },
  335. getIndicesOf(searchStr, str, caseSensitive) {
  336. const searchStrLen = searchStr.length
  337. if (searchStrLen === 0) {
  338. return []
  339. }
  340. let startIndex = 0
  341. let index
  342. const indices = []
  343. if (!caseSensitive) {
  344. str = str.toLowerCase()
  345. searchStr = searchStr.toLowerCase()
  346. }
  347. while ((index = str.indexOf(searchStr, startIndex)) > -1) {
  348. indices.push(index)
  349. startIndex = index + searchStrLen
  350. }
  351. return indices
  352. },
  353. saveSelection(containerEl) {
  354. let start
  355. if (window.getSelection && document.createRange) {
  356. const selection = window.getSelection()
  357. if (!selection || selection.rangeCount === 0) return
  358. const range = selection.getRangeAt(0)
  359. const preSelectionRange = range.cloneRange()
  360. preSelectionRange.selectNodeContents(containerEl)
  361. preSelectionRange.setEnd(range.startContainer, range.startOffset)
  362. start = `${preSelectionRange}`.length
  363. return {
  364. start,
  365. end: start + `${range}`.length,
  366. }
  367. } else if (document.selection) {
  368. const selectedTextRange = document.selection.createRange()
  369. const preSelectionTextRange = document.body.createTextRange()
  370. preSelectionTextRange.moveToElementText(containerEl)
  371. preSelectionTextRange.setEndPoint("EndToStart", selectedTextRange)
  372. start = preSelectionTextRange.text.length
  373. return {
  374. start,
  375. end: start + selectedTextRange.text.length,
  376. }
  377. }
  378. },
  379. // Copied but modifed slightly from: https://stackoverflow.com/questions/14636218/jquery-convert-text-url-to-link-as-typing/14637351#14637351
  380. restoreSelection(containerEl, savedSel) {
  381. if (!savedSel) return
  382. if (window.getSelection && document.createRange) {
  383. let charIndex = 0
  384. const range = document.createRange()
  385. range.setStart(containerEl, 0)
  386. range.collapse(true)
  387. const nodeStack = [containerEl]
  388. let node
  389. let foundStart = false
  390. let stop = false
  391. while (!stop && (node = nodeStack.pop())) {
  392. if (node.nodeType === 3) {
  393. const nextCharIndex = charIndex + node.length
  394. if (
  395. !foundStart &&
  396. savedSel.start >= charIndex &&
  397. savedSel.start <= nextCharIndex
  398. ) {
  399. range.setStart(node, savedSel.start - charIndex)
  400. foundStart = true
  401. }
  402. if (
  403. foundStart &&
  404. savedSel.end >= charIndex &&
  405. savedSel.end <= nextCharIndex
  406. ) {
  407. range.setEnd(node, savedSel.end - charIndex)
  408. stop = true
  409. }
  410. charIndex = nextCharIndex
  411. } else {
  412. let i = node.childNodes.length
  413. while (i--) {
  414. nodeStack.push(node.childNodes[i])
  415. }
  416. }
  417. }
  418. const sel = window.getSelection()
  419. sel.removeAllRanges()
  420. sel.addRange(range)
  421. } else if (document.selection) {
  422. const textRange = document.body.createTextRange()
  423. textRange.moveToElementText(containerEl)
  424. textRange.collapse(true)
  425. textRange.moveEnd("character", savedSel.end)
  426. textRange.moveStart("character", savedSel.start)
  427. textRange.select()
  428. }
  429. },
  430. getEnvName(name) {
  431. if (name) return name
  432. return "choose an environment"
  433. },
  434. getEnvValue(value) {
  435. if (value) return value.replace(/"/g, "&quot;")
  436. // it does not filter special characters before adding them to HTML.
  437. return "not found"
  438. },
  439. },
  440. })
  441. </script>
  442. <style lang="scss" scoped>
  443. .env-input-container {
  444. @apply relative;
  445. @apply inline-grid;
  446. @apply flex-1;
  447. }
  448. [contenteditable] {
  449. @apply select-text;
  450. @apply text-secondaryDark;
  451. @apply font-medium;
  452. &:empty {
  453. @apply leading-loose;
  454. &::before {
  455. @apply text-secondary;
  456. @apply opacity-35;
  457. @apply pointer-events-none;
  458. content: attr(placeholder);
  459. }
  460. }
  461. }
  462. .env-input {
  463. @apply flex;
  464. @apply items-center;
  465. @apply justify-items-start;
  466. @apply whitespace-nowrap;
  467. @apply overflow-x-auto;
  468. @apply overflow-y-hidden;
  469. @apply resize-none;
  470. @apply focus:outline-none;
  471. }
  472. .env-input::-webkit-scrollbar {
  473. @apply hidden;
  474. }
  475. </style>