diff --git a/packages/frontend/editor-ui/src/components/canvas/Canvas.vue b/packages/frontend/editor-ui/src/components/canvas/Canvas.vue index c84a0c5d16..9ceafc56cd 100644 --- a/packages/frontend/editor-ui/src/components/canvas/Canvas.vue +++ b/packages/frontend/editor-ui/src/components/canvas/Canvas.vue @@ -6,7 +6,7 @@ import { useCanvasNodeHover } from '@/composables/useCanvasNodeHover'; import { useCanvasTraversal } from '@/composables/useCanvasTraversal'; import type { ContextMenuAction, ContextMenuTarget } from '@/composables/useContextMenu'; import { useContextMenu } from '@/composables/useContextMenu'; -import { useKeybindings } from '@/composables/useKeybindings'; +import { type KeyMap, useKeybindings } from '@/composables/useKeybindings'; import type { PinDataSource } from '@/composables/usePinnedData'; import { CanvasKey } from '@/constants'; import type { NodeCreatorOpenSource } from '@/Interface'; @@ -54,6 +54,7 @@ import CanvasArrowHeadMarker from './elements/edges/CanvasArrowHeadMarker.vue'; import Edge from './elements/edges/CanvasEdge.vue'; import Node from './elements/nodes/CanvasNode.vue'; import { useViewportAutoAdjust } from '@/components/canvas/composables/useViewportAutoAdjust'; +import { isOutsideSelected } from '@/utils/htmlUtils'; const $style = useCssModule(); @@ -278,9 +279,12 @@ function selectUpstreamNodes(id: string) { } const keyMap = computed(() => { - const readOnlyKeymap = { + const readOnlyKeymap: KeyMap = { ctrl_shift_o: emitWithLastSelectedNode((id) => emit('open:sub-workflow', id)), - ctrl_c: emitWithSelectedNodes((ids) => emit('copy:nodes', ids)), + ctrl_c: { + disabled: () => isOutsideSelected(viewportRef.value), + run: emitWithSelectedNodes((ids) => emit('copy:nodes', ids)), + }, enter: emitWithLastSelectedNode((id) => onSetNodeActivated(id)), ctrl_a: () => addSelectedNodes(graphNodes.value), // Support both key and code for zooming in and out @@ -301,7 +305,7 @@ const keyMap = computed(() => { if (props.readOnly) return readOnlyKeymap; - const fullKeymap = { + const fullKeymap: KeyMap = { ...readOnlyKeymap, ctrl_x: emitWithSelectedNodes((ids) => emit('cut:nodes', ids)), 'delete|backspace': emitWithSelectedNodes((ids) => emit('delete:nodes', ids)), diff --git a/packages/frontend/editor-ui/src/composables/useKeybindings.ts b/packages/frontend/editor-ui/src/composables/useKeybindings.ts index 3de381fd6c..6fe02a4cbc 100644 --- a/packages/frontend/editor-ui/src/composables/useKeybindings.ts +++ b/packages/frontend/editor-ui/src/composables/useKeybindings.ts @@ -3,7 +3,11 @@ import { useDeviceSupport } from '@n8n/composables/useDeviceSupport'; import type { MaybeRef, Ref } from 'vue'; import { computed, unref } from 'vue'; -type KeyMap = Record void>; +type KeyboardEventHandler = + | ((event: KeyboardEvent) => void) + | { disabled: () => boolean; run: (event: KeyboardEvent) => void }; + +export type KeyMap = Partial>; /** * Binds a `keydown` event to `document` and calls the approriate @@ -134,11 +138,13 @@ export const useKeybindings = ( // - Dvorak works correctly // - Non-ansi layouts work correctly const handler = normalizedKeymap.value[byKey] ?? normalizedKeymap.value[byCode]; + const run = + typeof handler === 'function' ? handler : handler?.disabled() ? undefined : handler?.run; - if (handler) { + if (run) { event.preventDefault(); event.stopPropagation(); - handler(event); + run(event); } } diff --git a/packages/frontend/editor-ui/src/features/logs/components/LogsPanel.vue b/packages/frontend/editor-ui/src/features/logs/components/LogsPanel.vue index ab3688bdfe..bd9aabebe1 100644 --- a/packages/frontend/editor-ui/src/features/logs/components/LogsPanel.vue +++ b/packages/frontend/editor-ui/src/features/logs/components/LogsPanel.vue @@ -14,6 +14,8 @@ import { useLogsTreeExpand } from '@/features/logs/composables/useLogsTreeExpand import { type LogEntry } from '@/features/logs/logs.types'; import { useLogsStore } from '@/stores/logs.store'; import { useLogsPanelLayout } from '@/features/logs/composables/useLogsPanelLayout'; +import { type KeyMap, useKeybindings } from '@/composables/useKeybindings'; +import { useActiveElement } from '@vueuse/core'; const props = withDefaults(defineProps<{ isReadOnly?: boolean }>(), { isReadOnly: false }); @@ -77,6 +79,25 @@ const logsPanelActionsProps = computed['$p onToggleOpen, onToggleSyncSelection: logsStore.toggleLogSelectionSync, })); +const activeElement = useActiveElement(); +const isBlurred = computed( + () => + !activeElement.value || + !container.value || + (!container.value.contains(activeElement.value) && container.value !== activeElement.value), +); + +const localKeyMap = computed(() => ({ + j: selectNext, + k: selectPrev, + Escape: () => select(undefined), + ArrowDown: selectNext, + ArrowUp: selectPrev, + Space: () => selected.value && toggleExpanded(selected.value), + Enter: () => selected.value && handleOpenNdv(selected.value), +})); + +useKeybindings(localKeyMap, { disabled: isBlurred }); function handleResizeOverviewPanelEnd() { if (isOverviewPanelFullWidth.value) { @@ -86,10 +107,10 @@ function handleResizeOverviewPanelEnd() { onOverviewPanelResizeEnd(); } -async function handleOpenNdv(treeNode: LogEntry) { +function handleOpenNdv(treeNode: LogEntry) { ndvStore.setActiveNodeName(treeNode.node.name); - await nextTick(() => { + void nextTick(() => { const source = treeNode.runData?.source[0]; const inputBranch = source?.previousNodeOutput ?? 0; @@ -112,18 +133,7 @@ async function handleOpenNdv(treeNode: LogEntry) { @resize="onResize" @resizeend="onResizeEnd" > -
+
{ // Looks smoother if we wait for slide animation to finish before updating the grid width // Has to wait for longer than SlideTransition duration setTimeout(() => { + if (!window) { + return; // for unit testing + } + uiStore.appGridDimensions = { ...uiStore.appGridDimensions, width: window.innerWidth, diff --git a/packages/frontend/editor-ui/src/utils/htmlUtils.ts b/packages/frontend/editor-ui/src/utils/htmlUtils.ts index f1d6119adb..d5f251888c 100644 --- a/packages/frontend/editor-ui/src/utils/htmlUtils.ts +++ b/packages/frontend/editor-ui/src/utils/htmlUtils.ts @@ -4,7 +4,6 @@ import { ALLOWED_HTML_ATTRIBUTES, ALLOWED_HTML_TAGS } from '@/constants'; /* Constants and utility functions that help in HTML, CSS and DOM manipulation */ - export function sanitizeHtml(dirtyHtml: string) { const sanitizedHtml = xss(dirtyHtml, { onTagAttr: (tag, name, value) => { @@ -48,21 +47,6 @@ export const sanitizeIfString = (message: T): string | T => { return message; }; -export function convertRemToPixels(rem: string) { - return parseInt(rem, 10) * parseFloat(getComputedStyle(document.documentElement).fontSize); -} - -export function isChildOf(parent: Element, child: Element): boolean { - if (child.parentElement === null) { - return false; - } - if (child.parentElement === parent) { - return true; - } - - return isChildOf(parent, child.parentElement); -} - export const capitalizeFirstLetter = (text: string): string => { return text.charAt(0).toUpperCase() + text.slice(1); }; @@ -74,3 +58,18 @@ export const getBannerRowHeight = async (): Promise => { }, 0); }); }; + +export function isOutsideSelected(el: HTMLElement | null) { + const selection = document.getSelection(); + + if (!selection?.anchorNode || !selection.focusNode || !el) { + return false; + } + + return ( + !el.contains(selection.anchorNode) && + !el.contains(selection.focusNode) && + (selection.anchorNode !== selection.focusNode || + selection.anchorOffset !== selection.focusOffset) + ); +}