feat(editor): Enable drag and drop in code editors (Code/SQL/HTML) (#10888)

This commit is contained in:
Elias Meire 2024-09-25 15:25:26 +02:00 committed by GitHub
parent ed91495ebc
commit af9e227ad4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 173 additions and 34 deletions

View file

@ -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>

View file

@ -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>

View file

@ -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();

View file

@ -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>

View file

@ -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());

View file

@ -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>

View file

@ -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];
}