feat(editor): Make focus panel resizable (no-changelog) (#17289)

This commit is contained in:
Daria
2025-07-15 11:20:26 +03:00
committed by GitHub
parent 5bb5a65edf
commit c1b008090f
3 changed files with 189 additions and 138 deletions

View File

@@ -14,13 +14,21 @@ function closestNumber(value: number, divisor: number): number {
return n2; return n2;
} }
function getSize(min: number, virtual: number, gridSize: number): number { function getSize(min: number, virtual: number, gridSize: number, max: number): number {
const target = closestNumber(virtual, gridSize); if (virtual <= 0) {
if (target >= min && virtual > 0) { return min;
return target;
} }
return min; const target = closestNumber(virtual, gridSize);
if (target <= min) {
return min;
}
if (target >= max) {
return max;
}
return target;
} }
interface ResizeProps { interface ResizeProps {
@@ -28,7 +36,9 @@ interface ResizeProps {
height?: number; height?: number;
width?: number; width?: number;
minHeight?: number; minHeight?: number;
maxHeight?: number;
minWidth?: number; minWidth?: number;
maxWidth?: number;
scale?: number; scale?: number;
gridSize?: number; gridSize?: number;
supportedDirections?: Direction[]; supportedDirections?: Direction[];
@@ -41,7 +51,9 @@ const props = withDefaults(defineProps<ResizeProps>(), {
height: 0, height: 0,
width: 0, width: 0,
minHeight: 0, minHeight: 0,
maxHeight: Number.POSITIVE_INFINITY,
minWidth: 0, minWidth: 0,
maxWidth: Number.POSITIVE_INFINITY,
scale: 1, scale: 1,
gridSize: 20, gridSize: 20,
outset: false, outset: false,
@@ -109,8 +121,8 @@ const mouseMove = (event: MouseEvent) => {
state.vHeight.value = state.vHeight.value + deltaHeight; state.vHeight.value = state.vHeight.value + deltaHeight;
state.vWidth.value = state.vWidth.value + deltaWidth; state.vWidth.value = state.vWidth.value + deltaWidth;
const height = getSize(props.minHeight, state.vHeight.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); const width = getSize(props.minWidth, state.vWidth.value, props.gridSize, props.maxWidth);
const dX = left && width !== props.width ? -1 * (width - props.width) : 0; const dX = left && width !== props.width ? -1 * (width - props.width) : 0;
const dY = top && height !== props.height ? -1 * (height - props.height) : 0; const dY = top && height !== props.height ? -1 * (height - props.height) : 0;

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useFocusPanelStore } from '@/stores/focusPanel.store'; import { useFocusPanelStore } from '@/stores/focusPanel.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.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 { computed, nextTick, ref } from 'vue';
import { useI18n } from '@n8n/i18n'; import { useI18n } from '@n8n/i18n';
import { import {
@@ -26,7 +26,8 @@ import { useEnvironmentsStore } from '@/stores/environments.ee.store';
import { useDebounce } from '@/composables/useDebounce'; import { useDebounce } from '@/composables/useDebounce';
import { htmlEditorEventBus } from '@/event-bus'; import { htmlEditorEventBus } from '@/event-bus';
import { hasFocusOnInput, isFocusableEl } from '@/utils/typesUtils'; 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' }); defineOptions({ name: 'FocusPanel' });
@@ -58,6 +59,7 @@ const resolvedParameter = computed(() =>
); );
const focusPanelActive = computed(() => focusPanelStore.focusPanelActive); const focusPanelActive = computed(() => focusPanelStore.focusPanelActive);
const focusPanelWidth = computed(() => focusPanelStore.focusPanelWidth);
const isDisabled = computed(() => { const isDisabled = computed(() => {
if (!resolvedParameter.value) return false; if (!resolvedParameter.value) return false;
@@ -244,153 +246,177 @@ function optionSelected(command: string) {
} }
const valueChangedDebounced = debounce(valueChanged, { debounceTime: 0 }); const valueChangedDebounced = debounce(valueChanged, { debounceTime: 0 });
function onResize(event: ResizeData) {
focusPanelStore.updateWidth(event.width);
}
const onResizeThrottle = useThrottleFn(onResize, 10);
</script> </script>
<template> <template>
<div v-if="focusPanelActive" :class="$style.container" @keydown.stop> <div v-if="focusPanelActive" :class="$style.wrapper" @keydown.stop>
<div :class="$style.header"> <N8nResizeWrapper
<N8nText size="small" :bold="true"> :width="focusPanelWidth"
{{ locale.baseText('nodeView.focusPanel.title') }} :supported-directions="['left']"
</N8nText> :min-width="300"
<div :class="$style.closeButton" @click="focusPanelStore.closeFocusPanel"> :max-width="1000"
<n8n-icon icon="arrow-right" color="text-base" /> :grid-size="8"
</div> :style="{ width: `${focusPanelWidth}px` }"
</div> @resize="onResizeThrottle"
<div v-if="resolvedParameter" :class="$style.content"> >
<div :class="$style.tabHeader"> <div :class="$style.container">
<div :class="$style.tabHeaderText"> <div :class="$style.header">
<N8nText color="text-dark" size="small"> <N8nText size="small" :bold="true">
{{ resolvedParameter.parameter.displayName }} {{ locale.baseText('nodeView.focusPanel.title') }}
</N8nText> </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> </div>
<NodeExecuteButton <div v-if="resolvedParameter" :class="$style.content">
data-test-id="node-execute-button" <div :class="$style.tabHeader">
:node-name="resolvedParameter.node.name" <div :class="$style.tabHeaderText">
:tooltip="`Execute ${resolvedParameter.node.name}`" <N8nText color="text-dark" size="small">
:disabled="!isExecutable" {{ resolvedParameter.parameter.displayName }}
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> </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>
</div> </div>
<ExpressionEditorModalInput </div>
v-else-if="expressionModeEnabled" <div v-else :class="[$style.content, $style.emptyContent]">
ref="inputField" <div :class="$style.emptyText">
:model-value="resolvedParameter.value" <N8nText color="text-base">
:class="$style.editor" {{ locale.baseText('nodeView.focusPanel.noParameters') }}
:is-read-only="isReadOnly" </N8nText>
:path="resolvedParameter.parameterPath" </div>
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>
</div> </div>
</div> </N8nResizeWrapper>
<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>
</template> </template>
<style lang="scss" module> <style lang="scss" module>
.container { .wrapper {
display: flex; display: flex;
flex-direction: column; flex-direction: row nowrap;
width: 528px;
border-left: 1px solid var(--color-foreground-base); border-left: 1px solid var(--color-foreground-base);
background: var(--color-foreground-light); background: var(--color-foreground-light);
overflow-y: hidden; overflow-y: hidden;
height: 100%;
}
.container {
display: flex;
flex-direction: column;
height: 100%;
} }
.closeButton:hover { .closeButton:hover {

View File

@@ -13,6 +13,8 @@ import { useWorkflowsStore } from './workflows.store';
import { LOCAL_STORAGE_FOCUS_PANEL, PLACEHOLDER_EMPTY_WORKFLOW_ID } from '@/constants'; import { LOCAL_STORAGE_FOCUS_PANEL, PLACEHOLDER_EMPTY_WORKFLOW_ID } from '@/constants';
import { useStorage } from '@/composables/useStorage'; import { useStorage } from '@/composables/useStorage';
const DEFAULT_PANEL_WIDTH = 528;
type FocusedNodeParameter = { type FocusedNodeParameter = {
nodeId: string; nodeId: string;
parameter: INodeProperties; parameter: INodeProperties;
@@ -27,6 +29,7 @@ export type RichFocusedNodeParameter = FocusedNodeParameter & {
type FocusPanelData = { type FocusPanelData = {
isActive: boolean; isActive: boolean;
parameters: FocusedNodeParameter[]; parameters: FocusedNodeParameter[];
width?: number;
}; };
type FocusPanelDataByWid = Record<string, FocusPanelData>; type FocusPanelDataByWid = Record<string, FocusPanelData>;
@@ -53,6 +56,7 @@ export const useFocusPanelStore = defineStore(STORES.FOCUS_PANEL, () => {
); );
const focusPanelActive = computed(() => currentFocusPanelData.value.isActive); const focusPanelActive = computed(() => currentFocusPanelData.value.isActive);
const focusPanelWidth = computed(() => currentFocusPanelData.value.width ?? DEFAULT_PANEL_WIDTH);
const _focusedNodeParameters = computed(() => currentFocusPanelData.value.parameters); const _focusedNodeParameters = computed(() => currentFocusPanelData.value.parameters);
// An unenriched parameter indicates a missing nodeId // An unenriched parameter indicates a missing nodeId
@@ -74,11 +78,13 @@ export const useFocusPanelStore = defineStore(STORES.FOCUS_PANEL, () => {
parameters, parameters,
isActive, isActive,
wid = workflowsStore.workflowId, wid = workflowsStore.workflowId,
width = undefined,
removeEmpty = false, removeEmpty = false,
}: { }: {
isActive?: boolean; isActive?: boolean;
parameters?: FocusedNodeParameter[]; parameters?: FocusedNodeParameter[];
wid?: string; wid?: string;
width?: number;
removeEmpty?: boolean; removeEmpty?: boolean;
}) { }) {
const focusPanelDataCurrent = focusPanelData.value; const focusPanelDataCurrent = focusPanelData.value;
@@ -92,6 +98,7 @@ export const useFocusPanelStore = defineStore(STORES.FOCUS_PANEL, () => {
[wid]: { [wid]: {
isActive: isActive ?? focusPanelActive.value, isActive: isActive ?? focusPanelActive.value,
parameters: parameters ?? _focusedNodeParameters.value, parameters: parameters ?? _focusedNodeParameters.value,
width,
}, },
}); });
} }
@@ -127,6 +134,10 @@ export const useFocusPanelStore = defineStore(STORES.FOCUS_PANEL, () => {
_setOptions({ isActive: !focusPanelActive.value }); _setOptions({ isActive: !focusPanelActive.value });
} }
function updateWidth(width: number) {
_setOptions({ width });
}
function isRichParameter( function isRichParameter(
p: RichFocusedNodeParameter | FocusedNodeParameter, p: RichFocusedNodeParameter | FocusedNodeParameter,
): p is RichFocusedNodeParameter { ): p is RichFocusedNodeParameter {
@@ -141,5 +152,7 @@ export const useFocusPanelStore = defineStore(STORES.FOCUS_PANEL, () => {
closeFocusPanel, closeFocusPanel,
toggleFocusPanel, toggleFocusPanel,
onNewWorkflowSave, onNewWorkflowSave,
updateWidth,
focusPanelWidth,
}; };
}); });