mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-25 03:44:29 +03:00
Added media selector pattern to editor and used it for gifs
refs https://github.com/TryGhost/Team/issues/1225 Re-using the existing pattern of creating an image card and having it launch an image selector was proving to have a lot of edge cases when we wanted a more streamlined in-line image selector for gifs. - added a new `'selector'` type to card definitions - requires a `selectorComponent` argument that is the name of a component that renders the media and handles search - updated card components to open the selector component when respective menu item is activated - updated slash menu to instantly trigger the selector component when the slash command matches a card and is followed by a space so that searches continue inside the selector - added `<KoenigMediaSelector>` component that wraps the card-definition provided component and handles escape key, clicks outside of the editor, and provides a stripped down API to the child component for selecting/closing - added `<KoenigMediaSelectorTenor>` which mostly replicates the `<GhTenor>` component but has different styling and uses the provided media selector API
This commit is contained in:
parent
a41c159f2a
commit
516ad8297a
@ -925,6 +925,18 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* In-line media selector
|
||||
/* --------------------------------------------------------------- */
|
||||
|
||||
.kg-media-selector {
|
||||
position: absolute;
|
||||
width: 90%;
|
||||
height: 600px;
|
||||
background-color: white;
|
||||
border: 1px solid gray;
|
||||
border-radius: 1em;
|
||||
}
|
||||
|
||||
/* Card settings panel
|
||||
/* --------------------------------------------------------------- */
|
||||
|
||||
@ -1015,6 +1027,7 @@
|
||||
width: 34px !important;
|
||||
}
|
||||
|
||||
|
||||
/* Cards
|
||||
/* --------------------------------------------------------------- */
|
||||
|
||||
|
@ -48,6 +48,19 @@
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{!-- pop-up media selector --}}
|
||||
{{#if this.activeSelectorComponent}}
|
||||
<KoenigMediaSelector
|
||||
@editor={{this.editor}}
|
||||
@editorRange={{this.selectedRange}}
|
||||
@replaceWithCardSection={{action "replaceWithCardSection"}}
|
||||
@close={{action "closeSelectorComponent"}}
|
||||
as |selector|
|
||||
>
|
||||
{{component this.activeSelectorComponent selector=selector}}
|
||||
</KoenigMediaSelector>
|
||||
{{/if}}
|
||||
|
||||
{{!-- (+) icon and pop-up menu --}}
|
||||
<KoenigPlusMenu
|
||||
@editor={{this.editor}}
|
||||
@ -56,6 +69,7 @@
|
||||
@deleteSnippet={{this.deleteSnippet}}
|
||||
@replaceWithCardSection={{action "replaceWithCardSection"}}
|
||||
@replaceWithPost={{action "replaceWithPost"}}
|
||||
@openSelectorComponent={{action "openSelectorComponent"}}
|
||||
@postType={{@postType}}
|
||||
/>
|
||||
|
||||
@ -67,6 +81,7 @@
|
||||
@deleteSnippet={{this.deleteSnippet}}
|
||||
@replaceWithCardSection={{action "replaceWithCardSection"}}
|
||||
@replaceWithPost={{action "replaceWithPost"}}
|
||||
@openSelectorComponent={{action "openSelectorComponent"}}
|
||||
@postType={{@postType}}
|
||||
/>
|
||||
|
||||
|
@ -757,6 +757,23 @@ export default Component.extend({
|
||||
|
||||
postEditor.setRange(newPara.tailPosition());
|
||||
});
|
||||
},
|
||||
|
||||
openSelectorComponent(componentName, range) {
|
||||
if (range) {
|
||||
this.editor.selectRange(range);
|
||||
}
|
||||
|
||||
// wait 1ms for event loop to finish so mobiledoc-kit doesn't
|
||||
// get hung up processing keyboard events when focus has switched
|
||||
// to selector search input
|
||||
run.later(() => {
|
||||
this.set('activeSelectorComponent', componentName);
|
||||
});
|
||||
},
|
||||
|
||||
closeSelectorComponent() {
|
||||
this.set('activeSelectorComponent', null);
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -0,0 +1,71 @@
|
||||
<div class="flex flex-column h-100" {{did-insert this.didInsertContainer}}>
|
||||
{{!-- static header --}}
|
||||
<header class="flex-shrink-0 flex flex-row-l flex-column justify-between pa8 items-center">
|
||||
<h1 class="flex items-center darkgrey-d2 w-100 nudge-top--4">
|
||||
<a class="gh-tenor-logo" href="https://unsplash.com/?utm_source=ghost&utm_medium=referral&utm_campaign=api-credit" target="_blank">
|
||||
{{svg-jar "tenor"}}
|
||||
</a>
|
||||
</h1>
|
||||
<span class="gh-input-icon mw88-l flex-auto w-100 mt3 mt0-l">
|
||||
{{svg-jar "search"}}
|
||||
<GhTextInput
|
||||
@class="gh-unsplash-search"
|
||||
@name="searchKeyword"
|
||||
@placeholder="Search Tenor"
|
||||
@tabindex="1"
|
||||
@shouldFocus={{true}}
|
||||
@autocorrect="off"
|
||||
@value={{readonly this.tenor.searchTerm}}
|
||||
@input={{this.search}}
|
||||
/>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{{!-- content container --}}
|
||||
<div class="relative h-100 overflow-hidden">
|
||||
{{!-- scrollable image container --}}
|
||||
<div class="overflow-auto h-100 w-100 pa8">
|
||||
{{#if this.tenor.gifs}}
|
||||
<section class="gh-unsplash-grid">
|
||||
{{#each this.tenor.columns as |gifs|}}
|
||||
<div class="gh-unsplash-grid-column">
|
||||
{{#each gifs as |gif|}}
|
||||
<GhTenor::Gif
|
||||
@gif={{gif}}
|
||||
@select={{fn this.select gif}} />
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/each}}
|
||||
</section>
|
||||
{{else if (and this.tenor.searchTerm (not this.tenor.error this.tenor.isLoading))}}
|
||||
<section class="gh-unsplash-error h-100 flex items-center justify-center pb30">
|
||||
<div>
|
||||
<img class="gh-unsplash-error-404" src="assets/img/unsplash-404.png" alt="No photos found" />
|
||||
<h4>No gifs found for '{{this.tenor.searchTerm}}'</h4>
|
||||
</div>
|
||||
</section>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.tenor.error}}
|
||||
<section class="gh-unsplash-error h-100 flex items-center justify-center pb30">
|
||||
<div>
|
||||
<img class="gh-unsplash-error-404" src="assets/img/unsplash-404.png" alt="Network error" />
|
||||
<h4>{{this.tenor.error}} (<a href="#" {{on "click" this.tenor.retry}}>retry</a>)</h4>
|
||||
</div>
|
||||
</section>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.tenor.isLoading}}
|
||||
<div class="gh-unsplash-loading h-100 flex items-center justify-center pb30">
|
||||
<div class="gh-loading-spinner"></div>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#unless this.tenor.isLoading}}
|
||||
<GhScrollTrigger
|
||||
@enter={{this.tenor.loadNextPage}}
|
||||
@triggerOffset={{1000}} />
|
||||
{{/unless}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,66 @@
|
||||
import Component from '@glimmer/component';
|
||||
import {action} from '@ember/object';
|
||||
import {inject as service} from '@ember/service';
|
||||
|
||||
// number of columns based on selector container width
|
||||
const TWO_COLUMN_WIDTH = 540;
|
||||
const THREE_COLUMN_WIDTH = 940;
|
||||
|
||||
export default class KoenigMediaSelectorTenorComponent extends Component {
|
||||
@service tenor;
|
||||
|
||||
willDestroy() {
|
||||
super.willDestroy(...arguments);
|
||||
this._resizeObserver?.disconnect();
|
||||
}
|
||||
|
||||
@action
|
||||
search(event) {
|
||||
const term = event.target.value;
|
||||
this.tenor.updateSearch(term);
|
||||
}
|
||||
|
||||
@action
|
||||
didInsertContainer(containerElem) {
|
||||
if (this.args.searchTerm !== this.tenor.searchTerm) {
|
||||
this.tenor.updateSearch(this.args.searchTerm);
|
||||
}
|
||||
|
||||
this._resizeObserver = new ResizeObserver((entries) => {
|
||||
const [containerEntry] = entries;
|
||||
const contentBoxSize = Array.isArray(containerEntry.contentBoxSize) ? containerEntry.contentBoxSize[0] : containerEntry.contentBoxSize;
|
||||
|
||||
const width = contentBoxSize.inlineSize;
|
||||
|
||||
let columns = 4;
|
||||
|
||||
if (width <= TWO_COLUMN_WIDTH) {
|
||||
columns = 2;
|
||||
} else if (width <= THREE_COLUMN_WIDTH) {
|
||||
columns = 3;
|
||||
}
|
||||
|
||||
this.tenor.changeColumnCount(columns);
|
||||
});
|
||||
this._resizeObserver.observe(containerElem);
|
||||
}
|
||||
|
||||
@action
|
||||
select(gif, event) {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
const media = gif.media[0].gif;
|
||||
|
||||
const payload = {
|
||||
src: media.url,
|
||||
width: media.dims[0],
|
||||
height: media.dims[1],
|
||||
caption: '(Via <a href="https://tenor.com">Tenor</a>)',
|
||||
type: 'gif'
|
||||
};
|
||||
|
||||
this.args.selector.insertCard('image', payload);
|
||||
this.args.selector.close();
|
||||
}
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
<div class="kg-media-selector" {{did-insert this.didInsertContainer}} {{on-key "Escape" this.handleEscape}}>
|
||||
{{yield (hash
|
||||
insertCard=this.insertCard
|
||||
close=this.args.close
|
||||
)}}
|
||||
</div>
|
@ -0,0 +1,67 @@
|
||||
import Component from '@glimmer/component';
|
||||
import {action} from '@ember/object';
|
||||
import {run} from '@ember/runloop';
|
||||
|
||||
const Y_OFFSET = 0;
|
||||
|
||||
export default class KoenigMediaSelectorComponent extends Component {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
// store editor range for later because it might change if focus is lost
|
||||
this._editorRange = this.args.editorRange;
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
super.willDestroy(...arguments);
|
||||
window.removeEventListener('click', this.handleBackgroundClick);
|
||||
}
|
||||
|
||||
@action
|
||||
didInsertContainer(containerElem) {
|
||||
this._containerElem = containerElem;
|
||||
|
||||
this._positionSelector(this._editorRange);
|
||||
|
||||
// any click outside of the selector should close it and clear any /command
|
||||
// add with 1ms delay so current event loop finishes to avoid instaclose
|
||||
run.later(() => {
|
||||
window.addEventListener('click', this.handleBackgroundClick);
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
insertCard(cardName, payload) {
|
||||
this.args.replaceWithCardSection(cardName, this._editorRange, payload);
|
||||
this.args.close();
|
||||
}
|
||||
|
||||
@action
|
||||
handleBackgroundClick(event) {
|
||||
if (!this._containerElem.contains(event.target)) {
|
||||
this.args.editor.run((postEditor) => {
|
||||
postEditor.deleteRange(this._editorRange.tail.section.toRange());
|
||||
});
|
||||
this.args.close();
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
handleEscape() {
|
||||
this.args.close();
|
||||
this.args.editor.selectRange(this._editorRange.tail);
|
||||
}
|
||||
|
||||
_positionSelector(range) {
|
||||
let {head: {section}} = range;
|
||||
|
||||
if (section && section.renderNode.element) {
|
||||
let containerRect = this._containerElem.parentNode.getBoundingClientRect();
|
||||
let selectedElement = section.renderNode.element;
|
||||
let selectedElementRect = selectedElement.getBoundingClientRect();
|
||||
let top = selectedElementRect.top - containerRect.top + Y_OFFSET;
|
||||
|
||||
this._containerElem.style.top = `${top}px`;
|
||||
}
|
||||
}
|
||||
}
|
@ -143,6 +143,10 @@ export default Component.extend({
|
||||
}
|
||||
}
|
||||
|
||||
if (item.type === 'selector') {
|
||||
this.openSelectorComponent(item.selectorComponent, range);
|
||||
}
|
||||
|
||||
this._hideButton();
|
||||
this._hideMenu();
|
||||
}
|
||||
|
@ -128,6 +128,12 @@ export default class KoenigSlashMenuComponent extends Component {
|
||||
this.selectedColumnIndex = 0;
|
||||
}
|
||||
|
||||
// open a selector item type immediately if it's followed by a space
|
||||
// to allow instant media searching
|
||||
if (matchedItems[0]?.items[0]?.type === 'selector' && this.query.charAt(this.query.length - 1) === ' ') {
|
||||
this.itemClicked(matchedItems[0].items[0]);
|
||||
}
|
||||
|
||||
this.itemSections = matchedItems;
|
||||
}
|
||||
|
||||
@ -182,7 +188,6 @@ export default class KoenigSlashMenuComponent extends Component {
|
||||
|
||||
let range = this._openRange.head.section.toRange();
|
||||
let [, ...params] = this.query.split(/\s/);
|
||||
let payload = Object.assign({}, item.payload);
|
||||
|
||||
// make sure the click doesn't propagate and get picked up by the
|
||||
// newly inserted card which can then remove itself because it
|
||||
@ -192,14 +197,16 @@ export default class KoenigSlashMenuComponent extends Component {
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
|
||||
// params are order-dependent and listed in CARD_MENU for each card
|
||||
if (!isEmpty(item.params) && !isEmpty(params)) {
|
||||
item.params.forEach((param, i) => {
|
||||
payload[param] = params[i];
|
||||
});
|
||||
}
|
||||
|
||||
if (item.type === 'card') {
|
||||
let payload = Object.assign({}, item.payload);
|
||||
|
||||
// params are order-dependent and listed in CARD_MENU for each card
|
||||
if (!isEmpty(item.params) && !isEmpty(params)) {
|
||||
item.params.forEach((param, i) => {
|
||||
payload[param] = params[i];
|
||||
});
|
||||
}
|
||||
|
||||
this.args.replaceWithCardSection(item.replaceArg, range, payload);
|
||||
}
|
||||
|
||||
@ -211,6 +218,10 @@ export default class KoenigSlashMenuComponent extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
if (item.type === 'selector') {
|
||||
this.args.openSelectorComponent(item.selectorComponent);
|
||||
}
|
||||
|
||||
this._hideMenu();
|
||||
}
|
||||
|
||||
|
@ -182,15 +182,11 @@ export const CARD_MENU = [
|
||||
{
|
||||
label: 'GIF',
|
||||
icon: 'koenig/kg-card-type-gif',
|
||||
desc: '/gif [url or search term]',
|
||||
desc: '/gif [search term]',
|
||||
iconClass: 'kg-card-type-unsplash',
|
||||
matches: ['gif', 'giphy', 'tenor'],
|
||||
type: 'card',
|
||||
replaceArg: 'image',
|
||||
params: ['searchTerm'],
|
||||
payload: {
|
||||
imageSelector: 'tenor'
|
||||
},
|
||||
type: 'selector',
|
||||
selectorComponent: 'koenig-media-selector-tenor',
|
||||
isAvailable: ['feature.gifsCard', 'config.tenor.apiKey']
|
||||
},
|
||||
{
|
||||
|
@ -0,0 +1 @@
|
||||
export {default} from 'koenig-editor/components/koenig-media-selector-tenor';
|
@ -0,0 +1 @@
|
||||
export {default} from 'koenig-editor/components/koenig-media-selector';
|
Loading…
Reference in New Issue
Block a user