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.schemaPreviewHint": "switch to {schema} to use the schema preview",
"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.notConnected.title": "Wire me up",
"ndv.input.notConnected.v2.title": "No input connected",

View File

@@ -1,5 +1,5 @@
<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 ExpressionFunctionIcon from '@/components/ExpressionFunctionIcon.vue';
@@ -16,6 +16,7 @@ import { startCompletion } from '@codemirror/autocomplete';
import type { EditorState, SelectionRange } from '@codemirror/state';
import type { IDataObject } from 'n8n-workflow';
import { createEventBus, type EventBus } from '@n8n/utils/event-bus';
import { CanvasKey, ExpressionLocalResolveContextSymbol } from '@/constants';
const isFocused = ref(false);
const segments = ref<Segment[]>([]);
@@ -23,6 +24,7 @@ const editorState = ref<EditorState>();
const selection = ref<SelectionRange>();
const inlineInput = ref<InstanceType<typeof InlineExpressionEditorInput>>();
const container = ref<HTMLDivElement>();
const outputPopover = ref<InstanceType<typeof InlineExpressionEditorOutput>>();
type Props = {
path: string;
@@ -46,14 +48,21 @@ const emit = defineEmits<{
'modal-opener-click': [];
'update:model-value': [value: string];
focus: [];
blur: [];
blur: [FocusEvent | KeyboardEvent | undefined];
}>();
const telemetry = useTelemetry();
const ndvStore = useNDVStore();
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 isOutputPopoverVisible = computed(
() => isFocused.value && (!isInExperimentalNdv.value || !canvas?.isPaneMoving.value),
);
function select() {
if (inlineInput.value) {
@@ -80,12 +89,16 @@ function onBlur(event?: FocusEvent | KeyboardEvent) {
return; // prevent blur on resizing
}
if (event?.target instanceof Element && outputPopover.value?.contentRef?.contains(event.target)) {
return;
}
const wasFocused = isFocused.value;
isFocused.value = false;
if (wasFocused) {
emit('blur');
emit('blur', event);
const telemetryPayload = createExpressionTelemetryPayload(
segments.value,
@@ -161,7 +174,8 @@ onBeforeUnmount(() => {
});
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();
}
});
@@ -212,13 +226,17 @@ defineExpose({ focus, select });
@click="emit('modal-opener-click')"
/>
</div>
<InlineExpressionEditorOutput
ref="outputPopover"
:visible="isOutputPopoverVisible"
:unresolved-expression="modelValue"
:selection="selection"
:editor-state="editorState"
:segments="segments"
:is-read-only="isReadOnly"
:visible="isFocused"
:virtual-ref="container"
:append-to="isInExperimentalNdv ? '#canvas' : undefined"
/>
</div>
</template>

View File

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

View File

@@ -4,23 +4,26 @@ import type { EditorState, SelectionRange } from '@codemirror/state';
import { useI18n } from '@n8n/i18n';
import { useNDVStore } from '@/stores/ndv.store';
import type { Segment } from '@/types/expressions';
import { onBeforeUnmount } from 'vue';
import { computed, onBeforeUnmount, useTemplateRef } from 'vue';
import ExpressionOutput from './ExpressionOutput.vue';
import OutputItemSelect from './OutputItemSelect.vue';
import InlineExpressionTip from './InlineExpressionTip.vue';
import { outputTheme } from './theme';
import { useElementSize } from '@vueuse/core';
import { N8nPopover } from '@n8n/design-system';
interface InlineExpressionEditorOutputProps {
segments: Segment[];
unresolvedExpression?: string;
editorState?: EditorState;
selection?: SelectionRange;
visible?: boolean;
isReadOnly?: boolean;
visible: boolean;
virtualRef?: HTMLElement;
appendTo?: string;
}
withDefaults(defineProps<InlineExpressionEditorOutputProps>(), {
visible: false,
const props = withDefaults(defineProps<InlineExpressionEditorOutputProps>(), {
editorState: undefined,
selection: undefined,
isReadOnly: false,
@@ -30,45 +33,81 @@ withDefaults(defineProps<InlineExpressionEditorOutputProps>(), {
const i18n = useI18n();
const theme = outputTheme();
const ndvStore = useNDVStore();
const contentRef = useTemplateRef('content');
const virtualRefSize = useElementSize(computed(() => props.virtualRef));
onBeforeUnmount(() => {
ndvStore.expressionOutputItemIndex = 0;
});
defineExpose({
contentRef,
});
</script>
<template>
<div v-if="visible" :class="$style.dropdown" title="">
<div :class="$style.header">
<n8n-text bold size="small" compact>
{{ i18n.baseText('parameterInput.result') }}
</n8n-text>
<N8nPopover
:visible="visible"
placement="bottom"
:show-arrow="false"
: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>
<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>
</N8nPopover>
</template>
<style lang="scss" module>
.popper {
background-color: transparent !important;
padding: 0 !important;
border: none !important;
}
.dropdown {
display: flex;
flex-direction: column;
position: absolute;
z-index: 2; // cover tooltips
background: var(--color-code-background);
border: var(--border-base);
border-top: none;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<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';
@@ -51,6 +51,7 @@ import {
APP_MODALS_ELEMENT_ID,
CORE_NODES_CATEGORY,
CUSTOM_API_CALL_KEY,
ExpressionLocalResolveContextSymbol,
HTML_NODE_TYPE,
NODES_USING_CODE_NODE_EDITOR,
} from '@/constants';
@@ -72,13 +73,14 @@ import { useUIStore } from '@/stores/ui.store';
import { N8nIcon, N8nInput, N8nInputNumber, N8nOption, N8nSelect } from '@n8n/design-system';
import type { EventBus } 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 { isCredentialOnlyNodeType } from '@/utils/credentialOnlyNodes';
import { hasFocusOnInput, isBlurrableEl, isFocusableEl, isSelectableEl } from '@/utils/typesUtils';
import { completeExpressionSyntax, shouldConvertToExpression } from '@/utils/expressions';
import CssEditor from './CssEditor/CssEditor.vue';
import { useFocusPanelStore } from '@/stores/focusPanel.store';
import ExperimentalEmbeddedNdvMapper from '@/components/canvas/experimental/components/ExperimentalEmbeddedNdvMapper.vue';
type Picker = { $emit: (arg0: string, arg1: Date) => void };
@@ -144,10 +146,13 @@ const nodeTypesStore = useNodeTypesStore();
const uiStore = useUIStore();
const focusPanelStore = useFocusPanelStore();
const expressionLocalResolveCtx = inject(ExpressionLocalResolveContextSymbol, undefined);
// ESLint: false positive
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
const inputField = ref<InstanceType<typeof N8nInput | typeof N8nSelect> | HTMLElement>();
const wrapper = ref<HTMLDivElement>();
const mapperRef = ref<InstanceType<typeof ExperimentalEmbeddedNdvMapper>>();
const nodeName = ref('');
const codeEditDialogVisible = ref(false);
@@ -190,7 +195,10 @@ const dateTimePickerOptions = ref({
});
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(
() => 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 shouldShowMapper = computed(
() =>
isFocused.value &&
(isModelValueExpression.value || props.forceShowExpression || props.modelValue === ''),
);
function isRemoteParameterOption(option: INodePropertyOptions) {
return remoteParameterOptionsKeys.value.includes(option.name);
}
@@ -916,7 +930,16 @@ function expressionUpdated(value: string) {
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');
isFocused.value = false;
}
@@ -1166,6 +1189,8 @@ onUpdated(async () => {
}
}
});
onClickOutside(wrapper, onBlur);
</script>
<template>
@@ -1186,7 +1211,17 @@ onUpdated(async () => {
:redact-values="shouldRedactValue"
@close-dialog="closeExpressionEditDialog"
@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
:class="[

View File

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

View File

@@ -209,6 +209,7 @@ const emit = defineEmits<{
];
displayModeChange: [IRunDataDisplayMode];
collapsingTableColumnChanged: [columnName: string | null];
captureWheelDataContainer: [WheelEvent];
}>();
const connectionType = ref<NodeConnectionType>(NodeConnectionTypes.Main);
@@ -1609,7 +1610,12 @@ defineExpose({ enterEditMode });
</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
v-if="binaryDataDisplayData"
:window-visible="binaryDataDisplayVisible"

View File

@@ -55,6 +55,14 @@ describe('SqlEditor.vue', () => {
},
[STORES.NDV]: {
activeNodeName: 'Test Node',
hasInputData: true,
isInputPanelEmpty: false,
isOutputPanelEmpty: false,
ndvInputDataWithPinnedData: [],
getHoveringItem: undefined,
expressionOutputItemIndex: 0,
isTableHoverOnboarded: false,
setHighlightDraggables: vi.fn(),
},
[STORES.WORKFLOWS]: {
workflow: {
@@ -185,10 +193,22 @@ describe('SqlEditor.vue', () => {
// Does not hide output when clicking inside the output
await focusEditor(container);
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
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 sqlEditor = ref<HTMLDivElement>();
const isFocused = ref(false);
const outputPopover = ref<InstanceType<typeof InlineExpressionEditorOutput>>();
const extensions = computed(() => {
const dialect = SQL_DIALECTS[props.dialect] ?? SQL_DIALECTS.StandardSQL;
@@ -156,9 +157,10 @@ onClickOutside(container, (event) => onBlur(event));
function onBlur(event: FocusEvent | KeyboardEvent) {
if (
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;
@@ -219,9 +221,11 @@ defineExpose({
<slot name="suffix" />
<InlineExpressionEditorOutput
v-if="!fullscreen"
ref="outputPopover"
:segments="segments"
:is-read-only="isReadOnly"
:visible="isFocused"
:virtual-ref="container"
/>
</div>
</template>

View File

@@ -137,6 +137,9 @@ const props = withDefaults(
);
const { isMobileDevice, controlKeyCode } = useDeviceSupport();
const experimentalNdvStore = useExperimentalNdvStore();
const isExperimentalNdvActive = computed(() => experimentalNdvStore.isActive(viewport.value.zoom));
const vueFlow = useVueFlow(props.id);
const {
@@ -174,14 +177,10 @@ const {
getDownstreamNodes,
getUpstreamNodes,
} = useCanvasTraversal(vueFlow);
const { layout } = useCanvasLayout({ id: props.id });
const experimentalNdvStore = useExperimentalNdvStore();
const { layout } = useCanvasLayout(props.id, isExperimentalNdvActive);
const isPaneReady = ref(false);
const isExperimentalNdvActive = computed(() => experimentalNdvStore.isActive(viewport.value.zoom));
const classes = computed(() => ({
[$style.canvas]: true,
[$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(() => {
experimentalNdvStore.focusNode(toFocusNode, {
collapseOthers: false,
canvasViewport: viewport.value,
canvasDimensions: dimensions.value,
setCenter,

View File

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

View File

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

View File

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

View File

@@ -74,6 +74,7 @@ const nodeSize = computed(() =>
mainInputs.value.length,
mainOutputs.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 { useCanvas } from '@/composables/useCanvas';
const { size = 'large', spinnerScrim = false } = defineProps<{
const {
size = 'large',
spinnerScrim = false,
spinnerLayout = 'absolute',
} = defineProps<{
size?: 'small' | 'medium' | 'large';
spinnerScrim?: boolean;
spinnerLayout?: 'absolute' | 'static';
}>();
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
);
});
const commonClasses = computed(() => [$style.status, spinnerScrim ? $style.spinnerScrim : '']);
const commonClasses = computed(() => [
$style.status,
spinnerScrim ? $style.spinnerScrim : '',
spinnerLayout === 'absolute' ? $style.absoluteSpinner : '',
]);
</script>
<template>
@@ -57,7 +66,10 @@ const commonClasses = computed(() => [$style.status, spinnerScrim ? $style.spinn
<N8nIcon icon="clock" :size="size" />
</N8nTooltip>
</div>
<div :class="[...commonClasses, $style['node-waiting-spinner']]">
<div
v-if="spinnerLayout === 'absolute'"
:class="[...commonClasses, $style['node-waiting-spinner']]"
>
<N8nIcon icon="refresh-cw" spin />
</div>
</div>
@@ -142,17 +154,21 @@ const commonClasses = computed(() => [$style.status, spinnerScrim ? $style.spinn
.node-waiting-spinner,
.running {
width: 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;
left: 0;
top: 0;
padding: var(--canvas-node--status-icons-offset);
color: hsl(var(--color-primary-h), var(--color-primary-s), var(--color-primary-l));
&.absoluteSpinner {
width: 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;
left: 0;
top: 0;
padding: var(--canvas-node--status-icons-offset);
}
&.spinnerScrim {
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) {
if (event.ctrlKey) {
// If the event is pinch, let it propagate and zoom canvas
@@ -55,7 +61,7 @@ function handleCaptureWheelEvent(event: WheelEvent) {
return;
}
// Otherwise, let it scroll the settings pane
// Otherwise, let it scroll the pane
event.stopImmediatePropagation();
}
</script>
@@ -71,8 +77,11 @@ function handleCaptureWheelEvent(event: WheelEvent) {
:executable="!isReadOnly"
is-embedded-in-canvas
:sub-title="subTitle"
extra-tabs-class-name="nodrag"
extra-parameter-wrapper-class-name="nodrag"
@value-changed="handleValueChanged"
@capture-wheel-body="handleCaptureWheelEvent"
@dblclick-header="handleDoubleClickHeader"
>
<template #actions>
<slot name="actions" />

View File

@@ -1,5 +1,4 @@
<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 }>();
@@ -9,9 +8,6 @@ const emit = defineEmits<{ openNdv: []; toggleExpand: [] }>();
<template>
<div :class="$style.actions">
<div :class="$style.icon">
<CanvasNodeStatusIcons size="small" spinner-scrim />
</div>
<N8nIconButton
icon="maximize-2"
type="secondary"
@@ -43,8 +39,4 @@ const emit = defineEmits<{ openNdv: []; toggleExpand: [] }>();
color: var(--color-text-light);
}
}
.icon {
margin-inline: var(--spacing-2xs);
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
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 { CanvasNodeRenderType, type CanvasNodeData } from '../types';
import { useCanvasLayout, type CanvasLayoutResult } from './useCanvasLayout';
@@ -36,7 +36,10 @@ describe('useCanvasLayout', () => {
vi.mocked(useVueFlow).mockReturnValue(vueFlowStoreMock);
const { layout } = useCanvasLayout();
const { layout } = useCanvasLayout(
'test-canvas-id',
computed(() => false),
);
return { layout };
}

View File

@@ -10,8 +10,8 @@ import {
} from '../types';
import { isPresent } from '../utils/typesUtils';
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 CanvasLayoutSource =
| 'keyboard-shortcut'
@@ -47,7 +47,7 @@ const AI_X_SPACING = GRID_SIZE * 3;
const AI_Y_SPACING = GRID_SIZE * 8;
const STICKY_BOTTOM_PADDING = GRID_SIZE * 4;
export function useCanvasLayout({ id: canvasId }: CanvasLayoutOptions = {}) {
export function useCanvasLayout(canvasId: string, isEmbeddedNdvActive: ComputedRef<boolean>) {
const {
findNode,
findEdge,
@@ -120,6 +120,7 @@ export function useCanvasLayout({ id: canvasId }: CanvasLayoutOptions = {}) {
mainInputCount,
mainOutputCount,
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_SUB_NODE = 'N8N_LOGS_DETAILS_PANEL_SUB_NODE';
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 =
'N8N_EXPERIMENTAL_DOCKED_NODE_SETTINGS';
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 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 = {
name: '029_ndv_ui_overhaul',
control: 'control',

View File

@@ -13,7 +13,6 @@ import { testHealthEndpoint } from '@n8n/rest-api-client/api/templates';
import {
INSECURE_CONNECTION_WARNING,
LOCAL_STORAGE_EXPERIMENTAL_DOCKED_NODE_SETTINGS,
LOCAL_STORAGE_EXPERIMENTAL_MIN_ZOOM_NODE_SETTINGS_IN_CANVAS,
} from '@/constants';
import { STORES } from '@n8n/stores';
import { UserManagementAuthenticationMethod } from '@/Interface';
@@ -314,15 +313,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
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
*/
@@ -388,7 +378,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
isAskAiEnabled,
isAiCreditsEnabled,
aiCreditsQuota,
experimental__minZoomNodeSettingsInCanvas,
experimental__dockedNodeSettingsEnabled,
partialExecutionVersion,
reset,

View File

@@ -484,6 +484,7 @@ describe('calculateNodeSize', () => {
1,
1,
0,
false,
);
// width = GRID_SIZE * 5 = 16 * 5 = 80
// height = GRID_SIZE * 5 = 16 * 5 = 80
@@ -499,7 +500,7 @@ describe('calculateNodeSize', () => {
// maxVerticalHandles = 3
// height = 96 + (3 - 2) * 32 = 96 + 32 = 128
expect(
calculateNodeSize(false, true, mainInputCount, mainOutputCount, nonMainInputCount),
calculateNodeSize(false, true, mainInputCount, mainOutputCount, nonMainInputCount, false),
).toEqual({ width: 272, height: 128 });
});
@@ -507,7 +508,7 @@ describe('calculateNodeSize', () => {
const nonMainInputCount = 2;
// width = 80 + 16 + (max(4, 2) - 1) * 16 * 3 = 240
// 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,
height: 80,
});
@@ -519,7 +520,7 @@ describe('calculateNodeSize', () => {
// width = 96
// maxVerticalHandles = 3
// 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,
height: 128,
});
@@ -530,7 +531,9 @@ describe('calculateNodeSize', () => {
const mainOutputCount = 4;
// maxVerticalHandles = 6
// 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', () => {
@@ -539,7 +542,7 @@ describe('calculateNodeSize', () => {
// height = default path, mainInputCount = 1, mainOutputCount = 1
// maxVerticalHandles = 1
// 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,
height: 96,
});
@@ -548,7 +551,7 @@ describe('calculateNodeSize', () => {
it('should handle edge case when mainInputCount and mainOutputCount are 0', () => {
// maxVerticalHandles = max(0,0,1) = 1
// 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,
mainOutputCount: number,
nonMainInputCount: number,
isExperimentalNdvActive: boolean,
): { width: number; height: number } {
const maxVerticalHandles = Math.max(mainInputCount, mainOutputCount, 1);
const height = DEFAULT_NODE_SIZE[1] + Math.max(0, maxVerticalHandles - 2) * GRID_SIZE * 2;
const widthScale = isExperimentalNdvActive ? 1.5 : 1;
if (isConfigurable) {
const portCount = Math.max(NODE_MIN_INPUT_ITEMS_COUNT, nonMainInputCount);
@@ -627,15 +629,16 @@ export function calculateNodeSize(
return {
// Configuration node has extra width so that its centered port aligns to the grid
width:
CONFIGURATION_NODE_RADIUS * 2 +
GRID_SIZE * ((isConfiguration ? 1 : 0) + (portCount - 1) * 3),
(CONFIGURATION_NODE_RADIUS * 2 +
GRID_SIZE * ((isConfiguration ? 1 : 0) + (portCount - 1) * 3)) *
widthScale,
height: isConfiguration ? CONFIGURATION_NODE_SIZE[1] : height,
};
}
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 };
}