mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
fix(editor): Fix '=' handling in expressions (#13129)
This commit is contained in:
parent
f057cfb46a
commit
8f25a06e6c
|
@ -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', () => {
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -204,7 +204,7 @@ export class NDV extends BasePage {
|
|||
typeIntoParameterInput: (
|
||||
parameterName: string,
|
||||
content: string,
|
||||
opts?: { parseSpecialCharSequences: boolean },
|
||||
opts?: Partial<Cypress.TypeOptions>,
|
||||
) => {
|
||||
this.getters.parameterInput(parameterName).type(content, opts);
|
||||
},
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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<string>(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({
|
||||
|
|
|
@ -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<string>(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(),
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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 = <L extends CodeEditorLanguage>({
|
|||
},
|
||||
}),
|
||||
keymap.of(editorKeymap),
|
||||
keymap.of(closeBracketsKeymap),
|
||||
];
|
||||
|
||||
const parsedStoredState = jsonParse<IDataObject | null>(
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
@ -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),
|
||||
];
|
|
@ -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];
|
|
@ -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: <div></div> -> <div>{| }</div>
|
||||
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];
|
|
@ -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)),
|
||||
]);
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue