mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
refactor(editor): Refactor code editors to composition API (no-changelog) (#9757)
This commit is contained in:
parent
f4e2c695f2
commit
202124152c
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
ref="codeNodeEditorContainer"
|
ref="codeNodeEditorContainerRef"
|
||||||
:class="['code-node-editor', $style['code-node-editor-container'], language]"
|
:class="['code-node-editor', $style['code-node-editor-container'], language]"
|
||||||
@mouseover="onMouseOver"
|
@mouseover="onMouseOver"
|
||||||
@mouseout="onMouseOut"
|
@mouseout="onMouseOut"
|
||||||
|
@ -18,7 +18,7 @@
|
||||||
data-test-id="code-node-tab-code"
|
data-test-id="code-node-tab-code"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref="codeNodeEditor"
|
ref="codeNodeEditorRef"
|
||||||
:class="['ph-no-capture', 'code-editor-tabs', $style.editorInput]"
|
:class="['ph-no-capture', 'code-editor-tabs', $style.editorInput]"
|
||||||
/>
|
/>
|
||||||
<slot name="suffix" />
|
<slot name="suffix" />
|
||||||
|
@ -33,248 +33,239 @@
|
||||||
:key="activeTab"
|
:key="activeTab"
|
||||||
:has-changes="hasChanges"
|
:has-changes="hasChanges"
|
||||||
@replace-code="onReplaceCode"
|
@replace-code="onReplaceCode"
|
||||||
@started-loading="isLoadingAIResponse = true"
|
@started-loading="onAiLoadStart"
|
||||||
@finished-loading="isLoadingAIResponse = false"
|
@finished-loading="onAiLoadEnd"
|
||||||
/>
|
/>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
<!-- If AskAi not enabled, there's no point in rendering tabs -->
|
<!-- If AskAi not enabled, there's no point in rendering tabs -->
|
||||||
<div v-else :class="$style.fillHeight">
|
<div v-else :class="$style.fillHeight">
|
||||||
<div ref="codeNodeEditor" :class="['ph-no-capture', $style.fillHeight]" />
|
<div ref="codeNodeEditorRef" :class="['ph-no-capture', $style.fillHeight]" />
|
||||||
<slot name="suffix" />
|
<slot name="suffix" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineComponent } from 'vue';
|
import { javascript } from '@codemirror/lang-javascript';
|
||||||
import type { PropType } from 'vue';
|
import { python } from '@codemirror/lang-python';
|
||||||
import jsParser from 'prettier/plugins/babel';
|
|
||||||
import { format } from 'prettier';
|
|
||||||
import * as estree from 'prettier/plugins/estree';
|
|
||||||
import { mapStores } from 'pinia';
|
|
||||||
import type { LanguageSupport } from '@codemirror/language';
|
import type { LanguageSupport } from '@codemirror/language';
|
||||||
import type { Extension, Line } from '@codemirror/state';
|
import type { Extension, Line } from '@codemirror/state';
|
||||||
import { Compartment, EditorState } from '@codemirror/state';
|
import { Compartment, EditorState } from '@codemirror/state';
|
||||||
import type { ViewUpdate } from '@codemirror/view';
|
import type { ViewUpdate } from '@codemirror/view';
|
||||||
import { EditorView } from '@codemirror/view';
|
import { EditorView } from '@codemirror/view';
|
||||||
import { javascript } from '@codemirror/lang-javascript';
|
|
||||||
import { python } from '@codemirror/lang-python';
|
|
||||||
import type { CodeExecutionMode, CodeNodeEditorLanguage } from 'n8n-workflow';
|
import type { CodeExecutionMode, CodeNodeEditorLanguage } from 'n8n-workflow';
|
||||||
import { CODE_EXECUTION_MODES, CODE_LANGUAGES } from 'n8n-workflow';
|
import { format } from 'prettier';
|
||||||
|
import jsParser from 'prettier/plugins/babel';
|
||||||
|
import * as estree from 'prettier/plugins/estree';
|
||||||
|
import { type Ref, computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
import { ASK_AI_EXPERIMENT, CODE_NODE_TYPE } from '@/constants';
|
import { ASK_AI_EXPERIMENT, CODE_NODE_TYPE } from '@/constants';
|
||||||
import { codeNodeEditorEventBus } from '@/event-bus';
|
import { codeNodeEditorEventBus } from '@/event-bus';
|
||||||
import { useRootStore } from '@/stores/root.store';
|
import { useRootStore } from '@/stores/root.store';
|
||||||
import { usePostHog } from '@/stores/posthog.store';
|
import { usePostHog } from '@/stores/posthog.store';
|
||||||
|
|
||||||
import { readOnlyEditorExtensions, writableEditorExtensions } from './baseExtensions';
|
|
||||||
import { CODE_PLACEHOLDERS } from './constants';
|
|
||||||
import { linterExtension } from './linter';
|
|
||||||
import { completerExtension } from './completer';
|
|
||||||
import { codeNodeEditorTheme } from './theme';
|
|
||||||
import AskAI from './AskAI/AskAI.vue';
|
|
||||||
import { useMessage } from '@/composables/useMessage';
|
import { useMessage } from '@/composables/useMessage';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
import AskAI from './AskAI/AskAI.vue';
|
||||||
|
import { readOnlyEditorExtensions, writableEditorExtensions } from './baseExtensions';
|
||||||
|
import { useCompleter } from './completer';
|
||||||
|
import { CODE_PLACEHOLDERS } from './constants';
|
||||||
|
import { useLinter } from './linter';
|
||||||
|
import { codeNodeEditorTheme } from './theme';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
|
|
||||||
export default defineComponent({
|
type Props = {
|
||||||
name: 'CodeNodeEditor',
|
mode: CodeExecutionMode;
|
||||||
components: {
|
modelValue: string;
|
||||||
AskAI,
|
aiButtonEnabled?: boolean;
|
||||||
},
|
fillParent?: boolean;
|
||||||
mixins: [linterExtension, completerExtension],
|
language?: CodeNodeEditorLanguage;
|
||||||
props: {
|
isReadOnly?: boolean;
|
||||||
aiButtonEnabled: {
|
rows?: number;
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
fillParent: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
mode: {
|
|
||||||
type: String as PropType<CodeExecutionMode>,
|
|
||||||
validator: (value: CodeExecutionMode): boolean => CODE_EXECUTION_MODES.includes(value),
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
language: {
|
|
||||||
type: String as PropType<CodeNodeEditorLanguage>,
|
|
||||||
default: 'javaScript' as CodeNodeEditorLanguage,
|
|
||||||
validator: (value: CodeNodeEditorLanguage): boolean => CODE_LANGUAGES.includes(value),
|
|
||||||
},
|
|
||||||
isReadOnly: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
rows: {
|
|
||||||
type: Number,
|
|
||||||
default: 4,
|
|
||||||
},
|
|
||||||
modelValue: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setup() {
|
|
||||||
return {
|
|
||||||
...useMessage(),
|
|
||||||
};
|
};
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
editor: null as EditorView | null,
|
|
||||||
languageCompartment: new Compartment(),
|
|
||||||
linterCompartment: new Compartment(),
|
|
||||||
isEditorHovered: false,
|
|
||||||
isEditorFocused: false,
|
|
||||||
tabs: ['code', 'ask-ai'],
|
|
||||||
activeTab: 'code',
|
|
||||||
hasChanges: false,
|
|
||||||
isLoadingAIResponse: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
mode(_newMode, previousMode: CodeExecutionMode) {
|
|
||||||
this.reloadLinter();
|
|
||||||
|
|
||||||
if (
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
this.getCurrentEditorContent().trim() === CODE_PLACEHOLDERS[this.language]?.[previousMode]
|
aiButtonEnabled: false,
|
||||||
) {
|
fillParent: false,
|
||||||
this.refreshPlaceholder();
|
language: 'javaScript',
|
||||||
}
|
isReadOnly: false,
|
||||||
},
|
rows: 4,
|
||||||
language(_newLanguage, previousLanguage: CodeNodeEditorLanguage) {
|
|
||||||
if (
|
|
||||||
this.getCurrentEditorContent().trim() === CODE_PLACEHOLDERS[previousLanguage]?.[this.mode]
|
|
||||||
) {
|
|
||||||
this.refreshPlaceholder();
|
|
||||||
}
|
|
||||||
|
|
||||||
const [languageSupport] = this.languageExtensions;
|
|
||||||
this.editor?.dispatch({
|
|
||||||
effects: this.languageCompartment.reconfigure(languageSupport),
|
|
||||||
});
|
});
|
||||||
},
|
const emit = defineEmits<{
|
||||||
aiEnabled: {
|
(event: 'update:modelValue', value: string): void;
|
||||||
immediate: true,
|
}>();
|
||||||
async handler(isEnabled) {
|
|
||||||
if (isEnabled && !this.modelValue) {
|
|
||||||
this.$emit('update:modelValue', this.placeholder);
|
|
||||||
}
|
|
||||||
await this.$nextTick();
|
|
||||||
this.hasChanges = this.modelValue !== this.placeholder;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
...mapStores(useRootStore, usePostHog, useSettingsStore),
|
|
||||||
aiEnabled(): boolean {
|
|
||||||
const isAiExperimentEnabled = [ASK_AI_EXPERIMENT.gpt3, ASK_AI_EXPERIMENT.gpt4].includes(
|
|
||||||
(this.posthogStore.getVariant(ASK_AI_EXPERIMENT.name) ?? '') as string,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
const message = useMessage();
|
||||||
isAiExperimentEnabled &&
|
const editor = ref(null) as Ref<EditorView | null>;
|
||||||
this.settingsStore.settings.ai.enabled &&
|
const languageCompartment = ref(new Compartment());
|
||||||
this.language === 'javaScript'
|
const linterCompartment = ref(new Compartment());
|
||||||
);
|
const isEditorHovered = ref(false);
|
||||||
},
|
const isEditorFocused = ref(false);
|
||||||
placeholder(): string {
|
const tabs = ref(['code', 'ask-ai']);
|
||||||
return CODE_PLACEHOLDERS[this.language]?.[this.mode] ?? '';
|
const activeTab = ref('code');
|
||||||
},
|
const hasChanges = ref(false);
|
||||||
// eslint-disable-next-line vue/return-in-computed-property
|
const isLoadingAIResponse = ref(false);
|
||||||
languageExtensions(): [LanguageSupport, ...Extension[]] {
|
const codeNodeEditorRef = ref<HTMLDivElement>();
|
||||||
switch (this.language) {
|
const codeNodeEditorContainerRef = ref<HTMLDivElement>();
|
||||||
case 'javaScript':
|
|
||||||
return [javascript(), this.autocompletionExtension('javaScript')];
|
|
||||||
case 'python':
|
|
||||||
return [python(), this.autocompletionExtension('python')];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
beforeUnmount() {
|
|
||||||
if (!this.isReadOnly) codeNodeEditorEventBus.off('error-line-number', this.highlightLine);
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
if (!this.isReadOnly) codeNodeEditorEventBus.on('error-line-number', this.highlightLine);
|
|
||||||
|
|
||||||
const { isReadOnly, language } = this;
|
const { autocompletionExtension } = useCompleter(() => props.mode, editor);
|
||||||
|
const { createLinter } = useLinter(() => props.mode, editor);
|
||||||
|
|
||||||
|
const rootStore = useRootStore();
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
const posthog = usePostHog();
|
||||||
|
const i18n = useI18n();
|
||||||
|
const telemetry = useTelemetry();
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!props.isReadOnly) codeNodeEditorEventBus.on('error-line-number', highlightLine);
|
||||||
|
|
||||||
|
const { isReadOnly, language } = props;
|
||||||
const extensions: Extension[] = [
|
const extensions: Extension[] = [
|
||||||
...readOnlyEditorExtensions,
|
...readOnlyEditorExtensions,
|
||||||
EditorState.readOnly.of(isReadOnly),
|
EditorState.readOnly.of(isReadOnly),
|
||||||
EditorView.editable.of(!isReadOnly),
|
EditorView.editable.of(!isReadOnly),
|
||||||
codeNodeEditorTheme({
|
codeNodeEditorTheme({
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
maxHeight: this.fillParent ? '100%' : '40vh',
|
maxHeight: props.fillParent ? '100%' : '40vh',
|
||||||
minHeight: '20vh',
|
minHeight: '20vh',
|
||||||
rows: this.rows,
|
rows: props.rows,
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!isReadOnly) {
|
if (!isReadOnly) {
|
||||||
const linter = this.createLinter(language);
|
const linter = createLinter(language);
|
||||||
if (linter) {
|
if (linter) {
|
||||||
extensions.push(this.linterCompartment.of(linter));
|
extensions.push(linterCompartment.value.of(linter));
|
||||||
}
|
}
|
||||||
|
|
||||||
extensions.push(
|
extensions.push(
|
||||||
...writableEditorExtensions,
|
...writableEditorExtensions,
|
||||||
EditorView.domEventHandlers({
|
EditorView.domEventHandlers({
|
||||||
focus: () => {
|
focus: () => {
|
||||||
this.isEditorFocused = true;
|
isEditorFocused.value = true;
|
||||||
},
|
},
|
||||||
blur: () => {
|
blur: () => {
|
||||||
this.isEditorFocused = false;
|
isEditorFocused.value = false;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
EditorView.updateListener.of((viewUpdate) => {
|
EditorView.updateListener.of((viewUpdate) => {
|
||||||
if (!viewUpdate.docChanged) return;
|
if (!viewUpdate.docChanged) return;
|
||||||
|
|
||||||
this.trackCompletion(viewUpdate);
|
trackCompletion(viewUpdate);
|
||||||
|
|
||||||
this.$emit('update:modelValue', this.editor?.state.doc.toString());
|
const value = editor.value?.state.doc.toString();
|
||||||
this.hasChanges = true;
|
if (value) {
|
||||||
|
emit('update:modelValue', value);
|
||||||
|
}
|
||||||
|
hasChanges.value = true;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [languageSupport, ...otherExtensions] = this.languageExtensions;
|
const [languageSupport, ...otherExtensions] = languageExtensions.value;
|
||||||
extensions.push(this.languageCompartment.of(languageSupport), ...otherExtensions);
|
extensions.push(languageCompartment.value.of(languageSupport), ...otherExtensions);
|
||||||
|
|
||||||
const state = EditorState.create({
|
const state = EditorState.create({
|
||||||
doc: this.modelValue ?? this.placeholder,
|
doc: props.modelValue ?? placeholder.value,
|
||||||
extensions,
|
extensions,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.editor = new EditorView({
|
editor.value = new EditorView({
|
||||||
parent: this.$refs.codeNodeEditor as HTMLDivElement,
|
parent: codeNodeEditorRef.value,
|
||||||
state,
|
state,
|
||||||
});
|
});
|
||||||
|
|
||||||
// empty on first load, default param value
|
// empty on first load, default param value
|
||||||
if (!this.modelValue) {
|
if (!props.modelValue) {
|
||||||
this.refreshPlaceholder();
|
refreshPlaceholder();
|
||||||
this.$emit('update:modelValue', this.placeholder);
|
emit('update:modelValue', placeholder.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (!props.isReadOnly) codeNodeEditorEventBus.off('error-line-number', highlightLine);
|
||||||
|
});
|
||||||
|
|
||||||
|
const aiEnabled = computed(() => {
|
||||||
|
const isAiExperimentEnabled = [ASK_AI_EXPERIMENT.gpt3, ASK_AI_EXPERIMENT.gpt4].includes(
|
||||||
|
(posthog.getVariant(ASK_AI_EXPERIMENT.name) ?? '') as string,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
isAiExperimentEnabled && settingsStore.settings.ai.enabled && props.language === 'javaScript'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const placeholder = computed(() => {
|
||||||
|
return CODE_PLACEHOLDERS[props.language]?.[props.mode] ?? '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line vue/return-in-computed-property
|
||||||
|
const languageExtensions = computed<[LanguageSupport, ...Extension[]]>(() => {
|
||||||
|
switch (props.language) {
|
||||||
|
case 'javaScript':
|
||||||
|
return [javascript(), autocompletionExtension('javaScript')];
|
||||||
|
case 'python':
|
||||||
|
return [python(), autocompletionExtension('python')];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.mode,
|
||||||
|
(_newMode, previousMode: CodeExecutionMode) => {
|
||||||
|
reloadLinter();
|
||||||
|
|
||||||
|
if (getCurrentEditorContent().trim() === CODE_PLACEHOLDERS[props.language]?.[previousMode]) {
|
||||||
|
refreshPlaceholder();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
);
|
||||||
getCurrentEditorContent() {
|
|
||||||
return this.editor?.state.doc.toString() ?? '';
|
watch(
|
||||||
},
|
() => props.language,
|
||||||
async onBeforeTabLeave(_activeName: string, oldActiveName: string) {
|
(_newLanguage, previousLanguage: CodeNodeEditorLanguage) => {
|
||||||
// Confirm dialog if leaving ask-ai tab during loading
|
if (getCurrentEditorContent().trim() === CODE_PLACEHOLDERS[previousLanguage]?.[props.mode]) {
|
||||||
if (oldActiveName === 'ask-ai' && this.isLoadingAIResponse) {
|
refreshPlaceholder();
|
||||||
const confirmModal = await this.alert(
|
}
|
||||||
this.$locale.baseText('codeNodeEditor.askAi.sureLeaveTab'),
|
|
||||||
{
|
const [languageSupport] = languageExtensions.value;
|
||||||
title: this.$locale.baseText('codeNodeEditor.askAi.areYouSure'),
|
editor.value?.dispatch({
|
||||||
confirmButtonText: this.$locale.baseText('codeNodeEditor.askAi.switchTab'),
|
effects: languageCompartment.value.reconfigure(languageSupport),
|
||||||
showClose: true,
|
});
|
||||||
showCancelButton: true,
|
reloadLinter();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
aiEnabled,
|
||||||
|
async (isEnabled) => {
|
||||||
|
if (isEnabled && !props.modelValue) {
|
||||||
|
emit('update:modelValue', placeholder.value);
|
||||||
|
}
|
||||||
|
await nextTick();
|
||||||
|
hasChanges.value = props.modelValue !== placeholder.value;
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
function getCurrentEditorContent() {
|
||||||
|
return editor.value?.state.doc.toString() ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onBeforeTabLeave(_activeName: string, oldActiveName: string) {
|
||||||
|
// Confirm dialog if leaving ask-ai tab during loading
|
||||||
|
if (oldActiveName === 'ask-ai' && isLoadingAIResponse.value) {
|
||||||
|
const confirmModal = await message.alert(i18n.baseText('codeNodeEditor.askAi.sureLeaveTab'), {
|
||||||
|
title: i18n.baseText('codeNodeEditor.askAi.areYouSure'),
|
||||||
|
confirmButtonText: i18n.baseText('codeNodeEditor.askAi.switchTab'),
|
||||||
|
showClose: true,
|
||||||
|
showCancelButton: true,
|
||||||
|
});
|
||||||
|
|
||||||
if (confirmModal === 'confirm') {
|
if (confirmModal === 'confirm') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
@ -283,83 +274,91 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
},
|
}
|
||||||
async onReplaceCode(code: string) {
|
|
||||||
|
async function onReplaceCode(code: string) {
|
||||||
const formattedCode = await format(code, {
|
const formattedCode = await format(code, {
|
||||||
parser: 'babel',
|
parser: 'babel',
|
||||||
plugins: [jsParser, estree],
|
plugins: [jsParser, estree],
|
||||||
});
|
});
|
||||||
|
|
||||||
this.editor?.dispatch({
|
editor.value?.dispatch({
|
||||||
changes: { from: 0, to: this.getCurrentEditorContent().length, insert: formattedCode },
|
changes: { from: 0, to: getCurrentEditorContent().length, insert: formattedCode },
|
||||||
});
|
});
|
||||||
|
|
||||||
this.activeTab = 'code';
|
activeTab.value = 'code';
|
||||||
this.hasChanges = false;
|
hasChanges.value = false;
|
||||||
},
|
}
|
||||||
onMouseOver(event: MouseEvent) {
|
|
||||||
|
function onMouseOver(event: MouseEvent) {
|
||||||
const fromElement = event.relatedTarget as HTMLElement;
|
const fromElement = event.relatedTarget as HTMLElement;
|
||||||
const ref = this.$refs.codeNodeEditorContainer as HTMLDivElement | undefined;
|
const containerRef = codeNodeEditorContainerRef.value;
|
||||||
|
|
||||||
if (!ref?.contains(fromElement)) this.isEditorHovered = true;
|
if (!containerRef?.contains(fromElement)) isEditorHovered.value = true;
|
||||||
},
|
}
|
||||||
onMouseOut(event: MouseEvent) {
|
|
||||||
|
function onMouseOut(event: MouseEvent) {
|
||||||
const fromElement = event.relatedTarget as HTMLElement;
|
const fromElement = event.relatedTarget as HTMLElement;
|
||||||
const ref = this.$refs.codeNodeEditorContainer as HTMLDivElement | undefined;
|
const containerRef = codeNodeEditorContainerRef.value;
|
||||||
|
|
||||||
if (!ref?.contains(fromElement)) this.isEditorHovered = false;
|
if (!containerRef?.contains(fromElement)) isEditorHovered.value = false;
|
||||||
},
|
}
|
||||||
reloadLinter() {
|
|
||||||
if (!this.editor) return;
|
|
||||||
|
|
||||||
const linter = this.createLinter(this.language);
|
function reloadLinter() {
|
||||||
|
if (!editor.value) return;
|
||||||
|
|
||||||
|
const linter = createLinter(props.language);
|
||||||
if (linter) {
|
if (linter) {
|
||||||
this.editor.dispatch({
|
editor.value.dispatch({
|
||||||
effects: this.linterCompartment.reconfigure(linter),
|
effects: linterCompartment.value.reconfigure(linter),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
refreshPlaceholder() {
|
|
||||||
if (!this.editor) return;
|
|
||||||
|
|
||||||
this.editor.dispatch({
|
function refreshPlaceholder() {
|
||||||
changes: { from: 0, to: this.getCurrentEditorContent().length, insert: this.placeholder },
|
if (!editor.value) return;
|
||||||
|
|
||||||
|
editor.value.dispatch({
|
||||||
|
changes: { from: 0, to: getCurrentEditorContent().length, insert: placeholder.value },
|
||||||
});
|
});
|
||||||
},
|
}
|
||||||
line(lineNumber: number): Line | null {
|
|
||||||
|
function getLine(lineNumber: number): Line | null {
|
||||||
try {
|
try {
|
||||||
return this.editor?.state.doc.line(lineNumber) ?? null;
|
return editor.value?.state.doc.line(lineNumber) ?? null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
highlightLine(lineNumber: number | 'final') {
|
|
||||||
if (!this.editor) return;
|
function highlightLine(lineNumber: number | 'final') {
|
||||||
|
if (!editor.value) return;
|
||||||
|
|
||||||
if (lineNumber === 'final') {
|
if (lineNumber === 'final') {
|
||||||
this.editor.dispatch({
|
editor.value.dispatch({
|
||||||
selection: { anchor: (this.modelValue ?? this.getCurrentEditorContent()).length },
|
selection: { anchor: (props.modelValue ?? getCurrentEditorContent()).length },
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const line = this.line(lineNumber);
|
const line = getLine(lineNumber);
|
||||||
|
|
||||||
if (!line) return;
|
if (!line) return;
|
||||||
|
|
||||||
this.editor.dispatch({
|
editor.value.dispatch({
|
||||||
selection: { anchor: line.from },
|
selection: { anchor: line.from },
|
||||||
});
|
});
|
||||||
},
|
}
|
||||||
trackCompletion(viewUpdate: ViewUpdate) {
|
|
||||||
|
function trackCompletion(viewUpdate: ViewUpdate) {
|
||||||
const completionTx = viewUpdate.transactions.find((tx) => tx.isUserEvent('input.complete'));
|
const completionTx = viewUpdate.transactions.find((tx) => tx.isUserEvent('input.complete'));
|
||||||
|
|
||||||
if (!completionTx) return;
|
if (!completionTx) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// @ts-ignore - undocumented fields
|
// @ts-expect-error - undocumented fields
|
||||||
const { fromA, toB } = viewUpdate?.changedRanges[0];
|
const { fromA, toB } = viewUpdate?.changedRanges[0];
|
||||||
const full = this.getCurrentEditorContent().slice(fromA, toB);
|
const full = getCurrentEditorContent().slice(fromA, toB);
|
||||||
const lastDotIndex = full.lastIndexOf('.');
|
const lastDotIndex = full.lastIndexOf('.');
|
||||||
|
|
||||||
let context = null;
|
let context = null;
|
||||||
|
@ -374,18 +373,24 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Still has to get updated for Python and JSON
|
// TODO: Still has to get updated for Python and JSON
|
||||||
this.$telemetry.track('User autocompleted code', {
|
telemetry.track('User autocompleted code', {
|
||||||
instance_id: this.rootStore.instanceId,
|
instance_id: rootStore.instanceId,
|
||||||
node_type: CODE_NODE_TYPE,
|
node_type: CODE_NODE_TYPE,
|
||||||
field_name: this.mode === 'runOnceForAllItems' ? 'jsCodeAllItems' : 'jsCodeEachItem',
|
field_name: props.mode === 'runOnceForAllItems' ? 'jsCodeAllItems' : 'jsCodeEachItem',
|
||||||
field_type: 'code',
|
field_type: 'code',
|
||||||
context,
|
context,
|
||||||
inserted_text: insertedText,
|
inserted_text: insertedText,
|
||||||
});
|
});
|
||||||
} catch {}
|
} catch {}
|
||||||
},
|
}
|
||||||
},
|
|
||||||
});
|
function onAiLoadStart() {
|
||||||
|
isLoadingAIResponse.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAiLoadEnd() {
|
||||||
|
isLoadingAIResponse.value = false;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
|
|
@ -1,15 +1,15 @@
|
||||||
import type { PropType } from 'vue';
|
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
|
||||||
import { defineComponent } from 'vue';
|
|
||||||
import { autocompletion } from '@codemirror/autocomplete';
|
import { autocompletion } from '@codemirror/autocomplete';
|
||||||
import { localCompletionSource } from '@codemirror/lang-javascript';
|
import { localCompletionSource } from '@codemirror/lang-javascript';
|
||||||
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
|
|
||||||
import type { Extension } from '@codemirror/state';
|
import type { Extension } from '@codemirror/state';
|
||||||
|
import type { MaybeRefOrGetter } from 'vue';
|
||||||
|
import { toValue } from 'vue';
|
||||||
|
|
||||||
import { useBaseCompletions } from './completions/base.completions';
|
import { useBaseCompletions } from './completions/base.completions';
|
||||||
import { jsSnippets } from './completions/js.snippets';
|
import { jsSnippets } from './completions/js.snippets';
|
||||||
|
|
||||||
|
import type { EditorView } from '@codemirror/view';
|
||||||
import type { CodeExecutionMode } from 'n8n-workflow';
|
import type { CodeExecutionMode } from 'n8n-workflow';
|
||||||
import { CODE_EXECUTION_MODES } from 'n8n-workflow';
|
|
||||||
import { useExecutionCompletions } from './completions/execution.completions';
|
import { useExecutionCompletions } from './completions/execution.completions';
|
||||||
import { useItemFieldCompletions } from './completions/itemField.completions';
|
import { useItemFieldCompletions } from './completions/itemField.completions';
|
||||||
import { useItemIndexCompletions } from './completions/itemIndex.completions';
|
import { useItemIndexCompletions } from './completions/itemIndex.completions';
|
||||||
|
@ -19,32 +19,20 @@ import { usePrevNodeCompletions } from './completions/prevNode.completions';
|
||||||
import { useRequireCompletions } from './completions/require.completions';
|
import { useRequireCompletions } from './completions/require.completions';
|
||||||
import { useVariablesCompletions } from './completions/variables.completions';
|
import { useVariablesCompletions } from './completions/variables.completions';
|
||||||
import { useWorkflowCompletions } from './completions/workflow.completions';
|
import { useWorkflowCompletions } from './completions/workflow.completions';
|
||||||
import type { EditorView } from '@codemirror/view';
|
|
||||||
|
|
||||||
export const completerExtension = defineComponent({
|
export const useCompleter = (
|
||||||
props: {
|
mode: MaybeRefOrGetter<CodeExecutionMode>,
|
||||||
mode: {
|
editor: MaybeRefOrGetter<EditorView | null>,
|
||||||
type: String as PropType<CodeExecutionMode>,
|
) => {
|
||||||
validator: (value: CodeExecutionMode): boolean => CODE_EXECUTION_MODES.includes(value),
|
function autocompletionExtension(language: 'javaScript' | 'python'): Extension {
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
editor: null as EditorView | null,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
autocompletionExtension(language: 'javaScript' | 'python'): Extension {
|
|
||||||
// Base completions
|
// Base completions
|
||||||
const { baseCompletions, itemCompletions, nodeSelectorCompletions } = useBaseCompletions(
|
const { baseCompletions, itemCompletions, nodeSelectorCompletions } = useBaseCompletions(
|
||||||
this.mode,
|
toValue(mode),
|
||||||
language,
|
language,
|
||||||
);
|
);
|
||||||
const { executionCompletions } = useExecutionCompletions();
|
const { executionCompletions } = useExecutionCompletions();
|
||||||
const { inputMethodCompletions, selectorMethodCompletions } =
|
const { inputMethodCompletions, selectorMethodCompletions } = useItemFieldCompletions(language);
|
||||||
useItemFieldCompletions(language);
|
const { inputCompletions, selectorCompletions } = useItemIndexCompletions(mode);
|
||||||
const { inputCompletions, selectorCompletions } = useItemIndexCompletions(this.mode);
|
|
||||||
const { inputJsonFieldCompletions, selectorJsonFieldCompletions } = useJsonFieldCompletions();
|
const { inputJsonFieldCompletions, selectorJsonFieldCompletions } = useJsonFieldCompletions();
|
||||||
const { dateTimeCompletions, nowCompletions, todayCompletions } = useLuxonCompletions();
|
const { dateTimeCompletions, nowCompletions, todayCompletions } = useLuxonCompletions();
|
||||||
const { prevNodeCompletions } = usePrevNodeCompletions();
|
const { prevNodeCompletions } = usePrevNodeCompletions();
|
||||||
|
@ -95,26 +83,27 @@ export const completerExtension = defineComponent({
|
||||||
selectorJsonFieldCompletions,
|
selectorJsonFieldCompletions,
|
||||||
|
|
||||||
// multiline
|
// multiline
|
||||||
this.multilineCompletions,
|
multilineCompletions,
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
},
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Complete uses of variables to any of the supported completions.
|
* Complete uses of variables to any of the supported completions.
|
||||||
*/
|
*/
|
||||||
multilineCompletions(context: CompletionContext): CompletionResult | null {
|
function multilineCompletions(context: CompletionContext): CompletionResult | null {
|
||||||
if (!this.editor) return null;
|
const editorValue = toValue(editor);
|
||||||
|
if (!editorValue) return null;
|
||||||
|
|
||||||
let variablesToValues: Record<string, string> = {};
|
let variablesToValueMap: Record<string, string> = {};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
variablesToValues = this.variablesToValues();
|
variablesToValueMap = variablesToValues();
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(variablesToValues).length === 0) return null;
|
if (Object.keys(variablesToValueMap).length === 0) return null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Complete uses of extended variables, i.e. variables having
|
* Complete uses of extended variables, i.e. variables having
|
||||||
|
@ -125,22 +114,22 @@ export const completerExtension = defineComponent({
|
||||||
* x.first().json. -> .field
|
* x.first().json. -> .field
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const docLines = this.editor.state.doc.toString().split('\n');
|
const docLines = editorValue.state.doc.toString().split('\n');
|
||||||
|
|
||||||
const varNames = Object.keys(variablesToValues);
|
const varNames = Object.keys(variablesToValueMap);
|
||||||
|
|
||||||
const uses = this.extendedUses(docLines, varNames);
|
const uses = extendedUses(docLines, varNames);
|
||||||
const { matcherItemFieldCompletions } = useItemFieldCompletions('javaScript');
|
const { matcherItemFieldCompletions } = useItemFieldCompletions('javaScript');
|
||||||
for (const use of uses.itemField) {
|
for (const use of uses.itemField) {
|
||||||
const matcher = use.replace(/\.$/, '');
|
const matcher = use.replace(/\.$/, '');
|
||||||
const completions = matcherItemFieldCompletions(context, matcher, variablesToValues);
|
const completions = matcherItemFieldCompletions(context, matcher, variablesToValueMap);
|
||||||
|
|
||||||
if (completions) return completions;
|
if (completions) return completions;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const use of uses.jsonField) {
|
for (const use of uses.jsonField) {
|
||||||
const matcher = use.replace(/(\.|\[)$/, '');
|
const matcher = use.replace(/(\.|\[)$/, '');
|
||||||
const completions = matcherItemFieldCompletions(context, matcher, variablesToValues);
|
const completions = matcherItemFieldCompletions(context, matcher, variablesToValueMap);
|
||||||
|
|
||||||
if (completions) return completions;
|
if (completions) return completions;
|
||||||
}
|
}
|
||||||
|
@ -190,13 +179,13 @@ export const completerExtension = defineComponent({
|
||||||
});
|
});
|
||||||
|
|
||||||
const { executionCompletions } = useExecutionCompletions();
|
const { executionCompletions } = useExecutionCompletions();
|
||||||
const { inputCompletions, selectorCompletions } = useItemIndexCompletions(this.mode);
|
const { inputCompletions, selectorCompletions } = useItemIndexCompletions(mode);
|
||||||
const { matcherJsonFieldCompletions } = useJsonFieldCompletions();
|
const { matcherJsonFieldCompletions } = useJsonFieldCompletions();
|
||||||
const { dateTimeCompletions, nowCompletions, todayCompletions } = useLuxonCompletions();
|
const { dateTimeCompletions, nowCompletions, todayCompletions } = useLuxonCompletions();
|
||||||
const { variablesCompletions } = useVariablesCompletions();
|
const { variablesCompletions } = useVariablesCompletions();
|
||||||
const { workflowCompletions } = useWorkflowCompletions();
|
const { workflowCompletions } = useWorkflowCompletions();
|
||||||
|
|
||||||
for (const [variable, value] of Object.entries(variablesToValues)) {
|
for (const [variable, value] of Object.entries(variablesToValueMap)) {
|
||||||
const { prevNodeCompletions } = usePrevNodeCompletions(variable);
|
const { prevNodeCompletions } = usePrevNodeCompletions(variable);
|
||||||
|
|
||||||
if (value === '$execution') return executionCompletions(context, variable);
|
if (value === '$execution') return executionCompletions(context, variable);
|
||||||
|
@ -222,7 +211,7 @@ export const completerExtension = defineComponent({
|
||||||
const selectorJsonMatched = SELECTOR_JSON_REGEXES.some((regex) => regex.test(value));
|
const selectorJsonMatched = SELECTOR_JSON_REGEXES.some((regex) => regex.test(value));
|
||||||
|
|
||||||
if (inputJsonMatched || selectorJsonMatched) {
|
if (inputJsonMatched || selectorJsonMatched) {
|
||||||
return matcherJsonFieldCompletions(context, variable, variablesToValues);
|
return matcherJsonFieldCompletions(context, variable, variablesToValueMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
// item field
|
// item field
|
||||||
|
@ -231,12 +220,12 @@ export const completerExtension = defineComponent({
|
||||||
const selectorMethodMatched = SELECTOR_METHOD_REGEXES.some((regex) => regex.test(value));
|
const selectorMethodMatched = SELECTOR_METHOD_REGEXES.some((regex) => regex.test(value));
|
||||||
|
|
||||||
if (inputMethodMatched || selectorMethodMatched) {
|
if (inputMethodMatched || selectorMethodMatched) {
|
||||||
return matcherItemFieldCompletions(context, variable, variablesToValues);
|
return matcherItemFieldCompletions(context, variable, variablesToValueMap);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
}
|
||||||
|
|
||||||
// ----------------------------------
|
// ----------------------------------
|
||||||
// helpers
|
// helpers
|
||||||
|
@ -245,8 +234,8 @@ export const completerExtension = defineComponent({
|
||||||
/**
|
/**
|
||||||
* Create a map of variables and the values they point to.
|
* Create a map of variables and the values they point to.
|
||||||
*/
|
*/
|
||||||
variablesToValues() {
|
function variablesToValues() {
|
||||||
return this.variableDeclarationLines().reduce<Record<string, string>>((acc, line) => {
|
return variableDeclarationLines().reduce<Record<string, string>>((acc, line) => {
|
||||||
const [left, right] = line.split('=');
|
const [left, right] = line.split('=');
|
||||||
|
|
||||||
const varName = left.replace(/(var|let|const)/, '').trim();
|
const varName = left.replace(/(var|let|const)/, '').trim();
|
||||||
|
@ -256,18 +245,19 @@ export const completerExtension = defineComponent({
|
||||||
|
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
},
|
}
|
||||||
|
|
||||||
variableDeclarationLines() {
|
function variableDeclarationLines() {
|
||||||
if (!this.editor) return [];
|
const editorValue = toValue(editor);
|
||||||
|
if (!editorValue) return [];
|
||||||
|
|
||||||
const docLines = this.editor.state.doc.toString().split('\n');
|
const docLines = editorValue.state.doc.toString().split('\n');
|
||||||
|
|
||||||
const isVariableDeclarationLine = (line: string) =>
|
const isVariableDeclarationLine = (line: string) =>
|
||||||
['var', 'const', 'let'].some((varType) => line.startsWith(varType));
|
['var', 'const', 'let'].some((varType) => line.startsWith(varType));
|
||||||
|
|
||||||
return docLines.filter(isVariableDeclarationLine);
|
return docLines.filter(isVariableDeclarationLine);
|
||||||
},
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collect uses of variables pointing to n8n syntax if they have been extended.
|
* Collect uses of variables pointing to n8n syntax if they have been extended.
|
||||||
|
@ -276,7 +266,7 @@ export const completerExtension = defineComponent({
|
||||||
* x.first().json.
|
* x.first().json.
|
||||||
* x.json.
|
* x.json.
|
||||||
*/
|
*/
|
||||||
extendedUses(docLines: string[], varNames: string[]) {
|
function extendedUses(docLines: string[], varNames: string[]) {
|
||||||
return docLines.reduce<{ itemField: string[]; jsonField: string[] }>(
|
return docLines.reduce<{ itemField: string[]; jsonField: string[] }>(
|
||||||
(acc, cur) => {
|
(acc, cur) => {
|
||||||
varNames.forEach((varName) => {
|
varNames.forEach((varName) => {
|
||||||
|
@ -305,6 +295,7 @@ export const completerExtension = defineComponent({
|
||||||
},
|
},
|
||||||
{ itemField: [], jsonField: [] },
|
{ itemField: [], jsonField: [] },
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
},
|
|
||||||
});
|
return { autocompletionExtension };
|
||||||
|
};
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
import { escape } from '../utils';
|
|
||||||
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
|
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
|
||||||
|
import type { CodeExecutionMode } from 'n8n-workflow';
|
||||||
|
import { toValue, type MaybeRefOrGetter } from 'vue';
|
||||||
|
import { escape } from '../utils';
|
||||||
|
|
||||||
export function useItemIndexCompletions(mode: 'runOnceForEachItem' | 'runOnceForAllItems') {
|
export function useItemIndexCompletions(mode: MaybeRefOrGetter<CodeExecutionMode>) {
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
/**
|
/**
|
||||||
* - Complete `$input.` to `.first() .last() .all() .itemMatching()` in all-items mode.
|
* - Complete `$input.` to `.first() .last() .all() .itemMatching()` in all-items mode.
|
||||||
|
@ -20,7 +22,7 @@ export function useItemIndexCompletions(mode: 'runOnceForEachItem' | 'runOnceFor
|
||||||
|
|
||||||
const options: Completion[] = [];
|
const options: Completion[] = [];
|
||||||
|
|
||||||
if (mode === 'runOnceForAllItems') {
|
if (toValue(mode) === 'runOnceForAllItems') {
|
||||||
options.push(
|
options.push(
|
||||||
{
|
{
|
||||||
label: `${matcher}.first()`,
|
label: `${matcher}.first()`,
|
||||||
|
@ -45,7 +47,7 @@ export function useItemIndexCompletions(mode: 'runOnceForEachItem' | 'runOnceFor
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === 'runOnceForEachItem') {
|
if (toValue(mode) === 'runOnceForEachItem') {
|
||||||
options.push({
|
options.push({
|
||||||
label: `${matcher}.item`,
|
label: `${matcher}.item`,
|
||||||
type: 'variable',
|
type: 'variable',
|
||||||
|
@ -97,7 +99,7 @@ export function useItemIndexCompletions(mode: 'runOnceForEachItem' | 'runOnceFor
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (mode === 'runOnceForAllItems') {
|
if (toValue(mode) === 'runOnceForAllItems') {
|
||||||
options.push(
|
options.push(
|
||||||
{
|
{
|
||||||
label: `${replacementBase}.first()`,
|
label: `${replacementBase}.first()`,
|
||||||
|
@ -122,7 +124,7 @@ export function useItemIndexCompletions(mode: 'runOnceForEachItem' | 'runOnceFor
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === 'runOnceForEachItem') {
|
if (toValue(mode) === 'runOnceForEachItem') {
|
||||||
options.push({
|
options.push({
|
||||||
label: `${replacementBase}.item`,
|
label: `${replacementBase}.item`,
|
||||||
type: 'variable',
|
type: 'variable',
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import type { Diagnostic } from '@codemirror/lint';
|
import type { Diagnostic } from '@codemirror/lint';
|
||||||
import { linter as createLinter } from '@codemirror/lint';
|
import { linter } from '@codemirror/lint';
|
||||||
import type { EditorView } from '@codemirror/view';
|
import type { EditorView } from '@codemirror/view';
|
||||||
import * as esprima from 'esprima-next';
|
import * as esprima from 'esprima-next';
|
||||||
import type { Node } from 'estree';
|
import type { Node } from 'estree';
|
||||||
import type { CodeExecutionMode, CodeNodeEditorLanguage } from 'n8n-workflow';
|
import type { CodeExecutionMode, CodeNodeEditorLanguage } from 'n8n-workflow';
|
||||||
import { type PropType, defineComponent } from 'vue';
|
import { toValue, type MaybeRefOrGetter } from 'vue';
|
||||||
|
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import {
|
import {
|
||||||
DEFAULT_LINTER_DELAY_IN_MS,
|
DEFAULT_LINTER_DELAY_IN_MS,
|
||||||
DEFAULT_LINTER_SEVERITY,
|
DEFAULT_LINTER_SEVERITY,
|
||||||
|
@ -14,24 +15,21 @@ import {
|
||||||
import type { RangeNode } from './types';
|
import type { RangeNode } from './types';
|
||||||
import { walk } from './utils';
|
import { walk } from './utils';
|
||||||
|
|
||||||
export const linterExtension = defineComponent({
|
export const useLinter = (
|
||||||
props: {
|
mode: MaybeRefOrGetter<CodeExecutionMode>,
|
||||||
mode: {
|
editor: MaybeRefOrGetter<EditorView | null>,
|
||||||
type: String as PropType<CodeExecutionMode>,
|
) => {
|
||||||
required: true,
|
const i18n = useI18n();
|
||||||
},
|
|
||||||
editor: { type: Object as PropType<EditorView | null>, default: null },
|
function createLinter(language: CodeNodeEditorLanguage) {
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
createLinter(language: CodeNodeEditorLanguage) {
|
|
||||||
switch (language) {
|
switch (language) {
|
||||||
case 'javaScript':
|
case 'javaScript':
|
||||||
return createLinter(this.lintSource, { delay: DEFAULT_LINTER_DELAY_IN_MS });
|
return linter(lintSource, { delay: DEFAULT_LINTER_DELAY_IN_MS });
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
},
|
}
|
||||||
|
|
||||||
lintSource(editorView: EditorView): Diagnostic[] {
|
function lintSource(editorView: EditorView): Diagnostic[] {
|
||||||
const doc = editorView.state.doc.toString();
|
const doc = editorView.state.doc.toString();
|
||||||
const script = `module.exports = async function() {${doc}\n}()`;
|
const script = `module.exports = async function() {${doc}\n}()`;
|
||||||
|
|
||||||
|
@ -59,7 +57,7 @@ export const linterExtension = defineComponent({
|
||||||
from: line.from,
|
from: line.from,
|
||||||
to: line.to,
|
to: line.to,
|
||||||
severity: DEFAULT_LINTER_SEVERITY,
|
severity: DEFAULT_LINTER_SEVERITY,
|
||||||
message: this.$locale.baseText('codeNodeEditor.linter.bothModes.syntaxError'),
|
message: i18n.baseText('codeNodeEditor.linter.bothModes.syntaxError'),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
} catch {
|
} catch {
|
||||||
|
@ -80,7 +78,7 @@ export const linterExtension = defineComponent({
|
||||||
* $input.item() -> $input.item
|
* $input.item() -> $input.item
|
||||||
*/
|
*/
|
||||||
|
|
||||||
if (this.mode === 'runOnceForEachItem') {
|
if (toValue(mode) === 'runOnceForEachItem') {
|
||||||
const isItemCall = (node: Node) =>
|
const isItemCall = (node: Node) =>
|
||||||
node.type === 'CallExpression' &&
|
node.type === 'CallExpression' &&
|
||||||
node.callee.type === 'MemberExpression' &&
|
node.callee.type === 'MemberExpression' &&
|
||||||
|
@ -88,13 +86,13 @@ export const linterExtension = defineComponent({
|
||||||
node.callee.property.name === 'item';
|
node.callee.property.name === 'item';
|
||||||
|
|
||||||
walk(ast, isItemCall).forEach((node) => {
|
walk(ast, isItemCall).forEach((node) => {
|
||||||
const [start, end] = this.getRange(node);
|
const [start, end] = getRange(node);
|
||||||
|
|
||||||
lintings.push({
|
lintings.push({
|
||||||
from: start,
|
from: start,
|
||||||
to: end,
|
to: end,
|
||||||
severity: DEFAULT_LINTER_SEVERITY,
|
severity: DEFAULT_LINTER_SEVERITY,
|
||||||
message: this.$locale.baseText('codeNodeEditor.linter.allItems.itemCall'),
|
message: i18n.baseText('codeNodeEditor.linter.allItems.itemCall'),
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
name: 'Fix',
|
name: 'Fix',
|
||||||
|
@ -113,20 +111,20 @@ export const linterExtension = defineComponent({
|
||||||
* $json -> <removed>
|
* $json -> <removed>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
if (this.mode === 'runOnceForAllItems') {
|
if (toValue(mode) === 'runOnceForAllItems') {
|
||||||
const isUnavailableVarInAllItems = (node: Node) =>
|
const isUnavailableVarInAllItems = (node: Node) =>
|
||||||
node.type === 'Identifier' && ['$json', '$binary', '$itemIndex'].includes(node.name);
|
node.type === 'Identifier' && ['$json', '$binary', '$itemIndex'].includes(node.name);
|
||||||
|
|
||||||
walk(ast, isUnavailableVarInAllItems).forEach((node) => {
|
walk(ast, isUnavailableVarInAllItems).forEach((node) => {
|
||||||
const [start, end] = this.getRange(node);
|
const [start, end] = getRange(node);
|
||||||
|
|
||||||
const varName = this.getText(node);
|
const varName = getText(node);
|
||||||
|
|
||||||
if (!varName) return;
|
if (!varName) return;
|
||||||
|
|
||||||
const message = [
|
const message = [
|
||||||
`\`${varName}\``,
|
`\`${varName}\``,
|
||||||
this.$locale.baseText('codeNodeEditor.linter.allItems.unavailableVar'),
|
i18n.baseText('codeNodeEditor.linter.allItems.unavailableVar'),
|
||||||
].join(' ');
|
].join(' ');
|
||||||
|
|
||||||
lintings.push({
|
lintings.push({
|
||||||
|
@ -152,7 +150,7 @@ export const linterExtension = defineComponent({
|
||||||
* $input.item -> <removed>
|
* $input.item -> <removed>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
if (this.mode === 'runOnceForAllItems') {
|
if (toValue(mode) === 'runOnceForAllItems') {
|
||||||
type TargetNode = RangeNode & { property: RangeNode };
|
type TargetNode = RangeNode & { property: RangeNode };
|
||||||
|
|
||||||
const isUnavailableInputItemAccess = (node: Node) =>
|
const isUnavailableInputItemAccess = (node: Node) =>
|
||||||
|
@ -164,13 +162,13 @@ export const linterExtension = defineComponent({
|
||||||
node.property.name === 'item';
|
node.property.name === 'item';
|
||||||
|
|
||||||
walk<TargetNode>(ast, isUnavailableInputItemAccess).forEach((node) => {
|
walk<TargetNode>(ast, isUnavailableInputItemAccess).forEach((node) => {
|
||||||
const [start, end] = this.getRange(node.property);
|
const [start, end] = getRange(node.property);
|
||||||
|
|
||||||
lintings.push({
|
lintings.push({
|
||||||
from: start,
|
from: start,
|
||||||
to: end,
|
to: end,
|
||||||
severity: DEFAULT_LINTER_SEVERITY,
|
severity: DEFAULT_LINTER_SEVERITY,
|
||||||
message: this.$locale.baseText('codeNodeEditor.linter.allItems.unavailableProperty'),
|
message: i18n.baseText('codeNodeEditor.linter.allItems.unavailableProperty'),
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
name: 'Remove',
|
name: 'Remove',
|
||||||
|
@ -189,20 +187,20 @@ export const linterExtension = defineComponent({
|
||||||
*
|
*
|
||||||
* items -> $input.item
|
* items -> $input.item
|
||||||
*/
|
*/
|
||||||
if (this.mode === 'runOnceForEachItem' && !/(let|const|var) items =/.test(script)) {
|
if (toValue(mode) === 'runOnceForEachItem' && !/(let|const|var) items =/.test(script)) {
|
||||||
type TargetNode = RangeNode & { object: RangeNode & { name: string } };
|
type TargetNode = RangeNode & { object: RangeNode & { name: string } };
|
||||||
|
|
||||||
const isUnavailableLegacyItems = (node: Node) =>
|
const isUnavailableLegacyItems = (node: Node) =>
|
||||||
node.type === 'Identifier' && node.name === 'items';
|
node.type === 'Identifier' && node.name === 'items';
|
||||||
|
|
||||||
walk<TargetNode>(ast, isUnavailableLegacyItems).forEach((node) => {
|
walk<TargetNode>(ast, isUnavailableLegacyItems).forEach((node) => {
|
||||||
const [start, end] = this.getRange(node);
|
const [start, end] = getRange(node);
|
||||||
|
|
||||||
lintings.push({
|
lintings.push({
|
||||||
from: start,
|
from: start,
|
||||||
to: end,
|
to: end,
|
||||||
severity: DEFAULT_LINTER_SEVERITY,
|
severity: DEFAULT_LINTER_SEVERITY,
|
||||||
message: this.$locale.baseText('codeNodeEditor.linter.eachItem.unavailableItems'),
|
message: i18n.baseText('codeNodeEditor.linter.eachItem.unavailableItems'),
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
name: 'Fix',
|
name: 'Fix',
|
||||||
|
@ -231,7 +229,7 @@ export const linterExtension = defineComponent({
|
||||||
* $input.itemMatching()
|
* $input.itemMatching()
|
||||||
*/
|
*/
|
||||||
|
|
||||||
if (this.mode === 'runOnceForEachItem') {
|
if (toValue(mode) === 'runOnceForEachItem') {
|
||||||
type TargetNode = RangeNode & { property: RangeNode & { name: string } };
|
type TargetNode = RangeNode & { property: RangeNode & { name: string } };
|
||||||
|
|
||||||
const isUnavailableMethodinEachItem = (node: Node) =>
|
const isUnavailableMethodinEachItem = (node: Node) =>
|
||||||
|
@ -243,9 +241,9 @@ export const linterExtension = defineComponent({
|
||||||
['first', 'last', 'all', 'itemMatching'].includes(node.property.name);
|
['first', 'last', 'all', 'itemMatching'].includes(node.property.name);
|
||||||
|
|
||||||
walk<TargetNode>(ast, isUnavailableMethodinEachItem).forEach((node) => {
|
walk<TargetNode>(ast, isUnavailableMethodinEachItem).forEach((node) => {
|
||||||
const [start, end] = this.getRange(node.property);
|
const [start, end] = getRange(node.property);
|
||||||
|
|
||||||
const method = this.getText(node.property);
|
const method = getText(node.property);
|
||||||
|
|
||||||
if (!method) return;
|
if (!method) return;
|
||||||
|
|
||||||
|
@ -253,7 +251,7 @@ export const linterExtension = defineComponent({
|
||||||
from: start,
|
from: start,
|
||||||
to: end,
|
to: end,
|
||||||
severity: DEFAULT_LINTER_SEVERITY,
|
severity: DEFAULT_LINTER_SEVERITY,
|
||||||
message: this.$locale.baseText('codeNodeEditor.linter.eachItem.unavailableMethod', {
|
message: i18n.baseText('codeNodeEditor.linter.eachItem.unavailableMethod', {
|
||||||
interpolate: { method },
|
interpolate: { method },
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
@ -266,7 +264,7 @@ export const linterExtension = defineComponent({
|
||||||
* $input.itemMatching()
|
* $input.itemMatching()
|
||||||
*/
|
*/
|
||||||
|
|
||||||
if (this.mode === 'runOnceForAllItems') {
|
if (toValue(mode) === 'runOnceForAllItems') {
|
||||||
type TargetNode = RangeNode & { callee: RangeNode & { property: RangeNode } };
|
type TargetNode = RangeNode & { callee: RangeNode & { property: RangeNode } };
|
||||||
|
|
||||||
const isItemMatchingCallWithoutArg = (node: Node) =>
|
const isItemMatchingCallWithoutArg = (node: Node) =>
|
||||||
|
@ -277,13 +275,13 @@ export const linterExtension = defineComponent({
|
||||||
node.arguments.length === 0;
|
node.arguments.length === 0;
|
||||||
|
|
||||||
walk<TargetNode>(ast, isItemMatchingCallWithoutArg).forEach((node) => {
|
walk<TargetNode>(ast, isItemMatchingCallWithoutArg).forEach((node) => {
|
||||||
const [start, end] = this.getRange(node.callee.property);
|
const [start, end] = getRange(node.callee.property);
|
||||||
|
|
||||||
lintings.push({
|
lintings.push({
|
||||||
from: start,
|
from: start,
|
||||||
to: end + '()'.length,
|
to: end + '()'.length,
|
||||||
severity: DEFAULT_LINTER_SEVERITY,
|
severity: DEFAULT_LINTER_SEVERITY,
|
||||||
message: this.$locale.baseText('codeNodeEditor.linter.allItems.itemMatchingNoArg'),
|
message: i18n.baseText('codeNodeEditor.linter.allItems.itemMatchingNoArg'),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -295,7 +293,7 @@ export const linterExtension = defineComponent({
|
||||||
* $input.last(arg) -> $input.last()
|
* $input.last(arg) -> $input.last()
|
||||||
*/
|
*/
|
||||||
|
|
||||||
if (this.mode === 'runOnceForAllItems') {
|
if (toValue(mode) === 'runOnceForAllItems') {
|
||||||
type TargetNode = RangeNode & {
|
type TargetNode = RangeNode & {
|
||||||
callee: { property: { name: string } & RangeNode };
|
callee: { property: { name: string } & RangeNode };
|
||||||
};
|
};
|
||||||
|
@ -311,11 +309,11 @@ export const linterExtension = defineComponent({
|
||||||
node.arguments.length !== 0;
|
node.arguments.length !== 0;
|
||||||
|
|
||||||
walk<TargetNode>(ast, inputFirstOrLastCalledWithArg).forEach((node) => {
|
walk<TargetNode>(ast, inputFirstOrLastCalledWithArg).forEach((node) => {
|
||||||
const [start, end] = this.getRange(node.callee.property);
|
const [start, end] = getRange(node.callee.property);
|
||||||
|
|
||||||
const message = [
|
const message = [
|
||||||
`\`$input.${node.callee.property.name}()\``,
|
`\`$input.${node.callee.property.name}()\``,
|
||||||
this.$locale.baseText('codeNodeEditor.linter.allItems.firstOrLastCalledWithArg'),
|
i18n.baseText('codeNodeEditor.linter.allItems.firstOrLastCalledWithArg'),
|
||||||
].join(' ');
|
].join(' ');
|
||||||
|
|
||||||
lintings.push({
|
lintings.push({
|
||||||
|
@ -333,13 +331,12 @@ export const linterExtension = defineComponent({
|
||||||
* return -> <no autofix>
|
* return -> <no autofix>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const isEmptyReturn = (node: Node) =>
|
const isEmptyReturn = (node: Node) => node.type === 'ReturnStatement' && node.argument === null;
|
||||||
node.type === 'ReturnStatement' && node.argument === null;
|
|
||||||
|
|
||||||
const emptyReturnMessage =
|
const emptyReturnMessage =
|
||||||
this.mode === 'runOnceForAllItems'
|
toValue(mode) === 'runOnceForAllItems'
|
||||||
? this.$locale.baseText('codeNodeEditor.linter.allItems.emptyReturn')
|
? i18n.baseText('codeNodeEditor.linter.allItems.emptyReturn')
|
||||||
: this.$locale.baseText('codeNodeEditor.linter.eachItem.emptyReturn');
|
: i18n.baseText('codeNodeEditor.linter.eachItem.emptyReturn');
|
||||||
|
|
||||||
walk<RangeNode>(ast, isEmptyReturn).forEach((node) => {
|
walk<RangeNode>(ast, isEmptyReturn).forEach((node) => {
|
||||||
const [start, end] = node.range.map((loc) => loc - OFFSET_FOR_SCRIPT_WRAPPER);
|
const [start, end] = node.range.map((loc) => loc - OFFSET_FOR_SCRIPT_WRAPPER);
|
||||||
|
@ -358,7 +355,7 @@ export const linterExtension = defineComponent({
|
||||||
* return [] -> <no autofix>
|
* return [] -> <no autofix>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
if (this.mode === 'runOnceForEachItem') {
|
if (toValue(mode) === 'runOnceForEachItem') {
|
||||||
const isArrayReturn = (node: Node) =>
|
const isArrayReturn = (node: Node) =>
|
||||||
node.type === 'ReturnStatement' &&
|
node.type === 'ReturnStatement' &&
|
||||||
node.argument !== null &&
|
node.argument !== null &&
|
||||||
|
@ -366,13 +363,13 @@ export const linterExtension = defineComponent({
|
||||||
node.argument.type === 'ArrayExpression';
|
node.argument.type === 'ArrayExpression';
|
||||||
|
|
||||||
walk<RangeNode>(ast, isArrayReturn).forEach((node) => {
|
walk<RangeNode>(ast, isArrayReturn).forEach((node) => {
|
||||||
const [start, end] = this.getRange(node);
|
const [start, end] = getRange(node);
|
||||||
|
|
||||||
lintings.push({
|
lintings.push({
|
||||||
from: start,
|
from: start,
|
||||||
to: end,
|
to: end,
|
||||||
severity: DEFAULT_LINTER_SEVERITY,
|
severity: DEFAULT_LINTER_SEVERITY,
|
||||||
message: this.$locale.baseText('codeNodeEditor.linter.eachItem.returnArray'),
|
message: i18n.baseText('codeNodeEditor.linter.eachItem.returnArray'),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -385,7 +382,7 @@ export const linterExtension = defineComponent({
|
||||||
* const a = item.myField -> const a = item.json.myField;
|
* const a = item.myField -> const a = item.json.myField;
|
||||||
*/
|
*/
|
||||||
|
|
||||||
if (this.mode === 'runOnceForAllItems') {
|
if (toValue(mode) === 'runOnceForAllItems') {
|
||||||
type TargetNode = RangeNode & {
|
type TargetNode = RangeNode & {
|
||||||
left: { declarations: Array<{ id: { type: string; name: string } }> };
|
left: { declarations: Array<{ id: { type: string; name: string } }> };
|
||||||
};
|
};
|
||||||
|
@ -424,7 +421,7 @@ export const linterExtension = defineComponent({
|
||||||
|
|
||||||
if (shadowFound.length > 0) {
|
if (shadowFound.length > 0) {
|
||||||
const [shadow] = shadowFound;
|
const [shadow] = shadowFound;
|
||||||
const [_shadowStart] = this.getRange(shadow);
|
const [_shadowStart] = getRange(shadow);
|
||||||
shadowStart = _shadowStart;
|
shadowStart = _shadowStart;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -436,11 +433,11 @@ export const linterExtension = defineComponent({
|
||||||
!['json', 'binary'].includes(node.property.name);
|
!['json', 'binary'].includes(node.property.name);
|
||||||
|
|
||||||
walk(ast, isDirectAccessToItem).forEach((node) => {
|
walk(ast, isDirectAccessToItem).forEach((node) => {
|
||||||
const [start, end] = this.getRange(node);
|
const [start, end] = getRange(node);
|
||||||
|
|
||||||
if (shadowStart && start > shadowStart) return; // skip shadow item
|
if (shadowStart && start > shadowStart) return; // skip shadow item
|
||||||
|
|
||||||
const varName = this.getText(node);
|
const varName = getText(node);
|
||||||
|
|
||||||
if (!varName) return;
|
if (!varName) return;
|
||||||
|
|
||||||
|
@ -448,9 +445,7 @@ export const linterExtension = defineComponent({
|
||||||
from: start,
|
from: start,
|
||||||
to: end,
|
to: end,
|
||||||
severity: DEFAULT_LINTER_SEVERITY,
|
severity: DEFAULT_LINTER_SEVERITY,
|
||||||
message: this.$locale.baseText(
|
message: i18n.baseText('codeNodeEditor.linter.bothModes.directAccess.itemProperty'),
|
||||||
'codeNodeEditor.linter.bothModes.directAccess.itemProperty',
|
|
||||||
),
|
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
name: 'Fix',
|
name: 'Fix',
|
||||||
|
@ -475,7 +470,7 @@ export const linterExtension = defineComponent({
|
||||||
* const a = $input.item.myField -> const a = $input.item.json.myField;
|
* const a = $input.item.myField -> const a = $input.item.json.myField;
|
||||||
*/
|
*/
|
||||||
|
|
||||||
if (this.mode === 'runOnceForEachItem') {
|
if (toValue(mode) === 'runOnceForEachItem') {
|
||||||
type TargetNode = RangeNode & { object: { property: RangeNode } };
|
type TargetNode = RangeNode & { object: { property: RangeNode } };
|
||||||
|
|
||||||
const isDirectAccessToItemSubproperty = (node: Node) =>
|
const isDirectAccessToItemSubproperty = (node: Node) =>
|
||||||
|
@ -487,21 +482,19 @@ export const linterExtension = defineComponent({
|
||||||
!['json', 'binary'].includes(node.property.name);
|
!['json', 'binary'].includes(node.property.name);
|
||||||
|
|
||||||
walk<TargetNode>(ast, isDirectAccessToItemSubproperty).forEach((node) => {
|
walk<TargetNode>(ast, isDirectAccessToItemSubproperty).forEach((node) => {
|
||||||
const varName = this.getText(node);
|
const varName = getText(node);
|
||||||
|
|
||||||
if (!varName) return;
|
if (!varName) return;
|
||||||
|
|
||||||
const [start, end] = this.getRange(node);
|
const [start, end] = getRange(node);
|
||||||
|
|
||||||
const [_, fixEnd] = this.getRange(node.object.property);
|
const [, fixEnd] = getRange(node.object.property);
|
||||||
|
|
||||||
lintings.push({
|
lintings.push({
|
||||||
from: start,
|
from: start,
|
||||||
to: end,
|
to: end,
|
||||||
severity: DEFAULT_LINTER_SEVERITY,
|
severity: DEFAULT_LINTER_SEVERITY,
|
||||||
message: this.$locale.baseText(
|
message: i18n.baseText('codeNodeEditor.linter.bothModes.directAccess.itemProperty'),
|
||||||
'codeNodeEditor.linter.bothModes.directAccess.itemProperty',
|
|
||||||
),
|
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
name: 'Fix',
|
name: 'Fix',
|
||||||
|
@ -536,17 +529,15 @@ export const linterExtension = defineComponent({
|
||||||
['first', 'last'].includes(node.object.callee.property.name);
|
['first', 'last'].includes(node.object.callee.property.name);
|
||||||
|
|
||||||
walk<TargetNode>(ast, isDirectAccessToFirstOrLastCall).forEach((node) => {
|
walk<TargetNode>(ast, isDirectAccessToFirstOrLastCall).forEach((node) => {
|
||||||
const [start, end] = this.getRange(node);
|
const [start, end] = getRange(node);
|
||||||
|
|
||||||
const [_, fixEnd] = this.getRange(node.object);
|
const [, fixEnd] = getRange(node.object);
|
||||||
|
|
||||||
lintings.push({
|
lintings.push({
|
||||||
from: start,
|
from: start,
|
||||||
to: end,
|
to: end,
|
||||||
severity: DEFAULT_LINTER_SEVERITY,
|
severity: DEFAULT_LINTER_SEVERITY,
|
||||||
message: this.$locale.baseText(
|
message: i18n.baseText('codeNodeEditor.linter.bothModes.directAccess.firstOrLastCall'),
|
||||||
'codeNodeEditor.linter.bothModes.directAccess.firstOrLastCall',
|
|
||||||
),
|
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
name: 'Fix',
|
name: 'Fix',
|
||||||
|
@ -562,22 +553,25 @@ export const linterExtension = defineComponent({
|
||||||
});
|
});
|
||||||
|
|
||||||
return lintings;
|
return lintings;
|
||||||
},
|
}
|
||||||
|
|
||||||
// ----------------------------------
|
// ----------------------------------
|
||||||
// helpers
|
// helpers
|
||||||
// ----------------------------------
|
// ----------------------------------
|
||||||
|
|
||||||
getText(node: RangeNode) {
|
function getText(node: RangeNode) {
|
||||||
if (!this.editor) return null;
|
const editorValue = toValue(editor);
|
||||||
|
|
||||||
const [start, end] = this.getRange(node);
|
if (!editorValue) return null;
|
||||||
|
|
||||||
return this.editor.state.doc.toString().slice(start, end);
|
const [start, end] = getRange(node);
|
||||||
},
|
|
||||||
|
|
||||||
getRange(node: RangeNode) {
|
return editorValue.state.doc.toString().slice(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRange(node: RangeNode) {
|
||||||
return node.range.map((loc) => loc - OFFSET_FOR_SCRIPT_WRAPPER);
|
return node.range.map((loc) => loc - OFFSET_FOR_SCRIPT_WRAPPER);
|
||||||
},
|
}
|
||||||
},
|
|
||||||
});
|
return { createLinter };
|
||||||
|
};
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
<template>
|
<template>
|
||||||
<div :class="$style.editor">
|
<div :class="$style.editor">
|
||||||
<div ref="jsEditor" class="ph-no-capture js-editor"></div>
|
<div ref="jsEditorRef" class="ph-no-capture js-editor"></div>
|
||||||
<slot name="suffix" />
|
<slot name="suffix" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { history, toggleComment } from '@codemirror/commands';
|
import { history, toggleComment } from '@codemirror/commands';
|
||||||
import { javascript } from '@codemirror/lang-javascript';
|
import { javascript } from '@codemirror/lang-javascript';
|
||||||
import { foldGutter, indentOnInput } from '@codemirror/language';
|
import { foldGutter, indentOnInput } from '@codemirror/language';
|
||||||
|
@ -21,9 +21,8 @@ import {
|
||||||
keymap,
|
keymap,
|
||||||
lineNumbers,
|
lineNumbers,
|
||||||
} from '@codemirror/view';
|
} from '@codemirror/view';
|
||||||
import { defineComponent } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
|
|
||||||
import {
|
import {
|
||||||
autocompleteKeyMap,
|
autocompleteKeyMap,
|
||||||
enterKeyMap,
|
enterKeyMap,
|
||||||
|
@ -31,54 +30,48 @@ import {
|
||||||
tabKeyMap,
|
tabKeyMap,
|
||||||
} from '@/plugins/codemirror/keymap';
|
} from '@/plugins/codemirror/keymap';
|
||||||
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
|
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
|
||||||
|
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
|
||||||
|
|
||||||
export default defineComponent({
|
type Props = {
|
||||||
name: 'JsEditor',
|
modelValue: string;
|
||||||
props: {
|
isReadOnly?: boolean;
|
||||||
modelValue: {
|
fillParent?: boolean;
|
||||||
type: String,
|
rows?: number;
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
isReadOnly: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
fillParent: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
rows: {
|
|
||||||
type: Number,
|
|
||||||
default: 4,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
editor: null as EditorView | null,
|
|
||||||
editorState: null as EditorState | null,
|
|
||||||
};
|
};
|
||||||
},
|
|
||||||
computed: {
|
const props = withDefaults(defineProps<Props>(), { fillParent: false, isReadOnly: false, rows: 4 });
|
||||||
doc(): string {
|
const emit = defineEmits<{
|
||||||
return this.editor?.state.doc.toString() ?? '';
|
(event: 'update:modelValue', value: string): void;
|
||||||
},
|
}>();
|
||||||
extensions(): Extension[] {
|
|
||||||
const { isReadOnly } = this;
|
onMounted(() => {
|
||||||
const extensions: Extension[] = [
|
const state = EditorState.create({ doc: props.modelValue, extensions: extensions.value });
|
||||||
|
const parent = jsEditorRef.value;
|
||||||
|
editor.value = new EditorView({ parent, state });
|
||||||
|
editorState.value = editor.value.state;
|
||||||
|
});
|
||||||
|
|
||||||
|
const jsEditorRef = ref<HTMLDivElement>();
|
||||||
|
const editor = ref<EditorView | null>(null);
|
||||||
|
const editorState = ref<EditorState | null>(null);
|
||||||
|
|
||||||
|
const extensions = computed(() => {
|
||||||
|
const extensionsToApply: Extension[] = [
|
||||||
javascript(),
|
javascript(),
|
||||||
lineNumbers(),
|
lineNumbers(),
|
||||||
EditorView.lineWrapping,
|
EditorView.lineWrapping,
|
||||||
EditorState.readOnly.of(isReadOnly),
|
EditorState.readOnly.of(props.isReadOnly),
|
||||||
EditorView.editable.of(!isReadOnly),
|
EditorView.editable.of(!props.isReadOnly),
|
||||||
codeNodeEditorTheme({
|
codeNodeEditorTheme({
|
||||||
isReadOnly,
|
isReadOnly: props.isReadOnly,
|
||||||
maxHeight: this.fillParent ? '100%' : '40vh',
|
maxHeight: props.fillParent ? '100%' : '40vh',
|
||||||
minHeight: '20vh',
|
minHeight: '20vh',
|
||||||
rows: this.rows,
|
rows: props.rows,
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
if (!isReadOnly) {
|
|
||||||
extensions.push(
|
if (!props.isReadOnly) {
|
||||||
|
extensionsToApply.push(
|
||||||
history(),
|
history(),
|
||||||
Prec.highest(
|
Prec.highest(
|
||||||
keymap.of([
|
keymap.of([
|
||||||
|
@ -97,20 +90,12 @@ export default defineComponent({
|
||||||
foldGutter(),
|
foldGutter(),
|
||||||
dropCursor(),
|
dropCursor(),
|
||||||
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
|
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
|
||||||
if (!viewUpdate.docChanged || !this.editor) return;
|
if (!viewUpdate.docChanged || !editor.value) return;
|
||||||
this.$emit('update:modelValue', this.editor?.state.doc.toString());
|
emit('update:modelValue', editor.value?.state.doc.toString());
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return extensions;
|
return extensionsToApply;
|
||||||
},
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
const state = EditorState.create({ doc: this.modelValue, extensions: this.extensions });
|
|
||||||
const parent = this.$refs.jsEditor as HTMLDivElement;
|
|
||||||
this.editor = new EditorView({ parent, state });
|
|
||||||
this.editorState = this.editor.state;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
<template>
|
<template>
|
||||||
<div :class="$style.editor">
|
<div :class="$style.editor">
|
||||||
<div ref="jsonEditor" class="ph-no-capture json-editor"></div>
|
<div ref="jsonEditorRef" class="ph-no-capture json-editor"></div>
|
||||||
<slot name="suffix" />
|
<slot name="suffix" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { history } from '@codemirror/commands';
|
import { history } from '@codemirror/commands';
|
||||||
import { json, jsonParseLinter } from '@codemirror/lang-json';
|
import { json, jsonParseLinter } from '@codemirror/lang-json';
|
||||||
import { bracketMatching, foldGutter, indentOnInput } from '@codemirror/language';
|
import { bracketMatching, foldGutter, indentOnInput } from '@codemirror/language';
|
||||||
|
@ -21,7 +21,6 @@ import {
|
||||||
keymap,
|
keymap,
|
||||||
lineNumbers,
|
lineNumbers,
|
||||||
} from '@codemirror/view';
|
} from '@codemirror/view';
|
||||||
import { defineComponent } from 'vue';
|
|
||||||
|
|
||||||
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
|
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
|
||||||
import {
|
import {
|
||||||
|
@ -31,54 +30,40 @@ import {
|
||||||
tabKeyMap,
|
tabKeyMap,
|
||||||
} from '@/plugins/codemirror/keymap';
|
} from '@/plugins/codemirror/keymap';
|
||||||
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
|
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
export default defineComponent({
|
type Props = {
|
||||||
name: 'JsonEditor',
|
modelValue: string;
|
||||||
props: {
|
isReadOnly?: boolean;
|
||||||
modelValue: {
|
fillParent?: boolean;
|
||||||
type: String,
|
rows?: number;
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
isReadOnly: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
fillParent: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
rows: {
|
|
||||||
type: Number,
|
|
||||||
default: 4,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
editor: null as EditorView | null,
|
|
||||||
editorState: null as EditorState | null,
|
|
||||||
};
|
};
|
||||||
},
|
|
||||||
computed: {
|
const props = withDefaults(defineProps<Props>(), { fillParent: false, isReadOnly: false, rows: 4 });
|
||||||
doc(): string {
|
const emit = defineEmits<{
|
||||||
return this.editor?.state.doc.toString() ?? '';
|
(event: 'update:modelValue', value: string): void;
|
||||||
},
|
}>();
|
||||||
extensions(): Extension[] {
|
|
||||||
const { isReadOnly } = this;
|
const jsonEditorRef = ref<HTMLDivElement>();
|
||||||
const extensions: Extension[] = [
|
const editor = ref<EditorView | null>(null);
|
||||||
|
const editorState = ref<EditorState | null>(null);
|
||||||
|
|
||||||
|
const extensions = computed(() => {
|
||||||
|
const extensionsToApply: Extension[] = [
|
||||||
json(),
|
json(),
|
||||||
lineNumbers(),
|
lineNumbers(),
|
||||||
EditorView.lineWrapping,
|
EditorView.lineWrapping,
|
||||||
EditorState.readOnly.of(isReadOnly),
|
EditorState.readOnly.of(props.isReadOnly),
|
||||||
EditorView.editable.of(!isReadOnly),
|
EditorView.editable.of(!props.isReadOnly),
|
||||||
codeNodeEditorTheme({
|
codeNodeEditorTheme({
|
||||||
isReadOnly,
|
isReadOnly: props.isReadOnly,
|
||||||
maxHeight: this.fillParent ? '100%' : '40vh',
|
maxHeight: props.fillParent ? '100%' : '40vh',
|
||||||
minHeight: '20vh',
|
minHeight: '20vh',
|
||||||
rows: this.rows,
|
rows: props.rows,
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
if (!isReadOnly) {
|
if (!props.isReadOnly) {
|
||||||
extensions.push(
|
extensionsToApply.push(
|
||||||
history(),
|
history(),
|
||||||
Prec.highest(
|
Prec.highest(
|
||||||
keymap.of([...tabKeyMap(), ...enterKeyMap, ...historyKeyMap, ...autocompleteKeyMap]),
|
keymap.of([...tabKeyMap(), ...enterKeyMap, ...historyKeyMap, ...autocompleteKeyMap]),
|
||||||
|
@ -93,41 +78,42 @@ export default defineComponent({
|
||||||
dropCursor(),
|
dropCursor(),
|
||||||
bracketMatching(),
|
bracketMatching(),
|
||||||
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
|
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
|
||||||
if (!viewUpdate.docChanged || !this.editor) return;
|
if (!viewUpdate.docChanged || !editor.value) return;
|
||||||
this.$emit('update:modelValue', this.editor?.state.doc.toString());
|
emit('update:modelValue', editor.value?.state.doc.toString());
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return extensions;
|
return extensionsToApply;
|
||||||
},
|
});
|
||||||
},
|
|
||||||
watch: {
|
onMounted(() => {
|
||||||
modelValue(newValue: string) {
|
createEditor();
|
||||||
const editorValue = this.editor?.state?.doc.toString();
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(newValue: string) => {
|
||||||
|
const editorValue = editor.value?.state?.doc.toString();
|
||||||
|
|
||||||
// If model value changes from outside the component
|
// If model value changes from outside the component
|
||||||
if (editorValue && editorValue.length !== newValue.length && editorValue !== newValue) {
|
if (editorValue && editorValue.length !== newValue.length && editorValue !== newValue) {
|
||||||
this.destroyEditor();
|
destroyEditor();
|
||||||
this.createEditor();
|
createEditor();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
);
|
||||||
mounted() {
|
|
||||||
this.createEditor();
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
createEditor() {
|
|
||||||
const state = EditorState.create({ doc: this.modelValue, extensions: this.extensions });
|
|
||||||
const parent = this.$refs.jsonEditor as HTMLDivElement;
|
|
||||||
|
|
||||||
this.editor = new EditorView({ parent, state });
|
function createEditor() {
|
||||||
this.editorState = this.editor.state;
|
const state = EditorState.create({ doc: props.modelValue, extensions: extensions.value });
|
||||||
},
|
const parent = jsonEditorRef.value;
|
||||||
destroyEditor() {
|
|
||||||
this.editor?.destroy();
|
editor.value = new EditorView({ parent, state });
|
||||||
},
|
editorState.value = editor.value.state;
|
||||||
},
|
}
|
||||||
});
|
|
||||||
|
function destroyEditor() {
|
||||||
|
editor.value?.destroy();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
|
|
Loading…
Reference in a new issue