10f30e075225da803c8da91e875f384b96f7febb
[arvados-workbench2.git] / src / lib / cwl-svg / plugins / port-drag / port-drag.ts
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";
7
8 export class SVGPortDragPlugin extends PluginBase {
9
10     /** Stored on drag start to detect collision with viewport edges */
11     private boundingClientRect: ClientRect | undefined;
12
13     private portOrigins: Map<SVGGElement, SVGMatrix> | undefined;
14
15     /** Group of edges (compound element) leading from origin port to ghost node */
16     private edgeGroup: SVGGElement | undefined;
17
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;
20
21     /** Reference to a node that marks a new input/output creation */
22     private ghostNode: SVGGElement | undefined;
23
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;
26
27     /** Tells if the port is on the left or on the right side of a node */
28     private portType: "input" | "output";
29
30     /** Stores a port to which a connection would snap if user stops the drag */
31     private snapPort: SVGGElement | undefined;
32
33     /** Map of CSS classes attached by this plugin */
34     private css = {
35
36         /** Added to svgRoot as a sign that this plugin is active */
37         plugin: "__plugin-port-drag",
38
39         /** Suggests that an element that contains it will be the one to snap to */
40         snap: "__port-drag-snap",
41
42         /** Added to svgRoot while dragging is in progress */
43         dragging: "__port-drag-dragging",
44
45         /** Will be added to suggested ports and their parent nodes */
46         suggestion: "__port-drag-suggestion",
47     };
48
49     /** Port from which we initiated the drag */
50     private originPort: SVGGElement | undefined;
51     private detachDragListenerFn: Function | undefined = undefined;
52
53     private wheelPrevent = (ev: any) => ev.stopPropagation();
54     private panner: EdgePanner;
55
56     private ghostX = 0;
57     private ghostY = 0;
58     private portOnCanvas: { x: number; y: number };
59     private lastMouseMove: { x: number; y: number };
60
61     registerWorkflow(workflow: Workflow): void {
62         super.registerWorkflow(workflow);
63         this.panner = new EdgePanner(this.workflow);
64
65         this.workflow.svgRoot.classList.add(this.css.plugin);
66     }
67
68     afterRender(): void {
69         if(this.workflow.editingEnabled){
70             this.attachPortDrag();
71         }
72
73     }
74
75     onEditableStateChange(enabled: boolean): void {
76
77         if (enabled) {
78             this.attachPortDrag();
79         } else {
80             this.detachPortDrag();
81         }
82     }
83
84
85     destroy(): void {
86         this.detachPortDrag();
87     }
88
89     detachPortDrag() {
90         if (typeof this.detachDragListenerFn === "function") {
91             this.detachDragListenerFn();
92         }
93
94         this.detachDragListenerFn = undefined;
95     }
96
97     attachPortDrag() {
98
99         this.detachPortDrag();
100
101         this.detachDragListenerFn = this.workflow.domEvents.drag(
102             ".port",
103             this.onMove.bind(this),
104             this.onMoveStart.bind(this),
105             this.onMoveEnd.bind(this)
106         );
107
108     }
109
110     onMove(dx: number, dy: number, ev: MouseEvent, portElement: SVGGElement): void {
111
112
113         document.addEventListener("mousewheel", this.wheelPrevent, true);
114         const mouseOnSVG = this.workflow.transformScreenCTMtoCanvas(ev.clientX, ev.clientY);
115         const scale      = this.workflow.scale;
116
117         const sdx = (dx - this.lastMouseMove.x) / scale;
118         const sdy = (dy - this.lastMouseMove.y) / scale;
119
120         /** We might have hit the boundary and need to start panning */
121         this.panner.triggerCollisionDetection(ev.clientX, ev.clientY, (sdx, sdy) => {
122             this.ghostX += sdx;
123             this.ghostY += sdy;
124             this.translateGhostNode(this.ghostX, this.ghostY);
125             this.updateEdge(this.portOnCanvas.x, this.portOnCanvas.y, this.ghostX, this.ghostY);
126         });
127
128         const nodeToMouseDistance = Geometry.distance(
129             this.nodeCoords!.x, this.nodeCoords!.y,
130             mouseOnSVG.x, mouseOnSVG.y
131         );
132
133         const closestPort = this.findClosestPort(mouseOnSVG.x, mouseOnSVG.y);
134         this.updateSnapPort(closestPort.portEl!, closestPort.distance);
135
136         this.ghostX += sdx;
137         this.ghostY += sdy;
138
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);
142
143         this.lastMouseMove = {x: dx, y: dy};
144     }
145
146     /**
147      * @FIXME: Add panning
148      * @param {MouseEvent} ev
149      * @param {SVGGElement} portEl
150      */
151     onMoveStart(ev: MouseEvent, portEl: SVGGElement): void {
152
153         this.lastMouseMove = {x: 0, y: 0};
154
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;
160
161         // Needed for collision detection
162         this.boundingClientRect = this.workflow.svgRoot.getBoundingClientRect();
163
164         const nodeMatrix = this.workflow.findParent(portEl)!.transform.baseVal.getItem(0).matrix;
165         this.nodeCoords  = {
166             x: nodeMatrix.e,
167             y: nodeMatrix.f
168         };
169
170         const workflowGroup = this.workflow.workflow;
171
172         this.portType = portEl.classList.contains("input-port") ? "input" : "output";
173
174         this.ghostNode = this.createGhostNode(this.portType);
175
176         workflowGroup.appendChild(this.ghostNode);
177
178         /** @FIXME: this should come from workflow */
179         this.edgeGroup = Edge.spawn();
180         this.edgeGroup.classList.add(this.css.dragging);
181
182         workflowGroup.appendChild(this.edgeGroup);
183
184         this.workflow.svgRoot.classList.add(this.css.dragging);
185
186
187         this.portOrigins = this.getPortCandidateTransformations(portEl);
188
189         this.highlightSuggestedPorts(portEl.getAttribute("data-connection-id")!);
190
191
192     }
193
194     onMoveEnd(ev: MouseEvent): void {
195
196         document.removeEventListener("mousewheel", this.wheelPrevent, true);
197
198         this.panner.stop();
199
200         const ghostType      = this.ghostNode!.getAttribute("data-type");
201         const ghostIsVisible = !this.ghostNode!.classList.contains("hidden");
202
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")!;
207
208         if (shouldSnap) {
209             this.createEdgeBetweenPorts(this.originPort!, this.snapPort!);
210         } else if (shouldCreateInput || shouldCreateOutput) {
211
212             const svgCoordsUnderMouse = this.workflow.transformScreenCTMtoCanvas(ev.clientX, ev.clientY);
213             const customProps         = {
214                 "sbg:x": svgCoordsUnderMouse.x,
215                 "sbg:y": svgCoordsUnderMouse.y
216             };
217
218             if (shouldCreateInput) {
219                 this.workflow.model.createInputFromPort(portID, {customProps});
220             } else {
221                 this.workflow.model.createOutputFromPort(portID, {customProps});
222             }
223         }
224
225         this.cleanMemory();
226         this.cleanStyles();
227     }
228
229     private updateSnapPort(closestPort: SVGGElement, closestPortDistance: number) {
230
231         const closestPortChanged      = closestPort !== this.snapPort;
232         const closestPortIsOutOfRange = closestPortDistance > this.snapRadius;
233
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;
240         }
241
242         // If closest port is further away than our snapRadius, no highlighting should be done
243         if (closestPortDistance > this.snapRadius) {
244             return;
245         }
246
247         const originID = this.originPort!.getAttribute("data-connection-id")!;
248         const targetID = closestPort.getAttribute("data-connection-id")!;
249
250         if (this.findEdge(originID, targetID)) {
251             delete this.snapPort;
252             return;
253         }
254
255         this.snapPort = closestPort;
256
257         const node             = this.workflow.findParent(closestPort)!;
258         const oppositePortType = this.portType === "input" ? "output" : "input";
259
260         closestPort.classList.add(this.css.snap);
261         node.classList.add(this.css.snap);
262         node.classList.add(`${this.css.snap}-${oppositePortType}`);
263     }
264
265     private updateEdge(fromX: number, fromY: number, toX: number, toY: number): void {
266         const subEdges = this.edgeGroup!.children as HTMLCollectionOf<SVGPathElement>;
267
268         for (let subEdge of <any>subEdges) {
269
270             const path = Workflow.makeConnectionPath(
271                 fromX,
272                 fromY,
273                 toX,
274                 toY,
275                 this.portType === "input" ? "left" : "right"
276             );
277
278             subEdge.setAttribute("d", path);
279         }
280     }
281
282     private updateGhostNodeVisibility(distanceToMouse: number, distanceToClosestPort: any) {
283
284         const isHidden        = this.ghostNode!.classList.contains("hidden");
285         const shouldBeVisible = distanceToMouse > this.snapRadius && distanceToClosestPort > this.snapRadius;
286
287         if (shouldBeVisible && isHidden) {
288             this.ghostNode!.classList.remove("hidden");
289         } else if (!shouldBeVisible && !isHidden) {
290             this.ghostNode!.classList.add("hidden");
291         }
292     }
293
294     private translateGhostNode(x: number, y: number) {
295         this.ghostNode!.transform.baseVal.getItem(0).setTranslate(x, y);
296     }
297
298     private getPortCandidateTransformations(portEl: SVGGElement): Map<SVGGElement, SVGMatrix> {
299         const nodeEl           = this.workflow.findParent(portEl)!;
300         const nodeConnectionID = nodeEl.getAttribute("data-connection-id");
301
302         const otherPortType = this.portType === "input" ? "output" : "input";
303         const portQuery     = `.node:not([data-connection-id="${nodeConnectionID}"]) .port.${otherPortType}-port`;
304
305         const candidates: any = this.workflow.workflow.querySelectorAll(portQuery) as NodeListOf<SVGGElement>;
306         const matrices   = new Map<SVGGElement, SVGMatrix>();
307
308         for (let port of candidates) {
309             matrices.set(port, Geometry.getTransformToElement(port, this.workflow.workflow));
310         }
311
312         return matrices;
313     }
314
315     /**
316      * Highlights ports that are model says are suggested.
317      * Also marks their parent nodes as highlighted.
318      *
319      * @param {string} targetConnectionID ConnectionID of the origin port
320      */
321     private highlightSuggestedPorts(targetConnectionID: string): void {
322
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);
326
327         for (let i = 0; i < portModels.length; i++) {
328
329             const portModel = portModels[i];
330
331             if (!portModel.isVisible) continue;
332
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)!;
337
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);
341         }
342     }
343
344     /**
345      * @FIXME: GraphNode.radius should somehow come through Workflow,
346      */
347     private createGhostNode(type: "input" | "output"): SVGGElement {
348         const namespace = "http://www.w3.org/2000/svg";
349         const node      = document.createElementNS(namespace, "g");
350
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>`;
356
357         return node;
358     }
359
360     /**
361      * Finds a port closest to given SVG coordinates.
362      */
363     private findClosestPort(x: number, y: number): { portEl: SVGGElement | undefined, distance: number } {
364         let closestPort: any     = undefined;
365         let closestDistance: any = Infinity;
366
367         this.portOrigins!.forEach((matrix, port) => {
368
369             const distance = Geometry.distance(x, y, matrix.e, matrix.f);
370
371             if (distance < closestDistance) {
372                 closestPort     = port;
373                 closestDistance = distance;
374             }
375         });
376
377
378         return {
379             portEl: closestPort,
380             distance: closestDistance
381         };
382     }
383
384     /**
385      * Removes all dom elements and objects cached in-memory during dragging that are no longer needed.
386      */
387     private cleanMemory() {
388         this.edgeGroup!.remove();
389         this.ghostNode!.remove();
390
391         this.snapPort           = undefined;
392         this.edgeGroup          = undefined;
393         this.nodeCoords         = undefined;
394         this.originPort         = undefined;
395         this.portOrigins        = undefined;
396         this.boundingClientRect = undefined;
397
398     }
399
400     /**
401      * Removes all css classes attached by this plugin
402      */
403     private cleanStyles(): void {
404         this.workflow.svgRoot.classList.remove(this.css.dragging);
405
406         for (let cls in this.css) {
407             const query: any = this.workflow.svgRoot.querySelectorAll("." + this.css[cls]);
408
409             for (let el of query) {
410                 el.classList.remove(this.css[cls]);
411             }
412         }
413     }
414
415
416     /**
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.
420      */
421     private createEdgeBetweenPorts(source: SVGGElement, destination: SVGGElement): void {
422
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")!;
426
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;
431             destinationID = tmp;
432         }
433
434         this.workflow.model.connect(sourceID, destinationID);
435     }
436
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;
441     }
442 }