diff --git a/packages/frontend/editor-ui/src/features/shared/editors/components/InlineExpressionEditor/InlineExpressionEditorInput.vue b/packages/frontend/editor-ui/src/features/shared/editors/components/InlineExpressionEditor/InlineExpressionEditorInput.vue index 344cd45b0c2..356c6080d64 100644 --- a/packages/frontend/editor-ui/src/features/shared/editors/components/InlineExpressionEditor/InlineExpressionEditorInput.vue +++ b/packages/frontend/editor-ui/src/features/shared/editors/components/InlineExpressionEditor/InlineExpressionEditorInput.vue @@ -75,6 +75,7 @@ const { isReadOnly: computed(() => props.isReadOnly), autocompleteTelemetry: { enabled: true, parameterPath: props.path }, additionalData: props.additionalData, + initialCursorPosition: 'lastExpression', }); watch(segments.display, (newSegments) => { @@ -102,8 +103,10 @@ defineExpose({ setCursorPosition, focus: () => { if (!hasFocus.value) { - setCursorPosition('lastExpression'); focus(); + requestAnimationFrame(() => { + setCursorPosition('lastExpression'); + }); } }, selectAll: () => { diff --git a/packages/frontend/editor-ui/src/features/shared/editors/composables/useExpressionEditor.test.ts b/packages/frontend/editor-ui/src/features/shared/editors/composables/useExpressionEditor.test.ts index 243f095e7df..e13c40341b4 100644 --- a/packages/frontend/editor-ui/src/features/shared/editors/composables/useExpressionEditor.test.ts +++ b/packages/frontend/editor-ui/src/features/shared/editors/composables/useExpressionEditor.test.ts @@ -297,6 +297,58 @@ describe('useExpressionEditor', () => { }); }); + describe('initialCursorPosition', () => { + test('should place cursor inside the empty expression block when value is auto-converted', async () => { + const editorValue = 'Hello {{ }}'; + const expectedPosition = editorValue.lastIndexOf(' }}'); + const { + expressionEditor: { editor }, + } = await renderExpressionEditor({ + editorValue, + initialCursorPosition: 'lastExpression', + extensions: [n8nLang()], + }); + + expect(toValue(editor)?.state.selection).toEqual(EditorSelection.single(expectedPosition)); + }); + + test('should place cursor at end when option is "end"', async () => { + const editorValue = 'text here'; + const { + expressionEditor: { editor }, + } = await renderExpressionEditor({ + editorValue, + initialCursorPosition: 'end', + }); + + expect(toValue(editor)?.state.selection).toEqual(EditorSelection.single(editorValue.length)); + }); + + test('should place cursor at the given numeric position', async () => { + const editorValue = 'text here'; + const { + expressionEditor: { editor }, + } = await renderExpressionEditor({ + editorValue, + initialCursorPosition: 3, + }); + + expect(toValue(editor)?.state.selection).toEqual(EditorSelection.single(3)); + }); + + test('should default to position 0 when no option is provided', async () => { + const editorValue = 'Hello {{ }}'; + const { + expressionEditor: { editor }, + } = await renderExpressionEditor({ + editorValue, + extensions: [n8nLang()], + }); + + expect(toValue(editor)?.state.selection).toEqual(EditorSelection.single(0)); + }); + }); + describe('select()', () => { test('should select number range', async () => { const editorValue = 'text here'; diff --git a/packages/frontend/editor-ui/src/features/shared/editors/composables/useExpressionEditor.ts b/packages/frontend/editor-ui/src/features/shared/editors/composables/useExpressionEditor.ts index 8b314a51ae0..6d6b226c09b 100644 --- a/packages/frontend/editor-ui/src/features/shared/editors/composables/useExpressionEditor.ts +++ b/packages/frontend/editor-ui/src/features/shared/editors/composables/useExpressionEditor.ts @@ -66,6 +66,7 @@ export const useExpressionEditor = ({ autocompleteTelemetry, isReadOnly = false, disableSearchDialog = false, + initialCursorPosition, onChange = () => {}, }: { editorRef: MaybeRefOrGetter; @@ -77,6 +78,7 @@ export const useExpressionEditor = ({ autocompleteTelemetry?: MaybeRefOrGetter<{ enabled: true; parameterPath: string }>; isReadOnly?: MaybeRefOrGetter; disableSearchDialog?: MaybeRefOrGetter; + initialCursorPosition?: number | 'lastExpression' | 'end'; onChange?: (viewUpdate: ViewUpdate) => void; }) => { const ndvStore = useNDVStore(); @@ -233,13 +235,33 @@ export const useExpressionEditor = ({ } } + function resolveInitialCursorPosition( + doc: string, + pos: number | 'lastExpression' | 'end', + ): number { + if (typeof pos === 'number') return pos; + if (pos === 'end') return doc.length; + const END_OF_EXPRESSION = ' }}'; + const endOfLastExpression = doc.lastIndexOf(END_OF_EXPRESSION); + return endOfLastExpression !== -1 ? endOfLastExpression : doc.length; + } + watch(toRef(editorRef), () => { const parent = toValue(editorRef); if (!parent) return; + const docContent = toValue(editorValue) ?? ''; + const initialSelection = + initialCursorPosition !== undefined + ? EditorSelection.cursor(resolveInitialCursorPosition(docContent, initialCursorPosition)) + : undefined; + + let hasReceivedFocus = false; + const state = EditorState.create({ - doc: toValue(editorValue), + doc: docContent, + selection: initialSelection, extensions: [ TARGET_NODE_PARAMETER_FACET.of( expressionLocalResolveContext.value @@ -251,9 +273,15 @@ export const useExpressionEditor = ({ readOnlyExtensions.value.of([EditorState.readOnly.of(toValue(isReadOnly))]), telemetryExtensions.value.of([]), EditorView.updateListener.of(onEditorUpdate), - EditorView.focusChangeEffect.of((_, newHasFocus) => { + EditorView.focusChangeEffect.of((currentState, newHasFocus) => { hasFocus.value = newHasFocus; - selection.value = state.selection.ranges[0]; + selection.value = currentState.selection.ranges[0]; + if (newHasFocus && !hasReceivedFocus && initialSelection) { + hasReceivedFocus = true; + requestAnimationFrame(() => { + editor.value?.dispatch({ selection: initialSelection }); + }); + } if (!newHasFocus) { autocompleteStatus.value = null; void debouncedUpdateSegments();