Skip to content
This repository has been archived by the owner on Nov 28, 2022. It is now read-only.

Commit

Permalink
✨ Koenig - Embed card
Browse files Browse the repository at this point in the history
refs TryGhost/Ghost#9623
requires TryGhost/Ghost#9666
- `{{koenig-card-embed}}`
    - URL input
    - perform oembed lookup & fetch on <kbd>Enter</kbd>
    - remove card if enter pressed with empty URL
    - show error message on server error
        - "retry" returns to input bar
        - "paste as link" removes card and outputs link
        - "X" removes card
    - force embedded <script> tags to run
    - wrap embed html with `.koenig-embed-{video,photo,rich}` class
- add embed cards to the (+) and /-menus
    - "section" support in the card menus
    - refactor to use single card menu map and content component for both menus
    - update /-menu keyboard movement to handle sections
- add parameter support to /-menu commands
  - `/embed {url}` will insert embed card and automatically fetch oembed for supplied url
  • Loading branch information
kevinansfield committed Jun 12, 2018
1 parent f58d462 commit 7967ee3
Show file tree
Hide file tree
Showing 15 changed files with 647 additions and 115 deletions.
243 changes: 243 additions & 0 deletions lib/koenig-editor/addon/components/koenig-card-embed.js
@@ -0,0 +1,243 @@
import Component from '@ember/component';
import layout from '../templates/components/koenig-card-embed';
import {NO_CURSOR_MOVEMENT} from './koenig-editor';
import {isBlank} from '@ember/utils';
import {run} from '@ember/runloop';
import {inject as service} from '@ember/service';
import {set} from '@ember/object';
import {task} from 'ember-concurrency';

export default Component.extend({
ajax: service(),
ghostPaths: service(),

layout,

// attrs
payload: null,
isSelected: false,
isEditing: false,

// internal properties
hasError: false,

// closure actions
selectCard() {},
deselectCard() {},
editCard() {},
saveCard() {},
deleteCard() { },
moveCursorToNextSection() { },
moveCursorToPrevSection() { },
addParagraphAfterCard() { },

init() {
this._super(...arguments);
if (this.payload.url && !this.payload.html) {
this.convertUrl.perform(this.payload.url);
}
},

didInsertElement() {
this._super(...arguments);
this._loadPayloadScript();
},

willDestroyElement() {
this._super(...arguments);
this._detachHandlers();
},

actions: {
onSelect() {
this._attachHandlers();
},

onDeselect() {
this._detachHandlers();

if (this.payload.url && !this.payload.html && !this.hasError) {
this.convertUrl.perform(this.payload.url);
} else {
this._deleteIfEmpty();
}
},

updateUrl(event) {
let url = event.target.value;
set(this.payload, 'url', url);
},

urlKeydown(event) {
if (event.key === 'Enter') {
event.preventDefault();
this.convertUrl.perform(this.payload.url);
}

if (event.key === 'Escape') {
this.deleteCard();
}
},

updateCaption(caption) {
set(this.payload, 'caption', caption);
this.saveCard(this.payload, false);
},

retry() {
this.set('hasError', false);
},

insertAsLink() {
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]);
});
}
},

_attachHandlers() {
if (!this._keypressHandler) {
this._keypressHandler = run.bind(this, this._handleKeypress);
window.addEventListener('keypress', this._keypressHandler);
}

if (!this._keydownHandler) {
this._keydownHandler = run.bind(this, this._handleKeydown);
window.addEventListener('keydown', this._keydownHandler);
}
},

_detachHandlers() {
window.removeEventListener('keypress', this._keypressHandler);
window.removeEventListener('keydown', this._keydownHandler);
this._keypressHandler = null;
this._keydownHandler = null;
},

// only fires if the card is selected, moves focus to the caption input so
// that it's possible to start typing without explicitly focusing the input
_handleKeypress(event) {
let captionInput = this.element.querySelector('[name="caption"]');

if (captionInput && captionInput !== document.activeElement) {
captionInput.value = `${captionInput.value}${event.key}`;
captionInput.focus();
}
},

// this will be fired for keydown events when the caption input is focused,
// we look for cursor movements or the enter key to defocus and trigger the
// corresponding editor behaviour
_handleKeydown(event) {
let captionInput = this.element.querySelector('[name="caption"]');

if (event.target === captionInput) {
if (event.key === 'Escape') {
captionInput.blur();
return;
}

if (event.key === 'Enter') {
captionInput.blur();
this.addParagraphAfterCard();
event.preventDefault();
return;
}

let selectionStart = captionInput.selectionStart;
let length = captionInput.value.length;

if ((event.key === 'ArrowUp' || event.key === 'ArrowLeft') && selectionStart === 0) {
captionInput.blur();
this.moveCursorToPrevSection();
event.preventDefault();
return;
}

if ((event.key === 'ArrowDown' || event.key === 'ArrowRight') && selectionStart === length) {
captionInput.blur();
this.moveCursorToNextSection();
event.preventDefault();
return;
}
}
},

convertUrl: task(function* (url) {
if (isBlank(url)) {
this.deleteCard();
return;
}

try {
let oembedEndpoint = this.ghostPaths.url.api('oembed');
let response = yield this.ajax.request(oembedEndpoint, {
data: {
url
}
});

if (!response.html) {
throw 'No HTML returned';
}

set(this.payload, 'html', response.html);
set(this.payload, 'type', response.type);
this.saveCard(this.payload, false);

run.schedule('afterRender', this, this._loadPayloadScript);
} catch (err) {
this.set('hasError', true);
}
}),

// some oembeds will have a script tag but it won't automatically run
// due to the way Ember renders the card components. Grab the script
// element and push a new one to force the browser to download+run it
_loadPayloadScript() {
let oldScript = this.element.querySelector('script');
if (oldScript) {
let parent = oldScript.parentElement;
let newScript = document.createElement('script');
newScript.type = 'text/javascript';

if (oldScript.src) {
// hide the original embed html to avoid ugly transitions as the
// script runs (at least on reasonably good network and cpu)
let embedElement = this.element.querySelector('[data-kg-embed]');
embedElement.style.display = 'none';

newScript.src = oldScript.src;

// once the script has loaded, wait a little while for it to do it's
// thing before making everything visible again
newScript.onload = run.bind(this, function () {
run.later(this, function () {
embedElement.style.display = null;
}, 500);
});

newScript.onerror = run.bind(this, function () {
embedElement.style.display = null;
});
} else {
newScript.innerHTML = oldScript.innerHTML;
}

oldScript.remove();
parent.appendChild(newScript);
}
},

_deleteIfEmpty() {
if (isBlank(this.payload.html) && !this.convertUrl.isRunning && !this.hasError) {
this.deleteCard(NO_CURSOR_MOVEMENT);
}
}
});
9 changes: 8 additions & 1 deletion lib/koenig-editor/addon/components/koenig-card.js
Expand Up @@ -9,7 +9,7 @@ const TICK_HEIGHT = 8;
export default Component.extend({
layout,
attributeBindings: ['style'],
classNameBindings: ['isSelected:kg-card-selected'],
classNameBindings: ['selectedClass'],

// attrs
icon: null,
Expand All @@ -19,6 +19,7 @@ export default Component.extend({
isEditing: false,
hasEditMode: true,
headerOffset: 0,
showSelectedOutline: true,

// properties
showToolbar: false,
Expand Down Expand Up @@ -65,6 +66,12 @@ export default Component.extend({
return this.headerOffset + 24;
}),

selectedClass: computed('isSelected', 'showSelectedOutline', function () {
if (this.isSelected && this.showSelectedOutline) {
return 'kg-card-selected';
}
}),

didReceiveAttrs() {
this._super(...arguments);

Expand Down
8 changes: 5 additions & 3 deletions lib/koenig-editor/addon/components/koenig-editor.js
Expand Up @@ -56,7 +56,8 @@ export const CARD_COMPONENT_MAP = {
markdown: 'koenig-card-markdown',
'card-markdown': 'koenig-card-markdown', // backwards-compat with markdown editor
html: 'koenig-card-html',
code: 'koenig-card-code'
code: 'koenig-card-code',
embed: 'koenig-card-embed'
};

export const CURSOR_BEFORE = -1;
Expand Down Expand Up @@ -463,13 +464,13 @@ export default Component.extend({
this._performEdit(operation, postEditor);
},

replaceWithCardSection(cardName, range) {
replaceWithCardSection(cardName, range, payload) {
let editor = this.editor;
let {head: {section}} = range;

editor.run((postEditor) => {
let {builder} = postEditor;
let card = builder.createCardSection(cardName);
let card = builder.createCardSection(cardName, payload);
let nextSection = section.next;
let needsTrailingParagraph = !nextSection;

Expand All @@ -491,6 +492,7 @@ export default Component.extend({
// is actually present
run.schedule('afterRender', this, function () {
let card = this.componentCards.lastObject;

if (card.koenigOptions.hasEditMode) {
this.editCard(card);
} else if (card.koenigOptions.selectAfterInsert) {
Expand Down
12 changes: 12 additions & 0 deletions lib/koenig-editor/addon/components/koenig-menu-content.js
@@ -0,0 +1,12 @@
import Component from '@ember/component';
import layout from '../templates/components/koenig-menu-content';

export default Component.extend({
layout,

tagName: '',

itemSections: null,

itemClicked() {}
});
10 changes: 8 additions & 2 deletions lib/koenig-editor/addon/components/koenig-plus-menu.js
@@ -1,5 +1,6 @@
import Component from '@ember/component';
import layout from '../templates/components/koenig-plus-menu';
import {CARD_MENU} from '../options/cards';
import {computed} from '@ember/object';
import {htmlSafe} from '@ember/string';
import {run} from '@ember/runloop';
Expand All @@ -14,6 +15,7 @@ export default Component.extend({
editorRange: null,

// internal properties
itemSections: null,
showButton: false,
showMenu: false,
top: 0,
Expand All @@ -37,6 +39,8 @@ export default Component.extend({
init() {
this._super(...arguments);

this.itemSections = CARD_MENU;

this._onResizeHandler = run.bind(this, this._handleResize);
window.addEventListener('resize', this._onResizeHandler);

Expand Down Expand Up @@ -82,10 +86,12 @@ export default Component.extend({
this._hideMenu();
},

replaceWithCardSection(cardName) {
itemClicked(item) {
let range = this._editorRange;

this.replaceWithCardSection(cardName, range);
if (item.type === 'card') {
this.replaceWithCardSection(item.replaceArg, range);
}

this._hideButton();
this._hideMenu();
Expand Down

0 comments on commit 7967ee3

Please sign in to comment.