1// Copyright (c) 2017 Uber Technologies, Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15import { get as _get } from 'lodash';
16
17import EUpdateTypes from './EUpdateTypes';
18import { DraggableBounds, DraggingUpdate } from './types';
19import { TNil } from '../../types';
20
21const LEFT_MOUSE_BUTTON = 0;
22
23type DraggableManagerOptions = {
24  getBounds: (tag: string | TNil) => DraggableBounds;
25  onMouseEnter?: (update: DraggingUpdate) => void;
26  onMouseLeave?: (update: DraggingUpdate) => void;
27  onMouseMove?: (update: DraggingUpdate) => void;
28  onDragStart?: (update: DraggingUpdate) => void;
29  onDragMove?: (update: DraggingUpdate) => void;
30  onDragEnd?: (update: DraggingUpdate) => void;
31  resetBoundsOnResize?: boolean;
32  tag?: string;
33};
34
35export default class DraggableManager {
36  // cache the last known DraggableBounds (invalidate via `#resetBounds())
37  _bounds: DraggableBounds | TNil;
38  _isDragging: boolean;
39  // optional callbacks for various dragging events
40  _onMouseEnter: ((update: DraggingUpdate) => void) | TNil;
41  _onMouseLeave: ((update: DraggingUpdate) => void) | TNil;
42  _onMouseMove: ((update: DraggingUpdate) => void) | TNil;
43  _onDragStart: ((update: DraggingUpdate) => void) | TNil;
44  _onDragMove: ((update: DraggingUpdate) => void) | TNil;
45  _onDragEnd: ((update: DraggingUpdate) => void) | TNil;
46  // whether to reset the bounds on window resize
47  _resetBoundsOnResize: boolean;
48
49  /**
50   * Get the `DraggableBounds` for the current drag. The returned value is
51   * cached until either `#resetBounds()` is called or the window is resized
52   * (assuming `_resetBoundsOnResize` is `true`). The `DraggableBounds` defines
53   * the range the current drag can span to. It also establishes the left offset
54   * to adjust `clientX` by (from the `MouseEvent`s).
55   */
56  getBounds: (tag: string | TNil) => DraggableBounds;
57
58  // convenience data
59  tag: string | TNil;
60
61  // handlers for integration with DOM elements
62  handleMouseEnter: (event: React.MouseEvent<any>) => void;
63  handleMouseMove: (event: React.MouseEvent<any>) => void;
64  handleMouseLeave: (event: React.MouseEvent<any>) => void;
65  handleMouseDown: (event: React.MouseEvent<any>) => void;
66
67  constructor({ getBounds, tag, resetBoundsOnResize = true, ...rest }: DraggableManagerOptions) {
68    this.handleMouseDown = this._handleDragEvent;
69    this.handleMouseEnter = this._handleMinorMouseEvent;
70    this.handleMouseMove = this._handleMinorMouseEvent;
71    this.handleMouseLeave = this._handleMinorMouseEvent;
72
73    this.getBounds = getBounds;
74    this.tag = tag;
75    this._isDragging = false;
76    this._bounds = undefined;
77    this._resetBoundsOnResize = Boolean(resetBoundsOnResize);
78    if (this._resetBoundsOnResize) {
79      window.addEventListener('resize', this.resetBounds);
80    }
81    this._onMouseEnter = rest.onMouseEnter;
82    this._onMouseLeave = rest.onMouseLeave;
83    this._onMouseMove = rest.onMouseMove;
84    this._onDragStart = rest.onDragStart;
85    this._onDragMove = rest.onDragMove;
86    this._onDragEnd = rest.onDragEnd;
87  }
88
89  _getBounds(): DraggableBounds {
90    if (!this._bounds) {
91      this._bounds = this.getBounds(this.tag);
92    }
93    return this._bounds;
94  }
95
96  _getPosition(clientX: number) {
97    const { clientXLeft, maxValue, minValue, width } = this._getBounds();
98    let x = clientX - clientXLeft;
99    let value = x / width;
100    if (minValue != null && value < minValue) {
101      value = minValue;
102      x = minValue * width;
103    } else if (maxValue != null && value > maxValue) {
104      value = maxValue;
105      x = maxValue * width;
106    }
107    return { value, x };
108  }
109
110  _stopDragging() {
111    window.removeEventListener('mousemove', this._handleDragEvent);
112    window.removeEventListener('mouseup', this._handleDragEvent);
113    const style = _get(document, 'body.style');
114    if (style) {
115      style.userSelect = null;
116    }
117    this._isDragging = false;
118  }
119
120  isDragging() {
121    return this._isDragging;
122  }
123
124  dispose() {
125    if (this._isDragging) {
126      this._stopDragging();
127    }
128    if (this._resetBoundsOnResize) {
129      window.removeEventListener('resize', this.resetBounds);
130    }
131    this._bounds = undefined;
132    this._onMouseEnter = undefined;
133    this._onMouseLeave = undefined;
134    this._onMouseMove = undefined;
135    this._onDragStart = undefined;
136    this._onDragMove = undefined;
137    this._onDragEnd = undefined;
138  }
139
140  resetBounds = () => {
141    this._bounds = undefined;
142  };
143
144  _handleMinorMouseEvent = (event: React.MouseEvent<any>) => {
145    const { button, clientX, type: eventType } = event;
146    if (this._isDragging || button !== LEFT_MOUSE_BUTTON) {
147      return;
148    }
149    let type: EUpdateTypes | null = null;
150    let handler: ((update: DraggingUpdate) => void) | TNil;
151    if (eventType === 'mouseenter') {
152      type = EUpdateTypes.MouseEnter;
153      handler = this._onMouseEnter;
154    } else if (eventType === 'mouseleave') {
155      type = EUpdateTypes.MouseLeave;
156      handler = this._onMouseLeave;
157    } else if (eventType === 'mousemove') {
158      type = EUpdateTypes.MouseMove;
159      handler = this._onMouseMove;
160    } else {
161      throw new Error(`invalid event type: ${eventType}`);
162    }
163    if (!handler) {
164      return;
165    }
166    const { value, x } = this._getPosition(clientX);
167    handler({
168      event,
169      type,
170      value,
171      x,
172      manager: this,
173      tag: this.tag,
174    });
175  };
176
177  _handleDragEvent = (event: MouseEvent | React.MouseEvent<any>) => {
178    const { button, clientX, type: eventType } = event;
179    let type: EUpdateTypes | null = null;
180    let handler: ((update: DraggingUpdate) => void) | TNil;
181    if (eventType === 'mousedown') {
182      if (this._isDragging || button !== LEFT_MOUSE_BUTTON) {
183        return;
184      }
185      window.addEventListener('mousemove', this._handleDragEvent);
186      window.addEventListener('mouseup', this._handleDragEvent);
187      const style = _get(document, 'body.style');
188      if (style) {
189        style.userSelect = 'none';
190      }
191      this._isDragging = true;
192
193      type = EUpdateTypes.DragStart;
194      handler = this._onDragStart;
195    } else if (eventType === 'mousemove') {
196      if (!this._isDragging) {
197        return;
198      }
199      type = EUpdateTypes.DragMove;
200      handler = this._onDragMove;
201    } else if (eventType === 'mouseup') {
202      if (!this._isDragging) {
203        return;
204      }
205      this._stopDragging();
206      type = EUpdateTypes.DragEnd;
207      handler = this._onDragEnd;
208    } else {
209      throw new Error(`invalid event type: ${eventType}`);
210    }
211    if (!handler) {
212      return;
213    }
214    const { value, x } = this._getPosition(clientX);
215    handler({
216      event,
217      type,
218      value,
219      x,
220      manager: this,
221      tag: this.tag,
222    });
223  };
224}
225