feat(editor): Add an option to sync canvas with log view (#15391)

This commit is contained in:
Suguru Inoue
2025-05-22 17:25:58 +02:00
committed by GitHub
parent b1da30f493
commit 9938e63a66
19 changed files with 383 additions and 49 deletions

View File

@@ -117,6 +117,7 @@ defineExpose({ open, close });
<span :class="$style.label">
{{ item.label }}
</span>
<N8nIcon v-if="item.checked" icon="check" :size="iconSize" />
<span v-if="item.badge">
<N8nBadge theme="primary" size="xsmall" v-bind="item.badgeProps">
{{ item.badge }}

View File

@@ -10,4 +10,5 @@ export interface ActionDropdownItem {
disabled?: boolean;
shortcut?: KeyboardShortcut;
customClass?: string;
checked?: boolean;
}

View File

@@ -23,6 +23,7 @@ import { useNDVStore } from '@/stores/ndv.store';
import { deepCopy } from 'n8n-workflow';
import { createTestTaskData } from '@/__tests__/mocks';
import { useLogsStore } from '@/stores/logs.store';
import { useUIStore } from '@/stores/ui.store';
describe('LogsPanel', () => {
const VIEWPORT_HEIGHT = 800;
@@ -33,6 +34,7 @@ describe('LogsPanel', () => {
let nodeTypeStore: ReturnType<typeof mockedStore<typeof useNodeTypesStore>>;
let logsStore: ReturnType<typeof mockedStore<typeof useLogsStore>>;
let ndvStore: ReturnType<typeof mockedStore<typeof useNDVStore>>;
let uiStore: ReturnType<typeof mockedStore<typeof useUIStore>>;
function render() {
return renderComponent(LogsPanel, {
@@ -67,6 +69,8 @@ describe('LogsPanel', () => {
ndvStore = mockedStore(useNDVStore);
uiStore = mockedStore(useUIStore);
Object.defineProperty(document.body, 'offsetHeight', {
configurable: true,
get() {
@@ -374,18 +378,59 @@ describe('LogsPanel', () => {
expect(rendered.queryByTestId('log-details-output')).not.toBeInTheDocument();
});
it('should allow to select previous and next row via keyboard shortcut', async () => {
logsStore.toggleOpen(true);
workflowsStore.setWorkflow(aiChatWorkflow);
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
describe('selection', () => {
beforeEach(() => {
logsStore.toggleOpen(true);
workflowsStore.setWorkflow(aiChatWorkflow);
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
});
const rendered = render();
const overview = rendered.getByTestId('logs-overview');
it('should allow to select previous and next row via keyboard shortcut', async () => {
const { getByTestId, findByRole } = render();
const overview = getByTestId('logs-overview');
expect(await rendered.findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/);
await fireEvent.keyDown(overview, { key: 'K' });
expect(await rendered.findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Agent/);
await fireEvent.keyDown(overview, { key: 'J' });
expect(await rendered.findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/);
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/);
await fireEvent.keyDown(overview, { key: 'K' });
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Agent/);
await fireEvent.keyDown(overview, { key: 'J' });
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/);
});
it('should not select a log for the selected node on canvas if sync is disabled', async () => {
logsStore.toggleLogSelectionSync(false);
const { findByRole, rerender } = render();
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/);
uiStore.lastSelectedNode = 'AI Agent';
await rerender({});
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/);
});
it('should automatically select a log for the selected node on canvas if sync is enabled', async () => {
logsStore.toggleLogSelectionSync(true);
const { rerender, findByRole } = render();
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/);
uiStore.lastSelectedNode = 'AI Agent';
await rerender({});
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Agent/);
});
it('should automatically expand and select a log for the selected node on canvas if the log entry is collapsed', async () => {
logsStore.toggleLogSelectionSync(true);
const { rerender, findByRole, getByLabelText, findByText, queryByText } = render();
await fireEvent.click(await findByText('AI Agent'));
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Agent/);
await fireEvent.click(getByLabelText('Toggle row'));
await rerender({});
expect(queryByText(/AI Model/)).not.toBeInTheDocument();
uiStore.lastSelectedNode = 'AI Model';
await rerender({});
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/);
});
});
});

View File

@@ -61,6 +61,7 @@ const { selected, select, selectNext, selectPrev } = useLogsSelection(
execution,
entries,
flatLogEntries,
toggleExpanded,
);
const isLogDetailsOpen = computed(() => isOpen.value && selected.value !== undefined);
@@ -69,10 +70,12 @@ const isLogDetailsVisuallyOpen = computed(
);
const logsPanelActionsProps = computed<InstanceType<typeof LogsPanelActions>['$props']>(() => ({
isOpen: isOpen.value,
isSyncSelectionEnabled: logsStore.isLogSelectionSyncedWithCanvas,
showToggleButton: !isPoppedOut.value,
showPopOutButton: canPopOut.value && !isPoppedOut.value,
onPopOut,
onToggleOpen,
onToggleSyncSelection: logsStore.toggleLogSelectionSync,
}));
function handleResizeOverviewPanelEnd() {

View File

@@ -2,16 +2,22 @@
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
import { useI18n } from '@/composables/useI18n';
import { useStyles } from '@/composables/useStyles';
import { N8nIconButton, N8nTooltip } from '@n8n/design-system';
import { N8nActionDropdown, N8nIconButton } from '@n8n/design-system';
import { computed } from 'vue';
const { isOpen, showToggleButton, showPopOutButton } = defineProps<{
const {
isOpen,
isSyncSelectionEnabled: isSyncEnabled,
showToggleButton,
showPopOutButton,
} = defineProps<{
isOpen: boolean;
isSyncSelectionEnabled: boolean;
showToggleButton: boolean;
showPopOutButton: boolean;
}>();
const emit = defineEmits<{ popOut: []; toggleOpen: [] }>();
const emit = defineEmits<{ popOut: []; toggleOpen: []; toggleSyncSelection: [] }>();
const appStyles = useStyles();
const locales = useI18n();
@@ -20,20 +26,54 @@ const popOutButtonText = computed(() => locales.baseText('runData.panel.actions.
const toggleButtonText = computed(() =>
locales.baseText(isOpen ? 'runData.panel.actions.collapse' : 'runData.panel.actions.open'),
);
const menuItems = computed(() => [
{
id: 'toggleSyncSelection' as const,
label: locales.baseText('runData.panel.actions.sync'),
checked: isSyncEnabled,
},
...(showPopOutButton ? [{ id: 'popOut' as const, label: popOutButtonText.value }] : []),
]);
function handleSelectMenuItem(selected: string) {
// This switch looks redundant, but needed to pass type checker
switch (selected) {
case 'popOut':
emit(selected);
return;
case 'toggleSyncSelection':
emit(selected);
return;
}
}
</script>
<template>
<div :class="$style.container">
<N8nTooltip v-if="showPopOutButton" :z-index="tooltipZIndex" :content="popOutButtonText">
<N8nTooltip
v-if="!isOpen && showPopOutButton"
:z-index="tooltipZIndex"
:content="popOutButtonText"
>
<N8nIconButton
icon="pop-out"
type="secondary"
type="tertiary"
text
size="small"
icon-size="medium"
:aria-label="popOutButtonText"
@click.stop="emit('popOut')"
/>
</N8nTooltip>
<N8nActionDropdown
v-if="isOpen"
icon-size="small"
activator-icon="ellipsis-h"
activator-size="small"
:items="menuItems"
:teleported="false /* for PiP window */"
@select="handleSelectMenuItem"
/>
<KeyboardShortcutTooltip
v-if="showToggleButton"
:label="locales.baseText('generic.shortcutHint')"
@@ -41,12 +81,12 @@ const toggleButtonText = computed(() =>
:z-index="tooltipZIndex"
>
<N8nIconButton
type="secondary"
type="tertiary"
text
size="small"
icon-size="medium"
:icon="isOpen ? 'chevron-down' : 'chevron-up'"
:aria-label="toggleButtonText"
style="color: var(--color-text-base)"
@click.stop="emit('toggleOpen')"
/>
</KeyboardShortcutTooltip>
@@ -58,8 +98,7 @@ const toggleButtonText = computed(() =>
display: flex;
}
.container button {
border: none;
color: var(--color-text-light);
.container button:hover {
background-color: var(--color-background-base);
}
</style>

View File

@@ -1,28 +1,48 @@
import type { LogEntrySelection } from '@/components/CanvasChat/types/logs';
import {
findLogEntryRec,
findSelectedLogEntry,
getDepth,
getEntryAtRelativeIndex,
type LogEntry,
} from '@/components/RunDataAi/utils';
import { useTelemetry } from '@/composables/useTelemetry';
import { canvasEventBus } from '@/event-bus/canvas';
import type { IExecutionResponse } from '@/Interface';
import { useCanvasStore } from '@/stores/canvas.store';
import { useLogsStore } from '@/stores/logs.store';
import { useUIStore } from '@/stores/ui.store';
import { watch } from 'vue';
import { computed, ref, type ComputedRef } from 'vue';
export function useLogsSelection(
execution: ComputedRef<IExecutionResponse | undefined>,
tree: ComputedRef<LogEntry[]>,
flatLogEntries: ComputedRef<LogEntry[]>,
toggleExpand: (entry: LogEntry, expand?: boolean) => void,
) {
const telemetry = useTelemetry();
const manualLogEntrySelection = ref<LogEntrySelection>({ type: 'initial' });
const selected = computed(() => findSelectedLogEntry(manualLogEntrySelection.value, tree.value));
const logsStore = useLogsStore();
const uiStore = useUIStore();
const canvasStore = useCanvasStore();
function syncSelectionToCanvasIfEnabled(value: LogEntry) {
if (!logsStore.isLogSelectionSyncedWithCanvas) {
return;
}
canvasEventBus.emit('nodes:select', { ids: [value.node.id], panIntoView: true });
}
function select(value: LogEntry | undefined) {
manualLogEntrySelection.value =
value === undefined ? { type: 'none' } : { type: 'selected', id: value.id };
if (value) {
syncSelectionToCanvasIfEnabled(value);
telemetry.track('User selected node in log view', {
node_type: value.node.type,
node_id: value.node.id,
@@ -39,7 +59,8 @@ export function useLogsSelection(
? (getEntryAtRelativeIndex(entries, selected.value.id, -1) ?? entries[0])
: entries[entries.length - 1];
select(prevEntry);
manualLogEntrySelection.value = { type: 'selected', id: prevEntry.id };
syncSelectionToCanvasIfEnabled(prevEntry);
}
function selectNext() {
@@ -48,8 +69,40 @@ export function useLogsSelection(
? (getEntryAtRelativeIndex(entries, selected.value.id, 1) ?? entries[entries.length - 1])
: entries[0];
select(nextEntry);
manualLogEntrySelection.value = { type: 'selected', id: nextEntry.id };
syncSelectionToCanvasIfEnabled(nextEntry);
}
// Synchronize selection from canvas
watch(
[() => uiStore.lastSelectedNode, () => logsStore.isLogSelectionSyncedWithCanvas],
([selectedOnCanvas, shouldSync]) => {
if (
!shouldSync ||
!selectedOnCanvas ||
canvasStore.hasRangeSelection ||
selected.value?.node.name === selectedOnCanvas
) {
return;
}
const entry = findLogEntryRec((e) => e.node.name === selectedOnCanvas, tree.value);
if (!entry) {
return;
}
manualLogEntrySelection.value = { type: 'selected', id: entry.id };
let parent = entry.parent;
while (parent !== undefined) {
toggleExpand(parent, true);
parent = parent.parent;
}
},
{ immediate: true },
);
return { selected, select, selectPrev, selectNext };
}

View File

@@ -5,8 +5,9 @@ export function useLogsTreeExpand(entries: ComputedRef<LogEntry[]>) {
const collapsedEntries = ref<Record<string, boolean>>({});
const flatLogEntries = computed(() => flattenLogEntries(entries.value, collapsedEntries.value));
function toggleExpanded(treeNode: LogEntry) {
collapsedEntries.value[treeNode.id] = !collapsedEntries.value[treeNode.id];
function toggleExpanded(treeNode: LogEntry, expand?: boolean) {
collapsedEntries.value[treeNode.id] =
expand === undefined ? !collapsedEntries.value[treeNode.id] : !expand;
}
return {

View File

@@ -488,13 +488,16 @@ function createLogTreeRec(context: LogTreeCreationContext) {
);
}
export function findLogEntryRec(id: string, entries: LogEntry[]): LogEntry | undefined {
export function findLogEntryRec(
isMatched: (entry: LogEntry) => boolean,
entries: LogEntry[],
): LogEntry | undefined {
for (const entry of entries) {
if (entry.id === id) {
if (isMatched(entry)) {
return entry;
}
const child = findLogEntryRec(id, entry.children);
const child = findLogEntryRec(isMatched, entry.children);
if (child) {
return child;
@@ -514,7 +517,7 @@ export function findSelectedLogEntry(
case 'none':
return undefined;
case 'selected': {
const entry = findLogEntryRec(selection.id, entries);
const entry = findLogEntryRec((e) => e.id === selection.id, entries);
if (entry) {
return entry;

View File

@@ -9,6 +9,7 @@ import { NodeConnectionTypes } from 'n8n-workflow';
import type { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
import { useVueFlow } from '@vue-flow/core';
import { SIMULATE_NODE_TYPE } from '@/constants';
import { canvasEventBus } from '@/event-bus/canvas';
const matchMedia = global.window.matchMedia;
// @ts-expect-error Initialize window object
@@ -156,6 +157,24 @@ describe('Canvas', () => {
expect(emitted()['update:node:name']).toEqual([['1']]);
});
it('should update viewport if nodes:select event is received with panIntoView=true', async () => {
const node = createCanvasNodeElement({ position: { x: -1000, y: -500 } });
renderComponent({
props: {
id: 'c0',
nodes: [node],
eventBus: canvasEventBus,
},
});
const { getViewport } = useVueFlow('c0');
expect(getViewport()).toEqual({ x: 0, y: 0, zoom: 1 });
canvasEventBus.emit('nodes:select', { ids: [node.id], panIntoView: true });
await waitFor(() => expect(getViewport()).toEqual({ x: 1100, y: 600, zoom: 1 }));
});
it('should not emit `update:node:name` event if long key press', async () => {
vi.useFakeTimers();

View File

@@ -19,7 +19,7 @@ import type {
CanvasNodeData,
} from '@/types';
import { CanvasNodeRenderType } from '@/types';
import { getMousePosition, GRID_SIZE } from '@/utils/nodeViewUtils';
import { updateViewportToContainNodes, getMousePosition, GRID_SIZE } from '@/utils/nodeViewUtils';
import { isPresent } from '@/utils/typesUtils';
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
import { useShortKeyPress } from '@n8n/composables/useShortKeyPress';
@@ -72,6 +72,7 @@ const emit = defineEmits<{
'update:logs-open': [open?: boolean];
'update:logs:input-open': [open?: boolean];
'update:logs:output-open': [open?: boolean];
'update:has-range-selection': [isActive: boolean];
'click:node': [id: string, position: XYPosition];
'click:node:add': [id: string, handle: string];
'run:node': [id: string];
@@ -153,6 +154,7 @@ const {
viewport,
dimensions,
nodesSelectionActive,
userSelectionRect,
setViewport,
onEdgeMouseLeave,
onEdgeMouseEnter,
@@ -402,9 +404,21 @@ function onSelectNode() {
emit('update:node:selected', lastSelectedNode.value?.id);
}
function onSelectNodes({ ids }: CanvasEventBusEvents['nodes:select']) {
function onSelectNodes({ ids, panIntoView }: CanvasEventBusEvents['nodes:select']) {
clearSelectedNodes();
addSelectedNodes(ids.map(findNode).filter(isPresent));
if (panIntoView) {
const nodes = ids.map(findNode).filter(isPresent);
if (nodes.length === 0) {
return;
}
const newViewport = updateViewportToContainNodes(viewport.value, dimensions.value, nodes, 100);
void setViewport(newViewport, { duration: 200 });
}
}
function onToggleNodeEnabled(id: string) {
@@ -798,6 +812,10 @@ watch(() => props.readOnly, setReadonly, {
immediate: true,
});
watch([nodesSelectionActive, userSelectionRect], ([isActive, rect]) =>
emit('update:has-range-selection', isActive || (rect?.width ?? 0) > 0 || (rect?.height ?? 0) > 0),
);
/**
* Provide
*/

View File

@@ -484,6 +484,7 @@ export const LOCAL_STORAGE_HIDE_GITHUB_STAR_BUTTON = 'N8N_HIDE_HIDE_GITHUB_STAR_
export const LOCAL_STORAGE_NDV_INPUT_PANEL_DISPLAY_MODE = 'N8N_NDV_INPUT_PANEL_DISPLAY_MODE';
export const LOCAL_STORAGE_NDV_OUTPUT_PANEL_DISPLAY_MODE = 'N8N_NDV_OUTPUT_PANEL_DISPLAY_MODE';
export const LOCAL_STORAGE_LOGS_PANEL_OPEN = 'N8N_LOGS_PANEL_OPEN';
export const LOCAL_STORAGE_LOGS_SYNC_SELECTION = 'N8N_LOGS_SYNC_SELECTION';
export const LOCAL_STORAGE_LOGS_PANEL_DETAILS_PANEL = 'N8N_LOGS_DETAILS_PANEL';
export const LOCAL_STORAGE_WORKFLOW_LIST_PREFERENCES_KEY = 'N8N_WORKFLOWS_LIST_PREFERENCES';
export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename=';

View File

@@ -0,0 +1,4 @@
import type { CanvasEventBusEvents } from '@/types';
import { createEventBus } from '@n8n/utils/event-bus';
export const canvasEventBus = createEventBus<CanvasEventBusEvents>();

View File

@@ -1824,6 +1824,7 @@
"runData.panel.actions.collapse": "Collapse panel",
"runData.panel.actions.open": "Open panel",
"runData.panel.actions.popOut": "Pop out panel",
"runData.panel.actions.sync": "Sync selection with canvas",
"saveButton.save": "@:_reusableBaseText.save",
"saveButton.saved": "Saved",
"saveWorkflowButton.hint": "Save workflow",

View File

@@ -13,13 +13,20 @@ export const useCanvasStore = defineStore('canvas', () => {
const aiNodes = computed<INodeUi[]>(() =>
nodes.value.filter((node) => node.type.includes('langchain')),
);
const hasRangeSelection = ref(false);
function setHasRangeSelection(value: boolean) {
hasRangeSelection.value = value;
}
return {
newNodeInsertPosition,
isLoading: loadingService.isLoading,
aiNodes,
hasRangeSelection: computed(() => hasRangeSelection.value),
startLoading: loadingService.startLoading,
setLoadingText: loadingService.setLoadingText,
stopLoading: loadingService.stopLoading,
setHasRangeSelection,
};
});

View File

@@ -4,7 +4,11 @@ import {
type LogDetailsPanelState,
} from '@/components/CanvasChat/types/logs';
import { useTelemetry } from '@/composables/useTelemetry';
import { LOCAL_STORAGE_LOGS_PANEL_DETAILS_PANEL, LOCAL_STORAGE_LOGS_PANEL_OPEN } from '@/constants';
import {
LOCAL_STORAGE_LOGS_PANEL_DETAILS_PANEL,
LOCAL_STORAGE_LOGS_PANEL_OPEN,
LOCAL_STORAGE_LOGS_SYNC_SELECTION,
} from '@/constants';
import { useLocalStorage } from '@vueuse/core';
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
@@ -25,6 +29,8 @@ export const useLogsStore = defineStore('logs', () => {
LOG_DETAILS_PANEL_STATE.OUTPUT,
{ writeDefaults: false },
);
const isLogSelectionSyncedWithCanvas = useLocalStorage(LOCAL_STORAGE_LOGS_SYNC_SELECTION, false);
const telemetry = useTelemetry();
function setHeight(value: number) {
@@ -77,15 +83,21 @@ export const useLogsStore = defineStore('logs', () => {
});
}
function toggleLogSelectionSync(value?: boolean) {
isLogSelectionSyncedWithCanvas.value = value ?? !isLogSelectionSyncedWithCanvas.value;
}
return {
state,
isOpen: computed(() => state.value !== LOGS_PANEL_STATE.CLOSED),
detailsState: computed(() => detailsState.value),
height: computed(() => height.value),
isLogSelectionSyncedWithCanvas: computed(() => isLogSelectionSyncedWithCanvas.value),
setHeight,
toggleOpen,
setPreferPoppedOut,
toggleInputOpen,
toggleOutputOpen,
toggleLogSelectionSync,
};
});

View File

@@ -175,7 +175,7 @@ export type CanvasEventBusEvents = {
fitView: never;
'saved:workflow': never;
'open:execution': IExecutionResponse;
'nodes:select': { ids: string[] };
'nodes:select': { ids: string[]; panIntoView?: boolean };
'nodes:action': {
ids: string[];
action: keyof CanvasNodeEventBusEvents;

View File

@@ -8,6 +8,7 @@ import {
getGenericHints,
getNewNodePosition,
NODE_SIZE,
updateViewportToContainNodes,
} from './nodeViewUtils';
import type { INode, INodeTypeDescription, INodeExecutionData, Workflow } from 'n8n-workflow';
import type { INodeUi, XYPosition } from '@/Interface';
@@ -17,6 +18,8 @@ import { describe, it, expect, beforeEach } from 'vitest';
import { mock, type MockProxy } from 'vitest-mock-extended';
import { SET_NODE_TYPE, STICKY_NODE_TYPE } from '@/constants';
import { createTestNode } from '@/__tests__/mocks';
import type { GraphNode } from '@vue-flow/core';
import { v4 as uuid } from 'uuid';
describe('getGenericHints', () => {
let mockWorkflowNode: MockProxy<INode>;
@@ -397,3 +400,66 @@ describe('getNodesGroupSize', () => {
expect(he).toBe(NODE_SIZE);
});
});
describe(updateViewportToContainNodes, () => {
it('should return the same viewport if given node is already in the viewport', () => {
const result = updateViewportToContainNodes(
{ x: 0, y: 0, zoom: 2 },
{ width: 1000, height: 800 },
[createTestGraphNode({ position: { x: 0, y: 0 }, dimensions: { width: 36, height: 36 } })],
0,
);
expect(result).toEqual({ x: 0, y: 0, zoom: 2 });
});
it('should return updated viewport with minimal position change to include node outside northwest edge', () => {
const result = updateViewportToContainNodes(
{ x: 0, y: 0, zoom: 2 },
{ width: 1000, height: 800 },
[
createTestGraphNode({
position: { x: -10, y: -20 },
dimensions: { width: 36, height: 36 },
}),
],
0,
);
expect(result).toEqual({ x: 20, y: 40, zoom: 2 });
});
it('should return updated viewport with minimal position change to include node outside southeast edge', () => {
const result = updateViewportToContainNodes(
{ x: 0, y: 0, zoom: 2 },
{ width: 1000, height: 800 },
[
createTestGraphNode({
position: { x: 500, y: 400 },
dimensions: { width: 36, height: 36 },
}),
],
0,
);
expect(result).toEqual({ x: -72, y: -72, zoom: 2 });
});
});
function createTestGraphNode(data: Partial<GraphNode> = {}): GraphNode {
return {
computedPosition: { z: 0, ...(data.position ?? { x: 0, y: 0 }) },
handleBounds: {},
dimensions: { width: 0, height: 0 },
isParent: true,
selected: false,
resizing: false,
dragging: false,
data: undefined,
events: {},
type: '',
id: uuid(),
position: { x: 0, y: 0 },
...data,
};
}

View File

@@ -19,6 +19,13 @@ import type {
import { NodeHelpers, SEND_AND_WAIT_OPERATION } from 'n8n-workflow';
import type { RouteLocation } from 'vue-router';
import type { ViewportBoundaries } from '@/types';
import {
getRectOfNodes,
type Dimensions,
type GraphNode,
type Rect,
type ViewportTransform,
} from '@vue-flow/core';
/*
* Canvas constants and functions
@@ -530,3 +537,66 @@ export const getNodeViewTab = (route: RouteLocation): string | null => {
}
return null;
};
export function getBounds(
{ x, y, zoom }: ViewportTransform,
{ width, height }: Dimensions,
): ViewportBoundaries {
const xMin = -x / zoom;
const yMin = -y / zoom;
const xMax = (width - x) / zoom;
const yMax = (height - y) / zoom;
return { xMin, yMin, xMax, yMax };
}
function addPadding({ x, y, width, height }: Rect, amount: number): Rect {
return {
x: x - amount,
y: y - amount,
width: width + amount * 2,
height: height + amount * 2,
};
}
export function updateViewportToContainNodes(
viewport: ViewportTransform,
dimensions: Dimensions,
nodes: GraphNode[],
padding: number,
): ViewportTransform {
function computeDelta(start: number, end: number, min: number, max: number) {
if (start >= min && end <= max) {
// Both ends are already in the range, no need for adjustment
return 0;
}
if (start < min) {
if (end > max) {
// Neither end is in the range, in this case we don't make
// any adjustment (for now; we could adjust zoom to fit in viewport)
return 0;
}
return min - start;
}
return max - end;
}
if (nodes.length === 0) {
return viewport;
}
const zoom = viewport.zoom;
const rect = addPadding(getRectOfNodes(nodes), padding / zoom);
const { xMax, xMin, yMax, yMin } = getBounds(viewport, dimensions);
const dx = computeDelta(rect.x, rect.x + rect.width, xMin, xMax);
const dy = computeDelta(rect.y, rect.y + rect.height, yMin, yMax);
return {
x: viewport.x + dx * zoom,
y: viewport.y + dy * zoom,
zoom,
};
}

View File

@@ -44,7 +44,6 @@ import type {
} from '@vue-flow/core';
import type {
CanvasConnectionCreateData,
CanvasEventBusEvents,
CanvasNode,
CanvasNodeMoveEvent,
ConnectStartEvent,
@@ -97,7 +96,7 @@ import { sourceControlEventBus } from '@/event-bus/source-control';
import { useTagsStore } from '@/stores/tags.store';
import { usePushConnectionStore } from '@/stores/pushConnection.store';
import { useNDVStore } from '@/stores/ndv.store';
import { getNodesWithNormalizedPosition, getNodeViewTab } from '@/utils/nodeViewUtils';
import { getBounds, getNodesWithNormalizedPosition, getNodeViewTab } from '@/utils/nodeViewUtils';
import CanvasStopCurrentExecutionButton from '@/components/canvas/elements/buttons/CanvasStopCurrentExecutionButton.vue';
import CanvasStopWaitingForWebhookButton from '@/components/canvas/elements/buttons/CanvasStopWaitingForWebhookButton.vue';
import CanvasClearExecutionDataButton from '@/components/canvas/elements/buttons/CanvasClearExecutionDataButton.vue';
@@ -105,7 +104,6 @@ import { nodeViewEventBus } from '@/event-bus';
import { tryToParseNumber } from '@/utils/typesUtils';
import { useTemplatesStore } from '@/stores/templates.store';
import { N8nCallout } from '@n8n/design-system';
import { createEventBus } from '@n8n/utils/event-bus';
import type { PinDataSource } from '@/composables/usePinnedData';
import { useClipboard } from '@/composables/useClipboard';
import { useBeforeUnload } from '@/composables/useBeforeUnload';
@@ -123,6 +121,7 @@ import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
import { useAgentRequestStore } from '@n8n/stores/useAgentRequestStore';
import { needsAgentInput } from '@/utils/nodes/nodeTransforms';
import { useLogsStore } from '@/stores/logs.store';
import { canvasEventBus } from '@/event-bus/canvas';
defineOptions({
name: 'NodeView',
@@ -178,8 +177,6 @@ const foldersStore = useFoldersStore();
const agentRequestStore = useAgentRequestStore();
const logsStore = useLogsStore();
const canvasEventBus = createEventBus<CanvasEventBusEvents>();
const { addBeforeUnloadEventBindings, removeBeforeUnloadEventBindings } = useBeforeUnload({
route,
});
@@ -1584,17 +1581,9 @@ async function onSaveFromWithinExecutionDebug() {
const viewportTransform = ref<ViewportTransform>({ x: 0, y: 0, zoom: 1 });
const viewportDimensions = ref<Dimensions>({ width: 0, height: 0 });
const viewportBoundaries = computed<ViewportBoundaries>(() => {
const { x, y, zoom } = viewportTransform.value;
const { width, height } = viewportDimensions.value;
const xMin = -x / zoom;
const yMin = -y / zoom;
const xMax = (width - x) / zoom;
const yMax = (height - y) / zoom;
return { xMin, yMin, xMax, yMax };
});
const viewportBoundaries = computed<ViewportBoundaries>(() =>
getBounds(viewportTransform.value, viewportDimensions.value),
);
function onViewportChange(viewport: ViewportTransform, dimensions: Dimensions) {
viewportTransform.value = viewport;
@@ -1934,6 +1923,7 @@ onBeforeUnmount(() => {
@update:logs-open="logsStore.toggleOpen($event)"
@update:logs:input-open="logsStore.toggleInputOpen"
@update:logs:output-open="logsStore.toggleOutputOpen"
@update:has-range-selection="canvasStore.setHasRangeSelection"
@open:sub-workflow="onOpenSubWorkflow"
@click:node="onClickNode"
@click:node:add="onClickNodeAdd"