Skip to content

Commit

Permalink
Merge 70947af into 899c8e7
Browse files Browse the repository at this point in the history
  • Loading branch information
tiffon committed Oct 31, 2017
2 parents 899c8e7 + 70947af commit 22c58e4
Show file tree
Hide file tree
Showing 10 changed files with 727 additions and 1 deletion.
2 changes: 1 addition & 1 deletion src/components/App/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ import { ConnectedRouter } from 'react-router-redux';

import 'semantic-ui-css/semantic.min.css';

import Page from './Page';
import NotFound from './NotFound';
import Page from './Page';
import { ConnectedDependencyGraphPage } from '../DependencyGraph';
import { ConnectedSearchTracePage } from '../SearchTracePage';
import { ConnectedTracePage } from '../TracePage';
Expand Down
309 changes: 309 additions & 0 deletions src/utils/DraggableManager/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,309 @@
# DraggbleManager Information and Demo

In the `src/utils/DraggableManager/demo` folder there is a small project that demonstrates the use of the `DraggableManager` utility.

The demo contains two components:

- `DividerDemo`, which occupies the top half of the web page
- `RegionDemo`, which occupies the bottom half of the web page, as shown in the GIF, below

![GIF of Demo](demo/demo-ux.gif)


## Caveat

This DraggableManager utility does not actually "drag" anything, it does not move or drag DOM elements, it just tells us where the mouse is while the mouse is down. Primarily, it listens for `mousedown` and subsequent `mousemove` and then finally `mouseup` events. (It listens to `window` for the `mousemove` and `mouseup` events.)

What we do with that information is up to us. This is mentioned because you need to handle the DraggableManager callbacks *to create the illusion of dragging*.


## In brief

DraggableManager instances provide three (and a half) conveniences:

- Handle mouse events related to dragging.
- Maps `MouseEvent.clientX` from the [client area](https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/clientX) to the local context (yielding `x` (pixels) and `value` (0 -> 1, e.g, `x/width`)).
- Maintains a sense of state in terms of whether or not the subject DOM element is being dragged. For example, it fires `onMouseMove` callbacks when not being dragged and `onDragMove` when being dragged.
- Two other minor conveniences (relating to window events)

And, DraggableManager instances have two (or three) primary requirements:

- Mouse events need to be piped into it
- The `getBounds()` constructor parameter must be provided
- At least some of the callbacks need to be handled


## Conveniences


### Handles the mouse events related to dragging

For the purposes of handling mouse events related to the intended dragging functionality, DraggableManager instances expose the following methods (among others):

- `handleMouseEnter`
- `handleMouseMove`
- `handleMouseLeave`
- `handleMouseDown`

To use a DraggableManager instance, relevant mouse events should be piped to the above handlers:

```html
<div className="DividerDemo--realm">
<div className="DividerDemo--divider" onMouseDown={this._dragManager.handleMouseDown} />
</div>
```

Note: Not all handlers are always necessary. See "Mouse events need to be piped into it" for more details.


### Maps the `clientX` to `x` and `value`

`MouseEvent` (and `SyntheticMouseEvent`) events provide the [`clientX`](https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/clientX) property, which generally needs some adjustments before it's useful. For instance, in the following snippet we transform `clientX` to the `x` within the `<div>`. The `value` is simply the `x/width` ratio, which is pretty much the percent but divided by `100`.


```jsx
<div className="DividerDemo--realm">
<div
className="DividerDemo--divider"
onMouseDown={event => {
const { clientX, target } = event;
const { left, width } = target.getBoundingClientRect();
const localX = clientX - left;
console.log('within the client area, x:', clientX);
console.log('within the div, x: ', localX);
console.log('position along the width: ', localX / width);
}}
/>
</div>
```

In other words, DraggableManager instances convert the data to the relevant context. (The "relevant context" is, naturally, varies... see the `getBounds()` constructor parameter below).


### Maintains a sense of state

The callbacks for DraggableManager instances are:

- onMouseEnter
- onMouseLeave
- onMouseMove
- onDragStart
- onDragMove
- onDragEnd

Implicit in the breakdown of the callbacks is the notion that `onDrag*` callbacks are fired when dragging and `onMouse*` callbacks are issued, otherwise.

Therefore, using the DraggableManager util relieves us of the necessity of keeping track of whether we are currently dragging or not.


### Two other minor conveniences

When dragging starts, the util then switches over to listening to window events (`mousemove` and `mouseup`). This prevents the dragging from having strange behavior if / when the user moves the mouse anywhere on the page.

Last but not least...

The util listens for window resize events and makes adjustments accordingly, preventing things from going crazy (due to miscalibration) if the user resizes the window. This primary relates to the `getBounds()` constructor option (see below).



## Requirements

### Mouse events need to be piped into it

In my use, DraggbaleManager instances become the receiver of the relevant mouse events instead of handlers on the React component.

For instance, if implementing a draggable divider (see `DividerDemo.js` and the top half of the gif), only `onMouseDown` needs to be handled:

```jsx
<div className="DividerDemo--realm">
<div
className="DividerDemo--divider"
onMouseDown={this._dragManager.handleMouseDown}
/>
</div>
```

But, if implementing the ability to drag a sub-range (see `RegionDemo.js` and the bottom of demo gif), you generally want to show a vertical line at the mouse cursor until the dragging starts (`onMouseDown`), then you want to draw the region being dragged. So, the `onMouseMove`, `onMouseLeave` and `onMouseDown` handlers are necessary:

```jsx
<div
className="RegionDemo--realm"
onMouseDown={this._dragManager.handleMouseDown}
onMouseMove={this._dragManager.handleMouseMove}
onMouseLeave={this._dragManager.handleMouseMove}
>
{/* Draw visuals for the currently dragged range, otherwise empty */}
</div>
```

### `getBounds()` constructor parameter

The crux of the conversion from `clientX` to `x` and `value` is the `getBounds()` constructor parameter.

The function is a required constructor parameter, and it must return a `DraggableBounds` object:

```
type DraggableBounds = {
clientXLeft: number,
maxValue?: number,
minValue?: number,
width: number,
};
```

This generally amounts to calling [`Element#getBoundingClientRect()`](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect) on the DOM element that defines the valid dragging range.

For instance, in the `DividerDemo`, the function used is `DivideDemo#_getDraggingBounds()`:

```js
_getDraggingBounds = (): DraggableBounds => {
if (!this._realmElm) {
throw new Error('invalid state');
}
const { left: clientXLeft, width } = this._realmElm.getBoundingClientRect();
return {
clientXLeft,
width,
maxValue: 0.98,
minValue: 0.02,
};
};
```

In the snippet above, `this._realmElm` is the `<div>` that fills the green draggable region.

On the other hand, if you need more flexibility, this function can ignore the DOM altogether and do something else entirely. It just needs to return an object with `clientXLeft` and `width` properties, at the minimum.

`maxValue` and `minValue` are optional and will restrict the extent of the dragging. They are in terms of `value`, not `x`.


### The callbacks need to be handled

Last but not least, if the callbacks are ignored, nothing happens.

In the `DividerDemo`, we're only interested in repositioning the divider when it is dragged. We don't care about mouse related callbacks. So, only the drag related callbacks are handled. And, all of the drag callbacks are handled in the same way: we update the position of the divider. Done. See `DividerDemo#_handleDragEvent()`.

In the other scenario, `RegionDemo`, we care about showing the red vertical line for mouse-over. This sort of indicates to the user they can click and drag, and when they drag we want to show a region that spans the current drag. So, we handle the mousemove and mouseleave callbacks along with the drag callbacks.

The `RegionDemo` is a bit more involved, so, to break down how we handle the callbacks... First, we store the following state (in the parent element, incidentally):

- `regionCursor` is where we draw the cursor indicator (a red vertical line, in the demo).
- `regionDragging` represents the start (at index `0`) and current position (at index `1`) of the region currently being dragged.

```
{
regionCursor: ?number,
regionDragging: ?[number, number],
}
```

Then, we handle the callbacks as follows:

- `onMouseMove`
- Set `regionCursor` to `value`
- This allows us to draw the red vertical line at the cursor
- `onMouseLeave`
- Set `regionCursor` to `null`
- So we know not to draw the red vertical line
- `onDragStart`
- Set `regionDragging` to `[value, value]`
- This allows us to draw the dragging region
- `onDragMove`
- Set `regionDragging` to `[regionDragging[0], value]`
- Again, for drawing the dragging region. We keep `regionDragging[0]` as-is so we always know where the drag started
- `onDragEnd`
- Set `regionDragging` to `null`, set `regionCursor` to `null`
- Setting `regionDragging` to `null` lets us know not to draw the region, and setting `regionCursor` lets us know to draw the cursor right where the user left off

This is a contrived demo, so `onDragEnd` is kind of boring... Usually we would do something more interesting with the final `x` or `value`.


## API


### Constants `updateTypes`

Used as the `type` field on `DraggingUpdate` objects.

```
{
DRAG_END: 'DRAG_END',
DRAG_MOVE: 'DRAG_MOVE',
DRAG_START: 'DRAG_START',
MOUSE_ENTER: 'MOUSE_ENTER',
MOUSE_LEAVE: 'MOUSE_LEAVE',
MOUSE_MOVE: 'MOUSE_MOVE',
};
```


### Type `DraggingUpdate`

The data type issued for all callbacks.

```
type DraggingUpdate = {
event: SyntheticMouseEvent<any>,
manager: DraggableManager,
tag: ?string,
type: UpdateType,
value: number,
x: number,
};
```


### Type `DraggableBounds`

The type the `getBounds()` constructor parameter must return.

```
type DraggableBounds = {
clientXLeft: number,
maxValue?: number,
minValue?: number,
width: number,
};
```

`clientXLeft` is used to convert [`MouseEvent.clientX`](https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/clientX) from the client area to the dragging area.

`maxValue` and `minValue` are in terms of `value` on the updates, e.g. they are in the range from `[0, 1]` where `0` is the far left (e.g. style `left: 0;`) end of the draggable region and `1` is the far right end (style `right: 0`). If set, they will restrict the `x` and `value` issued by the callbacks.

`width` is used to convert `x` to `value` and is also the span on which `minValue` and `maxValue` are mapped onto when calculating `x` and `value` for issuing callbacks.


### Constructor parameters

```
type DraggableManagerOptions = {
getBounds: (?string) => DraggableBounds,
onMouseEnter?: DraggingUpdate => void,
onMouseLeave?: DraggingUpdate => void,
onMouseMove?: DraggingUpdate => void,
onDragStart?: DraggingUpdate => void,
onDragMove?: DraggingUpdate => void,
onDragEnd?: DraggingUpdate => void,
resetBoundsOnResize?: boolean,
tag?: string,
};
```

`getBounds()` is used to map the `clientX` to whatever the dragging context is. **It is called lazily** and the returned value is cached, until either `DraggableManager#resetBounds()` is called, the window is resized (when `resetBoundsOnResize` is `true`) or `DraggableManager#dispose()` is called.

The callbacks are all optional. The callbacks all present the same data (`DraggingUpdate`), with the `type` field being set based on which callback is firing (e.g. `type` is `'MOUSE_ENTER'` when `onMouseEnter` is fired, and the `x` and `value` representing the last know position of the mouse cursor.

If `resetBoundsOnResize` is `true`, the instance resets the cached `DraggableBounds` when the window is resized.

`tag` is an optional string parameter. It is a convenience field for distinguishing different `DraggableManager` instances. If set on the constructor, it is set on every `DraggingUpdate` that is issued.


### `DraggableManager# isDragging()`

Returns `true` when the instance is in a dragged state, e.g. after `onDragStart` is fired and before `onDragEnd` is fired.



### `DraggableManager# dispose()`

Removes any event listeners attached to `window` and sets all instance properties to `undefined`.
44 changes: 44 additions & 0 deletions src/utils/DraggableManager/demo/DividerDemo.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
Copyright (c) 2017 Uber Technologies, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

.DividerDemo--realm {
bottom: 0;
left: 0;
position: absolute;
right: 0;
top: 0;
}

.DividerDemo--divider {
background: #888;
border-bottom: none;
border-top: none;
border: 1px solid #a9dccc;
bottom: 0;
cursor: col-resize;
position: absolute;
top: 0;
width: 4px;
}

.DividerDemo--divider::before {
bottom: 0;
content: " ";
left: -2px;
position: absolute;
right: -2px;
top: 0;
}
Loading

0 comments on commit 22c58e4

Please sign in to comment.