chore(editor): The zoomed view misc updates (#17865)

This commit is contained in:
Suguru Inoue
2025-08-07 12:10:06 +02:00
committed by GitHub
parent 678f468f68
commit 8b73b1ce82
31 changed files with 392 additions and 233 deletions

View File

@@ -1161,6 +1161,8 @@
"ndv.input.noOutputData.hint.tooltip": "From the earliest node which is unexecuted, or is executed but has since been changed", "ndv.input.noOutputData.hint.tooltip": "From the earliest node which is unexecuted, or is executed but has since been changed",
"ndv.input.noOutputData.schemaPreviewHint": "switch to {schema} to use the schema preview", "ndv.input.noOutputData.schemaPreviewHint": "switch to {schema} to use the schema preview",
"ndv.input.noOutputData.or": "or", "ndv.input.noOutputData.or": "or",
"ndv.input.noOutputData.embeddedNdv.link": "Execute previous nodes",
"ndv.input.noOutputData.embeddedNdv.description": "{link} to use their data here",
"ndv.input.executingPrevious": "Executing previous nodes...", "ndv.input.executingPrevious": "Executing previous nodes...",
"ndv.input.notConnected.title": "Wire me up", "ndv.input.notConnected.title": "Wire me up",
"ndv.input.notConnected.v2.title": "No input connected", "ndv.input.notConnected.v2.title": "No input connected",

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue'; import { computed, inject, nextTick, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue';
import { onClickOutside } from '@vueuse/core'; import { onClickOutside } from '@vueuse/core';
import ExpressionFunctionIcon from '@/components/ExpressionFunctionIcon.vue'; import ExpressionFunctionIcon from '@/components/ExpressionFunctionIcon.vue';
@@ -16,6 +16,7 @@ import { startCompletion } from '@codemirror/autocomplete';
import type { EditorState, SelectionRange } from '@codemirror/state'; import type { EditorState, SelectionRange } from '@codemirror/state';
import type { IDataObject } from 'n8n-workflow'; import type { IDataObject } from 'n8n-workflow';
import { createEventBus, type EventBus } from '@n8n/utils/event-bus'; import { createEventBus, type EventBus } from '@n8n/utils/event-bus';
import { CanvasKey, ExpressionLocalResolveContextSymbol } from '@/constants';
const isFocused = ref(false); const isFocused = ref(false);
const segments = ref<Segment[]>([]); const segments = ref<Segment[]>([]);
@@ -23,6 +24,7 @@ const editorState = ref<EditorState>();
const selection = ref<SelectionRange>(); const selection = ref<SelectionRange>();
const inlineInput = ref<InstanceType<typeof InlineExpressionEditorInput>>(); const inlineInput = ref<InstanceType<typeof InlineExpressionEditorInput>>();
const container = ref<HTMLDivElement>(); const container = ref<HTMLDivElement>();
const outputPopover = ref<InstanceType<typeof InlineExpressionEditorOutput>>();
type Props = { type Props = {
path: string; path: string;
@@ -46,14 +48,21 @@ const emit = defineEmits<{
'modal-opener-click': []; 'modal-opener-click': [];
'update:model-value': [value: string]; 'update:model-value': [value: string];
focus: []; focus: [];
blur: []; blur: [FocusEvent | KeyboardEvent | undefined];
}>(); }>();
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const ndvStore = useNDVStore(); const ndvStore = useNDVStore();
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const canvas = inject(CanvasKey, undefined);
const expressionLocalResolveCtx = inject(ExpressionLocalResolveContextSymbol, undefined);
const isInExperimentalNdv = computed(() => expressionLocalResolveCtx?.value !== undefined);
const isDragging = computed(() => ndvStore.isDraggableDragging); const isDragging = computed(() => ndvStore.isDraggableDragging);
const isOutputPopoverVisible = computed(
() => isFocused.value && (!isInExperimentalNdv.value || !canvas?.isPaneMoving.value),
);
function select() { function select() {
if (inlineInput.value) { if (inlineInput.value) {
@@ -80,12 +89,16 @@ function onBlur(event?: FocusEvent | KeyboardEvent) {
return; // prevent blur on resizing return; // prevent blur on resizing
} }
if (event?.target instanceof Element && outputPopover.value?.contentRef?.contains(event.target)) {
return;
}
const wasFocused = isFocused.value; const wasFocused = isFocused.value;
isFocused.value = false; isFocused.value = false;
if (wasFocused) { if (wasFocused) {
emit('blur'); emit('blur', event);
const telemetryPayload = createExpressionTelemetryPayload( const telemetryPayload = createExpressionTelemetryPayload(
segments.value, segments.value,
@@ -161,7 +174,8 @@ onBeforeUnmount(() => {
}); });
watch(isDragging, (newIsDragging) => { watch(isDragging, (newIsDragging) => {
if (newIsDragging) { // The input must stay focused in experimental NDV so that the input panel popover is open while dragging
if (newIsDragging && !isInExperimentalNdv.value) {
onBlur(); onBlur();
} }
}); });
@@ -212,13 +226,17 @@ defineExpose({ focus, select });
@click="emit('modal-opener-click')" @click="emit('modal-opener-click')"
/> />
</div> </div>
<InlineExpressionEditorOutput <InlineExpressionEditorOutput
ref="outputPopover"
:visible="isOutputPopoverVisible"
:unresolved-expression="modelValue" :unresolved-expression="modelValue"
:selection="selection" :selection="selection"
:editor-state="editorState" :editor-state="editorState"
:segments="segments" :segments="segments"
:is-read-only="isReadOnly" :is-read-only="isReadOnly"
:visible="isFocused" :virtual-ref="container"
:append-to="isInExperimentalNdv ? '#canvas' : undefined"
/> />
</div> </div>
</template> </template>

View File

@@ -257,6 +257,7 @@ const trackWorkflowInputFieldAdded = () => {
/> />
<div v-if="multipleValues"> <div v-if="multipleValues">
<Draggable <Draggable
:item-key="property.name"
v-model="mutableValues[property.name]" v-model="mutableValues[property.name]"
handle=".drag-handle" handle=".drag-handle"
drag-class="dragging" drag-class="dragging"

View File

@@ -4,23 +4,26 @@ import type { EditorState, SelectionRange } from '@codemirror/state';
import { useI18n } from '@n8n/i18n'; import { useI18n } from '@n8n/i18n';
import { useNDVStore } from '@/stores/ndv.store'; import { useNDVStore } from '@/stores/ndv.store';
import type { Segment } from '@/types/expressions'; import type { Segment } from '@/types/expressions';
import { onBeforeUnmount } from 'vue'; import { computed, onBeforeUnmount, useTemplateRef } from 'vue';
import ExpressionOutput from './ExpressionOutput.vue'; import ExpressionOutput from './ExpressionOutput.vue';
import OutputItemSelect from './OutputItemSelect.vue'; import OutputItemSelect from './OutputItemSelect.vue';
import InlineExpressionTip from './InlineExpressionTip.vue'; import InlineExpressionTip from './InlineExpressionTip.vue';
import { outputTheme } from './theme'; import { outputTheme } from './theme';
import { useElementSize } from '@vueuse/core';
import { N8nPopover } from '@n8n/design-system';
interface InlineExpressionEditorOutputProps { interface InlineExpressionEditorOutputProps {
segments: Segment[]; segments: Segment[];
unresolvedExpression?: string; unresolvedExpression?: string;
editorState?: EditorState; editorState?: EditorState;
selection?: SelectionRange; selection?: SelectionRange;
visible?: boolean;
isReadOnly?: boolean; isReadOnly?: boolean;
visible: boolean;
virtualRef?: HTMLElement;
appendTo?: string;
} }
withDefaults(defineProps<InlineExpressionEditorOutputProps>(), { const props = withDefaults(defineProps<InlineExpressionEditorOutputProps>(), {
visible: false,
editorState: undefined, editorState: undefined,
selection: undefined, selection: undefined,
isReadOnly: false, isReadOnly: false,
@@ -30,45 +33,81 @@ withDefaults(defineProps<InlineExpressionEditorOutputProps>(), {
const i18n = useI18n(); const i18n = useI18n();
const theme = outputTheme(); const theme = outputTheme();
const ndvStore = useNDVStore(); const ndvStore = useNDVStore();
const contentRef = useTemplateRef('content');
const virtualRefSize = useElementSize(computed(() => props.virtualRef));
onBeforeUnmount(() => { onBeforeUnmount(() => {
ndvStore.expressionOutputItemIndex = 0; ndvStore.expressionOutputItemIndex = 0;
}); });
defineExpose({
contentRef,
});
</script> </script>
<template> <template>
<div v-if="visible" :class="$style.dropdown" title=""> <N8nPopover
<div :class="$style.header"> :visible="visible"
<n8n-text bold size="small" compact> placement="bottom"
{{ i18n.baseText('parameterInput.result') }} :show-arrow="false"
</n8n-text> :offset="0"
:persistent="false"
:virtual-triggering="virtualRef !== undefined"
:virtual-ref="virtualRef"
:width="virtualRefSize.width.value"
:popper-class="$style.popper"
:popper-options="{
modifiers: [
{ name: 'flip', enabled: false },
{
// Ensures that the popover is re-positioned when the reference element is resized
name: 'custom modifier',
options: {
width: virtualRefSize.width.value,
height: virtualRefSize.height.value,
},
},
],
}"
:append-to="appendTo"
>
<div ref="content" :class="$style.dropdown">
<div :class="$style.header">
<n8n-text bold size="small" compact>
{{ i18n.baseText('parameterInput.result') }}
</n8n-text>
<OutputItemSelect /> <OutputItemSelect />
</div>
<n8n-text :class="$style.body">
<ExpressionOutput
data-test-id="inline-expression-editor-output"
:segments="segments"
:extensions="theme"
>
</ExpressionOutput>
</n8n-text>
<div v-if="!isReadOnly" :class="$style.footer">
<InlineExpressionTip
:editor-state="editorState"
:selection="selection"
:unresolved-expression="unresolvedExpression"
/>
</div>
</div> </div>
<n8n-text :class="$style.body"> </N8nPopover>
<ExpressionOutput
data-test-id="inline-expression-editor-output"
:segments="segments"
:extensions="theme"
>
</ExpressionOutput>
</n8n-text>
<div v-if="!isReadOnly" :class="$style.footer">
<InlineExpressionTip
:editor-state="editorState"
:selection="selection"
:unresolved-expression="unresolvedExpression"
/>
</div>
</div>
</template> </template>
<style lang="scss" module> <style lang="scss" module>
.popper {
background-color: transparent !important;
padding: 0 !important;
border: none !important;
}
.dropdown { .dropdown {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: absolute;
z-index: 2; // cover tooltips
background: var(--color-code-background); background: var(--color-code-background);
border: var(--border-base); border: var(--border-base);
border-top: none; border-top: none;

View File

@@ -163,9 +163,10 @@ watchDebounced(
} }
.tipText { .tipText {
display: inline;
color: var(--color-text-dark); color: var(--color-text-dark);
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold);
white-space: nowrap;
align-self: flex-start;
} }
.drag .tipText { .drag .tipText {

View File

@@ -49,6 +49,7 @@ export type Props = {
disableDisplayModeSelection?: boolean; disableDisplayModeSelection?: boolean;
focusedMappableInput: string; focusedMappableInput: string;
isMappingOnboarded: boolean; isMappingOnboarded: boolean;
nodeNotRunMessageVariant?: 'default' | 'simple';
}; };
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@@ -57,6 +58,7 @@ const props = withDefaults(defineProps<Props>(), {
readOnly: false, readOnly: false,
isProductionExecutionPreview: false, isProductionExecutionPreview: false,
isPaneActive: false, isPaneActive: false,
nodeNotRunMessageVariant: 'default',
}); });
const emit = defineEmits<{ const emit = defineEmits<{
@@ -249,6 +251,10 @@ const isNDVV2 = computed(() =>
), ),
); );
const nodeNameToExecute = computed(
() => (isActiveNodeConfig.value ? rootNode.value : activeNode.value?.name) ?? '',
);
watch( watch(
inputMode, inputMode,
(mode) => { (mode) => {
@@ -455,7 +461,23 @@ function handleChangeCollapsingColumn(columnName: string | null) {
v-if="(isActiveNodeConfig && rootNode) || parentNodes.length" v-if="(isActiveNodeConfig && rootNode) || parentNodes.length"
:class="$style.noOutputData" :class="$style.noOutputData"
> >
<template v-if="isNDVV2"> <N8nText v-if="nodeNotRunMessageVariant === 'simple'" color="text-base" size="small">
<I18nT scope="global" keypath="ndv.input.noOutputData.embeddedNdv.description">
<template #link>
<NodeExecuteButton
:class="$style.executeButton"
size="medium"
:node-name="nodeNameToExecute"
:label="i18n.baseText('ndv.input.noOutputData.embeddedNdv.link')"
text
telemetry-source="inputs"
hide-icon
/>
</template>
</I18nT>
</N8nText>
<template v-else-if="isNDVV2">
<NDVEmptyState <NDVEmptyState
v-if="isMappingEnabled || hasRootNodeRun" v-if="isMappingEnabled || hasRootNodeRun"
:title="i18n.baseText('ndv.input.noOutputData.v2.title')" :title="i18n.baseText('ndv.input.noOutputData.v2.title')"
@@ -470,7 +492,7 @@ function handleChangeCollapsingColumn(columnName: string | null) {
hide-icon hide-icon
transparent transparent
type="secondary" type="secondary"
:node-name="(isActiveNodeConfig ? rootNode : activeNode?.name) ?? ''" :node-name="nodeNameToExecute"
:label="i18n.baseText('ndv.input.noOutputData.v2.action')" :label="i18n.baseText('ndv.input.noOutputData.v2.action')"
:tooltip="i18n.baseText('ndv.input.noOutputData.v2.tooltip')" :tooltip="i18n.baseText('ndv.input.noOutputData.v2.tooltip')"
tooltip-placement="bottom" tooltip-placement="bottom"
@@ -537,7 +559,7 @@ function handleChangeCollapsingColumn(columnName: string | null) {
type="secondary" type="secondary"
hide-icon hide-icon
:transparent="true" :transparent="true"
:node-name="(isActiveNodeConfig ? rootNode : activeNode?.name) ?? ''" :node-name="nodeNameToExecute"
:label="i18n.baseText('ndv.input.noOutputData.executePrevious')" :label="i18n.baseText('ndv.input.noOutputData.executePrevious')"
class="mt-m" class="mt-m"
telemetry-source="inputs" telemetry-source="inputs"
@@ -707,4 +729,8 @@ function handleChangeCollapsingColumn(columnName: string | null) {
letter-spacing: 2px; letter-spacing: 2px;
font-size: var(--font-size-xs); font-size: var(--font-size-xs);
} }
.executeButton {
padding: 0;
}
</style> </style>

View File

@@ -73,6 +73,8 @@ const props = withDefaults(
activeNode?: INodeUi; activeNode?: INodeUi;
isEmbeddedInCanvas?: boolean; isEmbeddedInCanvas?: boolean;
subTitle?: string; subTitle?: string;
extraTabsClassName?: string;
extraParameterWrapperClassName?: string;
}>(), }>(),
{ {
inputSize: 0, inputSize: 0,
@@ -94,6 +96,7 @@ const emit = defineEmits<{
activate: []; activate: [];
execute: []; execute: [];
captureWheelBody: [WheelEvent]; captureWheelBody: [WheelEvent];
dblclickHeader: [MouseEvent];
}>(); }>();
const slots = defineSlots<{ actions?: {} }>(); const slots = defineSlots<{ actions?: {} }>();
@@ -596,11 +599,13 @@ function handleSelectAction(params: INodeParameters) {
:node-type="nodeType" :node-type="nodeType"
:push-ref="pushRef" :push-ref="pushRef"
:sub-title="subTitle" :sub-title="subTitle"
:extra-tabs-class-name="extraTabsClassName"
:include-action="parametersByTab.action.length > 0" :include-action="parametersByTab.action.length > 0"
:include-credential="isDisplayingCredentials" :include-credential="isDisplayingCredentials"
:has-credential-issue="!areAllCredentialsSet" :has-credential-issue="!areAllCredentialsSet"
@name-changed="nameChanged" @name-changed="nameChanged"
@tab-changed="onTabSelect" @tab-changed="onTabSelect"
@dblclick-title="emit('dblclickHeader', $event)"
> >
<template #actions> <template #actions>
<slot name="actions" /> <slot name="actions" />
@@ -662,6 +667,7 @@ function handleSelectAction(params: INodeParameters) {
'node-parameters-wrapper', 'node-parameters-wrapper',
shouldShowStaticScrollbar ? 'with-static-scrollbar' : '', shouldShowStaticScrollbar ? 'with-static-scrollbar' : '',
{ 'ndv-v2': isNDVV2 }, { 'ndv-v2': isNDVV2 },
extraParameterWrapperClassName ?? '',
]" ]"
data-test-id="node-parameters" data-test-id="node-parameters"
@wheel.capture="emit('captureWheelBody', $event)" @wheel.capture="emit('captureWheelBody', $event)"

View File

@@ -27,6 +27,11 @@ function getNdvStateMock(): Partial<ReturnType<typeof useNDVStore>> {
}, },
isInputPanelEmpty: false, isInputPanelEmpty: false,
isOutputPanelEmpty: false, isOutputPanelEmpty: false,
ndvInputDataWithPinnedData: [],
getHoveringItem: undefined,
expressionOutputItemIndex: 0,
isTableHoverOnboarded: false,
setHighlightDraggables: vi.fn(),
}; };
} }
@@ -100,6 +105,11 @@ describe('ParameterInput.vue', () => {
}, },
isInputPanelEmpty: false, isInputPanelEmpty: false,
isOutputPanelEmpty: false, isOutputPanelEmpty: false,
ndvInputDataWithPinnedData: [],
getHoveringItem: undefined,
expressionOutputItemIndex: 0,
isTableHoverOnboarded: false,
setHighlightDraggables: vi.fn(),
}; };
mockNodeTypesState = { mockNodeTypesState = {
allNodeTypes: [], allNodeTypes: [],

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, onUpdated, ref, watch } from 'vue'; import { computed, inject, nextTick, onBeforeUnmount, onMounted, onUpdated, ref, watch } from 'vue';
import get from 'lodash/get'; import get from 'lodash/get';
@@ -51,6 +51,7 @@ import {
APP_MODALS_ELEMENT_ID, APP_MODALS_ELEMENT_ID,
CORE_NODES_CATEGORY, CORE_NODES_CATEGORY,
CUSTOM_API_CALL_KEY, CUSTOM_API_CALL_KEY,
ExpressionLocalResolveContextSymbol,
HTML_NODE_TYPE, HTML_NODE_TYPE,
NODES_USING_CODE_NODE_EDITOR, NODES_USING_CODE_NODE_EDITOR,
} from '@/constants'; } from '@/constants';
@@ -72,13 +73,14 @@ import { useUIStore } from '@/stores/ui.store';
import { N8nIcon, N8nInput, N8nInputNumber, N8nOption, N8nSelect } from '@n8n/design-system'; import { N8nIcon, N8nInput, N8nInputNumber, N8nOption, N8nSelect } from '@n8n/design-system';
import type { EventBus } from '@n8n/utils/event-bus'; import type { EventBus } from '@n8n/utils/event-bus';
import { createEventBus } from '@n8n/utils/event-bus'; import { createEventBus } from '@n8n/utils/event-bus';
import { useElementSize } from '@vueuse/core'; import { onClickOutside, useElementSize } from '@vueuse/core';
import { captureMessage } from '@sentry/vue'; import { captureMessage } from '@sentry/vue';
import { isCredentialOnlyNodeType } from '@/utils/credentialOnlyNodes'; import { isCredentialOnlyNodeType } from '@/utils/credentialOnlyNodes';
import { hasFocusOnInput, isBlurrableEl, isFocusableEl, isSelectableEl } from '@/utils/typesUtils'; import { hasFocusOnInput, isBlurrableEl, isFocusableEl, isSelectableEl } from '@/utils/typesUtils';
import { completeExpressionSyntax, shouldConvertToExpression } from '@/utils/expressions'; import { completeExpressionSyntax, shouldConvertToExpression } from '@/utils/expressions';
import CssEditor from './CssEditor/CssEditor.vue'; import CssEditor from './CssEditor/CssEditor.vue';
import { useFocusPanelStore } from '@/stores/focusPanel.store'; import { useFocusPanelStore } from '@/stores/focusPanel.store';
import ExperimentalEmbeddedNdvMapper from '@/components/canvas/experimental/components/ExperimentalEmbeddedNdvMapper.vue';
type Picker = { $emit: (arg0: string, arg1: Date) => void }; type Picker = { $emit: (arg0: string, arg1: Date) => void };
@@ -144,10 +146,13 @@ const nodeTypesStore = useNodeTypesStore();
const uiStore = useUIStore(); const uiStore = useUIStore();
const focusPanelStore = useFocusPanelStore(); const focusPanelStore = useFocusPanelStore();
const expressionLocalResolveCtx = inject(ExpressionLocalResolveContextSymbol, undefined);
// ESLint: false positive // ESLint: false positive
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
const inputField = ref<InstanceType<typeof N8nInput | typeof N8nSelect> | HTMLElement>(); const inputField = ref<InstanceType<typeof N8nInput | typeof N8nSelect> | HTMLElement>();
const wrapper = ref<HTMLDivElement>(); const wrapper = ref<HTMLDivElement>();
const mapperRef = ref<InstanceType<typeof ExperimentalEmbeddedNdvMapper>>();
const nodeName = ref(''); const nodeName = ref('');
const codeEditDialogVisible = ref(false); const codeEditDialogVisible = ref(false);
@@ -190,7 +195,10 @@ const dateTimePickerOptions = ref({
}); });
const isFocused = ref(false); const isFocused = ref(false);
const node = computed(() => ndvStore.activeNode ?? undefined); const contextNode = expressionLocalResolveCtx?.value?.workflow.getNode(
expressionLocalResolveCtx.value.nodeName,
);
const node = computed(() => contextNode ?? ndvStore.activeNode ?? undefined);
const nodeType = computed( const nodeType = computed(
() => node.value && nodeTypesStore.getNodeType(node.value.type, node.value.typeVersion), () => node.value && nodeTypesStore.getNodeType(node.value.type, node.value.typeVersion),
); );
@@ -592,6 +600,12 @@ const showDragnDropTip = computed(
const shouldCaptureForPosthog = computed(() => node.value?.type === AI_TRANSFORM_NODE_TYPE); const shouldCaptureForPosthog = computed(() => node.value?.type === AI_TRANSFORM_NODE_TYPE);
const shouldShowMapper = computed(
() =>
isFocused.value &&
(isModelValueExpression.value || props.forceShowExpression || props.modelValue === ''),
);
function isRemoteParameterOption(option: INodePropertyOptions) { function isRemoteParameterOption(option: INodePropertyOptions) {
return remoteParameterOptionsKeys.value.includes(option.name); return remoteParameterOptionsKeys.value.includes(option.name);
} }
@@ -916,7 +930,16 @@ function expressionUpdated(value: string) {
valueChanged(val); valueChanged(val);
} }
function onBlur() { function onBlur(event?: FocusEvent | KeyboardEvent) {
if (
event?.target instanceof HTMLElement &&
mapperRef.value?.contentRef &&
(event.target === mapperRef.value.contentRef ||
mapperRef.value.contentRef.contains(event.target))
) {
return;
}
emit('blur'); emit('blur');
isFocused.value = false; isFocused.value = false;
} }
@@ -1166,6 +1189,8 @@ onUpdated(async () => {
} }
} }
}); });
onClickOutside(wrapper, onBlur);
</script> </script>
<template> <template>
@@ -1186,7 +1211,17 @@ onUpdated(async () => {
:redact-values="shouldRedactValue" :redact-values="shouldRedactValue"
@close-dialog="closeExpressionEditDialog" @close-dialog="closeExpressionEditDialog"
@update:model-value="expressionUpdated" @update:model-value="expressionUpdated"
></ExpressionEditModal> />
<ExperimentalEmbeddedNdvMapper
v-if="node && expressionLocalResolveCtx?.inputNode"
ref="mapperRef"
:workflow="expressionLocalResolveCtx?.workflow"
:node="node"
:input-node-name="expressionLocalResolveCtx?.inputNode?.name"
:visible="shouldShowMapper"
:virtual-ref="wrapper"
/>
<div <div
:class="[ :class="[

View File

@@ -26,6 +26,11 @@ beforeEach(() => {
}, },
isInputPanelEmpty: false, isInputPanelEmpty: false,
isOutputPanelEmpty: false, isOutputPanelEmpty: false,
ndvInputDataWithPinnedData: [],
getHoveringItem: undefined,
expressionOutputItemIndex: 0,
isTableHoverOnboarded: false,
setHighlightDraggables: vi.fn(),
}; };
mockNodeTypesState = { mockNodeTypesState = {
allNodeTypes: [], allNodeTypes: [],

View File

@@ -209,6 +209,7 @@ const emit = defineEmits<{
]; ];
displayModeChange: [IRunDataDisplayMode]; displayModeChange: [IRunDataDisplayMode];
collapsingTableColumnChanged: [columnName: string | null]; collapsingTableColumnChanged: [columnName: string | null];
captureWheelDataContainer: [WheelEvent];
}>(); }>();
const connectionType = ref<NodeConnectionType>(NodeConnectionTypes.Main); const connectionType = ref<NodeConnectionType>(NodeConnectionTypes.Main);
@@ -1609,7 +1610,12 @@ defineExpose({ enterEditMode });
</div> </div>
</div> </div>
<div ref="dataContainerRef" :class="$style.dataContainer" data-test-id="ndv-data-container"> <div
ref="dataContainerRef"
:class="$style.dataContainer"
data-test-id="ndv-data-container"
@wheel.capture="emit('captureWheelDataContainer', $event)"
>
<BinaryDataDisplay <BinaryDataDisplay
v-if="binaryDataDisplayData" v-if="binaryDataDisplayData"
:window-visible="binaryDataDisplayVisible" :window-visible="binaryDataDisplayVisible"

View File

@@ -55,6 +55,14 @@ describe('SqlEditor.vue', () => {
}, },
[STORES.NDV]: { [STORES.NDV]: {
activeNodeName: 'Test Node', activeNodeName: 'Test Node',
hasInputData: true,
isInputPanelEmpty: false,
isOutputPanelEmpty: false,
ndvInputDataWithPinnedData: [],
getHoveringItem: undefined,
expressionOutputItemIndex: 0,
isTableHoverOnboarded: false,
setHighlightDraggables: vi.fn(),
}, },
[STORES.WORKFLOWS]: { [STORES.WORKFLOWS]: {
workflow: { workflow: {
@@ -185,10 +193,22 @@ describe('SqlEditor.vue', () => {
// Does not hide output when clicking inside the output // Does not hide output when clicking inside the output
await focusEditor(container); await focusEditor(container);
await userEvent.click(getByTestId(EXPRESSION_OUTPUT_TEST_ID)); await userEvent.click(getByTestId(EXPRESSION_OUTPUT_TEST_ID));
await waitFor(() => expect(queryByTestId(EXPRESSION_OUTPUT_TEST_ID)).toBeInTheDocument());
await waitFor(() =>
expect(
queryByTestId(EXPRESSION_OUTPUT_TEST_ID)?.closest('[aria-hidden=false]'),
).toBeInTheDocument(),
);
// Does hide output when clicking outside the container // Does hide output when clicking outside the container
await userEvent.click(baseElement); await userEvent.click(baseElement);
await waitFor(() => expect(queryByTestId(EXPRESSION_OUTPUT_TEST_ID)).not.toBeInTheDocument());
// NOTE: in testing, popover persists regardless of persist option.
// See https://github.com/element-plus/element-plus/blob/2.4.3/packages/components/tooltip/src/content.vue#L83-L90
await waitFor(() =>
expect(
queryByTestId(EXPRESSION_OUTPUT_TEST_ID)?.closest('[aria-hidden=true]'),
).toBeInTheDocument(),
);
}); });
}); });

View File

@@ -73,6 +73,7 @@ const emit = defineEmits<{
const container = ref<HTMLDivElement>(); const container = ref<HTMLDivElement>();
const sqlEditor = ref<HTMLDivElement>(); const sqlEditor = ref<HTMLDivElement>();
const isFocused = ref(false); const isFocused = ref(false);
const outputPopover = ref<InstanceType<typeof InlineExpressionEditorOutput>>();
const extensions = computed(() => { const extensions = computed(() => {
const dialect = SQL_DIALECTS[props.dialect] ?? SQL_DIALECTS.StandardSQL; const dialect = SQL_DIALECTS[props.dialect] ?? SQL_DIALECTS.StandardSQL;
@@ -156,9 +157,10 @@ onClickOutside(container, (event) => onBlur(event));
function onBlur(event: FocusEvent | KeyboardEvent) { function onBlur(event: FocusEvent | KeyboardEvent) {
if ( if (
event?.target instanceof Element && event?.target instanceof Element &&
Array.from(event.target.classList).some((_class) => _class.includes('resizer')) (Array.from(event.target.classList).some((_class) => _class.includes('resizer')) ||
outputPopover.value?.contentRef?.contains(event.target))
) { ) {
return; // prevent blur on resizing return; // prevent blur on resizing or interacting with output popover
} }
isFocused.value = false; isFocused.value = false;
@@ -219,9 +221,11 @@ defineExpose({
<slot name="suffix" /> <slot name="suffix" />
<InlineExpressionEditorOutput <InlineExpressionEditorOutput
v-if="!fullscreen" v-if="!fullscreen"
ref="outputPopover"
:segments="segments" :segments="segments"
:is-read-only="isReadOnly" :is-read-only="isReadOnly"
:visible="isFocused" :visible="isFocused"
:virtual-ref="container"
/> />
</div> </div>
</template> </template>

View File

@@ -137,6 +137,9 @@ const props = withDefaults(
); );
const { isMobileDevice, controlKeyCode } = useDeviceSupport(); const { isMobileDevice, controlKeyCode } = useDeviceSupport();
const experimentalNdvStore = useExperimentalNdvStore();
const isExperimentalNdvActive = computed(() => experimentalNdvStore.isActive(viewport.value.zoom));
const vueFlow = useVueFlow(props.id); const vueFlow = useVueFlow(props.id);
const { const {
@@ -174,14 +177,10 @@ const {
getDownstreamNodes, getDownstreamNodes,
getUpstreamNodes, getUpstreamNodes,
} = useCanvasTraversal(vueFlow); } = useCanvasTraversal(vueFlow);
const { layout } = useCanvasLayout({ id: props.id }); const { layout } = useCanvasLayout(props.id, isExperimentalNdvActive);
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,
@@ -886,7 +885,6 @@ watch([vueFlow.nodes, () => experimentalNdvStore.nodeNameToBeFocused], ([nodes,
// setTimeout() so that this happens after layout recalculation with the node to be focused // setTimeout() so that this happens after layout recalculation with the node to be focused
setTimeout(() => { setTimeout(() => {
experimentalNdvStore.focusNode(toFocusNode, { experimentalNdvStore.focusNode(toFocusNode, {
collapseOthers: false,
canvasViewport: viewport.value, canvasViewport: viewport.value,
canvasDimensions: dimensions.value, canvasDimensions: dimensions.value,
setCenter, setCenter,

View File

@@ -70,7 +70,7 @@ const mappedConnectionsThrottled = throttledRef(mappedConnections, 200);
<template> <template>
<div :class="$style.wrapper" data-test-id="canvas-wrapper"> <div :class="$style.wrapper" data-test-id="canvas-wrapper">
<div :class="$style.canvas"> <div id="canvas" :class="$style.canvas">
<Canvas <Canvas
v-if="workflow" v-if="workflow"
:id="id" :id="id"

View File

@@ -397,6 +397,8 @@ onBeforeUnmount(() => {
data-test-id="canvas-node-toolbar" data-test-id="canvas-node-toolbar"
:read-only="readOnly" :read-only="readOnly"
:class="$style.canvasNodeToolbar" :class="$style.canvasNodeToolbar"
:show-status-icons="isExperimentalNdvActive"
:items-class="$style.canvasNodeToolbarItems"
@delete="onDelete" @delete="onDelete"
@toggle="onDisabledToggle" @toggle="onDisabledToggle"
@run="onRun" @run="onRun"
@@ -431,27 +433,24 @@ onBeforeUnmount(() => {
<style lang="scss" module> <style lang="scss" module>
.canvasNode { .canvasNode {
.canvasNodeToolbarItems {
transition: opacity 0.1s ease-in;
opacity: 0;
}
&:hover:not(:has(> .trigger:hover)), // exclude .trigger which has extended hit zone &:hover:not(:has(> .trigger:hover)), // exclude .trigger which has extended hit zone
&:focus-within, &:focus-within,
&.showToolbar { &.showToolbar {
.canvasNodeToolbar { .canvasNodeToolbarItems {
opacity: 1; opacity: 1;
} }
} }
} }
.canvasNodeToolbar { .canvasNodeToolbar {
transition: opacity 0.1s ease-in;
position: absolute; position: absolute;
top: 0; bottom: 100%;
left: 50%; left: 0;
transform: translate(-50%, -100%) scale(var(--canvas-zoom-compensation-factor, 1));
opacity: 0;
z-index: 1; z-index: 1;
&:focus-within,
&:hover {
opacity: 1;
}
} }
</style> </style>

View File

@@ -7,6 +7,7 @@ import { useCanvas } from '@/composables/useCanvas';
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 { useExperimentalNdvStore } from '../../experimental/experimentalNdv.store'; import { useExperimentalNdvStore } from '../../experimental/experimentalNdv.store';
import CanvasNodeStatusIcons from '@/components/canvas/elements/nodes/render-types/parts/CanvasNodeStatusIcons.vue';
const emit = defineEmits<{ const emit = defineEmits<{
delete: []; delete: [];
@@ -19,6 +20,8 @@ const emit = defineEmits<{
const props = defineProps<{ const props = defineProps<{
readOnly?: boolean; readOnly?: boolean;
showStatusIcons: boolean;
itemsClass: string;
}>(); }>();
const $style = useCssModule(); const $style = useCssModule();
@@ -63,12 +66,7 @@ const isDisableNodeVisible = computed(() => {
const isDeleteNodeVisible = computed(() => !props.readOnly); const isDeleteNodeVisible = computed(() => !props.readOnly);
const isFocusNodeVisible = computed( const isFocusNodeVisible = computed(() => experimentalNdvStore.isEnabled);
() =>
experimentalNdvStore.isEnabled &&
node.value !== null &&
experimentalNdvStore.collapsedNodes[node.value.id] !== false,
);
const isStickyNoteChangeColorVisible = computed( const isStickyNoteChangeColorVisible = computed(
() => !props.readOnly && render.value.type === CanvasNodeRenderType.StickyNote, () => !props.readOnly && render.value.type === CanvasNodeRenderType.StickyNote,
@@ -118,7 +116,7 @@ function onFocusNode() {
@mouseenter="onMouseEnter" @mouseenter="onMouseEnter"
@mouseleave="onMouseLeave" @mouseleave="onMouseLeave"
> >
<div :class="$style.canvasNodeToolbarItems"> <div :class="[$style.canvasNodeToolbarItems, itemsClass]">
<N8nTooltip <N8nTooltip
v-if="isExecuteNodeVisible" v-if="isExecuteNodeVisible"
placement="top" placement="top"
@@ -178,6 +176,11 @@ function onFocusNode() {
@click="onOpenContextMenu" @click="onOpenContextMenu"
/> />
</div> </div>
<CanvasNodeStatusIcons
v-if="showStatusIcons"
:class="$style.statusIcons"
spinner-layout="static"
/>
</div> </div>
</template> </template>
@@ -189,8 +192,11 @@ function onFocusNode() {
width: 100%; width: 100%;
&.isExperimentalNdvActive { &.isExperimentalNdvActive {
justify-content: center; justify-content: space-between;
align-items: center;
padding-bottom: var(--spacing-3xs); padding-bottom: var(--spacing-3xs);
zoom: var(--canvas-zoom-compensation-factor, 1);
margin-bottom: var(--spacing-2xs);
} }
} }
@@ -209,4 +215,8 @@ function onFocusNode() {
.forceVisible { .forceVisible {
opacity: 1 !important; opacity: 1 !important;
} }
.statusIcons {
margin-inline-end: var(--spacing-3xs);
}
</style> </style>

View File

@@ -74,6 +74,7 @@ const nodeSize = computed(() =>
mainInputs.value.length, mainInputs.value.length,
mainOutputs.value.length, mainOutputs.value.length,
nonMainInputs.value.length, nonMainInputs.value.length,
isExperimentalNdvActive.value,
), ),
); );

View File

@@ -8,9 +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 = 'large', spinnerScrim = false } = defineProps<{ const {
size = 'large',
spinnerScrim = false,
spinnerLayout = 'absolute',
} = defineProps<{
size?: 'small' | 'medium' | 'large'; size?: 'small' | 'medium' | 'large';
spinnerScrim?: boolean; spinnerScrim?: boolean;
spinnerLayout?: 'absolute' | 'static';
}>(); }>();
const nodeHelpers = useNodeHelpers(); const nodeHelpers = useNodeHelpers();
@@ -44,7 +49,11 @@ 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 : '']); const commonClasses = computed(() => [
$style.status,
spinnerScrim ? $style.spinnerScrim : '',
spinnerLayout === 'absolute' ? $style.absoluteSpinner : '',
]);
</script> </script>
<template> <template>
@@ -57,7 +66,10 @@ const commonClasses = computed(() => [$style.status, spinnerScrim ? $style.spinn
<N8nIcon icon="clock" :size="size" /> <N8nIcon icon="clock" :size="size" />
</N8nTooltip> </N8nTooltip>
</div> </div>
<div :class="[...commonClasses, $style['node-waiting-spinner']]"> <div
v-if="spinnerLayout === 'absolute'"
:class="[...commonClasses, $style['node-waiting-spinner']]"
>
<N8nIcon icon="refresh-cw" spin /> <N8nIcon icon="refresh-cw" spin />
</div> </div>
</div> </div>
@@ -142,17 +154,21 @@ const commonClasses = computed(() => [$style.status, spinnerScrim ? $style.spinn
.node-waiting-spinner, .node-waiting-spinner,
.running { .running {
width: 100%; color: hsl(var(--color-primary-h), var(--color-primary-s), var(--color-primary-l));
height: 100%;
display: flex; &.absoluteSpinner {
align-items: center; width: 100%;
justify-content: center; height: 100%;
font-size: 3.75em; display: flex;
color: hsla(var(--color-primary-h), var(--color-primary-s), var(--color-primary-l), 0.7); align-items: center;
position: absolute; justify-content: center;
left: 0; font-size: 3.75em;
top: 0; color: hsla(var(--color-primary-h), var(--color-primary-s), var(--color-primary-l), 0.7);
padding: var(--canvas-node--status-icons-offset); position: absolute;
left: 0;
top: 0;
padding: var(--canvas-node--status-icons-offset);
}
&.spinnerScrim { &.spinnerScrim {
z-index: 10; z-index: 10;

View File

@@ -36,6 +36,12 @@ function handleValueChanged(parameterData: IUpdateInformation) {
} }
} }
function handleDoubleClickHeader() {
if (activeNode.value) {
ndvStore.setActiveNodeName(activeNode.value.name);
}
}
function handleCaptureWheelEvent(event: WheelEvent) { function handleCaptureWheelEvent(event: WheelEvent) {
if (event.ctrlKey) { if (event.ctrlKey) {
// If the event is pinch, let it propagate and zoom canvas // If the event is pinch, let it propagate and zoom canvas
@@ -55,7 +61,7 @@ function handleCaptureWheelEvent(event: WheelEvent) {
return; return;
} }
// Otherwise, let it scroll the settings pane // Otherwise, let it scroll the pane
event.stopImmediatePropagation(); event.stopImmediatePropagation();
} }
</script> </script>
@@ -71,8 +77,11 @@ function handleCaptureWheelEvent(event: WheelEvent) {
:executable="!isReadOnly" :executable="!isReadOnly"
is-embedded-in-canvas is-embedded-in-canvas
:sub-title="subTitle" :sub-title="subTitle"
extra-tabs-class-name="nodrag"
extra-parameter-wrapper-class-name="nodrag"
@value-changed="handleValueChanged" @value-changed="handleValueChanged"
@capture-wheel-body="handleCaptureWheelEvent" @capture-wheel-body="handleCaptureWheelEvent"
@dblclick-header="handleDoubleClickHeader"
> >
<template #actions> <template #actions>
<slot name="actions" /> <slot name="actions" />

View File

@@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import CanvasNodeStatusIcons from '@/components/canvas/elements/nodes/render-types/parts/CanvasNodeStatusIcons.vue';
import { N8nIconButton } from '@n8n/design-system'; import { N8nIconButton } from '@n8n/design-system';
defineProps<{ isExpanded: boolean }>(); defineProps<{ isExpanded: boolean }>();
@@ -9,9 +8,6 @@ const emit = defineEmits<{ openNdv: []; toggleExpand: [] }>();
<template> <template>
<div :class="$style.actions"> <div :class="$style.actions">
<div :class="$style.icon">
<CanvasNodeStatusIcons size="small" spinner-scrim />
</div>
<N8nIconButton <N8nIconButton
icon="maximize-2" icon="maximize-2"
type="secondary" type="secondary"
@@ -43,8 +39,4 @@ const emit = defineEmits<{ openNdv: []; toggleExpand: [] }>();
color: var(--color-text-light); color: var(--color-text-light);
} }
} }
.icon {
margin-inline: var(--spacing-2xs);
}
</style> </style>

View File

@@ -11,6 +11,7 @@ defineProps<{
nodeType?: INodeTypeDescription | null; nodeType?: INodeTypeDescription | null;
pushRef: string; pushRef: string;
subTitle?: string; subTitle?: string;
extraTabsClassName?: string;
selectedTab: NodeSettingsTab; selectedTab: NodeSettingsTab;
includeAction: boolean; includeAction: boolean;
includeCredential: boolean; includeCredential: boolean;
@@ -19,6 +20,7 @@ defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
'name-changed': [value: string]; 'name-changed': [value: string];
'dblclick-title': [event: MouseEvent];
'tab-changed': [tab: NodeSettingsTab]; 'tab-changed': [tab: NodeSettingsTab];
}>(); }>();
@@ -27,7 +29,7 @@ defineSlots<{ actions?: {} }>();
<template> <template>
<div :class="[$style.component, node.disabled ? $style.disabled : '']"> <div :class="[$style.component, node.disabled ? $style.disabled : '']">
<div :class="$style.title"> <div :class="$style.title" @dblclick="emit('dblclick-title', $event)">
<NodeIcon :node-type="nodeType" :size="16" /> <NodeIcon :node-type="nodeType" :size="16" />
<div :class="$style.titleText"> <div :class="$style.titleText">
<N8nInlineTextEdit <N8nInlineTextEdit
@@ -44,6 +46,7 @@ defineSlots<{ actions?: {} }>();
</div> </div>
<div :class="$style.tabsContainer"> <div :class="$style.tabsContainer">
<NodeSettingsTabs <NodeSettingsTabs
:class="extraTabsClassName"
:model-value="selectedTab" :model-value="selectedTab"
:node-type="nodeType" :node-type="nodeType"
:push-ref="pushRef" :push-ref="pushRef"
@@ -68,8 +71,8 @@ defineSlots<{ actions?: {} }>();
align-items: center; align-items: center;
padding: var(--spacing-2xs) var(--spacing-3xs) var(--spacing-2xs) var(--spacing-xs); padding: var(--spacing-2xs) var(--spacing-3xs) var(--spacing-2xs) var(--spacing-xs);
border-bottom: var(--border-base); border-bottom: var(--border-base);
margin-bottom: 14px; // to match bottom padding of tabs
gap: var(--spacing-4xs); gap: var(--spacing-4xs);
cursor: grab;
.disabled & { .disabled & {
background-color: var(--color-foreground-light); background-color: var(--color-foreground-light);
@@ -99,6 +102,7 @@ defineSlots<{ actions?: {} }>();
} }
.tabsContainer { .tabsContainer {
padding-top: var(--spacing-xs);
padding-inline: var(--spacing-xs); padding-inline: var(--spacing-xs);
} }
</style> </style>

View File

@@ -1,83 +1,61 @@
<script setup lang="ts"> <script setup lang="ts">
import InputPanel from '@/components/InputPanel.vue'; import InputPanel from '@/components/InputPanel.vue';
import { useCanvas } from '@/composables/useCanvas'; import { CanvasKey } from '@/constants';
import type { INodeUi } from '@/Interface'; import type { INodeUi } from '@/Interface';
import { useNDVStore } from '@/stores/ndv.store'; import { useNDVStore } from '@/stores/ndv.store';
import { N8nText } from '@n8n/design-system'; import { N8nPopover } from '@n8n/design-system';
import { useVueFlow } from '@vue-flow/core'; import { useVueFlow } from '@vue-flow/core';
import { useActiveElement } from '@vueuse/core'; import { watchOnce } from '@vueuse/core';
import { ElPopover } from 'element-plus';
import type { Workflow } from 'n8n-workflow'; import type { Workflow } from 'n8n-workflow';
import { ref, useTemplateRef, watch } from 'vue'; import { computed, inject, ref, useTemplateRef } from 'vue';
const { node, container } = defineProps<{ const { node, inputNodeName, visible, virtualRef } = defineProps<{
workflow: Workflow; workflow: Workflow;
node: INodeUi; node: INodeUi;
container: HTMLDivElement | null; inputNodeName: string;
inputNodeName?: string; visible: boolean;
virtualRef: HTMLElement | undefined;
}>(); }>();
const contentRef = useTemplateRef('content');
const ndvStore = useNDVStore(); const ndvStore = useNDVStore();
const vf = useVueFlow(); const vf = useVueFlow();
const { isPaneMoving, viewport } = useCanvas(); const canvas = inject(CanvasKey, undefined);
const activeElement = useActiveElement(); const isVisible = computed(() => visible && !canvas?.isPaneMoving.value);
const isOnceVisible = ref(isVisible.value);
const inputPanelRef = useTemplateRef('inputPanel'); watchOnce(isVisible, (value) => {
const shouldShowInputPanel = ref(false); isOnceVisible.value = isOnceVisible.value || value;
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;
}
}); });
watch(isPaneMoving, (moving) => { defineExpose({
shouldShowInputPanel.value = !moving && getShouldShowInputPanel(); contentRef: computed<HTMLElement>(() => contentRef.value?.$el ?? null),
});
watch(viewport, () => {
shouldShowInputPanel.value = false;
}); });
</script> </script>
<template> <template>
<ElPopover <N8nPopover
:visible="shouldShowInputPanel" :visible="isVisible"
placement="left-start" placement="left"
:show-arrow="false" :show-arrow="false"
:popper-class="$style.component" :popper-class="$style.component"
:width="360" :width="360"
:offset="8" :offset="8"
:append-to="vf.viewportRef?.value" append-to="#canvas"
:popper-options="{ :popper-options="{
modifiers: [{ name: 'flip', enabled: false }], modifiers: [{ name: 'flip', enabled: false }],
}" }"
:persistent="isOnceVisible /* works like lazy initialization */"
virtual-triggering
:virtual-ref="virtualRef"
> >
<template #reference>
<slot />
</template>
<InputPanel <InputPanel
ref="inputPanel" ref="content"
:tabindex="-1" :tabindex="-1"
:class="$style.inputPanel" :class="$style.inputPanel"
:style="{
maxHeight: `calc(${vf.viewportRef.value?.offsetHeight ?? 0}px - var(--spacing-s) * 2)`,
}"
:workflow-object="workflow" :workflow-object="workflow"
:run-index="0" :run-index="0"
compact compact
@@ -88,21 +66,17 @@ watch(viewport, () => {
:current-node-name="inputNodeName" :current-node-name="inputNodeName"
:is-mapping-onboarded="ndvStore.isMappingOnboarded" :is-mapping-onboarded="ndvStore.isMappingOnboarded"
:focused-mappable-input="ndvStore.focusedMappableInput" :focused-mappable-input="ndvStore.focusedMappableInput"
> node-not-run-message-variant="simple"
<template #header> />
<N8nText :class="$style.inputPanelTitle" :bold="true" color="text-light" size="small"> </N8nPopover>
Input
</N8nText>
</template>
</InputPanel>
</ElPopover>
</template> </template>
<style lang="scss" module> <style lang="scss" module>
.component { .component {
background-color: transparent !important; background-color: transparent !important;
padding: 0 !important; padding: var(--spacing-s) 0 !important;
border: none !important; border: none !important;
box-shadow: none !important;
margin-top: -2px; margin-top: -2px;
} }
@@ -111,10 +85,10 @@ watch(viewport, () => {
border-width: 1px; border-width: 1px;
background-color: var(--color-background-light); background-color: var(--color-background-light);
border-radius: var(--border-radius-large); border-radius: var(--border-radius-large);
zoom: var(--zoom);
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.05); box-shadow: 0 2px 16px rgba(0, 0, 0, 0.05);
padding: var(--spacing-2xs); padding: var(--spacing-2xs);
height: 100%; height: 100%;
overflow: auto;
} }
.inputPanelTitle { .inputPanelTitle {

View File

@@ -8,7 +8,7 @@ import type { ExpressionLocalResolveContext } from '@/types/expressions';
import { N8nText } from '@n8n/design-system'; import { N8nText } from '@n8n/design-system';
import { useVueFlow } from '@vue-flow/core'; import { useVueFlow } from '@vue-flow/core';
import { watchOnce } from '@vueuse/core'; import { watchOnce } from '@vueuse/core';
import { computed, provide, ref, useTemplateRef } from 'vue'; import { computed, provide, ref } 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 { useI18n } from '@n8n/i18n';
@@ -18,10 +18,9 @@ import { getNodeSubTitleText } from '@/components/canvas/experimental/experiment
import ExperimentalEmbeddedNdvActions from '@/components/canvas/experimental/components/ExperimentalEmbeddedNdvActions.vue'; import ExperimentalEmbeddedNdvActions from '@/components/canvas/experimental/components/ExperimentalEmbeddedNdvActions.vue';
import { useCanvas } from '@/composables/useCanvas'; import { useCanvas } from '@/composables/useCanvas';
const { nodeId, isReadOnly, isConfigurable } = defineProps<{ const { nodeId, isReadOnly } = defineProps<{
nodeId: string; nodeId: string;
isReadOnly?: boolean; isReadOnly?: boolean;
isConfigurable: boolean;
}>(); }>();
const i18n = useI18n(); const i18n = useI18n();
@@ -52,8 +51,6 @@ const isVisible = computed(() =>
); );
const isOnceVisible = ref(isVisible.value); const isOnceVisible = ref(isVisible.value);
const containerRef = useTemplateRef('container');
const subTitle = computed(() => const subTitle = computed(() =>
node.value && nodeType.value node.value && nodeType.value
? getNodeSubTitleText(node.value, nodeType.value, !isExpanded.value, i18n) ? getNodeSubTitleText(node.value, nodeType.value, !isExpanded.value, i18n)
@@ -105,6 +102,7 @@ const expressionResolveCtx = computed<ExpressionLocalResolveContext | undefined>
}; };
}); });
const maxHeightOnFocus = computed(() => vf.dimensions.value.height * 0.8);
const workflowObject = computed(() => workflowsStore.workflowObject as Workflow); const workflowObject = computed(() => workflowsStore.workflowObject as Workflow);
function handleToggleExpand() { function handleToggleExpand() {
@@ -126,44 +124,34 @@ watchOnce(isVisible, (visible) => {
<template> <template>
<div <div
ref="container"
:class="[ :class="[
$style.component, $style.component,
isExpanded ? $style.expanded : $style.collapsed, isExpanded ? $style.expanded : $style.collapsed,
node?.disabled ? $style.disabled : '', node?.disabled ? $style.disabled : '',
isExpanded ? 'nodrag' : '',
]" ]"
:style="{ :style="{
'--zoom': `${1 / experimentalNdvStore.maxCanvasZoom}`, '--max-height-on-focus': `${maxHeightOnFocus / experimentalNdvStore.maxCanvasZoom}px`,
'--node-width-scaler': isConfigurable ? 1 : 1.5,
'--max-height-on-focus': `${(vf.dimensions.value.height * 0.8) / experimentalNdvStore.maxCanvasZoom}px`,
pointerEvents: isPaneMoving ? 'none' : 'auto', // Don't interrupt canvas panning pointerEvents: isPaneMoving ? 'none' : 'auto', // Don't interrupt canvas panning
}" }"
> >
<template v-if="!node || !isOnceVisible" /> <template v-if="!node || !isOnceVisible" />
<ExperimentalEmbeddedNdvMapper <ExperimentalCanvasNodeSettings
v-else-if="isExpanded" v-else-if="isExpanded"
:workflow="workflowObject" tabindex="-1"
:node="node" :node-id="nodeId"
:class="$style.settingsView"
:is-read-only="isReadOnly"
:sub-title="subTitle"
:input-node-name="expressionResolveCtx?.inputNode?.name" :input-node-name="expressionResolveCtx?.inputNode?.name"
:container="containerRef"
> >
<ExperimentalCanvasNodeSettings <template #actions>
tabindex="-1" <ExperimentalEmbeddedNdvActions
:node-id="nodeId" :is-expanded="isExpanded"
:class="$style.settingsView" @open-ndv="handleOpenNdv"
:is-read-only="isReadOnly" @toggle-expand="handleToggleExpand"
:sub-title="subTitle" />
> </template>
<template #actions> </ExperimentalCanvasNodeSettings>
<ExperimentalEmbeddedNdvActions
:is-expanded="isExpanded"
@open-ndv="handleOpenNdv"
@toggle-expand="handleToggleExpand"
/>
</template>
</ExperimentalCanvasNodeSettings>
</ExperimentalEmbeddedNdvMapper>
<div v-else role="button" :class="$style.collapsedContent" @click="handleToggleExpand"> <div v-else role="button" :class="$style.collapsedContent" @click="handleToggleExpand">
<NodeIcon :node-type="nodeType" :size="18" /> <NodeIcon :node-type="nodeType" :size="18" />
<div :class="$style.collapsedNodeName"> <div :class="$style.collapsedNodeName">
@@ -189,7 +177,6 @@ watchOnce(isVisible, (visible) => {
justify-content: stretch; justify-content: stretch;
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)) !important;
overflow: hidden; overflow: hidden;
--canvas-node--border-color: var(--color-text-lighter); --canvas-node--border-color: var(--color-text-lighter);
@@ -253,7 +240,7 @@ watchOnce(isVisible, (visible) => {
} }
& > * { & > * {
zoom: var(--zoom); zoom: var(--canvas-zoom-compensation-factor, 1);
flex-grow: 0; flex-grow: 0;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -275,7 +262,7 @@ watchOnce(isVisible, (visible) => {
.settingsView { .settingsView {
& > * { & > * {
zoom: var(--zoom); zoom: var(--canvas-zoom-compensation-factor, 1);
} }
} }
</style> </style>

View File

@@ -1,7 +1,6 @@
import { computed, ref, shallowRef } from 'vue'; import { computed, ref, shallowRef } from 'vue';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { useSettingsStore } from '@/stores/settings.store';
import { import {
type Dimensions, type Dimensions,
type FitView, type FitView,
@@ -12,18 +11,18 @@ import {
type ZoomTo, type ZoomTo,
} from '@vue-flow/core'; } from '@vue-flow/core';
import { CanvasNodeRenderType, type CanvasNodeData } from '@/types'; import { CanvasNodeRenderType, type CanvasNodeData } from '@/types';
import { usePostHog } from '@/stores/posthog.store';
import { CANVAS_ZOOMED_VIEW_EXPERIMENT } from '@/constants';
export const useExperimentalNdvStore = defineStore('experimentalNdv', () => { export const useExperimentalNdvStore = defineStore('experimentalNdv', () => {
const workflowStore = useWorkflowsStore(); const workflowStore = useWorkflowsStore();
const settingsStore = useSettingsStore(); const postHogStore = usePostHog();
const isEnabled = computed( const isEnabled = computed(
() => () =>
!Number.isNaN(settingsStore.experimental__minZoomNodeSettingsInCanvas) && postHogStore.getVariant(CANVAS_ZOOMED_VIEW_EXPERIMENT.name) ===
settingsStore.experimental__minZoomNodeSettingsInCanvas > 0, CANVAS_ZOOMED_VIEW_EXPERIMENT.variant,
);
const maxCanvasZoom = computed(() =>
isEnabled.value ? settingsStore.experimental__minZoomNodeSettingsInCanvas : 4,
); );
const maxCanvasZoom = computed(() => (isEnabled.value ? 2 : 4));
const previousViewport = ref<ViewportTransform>(); const previousViewport = ref<ViewportTransform>();
const collapsedNodes = shallowRef<Partial<Record<string, boolean>>>({}); const collapsedNodes = shallowRef<Partial<Record<string, boolean>>>({});
@@ -59,7 +58,6 @@ export const useExperimentalNdvStore = defineStore('experimentalNdv', () => {
} }
interface FocusNodeOptions { interface FocusNodeOptions {
collapseOthers?: boolean;
canvasViewport: ViewportTransform; canvasViewport: ViewportTransform;
canvasDimensions: Dimensions; canvasDimensions: Dimensions;
setCenter: SetCenter; setCenter: SetCenter;
@@ -67,14 +65,9 @@ export const useExperimentalNdvStore = defineStore('experimentalNdv', () => {
function focusNode( function focusNode(
node: GraphNode<CanvasNodeData>, node: GraphNode<CanvasNodeData>,
{ collapseOthers = true, canvasDimensions, canvasViewport, setCenter }: FocusNodeOptions, { canvasDimensions, canvasViewport, setCenter }: FocusNodeOptions,
) { ) {
collapsedNodes.value = collapseOthers collapsedNodes.value = { ...collapsedNodes.value, [node.id]: false };
? workflowStore.allNodes.reduce<Partial<Record<string, boolean>>>((acc, n) => {
acc[n.id] = n.id !== node.id;
return acc;
}, {})
: { ...collapsedNodes.value, [node.id]: false };
const topMargin = 80; // pixels const topMargin = 80; // pixels
const nodeWidth = node.dimensions.width * (isActive(canvasViewport.zoom) ? 1 : 1.5); const nodeWidth = node.dimensions.width * (isActive(canvasViewport.zoom) ? 1 : 1.5);
@@ -134,7 +127,7 @@ export const useExperimentalNdvStore = defineStore('experimentalNdv', () => {
)[0]; )[0];
if (toFocus) { if (toFocus) {
focusNode(toFocus, { ...options, collapseOthers: false }); focusNode(toFocus, options);
return; return;
} }

View File

@@ -1,5 +1,5 @@
import { useVueFlow, type GraphNode, type VueFlowStore } from '@vue-flow/core'; import { useVueFlow, type GraphNode, type VueFlowStore } from '@vue-flow/core';
import { ref } from 'vue'; import { computed, ref } from 'vue';
import { createCanvasGraphEdge, createCanvasGraphNode } from '../__tests__/data'; import { createCanvasGraphEdge, createCanvasGraphNode } from '../__tests__/data';
import { CanvasNodeRenderType, type CanvasNodeData } from '../types'; import { CanvasNodeRenderType, type CanvasNodeData } from '../types';
import { useCanvasLayout, type CanvasLayoutResult } from './useCanvasLayout'; import { useCanvasLayout, type CanvasLayoutResult } from './useCanvasLayout';
@@ -36,7 +36,10 @@ describe('useCanvasLayout', () => {
vi.mocked(useVueFlow).mockReturnValue(vueFlowStoreMock); vi.mocked(useVueFlow).mockReturnValue(vueFlowStoreMock);
const { layout } = useCanvasLayout(); const { layout } = useCanvasLayout(
'test-canvas-id',
computed(() => false),
);
return { layout }; return { layout };
} }

View File

@@ -10,8 +10,8 @@ import {
} from '../types'; } from '../types';
import { isPresent } from '../utils/typesUtils'; import { isPresent } from '../utils/typesUtils';
import { DEFAULT_NODE_SIZE, GRID_SIZE, calculateNodeSize } from '../utils/nodeViewUtils'; import { DEFAULT_NODE_SIZE, GRID_SIZE, calculateNodeSize } from '../utils/nodeViewUtils';
import type { ComputedRef } from 'vue';
export type CanvasLayoutOptions = { id?: string };
export type CanvasLayoutTarget = 'selection' | 'all'; export type CanvasLayoutTarget = 'selection' | 'all';
export type CanvasLayoutSource = export type CanvasLayoutSource =
| 'keyboard-shortcut' | 'keyboard-shortcut'
@@ -47,7 +47,7 @@ const AI_X_SPACING = GRID_SIZE * 3;
const AI_Y_SPACING = GRID_SIZE * 8; const AI_Y_SPACING = GRID_SIZE * 8;
const STICKY_BOTTOM_PADDING = GRID_SIZE * 4; const STICKY_BOTTOM_PADDING = GRID_SIZE * 4;
export function useCanvasLayout({ id: canvasId }: CanvasLayoutOptions = {}) { export function useCanvasLayout(canvasId: string, isEmbeddedNdvActive: ComputedRef<boolean>) {
const { const {
findNode, findNode,
findEdge, findEdge,
@@ -120,6 +120,7 @@ export function useCanvasLayout({ id: canvasId }: CanvasLayoutOptions = {}) {
mainInputCount, mainInputCount,
mainOutputCount, mainOutputCount,
nonMainInputCount, nonMainInputCount,
isEmbeddedNdvActive.value,
); );
} }

View File

@@ -497,8 +497,6 @@ export const LOCAL_STORAGE_LOGS_SYNC_SELECTION = 'N8N_LOGS_SYNC_SELECTION_ENABLE
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_LOGS_PANEL_DETAILS_PANEL_SUB_NODE = 'N8N_LOGS_DETAILS_PANEL_SUB_NODE'; export const LOCAL_STORAGE_LOGS_PANEL_DETAILS_PANEL_SUB_NODE = 'N8N_LOGS_DETAILS_PANEL_SUB_NODE';
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 LOCAL_STORAGE_EXPERIMENTAL_MIN_ZOOM_NODE_SETTINGS_IN_CANVAS =
'N8N_EXPERIMENTAL_MIN_ZOOM_NODE_SETTINGS_IN_CANVAS';
export const LOCAL_STORAGE_EXPERIMENTAL_DOCKED_NODE_SETTINGS = export const LOCAL_STORAGE_EXPERIMENTAL_DOCKED_NODE_SETTINGS =
'N8N_EXPERIMENTAL_DOCKED_NODE_SETTINGS'; 'N8N_EXPERIMENTAL_DOCKED_NODE_SETTINGS';
export const LOCAL_STORAGE_READ_WHATS_NEW_ARTICLES = 'N8N_READ_WHATS_NEW_ARTICLES'; export const LOCAL_STORAGE_READ_WHATS_NEW_ARTICLES = 'N8N_READ_WHATS_NEW_ARTICLES';
@@ -743,6 +741,12 @@ export const KEEP_AUTH_IN_NDV_FOR_NODES = [
export const MAIN_AUTH_FIELD_NAME = 'authentication'; export const MAIN_AUTH_FIELD_NAME = 'authentication';
export const NODE_RESOURCE_FIELD_NAME = 'resource'; export const NODE_RESOURCE_FIELD_NAME = 'resource';
export const CANVAS_ZOOMED_VIEW_EXPERIMENT = {
name: 'canvas_zoomed_view',
control: 'control',
variant: 'variant',
};
export const NDV_UI_OVERHAUL_EXPERIMENT = { export const NDV_UI_OVERHAUL_EXPERIMENT = {
name: '029_ndv_ui_overhaul', name: '029_ndv_ui_overhaul',
control: 'control', control: 'control',

View File

@@ -13,7 +13,6 @@ import { testHealthEndpoint } from '@n8n/rest-api-client/api/templates';
import { import {
INSECURE_CONNECTION_WARNING, INSECURE_CONNECTION_WARNING,
LOCAL_STORAGE_EXPERIMENTAL_DOCKED_NODE_SETTINGS, LOCAL_STORAGE_EXPERIMENTAL_DOCKED_NODE_SETTINGS,
LOCAL_STORAGE_EXPERIMENTAL_MIN_ZOOM_NODE_SETTINGS_IN_CANVAS,
} from '@/constants'; } from '@/constants';
import { STORES } from '@n8n/stores'; import { STORES } from '@n8n/stores';
import { UserManagementAuthenticationMethod } from '@/Interface'; import { UserManagementAuthenticationMethod } from '@/Interface';
@@ -314,15 +313,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
moduleSettings.value = fetched; moduleSettings.value = fetched;
}; };
/**
* (Experimental) Minimum zoom level of the canvas to render node settings in place of nodes, without opening NDV
*/
const experimental__minZoomNodeSettingsInCanvas = useLocalStorage(
LOCAL_STORAGE_EXPERIMENTAL_MIN_ZOOM_NODE_SETTINGS_IN_CANVAS,
0,
{ writeDefaults: false },
);
/** /**
* (Experimental) If set to true, show node settings for a selected node in docked pane * (Experimental) If set to true, show node settings for a selected node in docked pane
*/ */
@@ -388,7 +378,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
isAskAiEnabled, isAskAiEnabled,
isAiCreditsEnabled, isAiCreditsEnabled,
aiCreditsQuota, aiCreditsQuota,
experimental__minZoomNodeSettingsInCanvas,
experimental__dockedNodeSettingsEnabled, experimental__dockedNodeSettingsEnabled,
partialExecutionVersion, partialExecutionVersion,
reset, reset,

View File

@@ -484,6 +484,7 @@ describe('calculateNodeSize', () => {
1, 1,
1, 1,
0, 0,
false,
); );
// width = GRID_SIZE * 5 = 16 * 5 = 80 // width = GRID_SIZE * 5 = 16 * 5 = 80
// height = GRID_SIZE * 5 = 16 * 5 = 80 // height = GRID_SIZE * 5 = 16 * 5 = 80
@@ -499,7 +500,7 @@ describe('calculateNodeSize', () => {
// maxVerticalHandles = 3 // maxVerticalHandles = 3
// height = 96 + (3 - 2) * 32 = 96 + 32 = 128 // height = 96 + (3 - 2) * 32 = 96 + 32 = 128
expect( expect(
calculateNodeSize(false, true, mainInputCount, mainOutputCount, nonMainInputCount), calculateNodeSize(false, true, mainInputCount, mainOutputCount, nonMainInputCount, false),
).toEqual({ width: 272, height: 128 }); ).toEqual({ width: 272, height: 128 });
}); });
@@ -507,7 +508,7 @@ describe('calculateNodeSize', () => {
const nonMainInputCount = 2; const nonMainInputCount = 2;
// width = 80 + 16 + (max(4, 2) - 1) * 16 * 3 = 240 // width = 80 + 16 + (max(4, 2) - 1) * 16 * 3 = 240
// height = CONFIGURATION_NODE_SIZE[1] = 16 * 5 = 80 // height = CONFIGURATION_NODE_SIZE[1] = 16 * 5 = 80
expect(calculateNodeSize(true, true, 1, 1, nonMainInputCount)).toEqual({ expect(calculateNodeSize(true, true, 1, 1, nonMainInputCount, false)).toEqual({
width: 240, width: 240,
height: 80, height: 80,
}); });
@@ -519,7 +520,7 @@ describe('calculateNodeSize', () => {
// width = 96 // width = 96
// maxVerticalHandles = 3 // maxVerticalHandles = 3
// height = 96 + (3 - 2) * 32 = 128 // height = 96 + (3 - 2) * 32 = 128
expect(calculateNodeSize(false, false, mainInputCount, mainOutputCount, 0)).toEqual({ expect(calculateNodeSize(false, false, mainInputCount, mainOutputCount, 0, false)).toEqual({
width: 96, width: 96,
height: 128, height: 128,
}); });
@@ -530,7 +531,9 @@ describe('calculateNodeSize', () => {
const mainOutputCount = 4; const mainOutputCount = 4;
// maxVerticalHandles = 6 // maxVerticalHandles = 6
// height = 96 + (6 - 2) * 32 = 96 + 128 = 224 // height = 96 + (6 - 2) * 32 = 96 + 128 = 224
expect(calculateNodeSize(false, false, mainInputCount, mainOutputCount, 0).height).toBe(224); expect(calculateNodeSize(false, false, mainInputCount, mainOutputCount, 0, false).height).toBe(
224,
);
}); });
it('should respect the minimum width for configurable nodes', () => { it('should respect the minimum width for configurable nodes', () => {
@@ -539,7 +542,7 @@ describe('calculateNodeSize', () => {
// height = default path, mainInputCount = 1, mainOutputCount = 1 // height = default path, mainInputCount = 1, mainOutputCount = 1
// maxVerticalHandles = 1 // maxVerticalHandles = 1
// height = 96 + (1 - 2) * 32 = 96 + 0 = 96 // height = 96 + (1 - 2) * 32 = 96 + 0 = 96
expect(calculateNodeSize(false, true, 1, 1, nonMainInputCount)).toEqual({ expect(calculateNodeSize(false, true, 1, 1, nonMainInputCount, false)).toEqual({
width: 224, width: 224,
height: 96, height: 96,
}); });
@@ -548,7 +551,7 @@ describe('calculateNodeSize', () => {
it('should handle edge case when mainInputCount and mainOutputCount are 0', () => { it('should handle edge case when mainInputCount and mainOutputCount are 0', () => {
// maxVerticalHandles = max(0,0,1) = 1 // maxVerticalHandles = max(0,0,1) = 1
// height = 96 + (1 - 2) * 32 = 96 + 0 = 96 // height = 96 + (1 - 2) * 32 = 96 + 0 = 96
expect(calculateNodeSize(false, false, 0, 0, 0).height).toBe(96); expect(calculateNodeSize(false, false, 0, 0, 0, false).height).toBe(96);
}); });
}); });

View File

@@ -617,9 +617,11 @@ export function calculateNodeSize(
mainInputCount: number, mainInputCount: number,
mainOutputCount: number, mainOutputCount: number,
nonMainInputCount: number, nonMainInputCount: number,
isExperimentalNdvActive: boolean,
): { width: number; height: number } { ): { width: number; height: number } {
const maxVerticalHandles = Math.max(mainInputCount, mainOutputCount, 1); const maxVerticalHandles = Math.max(mainInputCount, mainOutputCount, 1);
const height = DEFAULT_NODE_SIZE[1] + Math.max(0, maxVerticalHandles - 2) * GRID_SIZE * 2; const height = DEFAULT_NODE_SIZE[1] + Math.max(0, maxVerticalHandles - 2) * GRID_SIZE * 2;
const widthScale = isExperimentalNdvActive ? 1.5 : 1;
if (isConfigurable) { if (isConfigurable) {
const portCount = Math.max(NODE_MIN_INPUT_ITEMS_COUNT, nonMainInputCount); const portCount = Math.max(NODE_MIN_INPUT_ITEMS_COUNT, nonMainInputCount);
@@ -627,15 +629,16 @@ export function calculateNodeSize(
return { return {
// Configuration node has extra width so that its centered port aligns to the grid // Configuration node has extra width so that its centered port aligns to the grid
width: width:
CONFIGURATION_NODE_RADIUS * 2 + (CONFIGURATION_NODE_RADIUS * 2 +
GRID_SIZE * ((isConfiguration ? 1 : 0) + (portCount - 1) * 3), GRID_SIZE * ((isConfiguration ? 1 : 0) + (portCount - 1) * 3)) *
widthScale,
height: isConfiguration ? CONFIGURATION_NODE_SIZE[1] : height, height: isConfiguration ? CONFIGURATION_NODE_SIZE[1] : height,
}; };
} }
if (isConfiguration) { if (isConfiguration) {
return { width: CONFIGURATION_NODE_SIZE[0], height: CONFIGURATION_NODE_SIZE[1] }; return { width: CONFIGURATION_NODE_SIZE[0] * widthScale, height: CONFIGURATION_NODE_SIZE[1] };
} }
return { width: DEFAULT_NODE_SIZE[0], height }; return { width: DEFAULT_NODE_SIZE[0] * widthScale, height };
} }