codemirror.ts 9.6 KB

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