refactor(editor): Encapsulate canvas actions (#4416)

* feat(editor): encapsulating canvas actions

* fiz(editor): zoomToFit

* fiz(editor): zoomToFit

* fiz(editor): fix imoprts in canvas controls

* fiz(editor): fix imports in node view

* fiz(editor): remove unused props from canvas controls

* fiz(editor): fix zoomToFit functionality

* fiz(editor): move more functions from NodeView to canvas store

* chore(editor): code formatting fixes

* fix(editor): adding back some lost refactoring after merge

* fix(editor): remove console.log

* fix(editor): add missing canvasAddButtonPosition

* fix(editor): modify root store env query

* fix(editor): modify canvas control position styling

* fix(editor): modify canvas control position styling

* fix(editor): roll back process.env

* fix(editor): fix canvas controls positioning

* fix(editor): fix canvas controls positioning

* fix(editor): adopting new styles after merge

* fix(editor): not storing html element in the store

* fix(editor): remove unused variables

* fix(editor): update canvas controls after conflict resolution

* fix(editor): revert main.ts to reduce change noise

* fix(editor): remove old store commit

* fix(editor): simplify canvas store

* fix(editor): reposition execute workflow button in mobile view

* fix(editor): fox mouse scroll zoom in canvas

* fix(editor): move canvas scroll handling into canvas controls
This commit is contained in:
Csaba Tuncsik
2022-11-07 09:53:27 +01:00
committed by GitHub
parent 4517c4a90a
commit 350b356271
5 changed files with 266 additions and 191 deletions

View File

@@ -0,0 +1,81 @@
<template>
<div
:class="{ [$style.zoomMenu]: true, [$style.regularZoomMenu]: !isDemo, [$style.demoZoomMenu]: isDemo }">
<n8n-icon-button @click="zoomToFit" type="tertiary" size="large" :title="$locale.baseText('nodeView.zoomToFit')"
icon="expand" />
<n8n-icon-button @click="zoomIn" type="tertiary" size="large" :title="$locale.baseText('nodeView.zoomIn')"
icon="search-plus" />
<n8n-icon-button @click="zoomOut" type="tertiary" size="large" :title="$locale.baseText('nodeView.zoomOut')"
icon="search-minus" />
<n8n-icon-button v-if="nodeViewScale !== 1 && !isDemo" @click="resetZoom" type="tertiary" size="large"
:title="$locale.baseText('nodeView.resetZoom')" icon="undo" />
</div>
</template>
<script lang="ts" setup>
import { onBeforeMount, onBeforeUnmount } from 'vue';
import { storeToRefs } from 'pinia';
import { useCanvasStore } from '@/stores/canvas';
const canvasStore = useCanvasStore();
const { zoomToFit, zoomIn, zoomOut, resetZoom } = canvasStore;
const { nodeViewScale, isDemo } = storeToRefs(canvasStore);
const keyDown = (e: KeyboardEvent) => {
const isCtrlKeyPressed = e.metaKey || e.ctrlKey;
if ((e.key === '=' || e.key === '+') && !isCtrlKeyPressed) {
zoomIn();
} else if ((e.key === '_' || e.key === '-') && !isCtrlKeyPressed) {
zoomOut();
} else if ((e.key === '0') && !isCtrlKeyPressed) {
resetZoom();
} else if ((e.key === '1') && !isCtrlKeyPressed) {
zoomToFit();
}
};
onBeforeMount(() => {
document.addEventListener('keydown', keyDown);
});
onBeforeUnmount(() => {
document.removeEventListener('keydown', keyDown);
});
</script>
<style lang="scss" module>
.zoomMenu {
position: absolute;
width: 210px;
bottom: 108px;
left: 35px;
line-height: 25px;
color: #444;
padding-right: 5px;
button {
border: var(--border-base);
}
>* {
+* {
margin-left: var(--spacing-3xs);
}
&:hover {
transform: scale(1.1);
}
}
}
.regularZoomMenu {
@media (max-width: $breakpoint-2xs) {
bottom: 90px;
}
}
.demoZoomMenu {
left: 10px;
bottom: 10px;
}
</style>

View File

@@ -1,6 +1,4 @@
import mixins from 'vue-typed-mixins';
// @ts-ignore
import normalizeWheel from 'normalize-wheel';
import { deviceSupportHelpers } from '@/components/mixins/deviceSupportHelpers';
import { getMousePosition } from '@/views/canvasHelpers';
import { mapStores } from 'pinia';
@@ -88,12 +86,5 @@ export const moveNodeWorkflow = mixins(
this.moveWorkflow(e);
},
wheelMoveWorkflow (e: WheelEvent) {
const normalized = normalizeWheel(e);
const offsetPosition = this.uiStore.nodeViewOffsetPosition;
const nodeViewOffsetPositionX = offsetPosition[0] - (e.shiftKey ? normalized.pixelY : normalized.pixelX);
const nodeViewOffsetPositionY = offsetPosition[1] - (e.shiftKey ? normalized.pixelX : normalized.pixelY);
this.uiStore.nodeViewOffsetPosition = [nodeViewOffsetPositionX, nodeViewOffsetPositionY];
},
},
});

View File

@@ -0,0 +1,9 @@
declare module 'normalize-wheel' {
function normalizeWheel(e: WheelEvent): { spinX : number,
spinY : number,
pixelX : number,
pixelY : number
};
export = normalizeWheel;
}

View File

@@ -0,0 +1,129 @@
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
import { jsPlumb } from 'jsplumb';
import { v4 as uuid } from 'uuid';
import normalizeWheel from 'normalize-wheel';
import { useWorkflowsStore } from '@/stores/workflows';
import { useNodeTypesStore } from '@/stores/nodeTypes';
import { useUIStore } from '@/stores/ui';
import { INodeUi, XYPosition } from '@/Interface';
import * as CanvasHelpers from '@/views/canvasHelpers';
import { START_NODE_TYPE } from '@/constants';
import '@/plugins/N8nCustomConnectorType';
import '@/plugins/PlusEndpointType';
export const useCanvasStore = defineStore('canvas', () => {
const workflowStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
const uiStore = useUIStore();
const jsPlumbInstance = jsPlumb.getInstance();
const nodes = computed<INodeUi[]>(() => workflowStore.allNodes);
const triggerNodes = computed<INodeUi[]>(
() => nodes.value.filter(
node => node.type === START_NODE_TYPE || nodeTypesStore.isTriggerNode(node.type),
),
);
const isDemo = ref<boolean>(false);
const nodeViewScale = ref<number>(1);
const canvasAddButtonPosition = ref<XYPosition>([1, 1]);
const setRecenteredCanvasAddButtonPosition = (offset?: XYPosition) => {
const position = CanvasHelpers.getMidCanvasPosition(nodeViewScale.value, offset || [0, 0]);
position[0] -= CanvasHelpers.PLACEHOLDER_TRIGGER_NODE_SIZE / 2;
position[1] -= CanvasHelpers.PLACEHOLDER_TRIGGER_NODE_SIZE / 2;
canvasAddButtonPosition.value = CanvasHelpers.getNewNodePosition(nodes.value, position);
};
const getPlaceholderTriggerNodeUI = (): INodeUi => {
setRecenteredCanvasAddButtonPosition();
return {
id: uuid(),
...CanvasHelpers.DEFAULT_PLACEHOLDER_TRIGGER_BUTTON,
position: canvasAddButtonPosition.value,
};
};
const getNodesWithPlaceholderNode = (): INodeUi[] =>
triggerNodes.value.length > 0 ? nodes.value : [getPlaceholderTriggerNodeUI(), ...nodes.value];
const setZoomLevel = (zoomLevel: number, offset: XYPosition) => {
nodeViewScale.value = zoomLevel;
jsPlumbInstance.setZoom(zoomLevel);
uiStore.nodeViewOffsetPosition = offset;
};
const resetZoom = () => {
const {scale, offset} = CanvasHelpers.scaleReset({
scale: nodeViewScale.value,
offset: uiStore.nodeViewOffsetPosition,
});
setZoomLevel(scale, offset);
};
const zoomIn = () => {
const {scale, offset} = CanvasHelpers.scaleBigger({
scale: nodeViewScale.value,
offset: uiStore.nodeViewOffsetPosition,
});
setZoomLevel(scale, offset);
};
const zoomOut = () => {
const {scale, offset} = CanvasHelpers.scaleSmaller({
scale: nodeViewScale.value,
offset: uiStore.nodeViewOffsetPosition,
});
setZoomLevel(scale, offset);
};
const zoomToFit = () => {
const nodes = getNodesWithPlaceholderNode();
if (!nodes.length) { // some unknown workflow executions
return;
}
const {zoomLevel, offset} = CanvasHelpers.getZoomToFit(nodes, !isDemo.value);
setZoomLevel(zoomLevel, offset);
};
const wheelMoveWorkflow = (e: WheelEvent) => {
const normalized = normalizeWheel(e);
const offsetPosition = uiStore.nodeViewOffsetPosition;
const nodeViewOffsetPositionX = offsetPosition[0] - (e.shiftKey ? normalized.pixelY : normalized.pixelX);
const nodeViewOffsetPositionY = offsetPosition[1] - (e.shiftKey ? normalized.pixelX : normalized.pixelY);
uiStore.nodeViewOffsetPosition = [nodeViewOffsetPositionX, nodeViewOffsetPositionY];
};
const wheelScroll = (e: WheelEvent) => {
//* Control + scroll zoom
if (e.ctrlKey) {
if (e.deltaY > 0) {
zoomOut();
} else {
zoomIn();
}
e.preventDefault();
return;
}
wheelMoveWorkflow(e);
};
return {
jsPlumbInstance,
isDemo,
nodeViewScale,
canvasAddButtonPosition,
setRecenteredCanvasAddButtonPosition,
getNodesWithPlaceholderNode,
setZoomLevel,
resetZoom,
zoomIn,
zoomOut,
zoomToFit,
wheelScroll,
};
});

View File

@@ -15,7 +15,7 @@
@mousedown="mouseDown"
v-touch:tap="touchTap"
@mouseup="mouseUp"
@wheel="wheelScroll"
@wheel="canvasStore.wheelScroll"
>
<div id="node-view-background" class="node-view-background" :style="backgroundStyle" />
<div
@@ -29,8 +29,8 @@
@click="showTriggerCreator('trigger_placeholder_button')"
v-show="showCanvasAddButton"
:showTooltip="!containsTrigger && showTriggerMissingTooltip"
:position="canvasAddButtonPosition"
@hook:mounted="setRecenteredCanvasAddButtonPosition"
:position="canvasStore.canvasAddButtonPosition"
@hook:mounted="canvasStore.setRecenteredCanvasAddButtonPosition"
/>
<div v-for="nodeData in nodes" :key="nodeData.id">
<node
@@ -86,17 +86,7 @@
@toggleNodeCreator="onToggleNodeCreator"
@addNode="onAddNode"
/>
<div
:class="{ 'zoom-menu': true, 'regular-zoom-menu': !isDemo, 'demo-zoom-menu': isDemo, expanded: !uiStore.sidebarMenuCollapsed }">
<n8n-icon-button @click="zoomToFit" type="tertiary" size="large" :title="$locale.baseText('nodeView.zoomToFit')"
icon="expand" />
<n8n-icon-button @click="zoomIn" type="tertiary" size="large" :title="$locale.baseText('nodeView.zoomIn')"
icon="search-plus" />
<n8n-icon-button @click="zoomOut" type="tertiary" size="large" :title="$locale.baseText('nodeView.zoomOut')"
icon="search-minus" />
<n8n-icon-button v-if="nodeViewScale !== 1 && !isDemo" @click="resetZoom" type="tertiary" size="large"
:title="$locale.baseText('nodeView.resetZoom')" icon="undo" />
</div>
<canvas-controls />
<div
class="workflow-execute-wrapper" v-if="!isReadOnly"
>
@@ -137,11 +127,11 @@
<script lang="ts">
import Vue from 'vue';
import { mapStores } from 'pinia';
import {
Connection, Endpoint, N8nPlusEndpoint,
OnConnectionBindInfo, Connection, Endpoint, N8nPlusEndpoint, jsPlumbInstance,
} from 'jsplumb';
import type { MessageBoxInputData } from 'element-ui/types/message-box';
import { jsPlumb, OnConnectionBindInfo } from 'jsplumb';
import once from 'lodash/once';
import {
@@ -223,14 +213,9 @@ import {
IUser,
INodeUpdatePropertiesInformation,
} from '@/Interface';
import { mapGetters } from 'vuex';
import '../plugins/N8nCustomConnectorType';
import '../plugins/PlusEndpointType';
import { getAccountAge } from '@/modules/userHelpers';
import { dataPinningEventBus } from "@/event-bus/data-pinning-event-bus";
import { debounceHelper } from '@/components/mixins/debounce';
import { mapStores } from 'pinia';
import { useUIStore } from '@/stores/ui';
import { useSettingsStore } from '@/stores/settings';
import { useUsersStore } from '@/stores/users';
@@ -241,12 +226,16 @@ import { useRootStore } from '@/stores/n8nRootStore';
import { useNDVStore } from '@/stores/ndv';
import { useTemplatesStore } from '@/stores/templates';
import { useNodeTypesStore } from '@/stores/nodeTypes';
import { useCanvasStore } from '@/stores/canvas';
interface AddNodeOptions {
position?: XYPosition;
dragAndDrop?: boolean;
}
const NodeCreator = () => import('@/components/Node/NodeCreator/NodeCreator.vue');
const NodeCreation = () => import('@/components/Node/NodeCreation.vue');
const CanvasControls = () => import('@/components/CanvasControls.vue');
export default mixins(
copyPaste,
@@ -268,11 +257,12 @@ export default mixins(
components: {
NodeDetailsView,
Node,
NodeCreator: () => import('@/components/Node/NodeCreator/NodeCreator.vue'),
NodeCreator,
NodeSettings,
Sticky,
CanvasAddButton,
NodeCreation: () => import('@/components/Node/NodeCreation.vue'),
NodeCreation,
CanvasControls,
},
errorCaptured: (err, vm, info) => {
console.error('errorCaptured'); // eslint-disable-line no-console
@@ -323,9 +313,16 @@ export default mixins(
},
containsTrigger(containsTrigger) {
// Re-center CanvasAddButton if there's no triggers
if (containsTrigger === false) this.setRecenteredCanvasAddButtonPosition(this.getNodeViewOffsetPosition);
if (containsTrigger === false) this.canvasStore.setRecenteredCanvasAddButtonPosition(this.getNodeViewOffsetPosition);
else this.tryToAddWelcomeSticky();
},
nodeViewScale(newScale) {
const element = this.$refs.nodeView as HTMLDivElement;
if(element) {
element.style.transform = `scale(${newScale})`;
}
},
},
async beforeRouteLeave(to, from, next) {
const nextTab = getNodeViewTab(to);
@@ -486,16 +483,21 @@ export default mixins(
getNodeViewOffsetPosition(): XYPosition {
return this.uiStore.nodeViewOffsetPosition;
},
...mapStores(useCanvasStore),
nodeViewScale(): number {
return this.canvasStore.nodeViewScale;
},
instance(): jsPlumbInstance {
return this.canvasStore.jsPlumbInstance;
},
},
data() {
return {
GRID_SIZE: CanvasHelpers.GRID_SIZE,
STICKY_NODE_TYPE,
createNodeActive: false,
instance: jsPlumb.getInstance(),
lastSelectedConnection: null as null | Connection,
lastClickPosition: [450, 450] as XYPosition,
nodeViewScale: 1,
ctrlKeyPressed: false,
stopExecutionInProgress: false,
blankRedirect: false,
@@ -508,7 +510,6 @@ export default mixins(
showStickyButton: false,
isExecutionPreview: false,
showTriggerMissingTooltip: false,
canvasAddButtonPosition: [1, 1] as XYPosition,
workflowData: null as INewWorkflowData | null,
};
},
@@ -677,7 +678,7 @@ export default mixins(
await this.addNodes(deepCopy(data.workflowData.nodes), deepCopy(data.workflowData.connections));
this.$nextTick(() => {
this.zoomToFit();
this.canvasStore.zoomToFit();
this.uiStore.stateIsDirty = false;
});
this.$externalHooks().run('execution.open', { workflowId: data.workflowData.id, workflowName: data.workflowData.name, executionId });
@@ -740,7 +741,7 @@ export default mixins(
}
this.$nextTick(() => {
this.zoomToFit();
this.canvasStore.zoomToFit();
});
},
async openWorkflowTemplate(templateId: string) {
@@ -778,7 +779,7 @@ export default mixins(
await this.addNodes(data.workflow.nodes, data.workflow.connections);
this.workflowData = await this.workflowsStore.getNewWorkflowData(data.name) || {};
this.$nextTick(() => {
this.zoomToFit();
this.canvasStore.zoomToFit();
this.uiStore.stateIsDirty = true;
});
@@ -824,7 +825,7 @@ export default mixins(
if (!this.credentialsUpdated) {
this.uiStore.stateIsDirty = false;
}
this.zoomToFit();
this.canvasStore.zoomToFit();
this.$externalHooks().run('workflow.open', { workflowId, workflowName: data.name });
this.workflowsStore.activeWorkflowExecution = null;
this.stopLoading();
@@ -849,20 +850,6 @@ export default mixins(
this.mouseUpMouseSelect(e);
this.mouseUpMoveWorkflow(e);
},
wheelScroll(e: WheelEvent) {
//* Control + scroll zoom
if (e.ctrlKey) {
if (e.deltaY > 0) {
this.zoomOut();
} else {
this.zoomIn();
}
e.preventDefault();
return;
}
this.wheelMoveWorkflow(e);
},
keyUp(e: KeyboardEvent) {
if (e.key === this.controlKeyCode) {
this.ctrlKeyPressed = false;
@@ -920,15 +907,7 @@ export default mixins(
if (lastSelectedNode !== null && lastSelectedNode.type !== STICKY_NODE_TYPE) {
this.callDebounced('renameNodePrompt', { debounceTime: 1500 }, lastSelectedNode.name);
}
} else if ((e.key === '=' || e.key === '+') && !this.isCtrlKeyPressed(e)) {
this.zoomIn();
} else if ((e.key === '_' || e.key === '-') && !this.isCtrlKeyPressed(e)) {
this.zoomOut();
} else if ((e.key === '0') && !this.isCtrlKeyPressed(e)) {
this.resetZoom();
} else if ((e.key === '1') && !this.isCtrlKeyPressed(e)) {
this.zoomToFit();
} else if ((e.key === 'a') && this.isCtrlKeyPressed(e)) {
} else if ((e.key === 'a') && (this.isCtrlKeyPressed(e) === true)) {
// Select all nodes
e.stopPropagation();
e.preventDefault();
@@ -1206,90 +1185,6 @@ export default mixins(
}
});
},
resetZoom() {
const { scale, offset } = CanvasHelpers.scaleReset({ scale: this.nodeViewScale, offset: this.getNodeViewOffsetPosition });
this.setZoomLevel(scale);
this.uiStore.nodeViewOffsetPosition = offset;
},
zoomIn() {
const { scale, offset: [xOffset, yOffset] } = CanvasHelpers.scaleBigger({ scale: this.nodeViewScale, offset: this.getNodeViewOffsetPosition });
this.setZoomLevel(scale);
this.uiStore.nodeViewOffsetPosition = [xOffset, yOffset];
},
zoomOut() {
const { scale, offset: [xOffset, yOffset] } = CanvasHelpers.scaleSmaller({ scale: this.nodeViewScale, offset: this.getNodeViewOffsetPosition });
this.setZoomLevel(scale);
this.uiStore.nodeViewOffsetPosition = [xOffset, yOffset];
},
setZoomLevel(zoomLevel: number) {
this.nodeViewScale = zoomLevel; // important for background
const element = this.$refs.nodeView as HTMLElement;
if (!element) {
return;
}
// https://docs.jsplumbtoolkit.com/community/current/articles/zooming.html
const scaleString = 'scale(' + zoomLevel + ')';
['webkit', 'moz', 'ms', 'o'].forEach((prefix) => {
// @ts-ignore
element.style[prefix + 'Transform'] = scaleString;
});
element.style['transform'] = scaleString;
// @ts-ignore
this.instance.setZoom(zoomLevel);
},
setRecenteredCanvasAddButtonPosition (offset?: XYPosition) {
const position = CanvasHelpers.getMidCanvasPosition(this.nodeViewScale, offset || [0, 0]);
position[0] -= CanvasHelpers.PLACEHOLDER_TRIGGER_NODE_SIZE / 2;
position[1] -= CanvasHelpers.PLACEHOLDER_TRIGGER_NODE_SIZE / 2;
this.canvasAddButtonPosition = CanvasHelpers.getNewNodePosition(this.nodes, position);
},
getPlaceholderTriggerNodeUI (): INodeUi {
this.setRecenteredCanvasAddButtonPosition();
return {
id: uuid(),
...CanvasHelpers.DEFAULT_PLACEHOLDER_TRIGGER_BUTTON,
position: this.canvasAddButtonPosition,
};
},
// Extend nodes with placeholder trigger button as NodeUI object
// with the centered position if canvas doesn't contains trigger node
getNodesWithPlaceholderNode(): INodeUi[] {
const nodes = this.workflowsStore.allNodes;
const extendedNodes = this.containsTrigger
? nodes
: [this.getPlaceholderTriggerNodeUI(), ...nodes];
return extendedNodes;
},
zoomToFit() {
const nodes = this.getNodesWithPlaceholderNode() as INodeUi[];
if (nodes.length === 0) { // some unknown workflow executions
return;
}
const {zoomLevel, offset} = CanvasHelpers.getZoomToFit(nodes, !this.isDemo);
this.setZoomLevel(zoomLevel);
this.uiStore.nodeViewOffsetPosition = offset;
},
async stopExecution() {
const executionId = this.workflowsStore.activeExecutionId;
if (executionId === null) {
@@ -1699,7 +1594,7 @@ export default mixins(
const lastSelectedNode = this.lastSelectedNode;
if (options.position) {
newNodeData.position = CanvasHelpers.getNewNodePosition(this.getNodesWithPlaceholderNode(), options.position);
newNodeData.position = CanvasHelpers.getNewNodePosition(this.canvasStore.getNodesWithPlaceholderNode(), options.position);
} else if (lastSelectedNode) {
const lastSelectedConnection = this.lastSelectedConnection;
if (lastSelectedConnection) { // set when injecting into a connection
@@ -1741,7 +1636,7 @@ export default mixins(
// If added node is a trigger and it's the first one added to the canvas
// we place it at canvasAddButtonPosition to replace the canvas add button
const position = this.nodeTypesStore.isTriggerNode(nodeTypeName) && !this.containsTrigger
? this.canvasAddButtonPosition
? this.canvasStore.canvasAddButtonPosition
// If no node is active find a free spot
: this.lastClickPosition as XYPosition;
@@ -2199,8 +2094,8 @@ export default mixins(
this.workflowsStore.activeWorkflowExecution = null;
this.uiStore.stateIsDirty = false;
this.setZoomLevel(1);
this.zoomToFit();
this.canvasStore.setZoomLevel(1);
this.canvasStore.zoomToFit();
},
tryToAddWelcomeSticky: once(async function(this: any) {
const newWorkflow = this.workflowData;
@@ -3398,6 +3293,7 @@ export default mixins(
this.$root.$on('importWorkflowUrl', this.onImportWorkflowUrlEvent);
dataPinningEventBus.$on('pin-data', this.addPinDataConnections);
dataPinningEventBus.$on('unpin-data', this.removePinDataConnections);
this.canvasStore.isDemo = this.isDemo;
},
deactivated () {
document.removeEventListener('keydown', this.keyDown);
@@ -3425,44 +3321,6 @@ export default mixins(
</script>
<style scoped lang="scss">
.zoom-menu {
$--zoom-menu-margin: 15;
position: absolute;
left: $sidebar-width + $--zoom-menu-margin;
width: 210px;
bottom: 108px;
left: 35px;
line-height: 25px;
color: #444;
padding-right: 5px;
button {
border: var(--border-base);
}
>* {
+* {
margin-left: var(--spacing-3xs);
}
&:hover {
transform: scale(1.1);
}
}
}
.regular-zoom-menu {
@media (max-width: $breakpoint-2xs) {
bottom: 90px;
}
}
.demo-zoom-menu {
left: 10px;
bottom: 10px;
}
.node-view-root {
position: relative;
flex: 1;
@@ -3511,13 +3369,20 @@ export default mixins(
position: absolute;
display: flex;
justify-content: center;
align-items: center;
left: 50%;
transform: translateX(-50%);
bottom: 110px;
width: auto;
> * {
margin-inline-end: 0.625rem;
@media (max-width: $breakpoint-2xs) {
bottom: 150px;
}
button {
display: flex;
justify-content: center;
align-items: center;
}
}