21700: Install Bundler system-wide in Rails postinst
[arvados.git] / services / workbench2 / src / lib / cwl-svg / plugins / node-move / node-move.ts
1 import {Workflow}   from "../..";
2 import {PluginBase} from "../plugin-base";
3 import {EdgePanner} from "../../behaviors/edge-panning";
4
5 export interface ConstructorParams {
6     movementSpeed?: number,
7     scrollMargin?: number
8 }
9
10 /**
11  * This plugin makes node dragging and movement possible.
12  *
13  * @FIXME: attach events for before and after change
14  */
15 export class SVGNodeMovePlugin extends PluginBase {
16
17     /** Difference in movement on the X axis since drag start, adapted for scale and possibly panned distance */
18     private sdx: number;
19
20     /** Difference in movement on the Y axis since drag start, adapted for scale and possibly panned distance */
21     private sdy: number;
22
23     /** Stored onDragStart so we can put node to a fixed position determined by startX + ∆x */
24     private startX?: number;
25
26     /** Stored onDragStart so we can put node to a fixed position determined by startY + ∆y */
27     private startY?: number;
28
29     /** How far from the edge of the viewport does mouse need to be before panning is triggered */
30     private scrollMargin = 50;
31
32     /** How fast does workflow move while panning */
33     private movementSpeed = 10;
34
35     /** Holds an element that is currently being dragged. Stored onDragStart and translated afterwards. */
36     private movingNode?: SVGGElement;
37
38     /** Stored onDragStart to detect collision with viewport edges */
39     private boundingClientRect?: ClientRect;
40
41     /** Cache input edges and their parsed bezier curve parameters so we don't query for them on each mouse move */
42     private inputEdges?: Map<SVGPathElement, number[]>;
43
44     /** Cache output edges and their parsed bezier curve parameters so we don't query for them on each mouse move */
45     private outputEdges?: Map<SVGPathElement, number[]>;
46
47     /** Workflow panning at the time of onDragStart, used to adjust ∆x and ∆y while panning */
48     private startWorkflowTranslation?: { x: number, y: number };
49
50     private wheelPrevent = (ev: any) => ev.stopPropagation();
51
52     private boundMoveHandler      = this.onMove.bind(this);
53     private boundMoveStartHandler = this.onMoveStart.bind(this);
54     private boundMoveEndHandler   = this.onMoveEnd.bind(this);
55
56     private detachDragListenerFn: any = undefined;
57
58     private edgePanner: EdgePanner;
59
60     constructor(parameters: ConstructorParams = {}) {
61         super();
62         Object.assign(this, parameters);
63     }
64
65
66     onEditableStateChange(enabled: boolean): void {
67
68         if (enabled) {
69             this.attachDrag();
70         } else {
71             this.detachDrag();
72         }
73     }
74
75     afterRender() {
76
77         if (this.workflow.editingEnabled) {
78             this.attachDrag();
79         }
80
81     }
82
83     destroy(): void {
84         this.detachDrag();
85     }
86
87     registerWorkflow(workflow: Workflow): void {
88         super.registerWorkflow(workflow);
89
90         this.edgePanner = new EdgePanner(this.workflow, {
91             scrollMargin: this.scrollMargin,
92             movementSpeed: this.movementSpeed
93         });
94     }
95
96     private detachDrag() {
97         if (typeof this.detachDragListenerFn === "function") {
98             this.detachDragListenerFn();
99         }
100
101         this.detachDragListenerFn = undefined;
102     }
103
104     private attachDrag() {
105
106         this.detachDrag();
107
108         this.detachDragListenerFn = this.workflow.domEvents.drag(
109             ".node .core",
110             this.boundMoveHandler,
111             this.boundMoveStartHandler,
112             this.boundMoveEndHandler
113         );
114     }
115
116     private getWorkflowMatrix(): SVGMatrix {
117         return this.workflow.workflow.transform.baseVal.getItem(0).matrix;
118     }
119
120     private onMove(dx: number, dy: number, ev: MouseEvent): void {
121
122         /** We will use workflow scale to determine how our mouse movement translate to svg proportions */
123         const scale = this.workflow.scale;
124
125         /** Need to know how far did the workflow itself move since when we started dragging */
126         const matrixMovement = {
127             x: this.getWorkflowMatrix().e - this.startWorkflowTranslation!.x,
128             y: this.getWorkflowMatrix().f - this.startWorkflowTranslation!.y
129         };
130
131         /** We might have hit the boundary and need to start panning */
132         this.edgePanner.triggerCollisionDetection(ev.clientX, ev.clientY, (sdx, sdy) => {
133             this.sdx += sdx;
134             this.sdy += sdy;
135
136             this.translateNodeBy(this.movingNode!, sdx, sdy);
137             this.redrawEdges(this.sdx, this.sdy);
138         });
139
140         /**
141          * We need to store scaled ∆x and ∆y because this is not the only place from which node is being moved.
142          * If mouse is outside the viewport, and the workflow is panning, startScroll will continue moving
143          * this node, so it needs to know where to start from and update it, so this method can take
144          * over when mouse gets back to the viewport.
145          *
146          * If there was no handoff, node would jump back and forth to
147          * last positions for each movement initiator separately.
148          */
149         this.sdx = (dx - matrixMovement.x) / scale;
150         this.sdy = (dy - matrixMovement.y) / scale;
151
152         const moveX = this.sdx + this.startX!;
153         const moveY = this.sdy + this.startY!;
154
155         this.translateNodeTo(this.movingNode!, moveX, moveY);
156         this.redrawEdges(this.sdx, this.sdy);
157     }
158
159     /**
160      * Triggered from {@link attachDrag} when drag starts.
161      * This method initializes properties that are needed for calculations during movement.
162      */
163     private onMoveStart(event: MouseEvent, handle: SVGGElement): void {
164
165         /** We will query the SVG dom for edges that we need to move, so store svg element for easy access */
166         const svg = this.workflow.svgRoot;
167
168         document.addEventListener("mousewheel", this.wheelPrevent, true);
169
170         /** Our drag handle is not the whole node because that would include ports and labels, but a child of it*/
171         const node = handle.parentNode as SVGGElement;
172
173         /** Store initial transform values so we know how much we've moved relative from the starting position */
174         const nodeMatrix = node.transform.baseVal.getItem(0).matrix;
175         this.startX      = nodeMatrix.e;
176         this.startY      = nodeMatrix.f;
177
178         /** We have to query for edges that are attached to this node because we will move them as well */
179         const nodeID = node.getAttribute("data-id");
180
181         /**
182          * When user drags the node to the edge and waits while workflow pans to the side,
183          * mouse movement stops, but workflow movement starts.
184          * We then utilize this to get movement ∆ of the workflow, and use that for translation instead.
185          */
186         this.startWorkflowTranslation = {
187             x: this.getWorkflowMatrix().e,
188             y: this.getWorkflowMatrix().f
189         };
190
191         /** Used to determine whether dragged node is hitting the edge, so we can pan the Workflow*/
192         this.boundingClientRect = svg.getBoundingClientRect();
193
194         /** Node movement can be initiated from both mouse events and animationFrame, so make it accessible */
195         this.movingNode = handle.parentNode as SVGGElement;
196
197         /**
198          * While node is being moved, incoming and outgoing edges also need to be moved in order to stay attached.
199          * We don't want to query them all the time, so we cache them in maps that point from their dom elements
200          * to an array of numbers that represent their bezier curves, since we will update those curves.
201          */
202         this.inputEdges = new Map();
203         this.outputEdges = new Map();
204
205         const outputsSelector = `.edge[data-source-node='${nodeID}'] .sub-edge`;
206         const inputsSelector  = `.edge[data-destination-node='${nodeID}'] .sub-edge`;
207
208         const query: any = svg.querySelectorAll([inputsSelector, outputsSelector].join(", ")) as NodeListOf<SVGPathElement>;
209
210         for (let subEdge of query) {
211             const isInput = subEdge.parentElement.getAttribute("data-destination-node") === nodeID;
212             const path    = subEdge.getAttribute("d").split(" ").map(Number).filter((e: any) => !isNaN(e));
213             isInput ? this.inputEdges.set(subEdge, path) : this.outputEdges.set(subEdge, path);
214         }
215     }
216
217     private translateNodeBy(node: SVGGElement, x?: number, y?: number): void {
218         const matrix = node.transform.baseVal.getItem(0).matrix;
219         this.translateNodeTo(node, matrix.e + x!, matrix.f + y!);
220     }
221
222     private translateNodeTo(node: SVGGElement, x?: number, y?: number): void {
223         node.transform.baseVal.getItem(0).setTranslate(x!, y!);
224     }
225
226     /**
227      * Redraws stored input and output edges so as to transform them with respect to
228      * scaled transformation differences, sdx and sdy.
229      */
230     private redrawEdges(sdx: number, sdy: number): void {
231         this.inputEdges!.forEach((p, el) => {
232             const path = Workflow.makeConnectionPath(p[0], p[1], p[6] + sdx, p[7] + sdy);
233             el.setAttribute("d", path!);
234         });
235
236         this.outputEdges!.forEach((p, el) => {
237             const path = Workflow.makeConnectionPath(p[0] + sdx, p[1] + sdy, p[6], p[7]);
238             el.setAttribute("d", path!);
239         });
240     }
241
242     /**
243      * Triggered from {@link attachDrag} after move event ends
244      */
245     private onMoveEnd(): void {
246
247         this.edgePanner.stop();
248
249         const id        = this.movingNode!.getAttribute("data-connection-id")!;
250         const nodeModel = this.workflow.model.findById(id);
251
252         if (!nodeModel.customProps) {
253             nodeModel.customProps = {};
254         }
255
256         const matrix = this.movingNode!.transform.baseVal.getItem(0).matrix;
257
258         Object.assign(nodeModel.customProps, {
259             "sbg:x": matrix.e,
260             "sbg:y": matrix.f,
261         });
262
263         this.onAfterChange({type: "node-move"});
264
265         document.removeEventListener("mousewheel", this.wheelPrevent, true);
266
267         delete this.startX;
268         delete this.startY;
269         delete this.movingNode;
270         delete this.inputEdges;
271         delete this.outputEdges;
272         delete this.boundingClientRect;
273         delete this.startWorkflowTranslation;
274     }
275
276
277 }