feat(editor): Zoom into a node to open experimental embedded NDV (no-changelog) (#16912)

Co-authored-by: Milorad FIlipović <milorad@n8n.io>
This commit is contained in:
Suguru Inoue
2025-07-09 11:50:30 +02:00
committed by GitHub
parent f67581b74d
commit ba692281f0
12 changed files with 152 additions and 53 deletions

View File

@@ -66,6 +66,7 @@ import IconLucideCode from '~icons/lucide/code';
import IconLucideCog from '~icons/lucide/cog';
import IconLucideContrast from '~icons/lucide/contrast';
import IconLucideCopy from '~icons/lucide/copy';
import IconLucideCrosshair from '~icons/lucide/crosshair';
import IconLucideDatabase from '~icons/lucide/database';
import IconLucideEarth from '~icons/lucide/earth';
import IconLucideEllipsis from '~icons/lucide/ellipsis';
@@ -460,6 +461,7 @@ export const updatedIconSet = {
cog: IconLucideCog,
contrast: IconLucideContrast,
copy: IconLucideCopy,
crosshair: IconLucideCrosshair,
database: IconLucideDatabase,
earth: IconLucideEarth,
ellipsis: IconLucideEllipsis,

View File

@@ -1556,6 +1556,8 @@
"nodeView.zoomToFit": "Zoom to Fit",
"nodeView.replaceMe": "Replace Me",
"nodeView.setupTemplate": "Set up template",
"nodeView.expandAllNodes": "Expand all nodes",
"nodeView.collapseAllNodes": "Collapse all nodes",
"nodeViewV2.showError.editingNotAllowed": "Editing is not allowed",
"nodeViewV2.showError.failedToCreateNode": "Failed to create node",
"contextMenu.node": "node | nodes",

View File

@@ -20,7 +20,6 @@ import type {
import { useActions } from './NodeCreator/composables/useActions';
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
import { useI18n } from '@n8n/i18n';
import { useExperimentalNdvStore } from '../canvas/experimental/experimentalNdv.store';
type Props = {
nodeViewScale: number;
@@ -45,7 +44,6 @@ const uiStore = useUIStore();
const focusPanelStore = useFocusPanelStore();
const posthogStore = usePostHog();
const i18n = useI18n();
const experimentalNdvStore = useExperimentalNdvStore();
const { getAddedNodesAndConnections } = useActions();
@@ -127,20 +125,6 @@ function nodeTypeSelected(value: NodeTypeSelectedPayload[]) {
@click="focusPanelStore.toggleFocusPanel"
/>
</KeyboardShortcutTooltip>
<n8n-icon-button
v-if="experimentalNdvStore.isEnabled"
type="tertiary"
size="large"
icon="maximize-2"
@click="experimentalNdvStore.expandAllNodes"
/>
<n8n-icon-button
v-if="experimentalNdvStore.isEnabled"
type="tertiary"
size="large"
icon="minimize-2"
@click="experimentalNdvStore.collapseAllNodes"
/>
</div>
<Suspense>
<LazyNodeCreator

View File

@@ -4,6 +4,7 @@ import TidyUpIcon from '@/components/TidyUpIcon.vue';
import { useI18n } from '@n8n/i18n';
import { Controls } from '@vue-flow/controls';
import { computed } from 'vue';
import { useExperimentalNdvStore } from '../../experimental/experimentalNdv.store';
const props = withDefaults(
defineProps<{
@@ -26,6 +27,8 @@ const emit = defineEmits<{
const i18n = useI18n();
const experimentalNdvStore = useExperimentalNdvStore();
const isResetZoomVisible = computed(() => props.zoom !== 1);
function onResetZoom() {
@@ -109,6 +112,30 @@ function onTidyUp() {
<TidyUpIcon />
</N8nButton>
</KeyboardShortcutTooltip>
<N8nTooltip
v-if="experimentalNdvStore.isActive(props.zoom)"
placement="top"
:content="i18n.baseText('nodeView.expandAllNodes')"
>
<N8nIconButton
type="tertiary"
size="large"
icon="maximize-2"
@click="experimentalNdvStore.expandAllNodes"
/>
</N8nTooltip>
<N8nTooltip
v-if="experimentalNdvStore.isActive(props.zoom)"
placement="top"
:content="i18n.baseText('nodeView.collapseAllNodes')"
>
<N8nIconButton
type="tertiary"
size="large"
icon="minimize-2"
@click="experimentalNdvStore.collapseAllNodes"
/>
</N8nTooltip>
</Controls>
</template>

View File

@@ -21,5 +21,7 @@ exports[`CanvasControlButtons > should render correctly 1`] = `
<n8n-button-stub block="false" element="button" label="" square="true" active="false" disabled="false" loading="false" outline="false" size="large" text="false" type="tertiary" data-test-id="tidy-up-button" class="iconButton el-tooltip__trigger el-tooltip__trigger"></n8n-button-stub>
<!--teleport start-->
<!--teleport end-->
<!--v-if-->
<!--v-if-->
</div>"
`;

View File

@@ -35,6 +35,7 @@ import { createEventBus } from '@n8n/utils/event-bus';
import isEqual from 'lodash/isEqual';
import CanvasNodeTrigger from '@/components/canvas/elements/nodes/render-types/parts/CanvasNodeTrigger.vue';
import { CONFIGURATION_NODE_OFFSET, GRID_SIZE } from '@/utils/nodeViewUtils';
import { useExperimentalNdvStore } from '../../experimental/experimentalNdv.store';
type Props = NodeProps<CanvasNodeData> & {
readOnly?: boolean;
@@ -72,7 +73,9 @@ const props = defineProps<Props>();
const contextMenu = useContextMenu();
const { connectingHandle } = useCanvas();
const { connectingHandle, viewport } = useCanvas();
const experimentalNdvStore = useExperimentalNdvStore();
/*
Toolbar slot classes
@@ -96,6 +99,10 @@ const {
const isDisabled = computed(() => props.data.disabled);
const isExperimentalEmbeddedNdvShown = computed(() =>
experimentalNdvStore.isActive(viewport.value.zoom),
);
const classes = computed(() => ({
[style.canvasNode]: true,
[style.showToolbar]: showToolbar.value,
@@ -187,6 +194,8 @@ const createEndpointMappingFn =
const offsetValue =
position === Position.Bottom
? `${GRID_SIZE * 2 * (1 + index * 2) + CONFIGURATION_NODE_OFFSET}px`
: isExperimentalEmbeddedNdvShown.value && endpoints.length === 1
? `${(1 + index) * (GRID_SIZE * 2)}px`
: `${(100 / (endpoints.length + 1)) * (index + 1)}%`;
return {

View File

@@ -6,6 +6,7 @@ import { CanvasNodeRenderType } from '@/types';
import { useCanvas } from '@/composables/useCanvas';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useExperimentalNdvStore } from '../../experimental/experimentalNdv.store';
const emit = defineEmits<{
delete: [];
@@ -27,8 +28,9 @@ const { isDisabled, render, name } = useCanvasNode();
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
const experimentalNdvStore = useExperimentalNdvStore();
const node = computed(() => !!name.value && workflowsStore.getNodeByName(name.value));
const node = computed(() => (name.value ? workflowsStore.getNodeByName(name.value) : null));
const isToolNode = computed(() => !!node.value && nodeTypesStore.isToolNode(node.value.type));
const nodeDisabledTitle = computed(() => {
@@ -59,6 +61,13 @@ const isDisableNodeVisible = computed(() => {
const isDeleteNodeVisible = computed(() => !props.readOnly);
const isFocusNodeVisible = computed(
() =>
experimentalNdvStore.isEnabled &&
node.value !== null &&
experimentalNdvStore.collapsedNodes[node.value.id] !== false,
);
const isStickyNoteChangeColorVisible = computed(
() => !props.readOnly && render.value.type === CanvasNodeRenderType.StickyNote,
);
@@ -92,6 +101,12 @@ function onMouseEnter() {
function onMouseLeave() {
isHovered.value = false;
}
function onFocusNode() {
if (node.value) {
experimentalNdvStore.focusNode(node.value.id);
}
}
</script>
<template>
@@ -139,6 +154,14 @@ function onMouseLeave() {
:title="i18n.baseText('node.delete')"
@click="onDeleteNode"
/>
<N8nIconButton
v-if="isFocusNodeVisible"
type="tertiary"
size="small"
text
icon="crosshair"
@click="onFocusNode"
/>
<CanvasNodeStickyColorSelector
v-if="isStickyNoteChangeColorVisible"
v-model:visible="isStickyColorSelectorOpen"

View File

@@ -26,6 +26,7 @@ const {
outputs,
connections,
isDisabled,
isReadOnly,
isSelected,
hasPinnedData,
executionStatus,
@@ -136,6 +137,8 @@ function onActivate(event: MouseEvent) {
:node-id="id"
:class="classes"
:style="styles"
:is-read-only="isReadOnly"
:is-configurable="renderOptions.configurable ?? false"
/>
<div
v-else

View File

@@ -6,7 +6,11 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
import { createEventBus } from '@n8n/utils/event-bus';
import { computed } from 'vue';
const { nodeId, noWheel } = defineProps<{ nodeId: string; noWheel?: boolean }>();
const { nodeId, noWheel, isReadOnly } = defineProps<{
nodeId: string;
noWheel?: boolean;
isReadOnly?: boolean;
}>();
defineSlots<{ actions?: {} }>();
@@ -30,7 +34,7 @@ function handleValueChanged(parameterData: IUpdateInformation) {
:active-node="activeNode"
push-ref=""
:foreign-credentials="[]"
:read-only="false"
:read-only="isReadOnly"
:block-u-i="false"
:executable="false"
:input-size="0"

View File

@@ -9,7 +9,11 @@ import { N8nIcon, N8nIconButton } from '@n8n/design-system';
import { useVueFlow } from '@vue-flow/core';
import { watchOnce } from '@vueuse/core';
const { nodeId } = defineProps<{ nodeId: string }>();
const { nodeId, isReadOnly, isConfigurable } = defineProps<{
nodeId: string;
isReadOnly?: boolean;
isConfigurable: boolean;
}>();
const experimentalNdvStore = useExperimentalNdvStore();
const isExpanded = computed(() => !experimentalNdvStore.collapsedNodes[nodeId]);
@@ -22,7 +26,7 @@ const nodeType = computed(() => {
}
return null;
});
const vf = useVueFlow(workflowsStore.workflowId);
const vf = useVueFlow();
const isMoving = ref(false);
@@ -65,7 +69,10 @@ function handleToggleExpand() {
<div
ref="container"
:class="[$style.component, isExpanded ? $style.expanded : $style.collapsed]"
:style="{ '--zoom': `${1 / experimentalNdvStore.maxCanvasZoom}` }"
:style="{
'--zoom': `${1 / experimentalNdvStore.maxCanvasZoom}`,
'--node-width-scaler': isConfigurable ? 1 : 1.5,
}"
>
<template v-if="isOnceVisible">
<ExperimentalCanvasNodeSettings
@@ -75,6 +82,7 @@ function handleToggleExpand() {
:no-wheel="
!isMoving /* to not interrupt panning while allowing scroll of the settings pane, allow wheel event while panning */
"
:is-read-only="isReadOnly"
>
<template #actions>
<N8nIconButton
@@ -107,20 +115,17 @@ function handleToggleExpand() {
position: relative;
align-items: flex-start;
justify-content: stretch;
overflow: visible;
border-width: 0 !important;
outline: none;
box-shadow: none !important;
background-color: transparent;
width: calc(var(--canvas-node--width) * 1.5);
overflow: hidden;
border-width: 1px !important;
border-radius: var(--border-radius-base) !important;
width: calc(var(--canvas-node--width) * var(--node-width-scaler));
&.expanded {
cursor: default;
height: auto;
}
&.collapsed {
height: 50px;
margin-block: calc(var(--canvas-node--width) * 0.25);
height: calc(16px * 4);
}
}
@@ -134,28 +139,12 @@ function handleToggleExpand() {
:root .collapsedContent,
:root .settingsView {
border-radius: var(--border-radius-base);
border: 1px solid var(--canvas-node--border-color, var(--color-foreground-xdark));
z-index: 1000;
position: absolute;
left: 0;
width: 100%;
:global(.selected) & {
box-shadow: 0 0 0 4px var(--color-canvas-selected-transparent);
z-index: 1001;
}
& > * {
zoom: var(--zoom);
}
}
:root .settingsView {
height: auto;
max-height: min(200%, 300px);
top: -10%;
min-height: 120%;
max-height: min(calc(var(--canvas-node--height) * 2), 300px);
min-height: var(--spacing-3xl); // should be multiple of GRID_SIZE
}
.collapsedContent {
@@ -169,5 +158,15 @@ function handleToggleExpand() {
background-color: var(--color-background-xlight);
color: var(--color-text-base);
cursor: pointer;
& > * {
zoom: calc(var(--zoom) * 1.25);
}
}
.settingsView {
& > * {
zoom: var(--zoom);
}
}
</style>

View File

@@ -2,6 +2,8 @@ import { computed, shallowRef } from 'vue';
import { defineStore } from 'pinia';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useVueFlow } from '@vue-flow/core';
import { calculateNodeSize } from '@/utils/nodeViewUtils';
export const useExperimentalNdvStore = defineStore('experimentalNdv', () => {
const workflowStore = useWorkflowsStore();
@@ -20,7 +22,7 @@ export const useExperimentalNdvStore = defineStore('experimentalNdv', () => {
function setNodeExpanded(nodeId: string, isExpanded?: boolean) {
collapsedNodes.value = {
...collapsedNodes.value,
[nodeId]: isExpanded ?? !collapsedNodes.value[nodeId],
[nodeId]: isExpanded === undefined ? !collapsedNodes.value[nodeId] : !isExpanded,
};
}
@@ -42,6 +44,40 @@ export const useExperimentalNdvStore = defineStore('experimentalNdv', () => {
return isEnabled.value && canvasZoom === maxCanvasZoom.value;
}
function focusNode(nodeId: string) {
const nodeToFocus = workflowStore.getNodeById(nodeId);
if (!nodeToFocus) {
return;
}
// Call useVueFlow() here because having it in setup fn scope seem to cause initialization problem
const vueFlow = useVueFlow(workflowStore.workflow.id);
collapsedNodes.value = workflowStore.allNodes.reduce<Partial<Record<string, boolean>>>(
(acc, node) => {
acc[node.id] = node.id !== nodeId;
return acc;
},
{},
);
const workflow = workflowStore.getCurrentWorkflow();
const nodeSize = calculateNodeSize(
workflow.getChildNodes(nodeToFocus.name, 'ALL_NON_MAIN').length > 0,
workflow.getParentNodes(nodeToFocus.name, 'ALL_NON_MAIN').length > 0,
workflow.getParentNodes(nodeToFocus.name, 'main').length,
workflow.getChildNodes(nodeToFocus.name, 'main').length,
workflow.getParentNodes(nodeToFocus.name, 'ALL_NON_MAIN').length,
);
void vueFlow.setCenter(
nodeToFocus.position[0] + (nodeSize.width * 1.5) / 2,
nodeToFocus.position[1] + 80,
{ duration: 200, zoom: maxCanvasZoom.value },
);
}
return {
isEnabled,
maxCanvasZoom,
@@ -50,5 +86,6 @@ export const useExperimentalNdvStore = defineStore('experimentalNdv', () => {
setNodeExpanded,
expandAllNodes,
collapseAllNodes,
focusNode,
};
});

View File

@@ -15,6 +15,7 @@ import { useUIStore } from '@/stores/ui.store';
import { shallowRef, watch } from 'vue';
import { computed, type ComputedRef } from 'vue';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useExperimentalNdvStore } from '@/components/canvas/experimental/experimentalNdv.store';
export function useLogsSelection(
execution: ComputedRef<IExecutionResponse | undefined>,
@@ -33,14 +34,20 @@ export function useLogsSelection(
const uiStore = useUIStore();
const canvasStore = useCanvasStore();
const workflowsStore = useWorkflowsStore();
const experimentalNdvStore = useExperimentalNdvStore();
function syncSelectionToCanvasIfEnabled(value: LogEntry) {
if (!logsStore.isLogSelectionSyncedWithCanvas) {
return;
}
if (experimentalNdvStore.isEnabled) {
canvasEventBus.emit('nodes:select', { ids: [value.node.id], panIntoView: false });
experimentalNdvStore.focusNode(value.node.id);
} else {
canvasEventBus.emit('nodes:select', { ids: [value.node.id], panIntoView: true });
}
}
function select(value: LogEntry | undefined) {
manualLogEntrySelection.value =