mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-28 22:43:30 +03:00
09435ecf76
no issue Keeps component JS backing files and template files in the same directory which avoids hunting across directories when working with components. Also lets you see all components when looking at one directory, whereas previously template-only or js-only components may not have been obvious without looking at both directories. - ran [codemod](https://github.com/ember-codemods/ember-component-template-colocation-migrator/) for app-level components - manually moved in-repo-addon component templates in `lib/koenig-editor` - removed all explicit `layout` imports as JS/template associations are now made at build-time removing the need for them - updated `.embercli` to default to new flat component structure
551 lines
18 KiB
JavaScript
551 lines
18 KiB
JavaScript
import $ from 'jquery';
|
|
import Component from '@ember/component';
|
|
import EmberObject, {computed, set} from '@ember/object';
|
|
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(),
|
|
|
|
// 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;
|
|
}
|
|
});
|