From 526c94d954551807834443708502c3c216602ffc Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Thu, 27 Jun 2019 16:34:23 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Added=20ability=20to=20drag=20image?= =?UTF-8?q?s=20in=20and=20out=20of=20galleries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no issue - adjust drag handlers in the editor and gallery card to handle drag/drop of image cards as well as straight images - adjust drag handlers in the gallery card to handle image inserts as well as re-orders - add `onDragEnd` event/action to the Koenig drag-n-drop handler so that containers can perform cleanup if one of their draggables was successfully dropped into a different container - change ghost element when dragging an image card to be an image rather than a card icon - allow `createGhostElement` function passed in when registering a drag-n-drop container to fall back to the default behaviour by returning a falsy value --- .../addon/components/koenig-card-gallery.js | 98 +++++++++++++++---- .../addon/components/koenig-editor.js | 86 +++++++++++----- .../koenig-editor/addon/lib/dnd/container.js | 24 ++++- .../services/koenig-drag-drop-handler.js | 8 +- 4 files changed, 168 insertions(+), 48 deletions(-) diff --git a/ghost/admin/lib/koenig-editor/addon/components/koenig-card-gallery.js b/ghost/admin/lib/koenig-editor/addon/components/koenig-card-gallery.js index cd0537b384..ce1a65d254 100644 --- a/ghost/admin/lib/koenig-editor/addon/components/koenig-card-gallery.js +++ b/ghost/admin/lib/koenig-editor/addon/components/koenig-card-gallery.js @@ -360,17 +360,12 @@ export default Component.extend({ draggableSelector: '[data-image]', droppableSelector: '[data-image]', isDragEnabled: !isEmpty(this.images), - onDragStart: run.bind(this, function () { - this.element.querySelector('figure').classList.remove('kg-card-selected'); - }), - onDragEnd: run.bind(this, function () { - if (this.isSelected) { - this.element.querySelector('figure').classList.add('kg-card-selected'); - } - }), + onDragStart: run.bind(this, this._dragStart), + onDragEnd: run.bind(this, this._dragEnd), getDraggableInfo: run.bind(this, this._getDraggableInfo), getIndicatorPosition: run.bind(this, this._getDropIndicatorPosition), - onDrop: run.bind(this, this._onDrop) + onDrop: run.bind(this, this._onDrop), + onDropEnd: run.bind(this, this._onDropEnd) } ); } @@ -378,10 +373,28 @@ export default Component.extend({ } }, + _dragStart(draggableInfo) { + this.element.querySelector('figure').classList.remove('kg-card-selected'); + + // enable dropping when an image is dragged in from outside of this card + let isImageDrag = draggableInfo.type === 'image' || draggableInfo.cardName === 'image'; + if (isImageDrag && draggableInfo.payload.src && this.images.length !== MAX_IMAGES) { + this._dragDropContainer.enableDrag(); + } + }, + + _dragEnd() { + if (this.isSelected) { + this.element.querySelector('figure').classList.add('kg-card-selected'); + } else { + this._dragDropContainer.disableDrag(); + } + }, + _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'); + let payload = image && image.getProperties('fileName', 'src', 'row', 'width', 'height', 'caption'); if (image) { return { @@ -393,9 +406,9 @@ export default Component.extend({ return {}; }, - _onDrop(draggableInfo/*, droppableElem, position*/) { - // do not allow dragging between galleries for now - if (!this.element.contains(draggableInfo.element)) { + _onDrop(draggableInfo, droppableElem, position) { + // do not allow dropping of non-images + if (draggableInfo.type !== 'image' && draggableInfo.cardName !== 'image') { return false; } @@ -403,12 +416,56 @@ export default Component.extend({ let draggableIndex = droppables.indexOf(draggableInfo.element); if (this._isDropAllowed(draggableIndex, draggableInfo.insertIndex)) { - let draggedImage = this.images.findBy('src', draggableInfo.payload.src); + if (draggableIndex === -1) { + // external image being added + let {payload} = draggableInfo; + let img = draggableInfo.element.querySelector(`img[src="${payload.src}"]`); + let insertIndex = draggableInfo.insertIndex; + + // insert index needs adjusting because we're not shuffling + if (position && position.match(/right/)) { + insertIndex += 1; + } + + // image card payloads may not have all of the details we need but we can fill them in + payload.width = payload.width || img.naturalWidth; + payload.height = payload.height || img.naturalHeight; + if (!payload.fileName) { + let url = new URL(img.src); + let fileName = url.pathname.match(/\/([^/]*)$/)[1]; + payload.fileName = fileName; + } + + this.images.insertAt(insertIndex, EmberObject.create(payload)); + } else { + // internal image being re-ordered + let draggedImage = this.images.findBy('src', draggableInfo.payload.src); + this.images.removeObject(draggedImage); + this.images.insertAt(draggableInfo.insertIndex, draggedImage); + } - this.images.removeObject(draggedImage); - this.images.insertAt(draggableInfo.insertIndex, draggedImage); this._recalculateImageRows(); + this._buildAndSaveImagesPayload(); + this._dragDropContainer.refresh(); + this._skipOnDragEnd = true; + return true; + } + + return false; + }, + + // if an image is dragged out of a gallery we need to remove it + _onDropEnd(draggableInfo, success) { + if (this._skipOnDragEnd || !success) { + this._skipOnDragEnd = false; + return; + } + + let image = this.images.findBy('src', draggableInfo.payload.src); + if (image) { + this.images.removeObject(image); + this._recalculateImageRows(); this._buildAndSaveImagesPayload(); this._dragDropContainer.refresh(); } @@ -422,8 +479,8 @@ export default Component.extend({ // droppableIndex: // } _getDropIndicatorPosition(draggableInfo, droppableElem, position) { - // do not allow dragging between galleries for now - if (!this.element.contains(draggableInfo.element)) { + // do not allow dropping of non-images + if (draggableInfo.type !== 'image' && draggableInfo.cardName !== 'image') { return false; } @@ -480,6 +537,11 @@ export default Component.extend({ // 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 = '') { + // external images can always be dropped + if (draggableIndex === -1) { + return true; + } + // can't drop on itself or when droppableIndex doesn't exist if (draggableIndex === droppableIndex || typeof droppableIndex === 'undefined') { return false; diff --git a/ghost/admin/lib/koenig-editor/addon/components/koenig-editor.js b/ghost/admin/lib/koenig-editor/addon/components/koenig-editor.js index 9a922cc2ba..9103f81984 100644 --- a/ghost/admin/lib/koenig-editor/addon/components/koenig-editor.js +++ b/ghost/admin/lib/koenig-editor/addon/components/koenig-editor.js @@ -1286,18 +1286,21 @@ export default Component.extend({ let cardDragDropContainer = this.koenigDragDropHandler.registerContainer(this.editor.element, { draggableSelector: ':scope > div', // cards droppableSelector: ':scope > *', // all block elements - onDragStart: run.bind(this, function () { - this._cardDragDropContainer.refresh(); - }), + onDragStart: run.bind(this, this._onDragStart), getDraggableInfo: run.bind(this, this._getDraggableInfo), createGhostElement: run.bind(this, this._createCardDragElement), getIndicatorPosition: run.bind(this, this._getDropIndicatorPosition), - onDrop: run.bind(this, this._onCardDrop) + onDrop: run.bind(this, this._onCardDrop), + onDropEnd: run.bind(this, this._onDropEnd) }); this._cardDragDropContainer = cardDragDropContainer; }, + _onDragStart() { + this._cardDragDropContainer.refresh(); + }, + _getDraggableInfo(draggableElement) { let card = this.getCardFromElement(draggableElement); @@ -1318,7 +1321,7 @@ export default Component.extend({ _createCardDragElement(draggableInfo) { let {cardName} = draggableInfo; - if (!cardName) { + if (!cardName || cardName === 'image') { return; } @@ -1345,9 +1348,8 @@ export default Component.extend({ let droppableIndex = droppables.indexOf(droppableElem); let draggableIndex = droppables.indexOf(draggableInfo.element); - // only allow card drag/drop for now so it's not possible to drag - // images out of a gallery and see drop indicators in the post content - if (draggableInfo.type !== 'card') { + // allow card and image drops (images can be dragged out of a gallery) + if (draggableInfo.type !== 'card' && draggableInfo.type !== 'image') { return false; } @@ -1379,7 +1381,7 @@ export default Component.extend({ }, _onCardDrop(draggableInfo) { - if (draggableInfo.type !== 'card') { + if (draggableInfo.type !== 'card' && draggableInfo.type !== 'image') { return false; } @@ -1387,26 +1389,46 @@ export default Component.extend({ let draggableIndex = droppables.indexOf(draggableInfo.element); if (this._isCardDropAllowed(draggableIndex, draggableInfo.insertIndex)) { - let card = this.getCardFromElement(draggableInfo.element); - let cardSection = this.getSectionFromCard(card); - let difference = draggableIndex - draggableInfo.insertIndex; + if (draggableInfo.type === 'card') { + let card = this.getCardFromElement(draggableInfo.element); + let cardSection = this.getSectionFromCard(card); + let difference = draggableIndex - draggableInfo.insertIndex; - if (draggableIndex < draggableInfo.insertIndex) { - difference += 1; + if (draggableIndex < draggableInfo.insertIndex) { + difference += 1; + } + + if (difference !== 0) { + this.editor.run((postEditor) => { + do { + if (difference > 0) { + cardSection = postEditor.moveSectionUp(cardSection); + difference -= 1; + } else if (difference < 0) { + cardSection = postEditor.moveSectionDown(cardSection); + difference += 1; + } + } while (difference !== 0); + }); + } + + // make sure we don't remove the dropped card in the card->card drop handler + this._skipOnDropEnd = true; + + return true; } - if (difference !== 0) { + if (draggableInfo.type === 'image') { + // we need to create an image card from a raw image payload this.editor.run((postEditor) => { - do { - if (difference > 0) { - cardSection = postEditor.moveSectionUp(cardSection); - difference -= 1; - } else if (difference < 0) { - cardSection = postEditor.moveSectionDown(cardSection); - difference += 1; - } - } while (difference !== 0); + let imageCard = postEditor.builder.createCardSection('image', draggableInfo.payload); + let sections = this.editor.post.sections; + let droppableSection = sections.objectAt(draggableInfo.insertIndex); + postEditor.insertSectionBefore(sections, imageCard, droppableSection); + postEditor.setRange(imageCard.tailPosition()); }); + + return true; } } }, @@ -1414,6 +1436,11 @@ export default Component.extend({ // TODO: more or less duplicated in koenig-card-gallery other than direction // - move to DnD container? _isCardDropAllowed(draggableIndex, droppableIndex, position = '') { + // images can be dragged out of a gallery to any position + if (draggableIndex === -1) { + return true; + } + // can't drop on itself or when droppableIndex doesn't exist if (draggableIndex === droppableIndex || typeof droppableIndex === 'undefined') { return false; @@ -1431,6 +1458,17 @@ export default Component.extend({ return droppableIndex !== draggableIndex; }, + // a card can be dropped into another card which means we need to remove the original + _onDropEnd(draggableInfo, success) { + if (this._skipOnDropEnd || !success || draggableInfo.type !== 'card') { + this._skipOnDropEnd = false; + return; + } + + let card = this.getCardFromElement(draggableInfo.element); + this.deleteCard(card, NO_CURSOR_MOVEMENT); + }, + // calculate the number of words in rich-text sections and query cards for // their own word and image counts. Image counts are used for reading-time _calculateWordCount() { diff --git a/ghost/admin/lib/koenig-editor/addon/lib/dnd/container.js b/ghost/admin/lib/koenig-editor/addon/lib/dnd/container.js index 48f4e702b4..e70951298d 100644 --- a/ghost/admin/lib/koenig-editor/addon/lib/dnd/container.js +++ b/ghost/admin/lib/koenig-editor/addon/lib/dnd/container.js @@ -13,6 +13,11 @@ import {A} from '@ember/array'; class Container { constructor(element, options) { + if (options.createGhostElement) { + this._createGhostElement = options.createGhostElement; + delete options.createGhostElement; + } + Object.assign(this, { element, draggables: A([]), @@ -48,8 +53,9 @@ class Container { onDragOverDroppable() { } onDragLeaveDroppable() { } onDragLeaveContainer() { } - onDrop() { } onDragEnd() { } + onDrop() { } + onDropEnd() { } // TODO: allow configuration for ghost element creation // builds an element that is attached to the mouse pointer when dragging. @@ -57,7 +63,13 @@ class Container { // - a selector for which element in the draggable to copy // - a function to hand off element creation to the consumer createGhostElement(draggableInfo) { - if (draggableInfo.type === 'image') { + let ghostElement; + + if (typeof this._createGhostElement === 'function') { + ghostElement = this._createGhostElement(draggableInfo); + } + + if (!ghostElement && (draggableInfo.type === 'image' || draggableInfo.cardName === 'image')) { let image = draggableInfo.element.querySelector('img'); if (image) { let aspectRatio = image.width / image.height; @@ -72,7 +84,7 @@ class Container { height = 200; } - let ghostElement = document.createElement('img'); + ghostElement = document.createElement('img'); ghostElement.width = width; ghostElement.height = height; ghostElement.id = 'koenig-drag-drop-ghost'; @@ -82,8 +94,6 @@ class Container { 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 element found in draggable'); @@ -91,6 +101,10 @@ class Container { } } + if (ghostElement) { + return ghostElement; + } + // eslint-disable-next-line console.warn(`No default createGhostElement handler for type "${draggableInfo.type}"`); } diff --git a/ghost/admin/lib/koenig-editor/addon/services/koenig-drag-drop-handler.js b/ghost/admin/lib/koenig-editor/addon/services/koenig-drag-drop-handler.js index 5aa869ecc9..0dc075f22f 100644 --- a/ghost/admin/lib/koenig-editor/addon/services/koenig-drag-drop-handler.js +++ b/ghost/admin/lib/koenig-editor/addon/services/koenig-drag-drop-handler.js @@ -149,15 +149,21 @@ export default Service.extend({ _onMouseUp(/*event*/) { if (this.draggableInfo) { + let success = false; + // TODO: accept object rather than positioned args? OR, should the // droppable data be stored on draggableInfo? if (this._currentOverContainer) { - this._currentOverContainer.onDrop( + success = this._currentOverContainer.onDrop( this.draggableInfo, this._currentOverDroppableElem, this._currentOverDroppablePosition ); } + + this.containers.forEach((container) => { + container.onDropEnd(this.draggableInfo, success); + }); } // remove drag info and any ghost element