diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index c345aa0498..3b3996d0dd 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -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", diff --git a/packages/editor-ui/src/__tests__/data/canvas.ts b/packages/editor-ui/src/__tests__/data/canvas.ts index f8a58b263a..780693ca46 100644 --- a/packages/editor-ui/src/__tests__/data/canvas.ts +++ b/packages/editor-ui/src/__tests__/data/canvas.ts @@ -55,7 +55,7 @@ export function createCanvasNodeData({ export function createCanvasNodeElement({ id = '1', - type = 'node', + type = 'default', label = 'Node', position = { x: 100, y: 100 }, data, diff --git a/packages/editor-ui/src/components/canvas/Canvas.test.ts b/packages/editor-ui/src/components/canvas/Canvas.test.ts index 41f1f7ad0f..ec8a3f36ca 100644 --- a/packages/editor-ui/src/components/canvas/Canvas.test.ts +++ b/packages/editor-ui/src/components/canvas/Canvas.test.ts @@ -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; 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; diff --git a/packages/editor-ui/src/components/canvas/Canvas.vue b/packages/editor-ui/src/components/canvas/Canvas.vue index c414a0641f..b76f724845 100644 --- a/packages/editor-ui/src/components/canvas/Canvas.vue +++ b/packages/editor-ui/src/components/canvas/Canvas.vue @@ -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(isMobileDevice ? true : [' ', controlKeyCode]); const panningMouseButton = ref(isMobileDevice ? true : [1]); const selectionKeyCode = ref(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]; diff --git a/packages/editor-ui/src/views/NodeView.v2.vue b/packages/editor-ui/src/views/NodeView.v2.vue index 3ca2e55799..ac6cbd02d3 100644 --- a/packages/editor-ui/src/views/NodeView.v2.vue +++ b/packages/editor-ui/src/views/NodeView.v2.vue @@ -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') + ':', diff --git a/packages/frontend/@n8n/composables/package.json b/packages/frontend/@n8n/composables/package.json index eeb1e1b7c0..92c125461c 100644 --- a/packages/frontend/@n8n/composables/package.json +++ b/packages/frontend/@n8n/composables/package.json @@ -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" } diff --git a/packages/frontend/@n8n/composables/src/useShortKeyPress.test.ts b/packages/frontend/@n8n/composables/src/useShortKeyPress.test.ts new file mode 100644 index 0000000000..451f30a494 --- /dev/null +++ b/packages/frontend/@n8n/composables/src/useShortKeyPress.test.ts @@ -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(); + }); +}); diff --git a/packages/frontend/@n8n/composables/src/useShortKeyPress.ts b/packages/frontend/@n8n/composables/src/useShortKeyPress.ts new file mode 100644 index 0000000000..3725d005be --- /dev/null +++ b/packages/frontend/@n8n/composables/src/useShortKeyPress.ts @@ -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; + }, +) { + const keyDownTime = ref(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(); + } + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b4abeca3f9..7a6425db20 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a404453b45..a719f95177 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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