Rift.IO OSM R1 Initial Submission
[osm/UI.git] / skyquake / plugins / composer / src / src / libraries / SelectionManager.js
1 /*global SVGSVGElement, SVGPathElement, SVGGElement*/
2 /**
3 * Created by onvelocity on 2/6/16.
4 */
5
6 'use strict';
7
8 import _ from 'lodash'
9 import d3 from 'd3'
10 import UID from './UniqueId'
11 import React from 'react'
12 import PathBuilder from './graph/PathBuilder'
13
14 const SELECTIONS = '::private::selections';
15
16 /**
17 * SelectionManager provides two features:
18 * 1) given DescriptorModel instances mark them as 'selected'
19 * 2) given DOM elements draw an outline around them
20 *
21 * Note that the consumer must call addSelection(descriptor) and outlineElement(dom) separately. This allows for complex
22 * selections to be separate from the outline indicator. It also allows for selection sets that do not have a visual
23 * outline associated with them.
24 */
25 class SelectionManager {
26
27 static disableOutlineChanges() {
28 SelectionManager._disableOutlineChanges = true;
29 }
30
31 static enableOutlineChanges() {
32 SelectionManager._disableOutlineChanges = false;
33 }
34
35 static select(obj) {
36 const isSelected = SelectionManager.isSelected(obj);
37 if (!isSelected) {
38 SelectionManager.clearSelectionAndRemoveOutline();
39 SelectionManager.addSelection(obj);
40 return true;
41 }
42 }
43
44 static isSelected(obj) {
45 const uid = UID.from(obj);
46 if (uid) {
47 return SelectionManager[SELECTIONS].has(uid);
48 }
49 }
50
51 static getSelections() {
52 return Array.from(SelectionManager[SELECTIONS]).filter(d => d);
53 }
54
55 static addSelection(obj) {
56 const uid = UID.from(obj);
57 if (uid) {
58 SelectionManager[SELECTIONS].add(uid);
59 }
60 }
61
62 static updateSelection(container, addOrRemove = true) {
63 if (addOrRemove) {
64 SelectionManager.addSelection(container);
65 } else {
66 SelectionManager.removeSelection(container);
67 }
68 }
69
70 static removeSelection(obj) {
71 const uid = UID.from(obj);
72 if (uid) {
73 SelectionManager[SELECTIONS].delete(uid);
74 }
75 }
76
77 static get onClearSelection() {
78 return this._onClearSelection;
79 }
80
81 static set onClearSelection(callback) {
82 if (!this._onClearSelection) {
83 this._onClearSelection = new Set();
84 }
85 if (typeof callback !== 'function') {
86 throw new TypeError('onClearSelection only takes functions');
87 }
88 this._onClearSelection.add(callback);
89 }
90
91 static clearSelectionAndRemoveOutline() {
92 SelectionManager.removeOutline();
93 const unselected = SelectionManager.getSelections();
94 if (unselected.length) {
95 SelectionManager[SELECTIONS].clear();
96 if (SelectionManager.onClearSelection) {
97 SelectionManager.onClearSelection.forEach(callback => callback(unselected));
98 }
99 }
100 return unselected;
101 }
102
103 static removeOutline() {
104 Array.from(document.querySelectorAll('[data-outline-indicator-outline]')).forEach(n => d3.select(n).remove());
105 }
106
107 static refreshOutline() {
108 clearTimeout(SelectionManager.timeoutId);
109 SelectionManager.moveOutline();
110 SelectionManager.timeoutId = setTimeout(() => {
111 // note the timeout is to wait for the react digest to complete
112 // in the case this method is called from an Alt action....
113 SelectionManager.moveOutline();
114 }, 100);
115 }
116
117 static moveOutline() {
118 SelectionManager.getSelections().forEach(uid => {
119 Array.from(document.body.querySelectorAll(`[data-uid="${uid}"]`)).forEach(SelectionManager.outline.bind(this));
120 });
121 }
122
123 static outline(dom) {
124
125 const elements = Array.isArray(dom) ? dom : [dom];
126
127 elements.map(SelectionManager.getClosestElementWithUID).filter(d => d).forEach(element => {
128
129 if (element instanceof SVGElement) {
130 SelectionManager.outlineSvg(element);
131 } else {
132 SelectionManager.outlineDom(element);
133 }
134
135 });
136
137 }
138
139 static outlineSvg(element) {
140
141 if (SelectionManager._disableOutlineChanges) {
142 return
143 }
144
145 const svg = element.viewportElement;
146
147 if (!svg) {
148 return
149 }
150
151 const path = new PathBuilder();
152
153 const dim = element.getBBox();
154 const adjustPos = SelectionManager.sumTranslates(element);
155
156 let w = dim.width + 11;
157 let h = dim.height + 11;
158 let top = adjustPos[1] - 8 + dim.y;
159 let left = adjustPos[0] - 8 + dim.x;
160
161 let square = path.M(5, 5).L(w, 5).L(w, h).L(5, h).L(5, 5).Z.toString();
162
163 let border = element;
164
165 // strategy copy the element to use as a foreground mask
166 // - this allows the item to appear above other items and
167 // - it makes the outline apear outside the element
168
169 const mask = d3.select(element.cloneNode(true));
170
171 if (border instanceof SVGPathElement) {
172 const t = d3.transform(d3.select(element).attr('transform')).translate;
173 square = d3.select(element).attr('d');
174 top = t[1];
175 left = t[0];
176 } else if (border instanceof SVGGElement) {
177 const t = d3.transform(d3.select(element).attr('transform')).translate;
178 border = d3.select(element).select('path.border');
179 square = border.attr('d');
180 top = t[1];
181 left = t[0];
182 }
183
184
185 const data = {
186 top: top,
187 left: left,
188 path: square
189 };
190
191 const indicator = svg.querySelector(['[data-outline-indicator]']) || svg;
192
193 const outline = d3.select(indicator).selectAll('[data-outline-indicator-outline]').data([data]);
194
195 outline.exit().remove();
196
197 // move to top of z-order
198 element.parentNode.appendChild(element);
199
200 outline.enter().append('g').attr({
201 'data-outline-indicator-outline': true,
202 'class': '-with-transitions'
203 }).style({
204 'pointer-events': 'none'
205 }).append('g');
206
207 outline.attr({
208 transform: d => `translate(${d.left}, ${d.top})`
209 });
210
211 outline.select('g').append('path').attr({
212 'data-outline-indicator-svg-outline-path': true,
213 'stroke': 'red',
214 'fill': 'transparent',
215 'fill-rule': 'evenodd',
216 'stroke-width': 5,
217 'stroke-linejoin': 'round',
218 'stroke-dasharray': '4',
219 'opacity': 1,
220 d: d => d.path
221 }).transition().style({'stroke-width': 15});
222
223 const g = outline.select('g').node();
224 const children = Array.from(mask.node().childNodes);
225 if (children.length) {
226
227 children.forEach(child => {
228 g.appendChild(child);
229 });
230
231 // ensure the outline moves with the item during drag operations
232 const observer = new MutationObserver(function(mutations) {
233 mutations.forEach(function(mutation) {
234 const transform = d3.select(mutation.target).style('opacity', 0).attr('transform');
235 outline.attr({
236 transform: transform
237 });
238 });
239 });
240
241 const config = {attributes: true, attributeOldValue: true, attributeFilter: ['transform']};
242
243 observer.observe(element, config);
244
245 } else {
246
247 if (mask.classed('connection')) {
248 element.parentNode.appendChild(outline.node());
249 element.parentNode.appendChild(element);
250 } else {
251 //mask.attr('transform', null);
252 element.parentNode.appendChild(outline.node());
253 element.parentNode.appendChild(element);
254 }
255
256 }
257
258 }
259
260 static outlineDom(element) {
261
262 if (SelectionManager._disableOutlineChanges) {
263 return
264 }
265
266 element = SelectionManager.getClosestElementWithUID(element);
267
268 const offsetParent = SelectionManager.offsetParent(element);
269
270 const dim = SelectionManager.offsetDimensions(element);
271 const w = dim.width + 11;
272 const h = dim.height + 11;
273
274
275 const path = new PathBuilder();
276 const square = path.M(5, 5).L(w, 5).L(w, h).L(5, h).L(5, 5).Z.toString();
277
278 const parentDim = SelectionManager.offsetDimensions(offsetParent);
279
280 const top = dim.top - parentDim.top;
281 const left = dim.left - parentDim.left;
282 const svg = d3.select(offsetParent).selectAll('[data-outline-indicator-outline]').data([{}]);
283 svg.enter().append('svg').attr({
284 'data-outline-indicator-outline': true,
285 width: parentDim.width + 20,
286 height: parentDim.height + 23,
287 style: `pointer-events: none; position: absolute; z-index: 999; top: ${top - 8}px; left: ${left - 8}px; background: transparent;`
288 }).append('g').append('path').attr({
289 'data-outline-indicator-dom-outline-path': true,
290 'stroke': 'red',
291 'fill': 'transparent',
292 'stroke-width': '1.5px',
293 'stroke-linejoin': 'round',
294 'stroke-dasharray': '4',
295 d: square
296 });
297
298 svg.select('svg').attr({
299 width: parentDim.width + 20,
300 height: parentDim.height + 23,
301 }).select('path').attr({
302 d: square
303 });
304
305 svg.exit().remove();
306
307 }
308
309 static getClosestElementWithUID(element) {
310 let target = element;
311 while (target) {
312 if (UID.from(target)) {
313 return target;
314 }
315 if (target === target.parentNode) {
316 return;
317 }
318 target = target.parentNode;
319 }
320 }
321
322 static offsetParent(target) {
323 while (target) {
324 if ((d3.select(target).attr('data-offset-parent')) || target.nodeName === 'BODY') {
325 return target;
326 }
327 target = target.parentNode;
328 }
329 return document.body;
330 }
331
332 /**
333 * Util for determining the widest child of an offsetParent that is scrolled.
334 *
335 * @param offsetParent
336 * @returns {{top: Number, right: Number, bottom: Number, left: Number, height: Number, width: Number}}
337 */
338 static offsetDimensions (offsetParent) {
339
340 const elementDim = offsetParent.getBoundingClientRect();
341 const dim = {
342 top: elementDim.top,
343 right: elementDim.right,
344 bottom: elementDim.bottom,
345 left: elementDim.left,
346 height: elementDim.height,
347 width: elementDim.width
348 };
349
350 const overrideWidth = Array.from(offsetParent.querySelectorAll('[data-offset-width]'));
351 dim.width = overrideWidth.reduce((width, child) => {
352 const dim = child.getBoundingClientRect();
353 return Math.max(width, dim.width);
354 }, elementDim.width);
355
356 return dim;
357
358 }
359
360 static sumTranslates(element) {
361 let parent = element;
362 const result = [0, 0];
363 while (parent) {
364 const t = d3.transform(d3.select(parent).attr('transform')).translate;
365 result[0] += t[0];
366 result[1] += t[1];
367 if (!parent.parentNode || /svg/i.test(parent.nodeName) || parent === parent.parentNode) {
368 return result;
369 }
370 parent = parent.parentNode;
371 }
372 return result;
373 }
374
375 }
376
377 // warn static variable to store all selections across instances
378 SelectionManager[SELECTIONS] = SelectionManager[SELECTIONS] || new Set();
379
380 export default SelectionManager;