Ghost/ghost/admin/lib/koenig-editor/addon/components/koenig-card-image.js

375 lines
11 KiB
JavaScript
Raw Normal View History

import $ from 'jquery';
import Component from '@ember/component';
import {
IMAGE_EXTENSIONS,
IMAGE_MIME_TYPES
} from 'ghost-admin/components/gh-image-uploader';
import {action, computed, set, setProperties} from '@ember/object';
import {utils as ghostHelperUtils} from '@tryghost/helpers';
import {isEmpty} from '@ember/utils';
import {run} from '@ember/runloop';
import {inject as service} from '@ember/service';
const {countWords} = ghostHelperUtils;
export default Component.extend({
ui: service(),
// attrs
editor: null,
files: null,
payload: null,
isSelected: false,
isEditing: false,
imageExtensions: IMAGE_EXTENSIONS,
imageMimeTypes: IMAGE_MIME_TYPES,
// properties
handlesDragDrop: true,
isEditingAlt: false,
// closure actions
selectCard() {},
deselectCard() {},
editCard() {},
saveCard() {},
deleteCard() {},
moveCursorToNextSection() {},
moveCursorToPrevSection() {},
addParagraphAfterCard() {},
registerComponent() {},
isEmpty: computed.not('payload.src'),
Removed duplication of "delete if empty" card logic no issue The logic for "delete if empty" was duplicated in two places: 1. when `createComponentCard` is used to register a card, this option method was used when cleaning up a post when first rendering (cards in empty state can be saved before the editor auto-removes them but we don't want to show them again) 2. inside of card's own delete-if-empty handling on certain actions such as deselection or leaving edit mode - added an `ifEmpty` property to each card component - used by the editor's first-render cleanup routing if the property is present - can be re-used internally for the card's own deselect/exit-edit-mode behaviour - updated the cleanup routine in `<KoenigEditor>` - added a `allComponentCardsRegistered` property that will return `true` when the `.component` property is set on every card (the property is set during card component initialisation so we're at the mercy of Ember's render process so not all card components will be immediately registered) - swapped `_cleanup` for `_cleanupTask` that will wait for `allComponentCardsRegistered` to be `true` before performing cleanup, ensuring that we always have access to the card component's `isEmpty` property even when Ember renders cards across multiple render batches - checks for `isEmpty` being a boolean and will delete the card if it's value is `true` - updated all cards that had delete-if-empty behaviour - added `isEmpty` properties - removed duplicated logic in the `createComponentCard` calls
2021-11-10 17:45:54 +03:00
imageSelector: computed('payload.imageSelector', function () {
let selector = this.payload.imageSelector;
let imageSelectors = {
unsplash: {
component: 'gh-unsplash',
type: 'modal'
},
tenor: {
component: 'koenig-card-image/selector-tenor',
type: 'placeholder'
}
};
return imageSelectors[selector];
}),
counts: computed('payload.{src,caption}', function () {
let wordCount = 0;
let imageCount = 0;
if (this.payload.src) {
imageCount += 1;
}
if (this.payload.caption) {
wordCount += countWords(this.payload.caption);
}
return {wordCount, imageCount};
}),
kgImgStyle: computed('payload.cardWidth', function () {
let cardWidth = this.payload.cardWidth;
if (cardWidth === 'wide') {
return 'image-wide';
}
if (cardWidth === 'full') {
return 'image-full';
}
return 'image-normal';
}),
toolbar: computed('isEditingLink', 'payload.{cardWidth,src}', function () {
if (!this.payload.src || this.isEditingLink) {
return false;
}
let cardWidth = this.payload.cardWidth;
2021-11-18 21:08:01 +03:00
let toolbarItems;
2021-11-18 21:08:01 +03:00
if (this.payload.type === 'gif') {
toolbarItems = [{
title: 'Link',
icon: 'koenig/kg-link',
iconClass: this.payload.href ? 'fill-green-l2' : 'fill-white',
action: run.bind(this, this._editLink)
}];
} else {
toolbarItems = [{
title: 'Regular',
icon: 'koenig/kg-img-regular',
iconClass: !cardWidth ? 'fill-green-l2' : 'fill-white',
action: run.bind(this, this._changeCardWidth, '')
}, {
title: 'Wide',
icon: 'koenig/kg-img-wide',
iconClass: cardWidth === 'wide' ? 'fill-green-l2' : 'fill-white',
action: run.bind(this, this._changeCardWidth, 'wide')
}, {
title: 'Full',
icon: 'koenig/kg-img-full',
iconClass: cardWidth === 'full' ? 'fill-green-l2' : 'fill-white',
action: run.bind(this, this._changeCardWidth, 'full')
}, {
divider: true
}, {
title: 'Link',
icon: 'koenig/kg-link',
iconClass: this.payload.href ? 'fill-green-l2' : 'fill-white',
action: run.bind(this, this._editLink)
}, {
title: 'Replace image',
icon: 'koenig/kg-replace',
iconClass: 'fill-white',
action: run.bind(this, this._triggerFileDialog)
2021-11-18 21:08:01 +03:00
}];
}
return {items: toolbarItems};
}),
init() {
this._super(...arguments);
if (!this.payload) {
this.set('payload', {});
}
let placeholders = ['summer', 'mountains', 'ufo-attack'];
this.set('placeholder', placeholders[Math.floor(Math.random() * placeholders.length)]);
this.registerComponent(this);
},
didReceiveAttrs() {
this._super(...arguments);
// `payload.files` can be set if we have an externaly set image that
// should be uploaded. Typical example would be from a paste or drag/drop
if (!isEmpty(this.payload.files)) {
run.schedule('afterRender', this, function () {
this.set('files', this.payload.files);
// we don't want to persist any file data in the document
delete this.payload.files;
});
}
// switch back to displaying caption when card is not selected
if (!this.isSelected) {
this.set('isEditingAlt', false);
}
},
didInsertElement() {
this._super(...arguments);
if (this.payload.triggerBrowse && !this.payload.src && !this.payload.files) {
// we don't want to persist this in the serialized payload
this._updatePayloadAttr('triggerBrowse', undefined);
let fileInput = this.element.querySelector('input[type="file"]');
if (fileInput) {
fileInput.click();
}
}
},
actions: {
updateSrc(images) {
let [image] = images;
// create undo snapshot when image finishes uploading
this.editor.run(() => {
this._updatePayloadAttr('src', image.url);
if (this._imageWidth && this._imageHeight) {
this._updatePayloadAttr('width', this._imageWidth);
this._updatePayloadAttr('height', this._imageHeight);
}
this._imageWidth = null;
this._imageHeight = null;
});
},
/**
* 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);
},
setPreviewSrc(files) {
let file = files[0];
if (file) {
let url = URL.createObjectURL(file);
this.set('previewSrc', url);
let imageElem = new Image();
imageElem.onload = () => {
// store width/height for use later to avoid saving an image card with no `src`
this._imageWidth = imageElem.naturalWidth;
this._imageHeight = imageElem.naturalHeight;
};
imageElem.src = url;
}
},
resetSrcs() {
this.set('previewSrc', null);
this._imageWidth = null;
this._imageHeight = null;
// create undo snapshot when clearing
this.editor.run(() => {
this._updatePayloadAttr('src', null);
this._updatePayloadAttr('width', null);
this._updatePayloadAttr('height', null);
});
},
2021-11-18 21:08:01 +03:00
selectFromImageSelector({src, width, height, caption, alt, type}) {
let {payload, saveCard} = this;
let searchTerm;
2021-11-18 21:08:01 +03:00
setProperties(payload, {src, width, height, caption, alt, type, searchTerm});
this.send('closeImageSelector');
// create undo snapshot when selecting an image
this.editor.run(() => {
saveCard(payload, false);
});
},
closeImageSelector(reselectParagraph = true) {
if (!this.payload.src) {
if (!this.env.postModel.parent) {
// card has been deleted by cleanup
return;
}
return this.editor.run((postEditor) => {
let {builder} = postEditor;
let cardSection = this.env.postModel;
let p = builder.createMarkupSection('p');
postEditor.replaceSection(cardSection, p);
if (reselectParagraph) {
postEditor.setRange(p.tailPosition());
}
});
}
set(this.payload, 'imageSelector', undefined);
},
updateHref(href) {
this._updatePayloadAttr('href', href);
},
cancelEditLink() {
this.set('isEditing', false);
this.set('isEditingLink', false);
},
onDeselect() {
if (this.imageSelector?.type === 'placeholder' && !this.payload.src) {
this.send('closeImageSelector', false);
}
}
},
updateCaption: action(function (caption) {
this._updatePayloadAttr('caption', caption);
}),
toggleAltEditing: action(function () {
this.toggleProperty('isEditingAlt');
}),
updateAlt: action(function (alt) {
this._updatePayloadAttr('alt', alt);
}),
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.set('files', [event.dataTransfer.files[0]]);
}
},
_changeCardWidth(cardWidth) {
// create undo snapshot when changing image size
this.editor.run(() => {
this._updatePayloadAttr('cardWidth', cardWidth);
});
},
_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();
},
_editLink(event) {
event.preventDefault();
event.stopPropagation();
const rect = this.element.getBoundingClientRect();
rect.x = rect.x - 5;
this.set('linkRect', rect);
this.set('isEditing', true); // hide snippet icon in toolbar
this.set('isEditingLink', true);
}
});