0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-02-24 23:48:13 -05:00

Added drag-n-drop re-ordering of images within gallery cards (#1073)

no issue
- first pass at implementation of drag-and-drop re-ordering of images within a gallery
- adds a `koenig-drag-drop-handler` service that allows consumers (editor and cards) to hook into drag and drop behaviour without interfering with each other and allows for future possibilities such as dragging images between galleries or into/out of galleries
This commit is contained in:
Kevin Ansfield 2018-12-11 10:05:59 +00:00 committed by GitHub
parent 1946c7f3fa
commit 5647459a2b
7 changed files with 925 additions and 25 deletions

View file

@ -10,12 +10,16 @@ import {
import {htmlSafe} from '@ember/string';
import {isEmpty} from '@ember/utils';
import {run} from '@ember/runloop';
import {inject as service} from '@ember/service';
const MAX_IMAGES = 9;
const MAX_PER_ROW = 3;
export default Component.extend({
koenigDragDropHandler: service(),
layout,
// attrs
files: null,
images: null,
@ -29,6 +33,8 @@ export default Component.extend({
errorMessage: null,
handlesDragDrop: true,
_dragDropContainer: null,
// closure actions
selectCard() { },
deselectCard() { },
@ -44,10 +50,6 @@ export default Component.extend({
let wordCount = 0;
let imageCount = this.payload.images.length;
if (this.payload.src) {
imageCount += 1;
}
if (this.payload.caption) {
wordCount += countWords(stripTags(this.payload.caption));
}
@ -75,18 +77,32 @@ export default Component.extend({
imageRows: computed('images.@each.{src,previewSrc,width,height,row}', function () {
let rows = [];
let noOfImages = this.images.length;
// 3 images per row unless last row would have a single image in which
// case the last 2 rows will have 2 images
let maxImagesInRow = function (idx) {
return noOfImages > 1 && (noOfImages % 3 === 1) && (idx === (noOfImages - 2));
};
this.images.forEach((image, idx) => {
let row = image.row;
let classes = ['relative', 'hide-child'];
if (noOfImages > 1 && (noOfImages % 3 === 1) && (idx === (noOfImages - 2))) {
// start a new display row if necessary
if (maxImagesInRow(idx)) {
row = row + 1;
}
// apply classes to the image containers
if (!rows[row]) {
// first image in row
rows[row] = [];
classes.push('mr2');
} else if (((idx + 1) % 3 === 0) || maxImagesInRow(idx + 1) || idx + 1 === noOfImages) {
// last image in row
classes.push('ml2');
} else {
classes.push('ml4');
// middle of row
classes.push('ml2', 'mr2');
}
if (row > 0) {
@ -116,6 +132,14 @@ export default Component.extend({
this.registerComponent(this);
},
willDestroyElement() {
this._super(...arguments);
if (this._dragDropContainer) {
this._dragDropContainer.destroy();
}
},
actions: {
addImage(file) {
let count = this.images.length + 1;
@ -141,10 +165,7 @@ export default Component.extend({
deleteImage(image) {
let localImage = this.images.findBy('fileName', image.fileName);
this.images.removeObject(localImage);
this.images.forEach((image, idx) => {
image.set('row', Math.ceil((idx + 1) / MAX_PER_ROW) - 1);
});
this._recalculateImageRows();
this._buildAndSaveImagesPayload();
},
@ -152,12 +173,6 @@ export default Component.extend({
this._updatePayloadAttr('caption', caption);
},
/**
* Opens a file selection dialog - Triggered by "Upload Image" buttons,
* searches for the hidden file input within the .gh-setting element
* containing the clicked button then simulates a click
* @param {MouseEvent} event - MouseEvent fired by the button click
*/
triggerFileDialog(event) {
this._triggerFileDialog(event);
},
@ -177,6 +192,25 @@ export default Component.extend({
clearErrorMessage() {
this.set('errorMessage', null);
},
didSelect() {
if (this._dragDropContainer) {
// add a delay when enabling reorder drag/drop so that the card
// must be selected before a reorder drag can be initiated
// - allows for cards to be drag and dropped themselves
run.later(this, function () {
if (!this.isDestroyed && !this.isDestroying) {
this._dragDropContainer.enableDrag();
}
}, 100);
}
},
didDeselect() {
if (this._dragDropContainer) {
this._dragDropContainer.disableDrag();
}
}
},
@ -216,6 +250,12 @@ export default Component.extend({
// Private methods ---------------------------------------------------------
_recalculateImageRows() {
this.images.forEach((image, idx) => {
image.set('row', Math.ceil((idx + 1) / MAX_PER_ROW) - 1);
});
},
_startUpload(files = []) {
let currentCount = this.images.length;
let allowedCount = (MAX_IMAGES - currentCount);
@ -270,6 +310,7 @@ export default Component.extend({
_buildImages() {
this.images = this.payload.images.map(image => EmberObject.create(image));
this._registerOrRefreshDragDropHandler();
},
_updatePayloadAttr(attr, value) {
@ -280,6 +321,8 @@ export default Component.extend({
// update the mobiledoc and stay in edit mode
save(payload, false);
this._registerOrRefreshDragDropHandler();
},
_triggerFileDialog(event) {
@ -291,5 +334,168 @@ export default Component.extend({
.closest('.__mobiledoc-card')
.find('input[type="file"]')
.click();
},
// TODO: revisit when the container is created and when drag is enabled/disabled
// - rename container so that it's more explicit when we have an initial file
// drop container vs a drag reorder+file drop container?
_registerOrRefreshDragDropHandler() {
if (this._dragDropContainer) {
run.schedule('afterRender', this, function () {
this._dragDropContainer.refresh();
if (!isEmpty(this.images) && !this._dragDropContainer.isDragEnabled) {
this._dragDropContainer.enableDrag();
}
});
} else {
run.schedule('afterRender', this, function () {
let galleryElem = this.element.querySelector('[data-gallery]');
if (galleryElem) {
this._dragDropContainer = this.koenigDragDropHandler.registerContainer(
galleryElem,
{
draggableSelector: '[data-image]',
droppableSelector: '[data-image]',
isDragEnabled: !isEmpty(this.images),
onDragStart: run.bind(this, function () {
// TODO: can this be handled in koenig-card?
// not currently done so because kg-card-hover is added as a base class
// by (kg-style "media-card")
this.element.querySelector('figure').classList.remove('kg-card-hover');
this.element.querySelector('figure').classList.remove('kg-card-selected');
}),
onDragEnd: run.bind(this, function () {
this.element.querySelector('figure').classList.add('kg-card-hover');
if (this.isSelected) {
this.element.querySelector('figure').classList.add('kg-card-selected');
}
}),
getDraggableInfo: run.bind(this, this._getDraggableInfo),
getIndicatorPosition: run.bind(this, this._getDropIndicatorPosition),
onDrop: run.bind(this, this._onDrop)
}
);
}
});
}
},
_getDraggableInfo(draggableElement) {
let src = draggableElement.querySelector('img').getAttribute('src');
let image = this.images.findBy('src', src) || this.images.findBy('previewSrc', src);
let payload = image && image.getProperties('fileName', 'src', 'row', 'width', 'height');
if (image) {
return {
type: 'image',
payload
};
}
return {};
},
_onDrop(draggableInfo/*, droppableElem, position*/) {
// do not allow dragging between galleries for now
if (!this.element.contains(draggableInfo.element)) {
return false;
}
let droppables = Array.from(this.element.querySelectorAll('[data-image]'));
let draggableIndex = droppables.indexOf(draggableInfo.element);
if (this._isDropAllowed(draggableIndex, draggableInfo.insertIndex)) {
let draggedImage = this.images.findBy('src', draggableInfo.payload.src);
this.images.removeObject(draggedImage);
this.images.insertAt(draggableInfo.insertIndex, draggedImage);
this._recalculateImageRows();
this._buildAndSaveImagesPayload();
this._dragDropContainer.refresh();
}
},
// returns {
// direction: 'horizontal' TODO: use a constant?
// position: 'left'/'right' TODO: use constants?
// beforeElems: array of elems to left of indicator
// afterElems: array of elems to right of indicator
// droppableIndex:
// }
_getDropIndicatorPosition(draggableInfo, droppableElem, position) {
// do not allow dragging between galleries for now
if (!this.element.contains(draggableInfo.element)) {
return false;
}
let row = droppableElem.closest('[data-row]');
let droppables = Array.from(this.element.querySelectorAll('[data-image]'));
let draggableIndex = droppables.indexOf(draggableInfo.element);
let droppableIndex = droppables.indexOf(droppableElem);
if (row && this._isDropAllowed(draggableIndex, droppableIndex, position)) {
let rowImages = Array.from(row.querySelectorAll('[data-image]'));
let rowDroppableIndex = rowImages.indexOf(droppableElem);
let insertIndex = droppableIndex;
let beforeElems = [];
let afterElems = [];
rowImages.forEach((image, index) => {
if (index < rowDroppableIndex) {
beforeElems.push(image);
}
if (index === rowDroppableIndex) {
if (position.match(/left/)) {
afterElems.push(image);
} else {
beforeElems.push(image);
}
}
if (index > rowDroppableIndex) {
afterElems.push(image);
}
});
if (position.match(/right/) && draggableIndex > insertIndex) {
insertIndex += 1;
}
if (insertIndex >= this.images.length - 1) {
insertIndex = this.images.length - 1;
}
return {
direction: 'horizontal',
position: position.match(/left/) ? 'left' : 'right',
beforeElems,
afterElems,
insertIndex
};
} else {
return false;
}
},
// we don't allow an image to be dropped where it would end up in the
// same position within the gallery
_isDropAllowed(draggableIndex, droppableIndex, position = '') {
// can't drop on itself
if (draggableIndex === droppableIndex) {
return false;
}
// account for dropping at beginning or end of a row
if (position.match(/left/)) {
droppableIndex -= 1;
}
if (position.match(/right/)) {
droppableIndex += 1;
}
return droppableIndex !== draggableIndex;
}
});

View file

@ -0,0 +1,6 @@
export const CONTAINER_DATA_ATTR = 'koenigDndContainer';
export const DRAGGABLE_DATA_ATTR = 'koenigDndDraggable';
export const DROPPABLE_DATA_ATTR = 'koenigDndDroppable';
export const DROP_INDICATOR_ID = 'koenig-drag-drop-indicator';
export const DROP_INDICATOR_ZINDEX = 10000;
export const GHOST_ZINDEX = DROP_INDICATOR_ZINDEX + 1;

View file

@ -0,0 +1,126 @@
import * as constants from './constants';
import {A} from '@ember/array';
// Container represents an element, inside which are draggables and/or droppables.
//
// Containers handle events triggered by the koenig-drag-drop-handler service.
// Containers can be nested, the drag-drop service will select the closest
// parent container in the DOM heirarchy when triggering events.
//
// Containers accept options which are mostly configuration for how to determine
// contained draggable/droppable elements and functions to call when events are
// processed.
class Container {
constructor(element, options) {
Object.assign(this, {
element,
draggables: A([]),
droppables: A([]),
isDragEnabled: true,
// TODO: move these to class-level functions?
onDragStart() { },
onDragEnterContainer() { },
onDragEnterDroppable() { },
onDragOverDroppable() { },
onDragLeaveDroppable() { },
onDragLeaveContainer() { },
onDrop() { },
onDragEnd() { }
}, options);
element.dataset[constants.CONTAINER_DATA_ATTR] = 'true';
this.refresh();
}
// get the draggable type and any payload. Types:
// - image
// - card
// - file
// TODO: review types
// TODO: get proper payload from the gallery component
// should be overridden by passed in option
getDraggableInfo(/*draggableElement*/) {
return false;
}
// should be overridden by passed in option
getIndicatorPosition(/*draggableInfo, droppableElem, position*/) {
return false;
}
// TODO: allow configuration for ghost element creation
// builds an element that is attached to the mouse pointer when dragging.
// currently grabs the first <img> and uses that but should be configurable:
// - a selector for which element in the draggable to copy
// - a function to hand off element creation to the consumer
createGhostElement(draggable) {
let image = draggable.querySelector('img');
if (image) {
let aspectRatio = image.width / image.height;
let width, height;
// max ghost image size is 200px in either dimension
if (image.width > image.height) {
width = 200;
height = 200 / aspectRatio;
} else {
width = 200 * aspectRatio;
height = 200;
}
let ghostElement = document.createElement('img');
ghostElement.width = width;
ghostElement.height = height;
ghostElement.id = 'koenig-drag-drop-ghost';
ghostElement.src = image.src;
ghostElement.style.position = 'absolute';
ghostElement.style.top = '0';
ghostElement.style.left = `-${width}px`;
ghostElement.style.zIndex = constants.GHOST_ZINDEX;
ghostElement.style.willChange = 'transform';
return ghostElement;
} else {
// eslint-disable-next-line
console.warn('No <img> element found in draggable');
}
}
enableDrag() {
this.isDragEnabled = true;
}
disableDrag() {
this.isDragEnabled = false;
}
// used to add data attributes to any draggable/droppable elements. This is
// for more efficient lookup through DOM by the drag-drop-handler service
refresh() {
// remove all data attributes for currently held draggable/droppable elements
this.draggables.forEach((draggable) => {
delete draggable.dataset[constants.DRAGGABLE_DATA_ATTR];
});
this.droppables.forEach((droppable) => {
delete droppable.dataset[constants.DROPPABLE_DATA_ATTR];
});
// re-populate draggable/droppable arrays
this.draggables = A([]);
this.element.querySelectorAll(this.draggableSelector).forEach((draggable) => {
draggable.dataset[constants.DRAGGABLE_DATA_ATTR] = 'true';
this.draggables.push(draggable);
});
this.droppables = A([]);
this.element.querySelectorAll(this.droppableSelector).forEach((droppable) => {
droppable.dataset[constants.DROPPABLE_DATA_ATTR] = 'true';
this.droppables.push(droppable);
});
}
}
export default Container;

View file

@ -0,0 +1,26 @@
// 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) {
let current = element;
while (current) {
if (current.dataset[dataAttribute]) {
return current;
}
current = current.parentElement;
}
return null;
}
export function applyUserSelect(element, value) {
element.style.webkitUserSelect = value;
element.style.mozUserSelect = value;
element.style.msUserSelect = value;
element.style.oUserSelect = value;
element.style.userSelect = value;
}

View file

@ -0,0 +1,520 @@
import * as constants from '../lib/dnd/constants';
import * as utils from '../lib/dnd/utils';
import Container from '../lib/dnd/container';
import Service from '@ember/service';
import {A} from '@ember/array';
import {didCancel, task, waitForProperty} from 'ember-concurrency';
import {run} from '@ember/runloop';
// this service allows registration of "containers"
// containers can have both draggables and droppables
// - draggables are elements that can be dragged
// - droppables are elements that should respond if dragged over
// containers will handle the drag start/drag over/drop events triggered by this service
// this service keeps track of all containers and has centralized event handling for mouse events
export default Service.extend({
containers: null,
ghostInfo: null,
grabbedElement: null, // TODO: standardise on draggableInfo.element
isDragging: false,
sourceContainer: null,
_eventHandlers: null,
// lifecycle ---------------------------------------------------------------
init() {
this._super(...arguments);
this.containers = A([]);
this._eventHandlers = {};
this._transformedDroppables = A([]);
// bind any raf handler functions
this._rafUpdateGhostElementPosition = run.bind(this, this._updateGhostElementPosition);
// set up document event listeners
this._addGrabListeners();
// append drop indicator element
this._appendDropIndicator();
},
willDestroy() {
this._super(...arguments);
// reset any on-going drag and remove any temporary listeners
this.cleanup();
// clean up document event listeners
this._removeGrabListeners();
// remove drop indicator element
this._removeDropIndicator();
},
// interface ---------------------------------------------------------------
registerContainer(element, options) {
let container = new Container(element, options);
this.containers.pushObject(container);
// return a minimal interface to the container because this service
// should be used for management rather than the container class instance
return {
enableDrag: () => {
container.enableDrag();
},
disableDrag: () => {
container.disableDrag();
},
refresh: () => {
// re-calculate draggables/droppables
container.refresh();
},
destroy: () => {
// unregister container
this.containers.removeObject(container);
}
};
},
// remove all containers and event handlers, useful when leaving an editor route
cleanup() {
this.containers = A([]);
// cancel any tasks and remove intermittent event handlers
this._resetDrag();
},
// event handlers ----------------------------------------------------------
// we use a custom "drag" detection rather than native drag events because it
// allows better tracking across multiple containers and gives more flexibilty
// 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);
if (this.grabbedElement) {
let containerElement = utils.getParent(this.grabbedElement, constants.CONTAINER_DATA_ATTR);
let container = this.containers.findBy('element', containerElement);
this.sourceContainer = container;
if (container.isDragEnabled) {
this._waitForDragStart.perform(event).then(() => {
// stop the drag creating a selection
window.getSelection().removeAllRanges();
// set up the drag details
this._initiateDrag(event);
// add watches to follow the drag/drop
this._addMoveListeners();
this._addReleaseListeners();
this._addKeyDownListeners();
}).catch((error) => {
// ignore cancelled tasks and throw unrecognized errors
if (!didCancel(error)) {
throw error;
}
});
}
}
}
},
_onMouseMove(event) {
event.preventDefault();
if (this.draggableInfo) {
this.draggableInfo.mousePosition.x = event.clientX;
this.draggableInfo.mousePosition.y = event.clientY;
this._handleDrag(event);
}
},
_onMouseUp(/*event*/) {
if (this.draggableInfo) {
// TODO: accept object rather than positioned args? OR, should the
// droppable data be stored on draggableInfo?
if (this._currentOverContainer) {
this._currentOverContainer.onDrop(
this.draggableInfo,
this._currentOverDroppableElem,
this._currentOverDroppablePosition
);
}
}
// remove drag info and any ghost element
this._resetDrag();
},
_onKeyDown(event) {
// cancel drag on escape
if (this.isDragging && event.key === 'Escape') {
this._resetDrag();
}
},
// private -----------------------------------------------------------------
// called when we detect a mousedown event on a draggable element. Sets
// up temporary event handlers for mousemove, mouseup, and drag. If
// sufficient movement is detected before the mouse is released and we don't
// detect a native drag event then the task will resolve. Mouseup or drag
// events will cancel the task which will result in a rejected promise if
// the task has been cast to a promise
_waitForDragStart: task(function* (startEvent) {
let moveThreshold = 1;
this.set('_dragStartConditionsMet', false);
let onMove = (event) => {
let {clientX: currentX, clientY: currentY} = event;
if (
Math.abs(startEvent.clientX - currentX) > moveThreshold ||
Math.abs(startEvent.clientY - currentY) > moveThreshold
) {
this.set('_dragStartConditionsMet', true);
}
};
let onUp = () => {
this._waitForDragStart.cancelAll();
};
// give preference to native drag/drop handlers
let onHtmlDrag = () => {
this._waitForDragStart.cancelAll();
};
// register local events
document.addEventListener('mousemove', onMove, {passive: false});
document.addEventListener('mouseup', onUp, {passive: false});
document.addEventListener('drag', onHtmlDrag, {passive: false});
try {
yield waitForProperty(this, '_dragStartConditionsMet');
} finally {
// finally is always called on task cancellation
this.set('_dragStartConditionsMet', false);
document.removeEventListener('mousemove', onMove, {passive: false});
document.removeEventListener('mouseup', onUp, {passive: false});
document.removeEventListener('drag', onHtmlDrag, {passive: false});
}
}).keepLatest(),
_initiateDrag(startEvent) {
this.set('isDragging', true);
utils.applyUserSelect(document.body, 'none');
let container = this.sourceContainer;
let draggableInfo = Object.assign({}, container.getDraggableInfo(this.grabbedElement), {
element: this.grabbedElement,
mousePosition: {
x: startEvent.clientX,
y: startEvent.clientY
}
});
this.set('draggableInfo', draggableInfo);
this.containers.forEach((container) => {
container.onDragStart(draggableInfo);
});
// style the dragged element
this.draggableInfo.element.style.opacity = 0.5;
// 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);
let ghostElementRect = ghostElement.getBoundingClientRect();
let ghostInfo = {
element: ghostElement,
positionX: ghostElementRect.x,
positionY: ghostElementRect.y
};
this.set('ghostInfo', ghostInfo);
// start ghost element following the mouse
requestAnimationFrame(this._rafUpdateGhostElementPosition);
this._handleDrag();
},
_handleDrag() {
// hide the ghost element so that it's not picked up by elementFromPoint
// when determining the target element under the mouse
this.ghostInfo.element.hidden = true;
let target = document.elementFromPoint(
this.draggableInfo.mousePosition.x,
this.draggableInfo.mousePosition.y
);
this.ghostInfo.element.hidden = false;
let overContainerElem = utils.getParent(target, constants.CONTAINER_DATA_ATTR);
let overDroppableElem = utils.getParent(target, constants.DROPPABLE_DATA_ATTR);
let isLeavingContainer = this._currentOverContainerElem && overContainerElem !== this._currentOverContainerElem;
let isLeavingDroppable = this._currentOverDroppableElem && overDroppableElem !== this._currentOverDroppableElem;
let isOverContainer = overContainerElem && overContainerElem !== this._currentOverContainer;
let isOverDroppable = overDroppableElem;
if (isLeavingContainer) {
this._currentOverContainer.onDragLeaveContainer();
this._currentOverContainer = null;
this._currentOverContainerElem = null;
this._hideDropIndicator();
}
if (isOverContainer) {
let container = this.containers.findBy('element', overContainerElem);
if (!this._currentOverContainer) {
container.onDragEnterContainer();
}
this._currentOverContainer = container;
this._currentOverContainerElem = overContainerElem;
}
if (isLeavingDroppable) {
if (this._currentOverContainer) {
this._currentOverContainer.onDragLeaveDroppable(overDroppableElem);
}
this._currentOverDroppableElem = null;
this._currentOverDroppablePosition = null;
}
if (isOverDroppable) {
// get position within the droppable
// TODO: cache droppable rects to avoid costly queries whilst dragging
let rect = overDroppableElem.getBoundingClientRect();
let inTop = this.draggableInfo.mousePosition.y < (rect.y + rect.height / 2);
let inLeft = this.draggableInfo.mousePosition.x < (rect.x + rect.width / 2);
let position = `${inTop ? 'top' : 'bottom'}-${inLeft ? 'left' : 'right'}`;
if (!this._currentOverDroppableElem) {
this._currentOverContainer.onDragEnterDroppable(overDroppableElem, position);
}
if (overDroppableElem !== this._currentOverDroppableElem || position !== this._currentOverDroppablePosition) {
this._currentOverDroppableElem = overDroppableElem;
this._currentOverDroppablePosition = position;
this._currentOverContainer.onDragOverDroppable(overDroppableElem, position);
// container.getIndicatorPosition returns false if the drop is not allowed
let indicatorPosition = this._currentOverContainer.getIndicatorPosition(this.draggableInfo, overDroppableElem, position);
if (indicatorPosition) {
this.draggableInfo.insertIndex = indicatorPosition.insertIndex;
this._showDropIndicator(indicatorPosition);
} else {
this._hideDropIndicator();
}
}
}
},
_updateGhostElementPosition() {
if (this.isDragging) {
requestAnimationFrame(this._rafUpdateGhostElementPosition);
}
let {ghostInfo, draggableInfo} = this;
if (draggableInfo && ghostInfo) {
let left = (ghostInfo.positionX * -1) + draggableInfo.mousePosition.x;
let top = (ghostInfo.positionY * -1) + draggableInfo.mousePosition.y;
ghostInfo.element.style.transform = `translate3d(${left}px, ${top}px, 0)`;
}
},
// direction = horizontal/vertical
// horizontal = beforeElems shift left, afterElems shift right
// vertical = afterElems shift down
// position = above/below/left/right, used to place the indicator
_showDropIndicator({direction, position, beforeElems, afterElems}) {
let dropIndicator = this._dropIndicator;
// reset everything before re-displaying indicator
this._hideDropIndicator();
if (direction === 'horizontal') {
beforeElems.forEach((elem) => {
elem.style.transform = 'translate3d(-30px, 0, 0)';
elem.style.transitionDuration = '250ms';
this._transformedDroppables.push(elem);
});
afterElems.forEach((elem) => {
elem.style.transform = 'translate3d(30px, 0, 0)';
elem.style.transitionDuration = '250ms';
this._transformedDroppables.push(elem);
});
let leftAdjustment = 0;
let droppable = this._currentOverDroppableElem;
let droppableStyles = getComputedStyle(droppable);
// calculate position based on offset parent to avoid the transform
// being accounted for
let parentRect = droppable.offsetParent.getBoundingClientRect();
let offsetLeft = parentRect.left + droppable.offsetLeft;
let offsetTop = parentRect.top + droppable.offsetTop;
if (position === 'left') {
leftAdjustment -= parseInt(droppableStyles.marginLeft);
} else {
leftAdjustment += parseInt(droppable.offsetWidth) + parseInt(droppableStyles.marginRight);
}
// account for indicator width
leftAdjustment -= 2;
let lastLeft = parseInt(dropIndicator.style.left);
let lastTop = parseInt(dropIndicator.style.top);
let newLeft = offsetLeft + leftAdjustment;
let newTop = offsetTop;
let newHeight = droppable.offsetHeight;
// if indicator hasn't moved, keep it showing, otherwise wait for
// the transform transitions to almost finish before re-positioning
// and showing
// NOTE: +- 1px is due to sub-pixel positioning of droppables
if (
newTop >= lastTop - 1 && newTop <= lastTop + 1 &&
newLeft >= lastLeft - 1 && newLeft <= lastLeft + 1
) {
dropIndicator.style.opacity = 1;
} else {
dropIndicator.style.opacity = 0;
this._dropIndicatorTimeout = run.later(this, function () {
dropIndicator.style.height = `${newHeight}px`;
dropIndicator.style.left = `${newLeft}px`;
dropIndicator.style.top = `${newTop}px`;
dropIndicator.style.opacity = 1;
}, 150);
}
}
// TODO: handle vertical drag/drop
},
_hideDropIndicator() {
// make sure the indicator isn't shown due to a running timeout
run.cancel(this._dropIndicatorTimeout);
// reset all transforms
this._transformedDroppables.forEach((elem) => {
elem.style.transform = '';
});
this.transformedDroppables = A([]);
// hide drop indicator
if (this._dropIndicator) {
this._dropIndicator.style.opacity = 0;
}
},
_resetDrag() {
this._waitForDragStart.cancelAll();
this._hideDropIndicator();
this._removeMoveListeners();
this._removeReleaseListeners();
this.grabbedElement.style.opacity = '';
this.set('isDragging', false);
this.set('grabbedElement', null);
this.set('sourceContainer', null);
if (this.ghostInfo) {
this.ghostInfo.element.remove();
this.set('ghostInfo', null);
}
this.containers.forEach((container) => {
container.onDragEnd();
});
utils.applyUserSelect(document.body, '');
},
_appendDropIndicator() {
let dropIndicator = document.querySelector(`#${constants.DROP_INDICATOR_ID}`);
if (!dropIndicator) {
dropIndicator = document.createElement('div');
dropIndicator.id = constants.DROP_INDICATOR_ID;
dropIndicator.classList.add('bg-blue');
dropIndicator.style.position = 'absolute';
dropIndicator.style.opacity = 0;
dropIndicator.style.width = '4px';
dropIndicator.style.height = 0;
dropIndicator.style.zIndex = constants.DROP_INDICATOR_ZINDEX;
dropIndicator.style.pointerEvents = 'none';
document.body.appendChild(dropIndicator);
}
this._dropIndicator = dropIndicator;
},
_removeDropIndicator() {
if (this._dropIndicator) {
this._dropIndicator.remove();
}
},
_addGrabListeners() {
this._addEventListener('mousedown', this._onMouseDown, {passive: false});
},
_removeGrabListeners() {
this._removeEventListener('mousedown');
},
_addMoveListeners() {
this._addEventListener('mousemove', this._onMouseMove, {passive: false});
},
_removeMoveListeners() {
this._removeEventListener('mousemove');
},
_addReleaseListeners() {
this._addEventListener('mouseup', this._onMouseUp, {passive: false});
},
_removeReleaseListeners() {
this._removeEventListener('mouseup');
},
_addKeyDownListeners() {
this._addEventListener('keydown', this._onKeyDown);
},
_removeKeyDownListeners() {
this._removeEventListener('keydown');
},
_addEventListener(e, method, options) {
if (!this._eventHandlers[e]) {
let handler = run.bind(this, method);
this._eventHandlers[e] = {handler, options};
document.addEventListener(e, handler, options);
}
},
_removeEventListener(e) {
let event = this._eventHandlers[e];
if (event) {
document.removeEventListener(e, event.handler, event.options);
delete this._eventHandlers[e];
}
}
});

View file

@ -12,6 +12,8 @@
moveCursorToPrevSection=moveCursorToPrevSection
moveCursorToNextSection=moveCursorToNextSection
editor=editor
onSelect=(action "didSelect")
onDeselect=(action "didDeselect")
as |card|
}}
{{#gh-uploader
@ -26,17 +28,30 @@
}}
<div class="relative{{unless images " bg-whitegrey-l2"}}">
{{#if imageRows}}
<div class="flex flex-column">
{{#each imageRows as |row|}}
<div class="flex flex-row justify-center">
<div class="flex flex-column" data-gallery>
{{#each imageRows as |row index|}}
<div class="flex flex-row justify-center" data-row="{{index}}">
{{#each row as |image|}}
<div style={{image.style}} class={{image.classes}}>
<img src={{or image.previewSrc image.src}} width={{image.width}} height={{image.height}} class="w-100 h-100 db">
<div class="bg-image-overlay-top child">
<div
style={{image.style}}
class={{image.classes}}
data-image
>
<img
src={{or image.previewSrc image.src}}
width={{image.width}}
height={{image.height}}
class="w-100 h-100 db pe-none"
>
{{#unless koenigDragDropHandler.isDragging}}
<div class="bg-image-overlay-top child pe-none">
<div class="flex flex-row-reverse">
<button class="bg-white-90 pl3 pr3 br3" {{action "deleteImage" image}}>{{svg-jar "koenig/kg-trash" class="fill-darkgrey w4 h4"}}</button>
<button class="bg-white-90 pl3 pr3 br3 pe-auto" {{action "deleteImage" image}}>
{{svg-jar "koenig/kg-trash" class="fill-darkgrey w4 h4"}}
</button>
</div>
</div>
{{/unless}}
</div>
{{/each}}
</div>

View file

@ -0,0 +1 @@
export {default} from 'koenig-editor/services/koenig-drag-drop-handler';