diff --git a/cypress/e2e/11-inline-expression-editor.cy.ts b/cypress/e2e/11-inline-expression-editor.cy.ts index e35842293e..5afebc4de6 100644 --- a/cypress/e2e/11-inline-expression-editor.cy.ts +++ b/cypress/e2e/11-inline-expression-editor.cy.ts @@ -1,3 +1,4 @@ +import { EDIT_FIELDS_SET_NODE_NAME } from '../constants'; import { NDV } from '../pages/ndv'; import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; @@ -24,6 +25,25 @@ describe('Inline expression editor', () => { ndv.getters.outputPanel().click(); WorkflowPage.getters.inlineExpressionEditorOutput().should('not.exist'); }); + + it('should switch between expression and fixed using keyboard', () => { + WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME); + WorkflowPage.actions.openNode(EDIT_FIELDS_SET_NODE_NAME); + + // Should switch to expression with = + ndv.getters.assignmentCollectionAdd('assignments').click(); + ndv.actions.typeIntoParameterInput('value', '='); + + // Should complete {{ --> {{ | }} + WorkflowPage.getters.inlineExpressionEditorInput().click().type('{{'); + WorkflowPage.getters.inlineExpressionEditorInput().should('have.text', '{{ }}'); + + // Should switch back to fixed with backspace on empty expression + ndv.actions.typeIntoParameterInput('value', '{selectall}{backspace}'); + ndv.getters.parameterInput('value').click(); + ndv.actions.typeIntoParameterInput('value', '{backspace}'); + ndv.getters.inlineExpressionEditorInput().should('not.exist'); + }); }); describe('Static data', () => { diff --git a/cypress/e2e/41-editors.cy.ts b/cypress/e2e/41-editors.cy.ts index f7ad8129b3..b4f7fa39a9 100644 --- a/cypress/e2e/41-editors.cy.ts +++ b/cypress/e2e/41-editors.cy.ts @@ -49,7 +49,8 @@ describe('Editors', () => { ndv.getters .sqlEditorContainer() .find('.cm-content') - .type('SELECT * FROM {{ $json.table }}', { parseSpecialCharSequences: false }); + // }} is inserted automatically by bracket matching + .type('SELECT * FROM {{ $json.table', { parseSpecialCharSequences: false }); workflowPage.getters .inlineExpressionEditorOutput() .should('have.text', 'SELECT * FROM test_table'); diff --git a/cypress/pages/ndv.ts b/cypress/pages/ndv.ts index 54ae067e72..275d80593d 100644 --- a/cypress/pages/ndv.ts +++ b/cypress/pages/ndv.ts @@ -204,7 +204,7 @@ export class NDV extends BasePage { typeIntoParameterInput: ( parameterName: string, content: string, - opts?: { parseSpecialCharSequences: boolean }, + opts?: Partial, ) => { this.getters.parameterInput(parameterName).type(content, opts); }, diff --git a/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue b/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue index 07e1346545..4a568e293e 100644 --- a/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue +++ b/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue @@ -4,7 +4,6 @@ import { Prec } from '@codemirror/state'; import { dropCursor, EditorView, keymap } from '@codemirror/view'; import { computed, onMounted, ref, watch } from 'vue'; -import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler'; import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang'; import { forceParse } from '@/utils/forceParse'; import { inputTheme } from './theme'; @@ -15,6 +14,7 @@ import type { Segment } from '@/types/expressions'; import { removeExpressionPrefix } from '@/utils/expressions'; import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop'; import { editorKeymap } from '@/plugins/codemirror/keymap'; +import { expressionCloseBrackets } from '@/plugins/codemirror/expressionCloseBrackets'; type Props = { modelValue: string; @@ -41,7 +41,7 @@ const extensions = computed(() => [ mappingDropCursor(), dropCursor(), history(), - expressionInputHandler(), + expressionCloseBrackets(), EditorView.lineWrapping, EditorView.domEventHandlers({ scroll: (_, view) => forceParse(view) }), infoBoxTooltips(), diff --git a/packages/editor-ui/src/components/HtmlEditor/HtmlEditor.vue b/packages/editor-ui/src/components/HtmlEditor/HtmlEditor.vue index c7202ef498..e868ff7ef2 100644 --- a/packages/editor-ui/src/components/HtmlEditor/HtmlEditor.vue +++ b/packages/editor-ui/src/components/HtmlEditor/HtmlEditor.vue @@ -25,7 +25,6 @@ import { computed, onBeforeUnmount, onMounted, ref, toRaw, toValue, watch } from import { useExpressionEditor } from '@/composables/useExpressionEditor'; import { htmlEditorEventBus } from '@/event-bus'; import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions'; -import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler'; import { editorKeymap } from '@/plugins/codemirror/keymap'; import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang'; import { autoCloseTags, htmlLanguage } from 'codemirror-lang-html-n8n'; @@ -33,6 +32,10 @@ import { codeEditorTheme } from '../CodeNodeEditor/theme'; import type { Range, Section } from './types'; import { nonTakenRanges } from './utils'; import { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop'; +import { + expressionCloseBrackets, + expressionCloseBracketsConfig, +} from '@/plugins/codemirror/expressionCloseBrackets'; type Props = { modelValue: string; @@ -56,12 +59,12 @@ const editorValue = ref(props.modelValue); const extensions = computed(() => [ bracketMatching(), n8nAutocompletion(), - new LanguageSupport( - htmlLanguage, + new LanguageSupport(htmlLanguage, [ + htmlLanguage.data.of({ closeBrackets: expressionCloseBracketsConfig }), n8nCompletionSources().map((source) => htmlLanguage.data.of(source)), - ), + ]), autoCloseTags, - expressionInputHandler(), + expressionCloseBrackets(), Prec.highest(keymap.of(editorKeymap)), indentOnInput(), codeEditorTheme({ diff --git a/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue b/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue index 2659840f82..bfce17ee94 100644 --- a/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue +++ b/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue @@ -6,14 +6,14 @@ import { computed, ref, watch } from 'vue'; import { useExpressionEditor } from '@/composables/useExpressionEditor'; import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop'; -import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler'; import { editorKeymap } from '@/plugins/codemirror/keymap'; import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang'; import { infoBoxTooltips } from '@/plugins/codemirror/tooltips/InfoBoxTooltip'; import type { Segment } from '@/types/expressions'; -import { removeExpressionPrefix } from '@/utils/expressions'; import type { IDataObject } from 'n8n-workflow'; import { inputTheme } from './theme'; +import { onKeyStroke } from '@vueuse/core'; +import { expressionCloseBrackets } from '@/plugins/codemirror/expressionCloseBrackets'; type Props = { modelValue: string; @@ -44,11 +44,20 @@ const extensions = computed(() => [ history(), mappingDropCursor(), dropCursor(), - expressionInputHandler(), + expressionCloseBrackets(), EditorView.lineWrapping, infoBoxTooltips(), ]); -const editorValue = ref(removeExpressionPrefix(props.modelValue)); +const editorValue = computed(() => props.modelValue); + +// Exit expression editor when pressing Backspace in empty field +onKeyStroke( + 'Backspace', + () => { + if (props.modelValue === '') emit('update:model-value', { value: '', segments: [] }); + }, + { target: root }, +); const { editor: editorRef, @@ -67,13 +76,6 @@ const { additionalData: props.additionalData, }); -watch( - () => props.modelValue, - (newValue) => { - editorValue.value = removeExpressionPrefix(newValue); - }, -); - watch(segments.display, (newSegments) => { emit('update:model-value', { value: '=' + readEditorValue(), diff --git a/packages/editor-ui/src/components/ParameterInput.vue b/packages/editor-ui/src/components/ParameterInput.vue index 809b1edfe4..80a4b9bac2 100644 --- a/packages/editor-ui/src/components/ParameterInput.vue +++ b/packages/editor-ui/src/components/ParameterInput.vue @@ -1067,11 +1067,11 @@ watch(remoteParameterOptionsLoading, () => { tempValue.value = displayValue.value as string; }); -// Focus input field when changing from fixed value to expression +// Focus input field when changing between fixed and expression watch(isModelValueExpression, async (isExpression, wasExpression) => { - if (!props.isReadOnly && isExpression && !wasExpression) { + if (!props.isReadOnly && isExpression !== wasExpression) { await nextTick(); - inputField.value?.focus(); + await setFocus(); } }); diff --git a/packages/editor-ui/src/components/SqlEditor/SqlEditor.vue b/packages/editor-ui/src/components/SqlEditor/SqlEditor.vue index 16a10468d0..398f66c866 100644 --- a/packages/editor-ui/src/components/SqlEditor/SqlEditor.vue +++ b/packages/editor-ui/src/components/SqlEditor/SqlEditor.vue @@ -4,7 +4,6 @@ import { useExpressionEditor } from '@/composables/useExpressionEditor'; import { codeNodeEditorEventBus } from '@/event-bus'; import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions'; import { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop'; -import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler'; import { editorKeymap } from '@/plugins/codemirror/keymap'; import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang'; import { ifNotIn } from '@codemirror/autocomplete'; @@ -33,6 +32,10 @@ import { import { onClickOutside } from '@vueuse/core'; import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue'; import { codeEditorTheme } from '../CodeNodeEditor/theme'; +import { + expressionCloseBrackets, + expressionCloseBracketsConfig, +} from '@/plugins/codemirror/expressionCloseBrackets'; const SQL_DIALECTS = { StandardSQL, @@ -72,6 +75,7 @@ const extensions = computed(() => { const dialect = SQL_DIALECTS[props.dialect] ?? SQL_DIALECTS.StandardSQL; function sqlWithN8nLanguageSupport() { return new LanguageSupport(dialect.language, [ + dialect.language.data.of({ closeBrackets: expressionCloseBracketsConfig }), dialect.language.data.of({ autocomplete: ifNotIn(['Resolvable'], keywordCompletionSource(dialect, true)), }), @@ -81,7 +85,7 @@ const extensions = computed(() => { const baseExtensions = [ sqlWithN8nLanguageSupport(), - expressionInputHandler(), + expressionCloseBrackets(), codeEditorTheme({ isReadOnly: props.isReadOnly, maxHeight: props.fullscreen ? '100%' : '40vh', diff --git a/packages/editor-ui/src/composables/useCodeEditor.ts b/packages/editor-ui/src/composables/useCodeEditor.ts index a470b72f77..1b493fcf5a 100644 --- a/packages/editor-ui/src/composables/useCodeEditor.ts +++ b/packages/editor-ui/src/composables/useCodeEditor.ts @@ -2,7 +2,12 @@ import { codeEditorTheme } from '@/components/CodeNodeEditor/theme'; import { editorKeymap } from '@/plugins/codemirror/keymap'; import { useTypescript } from '@/plugins/codemirror/typescript/client/useTypescript'; import { closeCursorInfoBox } from '@/plugins/codemirror/tooltips/InfoBoxTooltip'; -import { closeBrackets, closeCompletion, completionStatus } from '@codemirror/autocomplete'; +import { + closeBrackets, + closeBracketsKeymap, + closeCompletion, + completionStatus, +} from '@codemirror/autocomplete'; import { history, historyField } from '@codemirror/commands'; import { javascript } from '@codemirror/lang-javascript'; import { json } from '@codemirror/lang-json'; @@ -290,6 +295,7 @@ export const useCodeEditor = ({ }, }), keymap.of(editorKeymap), + keymap.of(closeBracketsKeymap), ]; const parsedStoredState = jsonParse( diff --git a/packages/editor-ui/src/plugins/codemirror/completions/utils.test.ts b/packages/editor-ui/src/plugins/codemirror/completions/utils.test.ts index 9d1da3ad52..64b9c13f05 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/utils.test.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/utils.test.ts @@ -1,10 +1,13 @@ import { createTestNode, createTestWorkflowObject } from '@/__tests__/mocks'; import * as workflowHelpers from '@/composables/useWorkflowHelpers'; -import { javascriptLanguage } from '@codemirror/lang-javascript'; -import { autocompletableNodeNames, expressionWithFirstItem } from './utils'; -import type { MockInstance } from 'vitest'; import * as ndvStore from '@/stores/ndv.store'; +import { CompletionContext, insertCompletionText } from '@codemirror/autocomplete'; +import { javascriptLanguage } from '@codemirror/lang-javascript'; +import { EditorState } from '@codemirror/state'; +import { EditorView } from '@codemirror/view'; import { NodeConnectionType, type IConnections } from 'n8n-workflow'; +import type { MockInstance } from 'vitest'; +import { autocompletableNodeNames, expressionWithFirstItem, stripExcessParens } from './utils'; vi.mock('@/composables/useWorkflowHelpers', () => ({ useWorkflowHelpers: vi.fn().mockReturnValue({ @@ -12,6 +15,22 @@ vi.mock('@/composables/useWorkflowHelpers', () => ({ }), })); +const editorFromString = (docWithCursor: string) => { + const cursorPosition = docWithCursor.indexOf('|'); + + const doc = docWithCursor.slice(0, cursorPosition) + docWithCursor.slice(cursorPosition + 1); + + const state = EditorState.create({ + doc, + selection: { anchor: cursorPosition }, + }); + + return { + context: new CompletionContext(state, cursorPosition, false), + view: new EditorView({ state, doc }), + }; +}; + describe('completion utils', () => { describe('expressionWithFirstItem', () => { it('should replace $input.item', () => { @@ -122,4 +141,41 @@ describe('completion utils', () => { expect(autocompletableNodeNames()).toEqual(['Normal Node']); }); }); + + describe('stripExcessParens', () => { + test.each([ + { + doc: '$(|', + completion: { label: "$('Node Name')" }, + expected: "$('Node Name')", + }, + { + doc: '$(|)', + completion: { label: "$('Node Name')" }, + expected: "$('Node Name')", + }, + { + doc: "$('|')", + completion: { label: "$('Node Name')" }, + expected: "$('Node Name')", + }, + { + doc: "$('No|')", + completion: { label: "$('Node Name')" }, + expected: "$('Node Name')", + }, + ])('should complete $doc to $expected', ({ doc, completion, expected }) => { + const { context, view } = editorFromString(doc); + const result = stripExcessParens(context)(completion); + const from = 0; + const to = doc.indexOf('|'); + if (typeof result.apply === 'function') { + result.apply(view, completion, from, to); + } else { + view.dispatch(insertCompletionText(view.state, completion.label, from, to)); + } + + expect(view.state.doc.toString()).toEqual(expected); + }); + }); }); diff --git a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts index 929d1d918c..510f3f5dad 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts @@ -15,7 +15,7 @@ import { type CompletionSection, } from '@codemirror/autocomplete'; import type { EditorView } from '@codemirror/view'; -import type { TransactionSpec } from '@codemirror/state'; +import { EditorSelection, type TransactionSpec } from '@codemirror/state'; import type { SyntaxNode, Tree } from '@lezer/common'; import { useRouter } from 'vue-router'; import type { DocMetadata } from 'n8n-workflow'; @@ -229,9 +229,27 @@ export function getPreviousNodes(nodeName: string) { .filter((name) => name !== nodeName); } +/** + * Finds the amount of common chars at the end of the source and the start of the target. + * Example: "hello world", "world peace" => 5 ("world" is the overlap) + */ +function findCommonBoundary(source: string, target: string) { + return ( + [...source] + .reverse() + .map((_, i) => source.slice(-i - 1)) + .find((end) => target.startsWith(end))?.length ?? 0 + ); +} + +function getClosingChars(input: string): string { + const match = input.match(/^['"\])]+/); + return match ? match[0] : ''; +} + /** * Remove excess parens from an option label when the cursor is already - * followed by parens, e.g. `$json.myStr.|()` -> `isNumeric` + * followed by parens, e.g. `$json.myStr.|()` -> `isNumeric` or `$(|)` -> `$("Node Name")|` */ export const stripExcessParens = (context: CompletionContext) => (option: Completion) => { const followedByParens = context.state.sliceDoc(context.pos, context.pos + 2) === '()'; @@ -240,6 +258,21 @@ export const stripExcessParens = (context: CompletionContext) => (option: Comple option.label = option.label.slice(0, '()'.length * -1); } + const closingChars = getClosingChars(context.state.sliceDoc(context.pos)); + const commonClosingChars = findCommonBoundary(option.label, closingChars); + + if (commonClosingChars > 0) { + option.apply = (view: EditorView, completion: Completion, from: number, to: number): void => { + const tx: TransactionSpec = { + ...insertCompletionText(view.state, option.label.slice(0, -commonClosingChars), from, to), + annotations: pickedCompletion.of(completion), + }; + + tx.selection = EditorSelection.cursor(from + option.label.length); + view.dispatch(tx); + }; + } + return option; }; diff --git a/packages/editor-ui/src/plugins/codemirror/expressionCloseBrackets.ts b/packages/editor-ui/src/plugins/codemirror/expressionCloseBrackets.ts new file mode 100644 index 0000000000..9342ecab30 --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/expressionCloseBrackets.ts @@ -0,0 +1,38 @@ +import { + closeBrackets, + closeBracketsKeymap, + type CloseBracketConfig, +} from '@codemirror/autocomplete'; +import { EditorSelection, Text } from '@codemirror/state'; +import { EditorView, keymap } from '@codemirror/view'; + +const expressionBracketSpacing = EditorView.updateListener.of((update) => { + if (!update.changes || update.changes.empty) return; + + // {{|}} --> {{ | }} + update.changes.iterChanges((_fromA, _toA, fromB, toB, inserted) => { + const doc = update.state.doc; + if ( + inserted.eq(Text.of(['{}'])) && + doc.sliceString(fromB - 1, fromB) === '{' && + doc.sliceString(toB, toB + 1) === '}' + ) { + update.view.dispatch({ + changes: [{ from: fromB + 1, insert: ' ' }], + selection: EditorSelection.cursor(toB), + }); + } + }); +}); + +export const expressionCloseBracketsConfig: CloseBracketConfig = { + brackets: ['{', '(', '"', "'", '['], + // <> so bracket completion works in HTML tags + before: ')]}:;<>\'"', +}; + +export const expressionCloseBrackets = () => [ + expressionBracketSpacing, + closeBrackets(), + keymap.of(closeBracketsKeymap), +]; diff --git a/packages/editor-ui/src/plugins/codemirror/inputHandlers/code.inputHandler.ts b/packages/editor-ui/src/plugins/codemirror/inputHandlers/code.inputHandler.ts deleted file mode 100644 index 13bb282908..0000000000 --- a/packages/editor-ui/src/plugins/codemirror/inputHandlers/code.inputHandler.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { closeBrackets, completionStatus, insertBracket } from '@codemirror/autocomplete'; -import type { Extension } from '@codemirror/state'; -import { codePointAt, codePointSize } from '@codemirror/state'; -import { EditorView } from '@codemirror/view'; - -const handler = EditorView.inputHandler.of((view, from, to, insert) => { - if (view.composing || view.state.readOnly) return false; - - // customization: do not autoclose tokens while autocompletion is active - if (completionStatus(view.state) !== null) return false; - - const selection = view.state.selection.main; - - // customization: do not autoclose square brackets prior to `.json` - if ( - insert === '[' && - view.state.doc.toString().slice(selection.from - '.json'.length, selection.to) === '.json' - ) { - return false; - } - - if ( - insert.length > 2 || - (insert.length === 2 && codePointSize(codePointAt(insert, 0)) === 1) || - from !== selection.from || - to !== selection.to - ) { - return false; - } - - const transaction = insertBracket(view.state, insert); - - if (!transaction) return false; - - view.dispatch(transaction); - - return true; -}); - -const [_, bracketState] = closeBrackets() as readonly Extension[]; - -/** - * CodeMirror plugin for code node editor: - * - * - prevent token autoclosing during autocompletion - * - prevent square bracket autoclosing prior to `.json` - * - * Other than segments marked `customization`, this is a copy of the [original](https://github.com/codemirror/closebrackets/blob/0a56edfaf2c6d97bc5e88f272de0985b4f41e37a/src/closebrackets.ts#L79). - */ -export const codeInputHandler = () => [handler, bracketState]; diff --git a/packages/editor-ui/src/plugins/codemirror/inputHandlers/expression.inputHandler.ts b/packages/editor-ui/src/plugins/codemirror/inputHandlers/expression.inputHandler.ts deleted file mode 100644 index 5337e847c6..0000000000 --- a/packages/editor-ui/src/plugins/codemirror/inputHandlers/expression.inputHandler.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { - closeBrackets, - completionStatus, - insertBracket, - startCompletion, -} from '@codemirror/autocomplete'; -import type { Extension } from '@codemirror/state'; -import { codePointAt, codePointSize } from '@codemirror/state'; -import { EditorView } from '@codemirror/view'; - -const handler = EditorView.inputHandler.of((view, from, to, insert) => { - if (view.composing || view.state.readOnly) return false; - - // customization: do not autoclose tokens while autocompletion is active - if (completionStatus(view.state) !== null) return false; - - const selection = view.state.selection.main; - - // customization: do not autoclose square brackets prior to `.json` - if ( - insert === '[' && - view.state.doc.toString().slice(selection.from - '.json'.length, selection.to) === '.json' - ) { - return false; - } - - if ( - insert.length > 2 || - (insert.length === 2 && codePointSize(codePointAt(insert, 0)) === 1) || - from !== selection.from || - to !== selection.to - ) { - return false; - } - - const transaction = insertBracket(view.state, insert); - - if (!transaction) { - // customization: brace setup when surrounded by HTML tags:
->
{| }
- if (insert === '{') { - const cursor = view.state.selection.main.head; - view.dispatch({ - changes: { from: cursor, insert: '{ }' }, - selection: { anchor: cursor + 1 }, - }); - return true; - } - - return false; - } - - view.dispatch(transaction); - - // customization: inject whitespace and second brace for brace completion: {| } -> {{ | }} - - const cursor = view.state.selection.main.head; - - const isBraceCompletion = - view.state.sliceDoc(cursor - 2, cursor) === '{{' && - view.state.sliceDoc(cursor, cursor + 1) === '}'; - - if (isBraceCompletion) { - view.dispatch({ - changes: { from: cursor, to: cursor + 2, insert: ' }' }, - selection: { anchor: cursor + 1 }, - }); - - startCompletion(view); - - return true; - } - - // customization: inject whitespace for brace setup: empty -> {| } - - const isBraceSetup = - view.state.sliceDoc(cursor - 1, cursor) === '{' && - view.state.sliceDoc(cursor, cursor + 1) === '}'; - - const { head } = view.state.selection.main; - - const isInsideResolvable = - view.state.sliceDoc(0, head).includes('{{') && - view.state.sliceDoc(head, view.state.doc.length).includes('}}'); - - if (isBraceSetup && !isInsideResolvable) { - view.dispatch({ changes: { from: cursor, insert: ' ' } }); - - return true; - } - - // customization: inject whitespace for brace completion from selection: {{abc|}} -> {{ abc| }} - - const [range] = view.state.selection.ranges; - - const isBraceCompletionFromSelection = - view.state.sliceDoc(range.from - 2, range.from) === '{{' && - view.state.sliceDoc(range.to, range.to + 2) === '}}'; - - if (isBraceCompletionFromSelection) { - view.dispatch( - { changes: { from: range.from, insert: ' ' } }, - { changes: { from: range.to, insert: ' ' }, selection: { anchor: range.to, head: range.to } }, - ); - - return true; - } - - return true; -}); - -const [_, bracketState] = closeBrackets() as readonly Extension[]; - -/** - * CodeMirror plugin for (inline and modal) expression editor: - * - * - prevent token autoclosing during autocompletion (exception: `{`), - * - prevent square bracket autoclosing prior to `.json` - * - inject whitespace and braces for resolvables - * - set up braces when surrounded by HTML tags - * - * Other than segments marked `customization`, this is a copy of the [original](https://github.com/codemirror/closebrackets/blob/0a56edfaf2c6d97bc5e88f272de0985b4f41e37a/src/closebrackets.ts#L79). - */ -export const expressionInputHandler = () => [handler, bracketState]; diff --git a/packages/editor-ui/src/plugins/codemirror/n8nLang.ts b/packages/editor-ui/src/plugins/codemirror/n8nLang.ts index 5cf0cff9d4..37ea74fb03 100644 --- a/packages/editor-ui/src/plugins/codemirror/n8nLang.ts +++ b/packages/editor-ui/src/plugins/codemirror/n8nLang.ts @@ -1,17 +1,20 @@ import { parserWithMetaData as n8nParser } from '@n8n/codemirror-lang'; import { LanguageSupport, LRLanguage } from '@codemirror/language'; -import { parseMixed } from '@lezer/common'; +import { parseMixed, type SyntaxNodeRef } from '@lezer/common'; import { javascriptLanguage } from '@codemirror/lang-javascript'; import { n8nCompletionSources } from './completions/addCompletions'; import { autocompletion } from '@codemirror/autocomplete'; +import { expressionCloseBracketsConfig } from './expressionCloseBrackets'; + +const isResolvable = (node: SyntaxNodeRef) => node.type.name === 'Resolvable'; const n8nParserWithNestedJsParser = n8nParser.configure({ wrap: parseMixed((node) => { if (node.type.isTop) return null; return node.name === 'Resolvable' - ? { parser: javascriptLanguage.parser, overlay: (node) => node.type.name === 'Resolvable' } + ? { parser: javascriptLanguage.parser, overlay: isResolvable } : null; }), }); @@ -20,7 +23,7 @@ const n8nLanguage = LRLanguage.define({ parser: n8nParserWithNestedJsParser }); export function n8nLang() { return new LanguageSupport(n8nLanguage, [ - n8nLanguage.data.of({ closeBrackets: { brackets: ['{', '('] } }), + n8nLanguage.data.of(expressionCloseBracketsConfig), ...n8nCompletionSources().map((source) => n8nLanguage.data.of(source)), ]); }