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:
Kevin Ansfield 2021-11-19 19:07:40 +00:00
parent 6a0088eba5
commit 48d0df3bdd
12 changed files with 283 additions and 15 deletions

View File

@ -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
/* --------------------------------------------------------------- */

View File

@ -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}}
/>

View File

@ -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);
}
},

View File

@ -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>

View File

@ -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();
}
}

View File

@ -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>

View File

@ -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`;
}
}
}

View File

@ -143,6 +143,10 @@ export default Component.extend({
}
}
if (item.type === 'selector') {
this.openSelectorComponent(item.selectorComponent, range);
}
this._hideButton();
this._hideMenu();
}

View File

@ -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();
}

View File

@ -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']
},
{

View File

@ -0,0 +1 @@
export {default} from 'koenig-editor/components/koenig-media-selector-tenor';

View File

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