Rift.IO OSM R1 Initial Submission
[osm/UI.git] / skyquake / plugins / composer / src / src / libraries / SelectionManager.js
diff --git a/skyquake/plugins/composer/src/src/libraries/SelectionManager.js b/skyquake/plugins/composer/src/src/libraries/SelectionManager.js
new file mode 100644 (file)
index 0000000..94ab813
--- /dev/null
@@ -0,0 +1,380 @@
+/*global SVGSVGElement, SVGPathElement, SVGGElement*/
+/**
+ * Created by onvelocity on 2/6/16.
+ */
+
+'use strict';
+
+import _ from 'lodash'
+import d3 from 'd3'
+import UID from './UniqueId'
+import React from 'react'
+import PathBuilder from './graph/PathBuilder'
+
+const SELECTIONS = '::private::selections';
+
+/**
+ * SelectionManager provides two features:
+ *     1) given DescriptorModel instances mark them as 'selected'
+ *     2) given DOM elements draw an outline around them
+ *
+ * Note that the consumer must call addSelection(descriptor) and outlineElement(dom) separately. This allows for complex
+ * selections to be separate from the outline indicator. It also allows for selection sets that do not have a visual
+ * outline associated with them.
+ */
+class SelectionManager {
+
+       static disableOutlineChanges() {
+               SelectionManager._disableOutlineChanges = true;
+       }
+
+       static enableOutlineChanges() {
+               SelectionManager._disableOutlineChanges = false;
+       }
+
+       static select(obj) {
+               const isSelected = SelectionManager.isSelected(obj);
+               if (!isSelected) {
+                       SelectionManager.clearSelectionAndRemoveOutline();
+                       SelectionManager.addSelection(obj);
+                       return true;
+               }
+       }
+
+       static isSelected(obj) {
+               const uid = UID.from(obj);
+               if (uid) {
+                       return SelectionManager[SELECTIONS].has(uid);
+               }
+       }
+
+       static getSelections() {
+               return Array.from(SelectionManager[SELECTIONS]).filter(d => d);
+       }
+
+       static addSelection(obj) {
+               const uid = UID.from(obj);
+               if (uid) {
+                       SelectionManager[SELECTIONS].add(uid);
+               }
+       }
+
+       static updateSelection(container, addOrRemove = true) {
+               if (addOrRemove) {
+                       SelectionManager.addSelection(container);
+               } else {
+                       SelectionManager.removeSelection(container);
+               }
+       }
+
+       static removeSelection(obj) {
+               const uid = UID.from(obj);
+               if (uid) {
+                       SelectionManager[SELECTIONS].delete(uid);
+               }
+       }
+
+       static get onClearSelection() {
+               return this._onClearSelection;
+       }
+
+       static set onClearSelection(callback) {
+               if (!this._onClearSelection) {
+                       this._onClearSelection = new Set();
+               }
+               if (typeof callback !== 'function') {
+                       throw new TypeError('onClearSelection only takes functions');
+               }
+               this._onClearSelection.add(callback);
+       }
+
+       static clearSelectionAndRemoveOutline() {
+               SelectionManager.removeOutline();
+               const unselected = SelectionManager.getSelections();
+               if (unselected.length) {
+                       SelectionManager[SELECTIONS].clear();
+                       if (SelectionManager.onClearSelection) {
+                               SelectionManager.onClearSelection.forEach(callback => callback(unselected));
+                       }
+               }
+               return unselected;
+       }
+
+       static removeOutline() {
+               Array.from(document.querySelectorAll('[data-outline-indicator-outline]')).forEach(n => d3.select(n).remove());
+       }
+
+       static refreshOutline() {
+               clearTimeout(SelectionManager.timeoutId);
+               SelectionManager.moveOutline();
+               SelectionManager.timeoutId = setTimeout(() => {
+                       // note the timeout is to wait for the react digest to complete
+                       // in the case this method is called from an Alt action....
+                       SelectionManager.moveOutline();
+               }, 100);
+       }
+
+       static moveOutline() {
+               SelectionManager.getSelections().forEach(uid => {
+                       Array.from(document.body.querySelectorAll(`[data-uid="${uid}"]`)).forEach(SelectionManager.outline.bind(this));
+               });
+       }
+
+       static outline(dom) {
+
+               const elements = Array.isArray(dom) ? dom : [dom];
+
+               elements.map(SelectionManager.getClosestElementWithUID).filter(d => d).forEach(element => {
+
+                       if (element instanceof SVGElement) {
+                               SelectionManager.outlineSvg(element);
+                       } else {
+                               SelectionManager.outlineDom(element);
+                       }
+
+               });
+
+       }
+
+       static outlineSvg(element) {
+
+               if (SelectionManager._disableOutlineChanges) {
+                       return
+               }
+
+               const svg = element.viewportElement;
+
+               if (!svg) {
+                       return
+               }
+
+               const path = new PathBuilder();
+
+               const dim = element.getBBox();
+               const adjustPos = SelectionManager.sumTranslates(element);
+
+               let w = dim.width + 11;
+               let h = dim.height + 11;
+               let top = adjustPos[1] - 8 + dim.y;
+               let left = adjustPos[0] - 8 + dim.x;
+
+               let square = path.M(5, 5).L(w, 5).L(w, h).L(5, h).L(5, 5).Z.toString();
+
+               let border = element;
+
+               // strategy copy the element to use as a foreground mask
+               // - this allows the item to appear above other items and
+               // - it makes the outline apear outside the element
+
+               const mask = d3.select(element.cloneNode(true));
+
+               if (border instanceof SVGPathElement) {
+                       const t = d3.transform(d3.select(element).attr('transform')).translate;
+                       square = d3.select(element).attr('d');
+                       top = t[1];
+                       left = t[0];
+               } else if (border instanceof SVGGElement) {
+                       const t = d3.transform(d3.select(element).attr('transform')).translate;
+                       border = d3.select(element).select('path.border');
+                       square = border.attr('d');
+                       top = t[1];
+                       left = t[0];
+               }
+
+
+               const data = {
+                       top: top,
+                       left: left,
+                       path: square
+               };
+
+               const indicator = svg.querySelector(['[data-outline-indicator]']) || svg;
+
+               const outline = d3.select(indicator).selectAll('[data-outline-indicator-outline]').data([data]);
+
+               outline.exit().remove();
+
+               // move to top of z-order
+               element.parentNode.appendChild(element);
+
+               outline.enter().append('g').attr({
+                       'data-outline-indicator-outline': true,
+                       'class': '-with-transitions'
+               }).style({
+                       'pointer-events': 'none'
+               }).append('g');
+
+               outline.attr({
+                       transform: d => `translate(${d.left}, ${d.top})`
+               });
+
+               outline.select('g').append('path').attr({
+                       'data-outline-indicator-svg-outline-path': true,
+                       'stroke': 'red',
+                       'fill': 'transparent',
+                       'fill-rule': 'evenodd',
+                       'stroke-width': 5,
+                       'stroke-linejoin': 'round',
+                       'stroke-dasharray': '4',
+                       'opacity': 1,
+                       d: d => d.path
+               }).transition().style({'stroke-width': 15});
+
+               const g = outline.select('g').node();
+               const children = Array.from(mask.node().childNodes);
+               if (children.length) {
+
+                       children.forEach(child => {
+                               g.appendChild(child);
+                       });
+
+                       // ensure the outline moves with the item during drag operations
+                       const observer = new MutationObserver(function(mutations) {
+                               mutations.forEach(function(mutation) {
+                                       const transform = d3.select(mutation.target).style('opacity', 0).attr('transform');
+                                       outline.attr({
+                                               transform: transform
+                                       });
+                               });
+                       });
+
+                       const config = {attributes: true, attributeOldValue: true, attributeFilter: ['transform']};
+
+                       observer.observe(element, config);
+
+               } else {
+
+                       if (mask.classed('connection')) {
+                               element.parentNode.appendChild(outline.node());
+                               element.parentNode.appendChild(element);
+                       } else {
+                               //mask.attr('transform', null);
+                               element.parentNode.appendChild(outline.node());
+                               element.parentNode.appendChild(element);
+                       }
+
+               }
+
+       }
+
+       static outlineDom(element) {
+
+               if (SelectionManager._disableOutlineChanges) {
+                       return
+               }
+
+               element = SelectionManager.getClosestElementWithUID(element);
+
+               const offsetParent = SelectionManager.offsetParent(element);
+
+               const dim = SelectionManager.offsetDimensions(element);
+               const w = dim.width + 11;
+               const h = dim.height + 11;
+
+
+               const path = new PathBuilder();
+               const square = path.M(5, 5).L(w, 5).L(w, h).L(5, h).L(5, 5).Z.toString();
+
+               const parentDim = SelectionManager.offsetDimensions(offsetParent);
+
+               const top = dim.top - parentDim.top;
+               const left = dim.left - parentDim.left;
+               const svg = d3.select(offsetParent).selectAll('[data-outline-indicator-outline]').data([{}]);
+               svg.enter().append('svg').attr({
+                       'data-outline-indicator-outline': true,
+                       width: parentDim.width + 20,
+                       height: parentDim.height + 23,
+                       style: `pointer-events: none; position: absolute; z-index: 999; top: ${top - 8}px; left: ${left - 8}px; background: transparent;`
+               }).append('g').append('path').attr({
+                       'data-outline-indicator-dom-outline-path': true,
+                       'stroke': 'red',
+                       'fill': 'transparent',
+                       'stroke-width': '1.5px',
+                       'stroke-linejoin': 'round',
+                       'stroke-dasharray': '4',
+                       d: square
+               });
+
+               svg.select('svg').attr({
+                       width: parentDim.width + 20,
+                       height: parentDim.height + 23,
+               }).select('path').attr({
+                       d: square
+               });
+
+               svg.exit().remove();
+
+       }
+
+       static getClosestElementWithUID(element) {
+               let target = element;
+               while (target) {
+                       if (UID.from(target)) {
+                               return target;
+                       }
+                       if (target === target.parentNode) {
+                               return;
+                       }
+                       target = target.parentNode;
+               }
+       }
+
+       static offsetParent(target) {
+               while (target) {
+                       if ((d3.select(target).attr('data-offset-parent')) || target.nodeName === 'BODY') {
+                               return target;
+                       }
+                       target = target.parentNode;
+               }
+               return document.body;
+       }
+
+       /**
+        * Util for determining the widest child of an offsetParent that is scrolled.
+        *
+        * @param offsetParent
+        * @returns {{top: Number, right: Number, bottom: Number, left: Number, height: Number, width: Number}}
+        */
+       static offsetDimensions (offsetParent) {
+
+               const elementDim = offsetParent.getBoundingClientRect();
+               const dim = {
+                       top: elementDim.top,
+                       right: elementDim.right,
+                       bottom: elementDim.bottom,
+                       left: elementDim.left,
+                       height: elementDim.height,
+                       width: elementDim.width
+               };
+
+               const overrideWidth = Array.from(offsetParent.querySelectorAll('[data-offset-width]'));
+               dim.width = overrideWidth.reduce((width, child) => {
+                       const dim = child.getBoundingClientRect();
+                       return Math.max(width, dim.width);
+               }, elementDim.width);
+
+               return dim;
+
+       }
+
+       static sumTranslates(element) {
+               let parent = element;
+               const result = [0, 0];
+               while (parent) {
+                       const t = d3.transform(d3.select(parent).attr('transform')).translate;
+                       result[0] += t[0];
+                       result[1] += t[1];
+                       if (!parent.parentNode || /svg/i.test(parent.nodeName) || parent === parent.parentNode) {
+                               return result;
+                       }
+                       parent = parent.parentNode;
+               }
+               return result;
+       }
+
+}
+
+// warn static variable to store all selections across instances
+SelectionManager[SELECTIONS] = SelectionManager[SELECTIONS] || new Set();
+
+export default SelectionManager;