EnvInput.vue 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. <template>
  2. <div
  3. class="flex items-center flex-1 flex-shrink-0 overflow-auto whitespace-nowrap hide-scrollbar"
  4. >
  5. <div
  6. ref="editor"
  7. :placeholder="placeholder"
  8. class="flex flex-1"
  9. :class="styles"
  10. @keydown.enter.prevent="emit('enter', $event)"
  11. @keyup="emit('keyup', $event)"
  12. @click="emit('click', $event)"
  13. @keydown="emit('keydown', $event)"
  14. ></div>
  15. </div>
  16. </template>
  17. <script setup lang="ts">
  18. import {
  19. ref,
  20. onMounted,
  21. watch,
  22. nextTick,
  23. computed,
  24. Ref,
  25. } from "@nuxtjs/composition-api"
  26. import {
  27. EditorView,
  28. placeholder as placeholderExt,
  29. ViewPlugin,
  30. ViewUpdate,
  31. keymap,
  32. } from "@codemirror/view"
  33. import { EditorState, Extension } from "@codemirror/state"
  34. import clone from "lodash/clone"
  35. import { tooltips } from "@codemirror/tooltip"
  36. import { history, historyKeymap } from "@codemirror/history"
  37. import { HoppRESTVar } from "@hoppscotch/data"
  38. import { inputTheme } from "~/helpers/editor/themes/baseTheme"
  39. import { HoppReactiveEnvPlugin } from "~/helpers/editor/extensions/HoppEnvironment"
  40. import { useReadonlyStream } from "~/helpers/utils/composables"
  41. import { AggregateEnvironment, aggregateEnvs$ } from "~/newstore/environments"
  42. import { HoppReactiveVarPlugin } from "~/helpers/editor/extensions/HoppVariable"
  43. import { restVars$ } from "~/newstore/RESTSession"
  44. const props = withDefaults(
  45. defineProps<{
  46. value: string
  47. placeholder: string
  48. styles: string
  49. envs: { key: string; value: string; source: string }[] | null
  50. vars: { key: string; value: string }[] | null
  51. focus: boolean
  52. readonly: boolean
  53. }>(),
  54. {
  55. value: "",
  56. placeholder: "",
  57. styles: "",
  58. envs: null,
  59. vars: null,
  60. focus: false,
  61. readonly: false,
  62. }
  63. )
  64. const emit = defineEmits<{
  65. (e: "input", data: string): void
  66. (e: "change", data: string): void
  67. (e: "paste", data: { prevValue: string; pastedValue: string }): void
  68. (e: "enter", ev: any): void
  69. (e: "keyup", ev: any): void
  70. (e: "keydown", ev: any): void
  71. (e: "click", ev: any): void
  72. }>()
  73. const cachedValue = ref(props.value)
  74. const view = ref<EditorView>()
  75. const editor = ref<any | null>(null)
  76. watch(
  77. () => props.value,
  78. (newVal) => {
  79. const singleLinedText = newVal.replaceAll("\n", "")
  80. const currDoc = view.value?.state.doc
  81. .toJSON()
  82. .join(view.value.state.lineBreak)
  83. if (cachedValue.value !== singleLinedText || newVal !== currDoc) {
  84. cachedValue.value = singleLinedText
  85. view.value?.dispatch({
  86. filter: false,
  87. changes: {
  88. from: 0,
  89. to: view.value.state.doc.length,
  90. insert: singleLinedText,
  91. },
  92. })
  93. }
  94. },
  95. {
  96. immediate: true,
  97. flush: "sync",
  98. }
  99. )
  100. let clipboardEv: ClipboardEvent | null = null
  101. let pastedValue: string | null = null
  102. const aggregateEnvs = useReadonlyStream(aggregateEnvs$, []) as Ref<
  103. AggregateEnvironment[]
  104. >
  105. const aggregateVars = useReadonlyStream(restVars$, []) as Ref<HoppRESTVar[]>
  106. const envVars = computed(() =>
  107. props.envs
  108. ? props.envs.map((x) => ({
  109. key: x.key,
  110. value: x.value,
  111. sourceEnv: x.source,
  112. }))
  113. : aggregateEnvs.value
  114. )
  115. const varVars = computed(() =>
  116. props.vars
  117. ? props.vars.map((x) => ({
  118. key: x.key,
  119. value: x.value,
  120. }))
  121. : aggregateVars.value
  122. )
  123. const envTooltipPlugin = new HoppReactiveEnvPlugin(envVars, view)
  124. const varTooltipPlugin = new HoppReactiveVarPlugin(varVars, view)
  125. const initView = (el: any) => {
  126. const extensions: Extension = [
  127. EditorView.contentAttributes.of({ "aria-label": props.placeholder }),
  128. EditorView.updateListener.of((update) => {
  129. if (props.readonly) {
  130. update.view.contentDOM.inputMode = "none"
  131. }
  132. }),
  133. EditorState.changeFilter.of(() => !props.readonly),
  134. inputTheme,
  135. props.readonly
  136. ? EditorView.theme({
  137. ".cm-content": {
  138. caretColor: "var(--secondary-dark-color) !important",
  139. color: "var(--secondary-dark-color) !important",
  140. backgroundColor: "var(--divider-color) !important",
  141. opacity: 0.25,
  142. },
  143. })
  144. : EditorView.theme({}),
  145. tooltips({
  146. position: "absolute",
  147. }),
  148. envTooltipPlugin,
  149. varTooltipPlugin,
  150. placeholderExt(props.placeholder),
  151. EditorView.domEventHandlers({
  152. paste(ev) {
  153. clipboardEv = ev
  154. pastedValue = ev.clipboardData?.getData("text") ?? ""
  155. },
  156. drop(ev) {
  157. ev.preventDefault()
  158. },
  159. }),
  160. ViewPlugin.fromClass(
  161. class {
  162. update(update: ViewUpdate) {
  163. if (props.readonly) return
  164. if (update.docChanged) {
  165. const prevValue = clone(cachedValue.value)
  166. cachedValue.value = update.state.doc
  167. .toJSON()
  168. .join(update.state.lineBreak)
  169. // We do not update the cache directly in this case (to trigger value watcher to dispatch)
  170. // So, we desync cachedValue a bit so we can trigger updates
  171. const value = clone(cachedValue.value).replaceAll("\n", "")
  172. emit("input", value)
  173. emit("change", value)
  174. const pasted = !!update.transactions.find((txn) =>
  175. txn.isUserEvent("input.paste")
  176. )
  177. if (pasted && clipboardEv) {
  178. const pastedVal = pastedValue
  179. nextTick(() => {
  180. emit("paste", {
  181. pastedValue: pastedVal!,
  182. prevValue,
  183. })
  184. })
  185. } else {
  186. clipboardEv = null
  187. pastedValue = null
  188. }
  189. }
  190. }
  191. }
  192. ),
  193. history(),
  194. keymap.of([...historyKeymap]),
  195. ]
  196. view.value = new EditorView({
  197. parent: el,
  198. state: EditorState.create({
  199. doc: props.value,
  200. extensions,
  201. }),
  202. })
  203. }
  204. onMounted(() => {
  205. if (editor.value) {
  206. if (!view.value) initView(editor.value)
  207. }
  208. })
  209. watch(editor, () => {
  210. if (editor.value) {
  211. if (!view.value) initView(editor.value)
  212. } else {
  213. view.value?.destroy()
  214. view.value = undefined
  215. }
  216. })
  217. </script>