fix(editor): Place caret inside expression brackets on auto-switch (#30030)

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Michael Kret <michael.k@radency.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Michael Kret <88898367+michael-radency@users.noreply.github.com>
This commit is contained in:
Alexander Gekov 2026-05-20 16:45:58 +03:00 committed by GitHub
parent c730def839
commit df5a1c4452
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 87 additions and 4 deletions

View File

@ -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: () => {

View File

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

View File

@ -66,6 +66,7 @@ export const useExpressionEditor = ({
autocompleteTelemetry,
isReadOnly = false,
disableSearchDialog = false,
initialCursorPosition,
onChange = () => {},
}: {
editorRef: MaybeRefOrGetter<HTMLElement | undefined>;
@ -77,6 +78,7 @@ export const useExpressionEditor = ({
autocompleteTelemetry?: MaybeRefOrGetter<{ enabled: true; parameterPath: string }>;
isReadOnly?: MaybeRefOrGetter<boolean>;
disableSearchDialog?: MaybeRefOrGetter<boolean>;
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();