fix(editor): Fix keyboard shortcut bugs in the log view (#16393)

This commit is contained in:
Suguru Inoue
2025-06-17 11:38:32 +02:00
committed by GitHub
parent 9805bd3a6d
commit 4acebabb4f
5 changed files with 60 additions and 37 deletions

View File

@@ -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)),

View File

@@ -3,7 +3,11 @@ import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
import type { MaybeRef, Ref } from 'vue';
import { computed, unref } from 'vue';
type KeyMap = Record<string, (event: KeyboardEvent) => void>;
type KeyboardEventHandler =
| ((event: KeyboardEvent) => void)
| { disabled: () => boolean; run: (event: KeyboardEvent) => void };
export type KeyMap = Partial<Record<string, KeyboardEventHandler>>;
/**
* 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);
}
}

View File

@@ -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<InstanceType<typeof LogsPanelActions>['$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<KeyMap>(() => ({
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"
>
<div
ref="container"
:class="$style.container"
tabindex="-1"
@keydown.esc.exact.stop="select(undefined)"
@keydown.j.exact.stop="selectNext"
@keydown.down.exact.stop.prevent="selectNext"
@keydown.k.exact.stop="selectPrev"
@keydown.up.exact.stop.prevent="selectPrev"
@keydown.space.exact.stop="selected && toggleExpanded(selected)"
@keydown.enter.exact.stop="selected && handleOpenNdv(selected)"
>
<div ref="container" :class="$style.container" tabindex="-1">
<N8nResizeWrapper
v-if="hasChat && (!props.isReadOnly || messages.length > 0)"
:supported-directions="['right']"

View File

@@ -95,6 +95,10 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
// 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,

View File

@@ -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 = <T>(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<number> => {
}, 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)
);
}