Ghost/ghost/admin/lib/koenig-editor/addon/components/koenig-card-gallery.js
Kevin Ansfield 2639ff62e6 Optimised gallery image upload previews (#1079)
no issue
- use `URL.createObjectURL(file)` to get a blob url rather than using `FileReader.readAsDataURL` which generates a very large data attribute
- speeds up the display of previews and associated browser hangs when an upload finishes, especially noticeable with large images and fast connections where multiple uploads finish around the same time
2018-12-05 20:16:03 +00:00

296 lines
8.4 KiB
JavaScript

import $ from 'jquery';
import Component from '@ember/component';
import EmberObject, {computed, set} from '@ember/object';
import countWords, {stripTags} from '../utils/count-words';
import layout from '../templates/components/koenig-card-gallery';
import {
IMAGE_EXTENSIONS,
IMAGE_MIME_TYPES
} from 'ghost-admin/components/gh-image-uploader';
import {htmlSafe} from '@ember/string';
import {isEmpty} from '@ember/utils';
import {run} from '@ember/runloop';
const MAX_IMAGES = 9;
const MAX_PER_ROW = 3;
export default Component.extend({
layout,
// attrs
files: null,
images: null,
payload: null,
isSelected: false,
isEditing: false,
imageExtensions: IMAGE_EXTENSIONS,
imageMimeTypes: IMAGE_MIME_TYPES,
// properties
errorMessage: null,
handlesDragDrop: true,
// 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.src) {
imageCount += 1;
}
if (this.payload.caption) {
wordCount += countWords(stripTags(this.payload.caption));
}
return {wordCount, imageCount};
}),
toolbar: computed('images.[]', function () {
let items = [];
if (!isEmpty(this.images)) {
items.push({
title: 'Add images',
icon: 'koenig/kg-add',
iconClass: 'fill-white',
action: run.bind(this, this._triggerFileDialog)
});
}
if (items.length > 0) {
return {items};
}
}),
imageRows: computed('images.@each.{src,previewSrc,width,height,row}', function () {
let rows = [];
let noOfImages = this.images.length;
this.images.forEach((image, idx) => {
let row = image.row;
let classes = ['relative', 'hide-child'];
if (noOfImages > 1 && (noOfImages % 3 === 1) && (idx === (noOfImages - 2))) {
row = row + 1;
}
if (!rows[row]) {
rows[row] = [];
} else {
classes.push('ml4');
}
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 = classes.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);
},
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.images.forEach((image, idx) => {
image.set('row', Math.ceil((idx + 1) / MAX_PER_ROW) - 1);
});
this._buildAndSaveImagesPayload();
},
updateCaption(caption) {
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);
},
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);
}
},
// 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 ---------------------------------------------------------
_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));
},
_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);
},
_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();
}
});