mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-13 16:14:07 -08:00
feat(editor): Enable drag and drop in code editors (Code/SQL/HTML) (#10888)
This commit is contained in:
parent
ed91495ebc
commit
af9e227ad4
|
@ -10,7 +10,7 @@ import type { CodeExecutionMode, CodeNodeEditorLanguage } 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 { type Ref, computed, nextTick, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue';
|
||||
|
||||
import { CODE_NODE_TYPE } from '@/constants';
|
||||
import { codeNodeEditorEventBus } from '@/event-bus';
|
||||
|
@ -26,6 +26,7 @@ import { useLinter } from './linter';
|
|||
import { codeNodeEditorTheme } from './theme';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { dropInCodeEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
|
||||
|
||||
type Props = {
|
||||
mode: CodeExecutionMode;
|
||||
|
@ -51,6 +52,7 @@ const emit = defineEmits<{
|
|||
const message = useMessage();
|
||||
const editor = ref(null) as Ref<EditorView | null>;
|
||||
const languageCompartment = ref(new Compartment());
|
||||
const dragAndDropCompartment = ref(new Compartment());
|
||||
const linterCompartment = ref(new Compartment());
|
||||
const isEditorHovered = ref(false);
|
||||
const isEditorFocused = ref(false);
|
||||
|
@ -95,6 +97,7 @@ onMounted(() => {
|
|||
|
||||
extensions.push(
|
||||
...writableEditorExtensions,
|
||||
dragAndDropCompartment.value.of(dragAndDropExtension.value),
|
||||
EditorView.domEventHandlers({
|
||||
focus: () => {
|
||||
isEditorFocused.value = true;
|
||||
|
@ -151,6 +154,12 @@ const placeholder = computed(() => {
|
|||
return CODE_PLACEHOLDERS[props.language]?.[props.mode] ?? '';
|
||||
});
|
||||
|
||||
const dragAndDropEnabled = computed(() => {
|
||||
return !props.isReadOnly && props.mode === 'runOnceForEachItem';
|
||||
});
|
||||
|
||||
const dragAndDropExtension = computed(() => (dragAndDropEnabled.value ? mappingDropCursor() : []));
|
||||
|
||||
// eslint-disable-next-line vue/return-in-computed-property
|
||||
const languageExtensions = computed<[LanguageSupport, ...Extension[]]>(() => {
|
||||
switch (props.language) {
|
||||
|
@ -188,6 +197,12 @@ watch(
|
|||
},
|
||||
);
|
||||
|
||||
watch(dragAndDropExtension, (extension) => {
|
||||
editor.value?.dispatch({
|
||||
effects: dragAndDropCompartment.value.reconfigure(extension),
|
||||
});
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.language,
|
||||
(_newLanguage, previousLanguage: CodeNodeEditorLanguage) => {
|
||||
|
@ -202,7 +217,6 @@ watch(
|
|||
reloadLinter();
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
aiEnabled,
|
||||
async (isEnabled) => {
|
||||
|
@ -361,6 +375,12 @@ function onAiLoadStart() {
|
|||
function onAiLoadEnd() {
|
||||
isLoadingAIResponse.value = false;
|
||||
}
|
||||
|
||||
async function onDrop(value: string, event: MouseEvent) {
|
||||
if (!editor.value) return;
|
||||
|
||||
await dropInCodeEditor(toRaw(editor.value), event, value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -384,10 +404,20 @@ function onAiLoadEnd() {
|
|||
data-test-id="code-node-tab-code"
|
||||
:class="$style.fillHeight"
|
||||
>
|
||||
<div
|
||||
ref="codeNodeEditorRef"
|
||||
:class="['ph-no-capture', 'code-editor-tabs', $style.editorInput, $style.fillHeight]"
|
||||
/>
|
||||
<DraggableTarget type="mapping" :disabled="!dragAndDropEnabled" @drop="onDrop">
|
||||
<template #default="{ activeDrop, droppable }">
|
||||
<div
|
||||
ref="codeNodeEditorRef"
|
||||
:class="[
|
||||
'ph-no-capture',
|
||||
'code-editor-tabs',
|
||||
$style.editorInput,
|
||||
$style.fillHeight,
|
||||
{ [$style.activeDrop]: activeDrop, [$style.droppable]: droppable },
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</DraggableTarget>
|
||||
<slot name="suffix" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane
|
||||
|
@ -407,7 +437,19 @@ function onAiLoadEnd() {
|
|||
</el-tabs>
|
||||
<!-- If AskAi not enabled, there's no point in rendering tabs -->
|
||||
<div v-else :class="$style.fillHeight">
|
||||
<div ref="codeNodeEditorRef" :class="['ph-no-capture', $style.fillHeight]" />
|
||||
<DraggableTarget type="mapping" :disabled="!dragAndDropEnabled" @drop="onDrop">
|
||||
<template #default="{ activeDrop, droppable }">
|
||||
<div
|
||||
ref="codeNodeEditorRef"
|
||||
:class="[
|
||||
'ph-no-capture',
|
||||
$style.fillHeight,
|
||||
$style.editorInput,
|
||||
{ [$style.activeDrop]: activeDrop, [$style.droppable]: droppable },
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</DraggableTarget>
|
||||
<slot name="suffix" />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -415,7 +457,7 @@ function onAiLoadEnd() {
|
|||
|
||||
<style scoped lang="scss">
|
||||
:deep(.el-tabs) {
|
||||
.code-editor-tabs .cm-editor {
|
||||
.cm-editor {
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
@ -454,4 +496,21 @@ function onAiLoadEnd() {
|
|||
.fillHeight {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.editorInput.droppable {
|
||||
:global(.cm-editor) {
|
||||
border-color: var(--color-ndv-droppable-parameter);
|
||||
border-style: dashed;
|
||||
border-width: 1.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.editorInput.activeDrop {
|
||||
:global(.cm-editor) {
|
||||
border-color: var(--color-success);
|
||||
border-style: solid;
|
||||
cursor: grabbing;
|
||||
border-width: 1px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -19,7 +19,7 @@ import OutputItemSelect from './InlineExpressionEditor/OutputItemSelect.vue';
|
|||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useDebounce } from '@/composables/useDebounce';
|
||||
import DraggableTarget from './DraggableTarget.vue';
|
||||
import { dropInEditor } from '@/plugins/codemirror/dragAndDrop';
|
||||
import { dropInExpressionEditor } from '@/plugins/codemirror/dragAndDrop';
|
||||
|
||||
import { APP_MODALS_ELEMENT_ID } from '@/constants';
|
||||
|
||||
|
@ -119,7 +119,7 @@ function closeDialog() {
|
|||
async function onDrop(expression: string, event: MouseEvent) {
|
||||
if (!inputEditor.value) return;
|
||||
|
||||
await dropInEditor(toRaw(inputEditor.value), event, expression);
|
||||
await dropInExpressionEditor(toRaw(inputEditor.value), event, expression);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
|
|||
import { createExpressionTelemetryPayload } from '@/utils/telemetryUtils';
|
||||
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { dropInEditor } from '@/plugins/codemirror/dragAndDrop';
|
||||
import { dropInExpressionEditor } from '@/plugins/codemirror/dragAndDrop';
|
||||
import type { Segment } from '@/types/expressions';
|
||||
import { startCompletion } from '@codemirror/autocomplete';
|
||||
import type { EditorState, SelectionRange } from '@codemirror/state';
|
||||
|
@ -119,7 +119,7 @@ async function onDrop(value: string, event: MouseEvent) {
|
|||
|
||||
if (!editor) return;
|
||||
|
||||
const droppedSelection = await dropInEditor(toRaw(editor), event, value);
|
||||
const droppedSelection = await dropInExpressionEditor(toRaw(editor), event, value);
|
||||
|
||||
if (!ndvStore.isMappingOnboarded) ndvStore.setMappingOnboarded();
|
||||
|
||||
|
|
|
@ -20,7 +20,7 @@ import jsParser from 'prettier/plugins/babel';
|
|||
import * as estree from 'prettier/plugins/estree';
|
||||
import htmlParser from 'prettier/plugins/html';
|
||||
import cssParser from 'prettier/plugins/postcss';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, toValue, watch } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, toRaw, toValue, watch } from 'vue';
|
||||
|
||||
import { htmlEditorEventBus } from '@/event-bus';
|
||||
import { useExpressionEditor } from '@/composables/useExpressionEditor';
|
||||
|
@ -37,6 +37,7 @@ import { autoCloseTags, htmlLanguage } from 'codemirror-lang-html-n8n';
|
|||
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
|
||||
import type { Range, Section } from './types';
|
||||
import { nonTakenRanges } from './utils';
|
||||
import { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
|
||||
|
||||
type Props = {
|
||||
modelValue: string;
|
||||
|
@ -84,6 +85,7 @@ const extensions = computed(() => [
|
|||
dropCursor(),
|
||||
indentOnInput(),
|
||||
highlightActiveLine(),
|
||||
mappingDropCursor(),
|
||||
]);
|
||||
const {
|
||||
editor: editorRef,
|
||||
|
@ -238,11 +240,25 @@ onMounted(() => {
|
|||
onBeforeUnmount(() => {
|
||||
htmlEditorEventBus.off('format-html', formatHtml);
|
||||
});
|
||||
|
||||
async function onDrop(value: string, event: MouseEvent) {
|
||||
if (!editorRef.value) return;
|
||||
|
||||
await dropInExpressionEditor(toRaw(editorRef.value), event, value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.editor">
|
||||
<div ref="htmlEditor" data-test-id="html-editor-container"></div>
|
||||
<DraggableTarget type="mapping" :disabled="isReadOnly" @drop="onDrop">
|
||||
<template #default="{ activeDrop, droppable }">
|
||||
<div
|
||||
ref="htmlEditor"
|
||||
:class="{ [$style.activeDrop]: activeDrop, [$style.droppable]: droppable }"
|
||||
data-test-id="html-editor-container"
|
||||
></div
|
||||
></template>
|
||||
</DraggableTarget>
|
||||
<slot name="suffix" />
|
||||
</div>
|
||||
</template>
|
||||
|
@ -255,4 +271,21 @@ onBeforeUnmount(() => {
|
|||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.droppable {
|
||||
:global(.cm-editor) {
|
||||
border-color: var(--color-ndv-droppable-parameter);
|
||||
border-style: dashed;
|
||||
border-width: 1.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.activeDrop {
|
||||
:global(.cm-editor) {
|
||||
border-color: var(--color-success);
|
||||
border-style: solid;
|
||||
cursor: grabbing;
|
||||
border-width: 1px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -24,6 +24,7 @@ import {
|
|||
} from '@/plugins/codemirror/keymap';
|
||||
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
|
||||
|
||||
type Props = {
|
||||
modelValue: string;
|
||||
|
@ -69,6 +70,7 @@ const extensions = computed(() => {
|
|||
foldGutter(),
|
||||
dropCursor(),
|
||||
bracketMatching(),
|
||||
mappingDropCursor(),
|
||||
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
|
||||
if (!viewUpdate.docChanged || !editor.value) return;
|
||||
emit('update:modelValue', editor.value?.state.doc.toString());
|
||||
|
|
|
@ -34,8 +34,9 @@ import {
|
|||
StandardSQL,
|
||||
keywordCompletionSource,
|
||||
} from '@n8n/codemirror-lang-sql';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue';
|
||||
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
|
||||
import { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
|
||||
|
||||
const SQL_DIALECTS = {
|
||||
StandardSQL,
|
||||
|
@ -111,6 +112,7 @@ const extensions = computed(() => {
|
|||
foldGutter(),
|
||||
dropCursor(),
|
||||
bracketMatching(),
|
||||
mappingDropCursor(),
|
||||
]);
|
||||
}
|
||||
return baseExtensions;
|
||||
|
@ -178,11 +180,28 @@ function highlightLine(lineNumber: number | 'final') {
|
|||
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 :class="$style.sqlEditor">
|
||||
<div ref="sqlEditor" :class="$style.codemirror" data-test-id="sql-editor-container"></div>
|
||||
<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"
|
||||
|
@ -202,4 +221,21 @@ function highlightLine(lineNumber: number | 'final') {
|
|||
.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>
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { EditorSelection, StateEffect, StateField, type Extension } from '@codemirror/state';
|
||||
import { ViewPlugin, type EditorView, type ViewUpdate } from '@codemirror/view';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { unwrapExpression } from '@/utils/expressions';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { EditorSelection, StateEffect, StateField, type Extension } from '@codemirror/state';
|
||||
import { ViewPlugin, type EditorView, type ViewUpdate } from '@codemirror/view';
|
||||
|
||||
const setDropCursorPos = StateEffect.define<number | null>({
|
||||
map(pos, mapping) {
|
||||
|
@ -121,20 +121,10 @@ function eventToCoord(event: MouseEvent): { x: number; y: number } {
|
|||
return { x: event.clientX, y: event.clientY };
|
||||
}
|
||||
|
||||
export async function dropInEditor(view: EditorView, event: MouseEvent, value: string) {
|
||||
const dropPos = view.posAtCoords(eventToCoord(event), false);
|
||||
|
||||
const node = syntaxTree(view.state).resolve(dropPos);
|
||||
let valueToInsert = value;
|
||||
|
||||
// We are already in an expression, do not insert brackets
|
||||
if (node.name === 'Resolvable') {
|
||||
valueToInsert = unwrapExpression(value);
|
||||
}
|
||||
|
||||
const changes = view.state.changes({ from: dropPos, insert: valueToInsert });
|
||||
const anchor = changes.mapPos(dropPos, -1);
|
||||
const head = changes.mapPos(dropPos, 1);
|
||||
function dropValueInEditor(view: EditorView, pos: number, value: string) {
|
||||
const changes = view.state.changes({ from: pos, insert: value });
|
||||
const anchor = changes.mapPos(pos, -1);
|
||||
const head = changes.mapPos(pos, 1);
|
||||
const selection = EditorSelection.single(anchor, head);
|
||||
|
||||
view.dispatch({
|
||||
|
@ -144,10 +134,29 @@ export async function dropInEditor(view: EditorView, event: MouseEvent, value: s
|
|||
});
|
||||
|
||||
setTimeout(() => view.focus());
|
||||
|
||||
return selection;
|
||||
}
|
||||
|
||||
export async function dropInExpressionEditor(view: EditorView, event: MouseEvent, value: string) {
|
||||
const dropPos = view.posAtCoords(eventToCoord(event), false);
|
||||
const node = syntaxTree(view.state).resolve(dropPos);
|
||||
let valueToInsert = value;
|
||||
|
||||
// We are already in an expression, do not insert brackets
|
||||
if (node.name === 'Resolvable') {
|
||||
valueToInsert = unwrapExpression(value);
|
||||
}
|
||||
|
||||
return dropValueInEditor(view, dropPos, valueToInsert);
|
||||
}
|
||||
|
||||
export async function dropInCodeEditor(view: EditorView, event: MouseEvent, value: string) {
|
||||
const dropPos = view.posAtCoords(eventToCoord(event), false);
|
||||
const valueToInsert = unwrapExpression(value);
|
||||
|
||||
return dropValueInEditor(view, dropPos, valueToInsert);
|
||||
}
|
||||
|
||||
export function mappingDropCursor(): Extension {
|
||||
return [dropCursorPos, drawDropCursor];
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue