Ghost/ghost/admin/lib/koenig-editor/addon/components/koenig-card-gallery.js
Kevin Ansfield 09435ecf76 Co-located component template files
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
2020-05-18 13:14:08 +01:00

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;
}
});