From f51c9dd5bb808f71477886e4a99c95c55cb47ef5 Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Thu, 13 Dec 2018 12:17:43 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Added=20auto-scroll=20when=20draggi?= =?UTF-8?q?ng=20gallery=20images=20(#1083)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/koenig-editor/addon/lib/dnd/constants.js | 15 +++ .../addon/lib/dnd/scroll-handler.js | 112 ++++++++++++++++++ lib/koenig-editor/addon/lib/dnd/utils.js | 82 +++++++++++-- .../services/koenig-drag-drop-handler.js | 56 +++++++-- 4 files changed, 243 insertions(+), 22 deletions(-) create mode 100644 lib/koenig-editor/addon/lib/dnd/scroll-handler.js diff --git a/lib/koenig-editor/addon/lib/dnd/constants.js b/lib/koenig-editor/addon/lib/dnd/constants.js index 71884783a2..0709e738ca 100644 --- a/lib/koenig-editor/addon/lib/dnd/constants.js +++ b/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; diff --git a/lib/koenig-editor/addon/lib/dnd/scroll-handler.js b/lib/koenig-editor/addon/lib/dnd/scroll-handler.js new file mode 100644 index 0000000000..a6d660b257 --- /dev/null +++ b/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); + } +} diff --git a/lib/koenig-editor/addon/lib/dnd/utils.js b/lib/koenig-editor/addon/lib/dnd/utils.js index 4966c6a538..b9d9597aa2 100644 --- a/lib/koenig-editor/addon/lib/dnd/utils.js +++ b/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) { @@ -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); +} diff --git a/lib/koenig-editor/addon/services/koenig-drag-drop-handler.js b/lib/koenig-editor/addon/services/koenig-drag-drop-handler.js index 9d922a1b06..79765a150b 100644 --- a/lib/koenig-editor/addon/services/koenig-drag-drop-handler.js +++ b/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'; @@ -29,6 +30,7 @@ export default Service.extend({ this._super(...arguments); this.containers = A([]); + this.scrollHandler = new ScrollHandler(); this._eventHandlers = {}; this._transformedDroppables = A([]); @@ -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() { @@ -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 --------------------------------------------------------------- @@ -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; @@ -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(); @@ -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, @@ -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(); }, @@ -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; @@ -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 @@ -433,6 +445,8 @@ export default Service.extend({ this._removeMoveListeners(); this._removeReleaseListeners(); + this.scrollHandler.dragStop(); + this.grabbedElement.style.opacity = ''; this.set('isDragging', false); @@ -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}); },