Ghost-Admin/lib/koenig-editor/addon/components/koenig-card-embed.js

363 lines
12 KiB
JavaScript

import classic from 'ember-classic-decorator';
import {inject as service} from '@ember/service';
/* global noframe */
import Component from '@ember/component';
import {NO_CURSOR_MOVEMENT} from './koenig-editor';
import {action, computed, set} from '@ember/object';
import {utils as ghostHelperUtils} from '@tryghost/helpers';
import {isBlank} from '@ember/utils';
import {run} from '@ember/runloop';
import {task} from 'ember-concurrency';
const {countWords} = ghostHelperUtils;
@classic
export default class KoenigCardEmbed extends Component {
@service ajax;
@service ghostPaths;
// attrs
payload = null;
isSelected = false;
isEditing = false;
// internal properties
hasError = false;
// closure actions
selectCard() {}
deselectCard() {}
editCard() {}
saveCard() {}
deleteCard() {}
moveCursorToNextSection() {}
moveCursorToPrevSection() {}
addParagraphAfterCard() {}
registerComponent() {}
@computed('payload.html')
get isEmpty() {
return isBlank(this.payload.html) && this.payload.type !== 'nft';
}
@computed('payload.{html,caption}')
get counts() {
return {
imageCount: this.payload.html ? 1 : 0,
wordCount: countWords(this.payload.caption)
};
}
init() {
super.init(...arguments);
if (this.payload.url && !this.payload.html) {
this.convertUrl.perform(this.payload.url);
}
this.registerComponent(this);
}
didInsertElement() {
super.didInsertElement(...arguments);
this._populateIframe();
this._focusInput();
}
willDestroyElement() {
super.willDestroyElement(...arguments);
run.cancel(this._resizeDebounce);
if (this._iframeMutationObserver) {
this._iframeMutationObserver.disconnect();
}
window.removeEventListener('resize', this._windowResizeHandler);
}
@action
onDeselect() {
if (this.payload.url && !this.payload.html && !this.hasError) {
this.convertUrl.perform(this.payload.url);
} else {
if (this.isEmpty && !this.convertUrl.isRunning && !this.hasError) {
this.deleteCard(NO_CURSOR_MOVEMENT);
}
}
}
@action
updateUrl(event) {
let url = event.target.value;
set(this.payload, 'url', url);
}
@action
urlKeydown(event) {
if (event.key === 'Enter') {
event.preventDefault();
this.convertUrl.perform(this.payload.url);
}
if (event.key === 'Escape') {
event.target.blur();
this.deleteCard();
}
}
@action
updateCaption(caption) {
set(this.payload, 'caption', caption);
this.saveCard(this.payload, false);
}
@action
retry() {
this.set('hasError', false);
}
@action
insertAsLink(options = {linkOnError: false}) {
let {range} = this.editor;
this.editor.run((postEditor) => {
let {builder} = postEditor;
let cardSection = this.env.postModel;
let p = builder.createMarkupSection('p');
let link = builder.createMarkup('a', {href: this.payload.url});
postEditor.replaceSection(cardSection, p);
postEditor.insertTextWithMarkup(p.toRange().head, this.payload.url, [link]);
// if a user is typing further on in the doc (possible if embed
// was created automatically via paste of URL) then return the
// cursor so the card->link change doesn't cause a cursor jump
if (range.headSection !== cardSection) {
postEditor.setRange(range);
}
// avoid adding an extra undo step when automatically creating
// link after an error so that an Undo after pasting a URL
// doesn't get stuck in a loop going through link->embed->link
if (options.linkOnError) {
postEditor.cancelSnapshot();
}
});
}
@action
insertAsBookmark(payload) {
let {range} = this.editor;
this.editor.run((postEditor) => {
let cardSection = this.env.postModel;
let bookmarkCard = postEditor.builder.createCardSection('bookmark', payload);
postEditor.replaceSection(cardSection, bookmarkCard);
// if a user is typing further on in the doc (possible if embed
// was created automatically via paste of URL) then return the
// cursor so the card->link change doesn't cause a cursor jump
if (range.headSection !== cardSection) {
postEditor.setRange(range);
}
});
}
@(task(function* (url) {
if (isBlank(url)) {
this.deleteCard();
return;
}
try {
let oembedEndpoint = this.ghostPaths.url.api('oembed');
let requestData = {
url
};
if (!this.payload.isDirectUrl) {
requestData.type = 'embed';
}
let response = yield this.ajax.request(oembedEndpoint, {
data: requestData
});
if (response.type === 'bookmark') {
this.send('insertAsBookmark', response);
return;
}
if (!response.html && response.type !== 'nft') {
throw 'No HTML returned';
}
set(this.payload, 'linkOnError', undefined);
set(this.payload, 'isDirectUrl', undefined);
set(this.payload, 'html', response.html);
delete response.html;
set(this.payload, 'type', response.type);
delete response.type;
// store all other data returned from oembed such as thumbnails, sizing, etc
set(this.payload, 'metadata', response);
this.saveCard(this.payload, false);
run.schedule('afterRender', this, this._populateIframe);
} catch (err) {
if (this.payload.linkOnError) {
this.send('insertAsLink', {linkOnError: true});
return;
}
this.set('hasError', true);
}
}).drop())
convertUrl;
_focusInput() {
let urlInput = this.element.querySelector('[name="url"]');
if (urlInput) {
urlInput.focus();
}
}
_populateIframe() {
let iframe = this.element.querySelector('iframe');
if (iframe) {
iframe.contentWindow.document.open();
iframe.contentWindow.document.write(this.payload.html);
iframe.contentWindow.document.close();
iframe.contentDocument.body.style.display = 'flex';
iframe.contentDocument.body.style.margin = '0';
iframe.contentDocument.body.style.justifyContent = 'center';
let nestedIframe = iframe.contentDocument.body.firstChild;
if (nestedIframe.nodeName === 'IFRAME') {
noframe(nestedIframe, '[data-kg-embed]');
this._resizeIframe(iframe);
}
this._iframeResizeHandler = run.bind(this, this._resizeIframe, iframe);
this._iframeMutationObserver = this._createMutationObserver(
iframe.contentWindow.document,
this._iframeResizeHandler
);
this._setupWindowResizeHandler(iframe);
}
}
_createMutationObserver(target, callback) {
function addImageLoadListeners(mutation) {
function addImageLoadListener(element) {
if (element.complete === false) {
element.addEventListener('load', imageEventTriggered, false);
element.addEventListener('error', imageEventTriggered, false);
imageElements.push(element);
}
}
if (mutation.type === 'attributes' && mutation.attributeName === 'src') {
addImageLoadListener(mutation.target);
} else if (mutation.type === 'childList') {
Array.prototype.forEach.call(
mutation.target.querySelectorAll('img'),
addImageLoadListener
);
}
}
function removeFromElements(element) {
imageElements.splice(imageElements.indexOf(element), 1);
}
function removeImageLoadListener(element) {
element.removeEventListener('load', imageEventTriggered, false);
element.removeEventListener('error', imageEventTriggered, false);
removeFromElements(element);
}
function imageEventTriggered(event) {
removeImageLoadListener(event.target);
callback();
}
function mutationObserved(mutations) {
callback();
// deal with async image loads when tags are injected into the page
mutations.forEach(addImageLoadListeners);
}
function createMutationObserver(mutationTarget) {
let config = {
attributes: true,
attributeOldValue: false,
characterData: true,
characterDataOldValue: false,
childList: true,
subtree: true
};
let observer = new MutationObserver(mutationObserved);
observer.observe(mutationTarget, config); // eslint-disable-line ghost/ember/no-observers
return observer;
}
let imageElements = [];
let observer = createMutationObserver(target);
return {
disconnect() {
if ('disconnect' in observer) {
observer.disconnect(); // eslint-disable-line ghost/ember/no-observers
imageElements.forEach(removeImageLoadListener);
}
}
};
}
_resizeIframe(iframe) {
this._resizeDebounce = run.debounce(this, this.__debouncedResizeIframe, iframe, 66);
}
__debouncedResizeIframe(iframe) {
iframe.style.height = null;
// get ratio from nested iframe if present (eg, Vimeo)
const firstElement = iframe.contentDocument.body.firstChild;
if (firstElement.tagName === 'IFRAME') {
const widthAttr = firstElement.getAttribute('width');
if (widthAttr.indexOf('%') === -1) {
const width = parseInt(firstElement.getAttribute('width'));
const height = parseInt(firstElement.getAttribute('height'));
if (width && height) {
const ratio = width / height;
const newHeight = iframe.offsetWidth / ratio;
firstElement.style.height = `${newHeight}px`;
iframe.style.height = `${newHeight}px`;
return;
}
}
const heightAttr = firstElement.getAttribute('height');
if (heightAttr.indexOf('%') === -1) {
const height = parseInt(firstElement.getAttribute('height'));
iframe.style.height = `${height}px`;
return;
}
}
// otherwise use iframes internal height (eg, Instagram)
const height = iframe.contentDocument.scrollingElement.scrollHeight;
iframe.style.height = `${height}px`;
}
_setupWindowResizeHandler(iframe) {
window.removeEventListener('resize', this._windowResizeHandler);
this._windowResizeHandler = run.bind(this, this._resizeIframe, iframe);
window.addEventListener('resize', this._windowResizeHandler, {passive: true});
}
}