Files
n8n-enterprise-unlocked/packages/editor-ui/src/components/SqlEditor/SqlEditor.vue
2025-02-10 16:41:55 +01:00

262 lines
5.9 KiB
Vue

<script setup lang="ts">
import InlineExpressionEditorOutput from '@/components/InlineExpressionEditor/InlineExpressionEditorOutput.vue';
import { useExpressionEditor } from '@/composables/useExpressionEditor';
import { codeNodeEditorEventBus } from '@/event-bus';
import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions';
import { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
import { editorKeymap } from '@/plugins/codemirror/keymap';
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
import { ifNotIn } from '@codemirror/autocomplete';
import { history } from '@codemirror/commands';
import { LanguageSupport, bracketMatching, foldGutter, indentOnInput } from '@codemirror/language';
import { Prec, type Line } from '@codemirror/state';
import {
EditorView,
dropCursor,
highlightActiveLine,
highlightActiveLineGutter,
keymap,
lineNumbers,
} from '@codemirror/view';
import {
Cassandra,
MSSQL,
MariaSQL,
MySQL,
PLSQL,
PostgreSQL,
SQLite,
StandardSQL,
keywordCompletionSource,
} from '@n8n/codemirror-lang-sql';
import { onClickOutside } from '@vueuse/core';
import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue';
import { codeEditorTheme } from '../CodeNodeEditor/theme';
import {
expressionCloseBrackets,
expressionCloseBracketsConfig,
} from '@/plugins/codemirror/expressionCloseBrackets';
const SQL_DIALECTS = {
StandardSQL,
PostgreSQL,
MySQL,
MariaSQL,
MSSQL,
SQLite,
Cassandra,
PLSQL,
} as const;
type Props = {
modelValue: string;
dialect?: keyof typeof SQL_DIALECTS;
rows?: number;
isReadOnly?: boolean;
fullscreen?: boolean;
};
const props = withDefaults(defineProps<Props>(), {
dialect: 'StandardSQL',
rows: 4,
isReadOnly: false,
fullscreen: false,
});
const emit = defineEmits<{
'update:model-value': [value: string];
}>();
const container = ref<HTMLDivElement>();
const sqlEditor = ref<HTMLDivElement>();
const isFocused = ref(false);
const extensions = computed(() => {
const dialect = SQL_DIALECTS[props.dialect] ?? SQL_DIALECTS.StandardSQL;
function sqlWithN8nLanguageSupport() {
return new LanguageSupport(dialect.language, [
dialect.language.data.of({ closeBrackets: expressionCloseBracketsConfig }),
dialect.language.data.of({
autocomplete: ifNotIn(['Resolvable'], keywordCompletionSource(dialect, true)),
}),
n8nCompletionSources().map((source) => dialect.language.data.of(source)),
]);
}
const baseExtensions = [
sqlWithN8nLanguageSupport(),
expressionCloseBrackets(),
codeEditorTheme({
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(editorKeymap)),
n8nAutocompletion(),
indentOnInput(),
highlightActiveLine(),
highlightActiveLineGutter(),
foldGutter(),
dropCursor(),
bracketMatching(),
mappingDropCursor(),
]);
}
return baseExtensions;
});
const editorValue = ref(props.modelValue);
const {
editor,
segments: { all: segments },
readEditorValue,
hasFocus: editorHasFocus,
isDirty,
} = useExpressionEditor({
editorRef: sqlEditor,
editorValue,
extensions,
skipSegments: ['Statement', 'CompositeIdentifier', 'Parens', 'Brackets'],
isReadOnly: props.isReadOnly,
});
watch(
() => props.modelValue,
(newValue) => {
editorValue.value = newValue;
},
);
watch(editorHasFocus, (focus) => {
if (focus) {
isFocused.value = true;
}
});
watch(segments, () => {
emit('update:model-value', readEditorValue());
});
onMounted(() => {
codeNodeEditorEventBus.on('highlightLine', highlightLine);
if (props.fullscreen) {
focus();
}
});
onBeforeUnmount(() => {
if (isDirty.value) emit('update:model-value', readEditorValue());
codeNodeEditorEventBus.off('highlightLine', highlightLine);
});
onClickOutside(container, (event) => onBlur(event));
function onBlur(event: FocusEvent | KeyboardEvent) {
if (
event?.target instanceof Element &&
Array.from(event.target.classList).some((_class) => _class.includes('resizer'))
) {
return; // prevent blur on resizing
}
isFocused.value = false;
}
function line(lineNumber: number): Line | null {
try {
return editor.value?.state.doc.line(lineNumber) ?? null;
} catch {
return null;
}
}
function highlightLine(lineNumber: number | 'last') {
if (!editor.value) return;
if (lineNumber === 'last') {
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 },
});
}
async function onDrop(value: string, event: MouseEvent) {
if (!editor.value) return;
await dropInExpressionEditor(toRaw(editor.value), event, value);
}
</script>
<template>
<div ref="container" :class="$style.sqlEditor" @keydown.tab="onBlur">
<DraggableTarget type="mapping" :disabled="isReadOnly" @drop="onDrop">
<template #default="{ activeDrop, droppable }">
<div
ref="sqlEditor"
:class="[
$style.codemirror,
{ [$style.activeDrop]: activeDrop, [$style.droppable]: droppable },
]"
data-test-id="sql-editor-container"
></div>
</template>
</DraggableTarget>
<slot name="suffix" />
<InlineExpressionEditorOutput
v-if="!fullscreen"
:segments="segments"
:is-read-only="isReadOnly"
:visible="isFocused"
/>
</div>
</template>
<style module lang="scss">
.sqlEditor {
position: relative;
height: 100%;
& > div {
height: 100%;
}
}
.codemirror {
height: 100%;
}
.codemirror.droppable {
:global(.cm-editor) {
border-color: var(--color-ndv-droppable-parameter);
border-style: dashed;
border-width: 1.5px;
}
}
.codemirror.activeDrop {
:global(.cm-editor) {
border-color: var(--color-success);
border-style: solid;
cursor: grabbing;
border-width: 1px;
}
}
</style>