mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
feat(editor): Add an option to sync canvas with log view (#15391)
This commit is contained in:
@@ -117,6 +117,7 @@ defineExpose({ open, close });
|
|||||||
<span :class="$style.label">
|
<span :class="$style.label">
|
||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
</span>
|
</span>
|
||||||
|
<N8nIcon v-if="item.checked" icon="check" :size="iconSize" />
|
||||||
<span v-if="item.badge">
|
<span v-if="item.badge">
|
||||||
<N8nBadge theme="primary" size="xsmall" v-bind="item.badgeProps">
|
<N8nBadge theme="primary" size="xsmall" v-bind="item.badgeProps">
|
||||||
{{ item.badge }}
|
{{ item.badge }}
|
||||||
|
|||||||
@@ -10,4 +10,5 @@ export interface ActionDropdownItem {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
shortcut?: KeyboardShortcut;
|
shortcut?: KeyboardShortcut;
|
||||||
customClass?: string;
|
customClass?: string;
|
||||||
|
checked?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { useNDVStore } from '@/stores/ndv.store';
|
|||||||
import { deepCopy } from 'n8n-workflow';
|
import { deepCopy } from 'n8n-workflow';
|
||||||
import { createTestTaskData } from '@/__tests__/mocks';
|
import { createTestTaskData } from '@/__tests__/mocks';
|
||||||
import { useLogsStore } from '@/stores/logs.store';
|
import { useLogsStore } from '@/stores/logs.store';
|
||||||
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
|
||||||
describe('LogsPanel', () => {
|
describe('LogsPanel', () => {
|
||||||
const VIEWPORT_HEIGHT = 800;
|
const VIEWPORT_HEIGHT = 800;
|
||||||
@@ -33,6 +34,7 @@ describe('LogsPanel', () => {
|
|||||||
let nodeTypeStore: ReturnType<typeof mockedStore<typeof useNodeTypesStore>>;
|
let nodeTypeStore: ReturnType<typeof mockedStore<typeof useNodeTypesStore>>;
|
||||||
let logsStore: ReturnType<typeof mockedStore<typeof useLogsStore>>;
|
let logsStore: ReturnType<typeof mockedStore<typeof useLogsStore>>;
|
||||||
let ndvStore: ReturnType<typeof mockedStore<typeof useNDVStore>>;
|
let ndvStore: ReturnType<typeof mockedStore<typeof useNDVStore>>;
|
||||||
|
let uiStore: ReturnType<typeof mockedStore<typeof useUIStore>>;
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
return renderComponent(LogsPanel, {
|
return renderComponent(LogsPanel, {
|
||||||
@@ -67,6 +69,8 @@ describe('LogsPanel', () => {
|
|||||||
|
|
||||||
ndvStore = mockedStore(useNDVStore);
|
ndvStore = mockedStore(useNDVStore);
|
||||||
|
|
||||||
|
uiStore = mockedStore(useUIStore);
|
||||||
|
|
||||||
Object.defineProperty(document.body, 'offsetHeight', {
|
Object.defineProperty(document.body, 'offsetHeight', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
get() {
|
get() {
|
||||||
@@ -374,18 +378,59 @@ describe('LogsPanel', () => {
|
|||||||
expect(rendered.queryByTestId('log-details-output')).not.toBeInTheDocument();
|
expect(rendered.queryByTestId('log-details-output')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should allow to select previous and next row via keyboard shortcut', async () => {
|
describe('selection', () => {
|
||||||
logsStore.toggleOpen(true);
|
beforeEach(() => {
|
||||||
workflowsStore.setWorkflow(aiChatWorkflow);
|
logsStore.toggleOpen(true);
|
||||||
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
|
workflowsStore.setWorkflow(aiChatWorkflow);
|
||||||
|
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
|
||||||
|
});
|
||||||
|
|
||||||
const rendered = render();
|
it('should allow to select previous and next row via keyboard shortcut', async () => {
|
||||||
const overview = rendered.getByTestId('logs-overview');
|
const { getByTestId, findByRole } = render();
|
||||||
|
const overview = getByTestId('logs-overview');
|
||||||
|
|
||||||
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' });
|
await fireEvent.keyDown(overview, { key: 'K' });
|
||||||
expect(await rendered.findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Agent/);
|
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Agent/);
|
||||||
await fireEvent.keyDown(overview, { key: 'J' });
|
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/);
|
||||||
|
});
|
||||||
|
|
||||||
|
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/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ const { selected, select, selectNext, selectPrev } = useLogsSelection(
|
|||||||
execution,
|
execution,
|
||||||
entries,
|
entries,
|
||||||
flatLogEntries,
|
flatLogEntries,
|
||||||
|
toggleExpanded,
|
||||||
);
|
);
|
||||||
|
|
||||||
const isLogDetailsOpen = computed(() => isOpen.value && selected.value !== undefined);
|
const isLogDetailsOpen = computed(() => isOpen.value && selected.value !== undefined);
|
||||||
@@ -69,10 +70,12 @@ const isLogDetailsVisuallyOpen = computed(
|
|||||||
);
|
);
|
||||||
const logsPanelActionsProps = computed<InstanceType<typeof LogsPanelActions>['$props']>(() => ({
|
const logsPanelActionsProps = computed<InstanceType<typeof LogsPanelActions>['$props']>(() => ({
|
||||||
isOpen: isOpen.value,
|
isOpen: isOpen.value,
|
||||||
|
isSyncSelectionEnabled: logsStore.isLogSelectionSyncedWithCanvas,
|
||||||
showToggleButton: !isPoppedOut.value,
|
showToggleButton: !isPoppedOut.value,
|
||||||
showPopOutButton: canPopOut.value && !isPoppedOut.value,
|
showPopOutButton: canPopOut.value && !isPoppedOut.value,
|
||||||
onPopOut,
|
onPopOut,
|
||||||
onToggleOpen,
|
onToggleOpen,
|
||||||
|
onToggleSyncSelection: logsStore.toggleLogSelectionSync,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function handleResizeOverviewPanelEnd() {
|
function handleResizeOverviewPanelEnd() {
|
||||||
|
|||||||
@@ -2,16 +2,22 @@
|
|||||||
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
|
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useStyles } from '@/composables/useStyles';
|
import { useStyles } from '@/composables/useStyles';
|
||||||
import { N8nIconButton, N8nTooltip } from '@n8n/design-system';
|
import { N8nActionDropdown, N8nIconButton } from '@n8n/design-system';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
|
||||||
const { isOpen, showToggleButton, showPopOutButton } = defineProps<{
|
const {
|
||||||
|
isOpen,
|
||||||
|
isSyncSelectionEnabled: isSyncEnabled,
|
||||||
|
showToggleButton,
|
||||||
|
showPopOutButton,
|
||||||
|
} = defineProps<{
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
isSyncSelectionEnabled: boolean;
|
||||||
showToggleButton: boolean;
|
showToggleButton: boolean;
|
||||||
showPopOutButton: boolean;
|
showPopOutButton: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{ popOut: []; toggleOpen: [] }>();
|
const emit = defineEmits<{ popOut: []; toggleOpen: []; toggleSyncSelection: [] }>();
|
||||||
|
|
||||||
const appStyles = useStyles();
|
const appStyles = useStyles();
|
||||||
const locales = useI18n();
|
const locales = useI18n();
|
||||||
@@ -20,20 +26,54 @@ const popOutButtonText = computed(() => locales.baseText('runData.panel.actions.
|
|||||||
const toggleButtonText = computed(() =>
|
const toggleButtonText = computed(() =>
|
||||||
locales.baseText(isOpen ? 'runData.panel.actions.collapse' : 'runData.panel.actions.open'),
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="$style.container">
|
<div :class="$style.container">
|
||||||
<N8nTooltip v-if="showPopOutButton" :z-index="tooltipZIndex" :content="popOutButtonText">
|
<N8nTooltip
|
||||||
|
v-if="!isOpen && showPopOutButton"
|
||||||
|
:z-index="tooltipZIndex"
|
||||||
|
:content="popOutButtonText"
|
||||||
|
>
|
||||||
<N8nIconButton
|
<N8nIconButton
|
||||||
icon="pop-out"
|
icon="pop-out"
|
||||||
type="secondary"
|
type="tertiary"
|
||||||
|
text
|
||||||
size="small"
|
size="small"
|
||||||
icon-size="medium"
|
icon-size="medium"
|
||||||
:aria-label="popOutButtonText"
|
:aria-label="popOutButtonText"
|
||||||
@click.stop="emit('popOut')"
|
@click.stop="emit('popOut')"
|
||||||
/>
|
/>
|
||||||
</N8nTooltip>
|
</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
|
<KeyboardShortcutTooltip
|
||||||
v-if="showToggleButton"
|
v-if="showToggleButton"
|
||||||
:label="locales.baseText('generic.shortcutHint')"
|
:label="locales.baseText('generic.shortcutHint')"
|
||||||
@@ -41,12 +81,12 @@ const toggleButtonText = computed(() =>
|
|||||||
:z-index="tooltipZIndex"
|
:z-index="tooltipZIndex"
|
||||||
>
|
>
|
||||||
<N8nIconButton
|
<N8nIconButton
|
||||||
type="secondary"
|
type="tertiary"
|
||||||
|
text
|
||||||
size="small"
|
size="small"
|
||||||
icon-size="medium"
|
icon-size="medium"
|
||||||
:icon="isOpen ? 'chevron-down' : 'chevron-up'"
|
:icon="isOpen ? 'chevron-down' : 'chevron-up'"
|
||||||
:aria-label="toggleButtonText"
|
:aria-label="toggleButtonText"
|
||||||
style="color: var(--color-text-base)"
|
|
||||||
@click.stop="emit('toggleOpen')"
|
@click.stop="emit('toggleOpen')"
|
||||||
/>
|
/>
|
||||||
</KeyboardShortcutTooltip>
|
</KeyboardShortcutTooltip>
|
||||||
@@ -58,8 +98,7 @@ const toggleButtonText = computed(() =>
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container button {
|
.container button:hover {
|
||||||
border: none;
|
background-color: var(--color-background-base);
|
||||||
color: var(--color-text-light);
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,28 +1,48 @@
|
|||||||
import type { LogEntrySelection } from '@/components/CanvasChat/types/logs';
|
import type { LogEntrySelection } from '@/components/CanvasChat/types/logs';
|
||||||
import {
|
import {
|
||||||
|
findLogEntryRec,
|
||||||
findSelectedLogEntry,
|
findSelectedLogEntry,
|
||||||
getDepth,
|
getDepth,
|
||||||
getEntryAtRelativeIndex,
|
getEntryAtRelativeIndex,
|
||||||
type LogEntry,
|
type LogEntry,
|
||||||
} from '@/components/RunDataAi/utils';
|
} from '@/components/RunDataAi/utils';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
|
import { canvasEventBus } from '@/event-bus/canvas';
|
||||||
import type { IExecutionResponse } from '@/Interface';
|
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';
|
import { computed, ref, type ComputedRef } from 'vue';
|
||||||
|
|
||||||
export function useLogsSelection(
|
export function useLogsSelection(
|
||||||
execution: ComputedRef<IExecutionResponse | undefined>,
|
execution: ComputedRef<IExecutionResponse | undefined>,
|
||||||
tree: ComputedRef<LogEntry[]>,
|
tree: ComputedRef<LogEntry[]>,
|
||||||
flatLogEntries: ComputedRef<LogEntry[]>,
|
flatLogEntries: ComputedRef<LogEntry[]>,
|
||||||
|
toggleExpand: (entry: LogEntry, expand?: boolean) => void,
|
||||||
) {
|
) {
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
const manualLogEntrySelection = ref<LogEntrySelection>({ type: 'initial' });
|
const manualLogEntrySelection = ref<LogEntrySelection>({ type: 'initial' });
|
||||||
const selected = computed(() => findSelectedLogEntry(manualLogEntrySelection.value, tree.value));
|
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) {
|
function select(value: LogEntry | undefined) {
|
||||||
manualLogEntrySelection.value =
|
manualLogEntrySelection.value =
|
||||||
value === undefined ? { type: 'none' } : { type: 'selected', id: value.id };
|
value === undefined ? { type: 'none' } : { type: 'selected', id: value.id };
|
||||||
|
|
||||||
if (value) {
|
if (value) {
|
||||||
|
syncSelectionToCanvasIfEnabled(value);
|
||||||
|
|
||||||
telemetry.track('User selected node in log view', {
|
telemetry.track('User selected node in log view', {
|
||||||
node_type: value.node.type,
|
node_type: value.node.type,
|
||||||
node_id: value.node.id,
|
node_id: value.node.id,
|
||||||
@@ -39,7 +59,8 @@ export function useLogsSelection(
|
|||||||
? (getEntryAtRelativeIndex(entries, selected.value.id, -1) ?? entries[0])
|
? (getEntryAtRelativeIndex(entries, selected.value.id, -1) ?? entries[0])
|
||||||
: entries[entries.length - 1];
|
: entries[entries.length - 1];
|
||||||
|
|
||||||
select(prevEntry);
|
manualLogEntrySelection.value = { type: 'selected', id: prevEntry.id };
|
||||||
|
syncSelectionToCanvasIfEnabled(prevEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectNext() {
|
function selectNext() {
|
||||||
@@ -48,8 +69,40 @@ export function useLogsSelection(
|
|||||||
? (getEntryAtRelativeIndex(entries, selected.value.id, 1) ?? entries[entries.length - 1])
|
? (getEntryAtRelativeIndex(entries, selected.value.id, 1) ?? entries[entries.length - 1])
|
||||||
: entries[0];
|
: 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 };
|
return { selected, select, selectPrev, selectNext };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ export function useLogsTreeExpand(entries: ComputedRef<LogEntry[]>) {
|
|||||||
const collapsedEntries = ref<Record<string, boolean>>({});
|
const collapsedEntries = ref<Record<string, boolean>>({});
|
||||||
const flatLogEntries = computed(() => flattenLogEntries(entries.value, collapsedEntries.value));
|
const flatLogEntries = computed(() => flattenLogEntries(entries.value, collapsedEntries.value));
|
||||||
|
|
||||||
function toggleExpanded(treeNode: LogEntry) {
|
function toggleExpanded(treeNode: LogEntry, expand?: boolean) {
|
||||||
collapsedEntries.value[treeNode.id] = !collapsedEntries.value[treeNode.id];
|
collapsedEntries.value[treeNode.id] =
|
||||||
|
expand === undefined ? !collapsedEntries.value[treeNode.id] : !expand;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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) {
|
for (const entry of entries) {
|
||||||
if (entry.id === id) {
|
if (isMatched(entry)) {
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
const child = findLogEntryRec(id, entry.children);
|
const child = findLogEntryRec(isMatched, entry.children);
|
||||||
|
|
||||||
if (child) {
|
if (child) {
|
||||||
return child;
|
return child;
|
||||||
@@ -514,7 +517,7 @@ export function findSelectedLogEntry(
|
|||||||
case 'none':
|
case 'none':
|
||||||
return undefined;
|
return undefined;
|
||||||
case 'selected': {
|
case 'selected': {
|
||||||
const entry = findLogEntryRec(selection.id, entries);
|
const entry = findLogEntryRec((e) => e.id === selection.id, entries);
|
||||||
|
|
||||||
if (entry) {
|
if (entry) {
|
||||||
return entry;
|
return entry;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { NodeConnectionTypes } from 'n8n-workflow';
|
|||||||
import type { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
|
import type { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
|
||||||
import { useVueFlow } from '@vue-flow/core';
|
import { useVueFlow } from '@vue-flow/core';
|
||||||
import { SIMULATE_NODE_TYPE } from '@/constants';
|
import { SIMULATE_NODE_TYPE } from '@/constants';
|
||||||
|
import { canvasEventBus } from '@/event-bus/canvas';
|
||||||
|
|
||||||
const matchMedia = global.window.matchMedia;
|
const matchMedia = global.window.matchMedia;
|
||||||
// @ts-expect-error Initialize window object
|
// @ts-expect-error Initialize window object
|
||||||
@@ -156,6 +157,24 @@ describe('Canvas', () => {
|
|||||||
expect(emitted()['update:node:name']).toEqual([['1']]);
|
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 () => {
|
it('should not emit `update:node:name` event if long key press', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import type {
|
|||||||
CanvasNodeData,
|
CanvasNodeData,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
import { CanvasNodeRenderType } 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 { isPresent } from '@/utils/typesUtils';
|
||||||
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
|
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
|
||||||
import { useShortKeyPress } from '@n8n/composables/useShortKeyPress';
|
import { useShortKeyPress } from '@n8n/composables/useShortKeyPress';
|
||||||
@@ -72,6 +72,7 @@ const emit = defineEmits<{
|
|||||||
'update:logs-open': [open?: boolean];
|
'update:logs-open': [open?: boolean];
|
||||||
'update:logs:input-open': [open?: boolean];
|
'update:logs:input-open': [open?: boolean];
|
||||||
'update:logs:output-open': [open?: boolean];
|
'update:logs:output-open': [open?: boolean];
|
||||||
|
'update:has-range-selection': [isActive: boolean];
|
||||||
'click:node': [id: string, position: XYPosition];
|
'click:node': [id: string, position: XYPosition];
|
||||||
'click:node:add': [id: string, handle: string];
|
'click:node:add': [id: string, handle: string];
|
||||||
'run:node': [id: string];
|
'run:node': [id: string];
|
||||||
@@ -153,6 +154,7 @@ const {
|
|||||||
viewport,
|
viewport,
|
||||||
dimensions,
|
dimensions,
|
||||||
nodesSelectionActive,
|
nodesSelectionActive,
|
||||||
|
userSelectionRect,
|
||||||
setViewport,
|
setViewport,
|
||||||
onEdgeMouseLeave,
|
onEdgeMouseLeave,
|
||||||
onEdgeMouseEnter,
|
onEdgeMouseEnter,
|
||||||
@@ -402,9 +404,21 @@ function onSelectNode() {
|
|||||||
emit('update:node:selected', lastSelectedNode.value?.id);
|
emit('update:node:selected', lastSelectedNode.value?.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSelectNodes({ ids }: CanvasEventBusEvents['nodes:select']) {
|
function onSelectNodes({ ids, panIntoView }: CanvasEventBusEvents['nodes:select']) {
|
||||||
clearSelectedNodes();
|
clearSelectedNodes();
|
||||||
addSelectedNodes(ids.map(findNode).filter(isPresent));
|
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) {
|
function onToggleNodeEnabled(id: string) {
|
||||||
@@ -798,6 +812,10 @@ watch(() => props.readOnly, setReadonly, {
|
|||||||
immediate: true,
|
immediate: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch([nodesSelectionActive, userSelectionRect], ([isActive, rect]) =>
|
||||||
|
emit('update:has-range-selection', isActive || (rect?.width ?? 0) > 0 || (rect?.height ?? 0) > 0),
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provide
|
* Provide
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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_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_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_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_LOGS_PANEL_DETAILS_PANEL = 'N8N_LOGS_DETAILS_PANEL';
|
||||||
export const LOCAL_STORAGE_WORKFLOW_LIST_PREFERENCES_KEY = 'N8N_WORKFLOWS_LIST_PREFERENCES';
|
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=';
|
export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename=';
|
||||||
|
|||||||
4
packages/frontend/editor-ui/src/event-bus/canvas.ts
Normal file
4
packages/frontend/editor-ui/src/event-bus/canvas.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import type { CanvasEventBusEvents } from '@/types';
|
||||||
|
import { createEventBus } from '@n8n/utils/event-bus';
|
||||||
|
|
||||||
|
export const canvasEventBus = createEventBus<CanvasEventBusEvents>();
|
||||||
@@ -1824,6 +1824,7 @@
|
|||||||
"runData.panel.actions.collapse": "Collapse panel",
|
"runData.panel.actions.collapse": "Collapse panel",
|
||||||
"runData.panel.actions.open": "Open panel",
|
"runData.panel.actions.open": "Open panel",
|
||||||
"runData.panel.actions.popOut": "Pop out panel",
|
"runData.panel.actions.popOut": "Pop out panel",
|
||||||
|
"runData.panel.actions.sync": "Sync selection with canvas",
|
||||||
"saveButton.save": "@:_reusableBaseText.save",
|
"saveButton.save": "@:_reusableBaseText.save",
|
||||||
"saveButton.saved": "Saved",
|
"saveButton.saved": "Saved",
|
||||||
"saveWorkflowButton.hint": "Save workflow",
|
"saveWorkflowButton.hint": "Save workflow",
|
||||||
|
|||||||
@@ -13,13 +13,20 @@ export const useCanvasStore = defineStore('canvas', () => {
|
|||||||
const aiNodes = computed<INodeUi[]>(() =>
|
const aiNodes = computed<INodeUi[]>(() =>
|
||||||
nodes.value.filter((node) => node.type.includes('langchain')),
|
nodes.value.filter((node) => node.type.includes('langchain')),
|
||||||
);
|
);
|
||||||
|
const hasRangeSelection = ref(false);
|
||||||
|
|
||||||
|
function setHasRangeSelection(value: boolean) {
|
||||||
|
hasRangeSelection.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
newNodeInsertPosition,
|
newNodeInsertPosition,
|
||||||
isLoading: loadingService.isLoading,
|
isLoading: loadingService.isLoading,
|
||||||
aiNodes,
|
aiNodes,
|
||||||
|
hasRangeSelection: computed(() => hasRangeSelection.value),
|
||||||
startLoading: loadingService.startLoading,
|
startLoading: loadingService.startLoading,
|
||||||
setLoadingText: loadingService.setLoadingText,
|
setLoadingText: loadingService.setLoadingText,
|
||||||
stopLoading: loadingService.stopLoading,
|
stopLoading: loadingService.stopLoading,
|
||||||
|
setHasRangeSelection,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import {
|
|||||||
type LogDetailsPanelState,
|
type LogDetailsPanelState,
|
||||||
} from '@/components/CanvasChat/types/logs';
|
} from '@/components/CanvasChat/types/logs';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
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 { useLocalStorage } from '@vueuse/core';
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
@@ -25,6 +29,8 @@ export const useLogsStore = defineStore('logs', () => {
|
|||||||
LOG_DETAILS_PANEL_STATE.OUTPUT,
|
LOG_DETAILS_PANEL_STATE.OUTPUT,
|
||||||
{ writeDefaults: false },
|
{ writeDefaults: false },
|
||||||
);
|
);
|
||||||
|
const isLogSelectionSyncedWithCanvas = useLocalStorage(LOCAL_STORAGE_LOGS_SYNC_SELECTION, false);
|
||||||
|
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
|
|
||||||
function setHeight(value: number) {
|
function setHeight(value: number) {
|
||||||
@@ -77,15 +83,21 @@ export const useLogsStore = defineStore('logs', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleLogSelectionSync(value?: boolean) {
|
||||||
|
isLogSelectionSyncedWithCanvas.value = value ?? !isLogSelectionSyncedWithCanvas.value;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
isOpen: computed(() => state.value !== LOGS_PANEL_STATE.CLOSED),
|
isOpen: computed(() => state.value !== LOGS_PANEL_STATE.CLOSED),
|
||||||
detailsState: computed(() => detailsState.value),
|
detailsState: computed(() => detailsState.value),
|
||||||
height: computed(() => height.value),
|
height: computed(() => height.value),
|
||||||
|
isLogSelectionSyncedWithCanvas: computed(() => isLogSelectionSyncedWithCanvas.value),
|
||||||
setHeight,
|
setHeight,
|
||||||
toggleOpen,
|
toggleOpen,
|
||||||
setPreferPoppedOut,
|
setPreferPoppedOut,
|
||||||
toggleInputOpen,
|
toggleInputOpen,
|
||||||
toggleOutputOpen,
|
toggleOutputOpen,
|
||||||
|
toggleLogSelectionSync,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ export type CanvasEventBusEvents = {
|
|||||||
fitView: never;
|
fitView: never;
|
||||||
'saved:workflow': never;
|
'saved:workflow': never;
|
||||||
'open:execution': IExecutionResponse;
|
'open:execution': IExecutionResponse;
|
||||||
'nodes:select': { ids: string[] };
|
'nodes:select': { ids: string[]; panIntoView?: boolean };
|
||||||
'nodes:action': {
|
'nodes:action': {
|
||||||
ids: string[];
|
ids: string[];
|
||||||
action: keyof CanvasNodeEventBusEvents;
|
action: keyof CanvasNodeEventBusEvents;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
getGenericHints,
|
getGenericHints,
|
||||||
getNewNodePosition,
|
getNewNodePosition,
|
||||||
NODE_SIZE,
|
NODE_SIZE,
|
||||||
|
updateViewportToContainNodes,
|
||||||
} from './nodeViewUtils';
|
} from './nodeViewUtils';
|
||||||
import type { INode, INodeTypeDescription, INodeExecutionData, Workflow } from 'n8n-workflow';
|
import type { INode, INodeTypeDescription, INodeExecutionData, Workflow } from 'n8n-workflow';
|
||||||
import type { INodeUi, XYPosition } from '@/Interface';
|
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 { mock, type MockProxy } from 'vitest-mock-extended';
|
||||||
import { SET_NODE_TYPE, STICKY_NODE_TYPE } from '@/constants';
|
import { SET_NODE_TYPE, STICKY_NODE_TYPE } from '@/constants';
|
||||||
import { createTestNode } from '@/__tests__/mocks';
|
import { createTestNode } from '@/__tests__/mocks';
|
||||||
|
import type { GraphNode } from '@vue-flow/core';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
describe('getGenericHints', () => {
|
describe('getGenericHints', () => {
|
||||||
let mockWorkflowNode: MockProxy<INode>;
|
let mockWorkflowNode: MockProxy<INode>;
|
||||||
@@ -397,3 +400,66 @@ describe('getNodesGroupSize', () => {
|
|||||||
expect(he).toBe(NODE_SIZE);
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ import type {
|
|||||||
import { NodeHelpers, SEND_AND_WAIT_OPERATION } from 'n8n-workflow';
|
import { NodeHelpers, SEND_AND_WAIT_OPERATION } from 'n8n-workflow';
|
||||||
import type { RouteLocation } from 'vue-router';
|
import type { RouteLocation } from 'vue-router';
|
||||||
import type { ViewportBoundaries } from '@/types';
|
import type { ViewportBoundaries } from '@/types';
|
||||||
|
import {
|
||||||
|
getRectOfNodes,
|
||||||
|
type Dimensions,
|
||||||
|
type GraphNode,
|
||||||
|
type Rect,
|
||||||
|
type ViewportTransform,
|
||||||
|
} from '@vue-flow/core';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Canvas constants and functions
|
* Canvas constants and functions
|
||||||
@@ -530,3 +537,66 @@ export const getNodeViewTab = (route: RouteLocation): string | null => {
|
|||||||
}
|
}
|
||||||
return 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ import type {
|
|||||||
} from '@vue-flow/core';
|
} from '@vue-flow/core';
|
||||||
import type {
|
import type {
|
||||||
CanvasConnectionCreateData,
|
CanvasConnectionCreateData,
|
||||||
CanvasEventBusEvents,
|
|
||||||
CanvasNode,
|
CanvasNode,
|
||||||
CanvasNodeMoveEvent,
|
CanvasNodeMoveEvent,
|
||||||
ConnectStartEvent,
|
ConnectStartEvent,
|
||||||
@@ -97,7 +96,7 @@ import { sourceControlEventBus } from '@/event-bus/source-control';
|
|||||||
import { useTagsStore } from '@/stores/tags.store';
|
import { useTagsStore } from '@/stores/tags.store';
|
||||||
import { usePushConnectionStore } from '@/stores/pushConnection.store';
|
import { usePushConnectionStore } from '@/stores/pushConnection.store';
|
||||||
import { useNDVStore } from '@/stores/ndv.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 CanvasStopCurrentExecutionButton from '@/components/canvas/elements/buttons/CanvasStopCurrentExecutionButton.vue';
|
||||||
import CanvasStopWaitingForWebhookButton from '@/components/canvas/elements/buttons/CanvasStopWaitingForWebhookButton.vue';
|
import CanvasStopWaitingForWebhookButton from '@/components/canvas/elements/buttons/CanvasStopWaitingForWebhookButton.vue';
|
||||||
import CanvasClearExecutionDataButton from '@/components/canvas/elements/buttons/CanvasClearExecutionDataButton.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 { tryToParseNumber } from '@/utils/typesUtils';
|
||||||
import { useTemplatesStore } from '@/stores/templates.store';
|
import { useTemplatesStore } from '@/stores/templates.store';
|
||||||
import { N8nCallout } from '@n8n/design-system';
|
import { N8nCallout } from '@n8n/design-system';
|
||||||
import { createEventBus } from '@n8n/utils/event-bus';
|
|
||||||
import type { PinDataSource } from '@/composables/usePinnedData';
|
import type { PinDataSource } from '@/composables/usePinnedData';
|
||||||
import { useClipboard } from '@/composables/useClipboard';
|
import { useClipboard } from '@/composables/useClipboard';
|
||||||
import { useBeforeUnload } from '@/composables/useBeforeUnload';
|
import { useBeforeUnload } from '@/composables/useBeforeUnload';
|
||||||
@@ -123,6 +121,7 @@ import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
|
|||||||
import { useAgentRequestStore } from '@n8n/stores/useAgentRequestStore';
|
import { useAgentRequestStore } from '@n8n/stores/useAgentRequestStore';
|
||||||
import { needsAgentInput } from '@/utils/nodes/nodeTransforms';
|
import { needsAgentInput } from '@/utils/nodes/nodeTransforms';
|
||||||
import { useLogsStore } from '@/stores/logs.store';
|
import { useLogsStore } from '@/stores/logs.store';
|
||||||
|
import { canvasEventBus } from '@/event-bus/canvas';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'NodeView',
|
name: 'NodeView',
|
||||||
@@ -178,8 +177,6 @@ const foldersStore = useFoldersStore();
|
|||||||
const agentRequestStore = useAgentRequestStore();
|
const agentRequestStore = useAgentRequestStore();
|
||||||
const logsStore = useLogsStore();
|
const logsStore = useLogsStore();
|
||||||
|
|
||||||
const canvasEventBus = createEventBus<CanvasEventBusEvents>();
|
|
||||||
|
|
||||||
const { addBeforeUnloadEventBindings, removeBeforeUnloadEventBindings } = useBeforeUnload({
|
const { addBeforeUnloadEventBindings, removeBeforeUnloadEventBindings } = useBeforeUnload({
|
||||||
route,
|
route,
|
||||||
});
|
});
|
||||||
@@ -1584,17 +1581,9 @@ async function onSaveFromWithinExecutionDebug() {
|
|||||||
const viewportTransform = ref<ViewportTransform>({ x: 0, y: 0, zoom: 1 });
|
const viewportTransform = ref<ViewportTransform>({ x: 0, y: 0, zoom: 1 });
|
||||||
const viewportDimensions = ref<Dimensions>({ width: 0, height: 0 });
|
const viewportDimensions = ref<Dimensions>({ width: 0, height: 0 });
|
||||||
|
|
||||||
const viewportBoundaries = computed<ViewportBoundaries>(() => {
|
const viewportBoundaries = computed<ViewportBoundaries>(() =>
|
||||||
const { x, y, zoom } = viewportTransform.value;
|
getBounds(viewportTransform.value, viewportDimensions.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 };
|
|
||||||
});
|
|
||||||
|
|
||||||
function onViewportChange(viewport: ViewportTransform, dimensions: Dimensions) {
|
function onViewportChange(viewport: ViewportTransform, dimensions: Dimensions) {
|
||||||
viewportTransform.value = viewport;
|
viewportTransform.value = viewport;
|
||||||
@@ -1934,6 +1923,7 @@ onBeforeUnmount(() => {
|
|||||||
@update:logs-open="logsStore.toggleOpen($event)"
|
@update:logs-open="logsStore.toggleOpen($event)"
|
||||||
@update:logs:input-open="logsStore.toggleInputOpen"
|
@update:logs:input-open="logsStore.toggleInputOpen"
|
||||||
@update:logs:output-open="logsStore.toggleOutputOpen"
|
@update:logs:output-open="logsStore.toggleOutputOpen"
|
||||||
|
@update:has-range-selection="canvasStore.setHasRangeSelection"
|
||||||
@open:sub-workflow="onOpenSubWorkflow"
|
@open:sub-workflow="onOpenSubWorkflow"
|
||||||
@click:node="onClickNode"
|
@click:node="onClickNode"
|
||||||
@click:node:add="onClickNodeAdd"
|
@click:node:add="onClickNodeAdd"
|
||||||
|
|||||||
Reference in New Issue
Block a user