codemirror.ts 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. import {
  2. keymap,
  3. EditorView,
  4. ViewPlugin,
  5. ViewUpdate,
  6. placeholder,
  7. } from "@codemirror/view"
  8. import {
  9. Extension,
  10. EditorState,
  11. Compartment,
  12. EditorSelection,
  13. } from "@codemirror/state"
  14. import { Language, LanguageSupport } from "@codemirror/language"
  15. import { defaultKeymap, indentLess, insertTab } from "@codemirror/commands"
  16. import { Completion, autocompletion } from "@codemirror/autocomplete"
  17. import { linter } from "@codemirror/lint"
  18. import {
  19. watch,
  20. ref,
  21. Ref,
  22. onMounted,
  23. onBeforeUnmount,
  24. } from "@nuxtjs/composition-api"
  25. import { javascriptLanguage } from "@codemirror/lang-javascript"
  26. import { xmlLanguage } from "@codemirror/lang-xml"
  27. import { jsonLanguage } from "@codemirror/lang-json"
  28. import { GQLLanguage } from "@hoppscotch/codemirror-lang-graphql"
  29. import { StreamLanguage } from "@codemirror/stream-parser"
  30. import { html } from "@codemirror/legacy-modes/mode/xml"
  31. import { shell } from "@codemirror/legacy-modes/mode/shell"
  32. import { yaml } from "@codemirror/legacy-modes/mode/yaml"
  33. import { isJSONContentType } from "../utils/contenttypes"
  34. import { useStreamSubscriber } from "../utils/composables"
  35. import { Completer } from "./completion"
  36. import { LinterDefinition } from "./linting/linter"
  37. import { basicSetup, baseTheme, baseHighlightStyle } from "./themes/baseTheme"
  38. import { HoppEnvironmentPlugin } from "./extensions/HoppEnvironment"
  39. // TODO: Migrate from legacy mode
  40. type ExtendedEditorConfig = {
  41. mode: string
  42. placeholder: string
  43. readOnly: boolean
  44. lineWrapping: boolean
  45. }
  46. type CodeMirrorOptions = {
  47. extendedEditorConfig: Partial<ExtendedEditorConfig>
  48. linter: LinterDefinition | null
  49. completer: Completer | null
  50. // NOTE: This property is not reactive
  51. environmentHighlights: boolean
  52. }
  53. const hoppCompleterExt = (completer: Completer): Extension => {
  54. return autocompletion({
  55. override: [
  56. async (context) => {
  57. // Expensive operation! Disable on bigger files ?
  58. const text = context.state.doc.toJSON().join(context.state.lineBreak)
  59. const line = context.state.doc.lineAt(context.pos)
  60. const lineStart = line.from
  61. const lineNo = line.number - 1
  62. const ch = context.pos - lineStart
  63. // Only do trigger on type when typing a word token, else stop (unless explicit)
  64. if (!context.matchBefore(/\w+/) && !context.explicit)
  65. return {
  66. from: context.pos,
  67. options: [],
  68. }
  69. const result = await completer(text, { line: lineNo, ch })
  70. // Use more completion features ?
  71. const completions =
  72. result?.completions.map<Completion>((comp) => ({
  73. label: comp.text,
  74. detail: comp.meta,
  75. })) ?? []
  76. return {
  77. from: context.state.wordAt(context.pos)?.from ?? context.pos,
  78. options: completions,
  79. }
  80. },
  81. ],
  82. })
  83. }
  84. const hoppLinterExt = (hoppLinter: LinterDefinition | undefined): Extension => {
  85. return linter(async (view) => {
  86. if (!hoppLinter) return []
  87. // Requires full document scan, hence expensive on big files, force disable on big files ?
  88. const linterResult = await hoppLinter(
  89. view.state.doc.toJSON().join(view.state.lineBreak)
  90. )
  91. return linterResult.map((result) => {
  92. const startPos =
  93. view.state.doc.line(result.from.line).from + result.from.ch - 1
  94. const endPos = view.state.doc.line(result.to.line).from + result.to.ch - 1
  95. return {
  96. from: startPos < 0 ? 0 : startPos,
  97. to: endPos > view.state.doc.length ? view.state.doc.length : endPos,
  98. message: result.message,
  99. severity: result.severity,
  100. }
  101. })
  102. })
  103. }
  104. const hoppLang = (
  105. language: Language | undefined,
  106. linter?: LinterDefinition | undefined,
  107. completer?: Completer | undefined
  108. ): Extension | LanguageSupport => {
  109. const exts: Extension[] = []
  110. exts.push(hoppLinterExt(linter))
  111. if (completer) exts.push(hoppCompleterExt(completer))
  112. return language ? new LanguageSupport(language, exts) : exts
  113. }
  114. const getLanguage = (langMime: string): Language | null => {
  115. if (isJSONContentType(langMime)) {
  116. return jsonLanguage
  117. } else if (langMime === "application/javascript") {
  118. return javascriptLanguage
  119. } else if (langMime === "graphql") {
  120. return GQLLanguage
  121. } else if (langMime === "application/xml") {
  122. return xmlLanguage
  123. } else if (langMime === "htmlmixed") {
  124. return StreamLanguage.define(html)
  125. } else if (langMime === "application/x-sh") {
  126. return StreamLanguage.define(shell)
  127. } else if (langMime === "text/x-yaml") {
  128. return StreamLanguage.define(yaml)
  129. }
  130. // None matched, so return null
  131. return null
  132. }
  133. const getEditorLanguage = (
  134. langMime: string,
  135. linter: LinterDefinition | undefined,
  136. completer: Completer | undefined
  137. ): Extension => hoppLang(getLanguage(langMime) ?? undefined, linter, completer)
  138. export function useCodemirror(
  139. el: Ref<any | null>,
  140. value: Ref<string>,
  141. options: CodeMirrorOptions
  142. ): { cursor: Ref<{ line: number; ch: number }> } {
  143. const { subscribeToStream } = useStreamSubscriber()
  144. const language = new Compartment()
  145. const lineWrapping = new Compartment()
  146. const placeholderConfig = new Compartment()
  147. const cachedCursor = ref({
  148. line: 0,
  149. ch: 0,
  150. })
  151. const cursor = ref({
  152. line: 0,
  153. ch: 0,
  154. })
  155. const cachedValue = ref(value.value)
  156. const view = ref<EditorView>()
  157. const environmentTooltip = options.environmentHighlights
  158. ? new HoppEnvironmentPlugin(subscribeToStream, view)
  159. : null
  160. const initView = (el: any) => {
  161. const extensions = [
  162. basicSetup,
  163. baseTheme,
  164. baseHighlightStyle,
  165. ViewPlugin.fromClass(
  166. class {
  167. update(update: ViewUpdate) {
  168. if (update.selectionSet) {
  169. const cursorPos = update.state.selection.main.head
  170. const line = update.state.doc.lineAt(cursorPos)
  171. cachedCursor.value = {
  172. line: line.number - 1,
  173. ch: cursorPos - line.from,
  174. }
  175. cursor.value = {
  176. line: cachedCursor.value.line,
  177. ch: cachedCursor.value.ch,
  178. }
  179. }
  180. if (update.docChanged) {
  181. // Expensive on big files ?
  182. cachedValue.value = update.state.doc
  183. .toJSON()
  184. .join(update.state.lineBreak)
  185. if (!options.extendedEditorConfig.readOnly)
  186. value.value = cachedValue.value
  187. }
  188. }
  189. }
  190. ),
  191. EditorView.updateListener.of((update) => {
  192. if (options.extendedEditorConfig.readOnly) {
  193. update.view.contentDOM.inputMode = "none"
  194. }
  195. }),
  196. EditorState.changeFilter.of(() => !options.extendedEditorConfig.readOnly),
  197. placeholderConfig.of(
  198. placeholder(options.extendedEditorConfig.placeholder ?? "")
  199. ),
  200. language.of(
  201. getEditorLanguage(
  202. options.extendedEditorConfig.mode ?? "",
  203. options.linter ?? undefined,
  204. options.completer ?? undefined
  205. )
  206. ),
  207. lineWrapping.of(
  208. options.extendedEditorConfig.lineWrapping
  209. ? [EditorView.lineWrapping]
  210. : []
  211. ),
  212. keymap.of([
  213. ...defaultKeymap,
  214. {
  215. key: "Tab",
  216. preventDefault: true,
  217. run: insertTab,
  218. },
  219. {
  220. key: "Shift-Tab",
  221. preventDefault: true,
  222. run: indentLess,
  223. },
  224. ]),
  225. ]
  226. if (environmentTooltip) extensions.push(environmentTooltip.extension)
  227. view.value = new EditorView({
  228. parent: el,
  229. state: EditorState.create({
  230. doc: value.value,
  231. extensions,
  232. }),
  233. })
  234. }
  235. onMounted(() => {
  236. if (el.value) {
  237. if (!view.value) initView(el.value)
  238. }
  239. })
  240. watch(el, () => {
  241. if (el.value) {
  242. if (view.value) view.value.destroy()
  243. initView(el.value)
  244. } else {
  245. view.value?.destroy()
  246. view.value = undefined
  247. }
  248. })
  249. onBeforeUnmount(() => {
  250. view.value?.destroy()
  251. })
  252. watch(value, (newVal) => {
  253. if (cachedValue.value !== newVal) {
  254. view.value?.dispatch({
  255. filter: false,
  256. changes: {
  257. from: 0,
  258. to: view.value.state.doc.length,
  259. insert: newVal,
  260. },
  261. })
  262. }
  263. cachedValue.value = newVal
  264. })
  265. watch(
  266. () => [
  267. options.extendedEditorConfig.mode,
  268. options.linter,
  269. options.completer,
  270. ],
  271. () => {
  272. view.value?.dispatch({
  273. effects: language.reconfigure(
  274. getEditorLanguage(
  275. (options.extendedEditorConfig.mode as any) ?? "",
  276. options.linter ?? undefined,
  277. options.completer ?? undefined
  278. )
  279. ),
  280. })
  281. }
  282. )
  283. watch(
  284. () => options.extendedEditorConfig.lineWrapping,
  285. (newMode) => {
  286. view.value?.dispatch({
  287. effects: lineWrapping.reconfigure(
  288. newMode ? [EditorView.lineWrapping] : []
  289. ),
  290. })
  291. }
  292. )
  293. watch(
  294. () => options.extendedEditorConfig.placeholder,
  295. (newValue) => {
  296. view.value?.dispatch({
  297. effects: placeholderConfig.reconfigure(placeholder(newValue ?? "")),
  298. })
  299. }
  300. )
  301. watch(cursor, (newPos) => {
  302. if (view.value) {
  303. if (
  304. cachedCursor.value.line !== newPos.line ||
  305. cachedCursor.value.ch !== newPos.ch
  306. ) {
  307. const line = view.value.state.doc.line(newPos.line + 1)
  308. const selUpdate = EditorSelection.cursor(line.from + newPos.ch - 1)
  309. view.value?.focus()
  310. view.value.dispatch({
  311. scrollIntoView: true,
  312. selection: selUpdate,
  313. effects: EditorView.scrollTo.of(selUpdate),
  314. })
  315. }
  316. }
  317. })
  318. return {
  319. cursor,
  320. }
  321. }