feat(editor): Implement some quick improvements on NDV in canvas experiment (no-changelog) (#16717)

Co-authored-by: Milorad FIlipović <milorad@n8n.io>
This commit is contained in:
Suguru Inoue
2025-06-30 11:04:11 +02:00
committed by GitHub
parent 5392efaf71
commit eb138ecf8d
14 changed files with 424 additions and 147 deletions

View File

@@ -20,6 +20,7 @@ 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;
@@ -44,6 +45,7 @@ const uiStore = useUIStore();
const focusPanelStore = useFocusPanelStore();
const posthogStore = usePostHog();
const i18n = useI18n();
const experimentalNdvStore = useExperimentalNdvStore();
const { getAddedNodesAndConnections } = useActions();
@@ -125,6 +127,20 @@ function nodeTypeSelected(value: NodeTypeSelectedPayload[]) {
@click="focusPanelStore.toggleFocusPanel"
/>
</KeyboardShortcutTooltip>
<n8n-icon-button
v-if="experimentalNdvStore.isEnabled"
type="tertiary"
size="large"
icon="expand"
@click="experimentalNdvStore.expandAllNodes"
/>
<n8n-icon-button
v-if="experimentalNdvStore.isEnabled"
type="tertiary"
size="large"
icon="compress"
@click="experimentalNdvStore.collapseAllNodes"
/>
</div>
<Suspense>
<LazyNodeCreator

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useTemplateRef, computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import type {
INodeTypeDescription,
INodeParameters,
@@ -18,10 +18,8 @@ import type {
import { COMMUNITY_NODES_INSTALLATION_DOCS_URL, CUSTOM_NODES_DOCS_URL } from '@/constants';
import NodeTitle from '@/components/NodeTitle.vue';
import ParameterInputList from '@/components/ParameterInputList.vue';
import NodeCredentials from '@/components/NodeCredentials.vue';
import NodeSettingsTabs from '@/components/NodeSettingsTabs.vue';
import NodeWebhooks from '@/components/NodeWebhooks.vue';
import NDVSubConnections from '@/components/NDVSubConnections.vue';
import get from 'lodash/get';
@@ -42,8 +40,9 @@ import { useTelemetry } from '@/composables/useTelemetry';
import { importCurlEventBus, ndvEventBus } from '@/event-bus';
import { ProjectTypes } from '@/types/projects.types';
import FreeAiCreditsCallout from '@/components/FreeAiCreditsCallout.vue';
import { shouldShowParameter } from './canvas/experimental/experimentalNdv.utils';
import { useResizeObserver } from '@vueuse/core';
import { useNodeSettingsParameters } from '@/composables/useNodeSettingsParameters';
import { N8nIconButton } from '@n8n/design-system';
const props = withDefaults(
defineProps<{
@@ -57,8 +56,8 @@ const props = withDefaults(
executable: boolean;
inputSize: number;
activeNode?: INodeUi;
canExpand?: boolean;
hideConnections?: boolean;
isEmbeddedInCanvas?: boolean;
noWheel?: boolean;
}>(),
{
foreignCredentials: () => [],
@@ -67,8 +66,8 @@ const props = withDefaults(
inputSize: 0,
blockUI: false,
activeNode: undefined,
canExpand: false,
hideConnections: false,
isEmbeddedInCanvas: false,
noWheel: false,
},
);
@@ -84,9 +83,10 @@ const emit = defineEmits<{
];
activate: [];
execute: [];
expand: [];
}>();
const slots = defineSlots<{ actions?: {} }>();
const nodeTypesStore = useNodeTypesStore();
const ndvStore = useNDVStore();
const workflowsStore = useWorkflowsStore();
@@ -100,6 +100,17 @@ const i18n = useI18n();
const nodeSettingsParameters = useNodeSettingsParameters();
const nodeValues = nodeSettingsParameters.nodeValues;
const nodeParameterWrapper = useTemplateRef('nodeParameterWrapper');
const shouldShowStaticScrollbar = ref(false);
if (props.isEmbeddedInCanvas) {
useResizeObserver(nodeParameterWrapper, () => {
shouldShowStaticScrollbar.value =
(nodeParameterWrapper.value?.scrollHeight ?? 0) >
(nodeParameterWrapper.value?.offsetHeight ?? 0);
});
}
const nodeValid = ref(true);
const openPanel = ref<'params' | 'settings'>('params');
@@ -198,10 +209,12 @@ const parameters = computed(() => {
const parametersSetting = computed(() => parameters.value.filter((item) => item.isNodeSetting));
const parametersNoneSetting = computed(() =>
const parametersNoneSetting = computed(() => {
// The connection hint notice is visually hidden via CSS in NodeDetails.vue when the node has output connections
parameters.value.filter((item) => !item.isNodeSetting),
);
const paramsToShow = parameters.value.filter((item) => !item.isNodeSetting);
return props.isEmbeddedInCanvas ? parameters.value.filter(shouldShowParameter) : paramsToShow;
});
const isDisplayingCredentials = computed(
() =>
@@ -735,6 +748,12 @@ function displayCredentials(credentialTypeDescription: INodeCredentialDescriptio
nodeHelpers.displayParameter(node.value.parameters, credentialTypeDescription, '', node.value)
);
}
function handleWheelEvent(event: WheelEvent) {
if (event.ctrlKey) {
event.preventDefault();
}
}
</script>
<template>
@@ -742,6 +761,7 @@ function displayCredentials(credentialTypeDescription: INodeCredentialDescriptio
:class="{
'node-settings': true,
dragging: dragging,
embedded: props.isEmbeddedInCanvas,
}"
@keydown.stop
>
@@ -754,8 +774,8 @@ function displayCredentials(credentialTypeDescription: INodeCredentialDescriptio
:node-type="nodeType"
:read-only="isReadOnly"
@update:model-value="nameChanged"
></NodeTitle>
<div v-if="isExecutable || props.canExpand" :class="$style.headerActions">
/>
<template v-if="isExecutable || slots.actions">
<NodeExecuteButton
v-if="isExecutable && !blockUI && node && nodeValid"
data-test-id="node-execute-button"
@@ -768,17 +788,8 @@ function displayCredentials(credentialTypeDescription: INodeCredentialDescriptio
@stop-execution="onStopExecution"
@value-changed="valueChanged"
/>
<N8nIconButton
v-if="props.canExpand"
icon="expand"
type="secondary"
text
size="mini"
icon-size="large"
aria-label="Expand"
@click="emit('expand')"
/>
</div>
<slot name="actions" />
</template>
</div>
<NodeSettingsTabs
v-if="node && nodeValid"
@@ -830,7 +841,17 @@ function displayCredentials(credentialTypeDescription: INodeCredentialDescriptio
</template>
</i18n-t>
</div>
<div v-if="node && nodeValid" class="node-parameters-wrapper" data-test-id="node-parameters">
<div
v-if="node && nodeValid"
ref="nodeParameterWrapper"
:class="[
'node-parameters-wrapper',
shouldShowStaticScrollbar ? 'with-static-scrollbar' : '',
noWheel && shouldShowStaticScrollbar ? 'nowheel' : '',
]"
data-test-id="node-parameters"
@wheel="noWheel ? handleWheelEvent : undefined"
>
<n8n-notice
v-if="hasForeignCredential && !isHomeProjectTeam"
:content="
@@ -851,11 +872,13 @@ function displayCredentials(credentialTypeDescription: INodeCredentialDescriptio
:is-read-only="isReadOnly"
:hidden-issues-inputs="hiddenIssuesInputs"
path="parameters"
:node="props.activeNode"
@value-changed="valueChanged"
@activate="onWorkflowActivate"
@parameter-blur="onParameterBlur"
>
<NodeCredentials
v-if="!isEmbeddedInCanvas"
:node="node"
:readonly="isReadOnly"
:show-all="true"
@@ -920,7 +943,7 @@ function displayCredentials(credentialTypeDescription: INodeCredentialDescriptio
</div>
</div>
<NDVSubConnections
v-if="node && !props.hideConnections"
v-if="node && !props.isEmbeddedInCanvas"
ref="subConnections"
:root-node="node"
@switch-selected-node="onSwitchSelectedNode"
@@ -935,12 +958,6 @@ function displayCredentials(credentialTypeDescription: INodeCredentialDescriptio
background-color: var(--color-background-base);
}
.headerActions {
display: flex;
gap: var(--spacing-4xs);
align-items: center;
}
.warningIcon {
color: var(--color-text-lighter);
font-size: var(--font-size-2xl);
@@ -976,6 +993,10 @@ function displayCredentials(credentialTypeDescription: INodeCredentialDescriptio
}
}
&.embedded .header-side-menu {
padding: var(--spacing-xs);
}
.node-is-not-valid {
height: 75%;
padding: 10px;
@@ -993,6 +1014,28 @@ function displayCredentials(credentialTypeDescription: INodeCredentialDescriptio
flex-grow: 1;
}
&.embedded .node-parameters-wrapper {
padding: 0 var(--spacing-xs) var(--spacing-xs) var(--spacing-xs);
}
&.embedded .node-parameters-wrapper.with-static-scrollbar {
padding: 0 var(--spacing-2xs) var(--spacing-xs) var(--spacing-xs);
@supports not (selector(::-webkit-scrollbar)) {
scrollbar-width: thin;
}
@supports selector(::-webkit-scrollbar) {
&::-webkit-scrollbar {
width: var(--spacing-2xs);
}
&::-webkit-scrollbar-thumb {
border-radius: var(--spacing-2xs);
background: var(--color-foreground-dark);
border: var(--spacing-5xs) solid white;
}
}
}
&.dragging {
border-color: var(--color-primary);
box-shadow: 0px 6px 16px rgba(255, 74, 51, 0.15);

View File

@@ -9,7 +9,7 @@ import type {
import { ADD_FORM_NOTICE, deepCopy, NodeHelpers } from 'n8n-workflow';
import { computed, defineAsyncComponent, onErrorCaptured, ref, watch, type WatchSource } from 'vue';
import type { IUpdateInformation } from '@/Interface';
import type { INodeUi, IUpdateInformation } from '@/Interface';
import AssignmentCollection from '@/components/AssignmentCollection/AssignmentCollection.vue';
import ButtonParameter from '@/components/ButtonParameter/ButtonParameter.vue';
@@ -64,6 +64,7 @@ const LazyCollectionParameter = defineAsyncComponent(
const showIssuesInLabelFor = ['fixedCollection'];
type Props = {
node?: INodeUi;
nodeValues: INodeParameters;
parameters: INodeProperties[];
path?: string;
@@ -120,6 +121,8 @@ const nodeType = computed(() => {
return null;
});
const node = computed(() => props.node ?? ndvStore.activeNode);
const filteredParameters = computedWithControl(
[() => props.parameters, () => props.nodeValues] as WatchSource[],
() => {
@@ -127,22 +130,20 @@ const filteredParameters = computedWithControl(
displayNodeParameter(parameter),
);
const activeNode = ndvStore.activeNode;
if (activeNode && activeNode.type === FORM_TRIGGER_NODE_TYPE) {
return updateFormTriggerParameters(parameters, activeNode.name);
if (node.value && node.value.type === FORM_TRIGGER_NODE_TYPE) {
return updateFormTriggerParameters(parameters, node.value.name);
}
if (activeNode && activeNode.type === FORM_NODE_TYPE) {
return updateFormParameters(parameters, activeNode.name);
if (node.value && node.value.type === FORM_NODE_TYPE) {
return updateFormParameters(parameters, node.value.name);
}
if (
activeNode &&
activeNode.type === WAIT_NODE_TYPE &&
activeNode.parameters.resume === 'form'
node.value &&
node.value.type === WAIT_NODE_TYPE &&
node.value.parameters.resume === 'form'
) {
return updateWaitParameters(parameters, activeNode.name);
return updateWaitParameters(parameters, node.value.name);
}
return parameters;
@@ -153,8 +154,6 @@ const filteredParameterNames = computed(() => {
return filteredParameters.value.map((parameter) => parameter.name);
});
const node = computed(() => ndvStore.activeNode);
const nodeAuthFields = computed(() => {
return getNodeAuthFields(nodeType.value);
});

View File

@@ -55,6 +55,7 @@ import Edge from './elements/edges/CanvasEdge.vue';
import Node from './elements/nodes/CanvasNode.vue';
import { useViewportAutoAdjust } from './composables/useViewportAutoAdjust';
import { isOutsideSelected } from '@/utils/htmlUtils';
import { useExperimentalNdvStore } from './experimental/experimentalNdv.store';
const $style = useCssModule();
@@ -174,6 +175,8 @@ const {
} = useCanvasTraversal(vueFlow);
const { layout } = useCanvasLayout({ id: props.id });
const experimentalNdvStore = useExperimentalNdvStore();
const isPaneReady = ref(false);
const classes = computed(() => ({
@@ -854,7 +857,7 @@ provide(CanvasKey, {
snap-to-grid
:snap-grid="[GRID_SIZE, GRID_SIZE]"
:min-zoom="0"
:max-zoom="4"
:max-zoom="experimentalNdvStore.isEnabled ? experimentalNdvStore.maxCanvasZoom : 4"
:selection-key-code="selectionKeyCode"
:zoom-activation-key-code="panningKeyCode"
:pan-activation-key-code="panningKeyCode"

View File

@@ -10,7 +10,7 @@ import type { CanvasEventBusEvents } from '@/types';
import { useVueFlow } from '@vue-flow/core';
import { throttledRef } from '@vueuse/core';
import { useSettingsStore } from '@/stores/settings.store';
import ExperimentalNodeDetailsDrawer from './components/ExperimentalNodeDetailsDrawer.vue';
import ExperimentalNodeDetailsDrawer from './experimental/components/ExperimentalNodeDetailsDrawer.vue';
defineOptions({
inheritAttrs: false,

View File

@@ -1,27 +0,0 @@
import { useCanvasOperations } from '@/composables/useCanvasOperations';
import { useSettingsStore } from '@/stores/settings.store';
import { useVueFlow } from '@vue-flow/core';
import { useDebounce } from '@vueuse/core';
import { computed, type ComputedRef } from 'vue';
export function useNodeSettingsInCanvas(): ComputedRef<number | undefined> {
const settingsStore = useSettingsStore();
if (
Number.isNaN(settingsStore.experimental__minZoomNodeSettingsInCanvas) ||
settingsStore.experimental__minZoomNodeSettingsInCanvas <= 0
) {
return computed(() => undefined);
}
const { editableWorkflow } = useCanvasOperations();
const viewFlow = useVueFlow({ id: editableWorkflow.value.id });
const zoom = computed(() => viewFlow.viewport.value.zoom);
const debouncedZoom = useDebounce(zoom, 100);
return computed(() =>
debouncedZoom.value > settingsStore.experimental__minZoomNodeSettingsInCanvas
? debouncedZoom.value
: undefined,
);
}

View File

@@ -5,9 +5,9 @@ import { useI18n } from '@n8n/i18n';
import { useCanvasNode } from '@/composables/useCanvasNode';
import type { CanvasNodeDefaultRender } from '@/types';
import { useCanvas } from '@/composables/useCanvas';
import { useNodeSettingsInCanvas } from '@/components/canvas/composables/useNodeSettingsInCanvas';
import { calculateNodeSize } from '@/utils/nodeViewUtils';
import ExperimentalCanvasNodeSettings from '../../../components/ExperimentalCanvasNodeSettings.vue';
import ExperimentalInPlaceNodeSettings from '@/components/canvas/experimental/components/ExperimentalEmbeddedNodeDetails.vue';
import { useExperimentalNdvStore } from '@/components/canvas/experimental/experimentalNdv.store';
const $style = useCssModule();
const i18n = useI18n();
@@ -45,7 +45,7 @@ const { mainOutputs, mainOutputConnections, mainInputs, mainInputConnections, no
const renderOptions = computed(() => render.value.options as CanvasNodeDefaultRender['options']);
const nodeSettingsZoom = useNodeSettingsInCanvas();
const experimentalNdvStore = useExperimentalNdvStore();
const classes = computed(() => {
return {
@@ -61,7 +61,6 @@ const classes = computed(() => {
[$style.configuration]: renderOptions.value.configuration,
[$style.trigger]: renderOptions.value.trigger,
[$style.warning]: renderOptions.value.dirtiness !== undefined,
[$style.settingsView]: nodeSettingsZoom.value !== undefined,
};
});
@@ -81,7 +80,6 @@ const styles = computed(() => ({
'--canvas-node--width': `${nodeSize.value.width}px`,
'--canvas-node--height': `${nodeSize.value.height}px`,
'--node-icon-size': `${iconSize.value}px`,
...(nodeSettingsZoom.value === undefined ? {} : { '--zoom': nodeSettingsZoom.value }),
}));
const dataTestId = computed(() => {
@@ -133,35 +131,39 @@ function onActivate(event: MouseEvent) {
</script>
<template>
<ExperimentalInPlaceNodeSettings
v-if="experimentalNdvStore.isActive(viewport.zoom)"
:node-id="id"
:class="classes"
:style="styles"
/>
<div
v-else
:class="classes"
:style="styles"
:data-test-id="dataTestId"
@contextmenu="openContextMenu"
@dblclick.stop="onActivate"
>
<ExperimentalCanvasNodeSettings v-if="nodeSettingsZoom !== undefined" :node-id="id" />
<template v-else>
<CanvasNodeTooltip v-if="renderOptions.tooltip" :visible="showTooltip" />
<NodeIcon
:icon-source="iconSource"
:size="iconSize"
:shrink="false"
:disabled="isDisabled"
:class="$style.icon"
/>
<CanvasNodeDisabledStrikeThrough v-if="isStrikethroughVisible" />
<div :class="$style.description">
<div v-if="label" :class="$style.label">
{{ label }}
</div>
<div v-if="isDisabled" :class="$style.disabledLabel">
({{ i18n.baseText('node.disabled') }})
</div>
<div v-if="subtitle" :class="$style.subtitle">{{ subtitle }}</div>
<CanvasNodeTooltip v-if="renderOptions.tooltip" :visible="showTooltip" />
<NodeIcon
:icon-source="iconSource"
:size="iconSize"
:shrink="false"
:disabled="isDisabled"
:class="$style.icon"
/>
<CanvasNodeDisabledStrikeThrough v-if="isStrikethroughVisible" />
<div :class="$style.description">
<div v-if="label" :class="$style.label">
{{ label }}
</div>
<CanvasNodeStatusIcons v-if="!isDisabled" :class="$style.statusIcons" />
</template>
<div v-if="isDisabled" :class="$style.disabledLabel">
({{ i18n.baseText('node.disabled') }})
</div>
<div v-if="subtitle" :class="$style.subtitle">{{ subtitle }}</div>
</div>
<CanvasNodeStatusIcons v-if="!isDisabled" :class="$style.statusIcons" />
</div>
</template>
@@ -188,20 +190,6 @@ function onActivate(event: MouseEvent) {
var(--border-radius-large) var(--trigger-node--border-radius);
}
&.settingsView {
height: calc(var(--canvas-node--height) * 2.4) !important;
width: calc(var(--canvas-node--width) * 1.6) !important;
align-items: flex-start;
justify-content: stretch;
overflow: auto;
border-radius: var(--border-radius-large) !important;
& > * {
zoom: calc(1 / var(--zoom, 1));
width: 100% !important;
}
}
/**
* Node types
*/
@@ -272,32 +260,47 @@ function onActivate(event: MouseEvent) {
}
&.success {
border-color: var(--color-canvas-node-success-border-color, var(--color-success));
--canvas-node--border-color: var(
--color-canvas-node-success-border-color,
var(--color-success)
);
}
&.warning {
border-color: var(--color-warning);
--canvas-node--border-color: var(--color-warning);
}
&.error {
border-color: var(--color-canvas-node-error-border-color, var(--color-danger));
--canvas-node--border-color: var(--color-canvas-node-error-border-color, var(--color-danger));
}
&.pinned {
border-color: var(--color-canvas-node-pinned-border-color, var(--color-node-pinned-border));
--canvas-node--border-color: var(
--color-canvas-node-pinned-border-color,
var(--color-node-pinned-border)
);
}
&.disabled {
border-color: var(--color-canvas-node-disabled-border-color, var(--color-foreground-base));
--canvas-node--border-color: var(
--color-canvas-node-disabled-border-color,
var(--color-foreground-base)
);
}
&.running {
background-color: var(--color-node-executing-background);
border-color: var(--color-canvas-node-running-border-color, var(--color-node-running-border));
--canvas-node--border-color: var(
--color-canvas-node-running-border-color,
var(--color-node-running-border)
);
}
&.waiting {
border-color: var(--color-canvas-node-waiting-border-color, var(--color-secondary));
--canvas-node--border-color: var(
--color-canvas-node-waiting-border-color,
var(--color-secondary)
);
}
}

View File

@@ -6,7 +6,6 @@ exports[`CanvasNodeDefault > configurable > should render configurable node corr
data-test-id="canvas-configurable-node"
style="--canvas-node--width: 240px; --canvas-node--height: 100px; --node-icon-size: 40px;"
>
<!--v-if-->
<div
class="n8n-node-icon icon icon"
@@ -43,7 +42,6 @@ exports[`CanvasNodeDefault > configurable > should render configurable node corr
</div>
</div>
<!--v-if-->
</div>
`;
@@ -53,7 +51,6 @@ exports[`CanvasNodeDefault > configuration > should render configurable configur
data-test-id="canvas-configurable-node"
style="--canvas-node--width: 240px; --canvas-node--height: 75px; --node-icon-size: 30px;"
>
<!--v-if-->
<div
class="n8n-node-icon icon icon"
@@ -90,7 +87,6 @@ exports[`CanvasNodeDefault > configuration > should render configurable configur
</div>
</div>
<!--v-if-->
</div>
`;
@@ -100,7 +96,6 @@ exports[`CanvasNodeDefault > configuration > should render configuration node co
data-test-id="canvas-configuration-node"
style="--canvas-node--width: 80px; --canvas-node--height: 80px; --node-icon-size: 30px;"
>
<!--v-if-->
<div
class="n8n-node-icon icon icon"
@@ -137,7 +132,6 @@ exports[`CanvasNodeDefault > configuration > should render configuration node co
</div>
</div>
<!--v-if-->
</div>
`;
@@ -147,7 +141,6 @@ exports[`CanvasNodeDefault > should render node correctly 1`] = `
data-test-id="canvas-default-node"
style="--canvas-node--width: 100px; --canvas-node--height: 100px; --node-icon-size: 40px;"
>
<!--v-if-->
<div
class="n8n-node-icon icon icon"
@@ -184,7 +177,6 @@ exports[`CanvasNodeDefault > should render node correctly 1`] = `
</div>
</div>
<!--v-if-->
</div>
`;
@@ -194,7 +186,6 @@ exports[`CanvasNodeDefault > trigger > should render trigger node correctly 1`]
data-test-id="canvas-trigger-node"
style="--canvas-node--width: 100px; --canvas-node--height: 100px; --node-icon-size: 40px;"
>
<!--v-if-->
<div
class="n8n-node-icon icon icon"
@@ -231,6 +222,5 @@ exports[`CanvasNodeDefault > trigger > should render trigger node correctly 1`]
</div>
</div>
<!--v-if-->
</div>
`;

View File

@@ -2,18 +2,18 @@
import NodeSettings from '@/components/NodeSettings.vue';
import { useCanvasOperations } from '@/composables/useCanvasOperations';
import { type IUpdateInformation } from '@/Interface';
import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { createEventBus } from '@n8n/utils/event-bus';
import { computed } from 'vue';
const { nodeId, canOpenNdv } = defineProps<{ nodeId: string; canOpenNdv?: boolean }>();
const { nodeId, noWheel } = defineProps<{ nodeId: string; noWheel?: boolean }>();
defineSlots<{ actions?: {} }>();
const settingsEventBus = createEventBus();
const nodeTypesStore = useNodeTypesStore();
const workflowsStore = useWorkflowsStore();
const { setActiveNodeName } = useNDVStore();
const { renameNode } = useCanvasOperations();
const activeNode = computed(() => workflowsStore.getNodeById(nodeId));
@@ -24,12 +24,6 @@ const activeNodeType = computed(() => {
return null;
});
function handleOpenNdv() {
if (activeNode.value) {
setActiveNodeName(activeNode.value.name);
}
}
function handleValueChanged(parameterData: IUpdateInformation) {
if (parameterData.name === 'name' && parameterData.oldValue) {
void renameNode(parameterData.oldValue as string, parameterData.value as string);
@@ -39,7 +33,6 @@ function handleValueChanged(parameterData: IUpdateInformation) {
<template>
<NodeSettings
:can-expand="canOpenNdv"
:event-bus="settingsEventBus"
:dragging="false"
:active-node="activeNode"
@@ -50,8 +43,12 @@ function handleValueChanged(parameterData: IUpdateInformation) {
:block-u-i="false"
:executable="false"
:input-size="0"
hide-connections
@expand="handleOpenNdv"
is-embedded-in-canvas
:no-wheel="noWheel"
@value-changed="handleValueChanged"
/>
>
<template #actions>
<slot name="actions" />
</template>
</NodeSettings>
</template>

View File

@@ -0,0 +1,173 @@
<script setup lang="ts">
import ExperimentalCanvasNodeSettings from './ExperimentalCanvasNodeSettings.vue';
import { onBeforeUnmount, ref, computed } from 'vue';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useExperimentalNdvStore } from '../experimentalNdv.store';
import NodeTitle from '@/components/NodeTitle.vue';
import { N8nIcon, N8nIconButton } from '@n8n/design-system';
import { useVueFlow } from '@vue-flow/core';
import { watchOnce } from '@vueuse/core';
const { nodeId } = defineProps<{ nodeId: string }>();
const experimentalNdvStore = useExperimentalNdvStore();
const isExpanded = computed(() => !experimentalNdvStore.collapsedNodes[nodeId]);
const nodeTypesStore = useNodeTypesStore();
const workflowsStore = useWorkflowsStore();
const node = computed(() => workflowsStore.getNodeById(nodeId) ?? null);
const nodeType = computed(() => {
if (node.value) {
return nodeTypesStore.getNodeType(node.value.type, node.value.typeVersion);
}
return null;
});
const vf = useVueFlow(workflowsStore.workflowId);
const isMoving = ref(false);
const moveStartListener = vf.onMoveStart(() => {
isMoving.value = true;
});
const moveEndListener = vf.onMoveEnd(() => {
isMoving.value = false;
});
onBeforeUnmount(() => {
moveStartListener.off();
moveEndListener.off();
});
const isVisible = computed(() =>
vf.isNodeIntersecting(
{ id: nodeId },
{
x: -vf.viewport.value.x / vf.viewport.value.zoom,
y: -vf.viewport.value.y / vf.viewport.value.zoom,
width: vf.viewportRef.value?.offsetWidth ?? 0,
height: vf.viewportRef.value?.offsetHeight ?? 0,
},
),
);
const isOnceVisible = ref(isVisible.value);
watchOnce(isVisible, (visible) => {
isOnceVisible.value = isOnceVisible.value || visible;
});
function handleToggleExpand() {
experimentalNdvStore.setNodeExpanded(nodeId);
}
</script>
<template>
<div
ref="container"
:class="[$style.component, isExpanded ? $style.expanded : $style.collapsed]"
:style="{ '--zoom': `${1 / experimentalNdvStore.maxCanvasZoom}` }"
>
<template v-if="isOnceVisible">
<ExperimentalCanvasNodeSettings
v-if="isExpanded"
:node-id="nodeId"
:class="$style.settingsView"
:no-wheel="
!isMoving /* to not interrupt panning while allowing scroll of the settings pane, allow wheel event while panning */
"
>
<template #actions>
<N8nIconButton
icon="compress"
type="secondary"
text
size="mini"
icon-size="large"
aria-label="Toggle expand"
@click="handleToggleExpand"
/>
</template>
</ExperimentalCanvasNodeSettings>
<div v-else role="button " :class="$style.collapsedContent" @click="handleToggleExpand">
<NodeTitle
v-if="node"
class="node-name"
:model-value="node.name"
:node-type="nodeType"
read-only
/>
<N8nIcon icon="expand" size="large" />
</div>
</template>
</div>
</template>
<style lang="scss" module>
:root .component {
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);
&.expanded {
cursor: default;
}
&.collapsed {
height: 50px;
margin-block: calc(var(--canvas-node--width) * 0.25);
}
}
:root :global(.vue-flow__node):has(.component) {
z-index: 10;
:global(.selected) & {
z-index: 11;
}
}
: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%;
}
.collapsedContent {
height: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-s);
background-color: white;
padding: var(--spacing-2xs);
background-color: var(--color-background-xlight);
color: var(--color-text-base);
cursor: pointer;
}
</style>

View File

@@ -3,6 +3,7 @@ import { type CanvasNode } from '@/types';
import ExperimentalCanvasNodeSettings from './ExperimentalCanvasNodeSettings.vue';
import { N8nText } from '@n8n/design-system';
import { computed, watch, ref } from 'vue';
import { useNDVStore } from '@/stores/ndv.store';
const { selectedNodes } = defineProps<{ selectedNodes: CanvasNode[] }>();
@@ -14,6 +15,13 @@ const content = computed(() =>
: undefined,
);
const lastContent = ref<string | CanvasNode | undefined>();
const { setActiveNodeName } = useNDVStore();
function handleOpenNdv() {
if (typeof content.value === 'object' && content.value.data) {
setActiveNodeName(content.value.data.name);
}
}
// Sync lastContent to be "last defined content" (for drawer animation)
watch(
@@ -36,8 +44,19 @@ watch(
v-else-if="lastContent !== undefined"
:key="lastContent.id"
:node-id="lastContent.id"
can-open-ndv
/>
>
<template #actions>
<N8nIconButton
icon="expand"
type="secondary"
text
size="mini"
icon-size="large"
aria-label="Expand"
@click="handleOpenNdv"
/>
</template>
</ExperimentalCanvasNodeSettings>
</div>
</template>

View File

@@ -0,0 +1,54 @@
import { computed, shallowRef } from 'vue';
import { defineStore } from 'pinia';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useSettingsStore } from '@/stores/settings.store';
export const useExperimentalNdvStore = defineStore('experimentalNdv', () => {
const workflowStore = useWorkflowsStore();
const settingsStore = useSettingsStore();
const isEnabled = computed(
() =>
!Number.isNaN(settingsStore.experimental__minZoomNodeSettingsInCanvas) &&
settingsStore.experimental__minZoomNodeSettingsInCanvas > 0,
);
const maxCanvasZoom = computed(() =>
isEnabled.value ? settingsStore.experimental__minZoomNodeSettingsInCanvas : 4,
);
const collapsedNodes = shallowRef<Partial<Record<string, boolean>>>({});
function setNodeExpanded(nodeId: string, isExpanded?: boolean) {
collapsedNodes.value = {
...collapsedNodes.value,
[nodeId]: isExpanded ?? !collapsedNodes.value[nodeId],
};
}
function collapseAllNodes() {
collapsedNodes.value = workflowStore.allNodes.reduce<Partial<Record<string, boolean>>>(
(acc, node) => {
acc[node.id] = true;
return acc;
},
{},
);
}
function expandAllNodes() {
collapsedNodes.value = {};
}
function isActive(canvasZoom: number) {
return isEnabled.value && canvasZoom === maxCanvasZoom.value;
}
return {
isEnabled,
maxCanvasZoom,
collapsedNodes: computed(() => collapsedNodes.value),
isActive,
setNodeExpanded,
expandAllNodes,
collapseAllNodes,
};
});

View File

@@ -0,0 +1,5 @@
import type { INodeProperties } from 'n8n-workflow';
export function shouldShowParameter(item: INodeProperties): boolean {
return item.name.match(/resource|authentication|operation/i) === null;
}

View File

@@ -43,6 +43,7 @@ import {
faCogs,
faComment,
faComments,
faCompress,
faClipboardList,
faClock,
faClone,
@@ -247,6 +248,7 @@ export const FontAwesomePlugin: Plugin = {
addIcon(faCogs);
addIcon(faComment);
addIcon(faComments);
addIcon(faCompress);
addIcon(faClipboardList);
addIcon(faClock);
addIcon(faClone);