✨ 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:
parent
166c8ff5e6
commit
9bfd340885
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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']
|
||||
}]
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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">
|
||||
<div class="ghost-spinner spinner-blue"></div>
|
||||
</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}}
|
|
@ -0,0 +1 @@
|
|||
export {default} from 'koenig-editor/components/koenig-card-bookmark';
|
|
@ -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 |
Loading…
Reference in New Issue