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