feat(editor-ui): Resizable main panel (#3980)

* Introduce node deprecation (#3930)

 Introduce node deprecation

* 🚧 Scaffold out Code node

* 👕 Fix lint

* 📘 Create types file

* 🚚 Rename theme

* 🔥 Remove unneeded prop

*  Override keybindings

*  Expand lintings

*  Create editor content getter

* 🚚 Ensure all helpers use `$`

*  Add autocompletion

* ♻️ Refactore Resize UI lib component, allow to use it in different than n8n-sticky context

* 🚧 Use variable width for node settings and allow for resizing

*  Use store to keep track of wide and regular main panel widths

* ♻️ Extract Resize wrapper from the Sticky and create a story for it

* 🐛 Fixed cherry-pick conflicts

*  Filter out welcome note node

*  Convey error line number

*  Highlight error line

*  Restore logging from node

*  More autocompletions

*  Streamline completions

* 💄 Fix drag-button border

* ✏️ Update placeholders

*  Update linter to new methods

*  Preserve main panel width in local storage

* 🐛 Fallback to max size size if window is too big

* 🔥 Remove `$nodeItem` completions

*  Re-update placeholders

* 🎨 Fix formatting

* 📦 Update `package-lock.json`

*  Refresh with multi-line empty string

* ♻️ Refactored DraggablePanels to use relative units and implemented independent resizing, cleaned store

* 🐛 Re-implement dragging indicators and move border styles to NDVDraggablePanels component

* 🚨 Fix semis

* 🚨 Remove unsused UI state props

* ♻️ Use only relative left position and calculate right based on it, fix quirks

* 🚨Fix linting error

* ♻️ Store and retrieve main panel dimensions from store to make them persistable in the same app mount session

* 🐛 Prevent resizing of unknown nodes

* ♻️ Add typings for `nodeType` prop, remove unused `convertRemToPixels` import

* 🏷️ Add typings for `nodeType` prop in NodeSettings.vue

* 🐛 Prevent the main panel resize below 280px

* 🐛 Fix inputless panel left position

*  Resize resource locator on main panel size change

* 🐛 Resize resource locator on window resize

Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
This commit is contained in:
OlegIvaniv 2022-09-22 17:41:15 +02:00 committed by GitHub
parent 8eeed77edb
commit d01f7d4d93
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 510 additions and 182 deletions

62
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "n8n",
"version": "0.194.0",
"version": "0.195.3",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "n8n",
"version": "0.194.0",
"version": "0.195.3",
"workspaces": [
"packages/*",
"packages/@n8n_io/*"
@ -52142,7 +52142,7 @@
},
"packages/cli": {
"name": "n8n",
"version": "0.194.0",
"version": "0.195.3",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"@oclif/command": "^1.5.18",
@ -52186,10 +52186,10 @@
"lodash.split": "^4.4.2",
"lodash.unset": "^4.5.2",
"mysql2": "~2.3.0",
"n8n-core": "~0.134.0",
"n8n-editor-ui": "~0.160.0",
"n8n-nodes-base": "~0.192.0",
"n8n-workflow": "~0.116.0",
"n8n-core": "~0.135.0",
"n8n-editor-ui": "~0.161.1",
"n8n-nodes-base": "~0.193.1",
"n8n-workflow": "~0.117.0",
"nodemailer": "^6.7.1",
"oauth-1.0a": "^2.2.6",
"open": "^7.0.0",
@ -52268,7 +52268,7 @@
},
"packages/core": {
"name": "n8n-core",
"version": "0.134.0",
"version": "0.135.0",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"axios": "^0.21.1",
@ -52280,7 +52280,7 @@
"form-data": "^4.0.0",
"lodash.get": "^4.4.2",
"mime-types": "^2.1.27",
"n8n-workflow": "~0.116.0",
"n8n-workflow": "~0.117.0",
"oauth-1.0a": "^2.2.6",
"p-cancelable": "^2.0.0",
"qs": "^6.10.1",
@ -52367,7 +52367,7 @@
},
"packages/design-system": {
"name": "n8n-design-system",
"version": "0.34.0",
"version": "0.35.0",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"element-ui": "~2.15.7",
@ -52434,14 +52434,14 @@
},
"packages/editor-ui": {
"name": "n8n-editor-ui",
"version": "0.160.0",
"version": "0.161.1",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"@fontsource/open-sans": "^4.5.0",
"@fortawesome/free-regular-svg-icons": "^6.1.1",
"luxon": "^2.3.0",
"monaco-editor": "^0.30.1",
"n8n-design-system": "~0.34.0",
"n8n-design-system": "~0.35.0",
"timeago.js": "^4.0.2",
"v-click-outside": "^3.1.2",
"vue-fragment": "1.5.1",
@ -52487,7 +52487,7 @@
"lodash.get": "^4.4.2",
"lodash.set": "^4.3.2",
"monaco-editor-webpack-plugin": "^5.0.0",
"n8n-workflow": "~0.116.0",
"n8n-workflow": "~0.117.0",
"normalize-wheel": "^1.0.1",
"prismjs": "^1.17.1",
"quill": "^2.0.0-dev.3",
@ -52513,7 +52513,7 @@
},
"packages/node-dev": {
"name": "n8n-node-dev",
"version": "0.73.0",
"version": "0.74.0",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"@oclif/command": "^1.5.18",
@ -52521,8 +52521,8 @@
"change-case": "^4.1.1",
"fast-glob": "^3.2.5",
"inquirer": "^7.0.1",
"n8n-core": "~0.134.0",
"n8n-workflow": "~0.116.0",
"n8n-core": "~0.135.0",
"n8n-workflow": "~0.117.0",
"oauth-1.0a": "^2.2.6",
"replace-in-file": "^6.0.0",
"request": "^2.88.2",
@ -52544,7 +52544,7 @@
},
"packages/nodes-base": {
"name": "n8n-nodes-base",
"version": "0.192.0",
"version": "0.193.1",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"@kafkajs/confluent-schema-registry": "1.0.6",
@ -52579,7 +52579,7 @@
"mqtt": "4.2.6",
"mssql": "^8.1.2",
"mysql2": "~2.3.0",
"n8n-core": "~0.134.0",
"n8n-core": "~0.135.0",
"node-html-markdown": "^1.1.3",
"node-ssh": "^12.0.0",
"nodemailer": "^6.7.1",
@ -52636,7 +52636,7 @@
"eslint-plugin-n8n-nodes-base": "^1.9.3",
"gulp": "^4.0.0",
"jest": "^27.4.7",
"n8n-workflow": "~0.116.0",
"n8n-workflow": "~0.117.0",
"ts-jest": "^27.1.3",
"tslint": "^6.1.2",
"typescript": "~4.6.0"
@ -52686,7 +52686,7 @@
},
"packages/workflow": {
"name": "n8n-workflow",
"version": "0.116.0",
"version": "0.117.0",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"@n8n_io/riot-tmpl": "^1.0.1",
@ -81496,10 +81496,10 @@
"lodash.split": "^4.4.2",
"lodash.unset": "^4.5.2",
"mysql2": "~2.3.0",
"n8n-core": "~0.134.0",
"n8n-editor-ui": "~0.160.0",
"n8n-nodes-base": "~0.192.0",
"n8n-workflow": "~0.116.0",
"n8n-core": "~0.135.0",
"n8n-editor-ui": "~0.161.1",
"n8n-nodes-base": "~0.193.1",
"n8n-workflow": "~0.117.0",
"nodemailer": "^6.7.1",
"nodemon": "^2.0.2",
"oauth-1.0a": "^2.2.6",
@ -81553,7 +81553,7 @@
"jest": "^27.4.7",
"lodash.get": "^4.4.2",
"mime-types": "^2.1.27",
"n8n-workflow": "~0.116.0",
"n8n-workflow": "~0.117.0",
"oauth-1.0a": "^2.2.6",
"p-cancelable": "^2.0.0",
"qs": "^6.10.1",
@ -81699,8 +81699,8 @@
"luxon": "^2.3.0",
"monaco-editor": "^0.30.1",
"monaco-editor-webpack-plugin": "^5.0.0",
"n8n-design-system": "~0.34.0",
"n8n-workflow": "~0.116.0",
"n8n-design-system": "~0.35.0",
"n8n-workflow": "~0.117.0",
"normalize-wheel": "^1.0.1",
"prismjs": "^1.17.1",
"quill": "^2.0.0-dev.3",
@ -81746,8 +81746,8 @@
"change-case": "^4.1.1",
"fast-glob": "^3.2.5",
"inquirer": "^7.0.1",
"n8n-core": "~0.134.0",
"n8n-workflow": "~0.116.0",
"n8n-core": "~0.135.0",
"n8n-workflow": "~0.117.0",
"oauth-1.0a": "^2.2.6",
"replace-in-file": "^6.0.0",
"request": "^2.88.2",
@ -81824,8 +81824,8 @@
"mqtt": "4.2.6",
"mssql": "^8.1.2",
"mysql2": "~2.3.0",
"n8n-core": "~0.134.0",
"n8n-workflow": "~0.116.0",
"n8n-core": "~0.135.0",
"n8n-workflow": "~0.117.0",
"node-html-markdown": "^1.1.3",
"node-ssh": "^12.0.0",
"nodemailer": "^6.7.1",

View file

@ -0,0 +1,77 @@
import { action } from '@storybook/addon-actions';
import N8nResizeWrapper from './ResizeWrapper.vue';
export default {
title: 'Atoms/ResizeWrapper',
component: N8nResizeWrapper,
};
const methods = {
onInput: action('input'),
onResize(resizeData) {
action('resize', resizeData);
this.newHeight = resizeData.height;
this.newWidth = resizeData.width;
},
onResizeEnd: action('resizeend'),
onResizeStart: action('resizestart'),
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: {
N8nResizeWrapper,
},
data() {
return {
newWidth: this.width,
newHeight: this.height,
background: "linear-gradient(90deg, rgba(255,0,0,1) 0%, rgba(255,154,0,1) 10%, rgba(208,222,33,1) 20%, rgba(79,220,74,1) 30%, rgba(63,218,216,1) 40%, rgba(47,201,226,1) 50%, rgba(28,127,238,1) 60%, rgba(95,21,242,1) 70%, rgba(186,12,248,1) 80%, rgba(251,7,217,1) 90%, rgba(255,0,0,1) 100%)",
};
},
computed: {
containerStyles() {
return {
width: `${this.newWidth}px`,
height: `${this.newHeight}px`,
background: this.background,
};
},
},
template:
`<div style="width: fit-content; height: fit-content">
<n8n-resize-wrapper
v-bind="$props"
@resize="onResize"
@resizeend="onResizeEnd"
@resizestart="onResizeStart"
@input="onInput"
:width="newWidth"
:height="newHeight"
>
<div :style="containerStyles" />
</n8n-resize-wrapper>
</div>`,
methods,
});
export const Resize = Template.bind({});
Resize.args = {
width: 200,
height: 200,
minWidth: 200,
minHeight: 200,
scale: 1,
gridSize: 20,
isResizingEnabled: true,
supportedDirections: [
"right",
"top",
"bottom",
"left",
"topLeft",
"topRight",
"bottomLeft",
"bottomRight",
],
};

View file

@ -1,34 +1,24 @@
<template>
<div :class="$style.resize">
<div v-if="isResizingEnabled" @mousedown="resizerMove" data-dir="right" :class="[$style.resizer, $style.right]" />
<div v-if="isResizingEnabled" @mousedown="resizerMove" data-dir="left" :class="[$style.resizer, $style.left]" />
<div v-if="isResizingEnabled" @mousedown="resizerMove" data-dir="top" :class="[$style.resizer, $style.top]" />
<div v-if="isResizingEnabled" @mousedown="resizerMove" data-dir="bottom" :class="[$style.resizer, $style.bottom]" />
<div v-if="isResizingEnabled" @mousedown="resizerMove" data-dir="top-left" :class="[$style.resizer, $style.topLeft]" />
<div v-if="isResizingEnabled" @mousedown="resizerMove" data-dir="top-right" :class="[$style.resizer, $style.topRight]" />
<div v-if="isResizingEnabled" @mousedown="resizerMove" data-dir="bottom-left" :class="[$style.resizer, $style.bottomLeft]" />
<div v-if="isResizingEnabled" @mousedown="resizerMove" data-dir="bottom-right" :class="[$style.resizer, $style.bottomRight]" />
<div
v-for="direction in enabledDirections"
:key="direction"
:data-dir="direction"
:class="[$style.resizer, $style[direction]]"
@mousedown="resizerMove"
/>
<slot></slot>
</div>
</template>
<script lang="ts">
const cursorMap: { [key: string]: string } = {
right: 'ew-resize',
top: 'ns-resize',
bottom: 'ns-resize',
left: 'ew-resize',
'top-left': 'nw-resize',
'top-right' : 'ne-resize',
'bottom-left': 'sw-resize',
'bottom-right': 'se-resize',
};
import Vue from 'vue';
function closestNumber(value: number, divisor: number): number {
let q = value / divisor;
let n1 = divisor * q;
const q = value / divisor;
const n1 = divisor * q;
let n2 = (value * divisor) > 0 ?
const n2 = (value * divisor) > 0 ?
(divisor * (q + 1)) : (divisor * (q - 1));
if (Math.abs(value - n1) < Math.abs(value - n2))
@ -37,7 +27,7 @@ function closestNumber(value: number, divisor: number): number {
return n2;
}
function getSize(delta: number, min: number, virtual: number, gridSize: number): number {
function getSize(min: number, virtual: number, gridSize: number): number {
const target = closestNumber(virtual, gridSize);
if (target >= min && virtual > 0) {
return target;
@ -46,7 +36,16 @@ function getSize(delta: number, min: number, virtual: number, gridSize: number):
return min;
};
import Vue from 'vue';
const directionsCursorMaps: { [key: string]: string } = {
right: 'ew-resize',
top: 'ns-resize',
bottom: 'ns-resize',
left: 'ew-resize',
topLeft: 'nw-resize',
topRight : 'ne-resize',
bottomLeft: 'sw-resize',
bottomRight: 'se-resize',
};
export default Vue.extend({
name: 'n8n-resize',
@ -74,9 +73,14 @@ export default Vue.extend({
gridSize: {
type: Number,
},
supportedDirections: {
type: Array,
default: () => [],
},
},
data() {
return {
directionsCursorMaps,
dir: '',
dHeight: 0,
dWidth: 0,
@ -86,6 +90,16 @@ export default Vue.extend({
y: 0,
};
},
computed: {
enabledDirections() {
const availableDirections = Object.keys(directionsCursorMaps);
if(this.isResizingEnabled === false) return [];
if(this.supportedDirections.length === 0) return availableDirections;
return this.supportedDirections;
},
},
methods: {
resizerMove(event: MouseEvent) {
event.preventDefault();
@ -93,10 +107,10 @@ export default Vue.extend({
const targetResizer = event.target as { dataset: { dir: string } } | null;
if (targetResizer) {
this.dir = targetResizer.dataset.dir;
this.dir = targetResizer.dataset.dir.toLocaleLowerCase();
}
document.body.style.cursor = cursorMap[this.dir];
document.body.style.cursor = directionsCursorMaps[this.dir];
this.x = event.pageX;
this.y = event.pageY;
@ -137,17 +151,20 @@ export default Vue.extend({
this.vHeight = this.vHeight + deltaHeight;
this.vWidth = this.vWidth + deltaWidth;
const height = getSize(deltaHeight, this.minHeight, this.vHeight, this.gridSize);
const width = getSize(deltaWidth, this.minWidth, this.vWidth, this.gridSize);
const height = getSize(this.minHeight, this.vHeight, this.gridSize);
const width = getSize(this.minWidth, this.vWidth, this.gridSize);
const dX = left && width !== this.width ? -1 * (width - this.width) : 0;
const dY = top && height !== this.height ? -1 * (height - this.height): 0;
const x = event.x;
const y = event.y;
const direction = this.dir;
this.$emit('resize', { height, width, dX, dY });
this.$emit('resize', { height, width, dX, dY, x, y, direction });
this.dHeight = dHeight;
this.dWidth = dWidth;
},
mouseUp(event: Event) {
mouseUp(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
this.$emit('resizeend');
@ -162,7 +179,7 @@ export default Vue.extend({
<style lang="scss" module>
.resize {
position: absolute;
position: relative;
width: 100%;
height: 100%;
z-index: 2;
@ -170,7 +187,7 @@ export default Vue.extend({
.resizer {
position: absolute;
z-index: 2;
z-index: 3;
}
.right {
@ -211,7 +228,6 @@ export default Vue.extend({
top: -3px;
left: -3px;
cursor: nw-resize;
z-index: 3;
}
.topRight {
@ -220,7 +236,6 @@ export default Vue.extend({
top: -3px;
right: -3px;
cursor: ne-resize;
z-index: 3;
}
.bottomLeft {
@ -229,7 +244,6 @@ export default Vue.extend({
bottom: -3px;
left: -3px;
cursor: sw-resize;
z-index: 3;
}
.bottomRight {
@ -238,6 +252,5 @@ export default Vue.extend({
bottom: -3px;
right: -3px;
cursor: se-resize;
z-index: 3;
}
</style>

View file

@ -0,0 +1,3 @@
import ResizeWrapper from './ResizeWrapper.vue';
export default ResizeWrapper;

View file

@ -4,7 +4,7 @@
:style="styles"
@keydown.prevent
>
<resize
<n8n-resize-wrapper
:isResizingEnabled="!readOnly"
:height="height"
:width="width"
@ -60,14 +60,14 @@
</n8n-text>
</div>
</template>
</resize>
</n8n-resize-wrapper>
</div>
</template>
<script lang="ts">
import N8nInput from '../N8nInput';
import N8nMarkdown from '../N8nMarkdown';
import Resize from './Resize.vue';
import N8nResizeWrapper from '../N8nResizeWrapper';
import N8nText from '../N8nText';
import Locale from '../../mixins/locale';
import mixins from 'vue-typed-mixins';
@ -121,7 +121,7 @@ export default mixins(Locale).extend({
components: {
N8nInput,
N8nMarkdown,
Resize,
N8nResizeWrapper,
N8nText,
},
data() {

View file

@ -40,6 +40,7 @@ import N8nTree from '../components/N8nTree';
import N8nUserInfo from '../components/N8nUserInfo';
import N8nUserSelect from '../components/N8nUserSelect';
import N8nUsersList from '../components/N8nUsersList';
import N8nResizeWrapper from '../components/N8nResizeWrapper';
export default {
install: (app: typeof Vue, options?: {}) => {
@ -84,5 +85,6 @@ export default {
app.component('n8n-tree', N8nTree);
app.component('n8n-users-list', N8nUsersList);
app.component('n8n-user-select', N8nUserSelect);
app.component('n8n-resize-wrapper', N8nResizeWrapper);
},
};

View file

@ -934,6 +934,7 @@ export interface IUiState {
modals: {
[key: string]: IModalState;
};
mainPanelDimensions: {[key: string]: {[key: string]: number}};
isPageLoading: boolean;
currentView: string;
ndv: {

View file

@ -7,31 +7,61 @@
<slot name="output"></slot>
</div>
<div :class="$style.mainPanel" :style="mainPanelStyles">
<div :class="$style.dragButtonContainer" @click="close">
<n8n-resize-wrapper
:isResizingEnabled="currentNodePaneType !== 'unknown'"
:width="relativeWidthToPx(mainPanelDimensions.relativeWidth)"
:minWidth="MIN_PANEL_WIDTH"
:gridSize="20"
@resize="onResize"
@resizestart="onResizeStart"
@resizeend="onResizeEnd"
:supportedDirections="supportedResizeDirections"
>
<div :class="$style.dragButtonContainer">
<PanelDragButton
:class="{ [$style.draggable]: true, [$style.visible]: isDragging }"
v-if="!hideInputAndOutput && isDraggable"
:canMoveLeft="canMoveLeft"
:canMoveRight="canMoveRight"
v-if="!hideInputAndOutput && isDraggable"
@dragstart="onDragStart"
@drag="onDrag"
@dragend="onDragEnd"
/>
</div>
<slot name="main"></slot>
<div :class="{ [$style.mainPanelInner]: true, [$style.dragging]: isDragging }">
<slot name="main" />
</div>
</n8n-resize-wrapper>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import Vue, { PropType } from 'vue';
import { get } from 'lodash';
import { INodeTypeDescription } from 'n8n-workflow';
import PanelDragButton from './PanelDragButton.vue';
const MAIN_PANEL_WIDTH = 360;
import {
LOCAL_STORAGE_MAIN_PANEL_RELATIVE_WIDTH,
MAIN_NODE_PANEL_WIDTH,
} from '@/constants';
const SIDE_MARGIN = 24;
const FIXED_PANEL_WIDTH = 320;
const FIXED_PANEL_WIDTH_LARGE = 420;
const MINIMUM_INPUT_PANEL_WIDTH = 320;
const SIDE_PANELS_MARGIN = 80;
const MIN_PANEL_WIDTH = 280;
const PANEL_WIDTH = 320;
const PANEL_WIDTH_LARGE = 420;
const initialMainPanelWidth:{ [key: string]: number } = {
regular: MAIN_NODE_PANEL_WIDTH,
dragless: MAIN_NODE_PANEL_WIDTH,
unknown: MAIN_NODE_PANEL_WIDTH,
inputless: MAIN_NODE_PANEL_WIDTH,
wide: MAIN_NODE_PANEL_WIDTH * 2,
};
export default Vue.extend({
name: 'NDVDraggablePanels',
@ -48,124 +78,271 @@ export default Vue.extend({
position: {
type: Number,
},
nodeType: {
type: Object as PropType<INodeTypeDescription>,
default: () => ({}),
},
data() {
},
data(): { windowWidth: number, isDragging: boolean, MIN_PANEL_WIDTH: number} {
return {
windowWidth: 0,
windowWidth: 1,
isDragging: false,
MIN_PANEL_WIDTH,
};
},
mounted() {
this.setTotalWidth();
/*
Only set(or restore) initial position if `mainPanelDimensions`
is at the default state({relativeLeft:1, relativeRight: 1, relativeWidth: 1}) to make sure we use store values if they are set
*/
if(this.mainPanelDimensions.relativeLeft === 1 && this.mainPanelDimensions.relativeRight === 1) {
this.setMainPanelWidth();
this.setPositions(this.getInitialLeftPosition(this.mainPanelDimensions.relativeWidth));
this.restorePositionData();
}
window.addEventListener('resize', this.setTotalWidth);
this.$emit('init', { position: this.getRelativePosition() });
this.$emit('init', { position: this.mainPanelDimensions.relativeLeft });
},
destroyed() {
window.removeEventListener('resize', this.setTotalWidth);
},
computed: {
fixedPanelWidth() {
if (this.windowWidth > 1700) {
return FIXED_PANEL_WIDTH_LARGE;
}
return FIXED_PANEL_WIDTH;
mainPanelDimensions(): {
relativeWidth: number,
relativeLeft: number,
relativeRight: number
} {
return this.$store.getters['ui/mainPanelDimensions'](this.currentNodePaneType);
},
mainPanelPosition(): number {
if (typeof this.position === 'number') {
return this.position;
}
supportedResizeDirections() {
const supportedDirections = ['right'];
if (!this.isDraggable) {
return this.fixedPanelWidth + MAIN_PANEL_WIDTH / 2 + SIDE_MARGIN;
}
const relativePosition = this.$store.getters['ui/mainPanelPosition'] as number;
return relativePosition * this.windowWidth;
if(this.isDraggable) supportedDirections.push('left');
return supportedDirections;
},
currentNodePaneType() {
if(!this.hasInputSlot) return 'inputless';
if(!this.isDraggable) return 'dragless';
if(this.nodeType === null) return 'unknown';
return get(this, 'nodeType.parameterPane') || 'regular';
},
hasInputSlot() {
return this.$slots.input !== undefined;
},
inputPanelMargin(): number {
return !this.isDraggable? 0 : 80;
return this.pxToRelativeWidth(SIDE_PANELS_MARGIN);
},
minWindowWidth() {
return 2 * (SIDE_MARGIN + SIDE_PANELS_MARGIN) + MIN_PANEL_WIDTH;
},
minimumLeftPosition(): number {
return SIDE_MARGIN + this.inputPanelMargin;
if(this.windowWidth < this.minWindowWidth) return this.pxToRelativeWidth(1);
if(!this.hasInputSlot) return this.pxToRelativeWidth(SIDE_MARGIN);
return this.pxToRelativeWidth(SIDE_MARGIN + 20) + this.inputPanelMargin;
},
maximumRightPosition(): number {
return this.windowWidth - MAIN_PANEL_WIDTH - this.minimumLeftPosition;
},
mainPanelFinalPositionPx(): number {
const padding = this.minimumLeftPosition;
let pos = this.mainPanelPosition + MAIN_PANEL_WIDTH / 2;
pos = Math.max(padding, pos - MAIN_PANEL_WIDTH);
pos = Math.min(pos, this.maximumRightPosition);
if(this.windowWidth < this.minWindowWidth) return this.pxToRelativeWidth(1);
return pos;
return this.pxToRelativeWidth(SIDE_MARGIN + 20) + this.inputPanelMargin;
},
canMoveLeft(): boolean {
return this.mainPanelFinalPositionPx > this.minimumLeftPosition;
return this.mainPanelDimensions.relativeLeft > this.minimumLeftPosition;
},
canMoveRight(): boolean {
return this.mainPanelFinalPositionPx < this.maximumRightPosition;
return this.mainPanelDimensions.relativeRight > this.maximumRightPosition;
},
mainPanelStyles(): { left: string } {
mainPanelStyles(): { left: string, right: string } {
return {
left: `${this.mainPanelFinalPositionPx}px`,
'left': `${this.relativeWidthToPx(this.mainPanelDimensions.relativeLeft)}px`,
'right': `${this.relativeWidthToPx(this.mainPanelDimensions.relativeRight)}px`,
};
},
inputPanelStyles(): { width: string } {
if (!this.isDraggable) {
inputPanelStyles():{ right: string } {
return {
width: `${this.fixedPanelWidth}px`,
right: `${this.relativeWidthToPx(this.calculatedPositions.inputPanelRelativeRight)}px`,
};
},
outputPanelStyles(): { left: string, transform: string} {
return {
left: `${this.relativeWidthToPx(this.calculatedPositions.outputPanelRelativeLeft)}px`,
transform: `translateX(-${this.relativeWidthToPx(this.outputPanelRelativeTranslate)}px)`,
};
},
calculatedPositions():{ inputPanelRelativeRight: number, outputPanelRelativeLeft: number } {
const hasInput = this.$slots.input !== undefined;
const outputPanelRelativeLeft = this.mainPanelDimensions.relativeLeft + this.mainPanelDimensions.relativeWidth;
const inputPanelRelativeRight = hasInput
? 1 - outputPanelRelativeLeft + this.mainPanelDimensions.relativeWidth
: (1 - this.pxToRelativeWidth(SIDE_MARGIN));
return {
inputPanelRelativeRight,
outputPanelRelativeLeft,
};
},
outputPanelRelativeTranslate():number {
const panelMinLeft = 1 - this.pxToRelativeWidth(MIN_PANEL_WIDTH + SIDE_MARGIN);
const currentRelativeLeftDelta = this.calculatedPositions.outputPanelRelativeLeft - panelMinLeft;
return currentRelativeLeftDelta > 0 ? currentRelativeLeftDelta : 0;
},
hasDoubleWidth() {
return get(this, 'nodeType.parameterPane') === 'wide';
},
fixedPanelWidth(): number {
const multiplier = this.hasDoubleWidth ? 2 : 1;
if (this.windowWidth > 1700) {
return PANEL_WIDTH_LARGE * multiplier;
}
let width = this.mainPanelPosition - MAIN_PANEL_WIDTH / 2 - SIDE_MARGIN;
width = Math.min(
width,
this.windowWidth - SIDE_MARGIN * 2 - this.inputPanelMargin - MAIN_PANEL_WIDTH,
);
width = Math.max(320, width);
return {
width: `${width}px`,
};
return PANEL_WIDTH * multiplier;
},
outputPanelStyles(): { width: string } {
let width = this.windowWidth - this.mainPanelPosition - MAIN_PANEL_WIDTH / 2 - SIDE_MARGIN;
width = Math.min(
width,
this.windowWidth - SIDE_MARGIN * 2 - this.inputPanelMargin - MAIN_PANEL_WIDTH,
);
width = Math.max(MINIMUM_INPUT_PANEL_WIDTH, width);
return {
width: `${width}px`,
};
isBelowMinWidthMainPanel(): boolean {
const minRelativeWidth = this.pxToRelativeWidth(MIN_PANEL_WIDTH);
return this.mainPanelDimensions.relativeWidth < minRelativeWidth;
},
},
watch: {
windowWidth(windowWidth) {
const minRelativeWidth = this.pxToRelativeWidth(MIN_PANEL_WIDTH);
// Prevent the panel resizing below MIN_PANEL_WIDTH whhile maintaing position
if(this.isBelowMinWidthMainPanel) {
this.setMainPanelWidth(minRelativeWidth);
}
const isBelowMinLeft = this.minimumLeftPosition > this.mainPanelDimensions.relativeLeft;
const isMaxRight = this.maximumRightPosition > this.mainPanelDimensions.relativeRight;
// When user is resizing from non-supported view(sub ~488px) we need to refit the panels
if((windowWidth > this.minWindowWidth) && isBelowMinLeft && isMaxRight) {
this.setMainPanelWidth(minRelativeWidth);
this.setPositions(this.getInitialLeftPosition(this.mainPanelDimensions.relativeWidth));
}
this.setPositions(this.mainPanelDimensions.relativeLeft);
},
},
methods: {
getRelativePosition() {
const current = this.mainPanelFinalPositionPx + MAIN_PANEL_WIDTH / 2 - this.windowWidth / 2;
getInitialLeftPosition(width: number) {
if(this.currentNodePaneType === 'dragless') return this.pxToRelativeWidth(SIDE_MARGIN + 1 + this.fixedPanelWidth);
const pos = Math.floor(
(current / ((this.maximumRightPosition - this.minimumLeftPosition) / 2)) * 100,
return this.hasInputSlot
? 0.5 - (width / 2)
: this.minimumLeftPosition;
},
setMainPanelWidth(relativeWidth?: number) {
const mainPanelRelativeWidth = relativeWidth || this.pxToRelativeWidth(initialMainPanelWidth[this.currentNodePaneType]);
this.$store.commit('ui/setMainPanelDimensions', {
panelType: this.currentNodePaneType,
dimensions: {
relativeWidth: mainPanelRelativeWidth,
},
});
},
setPositions(relativeLeft: number) {
const mainPanelRelativeLeft = relativeLeft || 1 - this.calculatedPositions.inputPanelRelativeRight;
const mainPanelRelativeRight = 1 - mainPanelRelativeLeft - this.mainPanelDimensions.relativeWidth;
const isMaxRight = this.maximumRightPosition > mainPanelRelativeRight;
const isMinLeft = this.minimumLeftPosition > mainPanelRelativeLeft;
const isInputless = this.currentNodePaneType === 'inputless';
if(isMinLeft) {
this.$store.commit('ui/setMainPanelDimensions', {
panelType: this.currentNodePaneType,
dimensions: {
relativeLeft: this.minimumLeftPosition,
relativeRight: 1 - this.mainPanelDimensions.relativeWidth - this.minimumLeftPosition,
},
});
return;
}
if(isMaxRight) {
this.$store.commit('ui/setMainPanelDimensions', {
panelType: this.currentNodePaneType,
dimensions: {
relativeLeft: 1 - this.mainPanelDimensions.relativeWidth - this.maximumRightPosition,
relativeRight: this.maximumRightPosition,
},
});
return;
}
this.$store.commit('ui/setMainPanelDimensions', {
panelType: this.currentNodePaneType,
dimensions: {
relativeLeft: isInputless ? this.minimumLeftPosition : mainPanelRelativeLeft,
relativeRight: mainPanelRelativeRight,
},
});
},
pxToRelativeWidth(px: number) {
return px / this.windowWidth;
},
relativeWidthToPx(relativeWidth: number) {
return relativeWidth * this.windowWidth;
},
onResizeStart() {
this.setTotalWidth();
},
onResizeEnd() {
this.storePositionData();
},
onResize({ direction, x, width }: { direction: string, x: number, width: number}) {
const relativeDistance = this.pxToRelativeWidth(x);
const relativeWidth = this.pxToRelativeWidth(width);
if(direction === "left" && relativeDistance <= this.minimumLeftPosition) return;
if(direction === "right" && (1 - relativeDistance) <= this.maximumRightPosition) return;
if(width <= MIN_PANEL_WIDTH) return;
this.setMainPanelWidth(relativeWidth);
this.setPositions(direction === 'left'
? relativeDistance
: this.mainPanelDimensions.relativeLeft,
);
return pos;
},
restorePositionData() {
const storedPanelWidthData = window.localStorage.getItem(`${LOCAL_STORAGE_MAIN_PANEL_RELATIVE_WIDTH}_${this.currentNodePaneType}`);
if(storedPanelWidthData) {
const parsedWidth = parseFloat(storedPanelWidthData);
this.setMainPanelWidth(parsedWidth);
const initialPosition = this.getInitialLeftPosition(parsedWidth);
this.setPositions(initialPosition);
return true;
}
return false;
},
storePositionData() {
window.localStorage.setItem(`${LOCAL_STORAGE_MAIN_PANEL_RELATIVE_WIDTH}_${this.currentNodePaneType}`, this.mainPanelDimensions.relativeWidth.toString());
},
onDragStart() {
this.isDragging = true;
this.$emit('dragstart', { position: this.getRelativePosition() });
this.$emit('dragstart', { position: this.mainPanelDimensions.relativeLeft });
},
onDrag(e: {x: number, y: number}) {
const relativePosition = e.x / this.windowWidth;
this.$store.commit('ui/setMainPanelRelativePosition', relativePosition);
const relativeLeft = this.pxToRelativeWidth(e.x) - (this.mainPanelDimensions.relativeWidth / 2);
this.setPositions(relativeLeft);
},
onDragEnd() {
setTimeout(() => {
this.isDragging = false;
this.$emit('dragend', {
windowWidth: this.windowWidth,
position: this.getRelativePosition(),
position: this.mainPanelDimensions.relativeLeft,
});
}, 0);
this.storePositionData();
},
setTotalWidth() {
this.windowWidth = window.innerWidth;
@ -178,14 +355,13 @@ export default Vue.extend({
</script>
<style lang="scss" module>
$--main-panel-width: 360px;
.dataPanel {
position: absolute;
height: calc(100% - 2 * var(--spacing-l));
position: absolute;
top: var(--spacing-l);
z-index: 0;
min-width: 280px;
}
.inputPanel {
@ -200,7 +376,6 @@ $--main-panel-width: 360px;
.outputPanel {
composes: dataPanel;
right: var(--spacing-l);
width: $--main-panel-width;
> * {
border-radius: 0 var(--border-radius-large) var(--border-radius-large) 0;
@ -218,18 +393,35 @@ $--main-panel-width: 360px;
}
}
.mainPanelInner {
height: 100%;
border: var(--border-base);
border-radius: var(--border-radius-large);
box-shadow: 0 4px 16px rgb(50 61 85 / 10%);
overflow: hidden;
&.dragging {
border-color: var(--color-primary);
box-shadow: 0px 6px 16px rgba(255, 74, 51, 0.15);
}
}
.draggable {
position: absolute;
left: 40%;
visibility: hidden;
}
.dragButtonContainer {
position: absolute;
top: -12px;
width: $--main-panel-width;
width: 100%;
height: 12px;
display: flex;
justify-content: center;
pointer-events: none;
.draggable {
pointer-events: all;
}
&:hover .draggable {
visibility: visible;
}

View file

@ -32,6 +32,7 @@
:hideInputAndOutput="activeNodeType === null"
:position="isTriggerNode && !showTriggerPanel ? 0 : undefined"
:isDraggable="!isTriggerNode"
:nodeType="activeNodeType"
@close="close"
@init="onPanelsInit"
@dragstart="onDragStart"
@ -82,6 +83,7 @@
:eventBus="settingsEventBus"
:dragging="isDragging"
:sessionId="sessionId"
:nodeType="activeNodeType"
@valueChanged="valueChanged"
@execute="onNodeExecute"
@activate="onWorkflowActivate"

View file

@ -1,5 +1,6 @@
<template>
<div :class="{'node-settings': true, 'dragging': dragging}" @keydown.stop>
<div :class="{
'node-settings': true, 'dragging': dragging }" @keydown.stop>
<div :class="$style.header">
<div class="header-side-menu">
<NodeTitle class="node-name" :value="node && node.name" :nodeType="nodeType" @input="nameChanged" :readOnly="isReadOnly"></NodeTitle>
@ -96,7 +97,7 @@
</template>
<script lang="ts">
import Vue from 'vue';
import Vue, { PropType } from 'vue';
import {
INodeTypeDescription,
INodeParameters,
@ -113,7 +114,8 @@ import {
import {
COMMUNITY_NODES_INSTALLATION_DOCS_URL,
CUSTOM_NODES_DOCS_URL,
} from '../constants';
MAIN_NODE_PANEL_WIDTH,
} from '@/constants';
import NodeTitle from '@/components/NodeTitle.vue';
import ParameterInputFull from '@/components/ParameterInputFull.vue';
@ -148,13 +150,6 @@ export default mixins(
NodeExecuteButton,
},
computed: {
nodeType (): INodeTypeDescription | null {
if (this.node) {
return this.$store.getters['nodeTypes/getNodeType'](this.node.type, this.node.typeVersion);
}
return null;
},
nodeTypeName(): string {
if (this.nodeType) {
const shortNodeType = this.$locale.shortNodeType(this.nodeType.name);
@ -224,6 +219,9 @@ export default mixins(
sessionId: {
type: String,
},
nodeType: {
type: Object as PropType<INodeTypeDescription>,
},
},
data () {
return {
@ -336,6 +334,7 @@ export default mixins(
] as INodeProperties[],
COMMUNITY_NODES_INSTALLATION_DOCS_URL,
CUSTOM_NODES_DOCS_URL,
MAIN_NODE_PANEL_WIDTH,
};
},
watch: {
@ -641,13 +640,9 @@ export default mixins(
<style lang="scss">
.node-settings {
overflow: hidden;
min-width: 360px;
max-width: 360px;
background-color: var(--color-background-xlight);
height: 100%;
border: var(--border-base);
border-radius: var(--border-radius-large);
box-shadow: 0 4px 16px rgb(50 61 85 / 10%);
width: 100%;
.no-parameters {
margin-top: var(--spacing-xs);

View file

@ -1,5 +1,5 @@
<template>
<Draggable type="panel-resize" @drag="onDrag" @dragstart="onDragStart" @dragend="onDragEnd">
<Draggable type="panel-resize" @drag="onDrag" @dragstart="onDragStart" @dragend="onDragEnd" :class="$style.dragContainer">
<template v-slot="{ isDragging }">
<div
:class="{ [$style.dragButton]: true }"
@ -63,6 +63,9 @@ export default mixins(dragging).extend({
</script>
<style lang="scss" module>
.dragContainer {
pointer-events: all;
}
.dragButton {
background-color: var(--color-background-base);
width: 64px;
@ -74,6 +77,8 @@ export default mixins(dragging).extend({
align-items: center;
justify-content: center;
overflow: visible;
position: relative;
z-index: 3;
&:hover {
.leftArrow, .rightArrow {

View file

@ -1,5 +1,5 @@
<template>
<div class="resource-locator">
<div class="resource-locator" ref="container">
<resource-locator-dropdown
:value="value ? value.value: ''"
:show="showResourceDropdown"
@ -10,6 +10,7 @@
:filter="searchFilter"
:hasMore="currentQueryHasMore"
:errorView="currentQueryError"
:width="width"
@input="onListItemSelected"
@hide="onDropdownHide"
@filter="onSearchFilter"
@ -251,10 +252,12 @@ export default mixins(debounceHelper, workflowHelpers, nodeHelpers).extend({
},
data() {
return {
mainPanelMutationSubscription: () => {},
showResourceDropdown: false,
searchFilter: '',
cachedResponses: {} as { [key: string]: IResourceLocatorQuery },
hasCompletedASearch: false,
width: 0,
};
},
computed: {
@ -423,8 +426,23 @@ export default mixins(debounceHelper, workflowHelpers, nodeHelpers).extend({
},
mounted() {
this.$on('refreshList', this.refreshList);
this.setWidth();
window.addEventListener('resize', this.setWidth);
this.mainPanelMutationSubscription = this.$store.subscribe(this.setWidthOnMainPanelResize);
},
beforeDestroy() {
// Unsubscribe
this.mainPanelMutationSubscription();
window.removeEventListener('resize', this.setWidth);
},
methods: {
setWidth() {
this.width = (this.$refs.container as HTMLElement).offsetWidth;
},
setWidthOnMainPanelResize(mutation: { type: string }) {
// Update the width when main panel dimension change
if(mutation.type === 'ui/setMainPanelDimensions') this.setWidth();
},
getLinkAlt(entity: string) {
if (this.selectedMode === 'list' && entity) {
return this.$locale.baseText('resourceLocator.openSpecificResource', { interpolate: { entity, appName: this.appName } });

View file

@ -1,7 +1,7 @@
<template>
<n8n-popover
placement="bottom"
width="318"
:width="width"
:popper-class="$style.popover"
:value="show"
trigger="manual"
@ -90,6 +90,9 @@ export default Vue.extend({
filterRequired: {
type: Boolean,
},
width: {
type: Number,
},
},
data() {
return {
@ -254,7 +257,7 @@ export default Vue.extend({
--input-font-size: var(--font-size-2xs);
position: absolute;
top: 0;
width: 316px;
width: 100%;
z-index: 1;
}

View file

@ -118,3 +118,7 @@ export function isValueExpression (parameter: INodeProperties, paramValue: NodeP
}
return false;
}
export function convertRemToPixels(rem: string) {
return parseInt(rem, 10) * parseFloat(getComputedStyle(document.documentElement).fontSize);
}

View file

@ -233,6 +233,7 @@ export const LOCAL_STORAGE_ACTIVATION_FLAG = 'N8N_HIDE_ACTIVATION_ALERT';
export const LOCAL_STORAGE_PIN_DATA_DISCOVERY_NDV_FLAG = 'N8N_PIN_DATA_DISCOVERY_NDV';
export const LOCAL_STORAGE_PIN_DATA_DISCOVERY_CANVAS_FLAG = 'N8N_PIN_DATA_DISCOVERY_CANVAS';
export const LOCAL_STORAGE_MAPPING_FLAG = 'N8N_MAPPING_ONBOARDED';
export const LOCAL_STORAGE_MAIN_PANEL_RELATIVE_WIDTH = 'N8N_MAIN_PANEL_RELATIVE_WIDTH';
export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename=';
export const HIRING_BANNER = `
@ -317,3 +318,4 @@ export const DEFAULT_STICKY_WIDTH = 240;
export enum EnterpriseEditionFeature {
Sharing = 'sharing',
}
export const MAIN_NODE_PANEL_WIDTH = 360;

View file

@ -23,6 +23,7 @@ import {
ONBOARDING_CALL_SIGNUP_MODAL_KEY,
FAKE_DOOR_FEATURES,
COMMUNITY_PACKAGE_MANAGE_ACTIONS,
MAIN_NODE_PANEL_WIDTH,
} from '@/constants';
import Vue from 'vue';
import { ActionContext, Module } from 'vuex';
@ -109,6 +110,7 @@ const module: Module<IUiState, IRootState> = {
sidebarMenuCollapsed: true,
isPageLoading: true,
currentView: '',
mainPanelDimensions: {},
ndv: {
sessionId: '',
input: {
@ -205,10 +207,22 @@ const module: Module<IUiState, IRootState> = {
draggableType: (state: IUiState) => state.draggable.type,
draggableData: (state: IUiState) => state.draggable.data,
canDraggableDrop: (state: IUiState) => state.draggable.canDrop,
mainPanelDimensions: (state: IUiState) => (panelType: string) => {
const defaults = { relativeRight: 1, relativeLeft: 1, relativeWidth: 1 };
return {...defaults, ...state.mainPanelDimensions[panelType]};
},
draggableStickyPos: (state: IUiState) => state.draggable.stickyPosition,
mappingTelemetry: (state: IUiState) => state.ndv.mappingTelemetry,
},
mutations: {
setMainPanelDimensions: (state: IUiState, params: { panelType:string, dimensions: { relativeLeft?: number, relativeRight?: number, relativeWidth?: number }}) => {
Vue.set(
state.mainPanelDimensions,
params.panelType,
{...state.mainPanelDimensions[params.panelType], ...params.dimensions },
);
},
setMode: (state: IUiState, params: {name: string, mode: string}) => {
const { name, mode } = params;
Vue.set(state.modals[name], 'mode', mode);
@ -259,9 +273,6 @@ const module: Module<IUiState, IRootState> = {
setOutputPanelEditModeValue: (state: IUiState, payload: string) => {
Vue.set(state.ndv.output.editMode, 'value', payload);
},
setMainPanelRelativePosition(state: IUiState, relativePosition: number) {
state.mainPanelPosition = relativePosition;
},
setMappableNDVInputFocus(state: IUiState, paramName: string) {
Vue.set(state.ndv, 'focusedMappableInput', paramName);
},