mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-22 18:31:57 +03:00
46d5b779ae
no issue - the position that images were inserted did not always match the position indicator because of errors in the insert index calculations
554 lines
18 KiB
JavaScript
554 lines
18 KiB
JavaScript
import $ from 'jquery';
|
|
import Component from '@ember/component';
|
|
import EmberObject, {computed, set} from '@ember/object';
|
|
import layout from '../templates/components/koenig-card-gallery';
|
|
import {
|
|
IMAGE_EXTENSIONS,
|
|
IMAGE_MIME_TYPES
|
|
} from 'ghost-admin/components/gh-image-uploader';
|
|
import {utils as ghostHelperUtils} from '@tryghost/helpers';
|
|
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;
|
|
const {countWords} = ghostHelperUtils;
|
|
|
|
export default Component.extend({
|
|
koenigDragDropHandler: service(),
|
|
|
|
layout,
|
|
|
|
// attrs
|
|
files: null,
|
|
images: null,
|
|
payload: null,
|
|
isSelected: false,
|
|
isEditing: false,
|
|
imageExtensions: IMAGE_EXTENSIONS,
|
|
imageMimeTypes: IMAGE_MIME_TYPES,
|
|
|
|
// properties
|
|
errorMessage: null,
|
|
handlesDragDrop: true,
|
|
|
|
_dragDropContainer: null,
|
|
|
|
// closure actions
|
|
selectCard() { },
|
|
deselectCard() { },
|
|
editCard() { },
|
|
saveCard() { },
|
|
deleteCard() { },
|
|
moveCursorToNextSection() { },
|
|
moveCursorToPrevSection() { },
|
|
addParagraphAfterCard() { },
|
|
registerComponent() { },
|
|
|
|
counts: computed('payload.{caption,payload.images.[]}', function () {
|
|
let wordCount = 0;
|
|
let imageCount = this.payload.images.length;
|
|
|
|
if (this.payload.caption) {
|
|
wordCount += countWords(this.payload.caption);
|
|
}
|
|
|
|
return {wordCount, imageCount};
|
|
}),
|
|
|
|
toolbar: computed('images.[]', function () {
|
|
if (isEmpty(this.images)) {
|
|
return false;
|
|
}
|
|
|
|
return {
|
|
items: [{
|
|
title: 'Add images',
|
|
icon: 'koenig/kg-add',
|
|
iconClass: 'fill-white',
|
|
action: run.bind(this, this._triggerFileDialog)
|
|
}]
|
|
};
|
|
}),
|
|
|
|
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 = [];
|
|
let overlayClasses = [];
|
|
|
|
// 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('pr2');
|
|
overlayClasses.push('mr2');
|
|
} else if (((idx + 1) % 3 === 0) || maxImagesInRow(idx + 1) || idx + 1 === noOfImages) {
|
|
// last image in row
|
|
classes.push('pl2');
|
|
overlayClasses.push('ml2');
|
|
} else {
|
|
// middle of row
|
|
classes.push('pl2', 'pr2');
|
|
overlayClasses.push('ml2', 'mr2');
|
|
}
|
|
|
|
if (row > 0) {
|
|
classes.push('mt4');
|
|
}
|
|
|
|
let styledImage = Object.assign({}, image);
|
|
let aspectRatio = (image.width || 1) / (image.height || 1);
|
|
styledImage.style = htmlSafe(`flex: ${aspectRatio} 1 0%`);
|
|
styledImage.classes = htmlSafe(classes.join(' '));
|
|
styledImage.overlayClasses = htmlSafe(overlayClasses.join(' '));
|
|
|
|
rows[row].push(styledImage);
|
|
});
|
|
|
|
return rows;
|
|
}),
|
|
|
|
init() {
|
|
this._super(...arguments);
|
|
|
|
if (!this.payload || isEmpty(this.payload.images)) {
|
|
this._updatePayloadAttr('images', []);
|
|
}
|
|
|
|
this._buildImages();
|
|
|
|
this.registerComponent(this);
|
|
},
|
|
|
|
willDestroyElement() {
|
|
this._super(...arguments);
|
|
|
|
if (this._dragDropContainer) {
|
|
this._dragDropContainer.destroy();
|
|
}
|
|
},
|
|
|
|
actions: {
|
|
addImage(file) {
|
|
let count = this.images.length + 1;
|
|
let row = Math.ceil(count / MAX_PER_ROW) - 1;
|
|
|
|
let image = this._readDataFromImageFile(file);
|
|
image.row = row;
|
|
this.images.pushObject(image);
|
|
},
|
|
|
|
setImageSrc(uploadResult) {
|
|
let image = this.images.findBy('fileName', uploadResult.fileName);
|
|
|
|
image.set('src', uploadResult.url);
|
|
|
|
this._buildAndSaveImagesPayload();
|
|
},
|
|
|
|
setFiles(files) {
|
|
this._startUpload(files);
|
|
},
|
|
|
|
deleteImage(image) {
|
|
let localImage = this.images.findBy('fileName', image.fileName);
|
|
this.images.removeObject(localImage);
|
|
this._recalculateImageRows();
|
|
this._buildAndSaveImagesPayload();
|
|
},
|
|
|
|
updateCaption(caption) {
|
|
this._updatePayloadAttr('caption', caption);
|
|
},
|
|
|
|
triggerFileDialog(event) {
|
|
this._triggerFileDialog(event);
|
|
},
|
|
|
|
uploadFailed(uploadResult) {
|
|
let image = this.images.findBy('fileName', uploadResult.fileName);
|
|
this.images.removeObject(image);
|
|
|
|
this._buildAndSaveImagesPayload();
|
|
let fileName = (uploadResult.fileName.length > 20) ? `${uploadResult.fileName.substr(0, 20)}...` : uploadResult.fileName;
|
|
this.set('errorMessage', `${fileName} failed to upload`);
|
|
},
|
|
handleErrors(errors) {
|
|
let errorMssg = ((errors[0] && errors[0].message)) || 'Some images failed to upload';
|
|
this.set('errorMessage', errorMssg);
|
|
},
|
|
|
|
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();
|
|
}
|
|
}
|
|
},
|
|
|
|
// Ember event handlers ----------------------------------------------------
|
|
|
|
dragOver(event) {
|
|
if (!event.dataTransfer) {
|
|
return;
|
|
}
|
|
|
|
// this is needed to work around inconsistencies with dropping files
|
|
// from Chrome's downloads bar
|
|
if (navigator.userAgent.indexOf('Chrome') > -1) {
|
|
let eA = event.dataTransfer.effectAllowed;
|
|
event.dataTransfer.dropEffect = (eA === 'move' || eA === 'linkMove') ? 'move' : 'copy';
|
|
}
|
|
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
|
|
this.set('isDraggedOver', true);
|
|
},
|
|
|
|
dragLeave(event) {
|
|
event.preventDefault();
|
|
this.set('isDraggedOver', false);
|
|
},
|
|
|
|
drop(event) {
|
|
event.preventDefault();
|
|
this.set('isDraggedOver', false);
|
|
|
|
if (event.dataTransfer.files) {
|
|
this._startUpload(event.dataTransfer.files);
|
|
}
|
|
},
|
|
|
|
// 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);
|
|
|
|
let strippedFiles = Array.prototype.slice.call(files, 0, allowedCount);
|
|
if (strippedFiles.length < files.length) {
|
|
this.set('errorMessage', 'Galleries are limited to 9 images');
|
|
}
|
|
this.set('files', strippedFiles);
|
|
},
|
|
|
|
_readDataFromImageFile(file) {
|
|
let url = URL.createObjectURL(file);
|
|
let image = EmberObject.create({
|
|
fileName: file.name,
|
|
previewSrc: url
|
|
});
|
|
|
|
let imageElem = new Image();
|
|
imageElem.onload = () => {
|
|
// update current display images
|
|
image.set('width', imageElem.naturalWidth);
|
|
image.set('height', imageElem.naturalHeight);
|
|
|
|
// ensure width/height makes it into the payload images
|
|
this._buildAndSaveImagesPayload();
|
|
};
|
|
imageElem.src = url;
|
|
|
|
return image;
|
|
},
|
|
|
|
_buildAndSaveImagesPayload() {
|
|
let payloadImages = [];
|
|
|
|
let isValidImage = image => image.fileName
|
|
&& image.src
|
|
&& image.width
|
|
&& image.height;
|
|
|
|
this.images.forEach((image, idx) => {
|
|
if (isValidImage(image)) {
|
|
let payloadImage = Object.assign({}, image, {previewSrc: undefined});
|
|
payloadImage.row = Math.ceil((idx + 1) / MAX_PER_ROW) - 1;
|
|
|
|
payloadImages.push(payloadImage);
|
|
}
|
|
});
|
|
|
|
this._updatePayloadAttr('images', payloadImages);
|
|
},
|
|
|
|
_buildImages() {
|
|
this.images = this.payload.images.map(image => EmberObject.create(image));
|
|
this._registerOrRefreshDragDropHandler();
|
|
},
|
|
|
|
_updatePayloadAttr(attr, value) {
|
|
let payload = this.payload;
|
|
let save = this.saveCard;
|
|
|
|
set(payload, attr, value);
|
|
|
|
// update the mobiledoc and stay in edit mode
|
|
save(payload, false);
|
|
|
|
this._registerOrRefreshDragDropHandler();
|
|
},
|
|
|
|
_triggerFileDialog(event) {
|
|
let target = event && event.target || this.element;
|
|
|
|
// simulate click to open file dialog
|
|
// using jQuery because IE11 doesn't support MouseEvent
|
|
$(target)
|
|
.closest('.__mobiledoc-card')
|
|
.find('input[type="file"]')
|
|
.click();
|
|
},
|
|
|
|
// - 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, 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),
|
|
onDropEnd: run.bind(this, this._onDropEnd)
|
|
}
|
|
);
|
|
}
|
|
});
|
|
}
|
|
},
|
|
|
|
_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', 'caption');
|
|
|
|
if (image) {
|
|
return {
|
|
type: 'image',
|
|
payload
|
|
};
|
|
}
|
|
|
|
return {};
|
|
},
|
|
|
|
_onDrop(draggableInfo/*, droppableElem, position*/) {
|
|
// do not allow dropping of non-images
|
|
if (draggableInfo.type !== 'image' && draggableInfo.cardName !== 'image') {
|
|
return false;
|
|
}
|
|
|
|
let {insertIndex} = draggableInfo;
|
|
let droppables = Array.from(this.element.querySelectorAll('[data-image]'));
|
|
let draggableIndex = droppables.indexOf(draggableInfo.element);
|
|
|
|
if (this._isDropAllowed(draggableIndex, insertIndex)) {
|
|
if (draggableIndex === -1) {
|
|
// external image being added
|
|
let {payload} = draggableInfo;
|
|
let img = draggableInfo.element.querySelector(`img[src="${payload.src}"]`);
|
|
|
|
// 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);
|
|
let accountForRemoval = draggableIndex < insertIndex && insertIndex ? -1 : 0;
|
|
this.images.removeObject(draggedImage);
|
|
this.images.insertAt(insertIndex + accountForRemoval, 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();
|
|
}
|
|
},
|
|
|
|
// 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 dropping of non-images
|
|
if (draggableInfo.type !== 'image' && draggableInfo.cardName !== 'image') {
|
|
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/)) {
|
|
insertIndex += 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 = '') {
|
|
// 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;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
});
|