feat(editor): Update styling of embedded NDV (no-changelog) (#17366)

This commit is contained in:
Suguru Inoue
2025-07-22 11:39:14 +02:00
committed by GitHub
parent c1aae67a04
commit ee67e9e354
31 changed files with 623 additions and 265 deletions

View File

@@ -10,12 +10,14 @@ interface TabsProps {
modelValue?: Value; modelValue?: Value;
options?: Array<TabOptions<Value>>; options?: Array<TabOptions<Value>>;
size?: 'small' | 'medium'; size?: 'small' | 'medium';
variant?: 'modern' | 'legacy';
} }
withDefaults(defineProps<TabsProps>(), { withDefaults(defineProps<TabsProps>(), {
modelValue: undefined, modelValue: undefined,
options: () => [], options: () => [],
size: 'medium', size: 'medium',
variant: 'legacy',
}); });
const scrollPosition = ref(0); const scrollPosition = ref(0);
@@ -69,7 +71,14 @@ const scrollRight = () => scroll(50);
</script> </script>
<template> <template>
<div :class="['n8n-tabs', $style.container, size === 'small' ? $style.small : '']"> <div
:class="[
'n8n-tabs',
$style.container,
size === 'small' ? $style.small : '',
variant === 'modern' ? $style.modern : '',
]"
>
<div v-if="scrollPosition > 0" :class="$style.back" @click="scrollLeft"> <div v-if="scrollPosition > 0" :class="$style.back" @click="scrollLeft">
<N8nIcon icon="chevron-left" size="small" /> <N8nIcon icon="chevron-left" size="small" />
</div> </div>
@@ -133,6 +142,11 @@ const scrollRight = () => scroll(50);
height: 24px; height: 24px;
min-height: 24px; min-height: 24px;
width: 100%; width: 100%;
&.modern {
height: 26px;
min-height: 26px;
}
} }
.tabs { .tabs {
@@ -158,11 +172,9 @@ const scrollRight = () => scroll(50);
--active-tab-border-width: 2px; --active-tab-border-width: 2px;
display: block; display: block;
padding: 0 var(--spacing-s); padding: 0 var(--spacing-s);
padding-bottom: calc( padding-bottom: calc(var(--spacing-2xs) + var(--active-tab-border-width));
var(--spacing-bottom-tab, var(--spacing-2xs)) + var(--active-tab-border-width) font-size: var(--font-size-s);
);
font-size: var(--font-size-tab, var(--font-size-s));
font-weight: var(--font-weight-tab, var(--font-weight-regular));
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
color: var(--color-text-base); color: var(--color-text-base);
@@ -174,6 +186,12 @@ const scrollRight = () => scroll(50);
margin-left: var(--spacing-4xs); margin-left: var(--spacing-4xs);
} }
.modern & {
padding-bottom: calc(var(--spacing-xs) + var(--active-tab-border-width));
font-size: var(--font-size-2xs);
font-weight: var(--font-weight-bold);
}
.small & { .small & {
font-size: var(--font-size-2xs); font-size: var(--font-size-2xs);
} }
@@ -181,8 +199,12 @@ const scrollRight = () => scroll(50);
.activeTab { .activeTab {
color: var(--color-primary); color: var(--color-primary);
padding-bottom: var(--spacing-bottom-tab, var(--spacing-2xs)); padding-bottom: var(--spacing-2xs);
border-bottom: var(--color-primary) var(--active-tab-border-width) solid; border-bottom: var(--color-primary) var(--active-tab-border-width) solid;
.modern & {
padding-bottom: var(--spacing-xs);
}
} }
.alignRight:not(.alignRight + .alignRight) { .alignRight:not(.alignRight + .alignRight) {

View File

@@ -109,6 +109,7 @@
"@n8n/typescript-config": "workspace:*", "@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*", "@n8n/vitest-config": "workspace:*",
"@pinia/testing": "^0.1.6", "@pinia/testing": "^0.1.6",
"@testing-library/vue": "catalog:frontend",
"@types/dateformat": "^3.0.0", "@types/dateformat": "^3.0.0",
"@types/file-saver": "^2.0.1", "@types/file-saver": "^2.0.1",
"@types/humanize-duration": "^3.27.1", "@types/humanize-duration": "^3.27.1",

View File

@@ -138,6 +138,7 @@ export function createCanvasProvide({
isExecuting: ref(isExecuting), isExecuting: ref(isExecuting),
connectingHandle: ref(connectingHandle), connectingHandle: ref(connectingHandle),
viewport: ref(viewport), viewport: ref(viewport),
isExperimentalNdvActive: computed(() => false),
} satisfies CanvasInjectionData, } satisfies CanvasInjectionData,
}; };
} }

View File

@@ -9,6 +9,7 @@ import { useSettingsStore } from '@/stores/settings.store';
import { useUsersStore } from '@/stores/users.store'; import { useUsersStore } from '@/stores/users.store';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { OPEN_AI_API_CREDENTIAL_TYPE } from 'n8n-workflow'; import { OPEN_AI_API_CREDENTIAL_TYPE } from 'n8n-workflow';
import { N8nCallout, N8nText } from '@n8n/design-system';
const LANGCHAIN_NODES_PREFIX = '@n8n/n8n-nodes-langchain.'; const LANGCHAIN_NODES_PREFIX = '@n8n/n8n-nodes-langchain.';
@@ -85,11 +86,11 @@ const onClaimCreditsClicked = async () => {
}; };
</script> </script>
<template> <template>
<div class="mt-xs"> <N8nCallout
<n8n-callout
v-if="userCanClaimOpenAiCredits && !showSuccessCallout" v-if="userCanClaimOpenAiCredits && !showSuccessCallout"
theme="secondary" theme="secondary"
icon="circle-alert" icon="circle-alert"
class="mt-xs"
> >
{{ {{
i18n.baseText('freeAi.credits.callout.claim.title', { i18n.baseText('freeAi.credits.callout.claim.title', {
@@ -105,18 +106,19 @@ const onClaimCreditsClicked = async () => {
@click="onClaimCreditsClicked" @click="onClaimCreditsClicked"
/> />
</template> </template>
</n8n-callout> </N8nCallout>
<n8n-callout v-else-if="showSuccessCallout" theme="success" icon="circle-check"> <N8nCallout v-else-if="showSuccessCallout" theme="success" icon="circle-check" class="mt-xs">
<n8n-text size="small"> <N8nText size="small">
{{ {{
i18n.baseText('freeAi.credits.callout.success.title.part1', { i18n.baseText('freeAi.credits.callout.success.title.part1', {
interpolate: { credits: settingsStore.aiCreditsQuota }, interpolate: { credits: settingsStore.aiCreditsQuota },
}) })
}}</n8n-text }}
>&nbsp; </N8nText>
<n8n-text size="small" :bold="true"> &nbsp;
{{ i18n.baseText('freeAi.credits.callout.success.title.part2') }}</n8n-text <N8nText size="small" :bold="true">
> {{ i18n.baseText('freeAi.credits.callout.success.title.part2') }}
</n8n-callout> </N8nText>
</div> </N8nCallout>
<div v-else />
</template> </template>

View File

@@ -55,6 +55,8 @@ import { shouldShowParameter } from './canvas/experimental/experimentalNdv.utils
import { useResizeObserver } from '@vueuse/core'; import { useResizeObserver } from '@vueuse/core';
import { useNodeSettingsParameters } from '@/composables/useNodeSettingsParameters'; import { useNodeSettingsParameters } from '@/composables/useNodeSettingsParameters';
import { I18nT } from 'vue-i18n'; import { I18nT } from 'vue-i18n';
import { N8nBlockUi, N8nIcon, N8nLink, N8nNotice, N8nText } from '@n8n/design-system';
import ExperimentalEmbeddedNdvHeader from '@/components/canvas/experimental/components/ExperimentalEmbeddedNdvHeader.vue';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -69,6 +71,7 @@ const props = withDefaults(
activeNode?: INodeUi; activeNode?: INodeUi;
isEmbeddedInCanvas?: boolean; isEmbeddedInCanvas?: boolean;
noWheel?: boolean; noWheel?: boolean;
subTitle?: string;
}>(), }>(),
{ {
foreignCredentials: () => [], foreignCredentials: () => [],
@@ -79,6 +82,7 @@ const props = withDefaults(
activeNode: undefined, activeNode: undefined,
isEmbeddedInCanvas: false, isEmbeddedInCanvas: false,
noWheel: false, noWheel: false,
subTitle: undefined,
}, },
); );
@@ -827,7 +831,22 @@ function handleWheelEvent(event: WheelEvent) {
}" }"
@keydown.stop @keydown.stop
> >
<div v-if="!isNDVV2" :class="$style.header"> <ExperimentalEmbeddedNdvHeader
v-if="isEmbeddedInCanvas && node"
:node="node"
:selected-tab="openPanel"
:read-only="readOnly"
:node-type="nodeType"
:push-ref="pushRef"
:sub-title="subTitle"
@name-changed="nameChanged"
@tab-changed="onTabSelect"
>
<template #actions>
<slot name="actions" />
</template>
</ExperimentalEmbeddedNdvHeader>
<div v-else-if="!isNDVV2" :class="$style.header">
<div class="header-side-menu"> <div class="header-side-menu">
<NodeTitle <NodeTitle
v-if="node" v-if="node"
@@ -837,7 +856,6 @@ function handleWheelEvent(event: WheelEvent) {
:read-only="isReadOnly" :read-only="isReadOnly"
@update:model-value="nameChanged" @update:model-value="nameChanged"
/> />
<template v-if="isExecutable || slots.actions">
<NodeExecuteButton <NodeExecuteButton
v-if="isExecutable && !blockUI && node && nodeValid" v-if="isExecutable && !blockUI && node && nodeValid"
data-test-id="node-execute-button" data-test-id="node-execute-button"
@@ -850,8 +868,6 @@ function handleWheelEvent(event: WheelEvent) {
@stop-execution="onStopExecution" @stop-execution="onStopExecution"
@value-changed="valueChanged" @value-changed="valueChanged"
/> />
<slot name="actions" />
</template>
</div> </div>
<NodeSettingsTabs <NodeSettingsTabs
v-if="node && nodeValid" v-if="node && nodeValid"
@@ -878,12 +894,12 @@ function handleWheelEvent(event: WheelEvent) {
/> />
<div v-if="node && !nodeValid" class="node-is-not-valid"> <div v-if="node && !nodeValid" class="node-is-not-valid">
<p :class="$style.warningIcon"> <p :class="$style.warningIcon">
<n8n-icon icon="triangle-alert" /> <N8nIcon icon="triangle-alert" />
</p> </p>
<div class="missingNodeTitleContainer mt-s mb-xs"> <div class="missingNodeTitleContainer mt-s mb-xs">
<n8n-text size="large" color="text-dark" bold> <N8nText size="large" color="text-dark" bold>
{{ i18n.baseText('nodeSettings.communityNodeUnknown.title') }} {{ i18n.baseText('nodeSettings.communityNodeUnknown.title') }}
</n8n-text> </N8nText>
</div> </div>
<div v-if="isCommunityNode" :class="$style.descriptionContainer"> <div v-if="isCommunityNode" :class="$style.descriptionContainer">
<div class="mb-l"> <div class="mb-l">
@@ -902,12 +918,12 @@ function handleWheelEvent(event: WheelEvent) {
</template> </template>
</I18nT> </I18nT>
</div> </div>
<n8n-link <N8nLink
:to="COMMUNITY_NODES_INSTALLATION_DOCS_URL" :to="COMMUNITY_NODES_INSTALLATION_DOCS_URL"
@click="onMissingNodeLearnMoreLinkClick" @click="onMissingNodeLearnMoreLinkClick"
> >
{{ i18n.baseText('nodeSettings.communityNodeUnknown.installLink.text') }} {{ i18n.baseText('nodeSettings.communityNodeUnknown.installLink.text') }}
</n8n-link> </N8nLink>
</div> </div>
<I18nT v-else keypath="nodeSettings.nodeTypeUnknown.description" tag="span" scope="global"> <I18nT v-else keypath="nodeSettings.nodeTypeUnknown.description" tag="span" scope="global">
<template #action> <template #action>
@@ -931,7 +947,7 @@ function handleWheelEvent(event: WheelEvent) {
data-test-id="node-parameters" data-test-id="node-parameters"
@wheel="noWheel ? handleWheelEvent : undefined" @wheel="noWheel ? handleWheelEvent : undefined"
> >
<n8n-notice <N8nNotice
v-if="hasForeignCredential && !isHomeProjectTeam" v-if="hasForeignCredential && !isHomeProjectTeam"
:content=" :content="
i18n.baseText('nodeSettings.hasForeignCredential', { i18n.baseText('nodeSettings.hasForeignCredential', {
@@ -968,9 +984,9 @@ function handleWheelEvent(event: WheelEvent) {
/> />
</ParameterInputList> </ParameterInputList>
<div v-if="showNoParametersNotice" class="no-parameters"> <div v-if="showNoParametersNotice" class="no-parameters">
<n8n-text> <N8nText>
{{ i18n.baseText('nodeSettings.thisNodeDoesNotHaveAnyParameters') }} {{ i18n.baseText('nodeSettings.thisNodeDoesNotHaveAnyParameters') }}
</n8n-text> </N8nText>
</div> </div>
<div <div
@@ -978,7 +994,7 @@ function handleWheelEvent(event: WheelEvent) {
class="parameter-item parameter-notice" class="parameter-item parameter-notice"
data-test-id="node-parameters-http-notice" data-test-id="node-parameters-http-notice"
> >
<n8n-notice <N8nNotice
:content=" :content="
i18n.baseText('nodeSettings.useTheHttpRequestNode', { i18n.baseText('nodeSettings.useTheHttpRequestNode', {
interpolate: { nodeTypeDisplayName: nodeType?.displayName ?? '' }, interpolate: { nodeTypeDisplayName: nodeType?.displayName ?? '' },
@@ -1038,7 +1054,7 @@ function handleWheelEvent(event: WheelEvent) {
@switch-selected-node="onSwitchSelectedNode" @switch-selected-node="onSwitchSelectedNode"
@open-connection-node-creator="onOpenConnectionNodeCreator" @open-connection-node-creator="onOpenConnectionNodeCreator"
/> />
<n8n-block-ui :show="blockUI" /> <N8nBlockUi :show="blockUI" />
<CommunityNodeFooter <CommunityNodeFooter
v-if="openPanel === 'settings' && isCommunityNode" v-if="openPanel === 'settings' && isCommunityNode"
:package-name="packageName" :package-name="packageName"
@@ -1100,13 +1116,10 @@ function handleWheelEvent(event: WheelEvent) {
.node-name { .node-name {
padding-top: var(--spacing-5xs); padding-top: var(--spacing-5xs);
margin-right: var(--spacing-s);
} }
} }
&.embedded .header-side-menu {
padding: var(--spacing-xs);
}
.node-is-not-valid { .node-is-not-valid {
height: 75%; height: 75%;
padding: 10px; padding: 10px;

View File

@@ -34,6 +34,7 @@ const emit = defineEmits<{
:node-type="nodeType" :node-type="nodeType"
:push-ref="pushRef" :push-ref="pushRef"
:class="$style.tabs" :class="$style.tabs"
tabs-variant="modern"
@update:model-value="emit('tab-changed', $event)" @update:model-value="emit('tab-changed', $event)"
/> />
<NodeExecuteButton <NodeExecuteButton
@@ -54,10 +55,7 @@ const emit = defineEmits<{
<style lang="scss" module> <style lang="scss" module>
.header { .header {
--spacing-bottom-tab: calc(var(--spacing-xs));
--font-size-tab: var(--font-size-2xs);
--color-tabs-arrow-buttons: var(--color-background-xlight); --color-tabs-arrow-buttons: var(--color-background-xlight);
--font-weight-tab: var(--font-weight-bold);
display: flex; display: flex;
align-items: center; align-items: center;
@@ -68,7 +66,6 @@ const emit = defineEmits<{
} }
.tabs { .tabs {
padding-top: calc(var(--spacing-xs) + 1px); align-self: flex-end;
height: 100%;
} }
</style> </style>

View File

@@ -22,12 +22,14 @@ type Props = {
nodeType?: INodeTypeDescription | null; nodeType?: INodeTypeDescription | null;
pushRef?: string; pushRef?: string;
hideDocs?: boolean; hideDocs?: boolean;
tabsVariant?: 'modern' | 'legacy';
}; };
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
modelValue: 'params', modelValue: 'params',
nodeType: undefined, nodeType: undefined,
pushRef: '', pushRef: '',
tabsVariant: undefined,
}); });
const emit = defineEmits<{ const emit = defineEmits<{
'update:model-value': [tab: Tab]; 'update:model-value': [tab: Tab];
@@ -147,6 +149,7 @@ onMounted(async () => {
<N8nTabs <N8nTabs
:options="options" :options="options"
:model-value="modelValue" :model-value="modelValue"
:variant="tabsVariant"
@update:model-value="onTabSelect" @update:model-value="onTabSelect"
@tooltip-click="onTooltipClick" @tooltip-click="onTooltipClick"
/> />

View File

@@ -179,9 +179,12 @@ const experimentalNdvStore = useExperimentalNdvStore();
const isPaneReady = ref(false); const isPaneReady = ref(false);
const isExperimentalNdvActive = computed(() => experimentalNdvStore.isActive(viewport.value.zoom));
const classes = computed(() => ({ const classes = computed(() => ({
[$style.canvas]: true, [$style.canvas]: true,
[$style.ready]: !props.loading && isPaneReady.value, [$style.ready]: !props.loading && isPaneReady.value,
[$style.isExperimentalNdvActive]: isExperimentalNdvActive.value,
})); }));
/** /**
@@ -844,6 +847,7 @@ provide(CanvasKey, {
isExecuting, isExecuting,
initialized, initialized,
viewport, viewport,
isExperimentalNdvActive,
}); });
</script> </script>
@@ -892,6 +896,7 @@ provide(CanvasKey, {
:event-bus="eventBus" :event-bus="eventBus"
:hovered="nodesHoveredById[nodeProps.id]" :hovered="nodesHoveredById[nodeProps.id]"
:nearby-hovered="nodeProps.id === hoveredTriggerNode.id.value" :nearby-hovered="nodeProps.id === hoveredTriggerNode.id.value"
:is-experimental-ndv-active="isExperimentalNdvActive"
@delete="onDeleteNode" @delete="onDeleteNode"
@run="onRunNode" @run="onRunNode"
@select="onSelectNode" @select="onSelectNode"
@@ -957,6 +962,7 @@ provide(CanvasKey, {
:show-interactive="false" :show-interactive="false"
:zoom="viewport.zoom" :zoom="viewport.zoom"
:read-only="readOnly" :read-only="readOnly"
:is-experimental-ndv-active="isExperimentalNdvActive"
@zoom-to-fit="onFitView" @zoom-to-fit="onFitView"
@zoom-in="onZoomIn" @zoom-in="onZoomIn"
@zoom-out="onZoomOut" @zoom-out="onZoomOut"
@@ -992,6 +998,10 @@ provide(CanvasKey, {
cursor: grabbing; cursor: grabbing;
} }
} }
&.isExperimentalNdvActive {
--canvas-zoom-compensation-factor: 0.67;
}
} }
</style> </style>

View File

@@ -10,6 +10,7 @@ const props = withDefaults(
defineProps<{ defineProps<{
zoom?: number; zoom?: number;
readOnly?: boolean; readOnly?: boolean;
isExperimentalNdvActive: boolean;
}>(), }>(),
{ {
zoom: 1, zoom: 1,
@@ -113,7 +114,7 @@ function onTidyUp() {
</N8nButton> </N8nButton>
</KeyboardShortcutTooltip> </KeyboardShortcutTooltip>
<N8nTooltip <N8nTooltip
v-if="experimentalNdvStore.isActive(props.zoom)" v-if="isExperimentalNdvActive"
placement="top" placement="top"
:content="i18n.baseText('nodeView.expandAllNodes')" :content="i18n.baseText('nodeView.expandAllNodes')"
> >
@@ -125,7 +126,7 @@ function onTidyUp() {
/> />
</N8nTooltip> </N8nTooltip>
<N8nTooltip <N8nTooltip
v-if="experimentalNdvStore.isActive(props.zoom)" v-if="isExperimentalNdvActive"
placement="top" placement="top"
:content="i18n.baseText('nodeView.collapseAllNodes')" :content="i18n.baseText('nodeView.collapseAllNodes')"
> >

View File

@@ -185,9 +185,9 @@ describe('CanvasEdge', () => {
}, },
}); });
const label = container.querySelector('.vue-flow__edge-label')?.childNodes[0]; const labelWrapper = container.querySelector('.vue-flow__edge-label');
expect(label).toHaveAttribute('style', 'transform: translate(0, -100%);'); expect(labelWrapper).toHaveClass('straight');
}); });
it("should render a label in the middle of the connector when it isn't straight", () => { it("should render a label in the middle of the connector when it isn't straight", () => {
@@ -199,8 +199,8 @@ describe('CanvasEdge', () => {
}, },
}); });
const label = container.querySelector('.vue-flow__edge-label')?.childNodes[0]; const labelWrapper = container.querySelector('.vue-flow__edge-label');
expect(label).toHaveAttribute('style', 'transform: translate(0, 0%);'); expect(labelWrapper).not.toHaveClass('straight');
}); });
}); });

View File

@@ -75,7 +75,6 @@ const edgeColor = computed(() => {
const edgeStyle = computed(() => ({ const edgeStyle = computed(() => ({
...props.style, ...props.style,
...(isMainConnection.value ? {} : { strokeDasharray: '8,8' }), ...(isMainConnection.value ? {} : { strokeDasharray: '8,8' }),
strokeWidth: 2,
stroke: delayedHovered.value ? 'var(--color-primary)' : edgeColor.value, stroke: delayedHovered.value ? 'var(--color-primary)' : edgeColor.value,
})); }));
@@ -85,13 +84,6 @@ const edgeClasses = computed(() => ({
'bring-to-front': props.bringToFront, 'bring-to-front': props.bringToFront,
})); }));
const edgeLabelStyle = computed(() => ({
transform: `translate(0, ${isConnectorStraight.value ? '-100%' : '0%'})`,
color: 'var(--color-text-base)',
}));
const isConnectorStraight = computed(() => renderData.value.isConnectorStraight);
const edgeToolbarStyle = computed(() => ({ const edgeToolbarStyle = computed(() => ({
transform: `translate(-50%, -50%) translate(${labelPosition.value[0]}px, ${labelPosition.value[1]}px)`, transform: `translate(-50%, -50%) translate(${labelPosition.value[0]}px, ${labelPosition.value[1]}px)`,
...(delayedHovered.value ? { zIndex: 1 } : {}), ...(delayedHovered.value ? { zIndex: 1 } : {}),
@@ -101,6 +93,7 @@ const edgeToolbarClasses = computed(() => ({
[$style.edgeLabelWrapper]: true, [$style.edgeLabelWrapper]: true,
'vue-flow__edge-label': true, 'vue-flow__edge-label': true,
selected: props.selected, selected: props.selected,
[$style.straight]: renderData.value.isConnectorStraight,
})); }));
const renderData = computed(() => const renderData = computed(() =>
@@ -172,7 +165,7 @@ function onEdgeLabelMouseLeave() {
@add="onAdd" @add="onAdd"
@delete="onDelete" @delete="onDelete"
/> />
<div v-else :style="edgeLabelStyle" :class="$style.edgeLabel">{{ label }}</div> <div v-else :class="$style.edgeLabel">{{ label }}</div>
</div> </div>
</EdgeLabelRenderer> </EdgeLabelRenderer>
</template> </template>
@@ -182,14 +175,24 @@ function onEdgeLabelMouseLeave() {
transition: transition:
stroke 0.3s ease, stroke 0.3s ease,
fill 0.3s ease; fill 0.3s ease;
stroke-width: calc(2 * var(--canvas-zoom-compensation-factor, 1));
stroke-linecap: square;
} }
.edgeLabelWrapper { .edgeLabelWrapper {
transform: translateY(calc(var(--spacing-xs) * -1)); transform: translateY(calc(var(--spacing-xs) * -1));
position: absolute; position: absolute;
--label-translate-y: 0;
&.straight {
--label-translate-y: -100%;
}
} }
.edgeLabel { .edgeLabel {
transform: scale(var(--canvas-zoom-compensation-factor, 1)) translate(0, var(--label-translate-y));
color: var(--color-text-base);
font-size: var(--font-size-xs); font-size: var(--font-size-xs);
background-color: hsla( background-color: hsla(
var(--color-canvas-background-h), var(--color-canvas-background-h),

View File

@@ -66,6 +66,7 @@ function onDelete() {
gap: var(--spacing-2xs); gap: var(--spacing-2xs);
pointer-events: all; pointer-events: all;
padding: var(--spacing-2xs); padding: var(--spacing-2xs);
transform: scale(var(--canvas-zoom-compensation-factor, 1));
} }
</style> </style>

View File

@@ -166,8 +166,8 @@ provide(CanvasNodeHandleKey, {
<style lang="scss" module> <style lang="scss" module>
.handle { .handle {
--handle--indicator--width: 16px; --handle--indicator--width: calc(16px * var(--canvas-zoom-compensation-factor, 1));
--handle--indicator--height: 16px; --handle--indicator--height: calc(16px * var(--canvas-zoom-compensation-factor, 1));
width: var(--handle--indicator--width); width: var(--handle--indicator--width);
height: var(--handle--indicator--height); height: var(--handle--indicator--height);
@@ -181,7 +181,7 @@ provide(CanvasNodeHandleKey, {
&.inputs { &.inputs {
&.main { &.main {
--handle--indicator--width: 8px; --handle--indicator--width: calc(8px * var(--canvas-zoom-compensation-factor, 1));
} }
} }
} }

View File

@@ -33,7 +33,8 @@ const handleClasses = 'target';
position: absolute; position: absolute;
top: 50%; top: 50%;
left: calc(var(--spacing-xs) * -1); left: calc(var(--spacing-xs) * -1);
transform: translate(-100%, -50%); transform: translate(0, -50%) scale(var(--canvas-zoom-compensation-factor, 1)) translate(-100%, 0);
transform-origin: center left;
font-size: var(--font-size-2xs); font-size: var(--font-size-2xs);
color: var(--color-foreground-xdark); color: var(--color-foreground-xdark);
background: var(--color-canvas-label-background); background: var(--color-canvas-label-background);

View File

@@ -125,7 +125,8 @@ function onClickAdd() {
.outputLabel { .outputLabel {
top: 50%; top: 50%;
left: var(--spacing-m); left: var(--spacing-m);
transform: translate(0, -50%); transform: translate(0, -50%) scale(var(--canvas-zoom-compensation-factor, 1));
transform-origin: center left;
font-size: var(--font-size-2xs); font-size: var(--font-size-2xs);
color: var(--color-foreground-xdark); color: var(--color-foreground-xdark);
} }
@@ -133,8 +134,9 @@ function onClickAdd() {
.runDataLabel { .runDataLabel {
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 50%; left: calc(50% * var(--canvas-zoom-compensation-factor, 1));
transform: translate(-50%, -150%); transform: translate(-50%, -50%) scale(var(--canvas-zoom-compensation-factor, 1))
translate(0, -100%);
font-size: var(--font-size-xs); font-size: var(--font-size-xs);
color: var(--color-text-base); color: var(--color-text-base);
} }

View File

@@ -72,7 +72,7 @@ function onClickAdd() {
position: absolute; position: absolute;
top: 20px; top: 20px;
left: 50%; left: 50%;
transform: translate(-50%, 0); transform: translate(-50%, 0) scale(var(--canvas-zoom-compensation-factor, 1));
font-size: var(--font-size-2xs); font-size: var(--font-size-2xs);
color: var(--node-type-supplemental-color); color: var(--node-type-supplemental-color);
background: var(--color-canvas-label-background); background: var(--color-canvas-label-background);

View File

@@ -32,7 +32,7 @@ const classes = computed(() => ({
position: absolute; position: absolute;
top: -20px; top: -20px;
left: 50%; left: 50%;
transform: translate(-50%, 0); transform: translate(-50%, 0) scale(var(--canvas-zoom-compensation-factor, 1));
font-size: var(--font-size-2xs); font-size: var(--font-size-2xs);
color: var(--node-type-supplemental-color); color: var(--node-type-supplemental-color);
background: var(--color-canvas-label-background); background: var(--color-canvas-label-background);

View File

@@ -143,6 +143,15 @@ function onClick(event: MouseEvent) {
<style lang="scss" module> <style lang="scss" module>
.wrapper { .wrapper {
position: relative; position: relative;
transform: scale(var(--canvas-zoom-compensation-factor, 1));
&.right {
transform-origin: center left;
}
&.bottom {
transform-origin: top center;
}
&.secondary { &.secondary {
.line { .line {

View File

@@ -35,7 +35,6 @@ import { createEventBus } from '@n8n/utils/event-bus';
import isEqual from 'lodash/isEqual'; import isEqual from 'lodash/isEqual';
import CanvasNodeTrigger from '@/components/canvas/elements/nodes/render-types/parts/CanvasNodeTrigger.vue'; import CanvasNodeTrigger from '@/components/canvas/elements/nodes/render-types/parts/CanvasNodeTrigger.vue';
import { CONFIGURATION_NODE_OFFSET, GRID_SIZE } from '@/utils/nodeViewUtils'; import { CONFIGURATION_NODE_OFFSET, GRID_SIZE } from '@/utils/nodeViewUtils';
import { useExperimentalNdvStore } from '../../experimental/experimentalNdv.store';
type Props = NodeProps<CanvasNodeData> & { type Props = NodeProps<CanvasNodeData> & {
readOnly?: boolean; readOnly?: boolean;
@@ -73,9 +72,7 @@ const props = defineProps<Props>();
const contextMenu = useContextMenu(); const contextMenu = useContextMenu();
const { connectingHandle, viewport } = useCanvas(); const { connectingHandle, isExperimentalNdvActive } = useCanvas();
const experimentalNdvStore = useExperimentalNdvStore();
/* /*
Toolbar slot classes Toolbar slot classes
@@ -99,10 +96,6 @@ const {
const isDisabled = computed(() => props.data.disabled); const isDisabled = computed(() => props.data.disabled);
const isExperimentalEmbeddedNdvShown = computed(() =>
experimentalNdvStore.isActive(viewport.value.zoom),
);
const classes = computed(() => ({ const classes = computed(() => ({
[style.canvasNode]: true, [style.canvasNode]: true,
[style.showToolbar]: showToolbar.value, [style.showToolbar]: showToolbar.value,
@@ -194,8 +187,8 @@ const createEndpointMappingFn =
const offsetValue = const offsetValue =
position === Position.Bottom position === Position.Bottom
? `${GRID_SIZE * 2 * (1 + index * 2) + CONFIGURATION_NODE_OFFSET}px` ? `${GRID_SIZE * 2 * (1 + index * 2) + CONFIGURATION_NODE_OFFSET}px`
: isExperimentalEmbeddedNdvShown.value && endpoints.length === 1 : isExperimentalNdvActive.value && endpoints.length === 1
? `${(1 + index) * (GRID_SIZE * 2)}px` ? `${(1 + index) * (GRID_SIZE * 1.5)}px`
: `${(100 / (endpoints.length + 1)) * (index + 1)}%`; : `${(100 / (endpoints.length + 1)) * (index + 1)}%`;
return { return {
@@ -421,6 +414,7 @@ onBeforeUnmount(() => {
:disabled="isDisabled" :disabled="isDisabled"
:read-only="readOnly" :read-only="readOnly"
:class="$style.trigger" :class="$style.trigger"
:is-experimental-ndv-active="isExperimentalNdvActive"
/> />
</div> </div>
</template> </template>
@@ -441,7 +435,7 @@ onBeforeUnmount(() => {
position: absolute; position: absolute;
top: 0; top: 0;
left: 50%; left: 50%;
transform: translate(-50%, -100%); transform: translate(-50%, -100%) scale(var(--canvas-zoom-compensation-factor, 1));
opacity: 0; opacity: 0;
z-index: 1; z-index: 1;

View File

@@ -23,7 +23,7 @@ const props = defineProps<{
const $style = useCssModule(); const $style = useCssModule();
const i18n = useI18n(); const i18n = useI18n();
const { isExecuting } = useCanvas(); const { isExecuting, isExperimentalNdvActive } = useCanvas();
const { isDisabled, render, name } = useCanvasNode(); const { isDisabled, render, name } = useCanvasNode();
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
@@ -44,6 +44,7 @@ const classes = computed(() => ({
[$style.canvasNodeToolbar]: true, [$style.canvasNodeToolbar]: true,
[$style.readOnly]: props.readOnly, [$style.readOnly]: props.readOnly,
[$style.forceVisible]: isHovered.value || isStickyColorSelectorOpen.value, [$style.forceVisible]: isHovered.value || isStickyColorSelectorOpen.value,
[$style.isExperimentalNdvActive]: isExperimentalNdvActive.value,
})); }));
const isExecuteNodeVisible = computed(() => { const isExecuteNodeVisible = computed(() => {
@@ -185,6 +186,10 @@ function onFocusNode() {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
width: 100%; width: 100%;
&.isExperimentalNdvActive {
justify-content: center;
}
} }
.canvasNodeToolbarItems { .canvasNodeToolbarItems {

View File

@@ -7,7 +7,6 @@ import type { CanvasNodeDefaultRender } from '@/types';
import { useCanvas } from '@/composables/useCanvas'; import { useCanvas } from '@/composables/useCanvas';
import { calculateNodeSize } from '@/utils/nodeViewUtils'; import { calculateNodeSize } from '@/utils/nodeViewUtils';
import ExperimentalInPlaceNodeSettings from '@/components/canvas/experimental/components/ExperimentalEmbeddedNodeDetails.vue'; import ExperimentalInPlaceNodeSettings from '@/components/canvas/experimental/components/ExperimentalEmbeddedNodeDetails.vue';
import { useExperimentalNdvStore } from '@/components/canvas/experimental/experimentalNdv.store';
const $style = useCssModule(); const $style = useCssModule();
const i18n = useI18n(); const i18n = useI18n();
@@ -17,7 +16,7 @@ const emit = defineEmits<{
activate: [id: string, event: MouseEvent]; activate: [id: string, event: MouseEvent];
}>(); }>();
const { initialized, viewport } = useCanvas(); const { initialized, viewport, isExperimentalNdvActive } = useCanvas();
const { const {
id, id,
label, label,
@@ -46,8 +45,6 @@ const { mainOutputs, mainOutputConnections, mainInputs, mainInputConnections, no
const renderOptions = computed(() => render.value.options as CanvasNodeDefaultRender['options']); const renderOptions = computed(() => render.value.options as CanvasNodeDefaultRender['options']);
const experimentalNdvStore = useExperimentalNdvStore();
const classes = computed(() => { const classes = computed(() => {
return { return {
[$style.node]: true, [$style.node]: true,
@@ -133,7 +130,7 @@ function onActivate(event: MouseEvent) {
<template> <template>
<ExperimentalInPlaceNodeSettings <ExperimentalInPlaceNodeSettings
v-if="experimentalNdvStore.isActive(viewport.zoom)" v-if="isExperimentalNdvActive"
:node-id="id" :node-id="id"
:class="classes" :class="classes"
:style="styles" :style="styles"
@@ -260,7 +257,8 @@ function onActivate(event: MouseEvent) {
*/ */
&.selected { &.selected {
box-shadow: 0 0 0 8px var(--color-canvas-selected-transparent); box-shadow: 0 0 0 calc(8px * var(--canvas-zoom-compensation-factor, 1))
var(--color-canvas-selected-transparent);
} }
&.success { &.success {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, useCssModule } from 'vue';
import TitledList from '@/components/TitledList.vue'; import TitledList from '@/components/TitledList.vue';
import { useNodeHelpers } from '@/composables/useNodeHelpers'; import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useCanvasNode } from '@/composables/useCanvasNode'; import { useCanvasNode } from '@/composables/useCanvasNode';
@@ -8,8 +8,14 @@ import { CanvasNodeDirtiness, CanvasNodeRenderType } from '@/types';
import { N8nTooltip } from '@n8n/design-system'; import { N8nTooltip } from '@n8n/design-system';
import { useCanvas } from '@/composables/useCanvas'; import { useCanvas } from '@/composables/useCanvas';
const { size = 'medium', spinnerScrim = false } = defineProps<{
size?: 'small' | 'medium';
spinnerScrim?: boolean;
}>();
const nodeHelpers = useNodeHelpers(); const nodeHelpers = useNodeHelpers();
const i18n = useI18n(); const i18n = useI18n();
const $style = useCssModule();
const { const {
hasPinnedData, hasPinnedData,
@@ -38,31 +44,35 @@ const isNodeExecuting = computed(() => {
executionRunning.value || executionWaitingForNext.value || executionStatus.value === 'running' // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing executionRunning.value || executionWaitingForNext.value || executionStatus.value === 'running' // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing
); );
}); });
const commonClasses = computed(() => [$style.status, spinnerScrim ? $style.spinnerScrim : '']);
</script> </script>
<template> <template>
<div v-if="isDisabled" :class="[...commonClasses, $style.disabled]">
<N8nIcon icon="power" :size="size" />
</div>
<div <div
v-if="hasIssues && !hideNodeIssues" v-else-if="hasIssues && !hideNodeIssues"
:class="[$style.status, $style.issues]" :class="[...commonClasses, $style.issues]"
data-test-id="node-issues" data-test-id="node-issues"
> >
<N8nTooltip :show-after="500" placement="bottom"> <N8nTooltip :show-after="500" placement="bottom">
<template #content> <template #content>
<TitledList :title="`${i18n.baseText('node.issues')}:`" :items="issues" /> <TitledList :title="`${i18n.baseText('node.issues')}:`" :items="issues" />
</template> </template>
<N8nIcon icon="triangle-alert" /> <N8nIcon icon="triangle-alert" :size="size" />
</N8nTooltip> </N8nTooltip>
</div> </div>
<div v-else-if="executionWaiting || executionStatus === 'waiting'"> <div v-else-if="executionWaiting || executionStatus === 'waiting'">
<div :class="[$style.status, $style.waiting]"> <div :class="[...commonClasses, $style.waiting]">
<N8nTooltip placement="bottom"> <N8nTooltip placement="bottom">
<template #content> <template #content>
<div v-text="executionWaiting"></div> <div v-text="executionWaiting"></div>
</template> </template>
<N8nIcon icon="clock" /> <N8nIcon icon="clock" :size="size" />
</N8nTooltip> </N8nTooltip>
</div> </div>
<div :class="[$style.status, $style['node-waiting-spinner']]"> <div :class="[...commonClasses, $style['node-waiting-spinner']]">
<N8nIcon icon="refresh-cw" spin /> <N8nIcon icon="refresh-cw" spin />
</div> </div>
</div> </div>
@@ -72,16 +82,16 @@ const isNodeExecuting = computed(() => {
<div <div
v-else-if="isNodeExecuting" v-else-if="isNodeExecuting"
data-test-id="canvas-node-status-running" data-test-id="canvas-node-status-running"
:class="[$style.status, $style.running]" :class="[...commonClasses, $style.running]"
> >
<N8nIcon icon="refresh-cw" spin /> <N8nIcon icon="refresh-cw" spin />
</div> </div>
<div <div
v-else-if="hasPinnedData && !nodeHelpers.isProductionExecutionPreview.value && !isDisabled" v-else-if="hasPinnedData && !nodeHelpers.isProductionExecutionPreview.value"
data-test-id="canvas-node-status-pinned" data-test-id="canvas-node-status-pinned"
:class="[$style.status, $style.pinnedData]" :class="[...commonClasses, $style.pinnedData]"
> >
<N8nIcon icon="pin" /> <N8nIcon icon="pin" :size="size" />
</div> </div>
<div v-else-if="dirtiness !== undefined"> <div v-else-if="dirtiness !== undefined">
<N8nTooltip :show-after="500" placement="bottom"> <N8nTooltip :show-after="500" placement="bottom">
@@ -94,8 +104,8 @@ const isNodeExecuting = computed(() => {
) )
}} }}
</template> </template>
<div data-test-id="canvas-node-status-warning" :class="[$style.status, $style.warning]"> <div data-test-id="canvas-node-status-warning" :class="[...commonClasses, $style.warning]">
<N8nIcon icon="triangle" /> <N8nIcon icon="triangle" :size="size" />
<span v-if="runDataIterations > 1" :class="$style.count"> {{ runDataIterations }}</span> <span v-if="runDataIterations > 1" :class="$style.count"> {{ runDataIterations }}</span>
</div> </div>
</N8nTooltip> </N8nTooltip>
@@ -103,9 +113,9 @@ const isNodeExecuting = computed(() => {
<div <div
v-else-if="hasRunData" v-else-if="hasRunData"
data-test-id="canvas-node-status-success" data-test-id="canvas-node-status-success"
:class="[$style.status, $style.runData]" :class="[...commonClasses, $style.runData]"
> >
<N8nIcon icon="check" /> <N8nIcon icon="check" :size="size" />
<span v-if="runDataIterations > 1" :class="$style.count"> {{ runDataIterations }}</span> <span v-if="runDataIterations > 1" :class="$style.count"> {{ runDataIterations }}</span>
</div> </div>
</template> </template>
@@ -130,26 +140,25 @@ const isNodeExecuting = computed(() => {
color: var(--color-secondary); color: var(--color-secondary);
} }
.node-waiting-spinner,
.running { .running {
width: calc(100% - 2 * var(--canvas-node--status-icons-offset));
height: calc(100% - 2 * var(--canvas-node--status-icons-offset));
display: flex;
align-items: center;
justify-content: center;
font-size: 3.75em;
color: hsla(var(--color-primary-h), var(--color-primary-s), var(--color-primary-l), 0.7);
}
.node-waiting-spinner {
display: flex;
align-items: center;
justify-content: center;
font-size: 3.75em;
color: hsla(var(--color-primary-h), var(--color-primary-s), var(--color-primary-l), 0.7);
width: 100%; width: 100%;
height: 100%; height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 3.75em;
color: hsla(var(--color-primary-h), var(--color-primary-s), var(--color-primary-l), 0.7);
position: absolute; position: absolute;
left: -34px; left: 0;
top: -34px; top: 0;
padding: var(--canvas-node--status-icons-offset);
&.spinnerScrim {
z-index: 10;
background-color: rgba(255, 255, 255, 0.82);
border-radius: var(--border-radius-large);
}
} }
.issues { .issues {
@@ -164,4 +173,8 @@ const isNodeExecuting = computed(() => {
.warning { .warning {
color: var(--color-warning); color: var(--color-warning);
} }
.disabled {
color: var(--color-foreground-xdark);
}
</style> </style>

View File

@@ -17,6 +17,7 @@ const {
disabled, disabled,
readOnly, readOnly,
class: cls, class: cls,
isExperimentalNdvActive,
} = defineProps<{ } = defineProps<{
name: string; name: string;
type: string; type: string;
@@ -24,6 +25,7 @@ const {
disabled?: boolean; disabled?: boolean;
readOnly?: boolean; readOnly?: boolean;
class?: string; class?: string;
isExperimentalNdvActive: boolean;
}>(); }>();
const style = useCssModule(); const style = useCssModule();
@@ -32,6 +34,7 @@ const containerClass = computed(() => ({
[style.container]: true, [style.container]: true,
[style.interactive]: !disabled && !readOnly, [style.interactive]: !disabled && !readOnly,
[style.hovered]: !!hovered, [style.hovered]: !!hovered,
[style.isExperimentalNdvActive]: isExperimentalNdvActive,
})); }));
const router = useRouter(); const router = useRouter();
@@ -126,6 +129,8 @@ async function handleClickExecute() {
transition: transition:
translate 0.1s ease-in, translate 0.1s ease-in,
opacity 0.1s ease-in; opacity 0.1s ease-in;
transform: scale(var(--canvas-zoom-compensation-factor, 1));
transform-origin: center right;
} }
&.interactive.hovered button { &.interactive.hovered button {
@@ -133,6 +138,10 @@ async function handleClickExecute() {
translate: 0 0; translate: 0 0;
pointer-events: all; pointer-events: all;
} }
&.isExperimentalNdvActive {
height: var(--spacing-2xl);
}
} }
.bolt { .bolt {

View File

@@ -6,10 +6,11 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
import { createEventBus } from '@n8n/utils/event-bus'; import { createEventBus } from '@n8n/utils/event-bus';
import { computed } from 'vue'; import { computed } from 'vue';
const { nodeId, noWheel, isReadOnly } = defineProps<{ const { nodeId, noWheel, isReadOnly, subTitle } = defineProps<{
nodeId: string; nodeId: string;
noWheel?: boolean; noWheel?: boolean;
isReadOnly?: boolean; isReadOnly?: boolean;
subTitle?: string;
}>(); }>();
defineSlots<{ actions?: {} }>(); defineSlots<{ actions?: {} }>();
@@ -40,6 +41,7 @@ function handleValueChanged(parameterData: IUpdateInformation) {
:input-size="0" :input-size="0"
is-embedded-in-canvas is-embedded-in-canvas
:no-wheel="noWheel" :no-wheel="noWheel"
:sub-title="subTitle"
@value-changed="handleValueChanged" @value-changed="handleValueChanged"
> >
<template #actions> <template #actions>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import CanvasNodeStatusIcons from '@/components/canvas/elements/nodes/render-types/parts/CanvasNodeStatusIcons.vue';
import { N8nIconButton } from '@n8n/design-system';
defineProps<{ isExpanded: boolean }>();
const emit = defineEmits<{ openNdv: []; toggleExpand: [] }>();
</script>
<template>
<div :class="$style.actions">
<div :class="$style.icon">
<CanvasNodeStatusIcons size="small" spinner-scrim />
</div>
<N8nIconButton
icon="maximize-2"
type="secondary"
text
size="mini"
icon-size="small"
aria-label="Open..."
@click.stop="emit('openNdv')"
/>
<N8nIconButton
:icon="isExpanded ? 'chevron-down' : 'chevron-up'"
type="secondary"
text
size="mini"
icon-size="medium"
aria-label="Toggle expand"
@click.stop="emit('toggleExpand')"
/>
</div>
</template>
<style lang="scss" module>
.actions {
display: flex;
align-items: center;
color: var(--color-text-base);
& > button {
color: var(--color-text-light);
}
}
.icon {
margin-inline: var(--spacing-2xs);
}
</style>

View File

@@ -0,0 +1,90 @@
<script setup lang="ts">
import NodeIcon from '@/components/NodeIcon.vue';
import NodeSettingsTabs, { type Tab } from '@/components/NodeSettingsTabs.vue';
import { N8nText } from '@n8n/design-system';
import type { INode, INodeTypeDescription } from 'n8n-workflow';
defineProps<{
node: INode;
readOnly: boolean;
nodeType?: INodeTypeDescription | null;
pushRef: string;
subTitle?: string;
selectedTab: Tab;
}>();
const emit = defineEmits<{
'name-changed': [value: string];
'tab-changed': [tab: Tab];
}>();
defineSlots<{ actions?: {} }>();
</script>
<template>
<div :class="[$style.component, node.disabled ? $style.disabled : '']">
<div :class="$style.title">
<NodeIcon :node-type="nodeType" :size="16" />
<div :class="$style.titleText">
<N8nInlineTextEdit
:min-width="0"
:model-value="node.name"
:read-only="readOnly"
@update:model-value="emit('name-changed', $event)"
/>
</div>
<N8nText bold size="small" color="text-light" :class="$style.subTitleText">
{{ subTitle }}
</N8nText>
<slot name="actions" />
</div>
<NodeSettingsTabs
:model-value="selectedTab"
:node-type="nodeType"
:push-ref="pushRef"
tabs-variant="modern"
@update:model-value="emit('tab-changed', $event)"
/>
</div>
</template>
<style lang="scss" module>
.component {
border-bottom: var(--border-base);
}
.title {
display: flex;
align-items: center;
padding: var(--spacing-2xs) var(--spacing-3xs) var(--spacing-2xs) var(--spacing-xs);
border-bottom: var(--border-base);
margin-bottom: var(--spacing-xs);
gap: var(--spacing-4xs);
.disabled & {
background-color: var(--color-foreground-light);
}
}
.titleText {
min-width: 0;
flex-grow: 1;
flex-shrink: 1;
font-weight: var(--font-weight-medium);
font-size: var(--font-size-s);
overflow: hidden;
/* Same amount of padding and negative margin for border to not be cut by overflow: hidden */
padding: var(--spacing-2xs);
margin: calc(-1 * var(--spacing-2xs));
}
.subTitleText {
width: 0;
flex-grow: 100;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding-top: var(--spacing-5xs);
}
</style>

View File

@@ -0,0 +1,129 @@
<script setup lang="ts">
import InputPanel from '@/components/InputPanel.vue';
import type { INodeUi } from '@/Interface';
import { useNDVStore } from '@/stores/ndv.store';
import { N8nText } from '@n8n/design-system';
import { useVueFlow } from '@vue-flow/core';
import { useActiveElement } from '@vueuse/core';
import { ElPopover } from 'element-plus';
import type { Workflow } from 'n8n-workflow';
import { onBeforeUnmount, ref, useTemplateRef, watch } from 'vue';
const { node, container } = defineProps<{
workflow: Workflow;
node: INodeUi;
container: HTMLDivElement | null;
inputNodeName?: string;
}>();
const ndvStore = useNDVStore();
const vf = useVueFlow();
const activeElement = useActiveElement();
const inputPanelRef = useTemplateRef('inputPanel');
const shouldShowInputPanel = ref(false);
const moveStartListener = vf.onMoveStart(() => {
shouldShowInputPanel.value = false;
});
const moveEndListener = vf.onMoveEnd(() => {
shouldShowInputPanel.value = getShouldShowInputPanel();
});
const viewportChangeListener = vf.onViewportChange(() => {
shouldShowInputPanel.value = false;
});
function getShouldShowInputPanel() {
const active = activeElement.value;
if (!active || !container || !container.contains(active)) {
return false;
}
// TODO: find a way to implement this without depending on test ID
return (
!!active.closest('[data-test-id=inline-expression-editor-input]') ||
!!inputPanelRef.value?.$el.contains(active)
);
}
watch([activeElement, vf.getSelectedNodes], ([active, selected]) => {
if (active && container?.contains(active)) {
shouldShowInputPanel.value = getShouldShowInputPanel();
}
if (selected.every((sel) => sel.id !== node.id)) {
shouldShowInputPanel.value = false;
}
});
onBeforeUnmount(() => {
moveStartListener.off();
moveEndListener.off();
viewportChangeListener.off();
});
</script>
<template>
<ElPopover
:visible="shouldShowInputPanel"
placement="left-start"
:show-arrow="false"
:popper-class="$style.component"
:width="360"
:offset="8"
:append-to="vf.viewportRef?.value"
>
<template #reference>
<slot />
</template>
<InputPanel
ref="inputPanel"
:tabindex="-1"
:class="$style.inputPanel"
:workflow="workflow"
:run-index="0"
compact
push-ref=""
display-mode="schema"
disable-display-mode-selection
:active-node-name="node.name"
:current-node-name="inputNodeName"
:is-mapping-onboarded="ndvStore.isMappingOnboarded"
:focused-mappable-input="ndvStore.focusedMappableInput"
>
<template #header>
<N8nText :class="$style.inputPanelTitle" :bold="true" color="text-light" size="small">
Input
</N8nText>
</template>
</InputPanel>
</ElPopover>
</template>
<style lang="scss" module>
.component {
background-color: transparent !important;
padding: 0 !important;
border: none !important;
margin-top: -2px;
}
.inputPanel {
border: var(--border-base);
border-width: 1px;
background-color: var(--color-background-light);
border-radius: var(--border-radius-large);
zoom: var(--zoom);
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.05);
padding: var(--spacing-2xs);
height: 100%;
}
.inputPanelTitle {
text-transform: uppercase;
letter-spacing: 3px;
}
</style>

View File

@@ -1,18 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import InputPanel from '@/components/InputPanel.vue';
import NodeTitle from '@/components/NodeTitle.vue';
import { ExpressionLocalResolveContextSymbol } from '@/constants'; import { ExpressionLocalResolveContextSymbol } from '@/constants';
import { useEnvironmentsStore } from '@/stores/environments.ee.store'; import { useEnvironmentsStore } from '@/stores/environments.ee.store';
import { useNDVStore } from '@/stores/ndv.store'; import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import type { ExpressionLocalResolveContext } from '@/types/expressions'; import type { ExpressionLocalResolveContext } from '@/types/expressions';
import { N8nIcon, N8nIconButton } from '@n8n/design-system'; import { N8nText } from '@n8n/design-system';
import { useVueFlow } from '@vue-flow/core'; import { useVueFlow } from '@vue-flow/core';
import { useActiveElement, watchOnce } from '@vueuse/core'; import { watchOnce } from '@vueuse/core';
import { computed, onBeforeUnmount, provide, ref, useTemplateRef, watch } from 'vue'; import { computed, onBeforeUnmount, provide, ref, useTemplateRef } from 'vue';
import { useExperimentalNdvStore } from '../experimentalNdv.store'; import { useExperimentalNdvStore } from '../experimentalNdv.store';
import ExperimentalCanvasNodeSettings from './ExperimentalCanvasNodeSettings.vue'; import ExperimentalCanvasNodeSettings from './ExperimentalCanvasNodeSettings.vue';
import { useI18n } from '@n8n/i18n';
import NodeIcon from '@/components/NodeIcon.vue';
import { getNodeSubTitleText } from '@/components/canvas/experimental/experimentalNdv.utils';
import ExperimentalEmbeddedNdvActions from '@/components/canvas/experimental/components/ExperimentalEmbeddedNdvActions.vue';
const { nodeId, isReadOnly, isConfigurable } = defineProps<{ const { nodeId, isReadOnly, isConfigurable } = defineProps<{
nodeId: string; nodeId: string;
@@ -20,6 +22,7 @@ const { nodeId, isReadOnly, isConfigurable } = defineProps<{
isConfigurable: boolean; isConfigurable: boolean;
}>(); }>();
const i18n = useI18n();
const ndvStore = useNDVStore(); const ndvStore = useNDVStore();
const experimentalNdvStore = useExperimentalNdvStore(); const experimentalNdvStore = useExperimentalNdvStore();
const isExpanded = computed(() => !experimentalNdvStore.collapsedNodes[nodeId]); const isExpanded = computed(() => !experimentalNdvStore.collapsedNodes[nodeId]);
@@ -61,12 +64,14 @@ const isVisible = computed(() =>
), ),
); );
const isOnceVisible = ref(isVisible.value); const isOnceVisible = ref(isVisible.value);
const shouldShowInputPanel = ref(false);
const containerRef = useTemplateRef('container'); const containerRef = useTemplateRef('container');
const inputPanelContainerRef = useTemplateRef('inputPanelContainer');
const activeElement = useActiveElement();
const subTitle = computed(() =>
node.value && nodeType.value
? getNodeSubTitleText(node.value, nodeType.value, !isExpanded.value, i18n)
: undefined,
);
const expressionResolveCtx = computed<ExpressionLocalResolveContext | undefined>(() => { const expressionResolveCtx = computed<ExpressionLocalResolveContext | undefined>(() => {
if (!node.value) { if (!node.value) {
return undefined; return undefined;
@@ -118,38 +123,42 @@ function handleToggleExpand() {
experimentalNdvStore.setNodeExpanded(nodeId); experimentalNdvStore.setNodeExpanded(nodeId);
} }
function handleOpenNdv() {
if (node.value) {
ndvStore.setActiveNodeName(node.value.name);
}
}
provide(ExpressionLocalResolveContextSymbol, expressionResolveCtx); provide(ExpressionLocalResolveContextSymbol, expressionResolveCtx);
watchOnce(isVisible, (visible) => { watchOnce(isVisible, (visible) => {
isOnceVisible.value = isOnceVisible.value || visible; isOnceVisible.value = isOnceVisible.value || visible;
}); });
watch([activeElement, vf.getSelectedNodes], ([active, selected]) => {
if (active && containerRef.value?.contains(active)) {
// TODO: find a way to implement this without depending on test ID
shouldShowInputPanel.value =
!!active.closest('[data-test-id=inline-expression-editor-input]') ||
!!inputPanelContainerRef.value?.contains(active);
}
if (selected.every((sel) => sel.id !== node.value?.id)) {
shouldShowInputPanel.value = false;
}
});
</script> </script>
<template> <template>
<div <div
ref="container" ref="container"
:class="[$style.component, isExpanded ? $style.expanded : $style.collapsed]" :class="[
$style.component,
isExpanded ? $style.expanded : $style.collapsed,
node?.disabled ? $style.disabled : '',
isExpanded ? 'nodrag' : '',
]"
:style="{ :style="{
'--zoom': `${1 / experimentalNdvStore.maxCanvasZoom}`, '--zoom': `${1 / experimentalNdvStore.maxCanvasZoom}`,
'--node-width-scaler': isConfigurable ? 1 : 1.5, '--node-width-scaler': isConfigurable ? 1 : 1.5,
}" }"
> >
<template v-if="isOnceVisible"> <template v-if="!node || !isOnceVisible" />
<ExperimentalEmbeddedNdvMapper
v-else-if="isExpanded"
:workflow="workflow"
:node="node"
:input-node-name="expressionResolveCtx?.inputNode?.name"
:container="containerRef"
>
<ExperimentalCanvasNodeSettings <ExperimentalCanvasNodeSettings
v-if="isExpanded"
tabindex="-1" tabindex="-1"
:node-id="nodeId" :node-id="nodeId"
:class="$style.settingsView" :class="$style.settingsView"
@@ -157,58 +166,33 @@ watch([activeElement, vf.getSelectedNodes], ([active, selected]) => {
!isMoving /* to not interrupt panning while allowing scroll of the settings pane, allow wheel event while panning */ !isMoving /* to not interrupt panning while allowing scroll of the settings pane, allow wheel event while panning */
" "
:is-read-only="isReadOnly" :is-read-only="isReadOnly"
:sub-title="subTitle"
> >
<template #actions> <template #actions>
<N8nIconButton <ExperimentalEmbeddedNdvActions
icon="minimize-2" :is-expanded="isExpanded"
type="secondary" @open-ndv="handleOpenNdv"
text @toggle-expand="handleToggleExpand"
size="mini"
icon-size="large"
aria-label="Toggle expand"
@click="handleToggleExpand"
/> />
</template> </template>
</ExperimentalCanvasNodeSettings> </ExperimentalCanvasNodeSettings>
</ExperimentalEmbeddedNdvMapper>
<div v-else role="button" :class="$style.collapsedContent" @click="handleToggleExpand"> <div v-else role="button" :class="$style.collapsedContent" @click="handleToggleExpand">
<NodeTitle <NodeIcon :node-type="nodeType" :size="18" />
v-if="node" <div :class="$style.collapsedNodeName">
class="node-name" <N8nText bold>
:model-value="node.name" {{ node.name }}
:node-type="nodeType" </N8nText>
read-only <N8nText bold size="small" color="text-light">
/> {{ subTitle }}
<N8nIcon icon="maximize-2" size="large" />
</div>
<Transition name="input">
<div
v-if="shouldShowInputPanel && node"
ref="inputPanelContainer"
:class="$style.inputPanelContainer"
:tabindex="-1"
>
<InputPanel
:class="$style.inputPanel"
:workflow="workflow"
:run-index="0"
compact
push-ref=""
display-mode="schema"
disable-display-mode-selection
:active-node-name="node.name"
:current-node-name="expressionResolveCtx?.inputNode?.name"
:is-mapping-onboarded="ndvStore.isMappingOnboarded"
:focused-mappable-input="ndvStore.focusedMappableInput"
>
<template #header>
<N8nText :class="$style.inputPanelTitle" :bold="true" color="text-light" size="small">
Input
</N8nText> </N8nText>
</template>
</InputPanel>
</div> </div>
</Transition> <ExperimentalEmbeddedNdvActions
</template> :is-expanded="isExpanded"
@open-ndv="handleOpenNdv"
@toggle-expand="handleToggleExpand"
/>
</div>
</div> </div>
</template> </template>
@@ -220,16 +204,20 @@ watch([activeElement, vf.getSelectedNodes], ([active, selected]) => {
border-width: 1px !important; border-width: 1px !important;
border-radius: var(--border-radius-base) !important; border-radius: var(--border-radius-base) !important;
width: calc(var(--canvas-node--width) * var(--node-width-scaler)); width: calc(var(--canvas-node--width) * var(--node-width-scaler));
overflow: hidden;
--canvas-node--border-color: var(--color-text-lighter);
&.expanded { &.expanded {
cursor: default; user-select: text;
cursor: auto;
height: auto; height: auto;
max-height: min(calc(var(--canvas-node--height) * 2), 300px); max-height: min(calc(var(--canvas-node--height) * 2), 300px);
min-height: var(--spacing-3xl); min-height: var(--spacing-3xl);
} }
&.collapsed { &.collapsed {
overflow: hidden; overflow: hidden;
height: var(--spacing-3xl); height: var(--spacing-2xl);
} }
} }
@@ -245,27 +233,44 @@ watch([activeElement, vf.getSelectedNodes], ([active, selected]) => {
:root .settingsView { :root .settingsView {
z-index: 1000; z-index: 1000;
width: 100%; width: 100%;
border-radius: var(--border-radius-base);
height: auto; height: auto;
max-height: calc(min(calc(var(--canvas-node--height) * 2), 300px) - var(--border-width-base) * 2); max-height: calc(min(calc(var(--canvas-node--height) * 2), 300px) - var(--border-width-base) * 2);
min-height: var(--spacing-3xl); // should be multiple of GRID_SIZE min-height: var(--spacing-2xl); // should be multiple of GRID_SIZE
} }
.collapsedContent { .collapsedContent {
height: 100%; height: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; gap: var(--spacing-4xs);
gap: var(--spacing-s);
background-color: white; background-color: white;
padding: var(--spacing-2xs); padding: var(--spacing-2xs) var(--spacing-4xs) var(--spacing-2xs) var(--spacing-2xs);
background-color: var(--color-background-xlight); background-color: var(--color-background-xlight);
color: var(--color-text-base);
cursor: pointer; cursor: pointer;
.disabled & {
background-color: var(--color-foreground-light);
}
& > * { & > * {
zoom: calc(var(--zoom) * 1.25); zoom: var(--zoom);
flex-grow: 0;
flex-shrink: 0;
}
}
.collapsedNodeName {
width: 0;
flex-grow: 1;
display: flex;
flex-direction: column;
gap: var(--spacing-5xs);
& > * {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
} }
@@ -274,43 +279,4 @@ watch([activeElement, vf.getSelectedNodes], ([active, selected]) => {
zoom: var(--zoom); zoom: var(--zoom);
} }
} }
.inputPanelContainer {
position: absolute;
right: 100%;
top: 0;
padding-right: var(--spacing-4xs);
margin-top: calc(-1 * var(--border-width-base));
width: 180px;
z-index: 2000;
max-height: 80vh;
}
.inputPanel {
border: var(--border-base);
border-width: 1px;
background-color: var(--color-background-light);
border-radius: var(--border-radius-large);
zoom: var(--zoom);
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.05);
padding: var(--spacing-2xs);
height: 100%;
}
.inputPanelTitle {
text-transform: uppercase;
letter-spacing: 3px;
}
</style>
<style lang="scss" scoped>
.input-enter-active,
.input-leave-active {
transition: opacity 0.3s ease;
}
.input-enter-from,
.input-leave-to {
opacity: 0;
}
</style> </style>

View File

@@ -1,5 +1,37 @@
import type { INodeProperties } from 'n8n-workflow'; import type { INodeUi } from '@/Interface';
import type { I18nClass } from '@n8n/i18n';
import type { INodeProperties, INodeTypeDescription } from 'n8n-workflow';
export function shouldShowParameter(item: INodeProperties): boolean { export function shouldShowParameter(item: INodeProperties): boolean {
return item.name.match(/resource|authentication|operation/i) === null; return item.name.match(/resource|authentication|operation/i) === null;
} }
export function getNodeSubTitleText(
node: INodeUi,
nodeType: INodeTypeDescription,
includeOperation: boolean,
t: I18nClass,
) {
if (node.disabled) {
return `(${t.baseText('node.disabled')})`;
}
const displayName = nodeType.displayName ?? '';
if (!includeOperation) {
return displayName;
}
const selectedOperation = node.parameters.operation;
const selectedOperationDisplayName =
selectedOperation &&
nodeType.properties
.find((p) => p.name === 'operation')
?.options?.find((o) => 'value' in o && o.value === selectedOperation)?.name;
if (!selectedOperationDisplayName) {
return displayName;
}
return `${displayName}: ${selectedOperationDisplayName}`;
}

View File

@@ -163,6 +163,7 @@ export interface CanvasInjectionData {
isExecuting: Ref<boolean | undefined>; isExecuting: Ref<boolean | undefined>;
connectingHandle: Ref<ConnectStartEvent | undefined>; connectingHandle: Ref<ConnectStartEvent | undefined>;
viewport: Ref<ViewportTransform>; viewport: Ref<ViewportTransform>;
isExperimentalNdvActive: ComputedRef<boolean>;
} }
export type CanvasNodeEventBusEvents = { export type CanvasNodeEventBusEvents = {

3
pnpm-lock.yaml generated
View File

@@ -2533,6 +2533,9 @@ importers:
'@pinia/testing': '@pinia/testing':
specifier: ^0.1.6 specifier: ^0.1.6
version: 0.1.6(pinia@2.2.4(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3)))(vue@3.5.13(typescript@5.8.3)) version: 0.1.6(pinia@2.2.4(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3)))(vue@3.5.13(typescript@5.8.3))
'@testing-library/vue':
specifier: catalog:frontend
version: 8.1.0(@vue/compiler-sfc@3.5.13)(vue@3.5.13(typescript@5.8.3))
'@types/dateformat': '@types/dateformat':
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.0.1 version: 3.0.1