1 import {PluginBase} from "../plugin-base";
2 import {Workflow} from "../..";
3 import {GraphNode} from "../../graph/graph-node";
4 import {Geometry} from "../../utils/geometry";
5 import {Edge} from "../../graph/edge";
6 import {EdgePanner} from "../../behaviors/edge-panning";
8 export class SVGPortDragPlugin extends PluginBase {
10 /** Stored on drag start to detect collision with viewport edges */
11 private boundingClientRect: ClientRect | undefined;
13 private portOrigins: Map<SVGGElement, SVGMatrix> | undefined;
15 /** Group of edges (compound element) leading from origin port to ghost node */
16 private edgeGroup: SVGGElement | undefined;
18 /** Coordinates of the node from which dragged port originates, stored so we can measure the distance from it */
19 private nodeCoords: { x: number; y: number } | undefined;
21 /** Reference to a node that marks a new input/output creation */
22 private ghostNode: SVGGElement | undefined;
24 /** How far away from the port you need to drag in order to create a new input/output instead of snapping */
25 private snapRadius = 120;
27 /** Tells if the port is on the left or on the right side of a node */
28 private portType: "input" | "output";
30 /** Stores a port to which a connection would snap if user stops the drag */
31 private snapPort: SVGGElement | undefined;
33 /** Map of CSS classes attached by this plugin */
36 /** Added to svgRoot as a sign that this plugin is active */
37 plugin: "__plugin-port-drag",
39 /** Suggests that an element that contains it will be the one to snap to */
40 snap: "__port-drag-snap",
42 /** Added to svgRoot while dragging is in progress */
43 dragging: "__port-drag-dragging",
45 /** Will be added to suggested ports and their parent nodes */
46 suggestion: "__port-drag-suggestion",
49 /** Port from which we initiated the drag */
50 private originPort: SVGGElement | undefined;
51 private detachDragListenerFn: Function | undefined = undefined;
53 private wheelPrevent = (ev: any) => ev.stopPropagation();
54 private panner: EdgePanner;
58 private portOnCanvas: { x: number; y: number };
59 private lastMouseMove: { x: number; y: number };
61 registerWorkflow(workflow: Workflow): void {
62 super.registerWorkflow(workflow);
63 this.panner = new EdgePanner(this.workflow);
65 this.workflow.svgRoot.classList.add(this.css.plugin);
69 if(this.workflow.editingEnabled){
70 this.attachPortDrag();
75 onEditableStateChange(enabled: boolean): void {
78 this.attachPortDrag();
80 this.detachPortDrag();
86 this.detachPortDrag();
90 if (typeof this.detachDragListenerFn === "function") {
91 this.detachDragListenerFn();
94 this.detachDragListenerFn = undefined;
99 this.detachPortDrag();
101 this.detachDragListenerFn = this.workflow.domEvents.drag(
103 this.onMove.bind(this),
104 this.onMoveStart.bind(this),
105 this.onMoveEnd.bind(this)
110 onMove(dx: number, dy: number, ev: MouseEvent, portElement: SVGGElement): void {
113 document.addEventListener("mousewheel", this.wheelPrevent, true);
114 const mouseOnSVG = this.workflow.transformScreenCTMtoCanvas(ev.clientX, ev.clientY);
115 const scale = this.workflow.scale;
117 const sdx = (dx - this.lastMouseMove.x) / scale;
118 const sdy = (dy - this.lastMouseMove.y) / scale;
120 /** We might have hit the boundary and need to start panning */
121 this.panner.triggerCollisionDetection(ev.clientX, ev.clientY, (sdx, sdy) => {
124 this.translateGhostNode(this.ghostX, this.ghostY);
125 this.updateEdge(this.portOnCanvas.x, this.portOnCanvas.y, this.ghostX, this.ghostY);
128 const nodeToMouseDistance = Geometry.distance(
129 this.nodeCoords!.x, this.nodeCoords!.y,
130 mouseOnSVG.x, mouseOnSVG.y
133 const closestPort = this.findClosestPort(mouseOnSVG.x, mouseOnSVG.y);
134 this.updateSnapPort(closestPort.portEl!, closestPort.distance);
139 this.translateGhostNode(this.ghostX, this.ghostY);
140 this.updateGhostNodeVisibility(nodeToMouseDistance, closestPort.distance);
141 this.updateEdge(this.portOnCanvas.x, this.portOnCanvas.y, this.ghostX, this.ghostY);
143 this.lastMouseMove = {x: dx, y: dy};
147 * @FIXME: Add panning
148 * @param {MouseEvent} ev
149 * @param {SVGGElement} portEl
151 onMoveStart(ev: MouseEvent, portEl: SVGGElement): void {
153 this.lastMouseMove = {x: 0, y: 0};
155 this.originPort = portEl;
156 const portCTM = portEl.getScreenCTM()!;
157 this.portOnCanvas = this.workflow.transformScreenCTMtoCanvas(portCTM.e, portCTM.f);
158 this.ghostX = this.portOnCanvas.x;
159 this.ghostY = this.portOnCanvas.y;
161 // Needed for collision detection
162 this.boundingClientRect = this.workflow.svgRoot.getBoundingClientRect();
164 const nodeMatrix = this.workflow.findParent(portEl)!.transform.baseVal.getItem(0).matrix;
170 const workflowGroup = this.workflow.workflow;
172 this.portType = portEl.classList.contains("input-port") ? "input" : "output";
174 this.ghostNode = this.createGhostNode(this.portType);
176 workflowGroup.appendChild(this.ghostNode);
178 /** @FIXME: this should come from workflow */
179 this.edgeGroup = Edge.spawn();
180 this.edgeGroup.classList.add(this.css.dragging);
182 workflowGroup.appendChild(this.edgeGroup);
184 this.workflow.svgRoot.classList.add(this.css.dragging);
187 this.portOrigins = this.getPortCandidateTransformations(portEl);
189 this.highlightSuggestedPorts(portEl.getAttribute("data-connection-id")!);
194 onMoveEnd(ev: MouseEvent): void {
196 document.removeEventListener("mousewheel", this.wheelPrevent, true);
200 const ghostType = this.ghostNode!.getAttribute("data-type");
201 const ghostIsVisible = !this.ghostNode!.classList.contains("hidden");
203 const shouldSnap = this.snapPort !== undefined;
204 const shouldCreateInput = ghostIsVisible && ghostType === "input";
205 const shouldCreateOutput = ghostIsVisible && ghostType === "output";
206 const portID = this.originPort!.getAttribute("data-connection-id")!;
209 this.createEdgeBetweenPorts(this.originPort!, this.snapPort!);
210 } else if (shouldCreateInput || shouldCreateOutput) {
212 const svgCoordsUnderMouse = this.workflow.transformScreenCTMtoCanvas(ev.clientX, ev.clientY);
213 const customProps = {
214 "sbg:x": svgCoordsUnderMouse.x,
215 "sbg:y": svgCoordsUnderMouse.y
218 if (shouldCreateInput) {
219 this.workflow.model.createInputFromPort(portID, {customProps});
221 this.workflow.model.createOutputFromPort(portID, {customProps});
229 private updateSnapPort(closestPort: SVGGElement, closestPortDistance: number) {
231 const closestPortChanged = closestPort !== this.snapPort;
232 const closestPortIsOutOfRange = closestPortDistance > this.snapRadius;
234 // We might need to remove old class for snapping if we are closer to some other port now
235 if (this.snapPort && (closestPortChanged || closestPortIsOutOfRange)) {
236 const node = this.workflow.findParent(this.snapPort)!;
237 this.snapPort.classList.remove(this.css.snap);
238 node.classList.remove(this.css.snap);
239 delete this.snapPort;
242 // If closest port is further away than our snapRadius, no highlighting should be done
243 if (closestPortDistance > this.snapRadius) {
247 const originID = this.originPort!.getAttribute("data-connection-id")!;
248 const targetID = closestPort.getAttribute("data-connection-id")!;
250 if (this.findEdge(originID, targetID)) {
251 delete this.snapPort;
255 this.snapPort = closestPort;
257 const node = this.workflow.findParent(closestPort)!;
258 const oppositePortType = this.portType === "input" ? "output" : "input";
260 closestPort.classList.add(this.css.snap);
261 node.classList.add(this.css.snap);
262 node.classList.add(`${this.css.snap}-${oppositePortType}`);
265 private updateEdge(fromX: number, fromY: number, toX: number, toY: number): void {
266 const subEdges = this.edgeGroup!.children as HTMLCollectionOf<SVGPathElement>;
268 for (let subEdge of subEdges as any) {
270 const path = Workflow.makeConnectionPath(
275 this.portType === "input" ? "left" : "right"
278 subEdge.setAttribute("d", path);
282 private updateGhostNodeVisibility(distanceToMouse: number, distanceToClosestPort: any) {
284 const isHidden = this.ghostNode!.classList.contains("hidden");
285 const shouldBeVisible = distanceToMouse > this.snapRadius && distanceToClosestPort > this.snapRadius;
287 if (shouldBeVisible && isHidden) {
288 this.ghostNode!.classList.remove("hidden");
289 } else if (!shouldBeVisible && !isHidden) {
290 this.ghostNode!.classList.add("hidden");
294 private translateGhostNode(x: number, y: number) {
295 this.ghostNode!.transform.baseVal.getItem(0).setTranslate(x, y);
298 private getPortCandidateTransformations(portEl: SVGGElement): Map<SVGGElement, SVGMatrix> {
299 const nodeEl = this.workflow.findParent(portEl)!;
300 const nodeConnectionID = nodeEl.getAttribute("data-connection-id");
302 const otherPortType = this.portType === "input" ? "output" : "input";
303 const portQuery = `.node:not([data-connection-id="${nodeConnectionID}"]) .port.${otherPortType}-port`;
305 const candidates: any = this.workflow.workflow.querySelectorAll(portQuery) as NodeListOf<SVGGElement>;
306 const matrices = new Map<SVGGElement, SVGMatrix>();
308 for (let port of candidates) {
309 matrices.set(port, Geometry.getTransformToElement(port, this.workflow.workflow));
316 * Highlights ports that are model says are suggested.
317 * Also marks their parent nodes as highlighted.
319 * @param {string} targetConnectionID ConnectionID of the origin port
321 private highlightSuggestedPorts(targetConnectionID: string): void {
323 // Find all ports that we can validly connect to
324 // Note that we can connect to any port, but some of them are suggested based on hypothetical validity.
325 const portModels = this.workflow.model.gatherValidConnectionPoints(targetConnectionID);
327 for (let i = 0; i < portModels.length; i++) {
329 const portModel = portModels[i];
331 if (!portModel.isVisible) continue;
333 // Find port element by this connectionID and it's parent node element
334 const portQuery = `.port[data-connection-id="${portModel.connectionId}"]`;
335 const portElement = this.workflow.workflow.querySelector(portQuery)!;
336 const parentNode = this.workflow.findParent(portElement)!;
338 // Add highlighting classes to port and it's parent node
339 parentNode.classList.add(this.css.suggestion);
340 portElement.classList.add(this.css.suggestion);
345 * @FIXME: GraphNode.radius should somehow come through Workflow,
347 private createGhostNode(type: "input" | "output"): SVGGElement {
348 const namespace = "http://www.w3.org/2000/svg";
349 const node = document.createElementNS(namespace, "g");
351 node.setAttribute("transform", "matrix(1,0,0,1,0,0)");
352 node.setAttribute("data-type", type);
353 node.classList.add("ghost");
354 node.classList.add("node");
355 node.innerHTML = `<circle class="ghost-circle" cx="0" cy="0" r="${GraphNode.radius / 1.5}"></circle>`;
361 * Finds a port closest to given SVG coordinates.
363 private findClosestPort(x: number, y: number): { portEl: SVGGElement | undefined, distance: number } {
364 let closestPort: any = undefined;
365 let closestDistance: any = Infinity;
367 this.portOrigins!.forEach((matrix, port) => {
369 const distance = Geometry.distance(x, y, matrix.e, matrix.f);
371 if (distance < closestDistance) {
373 closestDistance = distance;
380 distance: closestDistance
385 * Removes all dom elements and objects cached in-memory during dragging that are no longer needed.
387 private cleanMemory() {
388 this.edgeGroup!.remove();
389 this.ghostNode!.remove();
391 this.snapPort = undefined;
392 this.edgeGroup = undefined;
393 this.nodeCoords = undefined;
394 this.originPort = undefined;
395 this.portOrigins = undefined;
396 this.boundingClientRect = undefined;
401 * Removes all css classes attached by this plugin
403 private cleanStyles(): void {
404 this.workflow.svgRoot.classList.remove(this.css.dragging);
406 for (let cls in this.css) {
407 const query: any = this.workflow.svgRoot.querySelectorAll("." + this.css[cls]);
409 for (let el of query) {
410 el.classList.remove(this.css[cls]);
417 * Creates an edge (connection) between two elements determined by their connection IDs
418 * This edge is created on the model, and not rendered directly on graph, as main workflow
419 * is supposed to catch the creation event and draw it.
421 private createEdgeBetweenPorts(source: SVGGElement, destination: SVGGElement): void {
423 // Find the connection ids of origin port and the highlighted port
424 let sourceID = source.getAttribute("data-connection-id")!;
425 let destinationID = destination.getAttribute("data-connection-id")!;
427 // Swap their places in case you dragged out from input to output, since they have to be ordered output->input
428 if (sourceID.startsWith("in")) {
429 const tmp = sourceID;
430 sourceID = destinationID;
434 this.workflow.model.connect(sourceID, destinationID);
437 private findEdge(sourceID: string, destinationID: string): SVGGElement | undefined {
438 const ltrQuery = `[data-source-connection="${sourceID}"][data-destination-connection="${destinationID}"]`;
439 const rtlQuery = `[data-source-connection="${destinationID}"][data-destination-connection="${sourceID}"]`;
440 return this.workflow.workflow.querySelector(`${ltrQuery},${rtlQuery}`) as SVGGElement;