Files
hoppscotch/packages/hoppscotch-common/src/composables/codemirror.ts
2023-05-29 20:18:19 -04:00

398 lines
10 KiB
TypeScript

import {
keymap,
EditorView,
ViewPlugin,
ViewUpdate,
placeholder,
} from "@codemirror/view"
import {
Extension,
EditorState,
Compartment,
EditorSelection,
} from "@codemirror/state"
import {
Language,
LanguageSupport,
StreamLanguage,
syntaxHighlighting,
} 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 "vue"
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 { 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 "@helpers/utils/contenttypes"
import { useStreamSubscriber } from "@composables/stream"
import { Completer } from "@helpers/editor/completion"
import { LinterDefinition } from "@helpers/editor/linting/linter"
import {
basicSetup,
baseTheme,
baseHighlightStyle,
} from "@helpers/editor/themes/baseTheme"
import { HoppEnvironmentPlugin } from "@helpers/editor/extensions/HoppEnvironment"
import xmlFormat from "xml-formatter"
import { platform } from "~/platform"
// TODO: Migrate from legacy mode
type ExtendedEditorConfig = {
mode: string
placeholder: string
readOnly: boolean
lineWrapping: boolean
}
type CodeMirrorOptions = {
extendedEditorConfig: Partial<ExtendedEditorConfig>
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<Completion>((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
}
/**
* Uses xml-formatter to format the XML document
* @param doc Document to parse
* @param langMime Language mime type
* @returns Parsed document if mime type is xml, else returns the original document
*/
const parseDoc = (
doc: string | undefined,
langMime: string
): string | undefined => {
if (langMime === "application/xml" && doc) {
return xmlFormat(doc, {
indentation: " ",
collapseContent: true,
lineSeparator: "\n",
})
} else {
return doc
}
}
const getEditorLanguage = (
langMime: string,
linter: LinterDefinition | undefined,
completer: Completer | undefined
): Extension => hoppLang(getLanguage(langMime) ?? undefined, linter, completer)
export function useCodemirror(
el: Ref<any | null>,
value: Ref<string | undefined>,
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<EditorView>()
const environmentTooltip = options.environmentHighlights
? new HoppEnvironmentPlugin(subscribeToStream, view)
: null
const initView = (el: any) => {
if (el) platform.ui?.onCodemirrorInstanceMount?.(el)
const extensions = [
basicSetup,
baseTheme,
syntaxHighlighting(baseHighlightStyle, { fallback: true }),
ViewPlugin.fromClass(
class {
update(update: ViewUpdate) {
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: parseDoc(value.value, options.extendedEditorConfig.mode ?? ""),
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 (newVal === undefined) {
view.value?.destroy()
view.value = undefined
return
}
if (!view.value && el.value) {
initView(el.value)
}
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.scrollIntoView(selUpdate),
})
}
}
})
return {
cursor,
}
}