Skip to content
This repository has been archived by the owner on Nov 28, 2022. It is now read-only.

Commit

Permalink
✨ Added auto-scroll when dragging gallery images (#1083)
Browse files Browse the repository at this point in the history
no issue

Adds auto-scroll if the mouse is placed near the top or bottom of the window whilst dragging an image to re-order it within a gallery. Useful when the position you want to drag to is not currently on screen.

- append drop indicator element to `.koenig-editor` element to account for scrolling (also fixes indicator positioning bug with current implementation if you use mouse-wheel or keyboard to scroll the page whilst dragging)
- generalise `getParent` util to accept a 
  - switch to using selectors rather than dataset for finding parent draggable/droppable/container
- add a `ScrollHandler` class that is used by the `koenigDragDropHandler` service to trigger scrolling whilst dragging
  • Loading branch information
kevinansfield committed Dec 13, 2018
1 parent cce8738 commit f51c9dd
Show file tree
Hide file tree
Showing 4 changed files with 243 additions and 22 deletions.
15 changes: 15 additions & 0 deletions lib/koenig-editor/addon/lib/dnd/constants.js
@@ -1,6 +1,21 @@
import {dasherize} from '@ember/string';

// we use data attributes rather than classes even though they can be slower
// because in many instances our draggable/droppable element's classes attribute
// could be dynamically generated which could remove our DnD classes when changed

export const CONTAINER_DATA_ATTR = 'koenigDndContainer';
export const CONTAINER_SELECTOR = `[data-${dasherize(CONTAINER_DATA_ATTR)}]`;

export const DRAGGABLE_DATA_ATTR = 'koenigDndDraggable';
export const DRAGGABLE_SELECTOR = `[data-${dasherize(DRAGGABLE_DATA_ATTR)}]`;

export const DROPPABLE_DATA_ATTR = 'koenigDndDroppable';
export const DROPPABLE_SELECTOR = `[data-${dasherize(DROPPABLE_DATA_ATTR)}]`;

export const DROP_INDICATOR_ID = 'koenig-drag-drop-indicator';
export const DROP_INDICATOR_ZINDEX = 10000;

export const GHOST_CONTAINER_ID = 'koenig-drag-drop-ghost-container';

export const GHOST_ZINDEX = DROP_INDICATOR_ZINDEX + 1;
112 changes: 112 additions & 0 deletions lib/koenig-editor/addon/lib/dnd/scroll-handler.js
@@ -0,0 +1,112 @@
// adapted from draggable.js Scrollable plugin (MIT)
// https://github.com/Shopify/draggable/blob/master/src/Draggable/Plugins/Scrollable/Scrollable.js
import UAParser from 'ua-parser-js';
import {
getDocumentScrollingElement,
getParentScrollableElement
} from './utils';

export const defaultOptions = {
speed: 8,
sensitivity: 50
};

export default class ScrollHandler {
constructor() {
this.options = Object.assign({}, defaultOptions);

this.currentMousePosition = null;
this.findScrollableElementFrame = null;
this.scrollableElement = null;
this.scrollAnimationFrame = null;

// bind `this` so methods can be passed to requestAnimationFrame
this._scroll = this._scroll.bind(this);

// cache browser info to avoid parsing on every animation frame
this.userAgent = new UAParser();
}

dragStart(draggableInfo) {
this.findScrollableElementFrame = requestAnimationFrame(() => {
this.scrollableElement = this.getScrollableElement(draggableInfo.element);
});
}

dragMove(draggableInfo) {
this.findScrollableElementFrame = requestAnimationFrame(() => {
this.scrollableElement = this.getScrollableElement(draggableInfo.target);
});

if (!this.scrollableElement) {
return;
}

this.currentMousePosition = {
clientX: draggableInfo.mousePosition.x,
clientY: draggableInfo.mousePosition.y
};

this.scrollAnimationFrame = requestAnimationFrame(this._scroll);
}

dragStop() {
cancelAnimationFrame(this.scrollAnimationFrame);
cancelAnimationFrame(this.findScrollableElementFrame);

this.currentMousePosition = null;
this.findScrollableElementFrame = null;
this.scrollableElement = null;
this.scrollAnimationFrame = null;
}

getScrollableElement(target) {
let scrollableElement = getParentScrollableElement(target);

// workaround for our particular scrolling setup
// TODO: find a way to make this configurable
if (scrollableElement === getDocumentScrollingElement()) {
scrollableElement = document.querySelector('.gh-koenig-editor');
}

return scrollableElement;
}

_scroll() {
if (!this.scrollableElement || !this.currentMousePosition) {
return;
}

cancelAnimationFrame(this.scrollAnimationFrame);

let {speed, sensitivity} = this.options;

let rect = this.scrollableElement.getBoundingClientRect();

let scrollableElement = this.scrollableElement;
let clientX = this.currentMousePosition.clientX;
let clientY = this.currentMousePosition.clientY;

let {offsetHeight, offsetWidth} = scrollableElement;

let topPosition = rect.top + offsetHeight - clientY;
let bottomPosition = clientY - rect.top;
let isSafari = this.userAgent.getBrowser().name === 'Safari';

// Safari will automatically scroll when the mouse is outside of the window
// so we want to avoid our own scrolling in that situation to avoid jank
if (topPosition < sensitivity && !(isSafari && topPosition < 0)) {
scrollableElement.scrollTop += speed;
} else if (bottomPosition < sensitivity && !(isSafari && bottomPosition < 0)) {
scrollableElement.scrollTop -= speed;
}

if (rect.left + offsetWidth - clientX < sensitivity) {
scrollableElement.scrollLeft += speed;
} else if (clientX - rect.left < sensitivity) {
scrollableElement.scrollLeft -= speed;
}

this.scrollAnimationFrame = requestAnimationFrame(this._scroll);
}
}
82 changes: 70 additions & 12 deletions lib/koenig-editor/addon/lib/dnd/utils.js
@@ -1,20 +1,59 @@
// we use datasets rather than classes even though they are slower because in
// many instances our draggable/droppable element's classes could be clobbered
// due to being a dynamically generated attribute
// -
// NOTE: if performance is an issue we could put data directly on the element
// object without using dataset but that won't be visible in DevTools without
// explicitly checking elements via the Console
export function getParent(element, dataAttribute) {
export function getParent(element, value) {
if (!element) {
return null;
}

let selector = value;
let callback = value;

let isSelector = typeof value === 'string';
let isFunction = typeof value === 'function';

function matches(currentElement) {
if (!currentElement) {
return currentElement;
} else if (isSelector) {
return currentElement.matches(selector);
} else if (isFunction) {
return callback(currentElement);
}
}

let current = element;
while (current) {
if (current.dataset[dataAttribute]) {

do {
if (matches(current)) {
return current;
}
current = current.parentElement;

current = current.parentNode;
} while (current && current !== document.body && current !== document);
}

export function getParentScrollableElement(element) {
if (!element) {
return getDocumentScrollingElement();
}

let position = getComputedStyle(element).getPropertyValue('position');
let excludeStaticParents = position === 'absolute';

let scrollableElement = getParent(element, (parent) => {
if (excludeStaticParents && isStaticallyPositioned(parent)) {
return false;
}
return hasOverflow(parent);
});

if (position === 'fixed' && !scrollableElement) {
return getDocumentScrollingElement();
} else {
return scrollableElement;
}
}

return null;
export function getDocumentScrollingElement() {
return document.scrollingElement || document.element;
}

export function applyUserSelect(element, value) {
Expand All @@ -24,3 +63,22 @@ export function applyUserSelect(element, value) {
element.style.oUserSelect = value;
element.style.userSelect = value;
}

/* Not exported --------------------------------------------------------------*/

function isStaticallyPositioned(element) {
let position = getComputedStyle(element).getPropertyValue('position');
return position === 'static';
}

function hasOverflow(element) {
let overflowRegex = /(auto|scroll)/;
let computedStyles = getComputedStyle(element, null);

let overflow =
computedStyles.getPropertyValue('overflow') +
computedStyles.getPropertyValue('overflow-y') +
computedStyles.getPropertyValue('overflow-x');

return overflowRegex.test(overflow);
}
56 changes: 46 additions & 10 deletions lib/koenig-editor/addon/services/koenig-drag-drop-handler.js
@@ -1,6 +1,7 @@
import * as constants from '../lib/dnd/constants';
import * as utils from '../lib/dnd/utils';
import Container from '../lib/dnd/container';
import ScrollHandler from '../lib/dnd/scroll-handler';
import Service from '@ember/service';
import {A} from '@ember/array';
import {didCancel, task, waitForProperty} from 'ember-concurrency';
Expand Down Expand Up @@ -29,6 +30,7 @@ export default Service.extend({
this._super(...arguments);

this.containers = A([]);
this.scrollHandler = new ScrollHandler();
this._eventHandlers = {};
this._transformedDroppables = A([]);

Expand All @@ -38,8 +40,9 @@ export default Service.extend({
// set up document event listeners
this._addGrabListeners();

// append drop indicator element
// append body elements
this._appendDropIndicator();
this._appendGhostContainerElement();
},

willDestroy() {
Expand All @@ -51,8 +54,9 @@ export default Service.extend({
// clean up document event listeners
this._removeGrabListeners();

// remove drop indicator element
// remove body elements
this._removeDropIndicator();
this._removeGhostContainerElement();
},

// interface ---------------------------------------------------------------
Expand Down Expand Up @@ -98,10 +102,10 @@ export default Service.extend({
// for handling touch events later if required
_onMouseDown(event) {
if (!this.isDragging && (event.button === undefined || event.button === 0)) {
this.grabbedElement = utils.getParent(event.target, constants.DRAGGABLE_DATA_ATTR);
this.grabbedElement = utils.getParent(event.target, constants.DRAGGABLE_SELECTOR);

if (this.grabbedElement) {
let containerElement = utils.getParent(this.grabbedElement, constants.CONTAINER_DATA_ATTR);
let containerElement = utils.getParent(this.grabbedElement, constants.CONTAINER_SELECTOR);
let container = this.containers.findBy('element', containerElement);
this.sourceContainer = container;

Expand All @@ -112,6 +116,7 @@ export default Service.extend({
// set up the drag details
this._initiateDrag(event);
// add watches to follow the drag/drop
// TODO: move to _initiateDrag
this._addMoveListeners();
this._addReleaseListeners();
this._addKeyDownListeners();
Expand Down Expand Up @@ -234,7 +239,7 @@ export default Service.extend({
// create the ghost element and cache it's position so avoid costly
// getBoundingClientRect calls in the mousemove handler
let ghostElement = container.createGhostElement(this.grabbedElement);
document.body.appendChild(ghostElement);
this._ghostContainerElement.appendChild(ghostElement);
let ghostElementRect = ghostElement.getBoundingClientRect();
let ghostInfo = {
element: ghostElement,
Expand All @@ -246,6 +251,9 @@ export default Service.extend({
// start ghost element following the mouse
requestAnimationFrame(this._rafUpdateGhostElementPosition);

// let the scroll handler select the scrollable element
this.scrollHandler.dragStart(this.draggableInfo);

this._handleDrag();
},

Expand All @@ -257,10 +265,13 @@ export default Service.extend({
this.draggableInfo.mousePosition.x,
this.draggableInfo.mousePosition.y
);
this.draggableInfo.target = target;
this.ghostInfo.element.hidden = false;

let overContainerElem = utils.getParent(target, constants.CONTAINER_DATA_ATTR);
let overDroppableElem = utils.getParent(target, constants.DROPPABLE_DATA_ATTR);
this.scrollHandler.dragMove(this.draggableInfo);

let overContainerElem = utils.getParent(target, constants.CONTAINER_SELECTOR);
let overDroppableElem = utils.getParent(target, constants.DROPPABLE_SELECTOR);

let isLeavingContainer = this._currentOverContainerElem && overContainerElem !== this._currentOverContainerElem;
let isLeavingDroppable = this._currentOverDroppableElem && overDroppableElem !== this._currentOverDroppableElem;
Expand Down Expand Up @@ -375,10 +386,11 @@ export default Service.extend({
// account for indicator width
leftAdjustment -= 2;

let dropIndicatorParentRect = dropIndicator.parentNode.getBoundingClientRect();
let lastLeft = parseInt(dropIndicator.style.left);
let lastTop = parseInt(dropIndicator.style.top);
let newLeft = offsetLeft + leftAdjustment;
let newTop = offsetTop;
let newLeft = offsetLeft + leftAdjustment - dropIndicatorParentRect.left;
let newTop = offsetTop - dropIndicatorParentRect.top;
let newHeight = droppable.offsetHeight;

// if indicator hasn't moved, keep it showing, otherwise wait for
Expand Down Expand Up @@ -433,6 +445,8 @@ export default Service.extend({
this._removeMoveListeners();
this._removeReleaseListeners();

this.scrollHandler.dragStop();

this.grabbedElement.style.opacity = '';

this.set('isDragging', false);
Expand Down Expand Up @@ -464,17 +478,39 @@ export default Service.extend({
dropIndicator.style.zIndex = constants.DROP_INDICATOR_ZINDEX;
dropIndicator.style.pointerEvents = 'none';

document.body.appendChild(dropIndicator);
// TODO: the scrollableElement should probably be configurable, it
// may need to be set on a per-container basis in case there are
// scrollable containers within a card
let scrollableElement = document.querySelector('.koenig-editor')
|| utils.getDocumentScrollingElement();
scrollableElement.appendChild(dropIndicator);
}
this._dropIndicator = dropIndicator;
},

_appendGhostContainerElement() {
if (!this._ghostContainerElement) {
let ghostContainerElement = document.createElement('div');
ghostContainerElement.id = constants.GHOST_CONTAINER_ID;
ghostContainerElement.style.position = 'fixed';
ghostContainerElement.style.width = '100%';
document.body.appendChild(ghostContainerElement);
this._ghostContainerElement = ghostContainerElement;
}
},

_removeDropIndicator() {
if (this._dropIndicator) {
this._dropIndicator.remove();
}
},

_removeGhostContainerElement() {
if (this.ghostContainerElement) {
this.ghostContainerElement.remove();
}
},

_addGrabListeners() {
this._addEventListener('mousedown', this._onMouseDown, {passive: false});
},
Expand Down

0 comments on commit f51c9dd

Please sign in to comment.