mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 01:56:46 +00:00
feat(editor): Make focus panel resizable (no-changelog) (#17289)
This commit is contained in:
@@ -14,13 +14,21 @@ function closestNumber(value: number, divisor: number): number {
|
||||
return n2;
|
||||
}
|
||||
|
||||
function getSize(min: number, virtual: number, gridSize: number): number {
|
||||
const target = closestNumber(virtual, gridSize);
|
||||
if (target >= min && virtual > 0) {
|
||||
return target;
|
||||
function getSize(min: number, virtual: number, gridSize: number, max: number): number {
|
||||
if (virtual <= 0) {
|
||||
return min;
|
||||
}
|
||||
|
||||
return min;
|
||||
const target = closestNumber(virtual, gridSize);
|
||||
|
||||
if (target <= min) {
|
||||
return min;
|
||||
}
|
||||
if (target >= max) {
|
||||
return max;
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
interface ResizeProps {
|
||||
@@ -28,7 +36,9 @@ interface ResizeProps {
|
||||
height?: number;
|
||||
width?: number;
|
||||
minHeight?: number;
|
||||
maxHeight?: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
scale?: number;
|
||||
gridSize?: number;
|
||||
supportedDirections?: Direction[];
|
||||
@@ -41,7 +51,9 @@ const props = withDefaults(defineProps<ResizeProps>(), {
|
||||
height: 0,
|
||||
width: 0,
|
||||
minHeight: 0,
|
||||
maxHeight: Number.POSITIVE_INFINITY,
|
||||
minWidth: 0,
|
||||
maxWidth: Number.POSITIVE_INFINITY,
|
||||
scale: 1,
|
||||
gridSize: 20,
|
||||
outset: false,
|
||||
@@ -109,8 +121,8 @@ const mouseMove = (event: MouseEvent) => {
|
||||
|
||||
state.vHeight.value = state.vHeight.value + deltaHeight;
|
||||
state.vWidth.value = state.vWidth.value + deltaWidth;
|
||||
const height = getSize(props.minHeight, state.vHeight.value, props.gridSize);
|
||||
const width = getSize(props.minWidth, state.vWidth.value, props.gridSize);
|
||||
const height = getSize(props.minHeight, state.vHeight.value, props.gridSize, props.maxHeight);
|
||||
const width = getSize(props.minWidth, state.vWidth.value, props.gridSize, props.maxWidth);
|
||||
|
||||
const dX = left && width !== props.width ? -1 * (width - props.width) : 0;
|
||||
const dY = top && height !== props.height ? -1 * (height - props.height) : 0;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useFocusPanelStore } from '@/stores/focusPanel.store';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { N8nText, N8nInput } from '@n8n/design-system';
|
||||
import { N8nText, N8nInput, N8nResizeWrapper } from '@n8n/design-system';
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import {
|
||||
@@ -26,7 +26,8 @@ import { useEnvironmentsStore } from '@/stores/environments.ee.store';
|
||||
import { useDebounce } from '@/composables/useDebounce';
|
||||
import { htmlEditorEventBus } from '@/event-bus';
|
||||
import { hasFocusOnInput, isFocusableEl } from '@/utils/typesUtils';
|
||||
import type { TargetNodeParameterContext } from '@/Interface';
|
||||
import type { ResizeData, TargetNodeParameterContext } from '@/Interface';
|
||||
import { useThrottleFn } from '@vueuse/core';
|
||||
|
||||
defineOptions({ name: 'FocusPanel' });
|
||||
|
||||
@@ -58,6 +59,7 @@ const resolvedParameter = computed(() =>
|
||||
);
|
||||
|
||||
const focusPanelActive = computed(() => focusPanelStore.focusPanelActive);
|
||||
const focusPanelWidth = computed(() => focusPanelStore.focusPanelWidth);
|
||||
|
||||
const isDisabled = computed(() => {
|
||||
if (!resolvedParameter.value) return false;
|
||||
@@ -244,153 +246,177 @@ function optionSelected(command: string) {
|
||||
}
|
||||
|
||||
const valueChangedDebounced = debounce(valueChanged, { debounceTime: 0 });
|
||||
|
||||
function onResize(event: ResizeData) {
|
||||
focusPanelStore.updateWidth(event.width);
|
||||
}
|
||||
|
||||
const onResizeThrottle = useThrottleFn(onResize, 10);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="focusPanelActive" :class="$style.container" @keydown.stop>
|
||||
<div :class="$style.header">
|
||||
<N8nText size="small" :bold="true">
|
||||
{{ locale.baseText('nodeView.focusPanel.title') }}
|
||||
</N8nText>
|
||||
<div :class="$style.closeButton" @click="focusPanelStore.closeFocusPanel">
|
||||
<n8n-icon icon="arrow-right" color="text-base" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="resolvedParameter" :class="$style.content">
|
||||
<div :class="$style.tabHeader">
|
||||
<div :class="$style.tabHeaderText">
|
||||
<N8nText color="text-dark" size="small">
|
||||
{{ resolvedParameter.parameter.displayName }}
|
||||
<div v-if="focusPanelActive" :class="$style.wrapper" @keydown.stop>
|
||||
<N8nResizeWrapper
|
||||
:width="focusPanelWidth"
|
||||
:supported-directions="['left']"
|
||||
:min-width="300"
|
||||
:max-width="1000"
|
||||
:grid-size="8"
|
||||
:style="{ width: `${focusPanelWidth}px` }"
|
||||
@resize="onResizeThrottle"
|
||||
>
|
||||
<div :class="$style.container">
|
||||
<div :class="$style.header">
|
||||
<N8nText size="small" :bold="true">
|
||||
{{ locale.baseText('nodeView.focusPanel.title') }}
|
||||
</N8nText>
|
||||
<N8nText color="text-base" size="xsmall">{{ resolvedParameter.node.name }}</N8nText>
|
||||
<div :class="$style.closeButton" @click="focusPanelStore.closeFocusPanel">
|
||||
<n8n-icon icon="arrow-right" color="text-base" />
|
||||
</div>
|
||||
</div>
|
||||
<NodeExecuteButton
|
||||
data-test-id="node-execute-button"
|
||||
:node-name="resolvedParameter.node.name"
|
||||
:tooltip="`Execute ${resolvedParameter.node.name}`"
|
||||
:disabled="!isExecutable"
|
||||
size="small"
|
||||
icon="play"
|
||||
:square="true"
|
||||
:hide-label="true"
|
||||
telemetry-source="focus"
|
||||
></NodeExecuteButton>
|
||||
</div>
|
||||
<div :class="$style.parameterDetailsWrapper">
|
||||
<div :class="$style.parameterOptionsWrapper">
|
||||
<div></div>
|
||||
<ParameterOptions
|
||||
v-if="isDisplayed"
|
||||
:parameter="resolvedParameter.parameter"
|
||||
:value="resolvedParameter.value"
|
||||
:is-read-only="isReadOnly"
|
||||
@update:model-value="optionSelected"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="typeof resolvedParameter.value === 'string'" :class="$style.editorContainer">
|
||||
<div v-if="!isDisplayed" :class="[$style.content, $style.emptyContent]">
|
||||
<div :class="$style.emptyText">
|
||||
<N8nText color="text-base">
|
||||
{{ locale.baseText('nodeView.focusPanel.missingParameter') }}
|
||||
<div v-if="resolvedParameter" :class="$style.content">
|
||||
<div :class="$style.tabHeader">
|
||||
<div :class="$style.tabHeaderText">
|
||||
<N8nText color="text-dark" size="small">
|
||||
{{ resolvedParameter.parameter.displayName }}
|
||||
</N8nText>
|
||||
<N8nText color="text-base" size="xsmall">{{ resolvedParameter.node.name }}</N8nText>
|
||||
</div>
|
||||
<NodeExecuteButton
|
||||
data-test-id="node-execute-button"
|
||||
:node-name="resolvedParameter.node.name"
|
||||
:tooltip="`Execute ${resolvedParameter.node.name}`"
|
||||
:disabled="!isExecutable"
|
||||
size="small"
|
||||
icon="play"
|
||||
:square="true"
|
||||
:hide-label="true"
|
||||
telemetry-source="focus"
|
||||
></NodeExecuteButton>
|
||||
</div>
|
||||
<div :class="$style.parameterDetailsWrapper">
|
||||
<div :class="$style.parameterOptionsWrapper">
|
||||
<div></div>
|
||||
<ParameterOptions
|
||||
v-if="isDisplayed"
|
||||
:parameter="resolvedParameter.parameter"
|
||||
:value="resolvedParameter.value"
|
||||
:is-read-only="isReadOnly"
|
||||
@update:model-value="optionSelected"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="typeof resolvedParameter.value === 'string'" :class="$style.editorContainer">
|
||||
<div v-if="!isDisplayed" :class="[$style.content, $style.emptyContent]">
|
||||
<div :class="$style.emptyText">
|
||||
<N8nText color="text-base">
|
||||
{{ locale.baseText('nodeView.focusPanel.missingParameter') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</div>
|
||||
<ExpressionEditorModalInput
|
||||
v-else-if="expressionModeEnabled"
|
||||
ref="inputField"
|
||||
:model-value="resolvedParameter.value"
|
||||
:class="$style.editor"
|
||||
:is-read-only="isReadOnly"
|
||||
:path="resolvedParameter.parameterPath"
|
||||
data-test-id="expression-modal-input"
|
||||
:target-node-parameter-context="targetNodeParameterContext"
|
||||
@change="valueChangedDebounced($event.value)"
|
||||
/>
|
||||
<template v-else-if="['json', 'string'].includes(resolvedParameter.parameter.type)">
|
||||
<CodeNodeEditor
|
||||
v-if="editorType === 'codeNodeEditor'"
|
||||
:id="resolvedParameter.parameterPath"
|
||||
:mode="codeEditorMode"
|
||||
:model-value="resolvedParameter.value"
|
||||
:default-value="resolvedParameter.parameter.default"
|
||||
:language="editorLanguage"
|
||||
:is-read-only="isReadOnly"
|
||||
:target-node-parameter-context="targetNodeParameterContext"
|
||||
fill-parent
|
||||
:disable-ask-ai="true"
|
||||
@update:model-value="valueChangedDebounced" />
|
||||
<HtmlEditor
|
||||
v-else-if="editorType === 'htmlEditor'"
|
||||
:model-value="resolvedParameter.value"
|
||||
:is-read-only="isReadOnly"
|
||||
:rows="editorRows"
|
||||
:disable-expression-coloring="!isHtmlNode"
|
||||
:disable-expression-completions="!isHtmlNode"
|
||||
fullscreen
|
||||
@update:model-value="valueChangedDebounced" />
|
||||
<CssEditor
|
||||
v-else-if="editorType === 'cssEditor'"
|
||||
:model-value="resolvedParameter.value"
|
||||
:is-read-only="isReadOnly"
|
||||
:rows="editorRows"
|
||||
fullscreen
|
||||
@update:model-value="valueChangedDebounced" />
|
||||
<SqlEditor
|
||||
v-else-if="editorType === 'sqlEditor'"
|
||||
:model-value="resolvedParameter.value"
|
||||
:dialect="getTypeOption('sqlDialect')"
|
||||
:is-read-only="isReadOnly"
|
||||
:rows="editorRows"
|
||||
fullscreen
|
||||
@update:model-value="valueChangedDebounced" />
|
||||
<JsEditor
|
||||
v-else-if="editorType === 'jsEditor'"
|
||||
:model-value="resolvedParameter.value"
|
||||
:is-read-only="isReadOnly"
|
||||
:rows="editorRows"
|
||||
:posthog-capture="shouldCaptureForPosthog"
|
||||
fill-parent
|
||||
@update:model-value="valueChangedDebounced" />
|
||||
<JsonEditor
|
||||
v-else-if="resolvedParameter.parameter.type === 'json'"
|
||||
:model-value="resolvedParameter.value"
|
||||
:is-read-only="isReadOnly"
|
||||
:rows="editorRows"
|
||||
fullscreen
|
||||
fill-parent
|
||||
@update:model-value="valueChangedDebounced" />
|
||||
<N8nInput
|
||||
v-else
|
||||
ref="inputField"
|
||||
:model-value="resolvedParameter.value"
|
||||
:class="$style.editor"
|
||||
:readonly="isReadOnly"
|
||||
type="textarea"
|
||||
resize="none"
|
||||
@update:model-value="valueChangedDebounced"
|
||||
></N8nInput
|
||||
></template>
|
||||
</div>
|
||||
</div>
|
||||
<ExpressionEditorModalInput
|
||||
v-else-if="expressionModeEnabled"
|
||||
ref="inputField"
|
||||
:model-value="resolvedParameter.value"
|
||||
:class="$style.editor"
|
||||
:is-read-only="isReadOnly"
|
||||
:path="resolvedParameter.parameterPath"
|
||||
data-test-id="expression-modal-input"
|
||||
:target-node-parameter-context="targetNodeParameterContext"
|
||||
@change="valueChangedDebounced($event.value)"
|
||||
/>
|
||||
<template v-else-if="['json', 'string'].includes(resolvedParameter.parameter.type)">
|
||||
<CodeNodeEditor
|
||||
v-if="editorType === 'codeNodeEditor'"
|
||||
:id="resolvedParameter.parameterPath"
|
||||
:mode="codeEditorMode"
|
||||
:model-value="resolvedParameter.value"
|
||||
:default-value="resolvedParameter.parameter.default"
|
||||
:language="editorLanguage"
|
||||
:is-read-only="isReadOnly"
|
||||
:target-node-parameter-context="targetNodeParameterContext"
|
||||
fill-parent
|
||||
:disable-ask-ai="true"
|
||||
@update:model-value="valueChangedDebounced" />
|
||||
<HtmlEditor
|
||||
v-else-if="editorType === 'htmlEditor'"
|
||||
:model-value="resolvedParameter.value"
|
||||
:is-read-only="isReadOnly"
|
||||
:rows="editorRows"
|
||||
:disable-expression-coloring="!isHtmlNode"
|
||||
:disable-expression-completions="!isHtmlNode"
|
||||
fullscreen
|
||||
@update:model-value="valueChangedDebounced" />
|
||||
<CssEditor
|
||||
v-else-if="editorType === 'cssEditor'"
|
||||
:model-value="resolvedParameter.value"
|
||||
:is-read-only="isReadOnly"
|
||||
:rows="editorRows"
|
||||
fullscreen
|
||||
@update:model-value="valueChangedDebounced" />
|
||||
<SqlEditor
|
||||
v-else-if="editorType === 'sqlEditor'"
|
||||
:model-value="resolvedParameter.value"
|
||||
:dialect="getTypeOption('sqlDialect')"
|
||||
:is-read-only="isReadOnly"
|
||||
:rows="editorRows"
|
||||
fullscreen
|
||||
@update:model-value="valueChangedDebounced" />
|
||||
<JsEditor
|
||||
v-else-if="editorType === 'jsEditor'"
|
||||
:model-value="resolvedParameter.value"
|
||||
:is-read-only="isReadOnly"
|
||||
:rows="editorRows"
|
||||
:posthog-capture="shouldCaptureForPosthog"
|
||||
fill-parent
|
||||
@update:model-value="valueChangedDebounced" />
|
||||
<JsonEditor
|
||||
v-else-if="resolvedParameter.parameter.type === 'json'"
|
||||
:model-value="resolvedParameter.value"
|
||||
:is-read-only="isReadOnly"
|
||||
:rows="editorRows"
|
||||
fullscreen
|
||||
fill-parent
|
||||
@update:model-value="valueChangedDebounced" />
|
||||
<N8nInput
|
||||
v-else
|
||||
ref="inputField"
|
||||
:model-value="resolvedParameter.value"
|
||||
:class="$style.editor"
|
||||
:readonly="isReadOnly"
|
||||
type="textarea"
|
||||
resize="none"
|
||||
@update:model-value="valueChangedDebounced"
|
||||
></N8nInput
|
||||
></template>
|
||||
</div>
|
||||
<div v-else :class="[$style.content, $style.emptyContent]">
|
||||
<div :class="$style.emptyText">
|
||||
<N8nText color="text-base">
|
||||
{{ locale.baseText('nodeView.focusPanel.noParameters') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else :class="[$style.content, $style.emptyContent]">
|
||||
<div :class="$style.emptyText">
|
||||
<N8nText color="text-base">
|
||||
{{ locale.baseText('nodeView.focusPanel.noParameters') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</div>
|
||||
</N8nResizeWrapper>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 528px;
|
||||
flex-direction: row nowrap;
|
||||
border-left: 1px solid var(--color-foreground-base);
|
||||
background: var(--color-foreground-light);
|
||||
overflow-y: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.closeButton:hover {
|
||||
|
||||
@@ -13,6 +13,8 @@ import { useWorkflowsStore } from './workflows.store';
|
||||
import { LOCAL_STORAGE_FOCUS_PANEL, PLACEHOLDER_EMPTY_WORKFLOW_ID } from '@/constants';
|
||||
import { useStorage } from '@/composables/useStorage';
|
||||
|
||||
const DEFAULT_PANEL_WIDTH = 528;
|
||||
|
||||
type FocusedNodeParameter = {
|
||||
nodeId: string;
|
||||
parameter: INodeProperties;
|
||||
@@ -27,6 +29,7 @@ export type RichFocusedNodeParameter = FocusedNodeParameter & {
|
||||
type FocusPanelData = {
|
||||
isActive: boolean;
|
||||
parameters: FocusedNodeParameter[];
|
||||
width?: number;
|
||||
};
|
||||
|
||||
type FocusPanelDataByWid = Record<string, FocusPanelData>;
|
||||
@@ -53,6 +56,7 @@ export const useFocusPanelStore = defineStore(STORES.FOCUS_PANEL, () => {
|
||||
);
|
||||
|
||||
const focusPanelActive = computed(() => currentFocusPanelData.value.isActive);
|
||||
const focusPanelWidth = computed(() => currentFocusPanelData.value.width ?? DEFAULT_PANEL_WIDTH);
|
||||
const _focusedNodeParameters = computed(() => currentFocusPanelData.value.parameters);
|
||||
|
||||
// An unenriched parameter indicates a missing nodeId
|
||||
@@ -74,11 +78,13 @@ export const useFocusPanelStore = defineStore(STORES.FOCUS_PANEL, () => {
|
||||
parameters,
|
||||
isActive,
|
||||
wid = workflowsStore.workflowId,
|
||||
width = undefined,
|
||||
removeEmpty = false,
|
||||
}: {
|
||||
isActive?: boolean;
|
||||
parameters?: FocusedNodeParameter[];
|
||||
wid?: string;
|
||||
width?: number;
|
||||
removeEmpty?: boolean;
|
||||
}) {
|
||||
const focusPanelDataCurrent = focusPanelData.value;
|
||||
@@ -92,6 +98,7 @@ export const useFocusPanelStore = defineStore(STORES.FOCUS_PANEL, () => {
|
||||
[wid]: {
|
||||
isActive: isActive ?? focusPanelActive.value,
|
||||
parameters: parameters ?? _focusedNodeParameters.value,
|
||||
width,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -127,6 +134,10 @@ export const useFocusPanelStore = defineStore(STORES.FOCUS_PANEL, () => {
|
||||
_setOptions({ isActive: !focusPanelActive.value });
|
||||
}
|
||||
|
||||
function updateWidth(width: number) {
|
||||
_setOptions({ width });
|
||||
}
|
||||
|
||||
function isRichParameter(
|
||||
p: RichFocusedNodeParameter | FocusedNodeParameter,
|
||||
): p is RichFocusedNodeParameter {
|
||||
@@ -141,5 +152,7 @@ export const useFocusPanelStore = defineStore(STORES.FOCUS_PANEL, () => {
|
||||
closeFocusPanel,
|
||||
toggleFocusPanel,
|
||||
onNewWorkflowSave,
|
||||
updateWidth,
|
||||
focusPanelWidth,
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user