mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-30 14:22:07 +03:00
8fee5f155b
no issue - we had a mix of legacy jQuery triggers and native triggers for file input clicks and jQuery hasn't been required to do this in our target browsers for quite a long time now so it made sense to update all click triggers to avoid old patterns being replicated - cleaned up some conditionals with optional-chaining - removed use of `run.bind(this)` for methods that use `@action` because the binding is already handled for us
592 lines
18 KiB
JavaScript
592 lines
18 KiB
JavaScript
import Component from '@ember/component';
|
|
import EmberObject, {action, computed, set} from '@ember/object';
|
|
import classic from 'ember-classic-decorator';
|
|
import {
|
|
IMAGE_EXTENSIONS,
|
|
IMAGE_MIME_TYPES
|
|
} from 'ghost-admin/components/gh-image-uploader';
|
|
import {utils as ghostHelperUtils} from '@tryghost/helpers';
|
|
import {htmlSafe} from '@ember/template';
|
|
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;
|
|
|
|
@classic
|
|
export default class KoenigCardGallery extends Component {
|
|
@service koenigDragDropHandler;
|
|
|
|
// 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() { }
|
|
|
|
@computed('payload.{caption,payload.images.[]}')
|
|
get counts() {
|
|
let wordCount = 0;
|
|
let imageCount = this.payload.images.length;
|
|
|
|
if (this.payload.caption) {
|
|
wordCount += countWords(this.payload.caption);
|
|
}
|
|
|
|
return {wordCount, imageCount};
|
|
}
|
|
|
|
@computed('images.[]')
|
|
get toolbar() {
|
|
if (isEmpty(this.images)) {
|
|
return false;
|
|
}
|
|
|
|
return {
|
|
items: [{
|
|
title: 'Add images',
|
|
icon: 'koenig/kg-add',
|
|
iconClass: 'fill-white',
|
|
action: this.triggerFileDialog
|
|
}]
|
|
};
|
|
}
|
|
|
|
@computed('images.@each.{src,previewSrc,width,height,row}')
|
|
get imageRows() {
|
|
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() {
|
|
super.init(...arguments);
|
|
|
|
if (!this.payload || isEmpty(this.payload.images)) {
|
|
this._updatePayloadAttr('images', []);
|
|
}
|
|
|
|
this._buildImages();
|
|
|
|
this.registerComponent(this);
|
|
}
|
|
|
|
willDestroyElement() {
|
|
super.willDestroyElement(...arguments);
|
|
|
|
if (this._dragDropContainer) {
|
|
this._dragDropContainer.destroy();
|
|
}
|
|
}
|
|
|
|
@action
|
|
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);
|
|
}
|
|
|
|
@action
|
|
setImageSrc(uploadResult) {
|
|
let image = this.images.findBy('fileName', uploadResult.fileName);
|
|
|
|
image.set('src', uploadResult.url);
|
|
|
|
this._buildAndSaveImagesPayload();
|
|
}
|
|
|
|
@action
|
|
setFiles(files) {
|
|
this._startUpload(files);
|
|
}
|
|
|
|
@action
|
|
deleteImage(image) {
|
|
let localImage = this.images.findBy('fileName', image.fileName);
|
|
this.images.removeObject(localImage);
|
|
this._recalculateImageRows();
|
|
this._buildAndSaveImagesPayload();
|
|
}
|
|
|
|
@action
|
|
updateCaption(caption) {
|
|
this._updatePayloadAttr('caption', caption);
|
|
}
|
|
|
|
@action
|
|
triggerFileDialog(event) {
|
|
const target = event?.target || this.element;
|
|
|
|
const cardElem = target.closest('.__mobiledoc-card');
|
|
const fileInput = cardElem?.querySelector('input[type="file"]');
|
|
|
|
fileInput?.click();
|
|
}
|
|
|
|
@action
|
|
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`);
|
|
}
|
|
|
|
@action
|
|
handleErrors(errors) {
|
|
let errorMssg = ((errors[0] && errors[0].message)) || 'Some images failed to upload';
|
|
this.set('errorMessage', errorMssg);
|
|
}
|
|
|
|
@action
|
|
clearErrorMessage() {
|
|
this.set('errorMessage', null);
|
|
}
|
|
|
|
@action
|
|
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);
|
|
}
|
|
}
|
|
|
|
@action
|
|
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();
|
|
}
|
|
|
|
// - 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.scheduleOnce('afterRender', this, this._refreshDragDropContainer);
|
|
} else {
|
|
run.scheduleOnce('afterRender', this, this._registerDragDropContainer);
|
|
}
|
|
}
|
|
|
|
_refreshDragDropContainer() {
|
|
const galleryElem = this.element.querySelector('[data-gallery]');
|
|
|
|
// gallery element can change when switching from placeholder to gallery
|
|
// make sure the DnD container is updated to match
|
|
if (this._dragDropContainer.element !== galleryElem) {
|
|
this._dragDropContainer.destroy();
|
|
this._registerDragDropContainer();
|
|
} else {
|
|
this._dragDropContainer.refresh();
|
|
}
|
|
|
|
if (!isEmpty(this.images) && !this._dragDropContainer.isDragEnabled) {
|
|
this._dragDropContainer.enableDrag();
|
|
}
|
|
}
|
|
|
|
_registerDragDropContainer() {
|
|
const 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),
|
|
onDragEnterContainer: run.bind(this, this._dragEnter),
|
|
onDragLeaveContainer: run.bind(this, this._dragLeave),
|
|
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();
|
|
}
|
|
|
|
if (this.isDraggedOver) {
|
|
this.set('isDraggedOver', false);
|
|
}
|
|
}
|
|
|
|
_dragEnter() {
|
|
if (this.images.length === 0) {
|
|
this.set('isDraggedOver', true);
|
|
}
|
|
}
|
|
|
|
_dragLeave() {
|
|
this.set('isDraggedOver', false);
|
|
}
|
|
|
|
_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.images.length) {
|
|
insertIndex = 0;
|
|
}
|
|
|
|
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(payload.src || 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;
|
|
}
|
|
}
|