feat(editor): Refactor expression editors and mixins to composition API (#8894)

Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
This commit is contained in:
Elias Meire
2024-03-15 18:40:37 +01:00
committed by GitHub
parent a10120f74e
commit 0c179e4e51
19 changed files with 1661 additions and 1126 deletions

View File

@@ -1,31 +1,35 @@
<template>
<div v-on-click-outside="onBlur" :class="$style.sqlEditor">
<div :class="$style.codemirror" ref="sqlEditor" data-test-id="sql-editor-container"></div>
<div :class="$style.sqlEditor">
<div ref="sqlEditor" :class="$style.codemirror" data-test-id="sql-editor-container"></div>
<slot name="suffix" />
<InlineExpressionEditorOutput
v-if="!fillParent"
v-if="!fullscreen"
:segments="segments"
:is-read-only="isReadOnly"
:visible="isFocused"
:visible="hasFocus"
:hovering-item-number="hoveringItemNumber"
/>
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import InlineExpressionEditorOutput from '@/components/InlineExpressionEditor/InlineExpressionEditorOutput.vue';
import { EXPRESSIONS_DOCS_URL } from '@/constants';
import { codeNodeEditorEventBus } from '@/event-bus';
import { expressionManager } from '@/mixins/expressionManager';
import { useExpressionEditor } from '@/composables/useExpressionEditor';
import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
import {
autocompleteKeyMap,
enterKeyMap,
historyKeyMap,
tabKeyMap,
} from '@/plugins/codemirror/keymap';
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
import { useNDVStore } from '@/stores/ndv.store';
import { ifNotIn } from '@codemirror/autocomplete';
import { history, toggleComment } from '@codemirror/commands';
import { LanguageSupport, bracketMatching, foldGutter, indentOnInput } from '@codemirror/language';
import { type Extension, type Line, Prec } from '@codemirror/state';
import { EditorState } from '@codemirror/state';
import type { ViewUpdate } from '@codemirror/view';
import { Prec, type Line } from '@codemirror/state';
import {
EditorView,
dropCursor,
@@ -34,7 +38,6 @@ import {
keymap,
lineNumbers,
} from '@codemirror/view';
import type { SQLDialect as SQLDialectType } from '@n8n/codemirror-lang-sql';
import {
Cassandra,
MSSQL,
@@ -46,15 +49,8 @@ import {
StandardSQL,
keywordCompletionSource,
} from '@n8n/codemirror-lang-sql';
import { defineComponent } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
import {
autocompleteKeyMap,
enterKeyMap,
historyKeyMap,
tabKeyMap,
} from '@/plugins/codemirror/keymap';
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
const SQL_DIALECTS = {
StandardSQL,
@@ -67,170 +63,142 @@ const SQL_DIALECTS = {
PLSQL,
} as const;
type SQLEditorData = {
editor: EditorView | null;
editorState: EditorState | null;
isFocused: boolean;
skipSegments: string[];
expressionsDocsUrl: string;
type Props = {
modelValue: string;
dialect?: keyof typeof SQL_DIALECTS;
rows?: number;
isReadOnly?: boolean;
fullscreen?: boolean;
};
export default defineComponent({
name: 'SqlEditor',
components: {
InlineExpressionEditorOutput,
},
mixins: [expressionManager],
props: {
modelValue: {
type: String,
required: true,
},
dialect: {
type: String,
default: 'StandardSQL',
validator: (value: string) => {
return Object.keys(SQL_DIALECTS).includes(value);
},
},
isReadOnly: {
type: Boolean,
default: false,
},
fillParent: {
type: Boolean,
default: false,
},
rows: {
type: Number,
default: 4,
},
},
data(): SQLEditorData {
return {
editor: null,
editorState: null,
expressionsDocsUrl: EXPRESSIONS_DOCS_URL,
isFocused: false,
skipSegments: ['Statement', 'CompositeIdentifier', 'Parens'],
};
},
computed: {
doc(): string {
return this.editor?.state.doc.toString() ?? '';
},
hoveringItemNumber(): number {
return this.ndvStore.hoveringItemNumber;
},
sqlDialect(): SQLDialectType {
return SQL_DIALECTS[this.dialect as keyof typeof SQL_DIALECTS] ?? SQL_DIALECTS.StandardSQL;
},
extensions(): Extension[] {
const dialect = this.sqlDialect;
function sqlWithN8nLanguageSupport() {
return new LanguageSupport(dialect.language, [
dialect.language.data.of({
autocomplete: ifNotIn(['Resolvable'], keywordCompletionSource(dialect, true)),
}),
n8nCompletionSources().map((source) => dialect.language.data.of(source)),
]);
}
const extensions = [
sqlWithN8nLanguageSupport(),
expressionInputHandler(),
codeNodeEditorTheme({
isReadOnly: this.isReadOnly,
maxHeight: this.fillParent ? '100%' : '40vh',
minHeight: '10vh',
rows: this.rows,
}),
lineNumbers(),
EditorView.lineWrapping,
EditorView.domEventHandlers({
focus: () => {
this.isFocused = true;
},
}),
EditorState.readOnly.of(this.isReadOnly),
EditorView.editable.of(!this.isReadOnly),
];
if (!this.isReadOnly) {
extensions.push(
history(),
Prec.highest(
keymap.of([
...tabKeyMap(),
...enterKeyMap,
...historyKeyMap,
...autocompleteKeyMap,
{ key: 'Mod-/', run: toggleComment },
]),
),
n8nAutocompletion(),
indentOnInput(),
highlightActiveLine(),
highlightActiveLineGutter(),
foldGutter(),
dropCursor(),
bracketMatching(),
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
if (!this.editor || !viewUpdate.docChanged) return;
// Force segments value update by keeping track of editor state
this.editorState = this.editor.state;
highlighter.removeColor(this.editor, this.plaintextSegments);
highlighter.addColor(this.editor, this.resolvableSegments);
this.$emit('update:modelValue', this.editor?.state.doc.toString());
}),
);
}
return extensions;
},
},
mounted() {
if (!this.isReadOnly) codeNodeEditorEventBus.on('error-line-number', this.highlightLine);
const state = EditorState.create({ doc: this.modelValue, extensions: this.extensions });
this.editor = new EditorView({ parent: this.$refs.sqlEditor as HTMLDivElement, state });
this.editorState = this.editor.state;
highlighter.addColor(this.editor as EditorView, this.resolvableSegments);
},
methods: {
onBlur() {
this.isFocused = false;
},
line(lineNumber: number): Line | null {
try {
return this.editor?.state.doc.line(lineNumber) ?? null;
} catch {
return null;
}
},
highlightLine(lineNumber: number | 'final') {
if (!this.editor) return;
if (lineNumber === 'final') {
this.editor.dispatch({
selection: { anchor: this.modelValue.length },
});
return;
}
const line = this.line(lineNumber);
if (!line) return;
this.editor.dispatch({
selection: { anchor: line.from },
});
},
},
const props = withDefaults(defineProps<Props>(), {
dialect: 'StandardSQL',
rows: 4,
isReadOnly: false,
fullscreen: false,
});
const emit = defineEmits<{
(event: 'update:model-value', value: string): void;
}>();
const sqlEditor = ref<HTMLElement>();
const extensions = computed(() => {
const dialect = SQL_DIALECTS[props.dialect] ?? SQL_DIALECTS.StandardSQL;
function sqlWithN8nLanguageSupport() {
return new LanguageSupport(dialect.language, [
dialect.language.data.of({
autocomplete: ifNotIn(['Resolvable'], keywordCompletionSource(dialect, true)),
}),
n8nCompletionSources().map((source) => dialect.language.data.of(source)),
]);
}
const baseExtensions = [
sqlWithN8nLanguageSupport(),
expressionInputHandler(),
codeNodeEditorTheme({
isReadOnly: props.isReadOnly,
maxHeight: props.fullscreen ? '100%' : '40vh',
minHeight: '10vh',
rows: props.rows,
}),
lineNumbers(),
EditorView.lineWrapping,
];
if (!props.isReadOnly) {
return baseExtensions.concat([
history(),
Prec.highest(
keymap.of([
...tabKeyMap(),
...enterKeyMap,
...historyKeyMap,
...autocompleteKeyMap,
{ key: 'Mod-/', run: toggleComment },
]),
),
n8nAutocompletion(),
indentOnInput(),
highlightActiveLine(),
highlightActiveLineGutter(),
foldGutter(),
dropCursor(),
bracketMatching(),
]);
}
return baseExtensions;
});
const editorValue = ref(props.modelValue);
const {
editor,
segments: { all: segments },
readEditorValue,
hasFocus,
} = useExpressionEditor({
editorRef: sqlEditor,
editorValue,
extensions,
skipSegments: ['Statement', 'CompositeIdentifier', 'Parens'],
isReadOnly: props.isReadOnly,
});
const ndvStore = useNDVStore();
const hoveringItemNumber = computed(() => {
return ndvStore.hoveringItemNumber;
});
watch(
() => props.modelValue,
(newValue) => {
editorValue.value = newValue;
},
);
watch(segments, () => {
emit('update:model-value', readEditorValue());
});
onMounted(() => {
codeNodeEditorEventBus.on('error-line-number', highlightLine);
if (props.fullscreen) {
focus();
}
});
onBeforeUnmount(() => {
codeNodeEditorEventBus.off('error-line-number', highlightLine);
emit('update:model-value', readEditorValue());
});
function line(lineNumber: number): Line | null {
try {
return editor.value?.state.doc.line(lineNumber) ?? null;
} catch {
return null;
}
}
function highlightLine(lineNumber: number | 'final') {
if (!editor.value) return;
if (lineNumber === 'final') {
editor.value.dispatch({
selection: { anchor: editor.value.state.doc.length },
});
return;
}
const lineToHighlight = line(lineNumber);
if (!lineToHighlight) return;
editor.value.dispatch({
selection: { anchor: lineToHighlight.from },
});
}
</script>
<style module lang="scss">