feat(editor): Change rename node keyboard shortcut to Space on new canvas (#11872)

This commit is contained in:
Alex Grozav
2025-02-17 17:56:40 +02:00
committed by GitHub
parent a5401d06a5
commit c90d0d9161
10 changed files with 238 additions and 28 deletions

View File

@@ -50,7 +50,7 @@
"@vue-flow/minimap": "^1.5.2",
"@vue-flow/node-resizer": "^1.4.0",
"@vueuse/components": "^10.11.0",
"@vueuse/core": "^10.11.0",
"@vueuse/core": "catalog:frontend",
"axios": "catalog:",
"bowser": "2.11.0",
"change-case": "^5.4.4",

View File

@@ -55,7 +55,7 @@ export function createCanvasNodeData({
export function createCanvasNodeElement({
id = '1',
type = 'node',
type = 'default',
label = 'Node',
position = { x: 100, y: 100 },
data,

View File

@@ -7,6 +7,7 @@ import type { CanvasConnection, CanvasNode } from '@/types';
import { createCanvasConnection, createCanvasNodeElement } from '@/__tests__/data';
import { NodeConnectionType } from 'n8n-workflow';
import type { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
import { useVueFlow } from '@vue-flow/core';
const matchMedia = global.window.matchMedia;
// @ts-expect-error Initialize window object
@@ -18,12 +19,21 @@ vi.mock('n8n-design-system', async (importOriginal) => {
return { ...actual, useDeviceSupport: vi.fn(() => ({ isCtrlKeyPressed: vi.fn() })) };
});
const canvasId = 'canvas';
let renderComponent: ReturnType<typeof createComponentRenderer>;
beforeEach(() => {
const pinia = createPinia();
setActivePinia(pinia);
renderComponent = createComponentRenderer(Canvas, { pinia });
renderComponent = createComponentRenderer(Canvas, {
pinia,
props: {
id: canvasId,
nodes: [],
connections: [],
},
});
});
afterEach(() => {
@@ -86,7 +96,7 @@ describe('Canvas', () => {
expect(container.querySelector(`[data-id="${connections[0].id}"]`)).toBeInTheDocument();
});
it('should handle `update:nodes:position` event', async () => {
it('should emit `update:nodes:position` event', async () => {
const nodes = [createCanvasNodeElement()];
const { container, emitted } = renderComponent({
props: {
@@ -122,6 +132,55 @@ describe('Canvas', () => {
]);
});
it('should emit `update:node:name` event', async () => {
const nodes = [createCanvasNodeElement()];
const { container, emitted } = renderComponent({
props: {
nodes,
},
});
await waitFor(() => expect(container.querySelectorAll('.vue-flow__node')).toHaveLength(1));
const node = container.querySelector(`[data-id="${nodes[0].id}"]`) as Element;
const { addSelectedNodes, nodes: graphNodes } = useVueFlow({ id: canvasId });
addSelectedNodes(graphNodes.value);
await waitFor(() => expect(container.querySelector('.selected')).toBeInTheDocument());
await fireEvent.keyDown(node, { key: ' ', view: window });
await fireEvent.keyUp(node, { key: ' ', view: window });
expect(emitted()['update:node:name']).toEqual([['1']]);
});
it('should not emit `update:node:name` event if long key press', async () => {
vi.useFakeTimers();
const nodes = [createCanvasNodeElement()];
const { container, emitted } = renderComponent({
props: {
nodes,
},
});
await waitFor(() => expect(container.querySelectorAll('.vue-flow__node')).toHaveLength(1));
const node = container.querySelector(`[data-id="${nodes[0].id}"]`) as Element;
const { addSelectedNodes, nodes: graphNodes } = useVueFlow({ id: canvasId });
addSelectedNodes(graphNodes.value);
await waitFor(() => expect(container.querySelector('.selected')).toBeInTheDocument());
await fireEvent.keyDown(node, { key: ' ', view: window });
await vi.advanceTimersByTimeAsync(1000);
await fireEvent.keyUp(node, { key: ' ', view: window });
expect(emitted()['update:node:name']).toBeUndefined();
});
describe('minimap', () => {
const minimapVisibilityDelay = 1000;
const minimapTransitionDuration = 300;

View File

@@ -22,6 +22,7 @@ import { computed, onMounted, onUnmounted, provide, ref, toRef, useCssModule, wa
import type { EventBus } from 'n8n-design-system';
import { createEventBus } from 'n8n-design-system';
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
import { useShortKeyPress } from '@n8n/composables/useShortKeyPress';
import { useContextMenu, type ContextMenuAction } from '@/composables/useContextMenu';
import { useKeybindings } from '@/composables/useKeybindings';
import ContextMenu from '@/components/ContextMenu/ContextMenu.vue';
@@ -145,29 +146,55 @@ const classes = computed(() => ({
}));
/**
* Key bindings
*/
const disableKeyBindings = computed(() => !props.keyBindings);
/**
* @see https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values#whitespace_keys
* Panning and Selection key bindings
*/
// @see https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values#whitespace_keys
const panningKeyCode = ref<string[] | true>(isMobileDevice ? true : [' ', controlKeyCode]);
const panningMouseButton = ref<number[] | true>(isMobileDevice ? true : [1]);
const selectionKeyCode = ref<string | true | null>(isMobileDevice ? 'Shift' : true);
onKeyDown(panningKeyCode.value, () => {
selectionKeyCode.value = null;
panningMouseButton.value = [0, 1];
});
onKeyDown(
panningKeyCode.value,
() => {
selectionKeyCode.value = null;
panningMouseButton.value = [0, 1];
},
{
dedupe: true,
},
);
onKeyUp(panningKeyCode.value, () => {
selectionKeyCode.value = true;
panningMouseButton.value = [1];
});
/**
* Rename node key bindings
* We differentiate between short and long press because the space key is also used for activating panning
*/
const renameKeyCode = ' ';
useShortKeyPress(
renameKeyCode,
() => {
if (lastSelectedNode.value) {
emit('update:node:name', lastSelectedNode.value.id);
}
},
{
disabled: toRef(props, 'readOnly'),
},
);
/**
* Key bindings
*/
const disableKeyBindings = computed(() => !props.keyBindings);
function selectLeftNode(id: string) {
const incomingNodes = getIncomingNodes(id);
const previousNode = incomingNodes[0];

View File

@@ -745,6 +745,9 @@ function onRenameNode(parameterData: IUpdateInformation) {
async function onOpenRenameNodeModal(id: string) {
const currentName = workflowsStore.getNodeById(id)?.name ?? '';
if (!keyBindingsEnabled.value || document.querySelector('.rename-prompt')) return;
try {
const promptResponsePromise = message.prompt(
i18n.baseText('nodeView.prompt.newName') + ':',

View File

@@ -24,9 +24,6 @@
"format": "biome format --write . && prettier --write . --ignore-path ../../../../.prettierignore",
"format:check": "biome ci . && prettier --check . --ignore-path ../../../../.prettierignore"
},
"dependencies": {
"vue": "catalog:frontend"
},
"devDependencies": {
"@n8n/frontend-eslint-config": "workspace:*",
"@n8n/frontend-typescript-config": "workspace:*",
@@ -36,6 +33,8 @@
"@testing-library/vue": "catalog:frontend",
"@vitejs/plugin-vue": "catalog:frontend",
"@vue/tsconfig": "catalog:frontend",
"@vueuse/core": "catalog:frontend",
"vue": "catalog:frontend",
"tsup": "catalog:frontend",
"typescript": "catalog:frontend",
"vite": "catalog:frontend",
@@ -43,5 +42,9 @@
"vitest": "catalog:frontend",
"vue-tsc": "catalog:frontend"
},
"peerDependencies": {
"@vueuse/core": "catalog:frontend",
"vue": "catalog:frontend"
},
"license": "See LICENSE.md file in the root of the repository"
}

View File

@@ -0,0 +1,71 @@
import { onKeyDown, onKeyUp } from '@vueuse/core';
import { ref } from 'vue';
import { useShortKeyPress } from './useShortKeyPress';
vi.mock('@vueuse/core', () => ({
onKeyDown: vi.fn(),
onKeyUp: vi.fn(),
}));
describe('useShortKeyPress', () => {
it('should call the function on short key press', async () => {
vi.useFakeTimers();
const fn = vi.fn();
const key = 'a';
const threshold = 300;
const disabled = ref(false);
useShortKeyPress(key, fn, { threshold, disabled });
const keyDownHandler = vi.mocked(onKeyDown).mock.calls[0][1];
const keyUpHandler = vi.mocked(onKeyUp).mock.calls[0][1];
keyDownHandler(new KeyboardEvent('keydown', { key }));
await vi.advanceTimersByTimeAsync(100);
keyUpHandler(new KeyboardEvent('keydown', { key }));
expect(fn).toHaveBeenCalled();
});
it('should not call the function if key press duration exceeds threshold', async () => {
vi.useFakeTimers();
const fn = vi.fn();
const key = 'a';
const threshold = 300;
const disabled = ref(false);
useShortKeyPress(key, fn, { threshold, disabled });
const keyDownHandler = vi.mocked(onKeyDown).mock.calls[0][1];
const keyUpHandler = vi.mocked(onKeyUp).mock.calls[0][1];
keyDownHandler(new KeyboardEvent('keydown', { key }));
await vi.advanceTimersByTimeAsync(400);
keyUpHandler(new KeyboardEvent('keydown', { key }));
expect(fn).not.toHaveBeenCalled();
});
it('should not call the function if disabled is true', async () => {
vi.useFakeTimers();
const fn = vi.fn();
const key = 'a';
const threshold = 300;
const disabled = ref(true);
useShortKeyPress(key, fn, { threshold, disabled });
const keyDownHandler = vi.mocked(onKeyDown).mock.calls[0][1];
const keyUpHandler = vi.mocked(onKeyUp).mock.calls[0][1];
keyDownHandler(new KeyboardEvent('keydown', { key }));
await vi.advanceTimersByTimeAsync(100);
keyUpHandler(new KeyboardEvent('keydown', { key }));
expect(fn).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,41 @@
import { onKeyDown, onKeyUp } from '@vueuse/core';
import type { KeyFilter } from '@vueuse/core';
import { ref, unref } from 'vue';
import type { MaybeRefOrGetter } from 'vue';
export function useShortKeyPress(
key: KeyFilter,
fn: () => void,
{
dedupe = true,
threshold = 300,
disabled = false,
}: {
dedupe?: boolean;
threshold?: number;
disabled?: MaybeRefOrGetter<boolean>;
},
) {
const keyDownTime = ref<number | null>(null);
onKeyDown(
key,
() => {
if (unref(disabled)) return;
keyDownTime.value = Date.now();
},
{
dedupe,
},
);
onKeyUp(key, () => {
if (unref(disabled) || !keyDownTime.value) return;
const isShortPress = Date.now() - keyDownTime.value < threshold;
if (isShortPress) {
fn();
}
});
}

25
pnpm-lock.yaml generated
View File

@@ -97,6 +97,9 @@ catalogs:
'@vue/tsconfig':
specifier: ^0.7.0
version: 0.7.0
'@vueuse/core':
specifier: ^10.11.0
version: 10.11.0
highlight.js:
specifier: ^11.8.0
version: 11.9.0
@@ -1490,7 +1493,7 @@ importers:
specifier: ^10.11.0
version: 10.11.0(vue@3.5.13(typescript@5.7.2))
'@vueuse/core':
specifier: ^10.11.0
specifier: catalog:frontend
version: 10.11.0(vue@3.5.13(typescript@5.7.2))
axios:
specifier: 'catalog:'
@@ -1702,10 +1705,6 @@ importers:
version: 2.1.10(patch_hash=e2aee939ccac8a57fe449bfd92bedd8117841579526217bc39aca26c6b8c317f)(typescript@5.7.2)
packages/frontend/@n8n/composables:
dependencies:
vue:
specifier: catalog:frontend
version: 3.5.13(typescript@5.7.2)
devDependencies:
'@n8n/frontend-eslint-config':
specifier: workspace:*
@@ -1731,6 +1730,9 @@ importers:
'@vue/tsconfig':
specifier: catalog:frontend
version: 0.7.0(typescript@5.7.2)(vue@3.5.13(typescript@5.7.2))
'@vueuse/core':
specifier: catalog:frontend
version: 10.11.0(vue@3.5.13(typescript@5.7.2))
tsup:
specifier: catalog:frontend
version: 8.3.6(@microsoft/api-extractor@7.48.0(@types/node@18.16.16))(jiti@1.21.0)(postcss@8.4.49)(typescript@5.7.2)
@@ -1746,6 +1748,9 @@ importers:
vitest:
specifier: catalog:frontend
version: 3.0.5(@types/debug@4.1.12)(@types/node@18.16.16)(jiti@1.21.0)(jsdom@23.0.1)(sass@1.64.1)(terser@5.16.1)
vue:
specifier: catalog:frontend
version: 3.5.13(typescript@5.7.2)
vue-tsc:
specifier: ^2.1.10
version: 2.1.10(patch_hash=e2aee939ccac8a57fe449bfd92bedd8117841579526217bc39aca26c6b8c317f)(typescript@5.7.2)
@@ -13329,8 +13334,8 @@ packages:
vue-component-type-helpers@2.1.10:
resolution: {integrity: sha512-lfgdSLQKrUmADiSV6PbBvYgQ33KF3Ztv6gP85MfGaGaSGMTXORVaHT1EHfsqCgzRNBstPKYDmvAV9Do5CmJ07A==}
vue-component-type-helpers@2.2.0:
resolution: {integrity: sha512-cYrAnv2me7bPDcg9kIcGwjJiSB6Qyi08+jLDo9yuvoFQjzHiPTzML7RnkJB1+3P6KMsX/KbCD4QE3Tv/knEllw==}
vue-component-type-helpers@2.2.2:
resolution: {integrity: sha512-6lLY+n2xz2kCYshl59mL6gy8OUUTmkscmDFMO8i7Lj+QKwgnIFUZmM1i/iTYObtrczZVdw7UakPqDTGwVSGaRg==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -18482,7 +18487,7 @@ snapshots:
ts-dedent: 2.2.0
type-fest: 2.19.0
vue: 3.5.13(typescript@5.7.2)
vue-component-type-helpers: 2.2.0
vue-component-type-helpers: 2.2.2
'@supabase/auth-js@2.65.0':
dependencies:
@@ -19509,7 +19514,7 @@ snapshots:
'@vue/test-utils@2.4.6':
dependencies:
js-beautify: 1.14.9
vue-component-type-helpers: 2.2.0
vue-component-type-helpers: 2.2.2
'@vue/tsconfig@0.7.0(typescript@5.7.2)(vue@3.5.13(typescript@5.7.2))':
optionalDependencies:
@@ -27795,7 +27800,7 @@ snapshots:
vue-component-type-helpers@2.1.10: {}
vue-component-type-helpers@2.2.0: {}
vue-component-type-helpers@2.2.2: {}
vue-demi@0.14.10(vue@3.5.13(typescript@5.7.2)):
dependencies:

View File

@@ -37,6 +37,7 @@ catalogs:
'@testing-library/user-event': ^14.6.0
'@testing-library/vue': ^8.1.0
'@vue/tsconfig': ^0.7.0
'@vueuse/core': ^10.11.0
'@vitest/coverage-v8': ^3.0.5
'@vitejs/plugin-vue': ^5.2.1
'@sentry/vue': ^8.33.1