MD Settings: Display: Implement dragging

This introduces drag behavior, but does not persist new positions.

BUG=547080
CQ_INCLUDE_TRYBOTS=tryserver.chromium.linux:closure_compilation

Review-Url: https://codereview.chromium.org/2097793004
Cr-Commit-Position: refs/heads/master@{#402354}
diff --git a/chrome/browser/resources/settings/device_page/compiled_resources2.gyp b/chrome/browser/resources/settings/device_page/compiled_resources2.gyp
index e58b0bf..9bd841d 100644
--- a/chrome/browser/resources/settings/device_page/compiled_resources2.gyp
+++ b/chrome/browser/resources/settings/device_page/compiled_resources2.gyp
@@ -57,6 +57,7 @@
         '<(DEPTH)/ui/webui/resources/js/compiled_resources2.gyp:cr',
         '<(EXTERNS_GYP):system_display',
         '<(INTERFACES_GYP):system_display_interface',
+        'drag_behavior'
       ],
       'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'],
     },
@@ -69,6 +70,13 @@
         'display'
       ],
       'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'],
+    }, 
+    {
+      'target_name': 'drag_behavior',
+      'dependencies': [
+        '<(DEPTH)/ui/webui/resources/js/compiled_resources2.gyp:cr',
+      ],
+      'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'],
     },
-  ],
+ ],
 }
diff --git a/chrome/browser/resources/settings/device_page/display_layout.html b/chrome/browser/resources/settings/device_page/display_layout.html
index ea9d5f55..e0bf1d7 100644
--- a/chrome/browser/resources/settings/device_page/display_layout.html
+++ b/chrome/browser/resources/settings/device_page/display_layout.html
@@ -1,6 +1,7 @@
 <link rel="import" href="chrome://resources/html/polymer.html">
 <link rel="import" href="chrome://resources/polymer/v1_0/iron-resizable-behavior/iron-resizable-behavior.html">
 <link rel="import" href="chrome://resources/polymer/v1_0/paper-button/paper-button.html">
+<link rel="import" href="/device_page/drag_behavior.html">
 <link rel="import" href="/settings_shared_css.html">
 
 <dom-module id="display-layout">
@@ -31,8 +32,8 @@
     </style>
     <div id="displayArea" on-iron-resize="calculateVisualScale_">
       <template is="dom-repeat" items="[[displays]]">
-        <div id="_[[item.id]]" class="display"
-            style$="[[getDivStyle_(item, visualScale)]]"
+        <div id="_[[item.id]]" class="display" draggable="true"
+            style$="[[getDivStyle_(item.id, item.bounds, visualScale)]]"
             selected$="[[isSelected_(item, selectedDisplay)]]"
             on-tap="onSelectDisplayTap_">
           [[item.name]]
diff --git a/chrome/browser/resources/settings/device_page/display_layout.js b/chrome/browser/resources/settings/device_page/display_layout.js
index 5004a5d..f3a5220 100644
--- a/chrome/browser/resources/settings/device_page/display_layout.js
+++ b/chrome/browser/resources/settings/device_page/display_layout.js
@@ -17,6 +17,7 @@
 
   behaviors: [
     Polymer.IronResizableBehavior,
+    DragBehavior,
   ],
 
   properties: {
@@ -32,6 +33,12 @@
      */
     layouts: Array,
 
+    /**
+     * Whether or not mirroring is enabled.
+     * @type {boolean}
+     */
+    mirroring: false,
+
     /** @type {!chrome.system.display.DisplayUnitInfo|undefined} */
     selectedDisplay: Object,
 
@@ -42,14 +49,17 @@
     visualScale: 1,
   },
 
-  /** @private {!Object<chrome.system.display.DisplayUnitInfo>} */
-  displayMap_: {},
+  /** @private {!Object<chrome.system.display.Bounds>} */
+  displayBoundsMap_: {},
 
   /** @private {!Object<chrome.system.display.DisplayLayout>} */
   layoutMap_: {},
 
-  /** @private {!Object<chrome.system.display.Bounds>} */
-  boundsMap_: {},
+  /**
+   * The calculated bounds used for generating the div bounds.
+   * @private {!Object<chrome.system.display.Bounds>}
+  */
+  calculatedBoundsMap_: {},
 
   /** @private {!{left: number, top: number}} */
   visualOffset_: {left: 0, top: 0},
@@ -67,6 +77,11 @@
     tryCalcVisualScale();
   },
 
+  /** @override */
+  detached: function() {
+    this.initializeDrag(false);
+  },
+
   /**
    * Called explicitly when |this.displays| and their associated |this.layouts|
    * have been fetched from chrome.
@@ -77,19 +92,24 @@
     this.displays = displays;
     this.layouts = layouts;
 
-    this.displayMap_ = {};
+    this.mirroring = displays.length > 0 && !!displays[0].mirroringSourceId;
+
+    this.displayBoundsMap_ = {};
     for (let display of this.displays)
-      this.displayMap_[display.id] = display;
+      this.displayBoundsMap_[display.id] = display.bounds;
 
     this.layoutMap_ = {};
     for (let layout of this.layouts)
       this.layoutMap_[layout.id] = layout;
 
-    this.boundsMap_ = {};
+    this.calculatedBoundsMap_ = {};
     for (let display of this.displays)
-      this.calcDisplayBounds_(display);
+      this.calculateBounds_(display.id, display.bounds);
 
     this.calculateVisualScale_();
+
+    this.initializeDrag(
+        !this.mirroring, this.$.displayArea, this.onDrag_.bind(this));
   },
 
   /**
@@ -107,7 +127,7 @@
     }
 
     var display = this.displays[0];
-    var bounds = this.boundsMap_[display.id];
+    var bounds = this.calculatedBoundsMap_[display.id];
     var displayInfoBoundingBox = {
       left: bounds.left,
       right: bounds.left + bounds.width,
@@ -118,7 +138,7 @@
     var maxHeight = bounds.height;
     for (let i = 1; i < this.displays.length; ++i) {
       display = this.displays[i];
-      bounds = this.boundsMap_[display.id];
+      bounds = this.calculatedBoundsMap_[display.id];
       displayInfoBoundingBox.left =
           Math.min(displayInfoBoundingBox.left, bounds.left);
       displayInfoBoundingBox.right =
@@ -154,15 +174,16 @@
   },
 
   /**
-   * @param {!chrome.system.display.DisplayUnitInfo} display
+   * @param {string} id
+   * @param {!chrome.system.display.Bounds} displayBounds
    * @param {number} visualScale
    * @return {string} The style string for the div.
    * @private
    */
-  getDivStyle_: function(display, visualScale) {
+  getDivStyle_: function(id, displayBounds, visualScale) {
     // This matches the size of the box-shadow or border in CSS.
     /** @const {number} */ var BORDER = 2;
-    var bounds = this.boundsMap_[display.id];
+    var bounds = this.calculatedBoundsMap_[id];
     var height = Math.round(bounds.height * this.visualScale) - BORDER * 2;
     var width = Math.round(bounds.width * this.visualScale) - BORDER * 2;
     var left =
@@ -196,29 +217,29 @@
    * Caches the display bounds so that parent bounds are only calculated once.
    * TODO(stevenjb): Move this function and the maps it requires to a separate
    *     behavior which will include snapping and collisions.
-   * @param {!chrome.system.display.DisplayUnitInfo} display
+   * @param {string} id
+   * @param {!chrome.system.display.Bounds} bounds
    * @private
    */
-  calcDisplayBounds_: function(display) {
-    if (display.id in this.boundsMap_)
+  calculateBounds_: function(id, bounds) {
+    if (id in this.calculatedBoundsMap_)
       return;  // Already calculated (i.e. a parent of a previous display)
     var left, top;
-    if (display.isPrimary) {
-      left = -display.bounds.width / 2;
-      top = -display.bounds.height / 2;
+    var layout = this.layoutMap_[id];
+    if (!layout || !layout.parentId) {
+      left = -bounds.width / 2;
+      top = -bounds.height / 2;
     } else {
-      var layout = this.layoutMap_[display.id];
-      assert(layout.parentId);
-      var parentDisplay = this.displayMap_[layout.parentId];
+      var parentDisplayBounds = this.displayBoundsMap_[layout.parentId];
       var parentBounds;
-      if (!(parentDisplay.id in this.boundsMap_))
-        this.calcDisplayBounds_(parentDisplay);
-      parentBounds = this.boundsMap_[parentDisplay.id];
+      if (!(layout.parentId in this.calculatedBoundsMap_))
+        this.calculateBounds_(layout.parentId, parentDisplayBounds);
+      parentBounds = this.calculatedBoundsMap_[layout.parentId];
       left = parentBounds.left;
       top = parentBounds.top;
       switch (layout.position) {
         case chrome.system.display.LayoutPosition.TOP:
-          top -= display.bounds.height;
+          top -= bounds.height;
           break;
         case chrome.system.display.LayoutPosition.RIGHT:
           left += parentBounds.width;
@@ -227,18 +248,52 @@
           top += parentBounds.height;
           break;
         case chrome.system.display.LayoutPosition.LEFT:
-          left -= display.bounds.height;
+          left -= bounds.height;
           break;
       }
     }
     var result = {
       left: left,
       top: top,
-      width: display.bounds.width,
-      height: display.bounds.height
+      width: bounds.width,
+      height: bounds.height
     };
-    this.boundsMap_[display.id] = result;
-  }
+    this.calculatedBoundsMap_[id] = result;
+  },
+
+  /**
+   * @param {string} id
+   * @param {?DragPosition} amount
+   */
+  onDrag_(id, amount) {
+    id = id.substr(1);  // Skip prefix
+
+    var newBounds;
+    if (!amount) {
+      // TODO(stevenjb): Resolve layout and send update.
+      newBounds = this.calculatedBoundsMap_[id];
+    } else {
+      // Make sure the dragged display is also selected.
+      if (id != this.selectedDisplay.id)
+        this.fire('select-display', id);
+
+      var calculatedBounds = this.calculatedBoundsMap_[id];
+      newBounds =
+          /** @type {chrome.system.display.Bounds} */ (
+              Object.assign({}, calculatedBounds));
+      newBounds.left += Math.round(amount.x / this.visualScale);
+      newBounds.top += Math.round(amount.y / this.visualScale);
+      // TODO(stevenjb): Update layout.
+    }
+    var left =
+        this.visualOffset_.left + Math.round(newBounds.left * this.visualScale);
+    var top =
+        this.visualOffset_.top + Math.round(newBounds.top * this.visualScale);
+    var div = this.$$('#_' + id);
+    div.style.left = '' + left + 'px';
+    div.style.top = '' + top + 'px';
+  },
+
 });
 
 })();
diff --git a/chrome/browser/resources/settings/device_page/drag_behavior.html b/chrome/browser/resources/settings/device_page/drag_behavior.html
new file mode 100644
index 0000000..3612890
--- /dev/null
+++ b/chrome/browser/resources/settings/device_page/drag_behavior.html
@@ -0,0 +1 @@
+<script src="drag_behavior.js"></script>
diff --git a/chrome/browser/resources/settings/device_page/drag_behavior.js b/chrome/browser/resources/settings/device_page/drag_behavior.js
new file mode 100644
index 0000000..91d9c50
--- /dev/null
+++ b/chrome/browser/resources/settings/device_page/drag_behavior.js
@@ -0,0 +1,220 @@
+// Copyright 2016 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/**
+ * @fileoverview Behavior for handling dragging elements in a container.
+ *     Draggable elements must have the 'draggable' attribute set.
+ */
+
+/**
+ * @typedef {{
+ *   x: number,
+ *   y: number
+ * }}
+ */
+var DragPosition;
+
+/** @polymerBehavior */
+var DragBehavior = {
+  /**
+   * The id of the element being dragged, or empty if not dragging.
+   * @private {string}
+   */
+  dragId_: '',
+
+  /** @private {boolean} */
+  enabled_: false,
+
+  /** @private {!HTMLDivElement|undefined} */
+  container_: undefined,
+
+  /** @private {?function(string, ?DragPosition):void} */
+  callback_: null,
+
+  /** @private {!DragPosition} */
+  dragStartLocation_: {x: 0, y: 0},
+
+  /**
+   * Used to ignore unnecessary drag events.
+   * @private {?DragPosition}
+   */
+  lastTouchLocation_: null,
+
+  /** @private {?function(!Event)} */
+  mouseDownListener_: null,
+
+  /** @private {?function(!Event)} */
+  mouseMoveListener_: null,
+
+  /** @private {?function(!Event)} */
+  touchStartListener_: null,
+
+  /** @private {?function(!Event)} */
+  touchMoveListener_: null,
+
+  /** @private {?function(!Event)} */
+  endDragListener_: null,
+
+  /**
+   * @param {boolean} enabled
+   * @param {!HTMLDivElement=} opt_container
+   * @param {!function(string, ?DragPosition):void=} opt_callback
+   */
+  initializeDrag: function(enabled, opt_container, opt_callback) {
+    this.enabled_ = enabled;
+    if (!enabled) {
+      if (this.container) {
+        this.container.removeEventListener('mousdown', this.mouseDownListener_);
+        this.mouseDownListener_ = null;
+        this.container.removeEventListener(
+            'mousemove', this.mouseMoveListener_);
+        this.mouseMoveListener_ = null;
+        this.container.removeEventListener(
+            'touchstart', this.touchStartListener_);
+        this.touchStartListener_ = null;
+        this.container.removeEventListener(
+            'touchmove', this.touchMoveListener_);
+        this.touchMoveListener_ = null;
+        this.container.removeEventListener('touchend', this.endDragListener_);
+      }
+      if (this.mouseUpListener_)
+        window.removeEventListener('mouseup', this.endDragListener_);
+      this.endDragListener_ = null;
+      return;
+    }
+
+    if (opt_container !== undefined)
+      this.container_ = opt_container;
+    var container = this.container_;
+    assert(container);
+
+    this.mouseDownListener_ = this.onMouseDown_.bind(this);
+    container.addEventListener('mousedown', this.mouseDownListener_, true);
+
+    this.mouseMoveListener_ = this.onMouseMove_.bind(this);
+    container.addEventListener('mousemove', this.mouseMoveListener_, true);
+
+    this.touchStartListener_ = this.onTouchStart_.bind(this);
+    container.addEventListener('touchstart', this.touchStartListener_, true);
+
+    this.touchMoveListener_ = this.onTouchMove_.bind(this);
+    container.addEventListener('touchmove', this.touchMoveListener_, true);
+
+    this.endDragListener_ = this.endDrag_.bind(this);
+    window.addEventListener('mouseup', this.endDragListener_, true);
+    container.addEventListener('touchend', this.endDragListener_, true);
+
+    if (opt_callback !== undefined)
+      this.callback_ = opt_callback;
+  },
+
+  /**
+   * @param {Event} e The mouse down event.
+   * @return {boolean}
+   * @private
+   */
+  onMouseDown_: function(e) {
+    if (e.button != 0)
+      return true;
+    if (!e.target.getAttribute('draggable'))
+      return true;
+    e.preventDefault();
+    var target = assertInstanceof(e.target, HTMLElement);
+    return this.startDrag_(target, {x: e.pageX, y: e.pageY});
+  },
+
+  /**
+   * @param {Event} e The mouse move event.
+   * @return {boolean}
+   * @private
+   */
+  onMouseMove_: function(e) {
+    e.preventDefault();
+    return this.processDrag_(e, {x: e.pageX, y: e.pageY});
+  },
+
+  /**
+   * @param {Event} e The touch start event.
+   * @return {boolean}
+   * @private
+   */
+  onTouchStart_: function(e) {
+    if (e.touches.length != 1)
+      return false;
+
+    e.preventDefault();
+    var touch = e.touches[0];
+    this.lastTouchLocation_ = {x: touch.pageX, y: touch.pageY};
+    var target = assertInstanceof(e.target, HTMLElement);
+    return this.startDrag_(target, this.lastTouchLocation_);
+  },
+
+  /**
+   * @param {Event} e The touch move event.
+   * @return {boolean}
+   * @private
+   */
+  onTouchMove_: function(e) {
+    if (e.touches.length != 1)
+      return true;
+
+    var touchLocation = {x: e.touches[0].pageX, y: e.touches[0].pageY};
+    // Touch move events can happen even if the touch location doesn't change
+    // and on small unintentional finger movements. Ignore these small changes.
+    if (this.lastTouchLocation_) {
+      /** @const */ var IGNORABLE_TOUCH_MOVE_PX = 1;
+      var xDiff = Math.abs(touchLocation.x - this.lastTouchLocation_.x);
+      var yDiff = Math.abs(touchLocation.y - this.lastTouchLocation_.y);
+      if (xDiff <= IGNORABLE_TOUCH_MOVE_PX && yDiff <= IGNORABLE_TOUCH_MOVE_PX)
+        return true;
+    }
+    this.lastTouchLocation_ = touchLocation;
+    e.preventDefault();
+    return this.processDrag_(e, touchLocation);
+  },
+
+  /**
+   * @param {!HTMLElement} target
+   * @param {!DragPosition} eventLocation
+   * @return {boolean}
+   * @private
+   */
+  startDrag_: function(target, eventLocation) {
+    this.dragId_ = target.id;
+    this.dragStartLocation_ = eventLocation;
+    return false;
+  },
+
+  /**
+   * @param {Event} e
+   * @return {boolean}
+   * @private
+   */
+  endDrag_: function(e) {
+    if (this.dragId_ && this.callback_)
+      this.callback_(this.dragId_, null);
+    this.dragId_ = '';
+    this.lastTouchLocation_ = null;
+    return false;
+  },
+
+  /**
+   * @param {Event} e The event which triggers this drag.
+   * @param {DragPosition} eventLocation The location of the event.
+   * @return {boolean}
+   * @private
+   */
+  processDrag_: function(e, eventLocation) {
+    if (!this.dragId_)
+      return true;
+    if (this.callback_) {
+      var delta = {
+        x: eventLocation.x - this.dragStartLocation_.x,
+        y: eventLocation.y - this.dragStartLocation_.y,
+      };
+      this.callback_(this.dragId_, delta);
+    }
+    return false;
+  },
+};
diff --git a/chrome/browser/resources/settings/settings_resources.grd b/chrome/browser/resources/settings/settings_resources.grd
index 5a8cc49..58e6432 100644
--- a/chrome/browser/resources/settings/settings_resources.grd
+++ b/chrome/browser/resources/settings/settings_resources.grd
@@ -372,6 +372,12 @@
         <structure name="IDR_SETTINGS_DEVICE_TOUCHPAD_JS"
                    file="device_page/touchpad.js"
                    type="chrome_html" />
+        <structure name="IDR_SETTINGS_DEVICE_DRAG_BEHAVIOR_HTML"
+                   file="device_page/drag_behavior.html"
+                   type="chrome_html" />
+        <structure name="IDR_SETTINGS_DEVICE_DRAG_BEHAVIOR_JS"
+                   file="device_page/drag_behavior.js"
+                   type="chrome_html" />
       </if>
       <structure name="IDR_SETTINGS_DIRECTION_DELEGATE_HTML"
                  file="direction_delegate.html"