feat(editor): Toggle command palette closed with Cmd+F and refocus canvas

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Charlie Kolb 2026-08-12 09:53:15 +02:00
parent e4aa5f0cb3
commit 33f3a16e36
No known key found for this signature in database
5 changed files with 57 additions and 12 deletions

View File

@ -152,20 +152,22 @@ const scrollSelectedIntoView = () => {
});
};
// Watch instead of imperative open so parents can open via the `open` model too
// Watch instead of imperative open/close so parents can drive the `open` model too
watch(isOpen, async (open) => {
if (!open) return;
selectedIndex.value = 0;
inputValue.value = '';
await nextTick();
inputRef.value?.focus();
if (open) {
selectedIndex.value = 0;
inputValue.value = '';
await nextTick();
inputRef.value?.focus();
} else {
selectedIndex.value = -1;
inputValue.value = '';
currentParentId.value = null;
}
});
const closeCommandBar = () => {
isOpen.value = false;
selectedIndex.value = -1;
inputValue.value = '';
currentParentId.value = null;
};
const navigateToChildren = (item: CommandBarItem) => {

View File

@ -6,6 +6,7 @@ import { type MockedStore, mockedStore } from '@/__tests__/utils';
import { defaultSettings } from '@/__tests__/defaults';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { commandBarEventBus } from '@/features/shared/commandBar/commandBar.eventBus';
import { canvasEventBus } from '@/features/workflows/canvas/canvas.eventBus';
import AppCommandBar from './AppCommandBar.vue';
vi.mock('@/app/utils/rbac/permissions', () => ({
@ -65,6 +66,21 @@ describe('AppCommandBar', () => {
await waitFor(() => expect(getByTestId('command-bar-stub').dataset.open).toBe('true'));
});
it('closes the command bar on Cmd+F and refocuses the canvas', async () => {
const emitSpy = vi.spyOn(canvasEventBus, 'emit');
const { getByTestId } = renderComponent({ pinia });
commandBarEventBus.emit('open:request');
await waitFor(() => expect(getByTestId('command-bar-stub').dataset.open).toBe('true'));
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'f', metaKey: true, cancelable: true }),
);
await waitFor(() => expect(getByTestId('command-bar-stub').dataset.open).toBe('false'));
await waitFor(() => expect(emitSpy).toHaveBeenCalledWith('focus'));
});
it('does not render the command bar in canvas-only mode', () => {
settingsStore.settings = { ...defaultSettings, canvasOnly: true };

View File

@ -1,6 +1,7 @@
<script setup lang="ts">
import { N8nCommandBar } from '@n8n/design-system';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { canvasEventBus } from '@/features/workflows/canvas/canvas.eventBus';
import { useRoute } from 'vue-router';
import { VIEWS } from '@/app/constants';
import { useStyles } from '@n8n/composables/useStyles';
@ -47,8 +48,24 @@ function onOpenRequest() {
isCommandBarOpen.value = true;
}
onMounted(() => commandBarEventBus.on('open:request', onOpenRequest));
onBeforeUnmount(() => commandBarEventBus.off('open:request', onOpenRequest));
// Cmd+F toggles: the canvas keymap opens, but can't close while the bar's input has focus
function onDocumentKeydown(event: KeyboardEvent) {
if (!isCommandBarOpen.value) return;
if ((event.metaKey || event.ctrlKey) && event.key === 'f') {
event.preventDefault();
isCommandBarOpen.value = false;
void nextTick(() => canvasEventBus.emit('focus'));
}
}
onMounted(() => {
commandBarEventBus.on('open:request', onOpenRequest);
document.addEventListener('keydown', onDocumentKeydown, { capture: true });
});
onBeforeUnmount(() => {
commandBarEventBus.off('open:request', onOpenRequest);
document.removeEventListener('keydown', onDocumentKeydown, { capture: true });
});
</script>
<template>

View File

@ -280,6 +280,8 @@ export type CanvasEventBusEvents = {
};
'create:sticky': never;
'deprecated:tab-shortcut': never;
/** Move keyboard focus back to the canvas element */
focus: never;
};
export interface CanvasNodeInjectionData {

View File

@ -233,6 +233,7 @@ const {
addSelectedNodes,
removeSelectedNodes,
viewportRef,
vueFlowRef,
fitView,
fitBounds,
zoomIn,
@ -1673,6 +1674,10 @@ function onRequestSetConnectionsOnInit(connections: IConnections) {
pendingConnections = connections;
}
function onFocusCanvas() {
vueFlowRef.value?.focus();
}
onMounted(() => {
props.eventBus.on('fitView', onFitView);
props.eventBus.on('fitView:onNodesInit', onRequestFitViewOnInit);
@ -1680,6 +1685,7 @@ onMounted(() => {
props.eventBus.on('nodes:select', onSelectNodes);
props.eventBus.on('nodes:selectAll', onSelectAllNodes);
props.eventBus.on('tidyUp', onTidyUp);
props.eventBus.on('focus', onFocusCanvas);
window.addEventListener('blur', onWindowBlur);
document.addEventListener('visibilitychange', onVisibilityChange);
});
@ -1691,6 +1697,7 @@ onUnmounted(() => {
props.eventBus.off('nodes:select', onSelectNodes);
props.eventBus.off('nodes:selectAll', onSelectAllNodes);
props.eventBus.off('tidyUp', onTidyUp);
props.eventBus.off('focus', onFocusCanvas);
window.removeEventListener('blur', onWindowBlur);
document.removeEventListener('visibilitychange', onVisibilityChange);
});
@ -1794,6 +1801,7 @@ defineExpose({
:pan-activation-key-code="panningKeyCode"
:disable-keyboard-a11y="true"
:delete-key-code="null"
tabindex="-1"
data-test-id="canvas"
@connect-start="onConnectStart"
@connect="onConnect"