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:
parent
1946c7f3fa
commit
5647459a2b
7 changed files with 925 additions and 25 deletions
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
|
6
ghost/admin/lib/koenig-editor/addon/lib/dnd/constants.js
Normal file
6
ghost/admin/lib/koenig-editor/addon/lib/dnd/constants.js
Normal 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;
|
126
ghost/admin/lib/koenig-editor/addon/lib/dnd/container.js
Normal file
126
ghost/admin/lib/koenig-editor/addon/lib/dnd/container.js
Normal 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;
|
26
ghost/admin/lib/koenig-editor/addon/lib/dnd/utils.js
Normal file
26
ghost/admin/lib/koenig-editor/addon/lib/dnd/utils.js
Normal 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;
|
||||
}
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
});
|
|
@ -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>
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
export {default} from 'koenig-editor/services/koenig-drag-drop-handler';
|
Loading…
Add table
Reference in a new issue