mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
chore(editor): The zoomed view misc updates (#17865)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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="[
|
||||
|
||||
@@ -26,6 +26,11 @@ beforeEach(() => {
|
||||
},
|
||||
isInputPanelEmpty: false,
|
||||
isOutputPanelEmpty: false,
|
||||
ndvInputDataWithPinnedData: [],
|
||||
getHoveringItem: undefined,
|
||||
expressionOutputItemIndex: 0,
|
||||
isTableHoverOnboarded: false,
|
||||
setHighlightDraggables: vi.fn(),
|
||||
};
|
||||
mockNodeTypesState = {
|
||||
allNodeTypes: [],
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -74,6 +74,7 @@ const nodeSize = computed(() =>
|
||||
mainInputs.value.length,
|
||||
mainOutputs.value.length,
|
||||
nonMainInputs.value.length,
|
||||
isExperimentalNdvActive.value,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user