Added bookmark card and integrated it as fallback for unknown embeds (#1293)

requires https://github.com/TryGhost/Ghost/pull/11024

With the bookmark card you can present links in a much richer format, similar to Twitter cards. If the URL points to a page with right meta information it can show the page title, excerpt, author, publisher and even a preview image.

Bookmark cards can be created in two ways:

1. pasting a link as the first thing in blank paragraph - we'll check to see if we can create an embed, if we can't then we'll create a bookmark card instead
2. manually selecting the bookmark card from the (+) menu or by typing "/bookmark<kbd>Enter</kbd>" or "/bookmark {url}<kbd>Enter</kbd>" for short (you might want to do this if you want the bookmark version instead of a full embed)

Pressing <kbd>Ctrl/Cmd+Z</kbd> after pasting will convert the bookmark card back to a link if that's preferred, alternatively a URL can be pasted with <kbd>Ctrl/Cmd+Shift+V</kbd> to avoid any automatic transformation to an embed/bookmark.

---

- adds "bookmark" card that functions similarly to the embed card
- if the oembed API request returns `type: "bookmark"` then the metadata is used to create a bookmark card
This commit is contained in:
Rishabh Garg 2019-08-27 19:40:31 +05:30 committed by Kevin Ansfield
parent 166c8ff5e6
commit 9bfd340885
9 changed files with 412 additions and 10 deletions

View File

@ -697,3 +697,17 @@
.CodeMirror .CodeMirror-selected {
background: color-mod(var(--blue) lightness(+30%));
}
figure {
margin: 0;
padding: 0;
}
.koenig-card-click-overlay {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 400;
}

View File

@ -806,13 +806,91 @@
/* Cards
/* --------------------------------------------------------------- */
.koenig-card-click-overlay {
.kg-bookmark-card {
width: 100%;
box-sizing: border-box;
border: 1px solid var(--whitegrey);
background: var(--white);
}
.koenig-editor__editor .kg-bookmark-container {
display: flex;
color: var(--darkgrey);
text-decoration: none;
box-shadow: none;
min-height: 120px; /* Just to make sure it's not a super-tiny box */
}
.kg-bookmark-content {
display: flex;
flex-direction: column;
flex-grow: 1;
flex-basis: 100%;
align-items: flex-start;
justify-content: flex-start;
padding: 20px;
}
.kg-bookmark-title {
font-size: 1.5rem;
line-height: 1.5em;
font-weight: 600;
}
.kg-bookmark-container:hover .kg-bookmark-title {
color: var(--blue);
}
.kg-bookmark-description {
display: -webkit-box;
font-size: 1.4rem;
line-height: 1.5em;
margin-top: 10px;
color: var(--middarkgrey);
font-weight: 400;
max-height: 44px;
overflow-y: hidden;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.kg-bookmark-thumbnail {
position: relative;
flex-grow: 1;
min-width: 33%;
}
.kg-bookmark-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 400;
left: 0;
}
.kg-bookmark-metadata {
color: var(--darkgrey);
font-size: 1.4rem;
font-weight: 500;
display: flex;
align-items: center;
margin-top: 14px;
}
.kg-bookmark-icon {
width: 20px;
height: 20px;
margin-right: 6px;
}
.kg-bookmark-author:after {
content: "•";
margin: 0 6px;
}
.kg-bookmark-publisher {
color: var(--blue);
}

View File

@ -0,0 +1,189 @@
import Component from '@ember/component';
import layout from '../templates/components/koenig-card-bookmark';
import {NO_CURSOR_MOVEMENT} from './koenig-editor';
import {computed} from '@ember/object';
import {utils as ghostHelperUtils} from '@tryghost/helpers';
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';
const {countWords} = ghostHelperUtils;
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() {},
registerComponent() {},
counts: computed('payload.{metadata,caption}', function () {
let imgCount = 0;
let wordCount = 0;
let metadata = this.payload.metadata;
let caption = this.payload.caption;
imgCount = (metadata && metadata.icon) ? (imgCount + 1) : imgCount;
imgCount = (metadata && metadata.thumbnail) ? (imgCount + 1) : imgCount;
let metadataWordCount = metadata ? (countWords(this.payload.metadata.title) + countWords(this.payload.metadata.description)) : 0;
wordCount = countWords(caption) + metadataWordCount;
return {
imageCount: imgCount,
wordCount: wordCount
};
}),
init() {
this._super(...arguments);
if (this.payload.url && !this.payload.metadata) {
this.convertUrl.perform(this.payload.url);
}
this.registerComponent(this);
},
didInsertElement() {
this._super(...arguments);
this._focusInput();
},
willDestroyElement() {
this._super(...arguments);
run.cancel(this._resizeDebounce);
if (this._iframeMutationObserver) {
this._iframeMutationObserver.disconnect();
}
window.removeEventListener('resize', this._windowResizeHandler);
},
actions: {
onDeselect() {
if (this.payload.url && !this.payload.metadata && !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') {
event.target.blur();
this.deleteCard();
}
},
updateCaption(caption) {
set(this.payload, 'caption', caption);
this.saveCard(this.payload, false);
},
retry() {
this.set('hasError', false);
},
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();
}
});
}
},
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,
type: 'bookmark'
}
});
if (!response.metadata) {
throw 'No metadata returned';
}
set(this.payload, 'linkOnError', undefined);
set(this.payload, 'metadata', response.metadata);
set(this.payload, 'type', response.type);
this.saveCard(this.payload, false);
} catch (err) {
if (this.payload.linkOnError) {
this.send('insertAsLink', {linkOnError: true});
return;
}
this.set('hasError', true);
}
}),
_focusInput() {
let urlInput = this.element.querySelector('[name="url"]');
if (urlInput) {
urlInput.focus();
}
},
_deleteIfEmpty() {
if (isBlank(this.payload.metadata) && !this.convertUrl.isRunning && !this.hasError) {
this.deleteCard(NO_CURSOR_MOVEMENT);
}
}
});

View File

@ -132,6 +132,24 @@ export default Component.extend({
postEditor.cancelSnapshot();
}
});
},
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);
}
});
}
},
@ -143,17 +161,25 @@ export default Component.extend({
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: {
url
}
data: requestData
});
if (response.type === 'bookmark') {
this.send('insertAsBookmark', response);
return;
}
if (!response.html) {
throw 'No HTML returned';
}
set(this.payload, 'linkOnError', undefined);
set(this.payload, 'isDirectUrl', undefined);
set(this.payload, 'html', response.html);
set(this.payload, 'type', response.type);
this.saveCard(this.payload, false);

View File

@ -886,7 +886,7 @@ export default Component.extend({
if (range && range.isCollapsed && range.headSection.isBlank && !range.headSection.isListItem) {
if (!this._modifierKeys.shift) {
editor.run((postEditor) => {
let payload = {url: text, linkOnError: true};
let payload = {url: text, linkOnError: true, isDirectUrl: true};
let card = postEditor.builder.createCardSection('embed', payload);
let nextSection = range.headSection.next;

View File

@ -9,6 +9,7 @@ export const CARD_COMPONENT_MAP = {
html: 'koenig-card-html',
code: 'koenig-card-code',
embed: 'koenig-card-embed',
bookmark: 'koenig-card-bookmark',
gallery: 'koenig-card-gallery'
};
@ -21,6 +22,7 @@ export const CARD_ICON_MAP = {
html: 'koenig/kg-card-type-html',
code: 'koenig/kg-card-type-gen-embed',
embed: 'koenig/kg-card-type-gen-embed',
bookmark: 'koenig/kg-card-type-bookmark',
gallery: 'koenig/kg-card-type-gallery'
};
@ -30,6 +32,7 @@ export default [
createComponentCard('card-markdown'), // backwards-compat with markdown editor
createComponentCard('code', {deleteIfEmpty: 'payload.code'}),
createComponentCard('embed', {hasEditMode: false, deleteIfEmpty: 'payload.html'}),
createComponentCard('bookmark', {hasEditMode: false, deleteIfEmpty: 'payload.metadata'}),
createComponentCard('hr', {hasEditMode: false, selectAfterInsert: false}),
createComponentCard('html', {deleteIfEmpty: 'payload.html'}),
createComponentCard('image', {hasEditMode: false, deleteIfEmpty(card) {
@ -82,6 +85,14 @@ export const CARD_MENU = [
matches: ['divider', 'horizontal-rule', 'hr'],
type: 'card',
replaceArg: 'hr'
},
{
label: 'Bookmark',
icon: 'koenig/kg-card-type-bookmark',
matches: ['bookmark'],
type: 'card',
replaceArg: 'bookmark',
params: ['url']
}]
},
{

View File

@ -0,0 +1,82 @@
{{#koenig-card
class="flex flex-column"
isSelected=isSelected
isEditing=isEditing
selectCard=(action selectCard)
deselectCard=(action deselectCard)
onDeselect=(action "onDeselect")
editCard=(action editCard)
toolbar=toolbar
hasEditMode=false
showSelectedOutline=payload.metadata
addParagraphAfterCard=addParagraphAfterCard
moveCursorToPrevSection=moveCursorToPrevSection
moveCursorToNextSection=moveCursorToNextSection
editor=editor
as |card|
}}
{{#if payload.metadata}}
<div class="kg-card-hover">
<div class="koenig-embed-{{payload.type}} flex justify-center relative" data-kg-embed>
{{!-- <iframe class="bn miw-100" scrolling="no"></iframe> --}}
<figure class="kg-card kg-bookmark-card also-new-tag">
<a href={{payload.metadata.url}} class="kg-bookmark-container">
<div class="kg-bookmark-content">
<div class="kg-bookmark-title">{{payload.metadata.title}}</div>
<div class="kg-bookmark-description">{{payload.metadata.description}}</div>
<div class="kg-bookmark-metadata">
{{#if payload.metadata.icon}}
<img src={{payload.metadata.icon}} class="kg-bookmark-icon">
{{/if}}
{{#if payload.metadata.author}}
<span class="kg-bookmark-author">{{payload.metadata.author}}</span>
{{/if}}
{{#if payload.metadata.publisher}}
<span class="kg-bookmark-publisher">{{payload.metadata.publisher}}</span>
{{/if}}
</div>
</div>
{{#if payload.metadata.thumbnail}}
<div class="kg-bookmark-thumbnail">
<img src={{payload.metadata.thumbnail}} >
</div>
{{/if}}
</a>
</figure>
<div class="koenig-card-click-overlay ba b--transparent" data-kg-overlay></div>
</div>
{{#if (or isSelected (clean-basic-html payload.caption))}}
{{card.captionInput
caption=payload.caption
update=(action "updateCaption")
placeholder="Type caption for bookmark (optional)"
}}
{{/if}}
</div>
{{else}}
{{#if convertUrl.isRunning}}
<div class="miw-100 pa2 ba br2 b--lightgrey-d1 flex items-center justify-center bg-whitegrey-l2 f6 lh-title h10">
&nbsp;<div class="ghost-spinner spinner-blue"></div>&nbsp;
</div>
{{else if hasError}}
<div class="miw-100 flex flex-row pa2 pl3 ba br2 b--red-l3 red bg-error-red f7 fw4 lh-title h10 items-center">
<span class="mr3">There was an error when parsing the URL.</span>
<button type="button" class="red-d2 mr3 fw6 hover-red" {{action "retry"}}><span class="underline">Retry</span></button>
<button type="button" class="red-d2 mr-auto fw6 underline hover-red" {{action "insertAsLink"}}><span class="underline">Paste URL as link</span></button>
<button type="button" {{action deleteCard}} class="nudge-right--2">
{{svg-jar "close" class="w3 stroke-red-l3"}}
</button>
</div>
{{else}}
<input
type="text"
value={{payload.url}}
name="url"
placeholder="Paste URL to add bookmark content..."
class="miw-100 pa2 ba br2 b--lightgrey-d2 f7 form-text lh-title tracked-2 h10 nl2 nr2"
oninput={{action "updateUrl"}}
onkeydown={{action "urlKeydown"}}>
{{/if}}
{{/if}}
{{/koenig-card}}

View File

@ -0,0 +1 @@
export {default} from 'koenig-editor/components/koenig-card-bookmark';

View File

@ -0,0 +1 @@
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><title>kg-card-type-bookmark</title><defs><rect id="b" width="272" height="499" rx="4"/><filter x="-10.3%" y="-4.6%" width="120.6%" height="111.2%" filterUnits="objectBoundingBox" id="a"><feOffset dy="5" in="SourceAlpha" result="shadowOffsetOuter1"/><feGaussianBlur stdDeviation="8.5" in="shadowOffsetOuter1" result="shadowBlurOuter1"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.09 0" in="shadowBlurOuter1"/></filter><path id="c" d="M.99 0H19v24H.99z"/></defs><g fill="none" fill-rule="evenodd"><g transform="translate(-200 -135)"><use fill="#000" filter="url(#a)" xlink:href="#b"/><use fill="#FFF" xlink:href="#b"/></g><path d="M32 2.667C32 .889 31.111 0 29.333 0H2.667C1.93 0 1.302.26.78.781.261 1.301 0 1.931 0 2.667v26.666C0 31.111.889 32 2.667 32h26.666C31.111 32 32 31.111 32 29.333V2.667z" fill="#3EB0EF"/><path stroke-opacity=".012" stroke="#000" stroke-width="0" d="M11 0h24v24H11z"/><g transform="translate(13)"><mask id="d" fill="#fff"><use xlink:href="#c"/></mask><path d="M2.998-.01v18.674c-.054.558.175.951.687 1.18.525.2.965.084 1.322-.35l2.689-2.683c.081-.081.18-.122.294-.122.116 0 .214.041.295.122l2.69 2.684c.245.274.552.417.92.428.139 0 .273-.026.401-.078.512-.23.741-.623.687-1.18V-.062l-9.985.05z" fill="#FFF" mask="url(#d)"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB