refactor(editor): Upgrade to jsPlumb 5 (#4989)

* WIP: Nodeview

* Replace types

* Finish N8nPlus endpoint type

* Working on connector

* Apply prettier

* Fixed prettier issues

* Debugging rendering

* Fixed connectorrs position recalc

* Fix snapping and output labels, WIP dragging

* Fix N8nPlus endpoint rendering issues

* Cleanup

* Fix undo/redo and canvas add button position, cleanup

* Cleanup

* Revert accidental CLI changes

* Fix pnpm-lock

* Address bugs that came up during review

* Reset CLI package from master

* Various fixes

* Fix run items label toggling

* Linter fixes

* Fix stalk size for larger run items label

* Remove comment

* Correctly reset workspace after renaming the node

* Fix canvas e2e tests

* Fix undo/redo tests

* Fix stalk positioning and triggering of endpoint overlays

* Repaint connections on pin removal

* Limit repaintings

* Unbind jsPlumb events on deactivation

* Fix jsPlumb managment of Sticky and minor memort managment improvments

* Address rest of PR points

* Lint fix

* Copy patches folder to docker

* Fix e2e tests

* set allowNonAppliedPatches to allow build

* fix(editor): Handling router errors when navigation is canceled by user (#5271)

* 🔨 Handling router errors in main sidebar, removing unused code
* 🔨 Handling router errors in modals

* ci(core): Fix docker nightly/custom image build (no-changelog) (#5284)

* ci(core): Copy patches dir to Docker (no-changelog)

* Update patch

* Update package-lock

* reapply the patch

* skip patchedDependencies after the frontend is built

---------

Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>

* Fix connector hover state on success

* Remove allowNonAppliedPatches from package.json

---------

Co-authored-by: Milorad FIlipović <milorad@n8n.io>
Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
This commit is contained in:
OlegIvaniv 2023-01-30 18:20:50 +01:00 committed by GitHub
parent 5cb7e5007d
commit 766501723b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 1756 additions and 2268 deletions

View file

@ -39,11 +39,9 @@ describe('Undo/Redo', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME);
WorkflowPage.getters.nodeConnections().first().trigger('mouseover', { force: true });
cy.get('.connection-actions .add').invoke('show');
cy.get('.connection-actions .add').should('be.visible');
cy.get('.connection-actions .add').click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters.nodeConnections().realHover();
cy.get('.connection-actions .add').filter(':visible').click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME, false);
WorkflowPage.actions.zoomToFit();
WorkflowPage.actions.hitUndo();
WorkflowPage.getters.canvasNodes().should('have.have.length', 3);
@ -141,8 +139,8 @@ describe('Undo/Redo', () => {
it('should undo/redo deleting a connection by pressing delete button', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters.nodeConnections().first().trigger('mouseover', { force: true });
cy.get('.connection-actions .delete').click();
WorkflowPage.getters.nodeConnections().realHover();
cy.get('.connection-actions .delete').filter(':visible').should('be.visible').click();
WorkflowPage.getters.nodeConnections().should('have.length', 0);
WorkflowPage.actions.hitUndo();
WorkflowPage.getters.nodeConnections().should('have.length', 1);

View file

@ -61,11 +61,9 @@ describe('Canvas Actions', () => {
it('should add note between two connected nodes', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters.nodeConnections().first().trigger('mouseover', { force: true });
cy.get('.connection-actions .add').as('AddNodeConnectionButton');
cy.get('@AddNodeConnectionButton').invoke('show');
cy.get('@AddNodeConnectionButton').should('be.visible').click();
WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME);
WorkflowPage.getters.nodeConnections().first().realHover();
cy.get('.connection-actions .add').filter(':visible').should('be.visible').click()
WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME, false);
WorkflowPage.getters.canvasNodes().should('have.length', 3);
WorkflowPage.getters.nodeConnections().should('have.length', 2);
// And last node should be pushed to the right
@ -219,8 +217,8 @@ describe('Canvas Actions', () => {
it('should delete connections by pressing the delete button', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters.nodeConnections().first().trigger('mouseover', { force: true });
cy.get('.connection-actions .delete').click();
WorkflowPage.getters.nodeConnections().first().realHover();
cy.get('.connection-actions .delete').filter(':visible').should('be.visible').click();
WorkflowPage.getters.nodeConnections().should('have.length', 0);
});

View file

@ -151,7 +151,7 @@ describe('Credentials', () => {
it('should correctly render required and optional credentials', () => {
workflowPage.actions.visit();
cy.waitForLoad();
workflowPage.actions.addNodeToCanvas(PIPEDRIVE_NODE_NAME, true);
workflowPage.actions.addNodeToCanvas(PIPEDRIVE_NODE_NAME, true, true);
cy.get('body').type('{downArrow}');
cy.get('body').type('{enter}');
// Select incoming authentication

View file

@ -53,7 +53,7 @@ describe('NDV', () => {
});
it('should show correct validation state for resource locator params', () => {
workflowPage.actions.addNodeToCanvas('Typeform', true);
workflowPage.actions.addNodeToCanvas('Typeform', true, false);
ndv.getters.container().should('be.visible');
cy.get('.has-issues').should('have.length', 0);
cy.get('[class*=hasIssues]').should('have.length', 0);
@ -66,7 +66,7 @@ describe('NDV', () => {
it('should show validation errors only after blur or re-opening of NDV', () => {
workflowPage.actions.addNodeToCanvas('Manual Trigger');
workflowPage.actions.addNodeToCanvas('Airtable', true);
workflowPage.actions.addNodeToCanvas('Airtable', true, true);
ndv.getters.container().should('be.visible');
cy.get('.has-issues').should('have.length', 0);
ndv.getters.parameterInput('table').find('input').eq(1).focus().blur();

View file

@ -92,8 +92,11 @@ export class WorkflowPage extends BasePage {
this.getters.nodeCreatorSearchBar().type('{enter}');
cy.get('body').type('{esc}');
},
addNodeToCanvas: (nodeDisplayName: string, preventNdvClose?: boolean) => {
addNodeToCanvas: (nodeDisplayName: string, plusButtonClick = true, preventNdvClose?: boolean) => {
if (plusButtonClick) {
this.getters.nodeCreatorPlusButton().click();
}
this.getters.nodeCreatorSearchBar().type(nodeDisplayName);
this.getters.nodeCreatorSearchBar().type('{enter}');

View file

@ -23,7 +23,7 @@
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
import "cypress-real-events";
import { WorkflowsPage, SigninPage, SignupPage } from '../pages';
import { N8N_AUTH_COOKIE } from '../constants';
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';

View file

@ -43,6 +43,7 @@
"@types/node": "^16.11.22",
"cross-env": "^7.0.3",
"cypress": "^10.0.3",
"cypress-real-events": "^1.7.6",
"jest": "^29.3.1",
"jest-environment-jsdom": "^29.3.1",
"jest-mock": "^29.3.1",

View file

@ -39,6 +39,11 @@
"@fortawesome/free-regular-svg-icons": "^6.1.1",
"@fortawesome/free-solid-svg-icons": "^5.15.3",
"@fortawesome/vue-fontawesome": "^2.0.2",
"@jsplumb/browser-ui": "^5.13.2",
"@jsplumb/common": "^5.13.2",
"@jsplumb/connector-bezier": "^5.13.2",
"@jsplumb/core": "^5.13.2",
"@jsplumb/util": "^5.13.2",
"axios": "^0.21.1",
"codemirror-lang-html-n8n": "^1.0.0",
"codemirror-lang-n8n-expression": "^0.1.0",
@ -50,7 +55,6 @@
"humanize-duration": "^3.27.2",
"jquery": "^3.4.1",
"jsonpath": "^1.1.1",
"jsplumb": "2.15.4",
"lodash-es": "^4.17.21",
"lodash.camelcase": "^4.3.0",
"lodash.debounce": "^4.0.8",

View file

@ -1,17 +1,6 @@
import { CREDENTIAL_EDIT_MODAL_KEY } from './constants';
/* eslint-disable @typescript-eslint/no-explicit-any */
import { IMenuItem } from 'n8n-design-system';
import {
jsPlumbInstance,
DragOptions,
DropOptions,
ElementGroupRef,
Endpoint,
EndpointOptions,
EndpointRectangle,
EndpointRectangleOptions,
EndpointSpec,
} from 'jsplumb';
import {
GenericValue,
IConnections,
@ -47,98 +36,6 @@ import { BulkCommand, Undoable } from '@/models/history';
export * from 'n8n-design-system/types';
declare module 'jsplumb' {
interface PaintStyle {
stroke?: string;
fill?: string;
strokeWidth?: number;
outlineStroke?: string;
outlineWidth?: number;
}
// Extend jsPlumb Anchor interface
interface Anchor {
lastReturnValue: number[];
}
interface Connection {
__meta?: {
sourceNodeName: string;
sourceOutputIndex: number;
targetNodeName: string;
targetOutputIndex: number;
};
canvas?: HTMLElement;
connector?: {
setTargetEndpoint: (endpoint: Endpoint) => void;
resetTargetEndpoint: () => void;
bounds: {
minX: number;
maxX: number;
minY: number;
maxY: number;
};
};
// bind(event: string, (connection: Connection): void;): void;
bind(event: string, callback: Function): void;
removeOverlay(name: string): void;
removeOverlays(): void;
setParameter(name: string, value: any): void;
setPaintStyle(arg0: PaintStyle): void;
addOverlay(arg0: any[]): void;
setConnector(arg0: any[]): void;
getUuids(): [string, string];
}
interface Endpoint {
endpoint: any;
elementId: string;
__meta?: {
nodeName: string;
nodeId: string;
index: number;
totalEndpoints: number;
};
getUuid(): string;
getOverlay(name: string): any;
repaint(params?: object): void;
}
interface N8nPlusEndpoint extends Endpoint {
setSuccessOutput(message: string): void;
clearSuccessOutput(): void;
}
interface Overlay {
setVisible(visible: boolean): void;
setLocation(location: number): void;
canvas?: HTMLElement;
}
interface OnConnectionBindInfo {
originalSourceEndpoint: Endpoint;
originalTargetEndpoint: Endpoint;
getParameters(): { index: number };
}
}
// EndpointOptions from jsplumb seems incomplete and wrong so we define an own one
export type IEndpointOptions = Omit<EndpointOptions, 'endpoint' | 'dragProxy'> & {
endpointStyle: EndpointStyle;
endpointHoverStyle: EndpointStyle;
endpoint?: EndpointSpec | string;
dragAllowedWhenFull?: boolean;
dropOptions?: DropOptions & {
tolerance: string;
};
dragProxy?:
| string
| string[]
| EndpointSpec
| [EndpointRectangle, EndpointRectangleOptions & { strokeWidth: number }];
};
export type EndpointStyle = {
width?: number;
height?: number;
@ -152,21 +49,6 @@ export type EndpointStyle = {
hoverMessage?: string;
};
export type IDragOptions = DragOptions & {
grid: [number, number];
filter: string;
};
export type IJsPlumbInstance = Omit<jsPlumbInstance, 'addEndpoint' | 'draggable'> & {
clearDragSelection: () => void;
addEndpoint(
el: ElementGroupRef,
params?: IEndpointOptions,
referenceParams?: IEndpointOptions,
): Endpoint | Endpoint[];
draggable(el: {}, options?: IDragOptions): IJsPlumbInstance;
};
export interface IUpdateInformation {
name: string;
key?: string;

View file

@ -1,5 +1,12 @@
<template>
<div class="node-wrapper" :style="nodePosition" :id="nodeId" data-test-id="canvas-node">
<div
class="node-wrapper"
:style="nodePosition"
:id="nodeId"
data-test-id="canvas-node"
:ref="data.name"
:data-name="data.name"
>
<div class="select-background" v-show="isSelected"></div>
<div
:class="{
@ -7,8 +14,6 @@
'touch-active': isTouchActive,
'is-touch-device': isTouchDevice,
}"
:data-name="data.name"
:ref="data.name"
>
<div
:class="nodeClass"
@ -515,7 +520,6 @@ export default mixins(
this.data.name,
!this.data.disabled,
this.data.disabled === true,
this,
),
);
this.$telemetry.track('User clicked node hover button', {
@ -698,6 +702,7 @@ export default mixins(
.items-count {
font-size: var(--font-size-s);
padding: 0;
}
}
@ -807,20 +812,33 @@ export default mixins(
}
}
}
.dot-output-endpoint:hover circle {
fill: var(--color-primary);
}
/** connector */
.jtk-connector {
z-index: 3;
}
.jtk-floating-endpoint {
opacity: 0;
}
.jtk-connector path {
transition: stroke 0.1s ease-in-out;
}
.jtk-connector.success {
.jtk-overlay {
z-index: 3;
}
.jtk-connector {
z-index: 4;
}
.node-input-endpoint-label,
.node-output-endpoint-label,
.connection-run-items-label {
z-index: 5;
}
.jtk-connector.jtk-hover {
z-index: 6;
}
@ -828,30 +846,25 @@ export default mixins(
.jtk-endpoint.plus-endpoint {
z-index: 6;
}
.jtk-endpoint.dot-output-endpoint {
z-index: 7;
}
.jtk-overlay {
z-index: 7;
overflow: auto;
}
.disabled-linethrough {
z-index: 8;
}
.jtk-connector.jtk-dragging {
z-index: 8;
}
.jtk-drag-active.dot-output-endpoint,
.jtk-drag-active.rect-input-endpoint {
z-index: 9;
}
.rect-input-endpoint > * {
pointer-events: none;
}
.connection-actions {
z-index: 10;
z-index: 100;
}
.node-options {
@ -861,88 +874,207 @@ export default mixins(
.drop-add-node-label {
z-index: 10;
}
.jtk-connector.success:not(.jtk-hover) {
path:not(.jtk-connector-outline) {
stroke: var(--color-success-light);
}
path[jtk-overlay-id='endpoint-arrow'],
path[jtk-overlay-id='midpoint-arrow'] {
fill: var(--color-success-light);
}
}
</style>
<style lang="scss">
$--stalklength: 40px;
$--box-size-medium: 24px;
$--box-size-small: 18px;
:root {
--endpoint-size-small: 14px;
--endpoint-size-medium: 18px;
--stalk-size: 40px;
--stalk-success-size: 87px;
--stalk-long-size: 127px;
--plus-endpoint-box-size: 24px;
--plus-endpoint-box-size-small: 17px;
}
.plus-svg-circle {
z-index: 111;
circle {
stroke: var(--color-foreground-xdark);
stroke-width: 2px;
fill: var(--color-foreground-xdark);
}
&:hover {
circle {
stroke: var(--color-primary);
fill: var(--color-primary);
}
}
}
.plus-stalk {
width: calc(var(--stalk-size) + 2px);
border: 1px solid var(--color-foreground-dark);
margin-left: calc(var(--stalk-size) / 2);
z-index: 3;
&.ep-success {
border-color: var(--color-success-light);
&:after {
content: attr(data-label);
position: absolute;
left: 0;
right: 0;
bottom: 100%;
margin: auto;
margin-bottom: 2px;
text-align: center;
line-height: 1.3em;
font-size: var(--font-size-s);
font-weight: var(--font-weight-regular);
color: var(--color-success);
}
}
}
.connection-run-items-label {
// Disable points events so that the label does not block the connection
// mouse over event.
pointer-events: none;
span {
border-radius: 7px;
background-color: hsla(
var(--color-canvas-background-h),
var(--color-canvas-background-s),
var(--color-canvas-background-l),
0.85
);
line-height: 1.3em;
padding: 0px 3px;
white-space: nowrap;
font-size: var(--font-size-s);
font-weight: var(--font-weight-regular);
color: var(--color-success);
margin-top: -15px;
&.floating {
position: absolute;
top: -6px;
transform: translateX(-50%);
}
}
}
.connection-input-name-label {
position: relative;
span {
position: absolute;
top: -10px;
left: -60px;
}
}
.plus-endpoint {
cursor: pointer;
.plus-stalk {
border-top: 2px solid var(--color-foreground-dark);
position: absolute;
width: $--stalklength;
height: 0;
right: 100%;
top: calc(50% - 1px);
z-index: 10;
margin-left: calc((var(--stalk-size) + var(--plus-endpoint-box-size) / 2) - 1px);
g {
fill: var(--color-background-xlight);
pointer-events: none;
.connection-run-items-label {
position: relative;
width: 100%;
span {
display: none;
left: calc(50% + 4px);
}
}
}
.plus-container {
color: var(--color-foreground-xdark);
border: 2px solid var(--color-foreground-xdark);
background-color: var(--color-background-xlight);
border-radius: var(--border-radius-base);
height: $--box-size-medium;
width: $--box-size-medium;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-2xs);
position: absolute;
top: 0;
right: 0;
pointer-events: none;
&:hover {
path {
fill: var(--color-primary);
}
rect {
stroke: var(--color-primary);
}
}
path {
fill: var(--color-foreground-xdark);
}
rect {
stroke: var(--color-foreground-xdark);
}
&.small {
height: $--box-size-small;
width: $--box-size-small;
font-size: 8px;
margin-left: calc((var(--stalk-size) + var(--plus-endpoint-box-size-small) / 2));
g {
transform: scale(0.75);
transform-origin: center;
}
rect {
stroke-width: 2.5;
}
}
&:hover .plus-container {
color: var(--color-primary);
border: 2px solid var(--color-primary);
}
&:hover .drop-hover-message {
display: block;
}
.fa-plus {
width: 1em;
}
&.hidden {
display: none;
}
}
.drop-hover-message {
.node-input-endpoint-label,
.node-output-endpoint-label {
background-color: hsla(
var(--color-canvas-background-h),
var(--color-canvas-background-s),
var(--color-canvas-background-l),
0.85
);
border-radius: 7px;
font-size: 0.7em;
padding: 2px;
white-space: nowrap;
}
.node-output-endpoint-label {
margin-left: calc(var(--endpoint-size-small) + var(--spacing-3xs));
}
.node-input-endpoint-label {
text-align: right;
margin-left: -25px;
&--moved {
margin-left: -40px;
}
}
.hover-message.jtk-overlay {
--hover-message-width: 110px;
font-weight: var(--font-weight-bold);
font-size: var(--font-size-2xs);
line-height: var(--font-line-height-regular);
color: var(--color-text-light);
position: absolute;
top: -6px;
left: calc(100% + 8px);
width: 200px;
display: none;
}
&.hidden > * {
display: none;
}
&.success .plus-stalk {
border-color: var(--color-success-light);
span {
display: inline;
width: var(--hover-message-width);
margin-left: calc(
(var(--hover-message-width) / 2) + var(--stalk-size) + var(--plus-endpoint-box-size) +
var(--spacing-2xs)
);
opacity: 0;
pointer-events: none;
&.small {
margin-left: calc(
(var(--hover-message-width) / 2) + var(--stalk-size) + var(--plus-endpoint-box-size-small) +
var(--spacing-2xs)
);
}
&.visible {
pointer-events: all;
opacity: 1;
}
}
.ep-success {
--stalk-size: var(--stalk-success-size);
}
.long-stalk {
--stalk-size: var(--stalk-long-size);
}
</style>

View file

@ -549,7 +549,7 @@ export default mixins(externalHooks, nodeHelpers).extend({
},
nameChanged(name: string) {
if (this.node) {
this.historyStore.pushCommandToUndo(new RenameNodeCommand(this.node.name, name, this));
this.historyStore.pushCommandToUndo(new RenameNodeCommand(this.node.name, name));
}
// @ts-ignore
this.valueChanged({

View file

@ -1,5 +1,11 @@
<template>
<div class="sticky-wrapper" :style="stickyPosition" :id="nodeId" ref="sticky">
<div
class="sticky-wrapper"
:id="nodeId"
:ref="data.name"
:style="stickyPosition"
:data-name="data.name"
>
<div
:class="{
'sticky-default': true,
@ -11,8 +17,6 @@
<div class="select-sticky-background" v-show="isSelected" />
<div
class="sticky-box"
:data-name="data.name"
:ref="data.name"
@click.left="mouseLeftClick"
v-touch:start="touchStart"
v-touch:end="touchEnd"
@ -186,9 +190,6 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
if (!this.isSelected && this.node) {
this.$emit('nodeSelected', this.node.name, false, true);
}
if (this.node) {
this.instance.destroyDraggable(this.node.id); // todo avoid destroying if possible
}
},
onResize({ height, width, dX, dY }: { width: number; height: number; dX: number; dY: number }) {
if (!this.node) {
@ -202,7 +203,6 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
},
onResizeEnd() {
this.isResizing = false;
this.__makeInstanceDraggable(this.data);
},
setParameters(params: { content?: string; height?: number; width?: number }) {
if (this.node) {
@ -252,8 +252,6 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
position: absolute;
.sticky-default {
position: absolute;
.sticky-box {
width: 100%;
height: 100%;

View file

@ -7,7 +7,7 @@ import 'prismjs';
import 'prismjs/themes/prism.css';
import 'vue-prism-editor/dist/VuePrismEditor.css';
import 'vue-json-pretty/lib/styles.css';
import '@jsplumb/browser-ui/css/jsplumbtoolkit.css';
import 'n8n-design-system/css/index.scss';
import './n8n-theme.scss';

View file

@ -203,12 +203,12 @@ export const mouseSelect = mixins(deviceSupportHelpers).extend({
nodeDeselected(node: INodeUi) {
this.uiStore.removeNodeFromSelection(node);
// @ts-ignore
this.instance.removeFromDragSelection(node.id);
this.instance.removeFromDragSelection(this.$refs[`node-${node.id}`][0].$el);
},
nodeSelected(node: INodeUi) {
this.uiStore.addSelectedNode(node);
// @ts-ignore
this.instance.addToDragSelection(node.id);
this.instance.addToDragSelection(this.$refs[`node-${node.id}`][0].$el);
},
deselectAllNodes() {
// @ts-ignore

View file

@ -1,18 +1,19 @@
import { PropType } from 'vue';
import mixins from 'vue-typed-mixins';
import { IJsPlumbInstance, IEndpointOptions, INodeUi, XYPosition } from '@/Interface';
import { INodeUi } from '@/Interface';
import { deviceSupportHelpers } from '@/mixins/deviceSupportHelpers';
import { NO_OP_NODE_TYPE, STICKY_NODE_TYPE } from '@/constants';
import { NO_OP_NODE_TYPE } from '@/constants';
import { INodeTypeDescription } from 'n8n-workflow';
import { mapStores } from 'pinia';
import { useUIStore } from '@/stores/ui';
import { useWorkflowsStore } from '@/stores/workflows';
import { useNodeTypesStore } from '@/stores/nodeTypes';
import { BrowserJsPlumbInstance } from '@jsplumb/browser-ui';
import { EndpointOptions } from '@jsplumb/core';
import * as NodeViewUtils from '@/utils/nodeViewUtils';
import { getStyleTokenValue } from '@/utils';
import { useHistoryStore } from '@/stores/history';
import { MoveNodeCommand } from '@/models/history';
import { useCanvasStore } from '@/stores/canvas';
export const nodeBase = mixins(deviceSupportHelpers).extend({
mounted() {
@ -27,7 +28,7 @@ export const nodeBase = mixins(deviceSupportHelpers).extend({
}
},
computed: {
...mapStores(useNodeTypesStore, useUIStore, useWorkflowsStore, useHistoryStore),
...mapStores(useNodeTypesStore, useUIStore, useCanvasStore, useWorkflowsStore, useHistoryStore),
data(): INodeUi | null {
return this.workflowsStore.getNodeByName(this.name);
},
@ -40,7 +41,7 @@ export const nodeBase = mixins(deviceSupportHelpers).extend({
type: String,
},
instance: {
type: Object as PropType<IJsPlumbInstance>,
type: Object as PropType<BrowserJsPlumbInstance>,
},
isReadOnly: {
type: Boolean,
@ -79,18 +80,15 @@ export const nodeBase = mixins(deviceSupportHelpers).extend({
const anchorPosition =
NodeViewUtils.ANCHOR_POSITIONS.input[nodeTypeData.inputs.length][index];
const newEndpointData: IEndpointOptions = {
const newEndpointData: EndpointOptions = {
uuid: NodeViewUtils.getInputEndpointUUID(this.nodeId, index),
anchor: anchorPosition,
maxConnections: -1,
endpoint: 'Rectangle',
endpointStyle: NodeViewUtils.getInputEndpointStyle(
nodeTypeData,
'--color-foreground-xdark',
),
endpointHoverStyle: NodeViewUtils.getInputEndpointStyle(nodeTypeData, '--color-primary'),
isSource: false,
isTarget: !this.isReadOnly && nodeTypeData.inputs.length > 1, // only enabled for nodes with multiple inputs.. otherwise attachment handled by connectionDrag event in NodeView,
paintStyle: NodeViewUtils.getInputEndpointStyle(nodeTypeData, '--color-foreground-xdark'),
hoverPaintStyle: NodeViewUtils.getInputEndpointStyle(nodeTypeData, '--color-primary'),
source: false,
target: !this.isReadOnly && nodeTypeData.inputs.length > 1, // only enabled for nodes with multiple inputs.. otherwise attachment handled by connectionDrag event in NodeView,
parameters: {
nodeId: this.nodeId,
type: inputName,
@ -99,20 +97,17 @@ export const nodeBase = mixins(deviceSupportHelpers).extend({
enabled: !this.isReadOnly, // enabled in default case to allow dragging
cssClass: 'rect-input-endpoint',
dragAllowedWhenFull: true,
dropOptions: {
tolerance: 'touch',
hoverClass: 'dropHover',
},
};
const endpoint = this.instance?.addEndpoint(
this.$refs[this.data.name] as Element,
newEndpointData,
);
if (nodeTypeData.inputNames) {
// Apply input names if they got set
newEndpointData.overlays = [
NodeViewUtils.getInputNameOverlay(nodeTypeData.inputNames[index]),
];
endpoint.addOverlay(NodeViewUtils.getInputNameOverlay(nodeTypeData.inputNames[index]));
}
const endpoint = this.instance.addEndpoint(this.nodeId, newEndpointData);
if (!Array.isArray(endpoint)) {
endpoint.__meta = {
nodeName: node.name,
@ -133,6 +128,9 @@ export const nodeBase = mixins(deviceSupportHelpers).extend({
// this.instance.makeTarget(this.nodeId, newEndpointData);
// }
});
if (nodeTypeData.inputs.length === 0) {
this.instance.manage(this.$refs[this.data.name] as Element);
}
},
__addOutputEndpoints(node: INodeUi, nodeTypeData: INodeTypeDescription) {
let index;
@ -153,37 +151,46 @@ export const nodeBase = mixins(deviceSupportHelpers).extend({
const anchorPosition =
NodeViewUtils.ANCHOR_POSITIONS.output[nodeTypeData.outputs.length][index];
const newEndpointData: IEndpointOptions = {
const newEndpointData: EndpointOptions = {
uuid: NodeViewUtils.getOutputEndpointUUID(this.nodeId, index),
anchor: anchorPosition,
maxConnections: -1,
endpoint: 'Dot',
endpointStyle: NodeViewUtils.getOutputEndpointStyle(
endpoint: {
type: 'Dot',
options: {
radius: nodeTypeData && nodeTypeData.outputs.length > 2 ? 7 : 9,
},
},
paintStyle: NodeViewUtils.getOutputEndpointStyle(
nodeTypeData,
'--color-foreground-xdark',
),
endpointHoverStyle: NodeViewUtils.getOutputEndpointStyle(nodeTypeData, '--color-primary'),
isSource: true,
isTarget: false,
hoverPaintStyle: NodeViewUtils.getOutputEndpointStyle(nodeTypeData, '--color-primary'),
source: true,
target: false,
enabled: !this.isReadOnly,
parameters: {
nodeId: this.nodeId,
type: inputName,
index,
},
hoverClass: 'dot-output-endpoint-hover',
connectionsDirected: true,
cssClass: 'dot-output-endpoint',
dragAllowedWhenFull: false,
dragProxy: ['Rectangle', { width: 1, height: 1, strokeWidth: 0 }],
};
const endpoint = this.instance.addEndpoint(
this.$refs[this.data.name] as Element,
newEndpointData,
);
if (nodeTypeData.outputNames) {
// Apply output names if they got set
newEndpointData.overlays = [
NodeViewUtils.getOutputNameOverlay(nodeTypeData.outputNames[index]),
];
const overlaySpec = NodeViewUtils.getOutputNameOverlay(nodeTypeData.outputNames[index]);
const overlay = endpoint.addOverlay(overlaySpec);
}
const endpoint = this.instance.addEndpoint(this.nodeId, { ...newEndpointData });
if (!Array.isArray(endpoint)) {
endpoint.__meta = {
nodeName: node.name,
@ -194,26 +201,28 @@ export const nodeBase = mixins(deviceSupportHelpers).extend({
}
if (!this.isReadOnly) {
const plusEndpointData: IEndpointOptions = {
const plusEndpointData: EndpointOptions = {
uuid: NodeViewUtils.getOutputEndpointUUID(this.nodeId, index),
anchor: anchorPosition,
maxConnections: -1,
endpoint: 'N8nPlus',
isSource: true,
isTarget: false,
enabled: !this.isReadOnly,
endpointStyle: {
fill: getStyleTokenValue('--color-xdark'),
outlineStroke: 'none',
hover: false,
endpoint: {
type: 'N8nPlus',
options: {
dimensions: 24,
connectedEndpoint: endpoint,
showOutputLabel: nodeTypeData.outputs.length === 1,
size: nodeTypeData.outputs.length >= 3 ? 'small' : 'medium',
hoverMessage: this.$locale.baseText('nodeBase.clickToAddNodeOrDragToConnect'),
},
endpointHoverStyle: {
fill: getStyleTokenValue('--color-primary'),
},
source: true,
target: false,
enabled: !this.isReadOnly,
paintStyle: {
outlineStroke: 'none',
},
hoverPaintStyle: {
outlineStroke: 'none',
hover: true, // hack to distinguish hover state
},
parameters: {
nodeId: this.nodeId,
@ -222,10 +231,12 @@ export const nodeBase = mixins(deviceSupportHelpers).extend({
},
cssClass: 'plus-draggable-endpoint',
dragAllowedWhenFull: false,
dragProxy: ['Rectangle', { width: 1, height: 1, strokeWidth: 0 }],
};
const plusEndpoint = this.instance.addEndpoint(
this.$refs[this.data.name] as Element,
plusEndpointData,
);
const plusEndpoint = this.instance.addEndpoint(this.nodeId, plusEndpointData);
if (!Array.isArray(plusEndpoint)) {
plusEndpoint.__meta = {
nodeName: node.name,
@ -237,105 +248,12 @@ export const nodeBase = mixins(deviceSupportHelpers).extend({
}
});
},
__makeInstanceDraggable(node: INodeUi) {
// TODO: This caused problems with displaying old information
// https://github.com/jsplumb/katavorio/wiki
// https://jsplumb.github.io/jsplumb/home.html
// Make nodes draggable
this.instance.draggable(this.nodeId, {
grid: [NodeViewUtils.GRID_SIZE, NodeViewUtils.GRID_SIZE],
start: (params: { e: MouseEvent }) => {
if (this.isReadOnly === true) {
// Do not allow to move nodes in readOnly mode
return false;
}
// @ts-ignore
this.dragging = true;
const isSelected = this.uiStore.isNodeSelected(this.data.name);
const nodeName = this.data.name;
if (this.data.type === STICKY_NODE_TYPE && !isSelected) {
setTimeout(() => {
this.$emit('nodeSelected', nodeName, false, true);
}, 0);
}
if (params.e && !isSelected) {
// Only the node which gets dragged directly gets an event, for all others it is
// undefined. So check if the currently dragged node is selected and if not clear
// the drag-selection.
this.instance.clearDragSelection();
this.uiStore.resetSelectedNodes();
}
this.uiStore.addActiveAction('dragActive');
return true;
},
stop: (params: { e: MouseEvent }) => {
// @ts-ignore
this.dragging = false;
if (this.uiStore.isActionActive('dragActive')) {
const moveNodes = this.uiStore.getSelectedNodes.slice();
const selectedNodeNames = moveNodes.map((node: INodeUi) => node.name);
if (!selectedNodeNames.includes(this.data.name)) {
// If the current node is not in selected add it to the nodes which
// got moved manually
moveNodes.push(this.data);
}
if (moveNodes.length > 1) {
this.historyStore.startRecordingUndo();
}
// This does for some reason just get called once for the node that got clicked
// even though "start" and "drag" gets called for all. So lets do for now
// some dirty DOM query to get the new positions till I have more time to
// create a proper solution
let newNodePosition: XYPosition;
moveNodes.forEach((node: INodeUi) => {
const element = document.getElementById(node.id);
if (element === null) {
return;
}
newNodePosition = [
parseInt(element.style.left!.slice(0, -2), 10),
parseInt(element.style.top!.slice(0, -2), 10),
];
const updateInformation = {
name: node.name,
properties: {
// @ts-ignore, draggable does not have definitions
position: newNodePosition,
},
};
const oldPosition = node.position;
if (oldPosition[0] !== newNodePosition[0] || oldPosition[1] !== newNodePosition[1]) {
this.historyStore.pushCommandToUndo(
new MoveNodeCommand(node.name, oldPosition, newNodePosition, this),
);
this.workflowsStore.updateNodeProperties(updateInformation);
this.$emit('moved', node);
}
});
if (moveNodes.length > 1) {
this.historyStore.stopRecordingUndo();
}
}
},
filter: '.node-description, .node-description .node-name, .node-description .node-subtitle',
});
},
__addNode(node: INodeUi) {
let nodeTypeData = this.nodeTypesStore.getNodeType(node.type, node.typeVersion);
if (!nodeTypeData) {
// If node type is not know use by default the base.noOp data to display it
nodeTypeData = this.nodeTypesStore.getNodeType(NO_OP_NODE_TYPE);
}
const nodeTypeData = (this.nodeTypesStore.getNodeType(node.type, node.typeVersion) ??
this.nodeTypesStore.getNodeType(NO_OP_NODE_TYPE)) as INodeTypeDescription;
this.__addInputEndpoints(node, nodeTypeData);
this.__addOutputEndpoints(node, nodeTypeData);
this.__makeInstanceDraggable(node);
},
touchEnd(e: MouseEvent) {
if (this.isTouchDevice) {

View file

@ -505,7 +505,7 @@ export const nodeHelpers = mixins(restApi).extend({
this.updateNodeCredentialIssues(node);
if (trackHistory) {
this.historyStore.pushCommandToUndo(
new EnableNodeToggleCommand(node.name, oldState === true, node.disabled === true, this),
new EnableNodeToggleCommand(node.name, oldState === true, node.disabled === true),
);
}
}

View file

@ -21,17 +21,16 @@ export enum COMMANDS {
// this timeout in between canvas actions
// (0 is usually enough but leaving this just in case)
const CANVAS_ACTION_TIMEOUT = 10;
export const historyBus = new Vue();
export abstract class Undoable {}
export abstract class Command extends Undoable {
readonly name: string;
eventBus: Vue;
constructor(name: string, eventBus: Vue) {
constructor(name: string) {
super();
this.name = name;
this.eventBus = eventBus;
}
abstract getReverseCommand(): Command;
abstract isEqualTo(anotherCommand: Command): boolean;
@ -52,15 +51,15 @@ export class MoveNodeCommand extends Command {
oldPosition: XYPosition;
newPosition: XYPosition;
constructor(nodeName: string, oldPosition: XYPosition, newPosition: XYPosition, eventBus: Vue) {
super(COMMANDS.MOVE_NODE, eventBus);
constructor(nodeName: string, oldPosition: XYPosition, newPosition: XYPosition) {
super(COMMANDS.MOVE_NODE);
this.nodeName = nodeName;
this.newPosition = newPosition;
this.oldPosition = oldPosition;
}
getReverseCommand(): Command {
return new MoveNodeCommand(this.nodeName, this.newPosition, this.oldPosition, this.eventBus);
return new MoveNodeCommand(this.nodeName, this.newPosition, this.oldPosition);
}
isEqualTo(anotherCommand: Command): boolean {
@ -76,7 +75,7 @@ export class MoveNodeCommand extends Command {
async revert(): Promise<void> {
return new Promise<void>((resolve) => {
this.eventBus.$root.$emit('nodeMove', {
historyBus.$emit('nodeMove', {
nodeName: this.nodeName,
position: this.oldPosition,
});
@ -88,13 +87,13 @@ export class MoveNodeCommand extends Command {
export class AddNodeCommand extends Command {
node: INodeUi;
constructor(node: INodeUi, eventBus: Vue) {
super(COMMANDS.ADD_NODE, eventBus);
constructor(node: INodeUi) {
super(COMMANDS.ADD_NODE);
this.node = node;
}
getReverseCommand(): Command {
return new RemoveNodeCommand(this.node, this.eventBus);
return new RemoveNodeCommand(this.node);
}
isEqualTo(anotherCommand: Command): boolean {
@ -103,7 +102,7 @@ export class AddNodeCommand extends Command {
async revert(): Promise<void> {
return new Promise<void>((resolve) => {
this.eventBus.$root.$emit('revertAddNode', { node: this.node });
historyBus.$emit('revertAddNode', { node: this.node });
resolve();
});
}
@ -112,13 +111,13 @@ export class AddNodeCommand extends Command {
export class RemoveNodeCommand extends Command {
node: INodeUi;
constructor(node: INodeUi, eventBus: Vue) {
super(COMMANDS.REMOVE_NODE, eventBus);
constructor(node: INodeUi) {
super(COMMANDS.REMOVE_NODE);
this.node = node;
}
getReverseCommand(): Command {
return new AddNodeCommand(this.node, this.eventBus);
return new AddNodeCommand(this.node);
}
isEqualTo(anotherCommand: Command): boolean {
@ -127,7 +126,7 @@ export class RemoveNodeCommand extends Command {
async revert(): Promise<void> {
return new Promise<void>((resolve) => {
this.eventBus.$root.$emit('revertRemoveNode', { node: this.node });
historyBus.$emit('revertRemoveNode', { node: this.node });
resolve();
});
}
@ -136,13 +135,13 @@ export class RemoveNodeCommand extends Command {
export class AddConnectionCommand extends Command {
connectionData: [IConnection, IConnection];
constructor(connectionData: [IConnection, IConnection], eventBus: Vue) {
super(COMMANDS.ADD_CONNECTION, eventBus);
constructor(connectionData: [IConnection, IConnection]) {
super(COMMANDS.ADD_CONNECTION);
this.connectionData = connectionData;
}
getReverseCommand(): Command {
return new RemoveConnectionCommand(this.connectionData, this.eventBus);
return new RemoveConnectionCommand(this.connectionData);
}
isEqualTo(anotherCommand: Command): boolean {
@ -157,7 +156,7 @@ export class AddConnectionCommand extends Command {
async revert(): Promise<void> {
return new Promise<void>((resolve) => {
this.eventBus.$root.$emit('revertAddConnection', { connection: this.connectionData });
historyBus.$emit('revertAddConnection', { connection: this.connectionData });
resolve();
});
}
@ -166,13 +165,13 @@ export class AddConnectionCommand extends Command {
export class RemoveConnectionCommand extends Command {
connectionData: [IConnection, IConnection];
constructor(connectionData: [IConnection, IConnection], eventBus: Vue) {
super(COMMANDS.REMOVE_CONNECTION, eventBus);
constructor(connectionData: [IConnection, IConnection]) {
super(COMMANDS.REMOVE_CONNECTION);
this.connectionData = connectionData;
}
getReverseCommand(): Command {
return new AddConnectionCommand(this.connectionData, this.eventBus);
return new AddConnectionCommand(this.connectionData);
}
isEqualTo(anotherCommand: Command): boolean {
@ -188,7 +187,7 @@ export class RemoveConnectionCommand extends Command {
async revert(): Promise<void> {
return new Promise<void>((resolve) => {
setTimeout(() => {
this.eventBus.$root.$emit('revertRemoveConnection', { connection: this.connectionData });
historyBus.$emit('revertRemoveConnection', { connection: this.connectionData });
resolve();
}, CANVAS_ACTION_TIMEOUT);
});
@ -200,15 +199,15 @@ export class EnableNodeToggleCommand extends Command {
oldState: boolean;
newState: boolean;
constructor(nodeName: string, oldState: boolean, newState: boolean, eventBus: Vue) {
super(COMMANDS.ENABLE_NODE_TOGGLE, eventBus);
constructor(nodeName: string, oldState: boolean, newState: boolean) {
super(COMMANDS.ENABLE_NODE_TOGGLE);
this.nodeName = nodeName;
this.newState = newState;
this.oldState = oldState;
}
getReverseCommand(): Command {
return new EnableNodeToggleCommand(this.nodeName, this.newState, this.oldState, this.eventBus);
return new EnableNodeToggleCommand(this.nodeName, this.newState, this.oldState);
}
isEqualTo(anotherCommand: Command): boolean {
@ -219,7 +218,7 @@ export class EnableNodeToggleCommand extends Command {
async revert(): Promise<void> {
return new Promise<void>((resolve) => {
this.eventBus.$root.$emit('enableNodeToggle', {
historyBus.$emit('enableNodeToggle', {
nodeName: this.nodeName,
isDisabled: this.oldState,
});
@ -232,14 +231,14 @@ export class RenameNodeCommand extends Command {
currentName: string;
newName: string;
constructor(currentName: string, newName: string, eventBus: Vue) {
super(COMMANDS.RENAME_NODE, eventBus);
constructor(currentName: string, newName: string) {
super(COMMANDS.RENAME_NODE);
this.currentName = currentName;
this.newName = newName;
}
getReverseCommand(): Command {
return new RenameNodeCommand(this.newName, this.currentName, this.eventBus);
return new RenameNodeCommand(this.newName, this.currentName);
}
isEqualTo(anotherCommand: Command): boolean {
@ -252,7 +251,7 @@ export class RenameNodeCommand extends Command {
async revert(): Promise<void> {
return new Promise<void>((resolve) => {
this.eventBus.$root.$emit('revertRenameNode', {
historyBus.$emit('revertRenameNode', {
currentName: this.currentName,
newName: this.newName,
});

View file

@ -1,896 +0,0 @@
/**
* Custom connector type
* Based on jsplumb Flowchart and Bezier types
*
* Source GitHub repository:
* https://github.com/jsplumb/jsplumb
*
* Source files:
* https://github.com/jsplumb/jsplumb/blob/fb5fce52794fa52306825bdaa62bf3855cdfd7e0/src/connectors-flowchart.js
* https://github.com/jsplumb/jsplumb/blob/fb5fce52794fa52306825bdaa62bf3855cdfd7e0/src/connectors-bezier.js
*
*
* All 1.x.x and 2.x.x versions of jsPlumb Community edition, and so also the
* content of this file, are dual-licensed under both MIT and GPLv2.
*
* MIT LICENSE
*
* Copyright (c) 2010 - 2014 jsPlumb, http://jsplumbtoolkit.com/
*
* Permission is hereby granted, free of charge, to any person obtaining
* a copy of this software and associated documentation files (the
* "Software"), to deal in the Software without restriction, including
* without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to
* permit persons to whom the Software is furnished to do so, subject to
* the following conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
* LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
* OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*
* ===============================================================================
* GNU GENERAL PUBLIC LICENSE
* Version 2, June 1991
*
* Copyright (C) 1989, 1991 Free Software Foundation, Inc.
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
* Everyone is permitted to copy and distribute verbatim copies
* of this license document, but changing it is not allowed.
*
* Preamble
*
* The licenses for most software are designed to take away your
* freedom to share and change it. By contrast, the GNU General Public
* License is intended to guarantee your freedom to share and change free
* software--to make sure the software is free for all its users. This
* General Public License applies to most of the Free Software
* Foundation's software and to any other program whose authors commit to
* using it. (Some other Free Software Foundation software is covered by
* the GNU Lesser General Public License instead.) You can apply it to
* your programs, too.
*
* When we speak of free software, we are referring to freedom, not
* price. Our General Public Licenses are designed to make sure that you
* have the freedom to distribute copies of free software (and charge for
* this service if you wish), that you receive source code or can get it
* if you want it, that you can change the software or use pieces of it
* in new free programs; and that you know you can do these things.
*
* To protect your rights, we need to make restrictions that forbid
* anyone to deny you these rights or to ask you to surrender the rights.
* These restrictions translate to certain responsibilities for you if you
* distribute copies of the software, or if you modify it.
*
* For example, if you distribute copies of such a program, whether
* gratis or for a fee, you must give the recipients all the rights that
* you have. You must make sure that they, too, receive or can get the
* source code. And you must show them these terms so they know their
* rights.
*
* We protect your rights with two steps: (1) copyright the software, and
* (2) offer you this license which gives you legal permission to copy,
* distribute and/or modify the software.
*
* Also, for each author's protection and ours, we want to make certain
* that everyone understands that there is no warranty for this free
* software. If the software is modified by someone else and passed on, we
* want its recipients to know that what they have is not the original, so
* that any problems introduced by others will not reflect on the original
* authors' reputations.
*
* Finally, any free program is threatened constantly by software
* patents. We wish to avoid the danger that redistributors of a free
* program will individually obtain patent licenses, in effect making the
* program proprietary. To prevent this, we have made it clear that any
* patent must be licensed for everyone's free use or not licensed at all.
*
* The precise terms and conditions for copying, distribution and
* modification follow.
*
* GNU GENERAL PUBLIC LICENSE
* TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
*
* 0. This License applies to any program or other work which contains
* a notice placed by the copyright holder saying it may be distributed
* under the terms of this General Public License. The "Program", below,
* refers to any such program or work, and a "work based on the Program"
* means either the Program or any derivative work under copyright law:
* that is to say, a work containing the Program or a portion of it,
* either verbatim or with modifications and/or translated into another
* language. (Hereinafter, translation is included without limitation in
* the term "modification".) Each licensee is addressed as "you".
*
* Activities other than copying, distribution and modification are not
* covered by this License; they are outside its scope. The act of
* running the Program is not restricted, and the output from the Program
* is covered only if its contents constitute a work based on the
* Program (independent of having been made by running the Program).
* Whether that is true depends on what the Program does.
*
* 1. You may copy and distribute verbatim copies of the Program's
* source code as you receive it, in any medium, provided that you
* conspicuously and appropriately publish on each copy an appropriate
* copyright notice and disclaimer of warranty; keep intact all the
* notices that refer to this License and to the absence of any warranty;
* and give any other recipients of the Program a copy of this License
* along with the Program.
*
* You may charge a fee for the physical act of transferring a copy, and
* you may at your option offer warranty protection in exchange for a fee.
*
* 2. You may modify your copy or copies of the Program or any portion
* of it, thus forming a work based on the Program, and copy and
* distribute such modifications or work under the terms of Section 1
* above, provided that you also meet all of these conditions:
*
* a) You must cause the modified files to carry prominent notices
* stating that you changed the files and the date of any change.
*
* b) You must cause any work that you distribute or publish, that in
* whole or in part contains or is derived from the Program or any
* part thereof, to be licensed as a whole at no charge to all third
* parties under the terms of this License.
*
* c) If the modified program normally reads commands interactively
* when run, you must cause it, when started running for such
* interactive use in the most ordinary way, to print or display an
* announcement including an appropriate copyright notice and a
* notice that there is no warranty (or else, saying that you provide
* a warranty) and that users may redistribute the program under
* these conditions, and telling the user how to view a copy of this
* License. (Exception: if the Program itself is interactive but
* does not normally print such an announcement, your work based on
* the Program is not required to print an announcement.)
*
* These requirements apply to the modified work as a whole. If
* identifiable sections of that work are not derived from the Program,
* and can be reasonably considered independent and separate works in
* themselves, then this License, and its terms, do not apply to those
* sections when you distribute them as separate works. But when you
* distribute the same sections as part of a whole which is a work based
* on the Program, the distribution of the whole must be on the terms of
* this License, whose permissions for other licensees extend to the
* entire whole, and thus to each and every part regardless of who wrote it.
*
* Thus, it is not the intent of this section to claim rights or contest
* your rights to work written entirely by you; rather, the intent is to
* exercise the right to control the distribution of derivative or
* collective works based on the Program.
*
* In addition, mere aggregation of another work not based on the Program
* with the Program (or with a work based on the Program) on a volume of
* a storage or distribution medium does not bring the other work under
* the scope of this License.
*
* 3. You may copy and distribute the Program (or a work based on it,
* under Section 2) in object code or executable form under the terms of
* Sections 1 and 2 above provided that you also do one of the following:
*
* a) Accompany it with the complete corresponding machine-readable
* source code, which must be distributed under the terms of Sections
* 1 and 2 above on a medium customarily used for software interchange; or,
*
* b) Accompany it with a written offer, valid for at least three
* years, to give any third party, for a charge no more than your
* cost of physically performing source distribution, a complete
* machine-readable copy of the corresponding source code, to be
* distributed under the terms of Sections 1 and 2 above on a medium
* customarily used for software interchange; or,
*
* c) Accompany it with the information you received as to the offer
* to distribute corresponding source code. (This alternative is
* allowed only for noncommercial distribution and only if you
* received the program in object code or executable form with such
* an offer, in accord with Subsection b above.)
*
* The source code for a work means the preferred form of the work for
* making modifications to it. For an executable work, complete source
* code means all the source code for all modules it contains, plus any
* associated interface definition files, plus the scripts used to
* control compilation and installation of the executable. However, as a
* special exception, the source code distributed need not include
* anything that is normally distributed (in either source or binary
* form) with the major components (compiler, kernel, and so on) of the
* operating system on which the executable runs, unless that component
* itself accompanies the executable.
*
* If distribution of executable or object code is made by offering
* access to copy from a designated place, then offering equivalent
* access to copy the source code from the same place counts as
* distribution of the source code, even though third parties are not
* compelled to copy the source along with the object code.
*
* 4. You may not copy, modify, sublicense, or distribute the Program
* except as expressly provided under this License. Any attempt
* otherwise to copy, modify, sublicense or distribute the Program is
* void, and will automatically terminate your rights under this License.
* However, parties who have received copies, or rights, from you under
* this License will not have their licenses terminated so long as such
* parties remain in full compliance.
*
* 5. You are not required to accept this License, since you have not
* signed it. However, nothing else grants you permission to modify or
* distribute the Program or its derivative works. These actions are
* prohibited by law if you do not accept this License. Therefore, by
* modifying or distributing the Program (or any work based on the
* Program), you indicate your acceptance of this License to do so, and
* all its terms and conditions for copying, distributing or modifying
* the Program or works based on it.
*
* 6. Each time you redistribute the Program (or any work based on the
* Program), the recipient automatically receives a license from the
* original licensor to copy, distribute or modify the Program subject to
* these terms and conditions. You may not impose any further
* restrictions on the recipients' exercise of the rights granted herein.
* You are not responsible for enforcing compliance by third parties to
* this License.
*
* 7. If, as a consequence of a court judgment or allegation of patent
* infringement or for any other reason (not limited to patent issues),
* conditions are imposed on you (whether by court order, agreement or
* otherwise) that contradict the conditions of this License, they do not
* excuse you from the conditions of this License. If you cannot
* distribute so as to satisfy simultaneously your obligations under this
* License and any other pertinent obligations, then as a consequence you
* may not distribute the Program at all. For example, if a patent
* license would not permit royalty-free redistribution of the Program by
* all those who receive copies directly or indirectly through you, then
* the only way you could satisfy both it and this License would be to
* refrain entirely from distribution of the Program.
*
* If any portion of this section is held invalid or unenforceable under
* any particular circumstance, the balance of the section is intended to
* apply and the section as a whole is intended to apply in other
* circumstances.
*
* It is not the purpose of this section to induce you to infringe any
* patents or other property right claims or to contest validity of any
* such claims; this section has the sole purpose of protecting the
* integrity of the free software distribution system, which is
* implemented by public license practices. Many people have made
* generous contributions to the wide range of software distributed
* through that system in reliance on consistent application of that
* system; it is up to the author/donor to decide if he or she is willing
* to distribute software through any other system and a licensee cannot
* impose that choice.
*
* This section is intended to make thoroughly clear what is believed to
* be a consequence of the rest of this License.
*
* 8. If the distribution and/or use of the Program is restricted in
* certain countries either by patents or by copyrighted interfaces, the
* original copyright holder who places the Program under this License
* may add an explicit geographical distribution limitation excluding
* those countries, so that distribution is permitted only in or among
* countries not thus excluded. In such case, this License incorporates
* the limitation as if written in the body of this License.
*
* 9. The Free Software Foundation may publish revised and/or new versions
* of the General Public License from time to time. Such new versions will
* be similar in spirit to the present version, but may differ in detail to
* address new problems or concerns.
*
* Each version is given a distinguishing version number. If the Program
* specifies a version number of this License which applies to it and "any
* later version", you have the option of following the terms and conditions
* either of that version or of any later version published by the Free
* Software Foundation. If the Program does not specify a version number of
* this License, you may choose any version ever published by the Free Software
* Foundation.
*
* 10. If you wish to incorporate parts of the Program into other free
* programs whose distribution conditions are different, write to the author
* to ask for permission. For software which is copyrighted by the Free
* Software Foundation, write to the Free Software Foundation; we sometimes
* make exceptions for this. Our decision will be guided by the two goals
* of preserving the free status of all derivatives of our free software and
* of promoting the sharing and reuse of software generally.
*
* NO WARRANTY
*
* 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
* FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
* OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
* PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
* OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
* TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
* PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
* REPAIR OR CORRECTION.
*
* 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
* WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
* REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
* INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
* OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
* TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
* YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
* PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGES.
*
*/
(function () {
'use strict';
var root = window,
_jp = root.jsPlumb,
_ju = root.jsPlumbUtil,
_jg = root.Biltong;
var STRAIGHT = 'Straight';
var ARC = 'Arc';
/**
* Custom connector type
*
* @param stub {number} length of stub segments in flowchart
* @param getEndpointOffset {Function} callback to offset stub length based on endpoint in flowchart
* @param midpoint {number} float percent of halfway point of segments in flowchart
* @param loopbackVerticalLength {number} height of vertical segment when looping in flowchart
* @param cornerRadius {number} radius of flowchart connectors
* @param loopbackMinimum {number} minimum threshold before looping behavior takes effect in flowchart
* @param targetGap {number} gap between connector and target endpoint in both flowchart and bezier
*/
const N8nCustom = function (params) {
params = params || {};
this.type = 'N8nCustom';
params.stub = params.stub == null ? 30 : params.stub;
var _super = _jp.Connectors.AbstractConnector.apply(this, arguments),
minorAnchor = 0, // seems to be angle at which connector leaves endpoint
majorAnchor = 0, // translates to curviness of bezier curve
segments,
midpoint = params.midpoint == null ? 0.5 : params.midpoint,
alwaysRespectStubs = params.alwaysRespectStubs === true,
loopbackVerticalLength = params.loopbackVerticalLength || 0,
lastx = null,
lasty = null,
cornerRadius = params.cornerRadius != null ? params.cornerRadius : 0,
loopbackMinimum = params.loopbackMinimum || 100,
curvinessCoeffient = 0.4,
zBezierOffset = 40,
targetGap = params.targetGap || 0,
stub = params.stub || 0;
/**
* Set target endpoint
* (to override default behavior tracking mouse when dragging mouse)
* @param {Endpoint} endpoint
*/
this.setTargetEndpoint = function (endpoint) {
this.overrideTargetEndpoint = endpoint;
};
/**
* reset target endpoint overriding default behavior
*/
this.resetTargetEndpoint = function () {
this.overrideTargetEndpoint = null;
};
this._compute = function (originalPaintInfo, connParams) {
const paintInfo = _getPaintInfo(connParams, {
targetGap,
stub,
overrideTargetEndpoint: this.overrideTargetEndpoint,
getEndpointOffset: params.getEndpointOffset,
});
Object.keys(paintInfo).forEach((key) => {
// override so that bounding box is calculated correctly wheen target override is set
originalPaintInfo[key] = paintInfo[key];
});
if (paintInfo.tx < 0) {
this._computeFlowchart(paintInfo);
} else {
this._computeBezier(paintInfo);
}
};
this._computeBezier = function (paintInfo) {
var sp = paintInfo.sourcePos,
tp = paintInfo.targetPos,
_w = Math.abs(sp[0] - tp[0]) - paintInfo.targetGap,
_h = Math.abs(sp[1] - tp[1]);
var _CP,
_CP2,
_sx = sp[0] < tp[0] ? _w : 0,
_sy = sp[1] < tp[1] ? _h : 0,
_tx = sp[0] < tp[0] ? 0 : _w,
_ty = sp[1] < tp[1] ? 0 : _h;
if (paintInfo.ySpan <= 20 || (paintInfo.ySpan <= 100 && paintInfo.xSpan <= 100)) {
majorAnchor = 0.1;
} else {
majorAnchor = paintInfo.xSpan * curvinessCoeffient + zBezierOffset;
}
_CP = _findControlPoint(
[_sx, _sy],
sp,
tp,
paintInfo.sourceEndpoint,
paintInfo.targetEndpoint,
paintInfo.so,
paintInfo.to,
majorAnchor,
minorAnchor,
);
_CP2 = _findControlPoint(
[_tx, _ty],
tp,
sp,
paintInfo.targetEndpoint,
paintInfo.sourceEndpoint,
paintInfo.to,
paintInfo.so,
majorAnchor,
minorAnchor,
);
_super.addSegment(this, 'Bezier', {
x1: _sx,
y1: _sy,
x2: _tx,
y2: _ty,
cp1x: _CP[0],
cp1y: _CP[1],
cp2x: _CP2[0],
cp2y: _CP2[1],
});
};
/**
* helper method to add a segment.
*/
const addFlowchartSegment = function (segments, x, y, paintInfo) {
if (lastx === x && lasty === y) {
return;
}
var lx = lastx == null ? paintInfo.sx : lastx,
ly = lasty == null ? paintInfo.sy : lasty,
o = lx === x ? 'v' : 'h';
lastx = x;
lasty = y;
segments.push([lx, ly, x, y, o]);
};
this._computeFlowchart = function (paintInfo) {
segments = [];
lastx = null;
lasty = null;
// calculate Stubs.
var stubs = calcualteStubSegment(paintInfo, { alwaysRespectStubs });
// add the start stub segment. use stubs for loopback as it will look better, with the loop spaced
// away from the element.
addFlowchartSegment(segments, stubs[0], stubs[1], paintInfo);
// compute the rest of the line
var p = calculateLineSegment(paintInfo, stubs, {
midpoint,
loopbackMinimum,
loopbackVerticalLength,
});
if (p) {
for (var i = 0; i < p.length; i++) {
addFlowchartSegment(segments, p[i][0], p[i][1], paintInfo);
}
}
// line to end stub
addFlowchartSegment(segments, stubs[2], stubs[3], paintInfo);
// end stub to end (common)
addFlowchartSegment(segments, paintInfo.tx, paintInfo.ty, paintInfo);
// write out the segments.
writeFlowchartSegments(_super, this, segments, paintInfo, cornerRadius);
};
};
_jp.Connectors.N8nCustom = N8nCustom;
_ju.extend(_jp.Connectors.N8nCustom, _jp.Connectors.AbstractConnector);
function _findControlPoint(
point,
sourceAnchorPosition,
targetAnchorPosition,
sourceEndpoint,
targetEndpoint,
soo,
too,
majorAnchor,
minorAnchor,
) {
// determine if the two anchors are perpendicular to each other in their orientation. we swap the control
// points around if so (code could be tightened up)
var perpendicular = soo[0] !== too[0] || soo[1] === too[1],
p = [];
if (!perpendicular) {
if (soo[0] === 0) {
p.push(
sourceAnchorPosition[0] < targetAnchorPosition[0]
? point[0] + minorAnchor
: point[0] - minorAnchor,
);
} else {
p.push(point[0] - majorAnchor * soo[0]);
}
if (soo[1] === 0) {
p.push(
sourceAnchorPosition[1] < targetAnchorPosition[1]
? point[1] + minorAnchor
: point[1] - minorAnchor,
);
} else {
p.push(point[1] + majorAnchor * too[1]);
}
} else {
if (too[0] === 0) {
p.push(
targetAnchorPosition[0] < sourceAnchorPosition[0]
? point[0] + minorAnchor
: point[0] - minorAnchor,
);
} else {
p.push(point[0] + majorAnchor * too[0]);
}
if (too[1] === 0) {
p.push(
targetAnchorPosition[1] < sourceAnchorPosition[1]
? point[1] + minorAnchor
: point[1] - minorAnchor,
);
} else {
p.push(point[1] + majorAnchor * soo[1]);
}
}
return p;
}
function sgn(n) {
return n < 0 ? -1 : n === 0 ? 0 : 1;
}
function getFlowchartSegmentDirections(segment) {
return [sgn(segment[2] - segment[0]), sgn(segment[3] - segment[1])];
}
function getSegmentLength(s) {
return Math.sqrt(Math.pow(s[0] - s[2], 2) + Math.pow(s[1] - s[3], 2));
}
function _cloneArray(a) {
var _a = [];
_a.push.apply(_a, a);
return _a;
}
function writeFlowchartSegments(_super, conn, segments, paintInfo, cornerRadius) {
var current = null,
next,
currentDirection,
nextDirection;
for (var i = 0; i < segments.length - 1; i++) {
current = current || _cloneArray(segments[i]);
next = _cloneArray(segments[i + 1]);
currentDirection = getFlowchartSegmentDirections(current);
nextDirection = getFlowchartSegmentDirections(next);
if (cornerRadius > 0 && current[4] !== next[4]) {
var minSegLength = Math.min(getSegmentLength(current), getSegmentLength(next));
var radiusToUse = Math.min(cornerRadius, minSegLength / 2);
current[2] -= currentDirection[0] * radiusToUse;
current[3] -= currentDirection[1] * radiusToUse;
next[0] += nextDirection[0] * radiusToUse;
next[1] += nextDirection[1] * radiusToUse;
var ac =
(currentDirection[1] === nextDirection[0] && nextDirection[0] === 1) ||
(currentDirection[1] === nextDirection[0] &&
nextDirection[0] === 0 &&
currentDirection[0] !== nextDirection[1]) ||
(currentDirection[1] === nextDirection[0] && nextDirection[0] === -1),
sgny = next[1] > current[3] ? 1 : -1,
sgnx = next[0] > current[2] ? 1 : -1,
sgnEqual = sgny === sgnx,
cx = (sgnEqual && ac) || (!sgnEqual && !ac) ? next[0] : current[2],
cy = (sgnEqual && ac) || (!sgnEqual && !ac) ? current[3] : next[1];
_super.addSegment(conn, STRAIGHT, {
x1: current[0],
y1: current[1],
x2: current[2],
y2: current[3],
});
_super.addSegment(conn, ARC, {
r: radiusToUse,
x1: current[2],
y1: current[3],
x2: next[0],
y2: next[1],
cx: cx,
cy: cy,
ac: ac,
});
} else {
// dx + dy are used to adjust for line width.
var dx =
current[2] === current[0]
? 0
: current[2] > current[0]
? paintInfo.lw / 2
: -(paintInfo.lw / 2),
dy =
current[3] === current[1]
? 0
: current[3] > current[1]
? paintInfo.lw / 2
: -(paintInfo.lw / 2);
_super.addSegment(conn, STRAIGHT, {
x1: current[0] - dx,
y1: current[1] - dy,
x2: current[2] + dx,
y2: current[3] + dy,
});
}
current = next;
}
if (next != null) {
// last segment
_super.addSegment(conn, STRAIGHT, {
x1: next[0],
y1: next[1],
x2: next[2],
y2: next[3],
});
}
}
const lineCalculators = {
opposite: function (paintInfo, { axis, startStub, endStub, idx, midx, midy }) {
var pi = paintInfo,
comparator = pi['is' + axis.toUpperCase() + 'GreaterThanStubTimes2'];
if (
!comparator ||
(pi.so[idx] === 1 && startStub > endStub) ||
(pi.so[idx] === -1 && startStub < endStub)
) {
return {
x: [
[startStub, midy],
[endStub, midy],
],
y: [
[midx, startStub],
[midx, endStub],
],
}[axis];
} else if (
(pi.so[idx] === 1 && startStub < endStub) ||
(pi.so[idx] === -1 && startStub > endStub)
) {
return {
x: [
[midx, pi.sy],
[midx, pi.ty],
],
y: [
[pi.sx, midy],
[pi.tx, midy],
],
}[axis];
}
},
};
const stubCalculators = {
opposite: function (paintInfo, { axis, alwaysRespectStubs }) {
var pi = paintInfo,
idx = axis === 'x' ? 0 : 1,
areInProximity = {
x: function () {
return (
(pi.so[idx] === 1 &&
((pi.startStubX > pi.endStubX && pi.tx > pi.startStubX) ||
(pi.sx > pi.endStubX && pi.tx > pi.sx))) ||
(pi.so[idx] === -1 &&
((pi.startStubX < pi.endStubX && pi.tx < pi.startStubX) ||
(pi.sx < pi.endStubX && pi.tx < pi.sx)))
);
},
y: function () {
return (
(pi.so[idx] === 1 &&
((pi.startStubY > pi.endStubY && pi.ty > pi.startStubY) ||
(pi.sy > pi.endStubY && pi.ty > pi.sy))) ||
(pi.so[idx] === -1 &&
((pi.startStubY < pi.endStubY && pi.ty < pi.startStubY) ||
(pi.sy < pi.endStubY && pi.ty < pi.sy)))
);
},
};
if (!alwaysRespectStubs && areInProximity[axis]()) {
return {
x: [
(paintInfo.sx + paintInfo.tx) / 2,
paintInfo.startStubY,
(paintInfo.sx + paintInfo.tx) / 2,
paintInfo.endStubY,
],
y: [
paintInfo.startStubX,
(paintInfo.sy + paintInfo.ty) / 2,
paintInfo.endStubX,
(paintInfo.sy + paintInfo.ty) / 2,
],
}[axis];
} else {
return [paintInfo.startStubX, paintInfo.startStubY, paintInfo.endStubX, paintInfo.endStubY];
}
},
};
function calcualteStubSegment(paintInfo, { alwaysRespectStubs }) {
return stubCalculators['opposite'](paintInfo, {
axis: paintInfo.sourceAxis,
alwaysRespectStubs,
});
}
function calculateLineSegment(
paintInfo,
stubs,
{ midpoint, loopbackVerticalLength, loopbackMinimum },
) {
const axis = paintInfo.sourceAxis,
idx = paintInfo.sourceAxis === 'x' ? 0 : 1,
oidx = paintInfo.sourceAxis === 'x' ? 1 : 0,
startStub = stubs[idx],
otherStartStub = stubs[oidx],
endStub = stubs[idx + 2],
otherEndStub = stubs[oidx + 2];
const diffX = paintInfo.endStubX - paintInfo.startStubX;
const diffY = paintInfo.endStubY - paintInfo.startStubY;
const direction = -1; // vertical direction of loop, always below source
var midx = paintInfo.startStubX + (paintInfo.endStubX - paintInfo.startStubX) * midpoint,
midy;
if (diffY >= 0 || diffX < -1 * loopbackMinimum) {
// loop backward behavior
midy = paintInfo.startStubY - (diffX < 0 ? direction * loopbackVerticalLength : 0);
} else {
// original flowchart behavior
midy = paintInfo.startStubY + (paintInfo.endStubY - paintInfo.startStubY) * midpoint;
}
return lineCalculators['opposite'](paintInfo, {
axis,
startStub,
otherStartStub,
endStub,
otherEndStub,
idx,
oidx,
midx,
midy,
});
}
function _getPaintInfo(params, { targetGap, stub, overrideTargetEndpoint, getEndpointOffset }) {
let { targetPos, targetEndpoint } = params;
if (overrideTargetEndpoint) {
targetPos = overrideTargetEndpoint.anchor.getCurrentLocation();
targetEndpoint = overrideTargetEndpoint;
}
const sourceGap = 0;
stub = stub || 0;
const sourceStub = _ju.isArray(stub) ? stub[0] : stub;
const targetStub = _ju.isArray(stub) ? stub[1] : stub;
var segment = _jg.quadrant(params.sourcePos, targetPos),
swapX = targetPos[0] < params.sourcePos[0],
swapY = targetPos[1] < params.sourcePos[1],
lw = params.strokeWidth || 1,
so = params.sourceEndpoint.anchor.getOrientation(params.sourceEndpoint), // source orientation
to = targetEndpoint.anchor.getOrientation(targetEndpoint), // target orientation
x = swapX ? targetPos[0] : params.sourcePos[0],
y = swapY ? targetPos[1] : params.sourcePos[1],
w = Math.abs(targetPos[0] - params.sourcePos[0]),
h = Math.abs(targetPos[1] - params.sourcePos[1]);
// if either anchor does not have an orientation set, we derive one from their relative
// positions. we fix the axis to be the one in which the two elements are further apart, and
// point each anchor at the other element. this is also used when dragging a new connection.
if ((so[0] === 0 && so[1] === 0) || (to[0] === 0 && to[1] === 0)) {
var index = w > h ? 0 : 1,
oIndex = [1, 0][index];
so = [];
to = [];
so[index] = params.sourcePos[index] > targetPos[index] ? -1 : 1;
to[index] = params.sourcePos[index] > targetPos[index] ? 1 : -1;
so[oIndex] = 0;
to[oIndex] = 0;
}
const sx = swapX ? w + sourceGap * so[0] : sourceGap * so[0],
sy = swapY ? h + sourceGap * so[1] : sourceGap * so[1],
tx = swapX ? targetGap * to[0] : w + targetGap * to[0],
ty = swapY ? targetGap * to[1] : h + targetGap * to[1],
oProduct = so[0] * to[0] + so[1] * to[1];
const sourceStubWithOffset =
sourceStub +
(getEndpointOffset && params.sourceEndpoint ? getEndpointOffset(params.sourceEndpoint) : 0);
const targetStubWithOffset =
targetStub + (getEndpointOffset && targetEndpoint ? getEndpointOffset(targetEndpoint) : 0);
// same as paintinfo generated by jsplumb AbstractConnector type
var result = {
sx: sx,
sy: sy,
tx: tx,
ty: ty,
lw: lw,
xSpan: Math.abs(tx - sx),
ySpan: Math.abs(ty - sy),
mx: (sx + tx) / 2,
my: (sy + ty) / 2,
so: so,
to: to,
x: x,
y: y,
w: w,
h: h,
segment: segment,
startStubX: sx + so[0] * sourceStubWithOffset,
startStubY: sy + so[1] * sourceStubWithOffset,
endStubX: tx + to[0] * targetStubWithOffset,
endStubY: ty + to[1] * targetStubWithOffset,
isXGreaterThanStubTimes2: Math.abs(sx - tx) > sourceStubWithOffset + targetStubWithOffset,
isYGreaterThanStubTimes2: Math.abs(sy - ty) > sourceStubWithOffset + targetStubWithOffset,
opposite: oProduct === -1,
perpendicular: oProduct === 0,
orthogonal: oProduct === 1,
sourceAxis: so[0] === 0 ? 'y' : 'x',
points: [x, y, w, h, sx, sy, tx, ty],
stubs: [sourceStubWithOffset, targetStubWithOffset],
anchorOrientation: 'opposite', // always opposite since our endpoints are always opposite (source orientation is left (1) and target orientaiton is right (-1))
/** custom keys added */
sourceEndpoint: params.sourceEndpoint,
targetEndpoint: targetEndpoint,
sourcePos: params.sourcePos,
targetPos: targetEndpoint.anchor.getCurrentLocation(),
targetGap,
};
return result;
}
}).call(typeof window !== 'undefined' ? window : this);

View file

@ -1,518 +0,0 @@
/**
* Custom Plus Endpoint
* Based on jsplumb Blank Endpoint type
*
* Source GitHub repository:
* https://github.com/jsplumb/jsplumb
*
* Source files:
* https://github.com/jsplumb/jsplumb/blob/fb5fce52794fa52306825bdaa62bf3855cdfd7e0/src/defaults.js#L1230
*
* All 1.x.x and 2.x.x versions of jsPlumb Community edition, and so also the
* content of this file, are dual-licensed under both MIT and GPLv2.
*
* MIT LICENSE
*
* Copyright (c) 2010 - 2014 jsPlumb, http://jsplumbtoolkit.com/
*
* Permission is hereby granted, free of charge, to any person obtaining
* a copy of this software and associated documentation files (the
* "Software"), to deal in the Software without restriction, including
* without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to
* permit persons to whom the Software is furnished to do so, subject to
* the following conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
* LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
* OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*
* ===============================================================================
* GNU GENERAL PUBLIC LICENSE
* Version 2, June 1991
*
* Copyright (C) 1989, 1991 Free Software Foundation, Inc.
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
* Everyone is permitted to copy and distribute verbatim copies
* of this license document, but changing it is not allowed.
*
* Preamble
*
* The licenses for most software are designed to take away your
* freedom to share and change it. By contrast, the GNU General Public
* License is intended to guarantee your freedom to share and change free
* software--to make sure the software is free for all its users. This
* General Public License applies to most of the Free Software
* Foundation's software and to any other program whose authors commit to
* using it. (Some other Free Software Foundation software is covered by
* the GNU Lesser General Public License instead.) You can apply it to
* your programs, too.
*
* When we speak of free software, we are referring to freedom, not
* price. Our General Public Licenses are designed to make sure that you
* have the freedom to distribute copies of free software (and charge for
* this service if you wish), that you receive source code or can get it
* if you want it, that you can change the software or use pieces of it
* in new free programs; and that you know you can do these things.
*
* To protect your rights, we need to make restrictions that forbid
* anyone to deny you these rights or to ask you to surrender the rights.
* These restrictions translate to certain responsibilities for you if you
* distribute copies of the software, or if you modify it.
*
* For example, if you distribute copies of such a program, whether
* gratis or for a fee, you must give the recipients all the rights that
* you have. You must make sure that they, too, receive or can get the
* source code. And you must show them these terms so they know their
* rights.
*
* We protect your rights with two steps: (1) copyright the software, and
* (2) offer you this license which gives you legal permission to copy,
* distribute and/or modify the software.
*
* Also, for each author's protection and ours, we want to make certain
* that everyone understands that there is no warranty for this free
* software. If the software is modified by someone else and passed on, we
* want its recipients to know that what they have is not the original, so
* that any problems introduced by others will not reflect on the original
* authors' reputations.
*
* Finally, any free program is threatened constantly by software
* patents. We wish to avoid the danger that redistributors of a free
* program will individually obtain patent licenses, in effect making the
* program proprietary. To prevent this, we have made it clear that any
* patent must be licensed for everyone's free use or not licensed at all.
*
* The precise terms and conditions for copying, distribution and
* modification follow.
*
* GNU GENERAL PUBLIC LICENSE
* TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
*
* 0. This License applies to any program or other work which contains
* a notice placed by the copyright holder saying it may be distributed
* under the terms of this General Public License. The "Program", below,
* refers to any such program or work, and a "work based on the Program"
* means either the Program or any derivative work under copyright law:
* that is to say, a work containing the Program or a portion of it,
* either verbatim or with modifications and/or translated into another
* language. (Hereinafter, translation is included without limitation in
* the term "modification".) Each licensee is addressed as "you".
*
* Activities other than copying, distribution and modification are not
* covered by this License; they are outside its scope. The act of
* running the Program is not restricted, and the output from the Program
* is covered only if its contents constitute a work based on the
* Program (independent of having been made by running the Program).
* Whether that is true depends on what the Program does.
*
* 1. You may copy and distribute verbatim copies of the Program's
* source code as you receive it, in any medium, provided that you
* conspicuously and appropriately publish on each copy an appropriate
* copyright notice and disclaimer of warranty; keep intact all the
* notices that refer to this License and to the absence of any warranty;
* and give any other recipients of the Program a copy of this License
* along with the Program.
*
* You may charge a fee for the physical act of transferring a copy, and
* you may at your option offer warranty protection in exchange for a fee.
*
* 2. You may modify your copy or copies of the Program or any portion
* of it, thus forming a work based on the Program, and copy and
* distribute such modifications or work under the terms of Section 1
* above, provided that you also meet all of these conditions:
*
* a) You must cause the modified files to carry prominent notices
* stating that you changed the files and the date of any change.
*
* b) You must cause any work that you distribute or publish, that in
* whole or in part contains or is derived from the Program or any
* part thereof, to be licensed as a whole at no charge to all third
* parties under the terms of this License.
*
* c) If the modified program normally reads commands interactively
* when run, you must cause it, when started running for such
* interactive use in the most ordinary way, to print or display an
* announcement including an appropriate copyright notice and a
* notice that there is no warranty (or else, saying that you provide
* a warranty) and that users may redistribute the program under
* these conditions, and telling the user how to view a copy of this
* License. (Exception: if the Program itself is interactive but
* does not normally print such an announcement, your work based on
* the Program is not required to print an announcement.)
*
* These requirements apply to the modified work as a whole. If
* identifiable sections of that work are not derived from the Program,
* and can be reasonably considered independent and separate works in
* themselves, then this License, and its terms, do not apply to those
* sections when you distribute them as separate works. But when you
* distribute the same sections as part of a whole which is a work based
* on the Program, the distribution of the whole must be on the terms of
* this License, whose permissions for other licensees extend to the
* entire whole, and thus to each and every part regardless of who wrote it.
*
* Thus, it is not the intent of this section to claim rights or contest
* your rights to work written entirely by you; rather, the intent is to
* exercise the right to control the distribution of derivative or
* collective works based on the Program.
*
* In addition, mere aggregation of another work not based on the Program
* with the Program (or with a work based on the Program) on a volume of
* a storage or distribution medium does not bring the other work under
* the scope of this License.
*
* 3. You may copy and distribute the Program (or a work based on it,
* under Section 2) in object code or executable form under the terms of
* Sections 1 and 2 above provided that you also do one of the following:
*
* a) Accompany it with the complete corresponding machine-readable
* source code, which must be distributed under the terms of Sections
* 1 and 2 above on a medium customarily used for software interchange; or,
*
* b) Accompany it with a written offer, valid for at least three
* years, to give any third party, for a charge no more than your
* cost of physically performing source distribution, a complete
* machine-readable copy of the corresponding source code, to be
* distributed under the terms of Sections 1 and 2 above on a medium
* customarily used for software interchange; or,
*
* c) Accompany it with the information you received as to the offer
* to distribute corresponding source code. (This alternative is
* allowed only for noncommercial distribution and only if you
* received the program in object code or executable form with such
* an offer, in accord with Subsection b above.)
*
* The source code for a work means the preferred form of the work for
* making modifications to it. For an executable work, complete source
* code means all the source code for all modules it contains, plus any
* associated interface definition files, plus the scripts used to
* control compilation and installation of the executable. However, as a
* special exception, the source code distributed need not include
* anything that is normally distributed (in either source or binary
* form) with the major components (compiler, kernel, and so on) of the
* operating system on which the executable runs, unless that component
* itself accompanies the executable.
*
* If distribution of executable or object code is made by offering
* access to copy from a designated place, then offering equivalent
* access to copy the source code from the same place counts as
* distribution of the source code, even though third parties are not
* compelled to copy the source along with the object code.
*
* 4. You may not copy, modify, sublicense, or distribute the Program
* except as expressly provided under this License. Any attempt
* otherwise to copy, modify, sublicense or distribute the Program is
* void, and will automatically terminate your rights under this License.
* However, parties who have received copies, or rights, from you under
* this License will not have their licenses terminated so long as such
* parties remain in full compliance.
*
* 5. You are not required to accept this License, since you have not
* signed it. However, nothing else grants you permission to modify or
* distribute the Program or its derivative works. These actions are
* prohibited by law if you do not accept this License. Therefore, by
* modifying or distributing the Program (or any work based on the
* Program), you indicate your acceptance of this License to do so, and
* all its terms and conditions for copying, distributing or modifying
* the Program or works based on it.
*
* 6. Each time you redistribute the Program (or any work based on the
* Program), the recipient automatically receives a license from the
* original licensor to copy, distribute or modify the Program subject to
* these terms and conditions. You may not impose any further
* restrictions on the recipients' exercise of the rights granted herein.
* You are not responsible for enforcing compliance by third parties to
* this License.
*
* 7. If, as a consequence of a court judgment or allegation of patent
* infringement or for any other reason (not limited to patent issues),
* conditions are imposed on you (whether by court order, agreement or
* otherwise) that contradict the conditions of this License, they do not
* excuse you from the conditions of this License. If you cannot
* distribute so as to satisfy simultaneously your obligations under this
* License and any other pertinent obligations, then as a consequence you
* may not distribute the Program at all. For example, if a patent
* license would not permit royalty-free redistribution of the Program by
* all those who receive copies directly or indirectly through you, then
* the only way you could satisfy both it and this License would be to
* refrain entirely from distribution of the Program.
*
* If any portion of this section is held invalid or unenforceable under
* any particular circumstance, the balance of the section is intended to
* apply and the section as a whole is intended to apply in other
* circumstances.
*
* It is not the purpose of this section to induce you to infringe any
* patents or other property right claims or to contest validity of any
* such claims; this section has the sole purpose of protecting the
* integrity of the free software distribution system, which is
* implemented by public license practices. Many people have made
* generous contributions to the wide range of software distributed
* through that system in reliance on consistent application of that
* system; it is up to the author/donor to decide if he or she is willing
* to distribute software through any other system and a licensee cannot
* impose that choice.
*
* This section is intended to make thoroughly clear what is believed to
* be a consequence of the rest of this License.
*
* 8. If the distribution and/or use of the Program is restricted in
* certain countries either by patents or by copyrighted interfaces, the
* original copyright holder who places the Program under this License
* may add an explicit geographical distribution limitation excluding
* those countries, so that distribution is permitted only in or among
* countries not thus excluded. In such case, this License incorporates
* the limitation as if written in the body of this License.
*
* 9. The Free Software Foundation may publish revised and/or new versions
* of the General Public License from time to time. Such new versions will
* be similar in spirit to the present version, but may differ in detail to
* address new problems or concerns.
*
* Each version is given a distinguishing version number. If the Program
* specifies a version number of this License which applies to it and "any
* later version", you have the option of following the terms and conditions
* either of that version or of any later version published by the Free
* Software Foundation. If the Program does not specify a version number of
* this License, you may choose any version ever published by the Free Software
* Foundation.
*
* 10. If you wish to incorporate parts of the Program into other free
* programs whose distribution conditions are different, write to the author
* to ask for permission. For software which is copyrighted by the Free
* Software Foundation, write to the Free Software Foundation; we sometimes
* make exceptions for this. Our decision will be guided by the two goals
* of preserving the free status of all derivatives of our free software and
* of promoting the sharing and reuse of software generally.
*
* NO WARRANTY
*
* 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
* FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
* OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
* PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
* OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
* TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
* PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
* REPAIR OR CORRECTION.
*
* 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
* WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
* REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
* INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
* OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
* TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
* YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
* PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGES.
*
*/
(function () {
'use strict';
var root = window,
_jp = root.jsPlumb,
_ju = root.jsPlumbUtil;
var DOMElementEndpoint = function (params) {
_jp.jsPlumbUIComponent.apply(this, arguments);
this._jsPlumb.displayElements = [];
};
_ju.extend(DOMElementEndpoint, _jp.jsPlumbUIComponent, {
getDisplayElements: function () {
return this._jsPlumb.displayElements;
},
appendDisplayElement: function (el) {
this._jsPlumb.displayElements.push(el);
},
});
/*
* Class: Endpoints.N8nPlus
*/
_jp.Endpoints.N8nPlus = function (params) {
const _super = _jp.Endpoints.AbstractEndpoint.apply(this, arguments);
this.type = 'N8nPlus';
this.label = '';
this.labelOffset = 0;
this.size = 'medium';
this.showOutputLabel = true;
const boxSize = {
medium: 24,
small: 18,
};
const stalkLength = 40;
DOMElementEndpoint.apply(this, arguments);
var clazz = params.cssClass ? ' ' + params.cssClass : '';
this.canvas = _jp.createElement(
'div',
{
display: 'block',
background: 'transparent',
position: 'absolute',
},
this._jsPlumb.instance.endpointClass + clazz + ' plus-endpoint',
);
this.canvas.innerHTML = `
<div class="plus-stalk">
<div class="connection-run-items-label">
<span class="floating"></span>
</div>
</div>
<div class="plus-container">
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="plus" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" class="svg-inline--fa fa-plus">
<path fill="currentColor" d="M416 208H272V64c0-17.67-14.33-32-32-32h-32c-17.67 0-32 14.33-32 32v144H32c-17.67 0-32 14.33-32 32v32c0 17.67 14.33 32 32 32h144v144c0 17.67 14.33 32 32 32h32c17.67 0 32-14.33 32-32V304h144c17.67 0 32-14.33 32-32v-32c0-17.67-14.33-32-32-32z" class=""></path>
</svg>
<div class="drop-hover-message">
Click to add node</br>
or drag to connect
</div>
</div>
`;
this.canvas.addEventListener('click', (e) => {
this._jsPlumb.instance.fire('plusEndpointClick', params.endpoint, e);
});
this._jsPlumb.instance.appendElement(this.canvas);
const container = this.canvas.querySelector('.plus-container');
const message = container.querySelector('.drop-hover-message');
const plusStalk = this.canvas.querySelector('.plus-stalk');
const successOutput = this.canvas.querySelector('.plus-stalk span');
this.setSuccessOutput = (label) => {
this.canvas.classList.add('success');
if (this.showOutputLabel) {
successOutput.textContent = label;
this.label = label;
this.labelOffset = successOutput.offsetWidth;
plusStalk.style.width = `${stalkLength + this.labelOffset}px`;
if (this._jsPlumb && this._jsPlumb.instance && !this._jsPlumb.instance.isSuspendDrawing()) {
params.endpoint.repaint(); // force rerender to move plus hoverable/draggable space
}
}
};
this.clearSuccessOutput = () => {
this.canvas.classList.remove('success');
successOutput.textContent = '';
this.label = '';
this.labelOffset = 0;
plusStalk.style.width = `${stalkLength}px`;
params.endpoint.repaint();
};
const isDragging = () => {
const endpoint = params.endpoint;
const plusConnections = endpoint.connections;
if (plusConnections.length) {
return !!plusConnections.find(
(conn) => conn && conn.targetId && conn.targetId.startsWith('jsPlumb'),
);
}
return false;
};
const hasEndpointConnections = () => {
const endpoint = params.endpoint;
const plusConnections = endpoint.connections;
if (plusConnections.length >= 1) {
return true;
}
const allConnections = this._jsPlumb.instance.getConnections({
source: endpoint.elementId,
}); // includes connections from other output endpoints like dot
return !!allConnections.find((connection) => {
if (
!connection ||
!connection.endpoints ||
!connection.endpoints.length ||
!connection.endpoints[0]
) {
return false;
}
const sourceEndpoint = connection.endpoints[0];
return sourceEndpoint === endpoint || sourceEndpoint.getUuid() === endpoint.getUuid();
});
};
this.paint = function (style, anchor) {
if (hasEndpointConnections()) {
this.canvas.classList.add('hidden');
} else {
this.canvas.classList.remove('hidden');
container.style.color = style.fill;
container.style['border-color'] = style.fill;
message.style.display = style.hover ? 'inline' : 'none';
}
_ju.sizeElement(this.canvas, this.x, this.y, this.w, this.h);
};
this._compute = (anchorPoint, orientation, endpointStyle, connectorPaintStyle) => {
this.size = endpointStyle.size || this.size;
this.showOutputLabel = !!endpointStyle.showOutputLabel;
if (this.hoverMessage !== endpointStyle.hoverMessage) {
this.hoverMessage = endpointStyle.hoverMessage;
message.innerHTML = endpointStyle.hoverMessage;
}
if (this.size !== 'medium') {
container.classList.add(this.size);
}
setTimeout(() => {
if (this.label && !this.labelOffset) {
// if label is hidden, offset is 0 so recalculate
this.setSuccessOutput(this.label);
}
}, 0);
const defaultPosition = [
anchorPoint[0] + stalkLength + this.labelOffset,
anchorPoint[1] - boxSize[this.size] / 2,
boxSize[this.size],
boxSize[this.size],
];
if (isDragging()) {
return defaultPosition;
}
if (hasEndpointConnections()) {
return [0, 0, 0, 0]; // remove hoverable box from view
}
return defaultPosition;
};
};
_ju.extend(_jp.Endpoints.N8nPlus, [_jp.Endpoints.AbstractEndpoint, DOMElementEndpoint], {
cleanup: function () {
if (this.canvas && this.canvas.parentNode) {
this.canvas.parentNode.removeChild(this.canvas);
}
},
});
_jp.Endpoints.svg.N8nPlus = _jp.Endpoints.N8nPlus;
})();

View file

@ -0,0 +1,586 @@
import { PointXY, log, extend, quadrant } from '@jsplumb/util';
import {
Connection,
ArcSegment,
AbstractConnector,
ConnectorComputeParams,
PaintGeometry,
Endpoint,
StraightSegment,
Orientation,
} from '@jsplumb/core';
import { AnchorPlacement, ConnectorOptions, Geometry, PaintAxis } from '@jsplumb/common';
import { BezierSegment } from '@jsplumb/connector-bezier';
import { isArray } from 'lodash';
import { deepCopy } from 'n8n-workflow';
export interface N8nConnectorOptions extends ConnectorOptions {}
interface N8nConnectorPaintGeometry extends PaintGeometry {
sourceEndpoint: Endpoint;
targetEndpoint: Endpoint;
sourcePos: AnchorPlacement;
targetPos: AnchorPlacement;
targetGap: number;
lw: number;
}
type FlowchartSegment = [number, number, number, number, string];
type StubPositions = [number, number, number, number];
const lineCalculators = {
opposite(
paintInfo: PaintGeometry,
{
axis,
startStub,
endStub,
idx,
midx,
midy,
}: {
axis: 'x' | 'y';
startStub: number;
endStub: number;
idx: number;
midx: number;
midy: number;
},
) {
const pi = paintInfo,
comparator = pi[('is' + axis.toUpperCase() + 'GreaterThanStubTimes2') as keyof PaintGeometry];
if (
!comparator ||
(pi.so[idx] === 1 && startStub > endStub) ||
(pi.so[idx] === -1 && startStub < endStub)
) {
return {
x: [
[startStub, midy],
[endStub, midy],
],
y: [
[midx, startStub],
[midx, endStub],
],
}[axis];
} else if (
(pi.so[idx] === 1 && startStub < endStub) ||
(pi.so[idx] === -1 && startStub > endStub)
) {
return {
x: [
[midx, pi.sy],
[midx, pi.ty],
],
y: [
[pi.sx, midy],
[pi.tx, midy],
],
}[axis];
}
},
};
const stubCalculators = {
opposite(
paintInfo: PaintGeometry,
{ axis, alwaysRespectStubs }: { axis: 'x' | 'y'; alwaysRespectStubs: boolean },
): StubPositions {
const pi = paintInfo,
idx = axis === 'x' ? 0 : 1,
areInProximity = {
x() {
return (
(pi.so[idx] === 1 &&
((pi.startStubX > pi.endStubX && pi.tx > pi.startStubX) ||
(pi.sx > pi.endStubX && pi.tx > pi.sx))) ||
(pi.so[idx] === -1 &&
((pi.startStubX < pi.endStubX && pi.tx < pi.startStubX) ||
(pi.sx < pi.endStubX && pi.tx < pi.sx)))
);
},
y() {
return (
(pi.so[idx] === 1 &&
((pi.startStubY > pi.endStubY && pi.ty > pi.startStubY) ||
(pi.sy > pi.endStubY && pi.ty > pi.sy))) ||
(pi.so[idx] === -1 &&
((pi.startStubY < pi.endStubY && pi.ty < pi.startStubY) ||
(pi.sy < pi.endStubY && pi.ty < pi.sy)))
);
},
};
if (!alwaysRespectStubs && areInProximity[axis]()) {
return {
x: [
(paintInfo.sx + paintInfo.tx) / 2,
paintInfo.startStubY,
(paintInfo.sx + paintInfo.tx) / 2,
paintInfo.endStubY,
] as StubPositions,
y: [
paintInfo.startStubX,
(paintInfo.sy + paintInfo.ty) / 2,
paintInfo.endStubX,
(paintInfo.sy + paintInfo.ty) / 2,
] as StubPositions,
}[axis];
} else {
return [
paintInfo.startStubX,
paintInfo.startStubY,
paintInfo.endStubX,
paintInfo.endStubY,
] as StubPositions;
}
},
};
export class N8nConnector extends AbstractConnector {
static type = 'N8nConnector';
type = N8nConnector.type;
majorAnchor: number;
minorAnchor: number;
midpoint: number;
alwaysRespectStubs: boolean;
loopbackVerticalLength: number;
lastx: number | null;
lasty: number | null;
cornerRadius: number;
loopbackMinimum: number;
curvinessCoefficient: number;
zBezierOffset: number;
targetGap: number;
overrideTargetEndpoint: Endpoint | null;
getEndpointOffset: Function | null;
private internalSegments: FlowchartSegment[] = [];
constructor(public connection: Connection, params: N8nConnectorOptions) {
super(connection, params);
params = params || {};
this.minorAnchor = 0; // seems to be angle at which connector leaves endpoint
this.majorAnchor = 0; // translates to curviness of bezier curve
this.stub = params.stub || 0;
this.midpoint = 0.5;
this.alwaysRespectStubs = params.alwaysRespectStubs === true;
this.loopbackVerticalLength = params.loopbackVerticalLength || 0;
this.lastx = null;
this.lasty = null;
this.cornerRadius = params.cornerRadius !== null ? params.cornerRadius : 0;
this.loopbackMinimum = params.loopbackMinimum || 100;
this.curvinessCoefficient = 0.4;
this.zBezierOffset = 40;
this.targetGap = params.targetGap || 0;
this.stub = params.stub || 0;
this.overrideTargetEndpoint = params.overrideTargetEndpoint || null;
this.getEndpointOffset = params.getEndpointOffset || null;
}
getDefaultStubs(): [number, number] {
return [30, 30];
}
sgn(n: number) {
return n < 0 ? -1 : n === 0 ? 0 : 1;
}
getFlowchartSegmentDirections(segment: FlowchartSegment): [number, number] {
return [this.sgn(segment[2] - segment[0]), this.sgn(segment[3] - segment[1])];
}
getSegmentLength(s: FlowchartSegment) {
return Math.sqrt(Math.pow(s[0] - s[2], 2) + Math.pow(s[1] - s[3], 2));
}
protected _findControlPoint(
point: PointXY,
sourceAnchorPosition: AnchorPlacement,
targetAnchorPosition: AnchorPlacement,
soo: [number, number],
too: [number, number],
): PointXY {
// determine if the two anchors are perpendicular to each other in their orientation. we swap the control
// points around if so (code could be tightened up)
const perpendicular = soo[0] !== too[0] || soo[1] === too[1],
p: PointXY = {
x: 0,
y: 0,
};
if (!perpendicular) {
if (soo[0] === 0) {
p.x =
sourceAnchorPosition.curX < targetAnchorPosition.curX
? point.x + this.minorAnchor
: point.x - this.minorAnchor;
} else {
p.x = point.x - this.majorAnchor * soo[0];
}
if (soo[1] === 0) {
p.y =
sourceAnchorPosition.curY < targetAnchorPosition.curY
? point.y + this.minorAnchor
: point.y - this.minorAnchor;
} else {
p.y = point.y + this.majorAnchor * too[1];
}
} else {
if (too[0] === 0) {
p.x =
targetAnchorPosition.curX < sourceAnchorPosition.curX
? point.x + this.minorAnchor
: point.x - this.minorAnchor;
} else {
p.x = point.x + this.majorAnchor * too[0];
}
if (too[1] === 0) {
p.y =
targetAnchorPosition.curY < sourceAnchorPosition.curY
? point.y + this.minorAnchor
: point.y - this.minorAnchor;
} else {
p.y = point.y + this.majorAnchor * soo[1];
}
}
return p;
}
writeFlowchartSegments(paintInfo: N8nConnectorPaintGeometry) {
let current: FlowchartSegment = null;
let next: FlowchartSegment = null;
let currentDirection: [number, number];
let nextDirection: [number, number];
for (let i = 0; i < this.internalSegments.length - 1; i++) {
current = current || (deepCopy(this.internalSegments[i]) as FlowchartSegment);
next = deepCopy(this.internalSegments[i + 1]) as FlowchartSegment;
currentDirection = this.getFlowchartSegmentDirections(current);
nextDirection = this.getFlowchartSegmentDirections(next);
if (this.cornerRadius > 0 && current[4] !== next[4]) {
const minSegLength = Math.min(this.getSegmentLength(current), this.getSegmentLength(next));
const radiusToUse = Math.min(this.cornerRadius, minSegLength / 2);
current[2] -= currentDirection[0] * radiusToUse;
current[3] -= currentDirection[1] * radiusToUse;
next[0] += nextDirection[0] * radiusToUse;
next[1] += nextDirection[1] * radiusToUse;
const ac =
(currentDirection[1] === nextDirection[0] && nextDirection[0] === 1) ||
(currentDirection[1] === nextDirection[0] &&
nextDirection[0] === 0 &&
currentDirection[0] !== nextDirection[1]) ||
(currentDirection[1] === nextDirection[0] && nextDirection[0] === -1),
sgny = next[1] > current[3] ? 1 : -1,
sgnx = next[0] > current[2] ? 1 : -1,
sgnEqual = sgny === sgnx,
cx = (sgnEqual && ac) || (!sgnEqual && !ac) ? next[0] : current[2],
cy = (sgnEqual && ac) || (!sgnEqual && !ac) ? current[3] : next[1];
this._addSegment(StraightSegment, {
x1: current[0],
y1: current[1],
x2: current[2],
y2: current[3],
});
this._addSegment(ArcSegment, {
r: radiusToUse,
x1: current[2],
y1: current[3],
x2: next[0],
y2: next[1],
cx,
cy,
ac,
});
} else {
// dx + dy are used to adjust for line width.
const dx =
current[2] === current[0]
? 0
: current[2] > current[0]
? paintInfo.lw / 2
: -(paintInfo.lw / 2),
dy =
current[3] === current[1]
? 0
: current[3] > current[1]
? paintInfo.lw / 2
: -(paintInfo.lw / 2);
this._addSegment(StraightSegment, {
x1: current[0] - dx,
y1: current[1] - dy,
x2: current[2] + dx,
y2: current[3] + dy,
});
}
current = next;
}
if (next !== null) {
// last segment
this._addSegment(StraightSegment, {
x1: next[0],
y1: next[1],
x2: next[2],
y2: next[3],
});
}
}
calculateStubSegment(paintInfo: PaintGeometry): StubPositions {
return stubCalculators['opposite'](paintInfo, {
axis: paintInfo.sourceAxis,
alwaysRespectStubs: this.alwaysRespectStubs,
});
}
calculateLineSegment(paintInfo: PaintGeometry, stubs: StubPositions) {
const axis = paintInfo.sourceAxis;
const idx = paintInfo.sourceAxis === 'x' ? 0 : 1;
const startStub = stubs[idx];
const endStub = stubs[idx + 2];
const diffX = paintInfo.endStubX - paintInfo.startStubX;
const diffY = paintInfo.endStubY - paintInfo.startStubY;
const direction = -1; // vertical direction of loop, always below source
const midx = paintInfo.startStubX + (paintInfo.endStubX - paintInfo.startStubX) * this.midpoint;
let midy: number;
if (diffY >= 0 || diffX < -1 * this.loopbackMinimum) {
// loop backward behavior
midy = paintInfo.startStubY - (diffX < 0 ? direction * this.loopbackVerticalLength : 0);
} else {
// original flowchart behavior
midy = paintInfo.startStubY + (paintInfo.endStubY - paintInfo.startStubY) * this.midpoint;
}
return lineCalculators['opposite'](paintInfo, { axis, startStub, endStub, idx, midx, midy });
}
_getPaintInfo(params: ConnectorComputeParams): N8nConnectorPaintGeometry {
let targetPos = params.targetPos;
let targetEndpoint: Endpoint = params.targetEndpoint;
if (this.overrideTargetEndpoint) {
targetPos = this.overrideTargetEndpoint._anchor.computedPosition as AnchorPlacement;
targetEndpoint = this.overrideTargetEndpoint;
}
this.stub = this.stub || 0;
const sourceGap = 0;
const sourceStub = isArray(this.stub) ? this.stub[0] : this.stub;
const targetStub = isArray(this.stub) ? this.stub[1] : this.stub;
const segment = quadrant(params.sourcePos, targetPos);
const swapX = targetPos.curX < params.sourcePos.curX;
const swapY = targetPos.curY < params.sourcePos.curY;
const lw = params.strokeWidth || 1;
const x = swapX ? targetPos.curX : params.sourcePos.curX;
const y = swapY ? targetPos.curY : params.sourcePos.curY;
const w = Math.abs(targetPos.curX - params.sourcePos.curX);
const h = Math.abs(targetPos.curY - params.sourcePos.curY);
let so: Orientation = [params.sourcePos.ox, params.sourcePos.oy];
let to: Orientation = [targetPos.ox, targetPos.oy];
// if either anchor does not have an orientation set, we derive one from their relative
// positions. we fix the axis to be the one in which the two elements are further apart, and
// point each anchor at the other element. this is also used when dragging a new connection.
if ((so[0] === 0 && so[1] === 0) || (to[0] === 0 && to[1] === 0)) {
const index = w > h ? 'curX' : 'curY';
const indexNum = w > h ? 0 : 1;
const oIndex = [1, 0][indexNum];
so = [];
to = [];
so[indexNum] = params.sourcePos[index] > targetPos[index] ? -1 : 1;
to[indexNum] = params.sourcePos[index] > targetPos[index] ? 1 : -1;
so[oIndex] = 0;
to[oIndex] = 0;
}
const sx = swapX ? w + sourceGap * so[0] : sourceGap * so[0],
sy = swapY ? h + sourceGap * so[1] : sourceGap * so[1],
tx = swapX ? this.targetGap * to[0] : w + this.targetGap * to[0],
ty = swapY ? this.targetGap * to[1] : h + this.targetGap * to[1],
oProduct = so[0] * to[0] + so[1] * to[1];
const sourceStubWithOffset =
sourceStub +
(this.getEndpointOffset && params.sourceEndpoint
? this.getEndpointOffset(params.sourceEndpoint)
: 0);
const targetStubWithOffset =
targetStub +
(this.getEndpointOffset && targetEndpoint ? this.getEndpointOffset(targetEndpoint) : 0);
// same as paintinfo generated by jsplumb AbstractConnector type
const result = {
sx,
sy,
tx,
ty,
lw,
xSpan: Math.abs(tx - sx),
ySpan: Math.abs(ty - sy),
mx: (sx + tx) / 2,
my: (sy + ty) / 2,
so,
to,
x,
y,
w,
h,
segment,
startStubX: sx + so[0] * sourceStubWithOffset,
startStubY: sy + so[1] * sourceStubWithOffset,
endStubX: tx + to[0] * targetStubWithOffset,
endStubY: ty + to[1] * targetStubWithOffset,
isXGreaterThanStubTimes2: Math.abs(sx - tx) > sourceStubWithOffset + targetStubWithOffset,
isYGreaterThanStubTimes2: Math.abs(sy - ty) > sourceStubWithOffset + targetStubWithOffset,
opposite: oProduct === -1,
perpendicular: oProduct === 0,
orthogonal: oProduct === 1,
sourceAxis: so[0] === 0 ? 'y' : ('x' as PaintAxis),
points: [x, y, w, h, sx, sy, tx, ty] as [
number,
number,
number,
number,
number,
number,
number,
number,
],
stubs: [sourceStubWithOffset, targetStubWithOffset] as [number, number],
anchorOrientation: 'opposite', // always opposite since our endpoints are always opposite (source orientation is left (1) and target orientation is right (-1))
/** custom keys added */
sourceEndpoint: params.sourceEndpoint,
targetEndpoint,
sourcePos: params.sourcePos,
targetPos,
targetGap: this.targetGap,
};
return result;
}
_compute(originalPaintInfo: PaintGeometry, connParams: ConnectorComputeParams) {
const paintInfo = this._getPaintInfo(connParams);
// Set the type of key as key of paintInfo
// TODO: Check if this is the best way to do this
// Object.assign(originalPaintInfo, paintInfo);
Object.keys(paintInfo).forEach((key) => {
if (key === undefined) return;
// override so that bounding box is calculated correctly when target override is set
originalPaintInfo[key as keyof PaintGeometry] = paintInfo[key as keyof PaintGeometry];
});
try {
if (paintInfo.tx < 0) {
this._computeFlowchart(paintInfo);
} else {
this._computeBezier(paintInfo);
}
} catch (error) {}
}
/**
* Set target endpoint
* (to override default behavior tracking mouse when dragging mouse)
* @param {Endpoint} endpoint
*/
setTargetEndpoint(endpoint: Endpoint) {
this.overrideTargetEndpoint = endpoint;
}
resetTargetEndpoint() {
this.overrideTargetEndpoint = null;
}
_computeBezier(paintInfo: N8nConnectorPaintGeometry) {
const sp = paintInfo.sourcePos;
const tp = paintInfo.targetPos;
const _w = Math.abs(sp.curX - tp.curX) - this.targetGap;
const _h = Math.abs(sp.curY - tp.curY);
const _sx = sp.curX < tp.curX ? _w : 0;
const _sy = sp.curY < tp.curY ? _h : 0;
const _tx = sp.curX < tp.curX ? 0 : _w;
const _ty = sp.curY < tp.curY ? 0 : _h;
if (paintInfo.ySpan <= 20 || (paintInfo.ySpan <= 100 && paintInfo.xSpan <= 100)) {
this.majorAnchor = 0.1;
} else {
this.majorAnchor = paintInfo.xSpan * this.curvinessCoefficient + this.zBezierOffset;
}
const _CP = this._findControlPoint({ x: _sx, y: _sy }, sp, tp, paintInfo.so, paintInfo.to);
const _CP2 = this._findControlPoint({ x: _tx, y: _ty }, tp, sp, paintInfo.to, paintInfo.so);
const bezRes = {
x1: _sx,
y1: _sy,
x2: _tx,
y2: _ty,
cp1x: _CP.x,
cp1y: _CP.y,
cp2x: _CP2.x,
cp2y: _CP2.y,
};
this._addSegment(BezierSegment, bezRes);
}
addFlowchartSegment(x: number, y: number, paintInfo: PaintGeometry) {
if (this.lastx === x && this.lasty === y) {
return;
}
const lx = this.lastx === null ? paintInfo.sx : this.lastx;
const ly = this.lasty === null ? paintInfo.sy : this.lasty;
const o = lx === x ? 'v' : 'h';
this.lastx = x;
this.lasty = y;
this.internalSegments.push([lx, ly, x, y, o]);
}
_computeFlowchart(paintInfo: N8nConnectorPaintGeometry) {
this.segments = [];
this.lastx = null;
this.lasty = null;
this.internalSegments = [];
// calculate Stubs.
const stubs = this.calculateStubSegment(paintInfo);
// add the start stub segment. use stubs for loopback as it will look better, with the loop spaced
// away from the element.
this.addFlowchartSegment(stubs[0], stubs[1], paintInfo);
// compute the rest of the line
const p = this.calculateLineSegment(paintInfo, stubs);
if (p) {
for (let i = 0; i < p.length; i++) {
this.addFlowchartSegment(p[i][0], p[i][1], paintInfo);
}
}
// line to end stub
this.addFlowchartSegment(stubs[2], stubs[3], paintInfo);
// end stub to end (common)
this.addFlowchartSegment(paintInfo.tx, paintInfo.ty, paintInfo);
// write out the segments.
this.writeFlowchartSegments(paintInfo);
}
transformGeometry(g: Geometry, dx: number, dy: number): Geometry {
return g;
}
}

View file

@ -0,0 +1,37 @@
import { registerEndpointRenderer, svg } from '@jsplumb/browser-ui';
import { N8nPlusEndpoint } from './N8nPlusEndpointType';
export const register = () => {
registerEndpointRenderer<N8nPlusEndpoint>(N8nPlusEndpoint.type, {
makeNode: (ep: N8nPlusEndpoint) => {
const group = svg.node('g');
const containerBorder = svg.node('rect', {
rx: 3,
'stroke-width': 2,
fillOpacity: 0,
height: ep.params.dimensions - 2,
width: ep.params.dimensions - 2,
y: 1,
x: 1,
});
const plusPath = svg.node('path', {
d: 'm16.40655,10.89837l-3.30491,0l0,-3.30491c0,-0.40555 -0.32889,-0.73443 -0.73443,-0.73443l-0.73443,0c-0.40554,0 -0.73442,0.32888 -0.73442,0.73443l0,3.30491l-3.30491,0c-0.40555,0 -0.73443,0.32888 -0.73443,0.73442l0,0.73443c0,0.40554 0.32888,0.73443 0.73443,0.73443l3.30491,0l0,3.30491c0,0.40554 0.32888,0.73442 0.73442,0.73442l0.73443,0c0.40554,0 0.73443,-0.32888 0.73443,-0.73442l0,-3.30491l3.30491,0c0.40554,0 0.73442,-0.32889 0.73442,-0.73443l0,-0.73443c0,-0.40554 -0.32888,-0.73442 -0.73442,-0.73442z',
});
if (ep.params.size !== 'medium') {
ep.addClass(ep.params.size);
}
group.appendChild(containerBorder);
group.appendChild(plusPath);
ep.setupOverlays();
ep.setVisible(false);
return group;
},
updateNode: (ep: N8nPlusEndpoint) => {
const ifNoConnections = ep.getConnections().length === 0;
ep.setIsVisible(ifNoConnections);
},
});
};

View file

@ -0,0 +1,183 @@
import { EndpointHandler, Endpoint, EndpointRepresentation, Overlay } from '@jsplumb/core';
import { AnchorPlacement, EndpointRepresentationParams } from '@jsplumb/common';
import {
createElement,
EVENT_ENDPOINT_MOUSEOVER,
EVENT_ENDPOINT_MOUSEOUT,
EVENT_ENDPOINT_CLICK,
EVENT_CONNECTION_ABORT,
} from '@jsplumb/browser-ui';
export type ComputedN8nPlusEndpoint = [number, number, number, number, number];
interface N8nPlusEndpointParams extends EndpointRepresentationParams {
dimensions: number;
connectedEndpoint: Endpoint;
hoverMessage: string;
size: 'small' | 'medium';
showOutputLabel: boolean;
}
export const PlusStalkOverlay = 'plus-stalk';
export const HoverMessageOverlay = 'hover-message';
export class N8nPlusEndpoint extends EndpointRepresentation<ComputedN8nPlusEndpoint> {
params: N8nPlusEndpointParams;
label: string;
stalkOverlay: Overlay | null;
messageOverlay: Overlay | null;
constructor(endpoint: Endpoint, params: N8nPlusEndpointParams) {
super(endpoint, params);
this.params = params;
this.label = '';
this.stalkOverlay = null;
this.messageOverlay = null;
this.unbindEvents();
this.bindEvents();
}
static type = 'N8nPlus';
type = N8nPlusEndpoint.type;
setupOverlays() {
this.clearOverlays();
this.endpoint.instance.setSuspendDrawing(true);
this.stalkOverlay = this.endpoint.addOverlay({
type: 'Custom',
options: {
id: PlusStalkOverlay,
create: () => {
const stalk = createElement('div', {}, `${PlusStalkOverlay} ${this.params.size}`);
return stalk;
},
},
});
this.messageOverlay = this.endpoint.addOverlay({
type: 'Custom',
options: {
id: HoverMessageOverlay,
location: 0.5,
create: () => {
const hoverMessage = createElement('p', {}, `${HoverMessageOverlay} ${this.params.size}`);
hoverMessage.innerHTML = this.params.hoverMessage;
return hoverMessage;
},
},
});
this.endpoint.instance.setSuspendDrawing(false);
}
bindEvents() {
this.instance.bind(EVENT_ENDPOINT_MOUSEOVER, this.setHoverMessageVisible);
this.instance.bind(EVENT_ENDPOINT_MOUSEOUT, this.unsetHoverMessageVisible);
this.instance.bind(EVENT_ENDPOINT_CLICK, this.fireClickEvent);
this.instance.bind(EVENT_CONNECTION_ABORT, this.setStalkLabels);
}
unbindEvents() {
this.instance.unbind(EVENT_ENDPOINT_MOUSEOVER, this.setHoverMessageVisible);
this.instance.unbind(EVENT_ENDPOINT_MOUSEOUT, this.unsetHoverMessageVisible);
this.instance.unbind(EVENT_ENDPOINT_CLICK, this.fireClickEvent);
this.instance.unbind(EVENT_CONNECTION_ABORT, this.setStalkLabels);
}
setStalkLabels = () => {
if (!this.endpoint) return;
const stalkOverlay = this.endpoint.getOverlay(PlusStalkOverlay);
const messageOverlay = this.endpoint.getOverlay(HoverMessageOverlay);
if (stalkOverlay && messageOverlay) {
// Increase the size of the stalk overlay if the label is too long
const fnKey = this.label.length > 10 ? 'add' : 'remove';
this.instance[`${fnKey}OverlayClass`](stalkOverlay, 'long-stalk');
this.instance[`${fnKey}OverlayClass`](messageOverlay, 'long-stalk');
this[`${fnKey}Class`]('long-stalk');
if (this.label) {
// @ts-expect-error: Overlay interface is missing the `canvas` property
stalkOverlay.canvas.setAttribute('data-label', this.label);
}
}
};
fireClickEvent = (endpoint: Endpoint) => {
if (endpoint === this.endpoint) {
this.instance.fire('plusEndpointClick', this.endpoint);
}
};
setHoverMessageVisible = (endpoint: Endpoint) => {
if (endpoint === this.endpoint && this.messageOverlay) {
this.instance.addOverlayClass(this.messageOverlay, 'visible');
}
};
unsetHoverMessageVisible = (endpoint: Endpoint) => {
if (endpoint === this.endpoint && this.messageOverlay) {
this.instance.removeOverlayClass(this.messageOverlay, 'visible');
}
};
clearOverlays() {
Object.keys(this.endpoint.getOverlays()).forEach((key) => {
this.endpoint.removeOverlay(key);
});
this.stalkOverlay = null;
this.messageOverlay = null;
}
getConnections() {
const connections = [
...this.endpoint.connections,
...this.params.connectedEndpoint.connections,
];
return connections;
}
setIsVisible(visible: boolean) {
this.instance.setSuspendDrawing(true);
Object.keys(this.endpoint.getOverlays()).forEach((overlay) => {
this.endpoint.getOverlays()[overlay].setVisible(visible);
});
this.setVisible(visible);
// Re-trigger the success state if label is set
if (visible && this.label) {
this.setSuccessOutput(this.label);
}
this.instance.setSuspendDrawing(false);
}
setSuccessOutput(label: string) {
this.endpoint.addClass('ep-success');
if (this.params.showOutputLabel) {
this.label = label;
this.setStalkLabels();
}
}
clearSuccessOutput() {
this.endpoint.removeOverlay('successOutputOverlay');
this.endpoint.removeClass('ep-success');
this.label = '';
this.setStalkLabels();
}
}
export const N8nPlusEndpointHandler: EndpointHandler<N8nPlusEndpoint, ComputedN8nPlusEndpoint> = {
type: N8nPlusEndpoint.type,
cls: N8nPlusEndpoint,
compute: (ep: N8nPlusEndpoint, anchorPoint: AnchorPlacement): ComputedN8nPlusEndpoint => {
const x = anchorPoint.curX - ep.params.dimensions / 2;
const y = anchorPoint.curY - ep.params.dimensions / 2;
const w = ep.params.dimensions;
const h = ep.params.dimensions;
ep.x = x;
ep.y = y;
ep.w = w;
ep.h = h;
ep.addClass('plus-endpoint');
return [x, y, w, h, ep.params.dimensions];
},
getParams: (ep: N8nPlusEndpoint): N8nPlusEndpointParams => {
return ep.params;
},
};

View file

@ -1,29 +1,44 @@
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 { useHistoryStore } from '@/stores/history';
import { INodeUi, XYPosition } from '@/Interface';
import { scaleBigger, scaleReset, scaleSmaller } from '@/utils';
import { START_NODE_TYPE } from '@/constants';
import '@/plugins/N8nCustomConnectorType';
import '@/plugins/PlusEndpointType';
import { START_NODE_TYPE, STICKY_NODE_TYPE } from '@/constants';
import type {
BeforeStartEventParams,
BrowserJsPlumbInstance,
DragStopEventParams,
} from '@jsplumb/browser-ui';
import { newInstance } from '@jsplumb/browser-ui';
import { N8nPlusEndpointHandler } from '@/plugins/endpoints/N8nPlusEndpointType';
import * as N8nPlusEndpointRenderer from '@/plugins/endpoints/N8nPlusEndpointRenderer';
import { N8nConnector } from '@/plugins/connectors/N8nCustomConnector';
import { EndpointFactory, Connectors } from '@jsplumb/core';
import { MoveNodeCommand } from '@/models/history';
import {
DEFAULT_PLACEHOLDER_TRIGGER_BUTTON,
getMidCanvasPosition,
getNewNodePosition,
getZoomToFit,
PLACEHOLDER_TRIGGER_NODE_SIZE,
CONNECTOR_FLOWCHART_TYPE,
GRID_SIZE,
} from '@/utils/nodeViewUtils';
import { PointXY } from '@jsplumb/util';
export const useCanvasStore = defineStore('canvas', () => {
const workflowStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
const uiStore = useUIStore();
const jsPlumbInstance = jsPlumb.getInstance();
const historyStore = useHistoryStore();
const jsPlumbInstance = ref<BrowserJsPlumbInstance>();
const isDragging = ref<boolean>(false);
const nodes = computed<INodeUi[]>(() => workflowStore.allNodes);
const triggerNodes = computed<INodeUi[]>(() =>
@ -35,6 +50,10 @@ export const useCanvasStore = defineStore('canvas', () => {
const nodeViewScale = ref<number>(1);
const canvasAddButtonPosition = ref<XYPosition>([1, 1]);
Connectors.register(N8nConnector.type, N8nConnector);
N8nPlusEndpointRenderer.register();
EndpointFactory.registerHandler(N8nPlusEndpointHandler);
const setRecenteredCanvasAddButtonPosition = (offset?: XYPosition) => {
const position = getMidCanvasPosition(nodeViewScale.value, offset || [0, 0]);
@ -59,7 +78,7 @@ export const useCanvasStore = defineStore('canvas', () => {
const setZoomLevel = (zoomLevel: number, offset: XYPosition) => {
nodeViewScale.value = zoomLevel;
jsPlumbInstance.setZoom(zoomLevel);
jsPlumbInstance.value?.setZoom(zoomLevel);
uiStore.nodeViewOffsetPosition = offset;
};
@ -122,8 +141,106 @@ export const useCanvasStore = defineStore('canvas', () => {
wheelMoveWorkflow(e);
};
function initInstance(container: Element) {
// Make sure to clean-up previous instance if it exists
if (jsPlumbInstance.value) {
jsPlumbInstance.value.destroy();
jsPlumbInstance.value.reset();
jsPlumbInstance.value = undefined;
}
jsPlumbInstance.value = newInstance({
container,
connector: CONNECTOR_FLOWCHART_TYPE,
resizeObserver: false,
dragOptions: {
cursor: 'pointer',
grid: { w: GRID_SIZE, h: GRID_SIZE },
start: (params: BeforeStartEventParams) => {
const draggedNode = params.drag.getDragElement();
const nodeName = draggedNode.getAttribute('data-name');
if (!nodeName) return;
isDragging.value = true;
const isSelected = uiStore.isNodeSelected(nodeName);
if (params.e && !isSelected) {
// Only the node which gets dragged directly gets an event, for all others it is
// undefined. So check if the currently dragged node is selected and if not clear
// the drag-selection.
jsPlumbInstance.value?.clearDragSelection();
uiStore.resetSelectedNodes();
}
uiStore.addActiveAction('dragActive');
return true;
},
stop: (params: DragStopEventParams) => {
const draggedNode = params.drag.getDragElement();
const nodeName = draggedNode.getAttribute('data-name');
if (!nodeName) return;
const nodeData = workflowStore.getNodeByName(nodeName);
isDragging.value = false;
if (uiStore.isActionActive('dragActive') && nodeData) {
const moveNodes = uiStore.getSelectedNodes.slice();
const selectedNodeNames = moveNodes.map((node: INodeUi) => node.name);
if (!selectedNodeNames.includes(nodeData.name)) {
// If the current node is not in selected add it to the nodes which
// got moved manually
moveNodes.push(nodeData);
}
if (moveNodes.length > 1) {
historyStore.startRecordingUndo();
}
// This does for some reason just get called once for the node that got clicked
// even though "start" and "drag" gets called for all. So lets do for now
// some dirty DOM query to get the new positions till I have more time to
// create a proper solution
let newNodePosition: XYPosition;
moveNodes.forEach((node: INodeUi) => {
const element = document.getElementById(node.id);
if (element === null) {
return;
}
newNodePosition = [
parseInt(element.style.left!.slice(0, -2), 10),
parseInt(element.style.top!.slice(0, -2), 10),
];
const updateInformation = {
name: node.name,
properties: {
position: newNodePosition,
},
};
const oldPosition = node.position;
if (oldPosition[0] !== newNodePosition[0] || oldPosition[1] !== newNodePosition[1]) {
historyStore.pushCommandToUndo(
new MoveNodeCommand(node.name, oldPosition, newNodePosition),
);
workflowStore.updateNodeProperties(updateInformation);
}
});
if (moveNodes.length > 1) {
historyStore.stopRecordingUndo();
}
}
},
filter: '.node-description, .node-description .node-name, .node-description .node-subtitle',
},
});
jsPlumbInstance.value?.setDragConstrainFunction((pos: PointXY) => {
const isReadOnly = uiStore.isReadOnlyView;
if (isReadOnly) {
// Do not allow to move nodes in readOnly mode
return null;
}
return pos;
});
}
return {
jsPlumbInstance,
isDemo,
nodeViewScale,
canvasAddButtonPosition,
@ -135,5 +252,7 @@ export const useCanvasStore = defineStore('canvas', () => {
zoomOut,
zoomToFit,
wheelScroll,
initInstance,
jsPlumbInstance,
};
});

View file

@ -251,6 +251,9 @@ export const useUIStore = defineStore(STORES.UI, {
return (id: string) =>
this.fakeDoorFeatures.find((fakeDoor) => fakeDoor.id.toString() === id);
},
isReadOnlyView(): boolean {
return ![VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW].includes(this.currentView as VIEWS);
},
isNodeView(): boolean {
return [
VIEWS.NEW_WORKFLOW.toString(),

View file

@ -195,7 +195,9 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
},
getNodeById() {
return (nodeId: string): INodeUi | undefined =>
this.workflow.nodes.find((node: INodeUi) => node.id === nodeId);
this.workflow.nodes.find((node: INodeUi) => {
return node.id === nodeId;
});
},
nodesIssuesExist(): boolean {
for (const node of this.workflow.nodes) {

View file

@ -1,7 +1,7 @@
import { MAIN_HEADER_TABS, VIEWS } from '@/constants';
import { IZoomConfig } from '@/Interface';
import { useWorkflowsStore } from '@/stores/workflows';
import { OnConnectionBindInfo } from 'jsplumb';
import { ConnectionDetachedParams } from '@jsplumb/core';
import { IConnection } from 'n8n-workflow';
import { Route } from 'vue-router';
@ -94,10 +94,10 @@ export const getNodeViewTab = (route: Route): string | null => {
};
export const getConnectionInfo = (
connection: OnConnectionBindInfo,
connection: ConnectionDetachedParams,
): [IConnection, IConnection] | null => {
const sourceInfo = connection.sourceEndpoint.getParameters();
const targetInfo = connection.targetEndpoint.getParameters();
const sourceInfo = connection.sourceEndpoint.parameters;
const targetInfo = connection.targetEndpoint.parameters;
const sourceNode = useWorkflowsStore().getNodeById(sourceInfo.nodeId);
const targetNode = useWorkflowsStore().getNodeById(targetInfo.nodeId);

View file

@ -1,7 +1,11 @@
import { closestNumberDivisibleBy, getStyleTokenValue, isNumber } from '@/utils';
import { getStyleTokenValue } from '@/utils/htmlUtils';
import { isNumber } from '@/utils';
import { NODE_OUTPUT_DEFAULT_KEY, STICKY_NODE_TYPE, QUICKSTART_NOTE_NAME } from '@/constants';
import { EndpointStyle, IBounds, INodeUi, XYPosition } from '@/Interface';
import { AnchorArraySpec, Connection, Endpoint, Overlay, OverlaySpec, PaintStyle } from 'jsplumb';
import { ArrayAnchorSpec, ConnectorSpec, OverlaySpec, PaintStyle } from '@jsplumb/common';
import { Endpoint, Connection } from '@jsplumb/core';
import { N8nConnector } from '@/plugins/connectors/N8nCustomConnector';
import { closestNumberDivisibleBy } from '@/utils';
import {
IConnection,
INode,
@ -10,6 +14,7 @@ import {
NodeInputConnections,
INodeTypeDescription,
} from 'n8n-workflow';
import { EVENT_CONNECTION_MOUSEOUT, EVENT_CONNECTION_MOUSEOVER } from '@jsplumb/browser-ui';
/*
Canvas constants and functions.
@ -24,8 +29,7 @@ export const OVERLAY_RUN_ITEMS_ID = 'run-items-label';
export const OVERLAY_CONNECTION_ACTIONS_ID = 'connection-actions';
export const JSPLUMB_FLOWCHART_STUB = 26;
export const OVERLAY_INPUT_NAME_LABEL = 'input-name-label';
export const OVERLAY_INPUT_NAME_LABEL_POSITION = [-3, 0.5];
export const OVERLAY_INPUT_NAME_LABEL_POSITION_MOVED = [-4.5, 0.5];
export const OVERLAY_INPUT_NAME_MOVED_CLASS = 'node-input-endpoint-label--moved';
export const OVERLAY_OUTPUT_NAME_LABEL = 'output-name-label';
export const GRID_SIZE = 20;
@ -69,9 +73,9 @@ export const WELCOME_STICKY_NODE = {
},
};
export const CONNECTOR_FLOWCHART_TYPE = [
'N8nCustom',
{
export const CONNECTOR_FLOWCHART_TYPE: ConnectorSpec = {
type: N8nConnector.type,
options: {
cornerRadius: 12,
stub: JSPLUMB_FLOWCHART_STUB + 10,
targetGap: 4,
@ -91,7 +95,7 @@ export const CONNECTOR_FLOWCHART_TYPE = [
return index * indexOffset + labelOffset + outputsOffset;
},
},
];
};
export const CONNECTOR_PAINT_STYLE_DEFAULT: PaintStyle = {
stroke: getStyleTokenValue('--color-foreground-dark'),
@ -110,15 +114,10 @@ export const CONNECTOR_PAINT_STYLE_PRIMARY = {
stroke: getStyleTokenValue('--color-primary'),
};
export const CONNECTOR_PAINT_STYLE_SUCCESS = {
...CONNECTOR_PAINT_STYLE_DEFAULT,
stroke: getStyleTokenValue('--color-success-light'),
};
export const CONNECTOR_ARROW_OVERLAYS: OverlaySpec[] = [
[
'Arrow',
{
type: 'Arrow',
options: {
id: OVERLAY_ENDPOINT_ARROW_ID,
location: 1,
width: 12,
@ -126,10 +125,10 @@ export const CONNECTOR_ARROW_OVERLAYS: OverlaySpec[] = [
length: 10,
visible: true,
},
],
[
'Arrow',
},
{
type: 'Arrow',
options: {
id: OVERLAY_MIDPOINT_ARROW_ID,
location: 0.5,
width: 12,
@ -137,12 +136,12 @@ export const CONNECTOR_ARROW_OVERLAYS: OverlaySpec[] = [
length: 10,
visible: false,
},
],
},
];
export const ANCHOR_POSITIONS: {
[key: string]: {
[key: number]: AnchorArraySpec[];
[key: number]: ArrayAnchorSpec[];
};
} = {
input: {
@ -194,33 +193,42 @@ export const getInputEndpointStyle = (
lineWidth: 0,
});
export const getInputNameOverlay = (label: string): OverlaySpec => [
'Label',
{
export const getInputNameOverlay = (labelText: string): OverlaySpec => ({
type: 'Custom',
options: {
id: OVERLAY_INPUT_NAME_LABEL,
location: OVERLAY_INPUT_NAME_LABEL_POSITION,
label,
cssClass: 'node-input-endpoint-label',
visible: true,
create: (component: Endpoint) => {
const label = document.createElement('div');
label.innerHTML = labelText;
label.classList.add('node-input-endpoint-label');
return label;
},
];
},
});
export const getOutputEndpointStyle = (nodeTypeData: INodeTypeDescription, color: string) => ({
radius: nodeTypeData && nodeTypeData.outputs.length > 2 ? 7 : 9,
export const getOutputEndpointStyle = (
nodeTypeData: INodeTypeDescription,
color: string,
): PaintStyle => ({
strokeWidth: nodeTypeData && nodeTypeData.outputs.length > 2 ? 7 : 9,
fill: getStyleTokenValue(color),
outlineStroke: 'none',
});
export const getOutputNameOverlay = (label: string): OverlaySpec => [
'Label',
{
export const getOutputNameOverlay = (labelText: string): OverlaySpec => ({
type: 'Custom',
options: {
id: OVERLAY_OUTPUT_NAME_LABEL,
location: [1.9, 0.5],
label,
cssClass: 'node-output-endpoint-label',
visible: true,
create: (component: Endpoint) => {
const label = document.createElement('div');
label.innerHTML = labelText;
label.classList.add('node-output-endpoint-label');
return label;
},
];
},
});
export const addOverlays = (connection: Connection, overlays: OverlaySpec[]) => {
overlays.forEach((overlay: OverlaySpec) => {
@ -302,25 +310,34 @@ export const showOrHideMidpointArrow = (connection: Connection) => {
if (!connection || !connection.endpoints || connection.endpoints.length !== 2) {
return;
}
const hasItemsLabel = !!getOverlay(connection, OVERLAY_RUN_ITEMS_ID);
const sourceEndpoint = connection.endpoints[0];
const targetEndpoint = connection.endpoints[1];
const sourcePosition = sourceEndpoint.anchor.lastReturnValue[0];
const targetPosition = targetEndpoint.anchor.lastReturnValue
? targetEndpoint.anchor.lastReturnValue[0]
: sourcePosition + 1; // lastReturnValue is null when moving connections from node to another
const sourcePosition = sourceEndpoint._anchor.computedPosition?.curX ?? 0;
const targetPosition = targetEndpoint._anchor.computedPosition?.curX ?? sourcePosition + 1;
const minimum = hasItemsLabel ? 150 : 0;
const isBackwards = sourcePosition >= targetPosition;
const isTooLong = Math.abs(sourcePosition - targetPosition) >= minimum;
const isActionsOverlayHovered = getOverlay(
connection,
OVERLAY_CONNECTION_ACTIONS_ID,
)?.component.isHover();
const isConnectionHovered = connection.isHover();
const arrow = getOverlay(connection, OVERLAY_MIDPOINT_ARROW_ID);
const isArrowVisible =
isBackwards &&
isTooLong &&
!isActionsOverlayHovered &&
!isConnectionHovered &&
!connection.instance.isConnectionBeingDragged;
if (arrow) {
arrow.setVisible(isBackwards && isTooLong);
arrow.setVisible(isArrowVisible);
arrow.setLocation(hasItemsLabel ? 0.6 : 0.5);
connection.instance.repaint(arrow.canvas);
}
};
@ -329,8 +346,8 @@ export const getConnectorLengths = (connection: Connection): [number, number] =>
return [0, 0];
}
const bounds = connection.connector.bounds;
const diffX = Math.abs(bounds.maxX - bounds.minX);
const diffY = Math.abs(bounds.maxY - bounds.minY);
const diffX = Math.abs(bounds.xmax - bounds.xmin);
const diffY = Math.abs(bounds.ymax - bounds.ymin);
return [diffX, diffY];
};
@ -339,36 +356,30 @@ const isLoopingBackwards = (connection: Connection) => {
const sourceEndpoint = connection.endpoints[0];
const targetEndpoint = connection.endpoints[1];
const sourcePosition = sourceEndpoint.anchor.lastReturnValue[0];
const targetPosition = targetEndpoint.anchor.lastReturnValue[0];
const sourcePosition = sourceEndpoint._anchor.computedPosition?.curX ?? 0;
const targetPosition = targetEndpoint._anchor.computedPosition?.curX ?? 0;
return targetPosition - sourcePosition < -1 * LOOPBACK_MINIMUM;
};
export const showOrHideItemsLabel = (connection: Connection) => {
if (!connection || !connection.connector) {
return;
}
if (!connection?.connector) return;
const overlay = getOverlay(connection, OVERLAY_RUN_ITEMS_ID);
if (!overlay) {
return;
}
if (!overlay) return;
const actionsOverlay = getOverlay(connection, OVERLAY_CONNECTION_ACTIONS_ID);
if (actionsOverlay && actionsOverlay.visible) {
const isActionsOverlayHovered = actionsOverlay?.component.isHover();
if (isActionsOverlayHovered) {
overlay.setVisible(false);
return;
}
const [diffX, diffY] = getConnectorLengths(connection);
const isHidden = diffX < MIN_X_TO_SHOW_OUTPUT_LABEL && diffY < MIN_Y_TO_SHOW_OUTPUT_LABEL;
if (diffX < MIN_X_TO_SHOW_OUTPUT_LABEL && diffY < MIN_Y_TO_SHOW_OUTPUT_LABEL) {
overlay.setVisible(false);
} else {
overlay.setVisible(true);
}
overlay.setVisible(!isHidden);
const innerElement = overlay.canvas && overlay.canvas.querySelector('span');
if (innerElement) {
if (diffY === 0 || isLoopingBackwards(connection)) {
@ -503,22 +514,27 @@ export const getBackgroundStyles = (
return styles;
};
export const hideConnectionActions = (connection: Connection | null) => {
if (connection && connection.connector) {
export const hideConnectionActions = (connection: Connection) => {
connection.instance.setSuspendDrawing(true);
hideOverlay(connection, OVERLAY_CONNECTION_ACTIONS_ID);
showOrHideItemsLabel(connection);
showOrHideMidpointArrow(connection);
}
showOrHideItemsLabel(connection);
connection.instance.setSuspendDrawing(false);
(connection.endpoints || []).forEach((endpoint) => {
connection.instance.repaint(endpoint.element);
});
};
export const showConnectionActions = (connection: Connection | null) => {
if (connection && connection.connector) {
export const showConnectionActions = (connection: Connection) => {
showOverlay(connection, OVERLAY_CONNECTION_ACTIONS_ID);
hideOverlay(connection, OVERLAY_RUN_ITEMS_ID);
if (!getOverlay(connection, OVERLAY_RUN_ITEMS_ID)) {
hideOverlay(connection, OVERLAY_MIDPOINT_ARROW_ID);
}
}
(connection.endpoints || []).forEach((endpoint) => {
connection.instance.repaint(endpoint.element);
});
};
export const getOutputSummary = (data: ITaskData[], nodeConnections: NodeInputConnections) => {
@ -586,11 +602,9 @@ export const getOutputSummary = (data: ITaskData[], nodeConnections: NodeInputCo
export const resetConnection = (connection: Connection) => {
connection.removeOverlay(OVERLAY_RUN_ITEMS_ID);
connection.setPaintStyle(CONNECTOR_PAINT_STYLE_DEFAULT);
connection.removeClass('success');
showOrHideMidpointArrow(connection);
if (connection.canvas) {
connection.canvas.classList.remove('success');
}
connection.setPaintStyle(CONNECTOR_PAINT_STYLE_DEFAULT);
};
export const getRunItemsLabel = (output: { total: number; iterations: number }): string => {
@ -604,27 +618,36 @@ export const addConnectionOutputSuccess = (
connection: Connection,
output: { total: number; iterations: number },
) => {
connection.setPaintStyle(CONNECTOR_PAINT_STYLE_SUCCESS);
if (connection.canvas) {
connection.canvas.classList.add('success');
}
connection.addClass('success');
if (getOverlay(connection, OVERLAY_RUN_ITEMS_ID)) {
connection.removeOverlay(OVERLAY_RUN_ITEMS_ID);
}
connection.addOverlay([
'Label',
{
const overlay = connection.addOverlay({
type: 'Custom',
options: {
id: OVERLAY_RUN_ITEMS_ID,
label: `<span>${getRunItemsLabel(output)}</span>`,
cssClass: 'connection-run-items-label',
create() {
const container = document.createElement('div');
const span = document.createElement('span');
container.classList.add('connection-run-items-label');
span.classList.add('floating');
span.innerHTML = getRunItemsLabel(output);
container.appendChild(span);
return container;
},
location: 0.5,
},
]);
});
overlay.setVisible(true);
showOrHideItemsLabel(connection);
showOrHideMidpointArrow(connection);
(connection.endpoints || []).forEach((endpoint) => {
connection.instance.repaint(endpoint.element);
});
};
const getContentDimensions = (): { editorWidth: number; editorHeight: number } => {
@ -677,9 +700,10 @@ export const getZoomToFit = (
};
export const showDropConnectionState = (connection: Connection, targetEndpoint?: Endpoint) => {
if (connection && connection.connector) {
if (connection?.connector) {
const connector = connection.connector as N8nConnector;
if (targetEndpoint) {
connection.connector.setTargetEndpoint(targetEndpoint);
connector.setTargetEndpoint(targetEndpoint);
}
connection.setPaintStyle(CONNECTOR_PAINT_STYLE_PRIMARY);
hideOverlay(connection, OVERLAY_DROP_NODE_ID);
@ -687,31 +711,33 @@ export const showDropConnectionState = (connection: Connection, targetEndpoint?:
};
export const showPullConnectionState = (connection: Connection) => {
if (connection && connection.connector) {
connection.connector.resetTargetEndpoint();
if (connection?.connector) {
const connector = connection.connector as N8nConnector;
connector.resetTargetEndpoint();
connection.setPaintStyle(CONNECTOR_PAINT_STYLE_PULL);
showOverlay(connection, OVERLAY_DROP_NODE_ID);
}
};
export const resetConnectionAfterPull = (connection: Connection) => {
if (connection && connection.connector) {
connection.connector.resetTargetEndpoint();
if (connection?.connector) {
const connector = connection.connector as N8nConnector;
connector.resetTargetEndpoint();
connection.setPaintStyle(CONNECTOR_PAINT_STYLE_DEFAULT);
}
};
export const resetInputLabelPosition = (targetEndpoint: Endpoint) => {
export const resetInputLabelPosition = (targetEndpoint: Connection | Endpoint) => {
const inputNameOverlay = getOverlay(targetEndpoint, OVERLAY_INPUT_NAME_LABEL);
if (inputNameOverlay) {
inputNameOverlay.setLocation(OVERLAY_INPUT_NAME_LABEL_POSITION);
targetEndpoint.instance.removeOverlayClass(inputNameOverlay, OVERLAY_INPUT_NAME_MOVED_CLASS);
}
};
export const moveBackInputLabelPosition = (targetEndpoint: Endpoint) => {
const inputNameOverlay = getOverlay(targetEndpoint, OVERLAY_INPUT_NAME_LABEL);
if (inputNameOverlay) {
inputNameOverlay.setLocation(OVERLAY_INPUT_NAME_LABEL_POSITION_MOVED);
targetEndpoint.instance.addOverlayClass(inputNameOverlay, OVERLAY_INPUT_NAME_MOVED_CLASS);
}
};
@ -720,36 +746,42 @@ export const addConnectionActionsOverlay = (
onDelete: Function,
onAdd: Function,
) => {
if (getOverlay(connection, OVERLAY_CONNECTION_ACTIONS_ID)) {
return; // avoid free floating actions when moving connection from one node to another
}
connection.addOverlay([
'Label',
{
const overlay = connection.addOverlay({
type: 'Custom',
options: {
id: OVERLAY_CONNECTION_ACTIONS_ID,
label: `<div class="add">${getIcon('plus')}</div> <div class="delete">${getIcon(
'trash',
)}</div>`,
cssClass: OVERLAY_CONNECTION_ACTIONS_ID,
visible: false,
events: {
mousedown: (overlay: Overlay, event: MouseEvent) => {
const element = event.target as HTMLElement;
if (
element.classList.contains('delete') ||
(element.parentElement && element.parentElement.classList.contains('delete'))
) {
onDelete();
} else if (
element.classList.contains('add') ||
(element.parentElement && element.parentElement.classList.contains('add'))
) {
onAdd();
}
create: (component: Connection) => {
const div = document.createElement('div');
const addButton = document.createElement('button');
const deleteButton = document.createElement('button');
div.classList.add(OVERLAY_CONNECTION_ACTIONS_ID);
addButton.classList.add('add');
deleteButton.classList.add('delete');
addButton.innerHTML = getIcon('plus');
deleteButton.innerHTML = getIcon('trash');
addButton.addEventListener('click', () => onAdd());
deleteButton.addEventListener('click', () => onDelete());
// We have to manually trigger connection mouse events because the overlay
// is not part of the connection element
div.addEventListener('mouseout', () =>
connection.instance.fire(EVENT_CONNECTION_MOUSEOUT, component),
);
div.addEventListener('mouseover', () =>
connection.instance.fire(EVENT_CONNECTION_MOUSEOVER, component),
);
div.appendChild(addButton);
div.appendChild(deleteButton);
return div;
},
},
},
]);
});
overlay.setVisible(false);
};
export const getOutputEndpointUUID = (nodeId: string, outputIndex: number) => {

View file

@ -1,6 +1,6 @@
<template>
<div
:class="$style.container"
:class="$style.canvasAddButton"
:style="containerCssVars"
ref="container"
data-test-id="canvas-add-button"
@ -44,7 +44,7 @@ const containerCssVars = computed(() => ({
</script>
<style lang="scss" module>
.container {
.canvasAddButton {
display: flex;
flex-direction: column;
align-items: center;

View file

@ -1,7 +1,7 @@
<template>
<div :class="$style['content']">
<div
class="node-view-root"
class="node-view-root do-not-select"
id="node-view-root"
data-test-id="node-view-root"
@dragover="onDragOver"
@ -37,10 +37,11 @@
v-show="showCanvasAddButton"
:showTooltip="!containsTrigger && showTriggerMissingTooltip"
:position="canvasStore.canvasAddButtonPosition"
ref="canvasAddButton"
@hook:mounted="canvasStore.setRecenteredCanvasAddButtonPosition"
data-test-id="canvas-add-button"
/>
<div v-for="nodeData in nodes" :key="nodeData.id">
<template v-for="nodeData in nodes">
<node
v-if="nodeData.type !== STICKY_NODE_TYPE"
@duplicateNode="duplicateNode"
@ -51,6 +52,7 @@
@runWorkflow="onRunNode"
@moved="onNodeMoved"
@run="onNodeRun"
:ref="`node-${nodeData.id}`"
:key="`${nodeData.id}_node`"
:name="nodeData.name"
:isReadOnly="isReadOnly"
@ -74,6 +76,7 @@
@nodeSelected="nodeSelectedByName"
@removeNode="(name) => removeNode(name, true)"
:key="`${nodeData.id}_sticky`"
:ref="`node-${nodeData.id}`"
:name="nodeData.name"
:isReadOnly="isReadOnly"
:instance="instance"
@ -82,7 +85,7 @@
:gridSize="GRID_SIZE"
:hideActions="pullConnActive"
/>
</div>
</template>
</div>
</div>
<node-details-view
@ -157,15 +160,21 @@
</template>
<script lang="ts">
import Vue from 'vue';
import Vue, { ComponentInstance } from 'vue';
import { mapStores } from 'pinia';
import type {
OnConnectionBindInfo,
Connection,
import {
Endpoint,
N8nPlusEndpoint,
jsPlumbInstance,
} from 'jsplumb';
Connection,
EVENT_CONNECTION,
ConnectionEstablishedParams,
EVENT_CONNECTION_DETACHED,
EVENT_CONNECTION_MOVED,
INTERCEPT_BEFORE_DROP,
BeforeDropParams,
ConnectionDetachedParams,
ConnectionMovedParams,
} from '@jsplumb/core';
import type { MessageBoxInputData } from 'element-ui/types/message-box';
import {
@ -206,7 +215,6 @@ import Node from '@/components/Node.vue';
import NodeSettings from '@/components/NodeSettings.vue';
import Sticky from '@/components/Sticky.vue';
import CanvasAddButton from './CanvasAddButton.vue';
import mixins from 'vue-typed-mixins';
import { v4 as uuid } from 'uuid';
import {
@ -275,7 +283,20 @@ import {
RemoveConnectionCommand,
RemoveNodeCommand,
RenameNodeCommand,
historyBus,
} from '@/models/history';
import {
EVENT_ENDPOINT_MOUSEOVER,
EVENT_ENDPOINT_MOUSEOUT,
EVENT_DRAG_MOVE,
EVENT_CONNECTION_DRAG,
EVENT_CONNECTION_ABORT,
EVENT_CONNECTION_MOUSEOUT,
EVENT_CONNECTION_MOUSEOVER,
BrowserJsPlumbInstance,
ready,
} from '@jsplumb/browser-ui';
import { N8nPlusEndpoint } from '@/plugins/endpoints/N8nPlusEndpointType';
interface AddNodeOptions {
position?: XYPosition;
@ -313,7 +334,6 @@ export default mixins(
},
setup() {
const { registerCustomAction, unregisterCustomAction } = useGlobalLinkActions();
return {
registerCustomAction,
unregisterCustomAction,
@ -563,7 +583,7 @@ export default mixins(
nodeViewScale(): number {
return this.canvasStore.nodeViewScale;
},
instance(): jsPlumbInstance {
instance(): BrowserJsPlumbInstance {
return this.canvasStore.jsPlumbInstance;
},
},
@ -587,7 +607,10 @@ export default mixins(
isExecutionPreview: false,
showTriggerMissingTooltip: false,
workflowData: null as INewWorkflowData | null,
activeConnection: null as null | Connection,
isProductionExecutionPreview: false,
enterTimer: undefined as undefined | ReturnType<typeof setTimeout>,
exitTimer: undefined as undefined | ReturnType<typeof setTimeout>,
// jsplumb automatically deletes all loose connections which is in turn recorded
// in undo history as a user action.
// This should prevent automatically removed connections from populating undo stack
@ -1488,7 +1511,7 @@ export default mixins(
}
}
return this.importWorkflowData(workflowData!, false, 'paste');
return this.importWorkflowData(workflowData!, 'paste', false);
}
},
@ -1516,9 +1539,8 @@ export default mixins(
// Imports the given workflow data into the current workflow
async importWorkflowData(
workflowData: IWorkflowToShare,
// eslint-disable-next-line @typescript-eslint/default-param-last
importTags = true,
source: string,
importTags = true,
): Promise<void> {
// eslint-disable-line @typescript-eslint/default-param-last
// If it is JSON check if it looks on the first look like data we can use
@ -2032,14 +2054,14 @@ export default mixins(
this.historyStore.stopRecordingUndo();
},
initNodeView() {
this.instance.importDefaults({
Connector: NodeViewUtils.CONNECTOR_FLOWCHART_TYPE,
Endpoint: ['Dot', { radius: 5 }],
DragOptions: { cursor: 'pointer', zIndex: 5000 },
PaintStyle: NodeViewUtils.CONNECTOR_PAINT_STYLE_DEFAULT,
HoverPaintStyle: NodeViewUtils.CONNECTOR_PAINT_STYLE_PRIMARY,
ConnectionOverlays: NodeViewUtils.CONNECTOR_ARROW_OVERLAYS,
Container: '#node-view',
this.instance?.importDefaults({
endpoint: {
type: 'Dot',
options: { radius: 5 },
},
paintStyle: NodeViewUtils.CONNECTOR_PAINT_STYLE_DEFAULT,
hoverPaintStyle: NodeViewUtils.CONNECTOR_PAINT_STYLE_PRIMARY,
connectionOverlays: NodeViewUtils.CONNECTOR_ARROW_OVERLAYS,
});
const insertNodeAfterSelected = (info: {
@ -2067,7 +2089,7 @@ export default mixins(
this.onToggleNodeCreator({ source: info.eventSource, createNodeActive: true });
};
this.instance.bind('connectionAborted', (connection) => {
this.instance.bind(EVENT_CONNECTION_ABORT, (connection: Connection) => {
try {
if (this.dropPrevented) {
this.dropPrevented = false;
@ -2075,10 +2097,10 @@ export default mixins(
}
if (this.pullConnActiveNodeName) {
const sourceNode = this.workflowsStore.getNodeById(connection.sourceId);
const sourceNode = this.workflowsStore.getNodeById(connection.parameters.nodeId);
if (sourceNode) {
const sourceNodeName = sourceNode.name;
const outputIndex = connection.getParameters().index;
const outputIndex = connection.parameters.index;
this.connectTwoNodes(
sourceNodeName,
@ -2093,8 +2115,8 @@ export default mixins(
}
insertNodeAfterSelected({
sourceId: connection.sourceId,
index: connection.getParameters().index,
sourceId: connection.parameters.nodeId,
index: connection.parameters.index,
eventSource: 'node_connection_drop',
});
} catch (e) {
@ -2102,11 +2124,10 @@ export default mixins(
}
});
this.instance.bind('beforeDrop', (info) => {
this.instance.bind(INTERCEPT_BEFORE_DROP, (info: BeforeDropParams) => {
try {
const sourceInfo = info.connection.endpoints[0].getParameters();
// @ts-ignore
const targetInfo = info.dropEndpoint.getParameters();
const sourceInfo = info.connection.endpoints[0].parameters;
const targetInfo = info.dropEndpoint.parameters;
const sourceNodeName = this.workflowsStore.getNodeById(sourceInfo.nodeId)?.name || '';
const targetNodeName = this.workflowsStore.getNodeById(targetInfo.nodeId)?.name || '';
@ -2127,13 +2148,10 @@ export default mixins(
}
});
// only one set of visible actions should be visible at the same time
let activeConnection: null | Connection = null;
this.instance.bind('connection', (info: OnConnectionBindInfo) => {
this.instance.bind(EVENT_CONNECTION, (info: ConnectionEstablishedParams) => {
try {
const sourceInfo = info.sourceEndpoint.getParameters();
const targetInfo = info.targetEndpoint.getParameters();
const sourceInfo = info.sourceEndpoint.parameters;
const targetInfo = info.targetEndpoint.parameters;
const sourceNodeName = this.workflowsStore.getNodeById(sourceInfo.nodeId)?.name;
const targetNodeName = this.workflowsStore.getNodeById(targetInfo.nodeId)?.name;
@ -2148,86 +2166,6 @@ export default mixins(
}
NodeViewUtils.resetConnection(info.connection);
if (!this.isReadOnly) {
let exitTimer: NodeJS.Timeout | undefined;
let enterTimer: NodeJS.Timeout | undefined;
info.connection.bind('mouseover', (connection: Connection) => {
try {
if (exitTimer !== undefined) {
clearTimeout(exitTimer);
exitTimer = undefined;
}
if (enterTimer) {
return;
}
if (!info.connection || info.connection === activeConnection) {
return;
}
NodeViewUtils.hideConnectionActions(activeConnection);
enterTimer = setTimeout(() => {
enterTimer = undefined;
if (info.connection) {
activeConnection = info.connection;
NodeViewUtils.showConnectionActions(info.connection);
}
}, 150);
} catch (e) {
console.error(e); // eslint-disable-line no-console
}
});
info.connection.bind('mouseout', (connection: Connection) => {
try {
if (exitTimer) {
return;
}
if (enterTimer) {
clearTimeout(enterTimer);
enterTimer = undefined;
}
if (!info.connection || activeConnection !== info.connection) {
return;
}
exitTimer = setTimeout(() => {
exitTimer = undefined;
if (info.connection && activeConnection === info.connection) {
NodeViewUtils.hideConnectionActions(activeConnection);
activeConnection = null;
}
}, 500);
} catch (e) {
console.error(e); // eslint-disable-line no-console
}
});
NodeViewUtils.addConnectionActionsOverlay(
info.connection,
() => {
activeConnection = null;
this.__deleteJSPlumbConnection(info.connection);
},
() => {
setTimeout(() => {
insertNodeAfterSelected({
sourceId: info.sourceId,
index: sourceInfo.index,
connection: info.connection,
eventSource: 'node_connection_action',
});
}, 150);
},
);
}
NodeViewUtils.moveBackInputLabelPosition(info.targetEndpoint);
const connectionData: [IConnection, IConnection] = [
@ -2247,26 +2185,105 @@ export default mixins(
connection: connectionData,
setStateDirty: true,
});
this.dropPrevented = true;
if (!this.suspendRecordingDetachedConnections) {
this.historyStore.pushCommandToUndo(new AddConnectionCommand(connectionData, this));
this.historyStore.pushCommandToUndo(new AddConnectionCommand(connectionData));
}
if (!this.isReadOnly) {
NodeViewUtils.addConnectionActionsOverlay(
info.connection,
() => {
this.activeConnection = null;
this.__deleteJSPlumbConnection(info.connection);
},
() => {
insertNodeAfterSelected({
sourceId: info.sourceEndpoint.parameters.nodeId,
index: sourceInfo.index,
connection: info.connection,
eventSource: 'node_connection_action',
});
},
);
}
} catch (e) {
console.error(e); // eslint-disable-line no-console
}
});
this.instance.bind('connectionMoved', (info) => {
this.instance.bind(EVENT_DRAG_MOVE, () => {
this.instance?.connections.forEach((connection) => {
NodeViewUtils.showOrHideItemsLabel(connection);
NodeViewUtils.showOrHideMidpointArrow(connection);
Object.values(connection.overlays).forEach((overlay) => {
if (!overlay.canvas) return;
this.instance?.repaint(overlay.canvas);
});
});
});
this.instance.bind(EVENT_CONNECTION_MOUSEOVER, (connection: Connection) => {
try {
if (this.exitTimer !== undefined) {
clearTimeout(this.exitTimer);
this.exitTimer = undefined;
}
if (
this.isReadOnly ||
this.enterTimer ||
!connection ||
connection === this.activeConnection
)
return;
if (this.activeConnection) NodeViewUtils.hideConnectionActions(this.activeConnection);
this.enterTimer = setTimeout(() => {
this.enterTimer = undefined;
if (connection) {
NodeViewUtils.showConnectionActions(connection);
this.activeConnection = connection;
}
}, 150);
} catch (e) {
console.error(e); // eslint-disable-line no-console
}
});
this.instance.bind(EVENT_CONNECTION_MOUSEOUT, (connection: Connection) => {
try {
if (this.exitTimer) return;
if (this.enterTimer) {
clearTimeout(this.enterTimer);
this.enterTimer = undefined;
}
if (this.isReadOnly || !connection || this.activeConnection?.id !== connection.id) return;
this.exitTimer = setTimeout(() => {
this.exitTimer = undefined;
if (connection && this.activeConnection === connection) {
NodeViewUtils.hideConnectionActions(this.activeConnection);
this.activeConnection = null;
}
}, 500);
} catch (e) {
console.error(e); // eslint-disable-line no-console
}
});
this.instance.bind(EVENT_CONNECTION_MOVED, (info: ConnectionMovedParams) => {
try {
// When a connection gets moved from one node to another it for some reason
// calls the "connection" event but not the "connectionDetached" one. So we listen
// additionally to the "connectionMoved" event and then only delete the existing connection.
NodeViewUtils.resetInputLabelPosition(info.originalTargetEndpoint);
NodeViewUtils.resetInputLabelPosition(info.connection);
// @ts-ignore
const sourceInfo = info.originalSourceEndpoint.getParameters();
// @ts-ignore
const targetInfo = info.originalTargetEndpoint.getParameters();
const sourceInfo = info.connection.parameters;
const targetInfo = info.originalEndpoint.parameters;
const connectionInfo = [
{
@ -2286,8 +2303,18 @@ export default mixins(
console.error(e); // eslint-disable-line no-console
}
});
this.instance.bind('connectionDetached', async (info) => {
this.instance.bind(EVENT_ENDPOINT_MOUSEOVER, (endpoint: Endpoint, mouse) => {
// This event seems bugged. It gets called constantly even when the mouse is not over the endpoint
// if the endpoint has a connection attached to it. So we need to check if the mouse is actually over
// the endpoint.
if (!endpoint.isTarget || mouse.target !== endpoint.endpoint.canvas) return;
this.instance.setHover(endpoint, true);
});
this.instance.bind(EVENT_ENDPOINT_MOUSEOUT, (endpoint: Endpoint) => {
if (!endpoint.isTarget) return;
this.instance.setHover(endpoint, false);
});
this.instance.bind(EVENT_CONNECTION_DETACHED, async (info: ConnectionDetachedParams) => {
try {
const connectionInfo: [IConnection, IConnection] | null = getConnectionInfo(info);
NodeViewUtils.resetInputLabelPosition(info.targetEndpoint);
@ -2297,14 +2324,12 @@ export default mixins(
if (this.pullConnActiveNodeName) {
// establish new connection when dragging connection from one node to another
this.historyStore.startRecordingUndo();
const sourceNode = this.workflowsStore.getNodeById(info.connection.sourceId);
const sourceNode = this.workflowsStore.getNodeById(info.connection.parameters.nodeId);
const sourceNodeName = sourceNode.name;
const outputIndex = info.connection.getParameters().index;
const outputIndex = info.connection.parameters.index;
if (connectionInfo) {
this.historyStore.pushCommandToUndo(
new RemoveConnectionCommand(connectionInfo, this),
);
this.historyStore.pushCommandToUndo(new RemoveConnectionCommand(connectionInfo));
}
this.connectTwoNodes(sourceNodeName, outputIndex, this.pullConnActiveNodeName, 0, true);
this.pullConnActiveNodeName = null;
@ -2324,26 +2349,27 @@ export default mixins(
console.error(e); // eslint-disable-line no-console
}
});
// @ts-ignore
this.instance.bind('connectionDrag', (connection: Connection) => {
this.instance.bind(EVENT_CONNECTION_DRAG, (connection: Connection) => {
// The overlays are visible by default so we need to hide the midpoint arrow
// manually
connection.overlays['midpoint-arrow']?.setVisible(false);
try {
this.pullConnActiveNodeName = null;
this.pullConnActive = true;
this.newNodeInsertPosition = null;
NodeViewUtils.resetConnection(connection);
const nodes = [...document.querySelectorAll('.node-default')];
const nodes = [...document.querySelectorAll('.node-wrapper')];
const onMouseMove = (e: MouseEvent | TouchEvent) => {
if (!connection) {
return;
}
const element = document.querySelector('.jtk-endpoint.dropHover');
const element = document.querySelector('.jtk-endpoint.jtk-drag-hover');
if (element) {
// @ts-ignore
NodeViewUtils.showDropConnectionState(connection, element._jsPlumb);
const endpoint = element.jtk.endpoint;
NodeViewUtils.showDropConnectionState(connection, endpoint);
return;
}
@ -2360,7 +2386,7 @@ export default mixins(
this.pullConnActiveNodeName = node.name;
const endpointUUID = this.getInputEndpointUUID(nodeName, 0);
if (endpointUUID) {
const endpoint = this.instance.getEndpoint(endpointUUID);
const endpoint = this.instance?.getEndpoint(endpointUUID);
NodeViewUtils.showDropConnectionState(connection, endpoint);
@ -2395,8 +2421,17 @@ export default mixins(
console.error(e); // eslint-disable-line no-console
}
});
// @ts-ignore
this.instance.bind(
[EVENT_CONNECTION_DRAG, EVENT_CONNECTION_ABORT, EVENT_CONNECTION_DETACHED],
(connection: Connection) => {
Object.values(this.instance?.endpointsByElement)
.flatMap((endpoints) => Object.values(endpoints))
.filter((endpoint) => endpoint.endpoint.type === 'N8nPlus')
.forEach((endpoint) =>
setTimeout(() => endpoint.instance.revalidate(endpoint.element), 0),
);
},
);
this.instance.bind('plusEndpointClick', (endpoint: Endpoint) => {
if (endpoint && endpoint.__meta) {
insertNodeAfterSelected({
@ -2555,10 +2590,8 @@ export default mixins(
}
const uuid: [string, string] = [outputUuid, inputUuid];
// Create connections in DOM
// @ts-ignore
this.instance.connect({
this.instance?.connect({
uuids: uuid,
detachable: !this.isReadOnly,
});
@ -2581,15 +2614,12 @@ export default mixins(
if (!sourceNode || !targetNode) {
return;
}
// @ts-ignore
const connections = this.instance.getConnections({
const connections = this.instance?.getConnections({
source: sourceNode.id,
target: targetNode.id,
});
// @ts-ignore
connections.forEach((connectionInstance) => {
connections.forEach((connectionInstance: Connection) => {
if (connectionInstance.__meta) {
// Only delete connections from specific indexes (if it can be determined by meta)
if (
@ -2611,13 +2641,8 @@ export default mixins(
// it visibly stays behind free floating without a connection.
connection.removeOverlays();
const sourceEndpoint = connection.endpoints && connection.endpoints[0];
this.pullConnActiveNodeName = null; // prevent new connections when connectionDetached is triggered
this.instance.deleteConnection(connection); // on delete, triggers connectionDetached event which applies mutation to store
if (sourceEndpoint) {
const endpoints = this.instance.getEndpoints(sourceEndpoint.elementId);
endpoints.forEach((endpoint: Endpoint) => endpoint.repaint()); // repaint both circle and plus endpoint
}
this.instance?.deleteConnection(connection); // on delete, triggers connectionDetached event which applies mutation to store
if (trackHistory && connection.__meta) {
const connectionData: [IConnection, IConnection] = [
{
@ -2635,18 +2660,14 @@ export default mixins(
this.historyStore.pushCommandToUndo(removeCommand);
}
},
__removeConnectionByConnectionInfo(
info: OnConnectionBindInfo,
removeVisualConnection = false,
trackHistory = false,
) {
__removeConnectionByConnectionInfo(info, removeVisualConnection = false, trackHistory = false) {
const connectionInfo: [IConnection, IConnection] | null = getConnectionInfo(info);
if (connectionInfo) {
if (removeVisualConnection) {
this.__deleteJSPlumbConnection(info.connection, trackHistory);
} else if (trackHistory) {
this.historyStore.pushCommandToUndo(new RemoveConnectionCommand(connectionInfo, this));
this.historyStore.pushCommandToUndo(new RemoveConnectionCommand(connectionInfo));
}
this.workflowsStore.removeConnection({ connection: connectionInfo });
}
@ -2751,7 +2772,7 @@ export default mixins(
const targetEndpoint = NodeViewUtils.getInputEndpointUUID(targetId, targetInputIndex);
// @ts-ignore
const connections = this.instance.getConnections({
const connections = this.instance?.getConnections({
source: sourceId,
target: targetId,
}) as Connection[];
@ -2763,14 +2784,19 @@ export default mixins(
},
getJSPlumbEndpoints(nodeName: string): Endpoint[] {
const node = this.workflowsStore.getNodeByName(nodeName);
return this.instance.getEndpoints(node !== null ? node.id : '');
const nodeEls: Element = (this.$refs[`node-${node?.id}`] as ComponentInstance[])[0]
.$el as Element;
const endpoints = this.instance?.getEndpoints(nodeEls);
return endpoints as Endpoint[];
},
getPlusEndpoint(nodeName: string, outputIndex: number): Endpoint | undefined {
const endpoints = this.getJSPlumbEndpoints(nodeName);
// @ts-ignore
return endpoints.find(
(endpoint: Endpoint) =>
endpoint.type === 'N8nPlus' && endpoint.__meta && endpoint.__meta.index === outputIndex,
// @ts-ignore
endpoint.endpoint.type === 'N8nPlus' && endpoint?.__meta?.index === outputIndex,
);
},
getIncomingOutgoingConnections(nodeName: string): {
@ -2781,12 +2807,12 @@ export default mixins(
if (node) {
// @ts-ignore
const outgoing = this.instance.getConnections({
const outgoing = this.instance?.getConnections({
source: node.id,
});
// @ts-ignore
const incoming = this.instance.getConnections({
const incoming = this.instance?.getConnections({
target: node.id,
}) as Connection[];
@ -2823,8 +2849,7 @@ export default mixins(
const sourceId = sourceNode !== null ? sourceNode.id : '';
if (data === null || data.length === 0 || waiting) {
// @ts-ignore
const outgoing = this.instance.getConnections({
const outgoing = this.instance?.getConnections({
source: sourceId,
}) as Connection[];
@ -2833,8 +2858,7 @@ export default mixins(
});
const endpoints = this.getJSPlumbEndpoints(sourceNodeName);
endpoints.forEach((endpoint: Endpoint) => {
// @ts-ignore
if (endpoint.type === 'N8nPlus') {
if (endpoint.endpoint.type === 'N8nPlus') {
(endpoint.endpoint as N8nPlusEndpoint).clearSuccessOutput();
}
});
@ -2875,6 +2899,7 @@ export default mixins(
);
if (endpoint && endpoint.endpoint) {
const output = outputMap[sourceOutputIndex][NODE_OUTPUT_DEFAULT_KEY][0];
if (output && output.total > 0) {
(endpoint.endpoint as N8nPlusEndpoint).setSuccessOutput(
NodeViewUtils.getRunItemsLabel(output),
@ -2963,7 +2988,7 @@ export default mixins(
);
if (waitForNewConnection) {
this.instance.setSuspendDrawing(false, true);
this.instance?.setSuspendDrawing(false, true);
waitForNewConnection = false;
}
}, 100); // just to make it clear to users that this is a new connection
@ -2973,14 +2998,10 @@ export default mixins(
setTimeout(() => {
// Suspend drawing
this.instance.setSuspendDrawing(true);
// Remove all endpoints and the connections in jsplumb
this.instance.removeAllEndpoints(node.id);
// Remove the draggable
// @ts-ignore
this.instance.destroyDraggable(node.id);
this.instance?.setSuspendDrawing(true);
(this.instance?.endpointsByElement[node.id] || [])
.flat()
.forEach((endpoint) => this.instance?.deleteEndpoint(endpoint));
// Remove the connections in data
this.workflowsStore.removeAllNodeConnection(node);
@ -2989,13 +3010,13 @@ export default mixins(
if (!waitForNewConnection) {
// Now it can draw again
this.instance.setSuspendDrawing(false, true);
this.instance?.setSuspendDrawing(false, true);
}
// Remove node from selected index if found in it
this.uiStore.removeNodeFromSelection(node);
if (trackHistory) {
this.historyStore.pushCommandToUndo(new RemoveNodeCommand(node, this));
this.historyStore.pushCommandToUndo(new RemoveNodeCommand(node));
}
}, 0); // allow other events to finish like drag stop
if (trackHistory && trackBulk) {
@ -3069,7 +3090,7 @@ export default mixins(
workflow.renameNode(currentName, newName);
if (trackHistory) {
this.historyStore.pushCommandToUndo(new RenameNodeCommand(currentName, newName, this));
this.historyStore.pushCommandToUndo(new RenameNodeCommand(currentName, newName));
}
// Update also last selected node and execution data
@ -3104,18 +3125,12 @@ export default mixins(
deleteEveryEndpoint() {
// Check as it does not exist on first load
if (this.instance) {
const nodes = this.workflowsStore.allNodes;
nodes.forEach((node: INodeUi) => {
try {
// important to prevent memory leak
// @ts-ignore
this.instance.destroyDraggable(node.id);
} catch (e) {
console.error(e);
}
});
this.instance?.reset();
Object.values(this.instance?.endpointsByElement)
.flatMap((endpoint) => endpoint)
.forEach((endpoint) => endpoint.destroy());
this.instance.deleteEveryEndpoint();
this.instance.deleteEveryConnection({ fireEvent: true });
}
},
matchCredentials(node: INodeUi) {
@ -3241,7 +3256,7 @@ export default mixins(
this.workflowsStore.addNode(node);
if (trackHistory) {
this.historyStore.pushCommandToUndo(new AddNodeCommand(node, this));
this.historyStore.pushCommandToUndo(new AddNodeCommand(node));
}
});
@ -3249,7 +3264,7 @@ export default mixins(
await Vue.nextTick();
// Suspend drawing
this.instance.setSuspendDrawing(true);
this.instance?.setSuspendDrawing(true);
// Load the connections
if (connections !== undefined) {
@ -3285,9 +3300,8 @@ export default mixins(
}
}
}
// Now it can draw again
this.instance.setSuspendDrawing(false, true);
this.instance?.setSuspendDrawing(false, true);
},
async addNodesToWorkflow(data: IWorkflowDataUpdate): Promise<IWorkflowDataUpdate> {
// Because nodes with the same name maybe already exist, it could
@ -3412,6 +3426,7 @@ export default mixins(
tempWorkflow.connectionsBySourceNode,
true,
);
this.historyStore.stopRecordingUndo();
this.uiStore.stateIsDirty = true;
@ -3458,7 +3473,6 @@ export default mixins(
}
// Keep only the connection to node which get also exported
// @ts-ignore
typeConnections = {};
for (type of Object.keys(connections)) {
for (sourceIndex = 0; sourceIndex < connections[type].length; sourceIndex++) {
@ -3508,14 +3522,12 @@ export default mixins(
// Ignore all errors
});
}
this.workflowsStore.removeAllConnections({ setStateDirty: false });
this.workflowsStore.removeAllNodes({ setStateDirty: false, removePinData: true });
// Reset workflow execution data
this.workflowsStore.setWorkflowExecutionData(null);
this.workflowsStore.resetAllNodesIssues();
// vm.$forceUpdate();
this.workflowsStore.setActive(false);
this.workflowsStore.setWorkflowId(PLACEHOLDER_EMPTY_WORKFLOW_ID);
@ -3622,12 +3634,12 @@ export default mixins(
} catch (e) {}
},
async onImportWorkflowDataEvent(data: IDataObject) {
await this.importWorkflowData(data.data as IWorkflowDataUpdate, undefined, 'file');
await this.importWorkflowData(data.data as IWorkflowDataUpdate, 'file');
},
async onImportWorkflowUrlEvent(data: IDataObject) {
const workflowData = await this.getWorkflowDataFromUrl(data.url as string);
if (workflowData !== undefined) {
await this.importWorkflowData(workflowData, undefined, 'url');
await this.importWorkflowData(workflowData, 'url');
}
},
addPinDataConnections(pinData: IPinData) {
@ -3638,7 +3650,7 @@ export default mixins(
}
// @ts-ignore
const connections = this.instance.getConnections({
const connections = this.instance?.getConnections({
source: node.id,
}) as Connection[];
@ -3658,11 +3670,13 @@ export default mixins(
}
// @ts-ignore
const connections = this.instance.getConnections({
const connections = this.instance?.getConnections({
source: node.id,
}) as Connection[];
this.instance.setSuspendDrawing(true);
connections.forEach(NodeViewUtils.resetConnection);
this.instance.setSuspendDrawing(false, true);
});
},
onToggleNodeCreator({
@ -3738,10 +3752,10 @@ export default mixins(
},
onMoveNode({ nodeName, position }: { nodeName: string; position: XYPosition }): void {
this.workflowsStore.updateNodeProperties({ name: nodeName, properties: { position } });
setTimeout(() => {
const node = this.workflowsStore.getNodeByName(nodeName);
setTimeout(() => {
if (node) {
this.instance.repaintEverything();
this.instance?.repaintEverything();
this.onNodeMoved(node);
}
}, 0);
@ -3780,12 +3794,12 @@ export default mixins(
},
},
async mounted() {
this.resetWorkspace();
this.canvasStore.initInstance(this.$refs.nodeView as HTMLElement);
this.$titleReset();
window.addEventListener('message', this.onPostMessageReceived);
this.startLoading();
this.resetWorkspace();
const loadPromises = [
this.loadActiveWorkflows(),
this.loadCredentials(),
@ -3806,8 +3820,7 @@ export default mixins(
);
return;
}
this.instance.ready(async () => {
ready(async () => {
try {
try {
this.initNodeView();
@ -3879,6 +3892,7 @@ export default mixins(
}
this.uiStore.addFirstStepOnLoad = false;
this.initNodeView();
document.addEventListener('keydown', this.keyDown);
document.addEventListener('keyup', this.keyUp);
window.addEventListener('message', this.onPostMessageReceived);
@ -3886,13 +3900,13 @@ export default mixins(
this.$root.$on('newWorkflow', this.newWorkflow);
this.$root.$on('importWorkflowData', this.onImportWorkflowDataEvent);
this.$root.$on('importWorkflowUrl', this.onImportWorkflowUrlEvent);
this.$root.$on('nodeMove', this.onMoveNode);
this.$root.$on('revertAddNode', this.onRevertAddNode);
this.$root.$on('revertRemoveNode', this.onRevertRemoveNode);
this.$root.$on('revertAddConnection', this.onRevertAddConnection);
this.$root.$on('revertRemoveConnection', this.onRevertRemoveConnection);
this.$root.$on('revertRenameNode', this.onRevertNameChange);
this.$root.$on('enableNodeToggle', this.onRevertEnableToggle);
historyBus.$on('nodeMove', this.onMoveNode);
historyBus.$on('revertAddNode', this.onRevertAddNode);
historyBus.$on('revertRemoveNode', this.onRevertRemoveNode);
historyBus.$on('revertAddConnection', this.onRevertAddConnection);
historyBus.$on('revertRemoveConnection', this.onRevertRemoveConnection);
historyBus.$on('revertRenameNode', this.onRevertNameChange);
historyBus.$on('enableNodeToggle', this.onRevertEnableToggle);
dataPinningEventBus.$on('pin-data', this.addPinDataConnections);
dataPinningEventBus.$on('unpin-data', this.removePinDataConnections);
@ -3908,20 +3922,23 @@ export default mixins(
this.$root.$off('newWorkflow', this.newWorkflow);
this.$root.$off('importWorkflowData', this.onImportWorkflowDataEvent);
this.$root.$off('importWorkflowUrl', this.onImportWorkflowUrlEvent);
this.$root.$off('nodeMove', this.onMoveNode);
this.$root.$off('revertAddNode', this.onRevertAddNode);
this.$root.$off('revertRemoveNode', this.onRevertRemoveNode);
this.$root.$off('revertAddConnection', this.onRevertAddConnection);
this.$root.$off('revertRemoveConnection', this.onRevertRemoveConnection);
this.$root.$off('revertRenameNode', this.onRevertNameChange);
this.$root.$off('enableNodeToggle', this.onRevertEnableToggle);
historyBus.$off('nodeMove', this.onMoveNode);
historyBus.$off('revertAddNode', this.onRevertAddNode);
historyBus.$off('revertRemoveNode', this.onRevertRemoveNode);
historyBus.$off('revertAddConnection', this.onRevertAddConnection);
historyBus.$off('revertRemoveConnection', this.onRevertRemoveConnection);
historyBus.$off('revertRenameNode', this.onRevertNameChange);
historyBus.$off('enableNodeToggle', this.onRevertEnableToggle);
dataPinningEventBus.$off('pin-data', this.addPinDataConnections);
dataPinningEventBus.$off('unpin-data', this.removePinDataConnections);
nodeViewEventBus.$off('saveWorkflow', this.saveCurrentWorkflowExternal);
this.instance.unbind();
},
destroyed() {
this.resetWorkspace();
this.instance.unbind();
this.instance.destroy();
this.uiStore.stateIsDirty = false;
window.removeEventListener('message', this.onPostMessageReceived);
this.$root.$off('newWorkflow', this.newWorkflow);
@ -4016,40 +4033,6 @@ export default mixins(
</style>
<style lang="scss">
.connection-run-items-label {
span {
border-radius: 7px;
background-color: hsla(
var(--color-canvas-background-h),
var(--color-canvas-background-s),
var(--color-canvas-background-l),
0.85
);
line-height: 1.3em;
padding: 0px 3px;
white-space: nowrap;
font-size: var(--font-size-s);
font-weight: var(--font-weight-regular);
color: var(--color-success);
}
.floating {
position: absolute;
top: -22px;
transform: translateX(-50%);
}
}
.connection-input-name-label {
position: relative;
span {
position: absolute;
top: -10px;
left: -60px;
}
}
.drop-add-node-label {
color: var(--color-text-dark);
font-weight: 600;
@ -4058,30 +4041,12 @@ export default mixins(
background-color: #ffffff55;
}
.node-input-endpoint-label,
.node-output-endpoint-label {
background-color: hsla(
var(--color-canvas-background-h),
var(--color-canvas-background-s),
var(--color-canvas-background-l),
0.85
);
border-radius: 7px;
font-size: 0.7em;
padding: 2px;
white-space: nowrap;
}
.node-input-endpoint-label {
text-align: right;
}
.connection-actions {
&:hover {
display: block !important;
}
> div {
> button {
color: var(--color-foreground-xdark);
border: 2px solid var(--color-foreground-xdark);
background-color: var(--color-background-xlight);

View file

@ -28,6 +28,7 @@ importers:
'@types/node': ^16.11.22
cross-env: ^7.0.3
cypress: ^10.0.3
cypress-real-events: ^1.7.6
jest: ^29.3.1
jest-environment-jsdom: ^29.3.1
jest-mock: ^29.3.1
@ -52,6 +53,7 @@ importers:
'@types/node': 16.11.65
cross-env: 7.0.3
cypress: 10.11.0
cypress-real-events: 1.7.6_cypress@10.11.0
jest: 29.3.1_@types+node@16.11.65
jest-environment-jsdom: 29.3.1
jest-mock: 29.3.1
@ -532,6 +534,11 @@ importers:
'@fortawesome/free-regular-svg-icons': ^6.1.1
'@fortawesome/free-solid-svg-icons': ^5.15.3
'@fortawesome/vue-fontawesome': ^2.0.2
'@jsplumb/browser-ui': ^5.13.2
'@jsplumb/common': ^5.13.2
'@jsplumb/connector-bezier': ^5.13.2
'@jsplumb/core': ^5.13.2
'@jsplumb/util': ^5.13.2
'@pinia/testing': ^0.0.14
'@testing-library/jest-dom': ^5.16.5
'@testing-library/vue': ^5.8.3
@ -561,7 +568,6 @@ importers:
jquery: ^3.4.1
jshint: ^2.9.7
jsonpath: ^1.1.1
jsplumb: 2.15.4
lodash-es: ^4.17.21
lodash.camelcase: ^4.3.0
lodash.debounce: ^4.0.8
@ -614,6 +620,11 @@ importers:
'@fortawesome/free-regular-svg-icons': 6.2.0
'@fortawesome/free-solid-svg-icons': 5.15.4
'@fortawesome/vue-fontawesome': 2.0.8_dh3wzfumpzw6zsszdpw5cxouqy
'@jsplumb/browser-ui': 5.13.2
'@jsplumb/common': 5.13.2
'@jsplumb/connector-bezier': 5.13.2
'@jsplumb/core': 5.13.2
'@jsplumb/util': 5.13.2
axios: 0.21.4
codemirror-lang-html-n8n: 1.0.0
codemirror-lang-n8n-expression: 0.1.0_zyklskjzaprvz25ee7sq7godcq
@ -625,7 +636,6 @@ importers:
humanize-duration: 3.27.3
jquery: 3.6.1
jsonpath: 1.1.1
jsplumb: 2.15.4
lodash-es: 4.17.21
lodash.camelcase: 4.3.0
lodash.debounce: 4.0.8
@ -2071,7 +2081,7 @@ packages:
'@babel/core': ^7.0.0-0
dependencies:
'@babel/core': 7.20.12
'@babel/helper-plugin-utils': 7.20.2
'@babel/helper-plugin-utils': 7.19.0
dev: true
/@babel/plugin-syntax-logical-assignment-operators/7.10.4_@babel+core@7.19.3:
@ -3711,6 +3721,34 @@ packages:
/@jsdevtools/ono/7.1.3:
resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==}
/@jsplumb/browser-ui/5.13.2:
resolution: {integrity: sha512-BZ76kPtxESMIdhcCtWXPdICMudJyBVzDxaKY4jlne93Zq1T2ErfpNQ3E6f3JZfvoyvlNbKgh0udYkZ7Yg7BmIQ==}
dependencies:
'@jsplumb/core': 5.13.2
dev: false
/@jsplumb/common/5.13.2:
resolution: {integrity: sha512-ZX/EvvYi4HBkRVtsuSSAa/AuAz4p2wr3RrRz6l+r8yeElzX3lrrBx/fkERY2qwZPkKcOoLCr5ezZ7sslVMnl0Q==}
dependencies:
'@jsplumb/util': 5.13.2
dev: false
/@jsplumb/connector-bezier/5.13.2:
resolution: {integrity: sha512-AALmOvkiP3ouGag6TGkBcd7SbCewPNwsKu9gku9AZqIq+fFu321zJ2IpfoyCFgkoFFSQjJ9jo1sWBbD3gnEXrg==}
dependencies:
'@jsplumb/core': 5.13.2
dev: false
/@jsplumb/core/5.13.2:
resolution: {integrity: sha512-IODXQzhpq9QEzGKhPir6+ea8m4KeU3gzJsYjIu8oqSQ4jDhvEYF7TvSfeaNgy9sUAMt3OoKCqxCS4ga9J7LS5A==}
dependencies:
'@jsplumb/common': 5.13.2
dev: false
/@jsplumb/util/5.13.2:
resolution: {integrity: sha512-POrqlZMOo821oa49Xbxb+pNmnxu0z2oS7FOeklRxKuYXR+7nsP0j9PpXjo8E8Ily4TaP+pdUnatb53vAaONO3g==}
dev: false
/@kafkajs/confluent-schema-registry/1.0.6:
resolution: {integrity: sha512-NrZL1peOIlmlLKvheQcJAx9PHdnc4kaW+9+Yt4jXUfbbYR9EFNCZt6yApI4SwlFilaiZieReM6XslWy1LZAvoQ==}
dependencies:
@ -10253,6 +10291,14 @@ packages:
resolution: {integrity: sha512-NJGVKPS81XejHcLhaLJS7plab0fK3slPh11mESeeDq2W4ZI5kUKK/LRRdVDvjJseojbPB7ZwjnyOybg3Igea/A==}
dev: true
/cypress-real-events/1.7.6_cypress@10.11.0:
resolution: {integrity: sha512-yP6GnRrbm6HK5q4DH6Nnupz37nOfZu/xn1xFYqsE2o4G73giPWQOdu6375QYpwfU1cvHNCgyD2bQ2hPH9D7NMw==}
peerDependencies:
cypress: ^4.x || ^5.x || ^6.x || ^7.x || ^8.x || ^9.x || ^10.x || ^11.x || ^12.x
dependencies:
cypress: 10.11.0
dev: true
/cypress/10.11.0:
resolution: {integrity: sha512-lsaE7dprw5DoXM00skni6W5ElVVLGAdRUUdZjX2dYsGjbY/QnpzWZ95Zom1mkGg0hAaO/QVTZoFVS7Jgr/GUPA==}
engines: {node: '>=12.0.0'}
@ -15088,10 +15134,6 @@ packages:
semver: 7.3.8
dev: false
/jsplumb/2.15.4:
resolution: {integrity: sha512-QssfhXe0YRxY4V2WHPmKwsE3bPHNj4Vts9oinys66ci+4m9lJvFDcEMDygqueiSFL8Jb8CnFyQC9fvL+YHJS7g==}
dev: false
/jsprim/1.4.2:
resolution: {integrity: sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==}
engines: {node: '>=0.6.0'}