feat(editor): Make auto-focus work for Focus Panel (no-changelog) (#17294)

This commit is contained in:
Daria
2025-07-15 11:52:23 +03:00
committed by GitHub
parent c1b008090f
commit 61e2e34caa
9 changed files with 140 additions and 14 deletions

View File

@@ -71,7 +71,7 @@ const dragAndDropEnabled = computed(() => {
return !props.isReadOnly; return !props.isReadOnly;
}); });
const { highlightLine, readEditorValue, editor } = useCodeEditor({ const { highlightLine, readEditorValue, editor, focus } = useCodeEditor({
id: props.id, id: props.id,
editorRef: codeNodeEditorRef, editorRef: codeNodeEditorRef,
language: () => props.language, language: () => props.language,
@@ -208,6 +208,10 @@ async function onDrop(value: string, event: MouseEvent) {
await dropInCodeEditor(toRaw(editor.value), event, valueToInsert); await dropInCodeEditor(toRaw(editor.value), event, valueToInsert);
} }
defineExpose({
focus,
});
</script> </script>
<template> <template>

View File

@@ -69,7 +69,11 @@ const extensions = computed(() => [
mappingDropCursor(), mappingDropCursor(),
]); ]);
const { editor: editorRef, readEditorValue } = useExpressionEditor({ const {
editor: editorRef,
readEditorValue,
focus,
} = useExpressionEditor({
editorRef: cssEditor, editorRef: cssEditor,
editorValue, editorValue,
extensions, extensions,
@@ -83,6 +87,10 @@ async function onDrop(value: string, event: MouseEvent) {
await dropInExpressionEditor(toRaw(editorRef.value), event, value); await dropInExpressionEditor(toRaw(editorRef.value), event, value);
} }
defineExpose({
focus,
});
</script> </script>
<template> <template>

View File

@@ -2,7 +2,7 @@
import { useFocusPanelStore } from '@/stores/focusPanel.store'; import { useFocusPanelStore } from '@/stores/focusPanel.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { N8nText, N8nInput, N8nResizeWrapper } from '@n8n/design-system'; import { N8nText, N8nInput, N8nResizeWrapper } from '@n8n/design-system';
import { computed, nextTick, ref } from 'vue'; import { computed, nextTick, ref, watch } from 'vue';
import { useI18n } from '@n8n/i18n'; import { useI18n } from '@n8n/i18n';
import { import {
formatAsExpression, formatAsExpression,
@@ -14,6 +14,7 @@ import { isValueExpression } from '@/utils/nodeTypesUtils';
import { useNodeHelpers } from '@/composables/useNodeHelpers'; import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useNodeSettingsParameters } from '@/composables/useNodeSettingsParameters'; import { useNodeSettingsParameters } from '@/composables/useNodeSettingsParameters';
import { useResolvedExpression } from '@/composables/useResolvedExpression'; import { useResolvedExpression } from '@/composables/useResolvedExpression';
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
import { import {
AI_TRANSFORM_NODE_TYPE, AI_TRANSFORM_NODE_TYPE,
type CodeExecutionMode, type CodeExecutionMode,
@@ -37,6 +38,7 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
focus: []; focus: [];
saveKeyboardShortcut: [event: KeyboardEvent];
}>(); }>();
// ESLint: false positive // ESLint: false positive
@@ -49,6 +51,7 @@ const focusPanelStore = useFocusPanelStore();
const nodeTypesStore = useNodeTypesStore(); const nodeTypesStore = useNodeTypesStore();
const nodeSettingsParameters = useNodeSettingsParameters(); const nodeSettingsParameters = useNodeSettingsParameters();
const environmentsStore = useEnvironmentsStore(); const environmentsStore = useEnvironmentsStore();
const deviceSupport = useDeviceSupport();
const { debounce } = useDebounce(); const { debounce } = useDebounce();
const focusedNodeParameter = computed(() => focusPanelStore.focusedNodeParameters[0]); const focusedNodeParameter = computed(() => focusPanelStore.focusedNodeParameters[0]);
@@ -206,11 +209,13 @@ function optionSelected(command: string) {
if (!resolvedParameter.value) return; if (!resolvedParameter.value) return;
switch (command) { switch (command) {
case 'resetValue': case 'resetValue': {
return ( if (typeof resolvedParameter.value.parameter.default === 'string') {
typeof resolvedParameter.value.parameter.default === 'string' && valueChanged(resolvedParameter.value.parameter.default);
valueChanged(resolvedParameter.value.parameter.default) }
); void setFocus();
break;
}
case 'addExpression': { case 'addExpression': {
const newValue = formatAsExpression( const newValue = formatAsExpression(
@@ -241,12 +246,53 @@ function optionSelected(command: string) {
case 'formatHtml': case 'formatHtml':
htmlEditorEventBus.emit('format-html'); htmlEditorEventBus.emit('format-html');
return; break;
} }
} }
const valueChangedDebounced = debounce(valueChanged, { debounceTime: 0 }); const valueChangedDebounced = debounce(valueChanged, { debounceTime: 0 });
// Wait for editor to mount before focusing
function focusWithDelay() {
setTimeout(() => {
void setFocus();
}, 50);
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 's' && deviceSupport.isCtrlKeyPressed(event)) {
event.stopPropagation();
event.preventDefault();
if (isReadOnly.value) return;
emit('saveKeyboardShortcut', event);
}
}
const registerKeyboardListener = () => {
document.addEventListener('keydown', handleKeydown, true);
};
const unregisterKeyboardListener = () => {
document.removeEventListener('keydown', handleKeydown, true);
};
watch([() => focusPanelStore.lastFocusTimestamp, () => expressionModeEnabled.value], () =>
focusWithDelay(),
);
watch(
() => focusPanelStore.focusPanelActive,
(newValue) => {
if (newValue) {
registerKeyboardListener();
} else {
unregisterKeyboardListener();
}
},
{ immediate: true },
);
function onResize(event: ResizeData) { function onResize(event: ResizeData) {
focusPanelStore.updateWidth(event.width); focusPanelStore.updateWidth(event.width);
} }
@@ -328,6 +374,8 @@ const onResizeThrottle = useThrottleFn(onResize, 10);
<CodeNodeEditor <CodeNodeEditor
v-if="editorType === 'codeNodeEditor'" v-if="editorType === 'codeNodeEditor'"
:id="resolvedParameter.parameterPath" :id="resolvedParameter.parameterPath"
ref="inputField"
:class="$style.heightFull"
:mode="codeEditorMode" :mode="codeEditorMode"
:model-value="resolvedParameter.value" :model-value="resolvedParameter.value"
:default-value="resolvedParameter.parameter.default" :default-value="resolvedParameter.parameter.default"
@@ -339,6 +387,7 @@ const onResizeThrottle = useThrottleFn(onResize, 10);
@update:model-value="valueChangedDebounced" /> @update:model-value="valueChangedDebounced" />
<HtmlEditor <HtmlEditor
v-else-if="editorType === 'htmlEditor'" v-else-if="editorType === 'htmlEditor'"
ref="inputField"
:model-value="resolvedParameter.value" :model-value="resolvedParameter.value"
:is-read-only="isReadOnly" :is-read-only="isReadOnly"
:rows="editorRows" :rows="editorRows"
@@ -348,6 +397,7 @@ const onResizeThrottle = useThrottleFn(onResize, 10);
@update:model-value="valueChangedDebounced" /> @update:model-value="valueChangedDebounced" />
<CssEditor <CssEditor
v-else-if="editorType === 'cssEditor'" v-else-if="editorType === 'cssEditor'"
ref="inputField"
:model-value="resolvedParameter.value" :model-value="resolvedParameter.value"
:is-read-only="isReadOnly" :is-read-only="isReadOnly"
:rows="editorRows" :rows="editorRows"
@@ -355,6 +405,7 @@ const onResizeThrottle = useThrottleFn(onResize, 10);
@update:model-value="valueChangedDebounced" /> @update:model-value="valueChangedDebounced" />
<SqlEditor <SqlEditor
v-else-if="editorType === 'sqlEditor'" v-else-if="editorType === 'sqlEditor'"
ref="inputField"
:model-value="resolvedParameter.value" :model-value="resolvedParameter.value"
:dialect="getTypeOption('sqlDialect')" :dialect="getTypeOption('sqlDialect')"
:is-read-only="isReadOnly" :is-read-only="isReadOnly"
@@ -363,6 +414,7 @@ const onResizeThrottle = useThrottleFn(onResize, 10);
@update:model-value="valueChangedDebounced" /> @update:model-value="valueChangedDebounced" />
<JsEditor <JsEditor
v-else-if="editorType === 'jsEditor'" v-else-if="editorType === 'jsEditor'"
ref="inputField"
:model-value="resolvedParameter.value" :model-value="resolvedParameter.value"
:is-read-only="isReadOnly" :is-read-only="isReadOnly"
:rows="editorRows" :rows="editorRows"
@@ -371,6 +423,7 @@ const onResizeThrottle = useThrottleFn(onResize, 10);
@update:model-value="valueChangedDebounced" /> @update:model-value="valueChangedDebounced" />
<JsonEditor <JsonEditor
v-else-if="resolvedParameter.parameter.type === 'json'" v-else-if="resolvedParameter.parameter.type === 'json'"
ref="inputField"
:model-value="resolvedParameter.value" :model-value="resolvedParameter.value"
:is-read-only="isReadOnly" :is-read-only="isReadOnly"
:rows="editorRows" :rows="editorRows"
@@ -496,4 +549,8 @@ const onResizeThrottle = useThrottleFn(onResize, 10);
} }
} }
} }
.heightFull {
height: 100%;
}
</style> </style>

View File

@@ -81,7 +81,11 @@ const extensions = computed(() => [
highlightActiveLine(), highlightActiveLine(),
mappingDropCursor(), mappingDropCursor(),
]); ]);
const { editor: editorRef, readEditorValue } = useExpressionEditor({ const {
editor: editorRef,
readEditorValue,
focus,
} = useExpressionEditor({
editorRef: htmlEditor, editorRef: htmlEditor,
editorValue: () => props.modelValue, editorValue: () => props.modelValue,
extensions, extensions,
@@ -235,6 +239,10 @@ async function onDrop(value: string, event: MouseEvent) {
await dropInExpressionEditor(toRaw(editorRef.value), event, value); await dropInExpressionEditor(toRaw(editorRef.value), event, value);
} }
defineExpose({
focus,
});
</script> </script>
<template> <template>

View File

@@ -107,6 +107,17 @@ const extensions = computed(() => {
} }
return extensionsToApply; return extensionsToApply;
}); });
const focus = () => {
const view = editor.value;
if (view && typeof view.focus === 'function') {
view.focus();
}
};
defineExpose({
focus,
});
</script> </script>
<template> <template>

View File

@@ -106,6 +106,17 @@ function createEditor() {
function destroyEditor() { function destroyEditor() {
editor.value?.destroy(); editor.value?.destroy();
} }
const focus = () => {
const view = editor.value;
if (view && typeof view.focus === 'function') {
view.focus();
}
};
defineExpose({
focus,
});
</script> </script>
<template> <template>

View File

@@ -117,6 +117,7 @@ const {
segments: { all: segments }, segments: { all: segments },
readEditorValue, readEditorValue,
hasFocus: editorHasFocus, hasFocus: editorHasFocus,
focus,
} = useExpressionEditor({ } = useExpressionEditor({
editorRef: sqlEditor, editorRef: sqlEditor,
editorValue: () => props.modelValue, editorValue: () => props.modelValue,
@@ -128,8 +129,8 @@ const {
}, },
}); });
watch(editorHasFocus, (focus) => { watch(editorHasFocus, (hasFocus) => {
if (focus) { if (hasFocus) {
isFocused.value = true; isFocused.value = true;
} }
}); });
@@ -191,6 +192,10 @@ async function onDrop(value: string, event: MouseEvent) {
await dropInExpressionEditor(toRaw(editor.value), event, value); await dropInExpressionEditor(toRaw(editor.value), event, value);
} }
defineExpose({
focus,
});
</script> </script>
<template> <template>

View File

@@ -1,6 +1,6 @@
import { STORES } from '@n8n/stores'; import { STORES } from '@n8n/stores';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { computed } from 'vue'; import { computed, ref } from 'vue';
import get from 'lodash/get'; import get from 'lodash/get';
import { import {
@@ -12,6 +12,7 @@ import {
import { useWorkflowsStore } from './workflows.store'; import { useWorkflowsStore } from './workflows.store';
import { LOCAL_STORAGE_FOCUS_PANEL, PLACEHOLDER_EMPTY_WORKFLOW_ID } from '@/constants'; import { LOCAL_STORAGE_FOCUS_PANEL, PLACEHOLDER_EMPTY_WORKFLOW_ID } from '@/constants';
import { useStorage } from '@/composables/useStorage'; import { useStorage } from '@/composables/useStorage';
import { watchOnce } from '@vueuse/core';
const DEFAULT_PANEL_WIDTH = 528; const DEFAULT_PANEL_WIDTH = 528;
@@ -55,6 +56,8 @@ export const useFocusPanelStore = defineStore(STORES.FOCUS_PANEL, () => {
focusPanelData.value[workflowsStore.workflowId] ?? DEFAULT_FOCUS_PANEL_DATA, focusPanelData.value[workflowsStore.workflowId] ?? DEFAULT_FOCUS_PANEL_DATA,
); );
const lastFocusTimestamp = ref(0);
const focusPanelActive = computed(() => currentFocusPanelData.value.isActive); const focusPanelActive = computed(() => currentFocusPanelData.value.isActive);
const focusPanelWidth = computed(() => currentFocusPanelData.value.width ?? DEFAULT_PANEL_WIDTH); const focusPanelWidth = computed(() => currentFocusPanelData.value.width ?? DEFAULT_PANEL_WIDTH);
const _focusedNodeParameters = computed(() => currentFocusPanelData.value.parameters); const _focusedNodeParameters = computed(() => currentFocusPanelData.value.parameters);
@@ -101,6 +104,10 @@ export const useFocusPanelStore = defineStore(STORES.FOCUS_PANEL, () => {
width, width,
}, },
}); });
if (isActive) {
lastFocusTimestamp.value = Date.now();
}
} }
// When a new workflow is saved, we should update the focus panel data with the new workflow ID // When a new workflow is saved, we should update the focus panel data with the new workflow ID
@@ -144,9 +151,20 @@ export const useFocusPanelStore = defineStore(STORES.FOCUS_PANEL, () => {
return 'value' in p && 'node' in p; return 'value' in p && 'node' in p;
} }
// Ensure lastFocusTimestamp is set on initial load if panel is already active (e.g. after reload)
watchOnce(
() => currentFocusPanelData.value,
(value) => {
if (value.isActive && value.parameters.length > 0) {
lastFocusTimestamp.value = Date.now();
}
},
);
return { return {
focusPanelActive, focusPanelActive,
focusedNodeParameters, focusedNodeParameters,
lastFocusTimestamp,
openWithFocusedNodeParameter, openWithFocusedNodeParameter,
isRichParameter, isRichParameter,
closeFocusPanel, closeFocusPanel,

View File

@@ -2157,7 +2157,11 @@ onBeforeUnmount(() => {
/> />
</Suspense> </Suspense>
</WorkflowCanvas> </WorkflowCanvas>
<FocusPanel v-if="isFocusPanelFeatureEnabled" :is-canvas-read-only="isCanvasReadOnly" /> <FocusPanel
v-if="isFocusPanelFeatureEnabled"
:is-canvas-read-only="isCanvasReadOnly"
@save-keyboard-shortcut="onSaveWorkflow"
/>
</div> </div>
</template> </template>