import { keymap, EditorView, ViewPlugin, ViewUpdate, placeholder, } from "@codemirror/view" import { Extension, EditorState, Compartment, EditorSelection, } from "@codemirror/state" import { Language, LanguageSupport } from "@codemirror/language" import { defaultKeymap, indentLess, insertTab } from "@codemirror/commands" import { Completion, autocompletion } from "@codemirror/autocomplete" import { linter } from "@codemirror/lint" import { watch, ref, Ref, onMounted, onBeforeUnmount, } from "@nuxtjs/composition-api" import { javascriptLanguage } from "@codemirror/lang-javascript" import { xmlLanguage } from "@codemirror/lang-xml" import { jsonLanguage } from "@codemirror/lang-json" import { GQLLanguage } from "@hoppscotch/codemirror-lang-graphql" import { StreamLanguage } from "@codemirror/stream-parser" import { html } from "@codemirror/legacy-modes/mode/xml" import { shell } from "@codemirror/legacy-modes/mode/shell" import { yaml } from "@codemirror/legacy-modes/mode/yaml" import { isJSONContentType } from "../utils/contenttypes" import { useStreamSubscriber } from "../utils/composables" import { Completer } from "./completion" import { LinterDefinition } from "./linting/linter" import { basicSetup, baseTheme, baseHighlightStyle } from "./themes/baseTheme" import { HoppEnvironmentPlugin } from "./extensions/HoppEnvironment" // TODO: Migrate from legacy mode type ExtendedEditorConfig = { mode: string placeholder: string readOnly: boolean lineWrapping: boolean } type CodeMirrorOptions = { extendedEditorConfig: Partial linter: LinterDefinition | null completer: Completer | null // NOTE: This property is not reactive environmentHighlights: boolean } const hoppCompleterExt = (completer: Completer): Extension => { return autocompletion({ override: [ async (context) => { // Expensive operation! Disable on bigger files ? const text = context.state.doc.toJSON().join(context.state.lineBreak) const line = context.state.doc.lineAt(context.pos) const lineStart = line.from const lineNo = line.number - 1 const ch = context.pos - lineStart // Only do trigger on type when typing a word token, else stop (unless explicit) if (!context.matchBefore(/\w+/) && !context.explicit) return { from: context.pos, options: [], } const result = await completer(text, { line: lineNo, ch }) // Use more completion features ? const completions = result?.completions.map((comp) => ({ label: comp.text, detail: comp.meta, })) ?? [] return { from: context.state.wordAt(context.pos)?.from ?? context.pos, options: completions, } }, ], }) } const hoppLinterExt = (hoppLinter: LinterDefinition | undefined): Extension => { return linter(async (view) => { if (!hoppLinter) return [] // Requires full document scan, hence expensive on big files, force disable on big files ? const linterResult = await hoppLinter( view.state.doc.toJSON().join(view.state.lineBreak) ) return linterResult.map((result) => { const startPos = view.state.doc.line(result.from.line).from + result.from.ch - 1 const endPos = view.state.doc.line(result.to.line).from + result.to.ch - 1 return { from: startPos < 0 ? 0 : startPos, to: endPos > view.state.doc.length ? view.state.doc.length : endPos, message: result.message, severity: result.severity, } }) }) } const hoppLang = ( language: Language | undefined, linter?: LinterDefinition | undefined, completer?: Completer | undefined ): Extension | LanguageSupport => { const exts: Extension[] = [] exts.push(hoppLinterExt(linter)) if (completer) exts.push(hoppCompleterExt(completer)) return language ? new LanguageSupport(language, exts) : exts } const getLanguage = (langMime: string): Language | null => { if (isJSONContentType(langMime)) { return jsonLanguage } else if (langMime === "application/javascript") { return javascriptLanguage } else if (langMime === "graphql") { return GQLLanguage } else if (langMime === "application/xml") { return xmlLanguage } else if (langMime === "htmlmixed") { return StreamLanguage.define(html) } else if (langMime === "application/x-sh") { return StreamLanguage.define(shell) } else if (langMime === "text/x-yaml") { return StreamLanguage.define(yaml) } // None matched, so return null return null } const getEditorLanguage = ( langMime: string, linter: LinterDefinition | undefined, completer: Completer | undefined ): Extension => hoppLang(getLanguage(langMime) ?? undefined, linter, completer) export function useCodemirror( el: Ref, value: Ref, options: CodeMirrorOptions ): { cursor: Ref<{ line: number; ch: number }> } { const { subscribeToStream } = useStreamSubscriber() const language = new Compartment() const lineWrapping = new Compartment() const placeholderConfig = new Compartment() const cachedCursor = ref({ line: 0, ch: 0, }) const cursor = ref({ line: 0, ch: 0, }) const cachedValue = ref(value.value) const view = ref() const environmentTooltip = options.environmentHighlights ? new HoppEnvironmentPlugin(subscribeToStream, view) : null const initView = (el: any) => { const extensions = [ basicSetup, baseTheme, baseHighlightStyle, ViewPlugin.fromClass( class { update(update: ViewUpdate) { if (update.selectionSet) { const cursorPos = update.state.selection.main.head const line = update.state.doc.lineAt(cursorPos) cachedCursor.value = { line: line.number - 1, ch: cursorPos - line.from, } cursor.value = { line: cachedCursor.value.line, ch: cachedCursor.value.ch, } } if (update.docChanged) { // Expensive on big files ? cachedValue.value = update.state.doc .toJSON() .join(update.state.lineBreak) if (!options.extendedEditorConfig.readOnly) value.value = cachedValue.value } } } ), EditorView.updateListener.of((update) => { if (options.extendedEditorConfig.readOnly) { update.view.contentDOM.inputMode = "none" } }), EditorState.changeFilter.of(() => !options.extendedEditorConfig.readOnly), placeholderConfig.of( placeholder(options.extendedEditorConfig.placeholder ?? "") ), language.of( getEditorLanguage( options.extendedEditorConfig.mode ?? "", options.linter ?? undefined, options.completer ?? undefined ) ), lineWrapping.of( options.extendedEditorConfig.lineWrapping ? [EditorView.lineWrapping] : [] ), keymap.of([ ...defaultKeymap, { key: "Tab", preventDefault: true, run: insertTab, }, { key: "Shift-Tab", preventDefault: true, run: indentLess, }, ]), ] if (environmentTooltip) extensions.push(environmentTooltip.extension) view.value = new EditorView({ parent: el, state: EditorState.create({ doc: value.value, extensions, }), }) } onMounted(() => { if (el.value) { if (!view.value) initView(el.value) } }) watch(el, () => { if (el.value) { if (view.value) view.value.destroy() initView(el.value) } else { view.value?.destroy() view.value = undefined } }) onBeforeUnmount(() => { view.value?.destroy() }) watch(value, (newVal) => { if (cachedValue.value !== newVal) { view.value?.dispatch({ filter: false, changes: { from: 0, to: view.value.state.doc.length, insert: newVal, }, }) } cachedValue.value = newVal }) watch( () => [ options.extendedEditorConfig.mode, options.linter, options.completer, ], () => { view.value?.dispatch({ effects: language.reconfigure( getEditorLanguage( (options.extendedEditorConfig.mode as any) ?? "", options.linter ?? undefined, options.completer ?? undefined ) ), }) } ) watch( () => options.extendedEditorConfig.lineWrapping, (newMode) => { view.value?.dispatch({ effects: lineWrapping.reconfigure( newMode ? [EditorView.lineWrapping] : [] ), }) } ) watch( () => options.extendedEditorConfig.placeholder, (newValue) => { view.value?.dispatch({ effects: placeholderConfig.reconfigure(placeholder(newValue ?? "")), }) } ) watch(cursor, (newPos) => { if (view.value) { if ( cachedCursor.value.line !== newPos.line || cachedCursor.value.ch !== newPos.ch ) { const line = view.value.state.doc.line(newPos.line + 1) const selUpdate = EditorSelection.cursor(line.from + newPos.ch - 1) view.value?.focus() view.value.dispatch({ scrollIntoView: true, selection: selUpdate, effects: EditorView.scrollTo.of(selUpdate), }) } } }) return { cursor, } }