codemirror.ts 9.7 KB

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