Skip to content

🐞Ports Edge Connection Wrong Line Target Position #4990

@abdofallah

Description

@abdofallah

问题描述

Ports connection to the target node is always in accurate, but moving it or manually adding edge never causes this issue. Watch video please.

预期行为

The issue did not happen in the older version of x6 (which was a very old version), i updated the library to v3 and started having this issue since.

屏幕截图或视频(可选但最好有)

https://drive.google.com/file/d/1o-dYLJToroal07BJcVQUkSKl8JoxGW3m/view?usp=sharing

x6.bug.mp4

平台

  • 操作系统: Windows
  • 网页浏览器: Google Chrome, FIrefox
  • X6 版本: 3~3.1.3

重现步骤

Code:

function registerScriptNodes() {
	// Register Start Node
	X6.Shape.HTML.register({
		shape: SCRIPT_NODE_TYPES.START,
		width: 250, // Smaller width for pill shape
		height: 70, // Fixed height for pill shape
		effect: [],
		ports: {
			groups: {
				output: {
					position: "bottom",
					attrs: {
						circle: {
							r: 8,
							magnet: true,
							stroke: "#198754",
							strokeWidth: 2,
							fill: "#fff",
						},
					},
				},
			},
		},
		html(cell) {
			const div = document.createElement("div");
			div.className = "script-node script-start-node";

			div.innerHTML = `
                <div class="script-node-content">
                    <div class="btn-ic-span-align">
						<i class="fa-regular fa-flag"></i>
						<span>Start</span>
					</div>
                </div>
            `;

			return div;
		},
	});

	// User Query/Message Node
	X6.Shape.HTML.register({
		shape: SCRIPT_NODE_TYPES.USER_QUERY,
		width: SCRIPT_NODE_WIDTH,
		height: SCRIPT_NODE_MIN_HEIGHT,
		effect: [],
		ports: {
			groups: {
				input: {
					position: "top",
					attrs: {
						circle: {
							r: 10,
							magnet: true,
							stroke: "#8f8f8f",
							strokeWidth: 2,
							fill: "#fff",
						},
					},
				},
				output: {
					position: "bottom",
					attrs: {
						circle: {
							r: 8,
							magnet: true,
							stroke: "#8f8f8f",
							strokeWidth: 2,
							fill: "#fff",
						},
					},
				},
			},
		},
		html(cell) {
			const div = document.createElement("div");
			div.className = "script-node script-user-query-node";

			const data = cell.getData() || {};
			const currentLanguage = scriptsManagerLanguageDropdown.getSelectedLanguage().id;

			div.innerHTML = `
                <div class="script-node-header">
                    <div>
						<div class="d-flex align-items-center btn-ic-span-align node-title">
							<i class="fa-regular fa-message me-2"></i>
							<span>User Query</span>
						</div>
						<span class="node-id">${cell.id}</span>
					</div>
                    <div class="node-actions html-shape-immovable">
                        <button class="btn btn-light btn-sm me-2" data-action="configure-user-query">
                            <i class="fa-regular fa-gear"></i>
                        </button>
                        <button class="btn btn-danger btn-sm" data-action="delete-node">
                            <i class="fa-regular fa-trash"></i>
                        </button>
                    </div>
                </div>
                <div class="script-node-content">
                    <div class="script-node-input-group html-shape-immovable">
                        <textarea 
                            class="form-control" 
                            placeholder="Type the user query..."
                            data-input="user-query"
                            rows="3"
                        >${data.query?.[currentLanguage] || ""}</textarea>
                    </div>
                </div>
            `;

			return div;
		},
	});

	// AI Response Node
	X6.Shape.HTML.register({
		shape: SCRIPT_NODE_TYPES.AI_RESPONSE,
		width: SCRIPT_NODE_WIDTH,
		height: SCRIPT_NODE_MIN_HEIGHT,
		effect: [],
		ports: {
			groups: {
				input: {
					position: "top",
					attrs: {
						circle: {
							r: 10,
							magnet: true,
							stroke: "#8f8f8f",
							strokeWidth: 2,
							fill: "#fff",
						},
					},
				},
				output: {
					position: "bottom",
					attrs: {
						circle: {
							r: 8,
							magnet: true,
							stroke: "#8f8f8f",
							strokeWidth: 2,
							fill: "#fff",
						},
					},
				},
			},
		},
		html(cell) {
			const div = document.createElement("div");
			div.className = "script-node script-ai-response-node";

			const data = cell.getData() || {};
			const currentLanguage = scriptsManagerLanguageDropdown.getSelectedLanguage().id;

			div.innerHTML = `
                <div class="script-node-header">
                    <div>
						<div class="d-flex align-items-center btn-ic-span-align node-title">
							<i class="fa-regular fa-robot me-2"></i>
							<span>AI Response</span>
						</div>
						<span class="node-id">${cell.id}</span>
					</div>
                    <div class="node-actions html-shape-immovable">
                        <button class="btn btn-light btn-sm me-2" data-action="configure-ai-response">
                            <i class="fa-regular fa-gear"></i>
                        </button>
                        <button class="btn btn-danger btn-sm" data-action="delete-node">
                            <i class="fa-regular fa-trash"></i>
                        </button>
                    </div>
                </div>
                <div class="script-node-content">
                    <div class="script-node-input-group html-shape-immovable">
                        <textarea 
                            class="form-control" 
                            placeholder="Type the AI response..."
                            data-input="ai-response"
                            rows="3"
                        >${data.response?.[currentLanguage] || ""}</textarea>
                    </div>
                </div>
            `;

			return div;
		},
	});
}

function initializeScriptGraph(isNew = true) {
	const container = $("#script-graph")[0];

	return resizeScriptGraphCSS((graphSize) => {
		// Set Default Shape Attributes
		X6.Shape.Edge.defaults.attrs.line.stroke = "#fff";
		X6.Shape.Edge.defaults.attrs.line.sourceMarker = "classic";
		X6.Shape.Edge.defaults.attrs.line.targetMarker = "classic";

		// Create the graph instance
		const graph = new X6.Graph({
			container: container,
			width: graphSize.width,
			height: graphSize.height,

			// Grid settings
			grid: {
				visible: true,
				type: "fixedDot",
				size: 30,
				args: {
					color: "#2a2a2a",
					thickness: 3,
				},
			},
			// Background settings
			background: {
				color: "#0f0f0f",
			},
			// Interaction settings
			mousewheel: {
				enabled: true,
				modifiers: [],
				factor: 1.1,
				maxScale: 16,
				minScale: 0.01,
			},
			scaling: {
				min: 0.01,
				max: 16
			},
			panning: {
				enabled: true,
				modifiers: [],
			},
			connecting: {
				connector: {
					name: "rounded",
					args: {
						radius: 20,
					},
				},
				allowBlank: false,
				allowLoop: false,
				allowNode: false,
				allowEdge: false,
				allowMulti: false,
				highlight: true,
				router: {
					name: "orth",
					args: {
						padding: 4,
					},
				},
				validateConnection({ sourceView, targetView, sourceMagnet, targetMagnet }) {
					if (!sourceView || !targetView) {
						return false;
					}

					if (!sourceMagnet || !targetMagnet) {
						return false;
					}

					const sourcePort = sourceMagnet.attributes["port-group"].value;
					const targetPort = targetMagnet.attributes["port-group"].value;

					if ((sourcePort === "output" && targetPort === "output") || (sourcePort === "input" && targetPort === "input")) {
						return false;
					}

					let inputCell;
					let outputCell;

					let inputPort;
					let outputPort;

					if (sourcePort === "input") {
						inputCell = sourceView.cell;
						outputCell = targetView.cell;

						inputPort = sourceMagnet.attributes.port.value;
						outputPort = targetMagnet.attributes.port.value;
					} else {
						inputCell = targetView.cell;
						outputCell = sourceView.cell;

						inputPort = targetMagnet.attributes.port.value;
						outputPort = sourceMagnet.attributes.port.value;
					}

					// start node can not connect to ai response node
					if (outputCell.shape === SCRIPT_NODE_TYPES.START && inputCell.shape === SCRIPT_NODE_TYPES.AI_RESPONSE) {
						return false;
					}

					// ai response node can only connect to user query node
					if (outputCell.shape === SCRIPT_NODE_TYPES.AI_RESPONSE && inputCell.shape !== SCRIPT_NODE_TYPES.USER_QUERY) {
						return false;
					}

					// validate if source already has connected nodes
					let validateNoDiffOuputTypes = false;
					CurrentScriptGraph.getEdges().forEach((edge) => {
						const letEdgeSource = edge.getSource();

						if (letEdgeSource.cell === outputCell.id && letEdgeSource.port === outputPort) {
							const letEdgeTarget = edge.getTarget();

							if (letEdgeTarget.cell) {
								letEdgeTargetCell = CurrentScriptGraph.getCellById(letEdgeTarget.cell);

								// if atleast one user query is connected, then no other node type can be connected
								if (letEdgeTargetCell.shape === SCRIPT_NODE_TYPES.USER_QUERY && inputCell.shape !== SCRIPT_NODE_TYPES.USER_QUERY) {
									validateNoDiffOuputTypes = true;
								}

								// if one custom tool/system tool/ai response is connected, then no other type and no more nodes can be connected
								if (
									(letEdgeTargetCell.shape === SCRIPT_NODE_TYPES.CUSTOM_TOOL ||
										letEdgeTargetCell.shape === SCRIPT_NODE_TYPES.SYSTEM_TOOL ||
										letEdgeTargetCell.shape === SCRIPT_NODE_TYPES.AI_RESPONSE) &&
									letEdgeTargetCell.shape !== inputCell.shape
								) {
									validateNoDiffOuputTypes = true;
								}
							}
						}
					});
					if (validateNoDiffOuputTypes) return false;

					return true;
				},
			},
			// Prevent node text selection
			preventDefaultContextMenu: ({ view, event }) => {
				if (!view || view == null) {
					return true;
				}

				// if event.target is textarea, then reutrn flase
				if ($(event.target).is("textarea") || $(event.target).is("input")) {
					return false;
				}

				return true;
			}
		});

		// Add minimap plugin
		const minimapContainer = document.getElementById("script-graph-minimap");
		const enableMinimap = true;
		if (minimapContainer && enableMinimap) {
			setTimeout(() => {
				graph.use(
					new SCRIPT_GRAPH_PLUGINS.Minimap({
						container: minimapContainer,
						width: 180,
						height: 150,
						scalable: false,
						padding: 0,
						graphOptions: {
							width: 180,
							height: 150,
							autoResize: true,
							grid: {
								visible: true,
								type: "fixedDot",
								size: 30,
								args: {
									color: "#2a2a2a",
									thickness: 3,
								},
							},
							// Background settings
							background: {
								color: "#0f0f0f",
							}
						}
					}),
				);
			}, 2000);
		}

		// Add keyboard shortcuts plugin
		if (SCRIPT_GRAPH_PLUGINS.Keyboard) {
			graph.use(
				new SCRIPT_GRAPH_PLUGINS.Keyboard({
					enabled: true,
					global: true,
				}),
			);
		}

		// Add clipboard plugin
		if (SCRIPT_GRAPH_PLUGINS.Clipboard) {
			graph.use(
				new SCRIPT_GRAPH_PLUGINS.Clipboard({
					enabled: true,
				}),
			);
		}

		// Add history plugin (undo/redo)
		if (SCRIPT_GRAPH_PLUGINS.History) {
			CurrentScriptGraphHistory = new SCRIPT_GRAPH_PLUGINS.History({
				enabled: true,
				beforeAddCommand: (event, args) => {
					// Validate before adding to history
					return true;
				},
			});
			graph.use(CurrentScriptGraphHistory);
		}

		// Add selection plugin
		if (SCRIPT_GRAPH_PLUGINS.Selection) {
			CurrentScriptGraphSelection = new SCRIPT_GRAPH_PLUGINS.Selection({
				enabled: true,
				modifiers: ["ctrl"],
				rubberband: true,
				multiple: true,
				movable: true,
				showNodeSelectionBox: true,
				eventTypes: ["leftMouseDown"],
			});
			graph.use(CurrentScriptGraphSelection);
		}

		// Add start node if new graph
		if (isNew) {
			const CurrentScriptGraphStartNode = graph.addNode({
				id: "start_node",
				shape: SCRIPT_NODE_TYPES.START,
				data: { type: SCRIPT_NODE_TYPES.START },
				x: graphSize.width / 2,
				y: graphSize.height / 5,
				ports: {
					items: [{ group: "output" }],
				},
			});
			ManageCurrentScriptData.nodes.push({
				id: CurrentScriptGraphStartNode.id,
				type: SCRIPT_NODE_TYPES.START,
				position: CurrentScriptGraphStartNode.getPosition(),
			});
		}

		// Event Listeners
		graph.on("scale", ({ sx, sy }) => {
			const scale = sx;
			$("#script-graph-zoom-in").prop("disabled", scale >= 16);
			$("#script-graph-zoom-out").prop("disabled", scale <= 0.01);
		});

		graph.on("history:change", (event) => {
			$("#script-graph-undo").prop("disabled", !CurrentScriptGraphHistory.canUndo());
			$("#script-graph-redo").prop("disabled", !CurrentScriptGraphHistory.canRedo());
		});

		graph.on("cell:click", ({ cell, e }) => {
			const target = e.target;
			if (target.closest('[data-action="delete-node"]')) {
				const nodeType = cell.getData()?.type;
				if (nodeType === SCRIPT_NODE_TYPES.START) {
					return;
				}

				if (CurrentCanvasConfigCell !== null && cell.id === CurrentCanvasConfigCell.id) {
					nodeConfigOffcanvas.hide();
				}

				cell.remove();
			}
		});

		graph.on("edge:mouseenter", (event) => {
			event.edge.setAttrs({ line: { strokeDasharray: 5, style: "animation: ant-line 30s infinite linear" } });
		});

		graph.on("edge:mouseleave", (event) => {
			event.edge.setAttrs({ line: { strokeDasharray: 0, style: {} } });
		});

		graph.on("cell:click", ({ cell, e }) => {
			if ($(e.target).is("textarea") || $(e.target).is("input") || $(e.target).is("select") || $(e.target).is("button")) {
				CurrentScriptGraphSelection.clean();
				return;
			}

			if (CurrentScriptGraphSelection.getSelectedCellCount() === 1) {
				if (CurrentScriptGraphSelection.getSelectedCells()[0].id === cell.id) {
					return;
				}
			}

			CurrentScriptGraphSelection.clean();

			console.log(cell);
		});

		graph.on("edge:added", (event) => {
			console.log("edge:added", event);

			// Add Remove Button Tool
			event.edge.addTools({
				name: "button-remove",
				args: { distance: "50%" },
			});
		});

		graph.on("edge:connected", (event) => {
			console.log("edge:connected", event);
		});

		graph.on("blank:click", () => {
			nodeConfigOffcanvas.hide();
		});

		CurrentScriptGraph = graph;
	});
}

CSS:

/* Node */
/* Common Node Styles */
.x6-widget-minimap .script-node,
.x6-widget-minimap .script-node-header
{
	background: var(--tcolor-darker-green) !important;
}

.script-node
{
	background: #1a1a1a;
	border: 1px solid var(--tcolor-darker-green) !important;
	border-radius: 8px;
	box-shadow: -2px 3px 2px 0px rgb(64 93 55 / 20%);
	position: relative;
	transition: all 0.2s ease;
	width: 100%;
	height: auto;
	color: #fff;
	cursor: pointer;
}

.node-moving .script-node {
	cursor: move;
}

.x6-widget-selection-inner
{
	border: 1px solid var(--tcolor-lighter-green) !important;
	box-shadow: none !important;
	border-radius: 4px;
	padding: 30px !important;
	margin-left: -27px !important;
	margin-top: -27px !important;
}

.x6-node-selected .script-node, .x6-node-selected .script-node:hover
{
	border: 1px solid var(--tcolor-lighter-green) !important;
	box-shadow: -2px 3px 2px 0px rgb(236 254 143 / 80%);
}

.script-node:hover
{
	box-shadow: -1px 1px 2px 0px rgb(236 254 143 / 70%);
}

.script-node.invalid-multilang
{
	border: 1px solid var(--bs-danger) !important;
	box-shadow: -1px 1px 2px 0px var(--bs-danger) !important;
}

.script-node-header
{
	background: #1a1a1a;
	padding: 8px;
	border-bottom: 1px solid var(--tcolor-darker-green) !important;
	display: flex;
	justify-content: flex-end;
}

.script-node-header .node-title {
	margin-bottom: 6px;
}


.script-node-header .node-id {
	opacity: 0.5;
    font-size: 0.8rem;
}

.script-node-delete-btn {
	background: none;
	border: none;
	color: #dc3545;
	cursor: pointer;
	padding: 4px;
	border-radius: 4px;
	transition: all 0.2s ease;
}

.script-node-delete-btn:hover {
	background: #fee2e2;
}

.script-node-content {
	padding: 12px;
}

.script-node-input-group textarea {
	resize: none; /** keep for now, requires complex js for updating port position */
	min-height: 60px;
}

/* User Message Node Specific */
.script-user-query-node .script-node-header {
	padding: 8px 12px;
	border-radius: 6px 6px 0 0;
	display: flex;
	justify-content: space-between;
	align-items: center;
}

.script-user-query-node .script-node-content {
	padding: 12px;
}

.script-user-query-node .node-actions {
	display: flex;
	gap: 5px;
}

.script-user-query-node.invalid-multilang [data-input="user-query"]
{
	border: 1px solid var(--bs-danger) !important;
	box-shadow: -1px 1px 2px 0px var(--bs-danger) !important;
}

/* AI Response Node Specific */
.script-ai-response-node {
	border: 1px solid #e9ecef;
	border-radius: 6px;
	box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
}

.script-ai-response-node .script-node-header {
	padding: 8px 12px;
	border-radius: 6px 6px 0 0;
	display: flex;
	justify-content: space-between;
	align-items: center;
}

.script-ai-response-node .script-node-content {
	padding: 12px;
}

.script-ai-response-node .node-actions {
	display: flex;
	gap: 5px;
}

.script-ai-response-node.invalid-multilang [data-input="ai-response"]
{
	border: 1px solid var(--bs-danger) !important;
	box-shadow: -1px 1px 2px 0px var(--bs-danger) !important;
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    waiting for maintainerTriage or intervention needed from a maintainer

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions