EnvInput.vue 14 KB

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