codemirror.ts 8.1 KB

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