Ghost/ghost/admin/lib/koenig-editor/addon/components/koenig-plus-menu.js
Kevin Ansfield 516ad8297a 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
2021-11-23 09:20:30 +00:00

333 lines
11 KiB
JavaScript

import Component from '@ember/component';
import mobiledocParsers from 'mobiledoc-kit/parsers/mobiledoc';
import snippetIcon from '../utils/snippet-icon';
import {CARD_MENU} from '../options/cards';
import {computed} from '@ember/object';
import {htmlSafe} from '@ember/template';
import {run} from '@ember/runloop';
export default Component.extend({
// public attrs
classNames: ['absolute'],
attributeBindings: ['style', 'data-kg'],
editor: null,
editorRange: null,
snippets: null,
// internal properties
showButton: false,
showMenu: false,
top: 0,
'data-kg': 'plus-menu',
// private properties
_onResizeHandler: null,
_onWindowMousedownHandler: null,
_lastEditorRange: null,
_hasCursorButton: false,
_onMousemoveHandler: null,
_onKeydownHandler: null,
// closure actions
replaceWithCardSection() {},
style: computed('top', function () {
return htmlSafe(`top: ${this.top}px`);
}),
itemSections: computed('snippets.[]', function () {
let {snippets} = this;
let itemSections = [...CARD_MENU];
// TODO: move or create util, duplicated with koenig-slash-menu
if (snippets?.length) {
let snippetsSection = {
title: 'Snippets',
items: [],
rowLength: 1
};
snippets.forEach((snippet) => {
let snippetItem = {
label: snippet.name,
icon: snippetIcon(snippet),
type: 'snippet',
matches: [snippet.name.toLowerCase()]
};
if (this.deleteSnippet) {
snippetItem.deleteClicked = (event) => {
event.preventDefault();
event.stopImmediatePropagation();
this.deleteSnippet(snippet);
};
}
snippetsSection.items.push(snippetItem);
});
itemSections.push(snippetsSection);
}
return itemSections;
}),
init() {
this._super(...arguments);
this._onResizeHandler = run.bind(this, this._handleResize);
window.addEventListener('resize', this._onResizeHandler);
this._onMousemoveHandler = run.bind(this, this._mousemoveRaf);
window.addEventListener('mousemove', this._onMousemoveHandler);
},
didReceiveAttrs() {
this._super(...arguments);
let editorRange = this.editorRange;
// show the (+) button when the cursor is on a blank P tag
if (!this.showMenu && editorRange !== this._lastEditorRange) {
this._showOrHideButton(editorRange);
this._hasCursorButton = this.showButton;
}
// re-position again on next runloop, prevents incorrect position after
// adding a card at the bottom of the doc
if (this.showButton) {
run.next(this, this._positionMenu);
}
// hide the menu if the editor range has changed
if (!this._ignoreRangeChange && this.showMenu && editorRange && !editorRange.isBlank && !editorRange.isEqual(this._lastEditorRange)) {
this._hideMenu();
}
this._lastEditorRange = editorRange;
this._ignoreRangeChange = false;
},
willDestroyElement() {
this._super(...arguments);
run.cancel(this._throttleResize);
window.removeEventListener('mousedown', this._onWindowMousedownHandler);
window.removeEventListener('resize', this._onResizeHandler);
window.removeEventListener('mousemove', this._onMousemoveHandler);
window.removeEventListener('keydown', this._onKeydownHandler);
},
actions: {
openMenu() {
this._showMenu();
},
closeMenu() {
this._hideMenu();
},
itemClicked(item, event) {
if (event) {
event.preventDefault();
}
let range = this._editorRange;
if (item.type === 'card') {
this.replaceWithCardSection(item.replaceArg, range, item.payload);
}
if (item.type === 'snippet') {
let clickedSnippet = this.snippets.find(snippet => snippet.name === item.label);
if (clickedSnippet) {
let post = mobiledocParsers.parse(this.editor.builder, clickedSnippet.mobiledoc);
this.replaceWithPost(range, post);
}
}
if (item.type === 'selector') {
this.openSelectorComponent(item.selectorComponent, range);
}
this._hideButton();
this._hideMenu();
}
},
_showOrHideButton(editorRange) {
if (!editorRange) {
this._hideButton();
this._hideMenu();
return;
}
let {head: {section}} = editorRange;
// show the button if the range is a blank paragraph
if (editorRange && editorRange.isCollapsed && section && !section.isListItem && (section.isBlank || section.text === '')) {
this._editorRange = editorRange;
this._showButton();
this._hideMenu();
} else {
this._hideButton();
this._hideMenu();
}
},
_showButton() {
this._positionMenu();
this.set('showButton', true);
},
_hideButton() {
this.set('showButton', false);
},
// find the "top" position by grabbing the current sections
// render node and querying it's bounding rect. Setting "top"
// positions the button+menu container element [data-kg="plus-menu"]
_positionMenu() {
// use the cached range if available because `editorRange` may have been
// lost due to clicks on the open menu
let {head: {section}} = this._editorRange || this.editorRange;
if (section) {
let containerRect = this.element.parentNode.getBoundingClientRect();
let selectedElement = section.renderNode.element;
if (selectedElement) {
let selectedElementRect = selectedElement.getBoundingClientRect();
let top = selectedElementRect.top - containerRect.top;
this.set('top', top);
}
}
},
_showMenu() {
this.set('showMenu', true);
// move the cursor to the blank paragraph, ensures any selected card
// gets inserted in the correct place because editorRange will be
// wherever the cursor currently is if the menu was opened via a
// mouseover button
this._moveCaretToCachedEditorRange();
// focus the search immediately so that you can filter immediately
run.schedule('afterRender', this, function () {
this._focusSearch();
});
// watch the window for mousedown events so that we can close the menu
// when we detect a click outside
this._onWindowMousedownHandler = run.bind(this, this._handleWindowMousedown);
window.addEventListener('mousedown', this._onWindowMousedownHandler);
// watch for keydown events so that we can close the menu on Escape
this._onKeydownHandler = run.bind(this, this._handleKeydown);
window.addEventListener('keydown', this._onKeydownHandler);
},
_hideMenu() {
if (this.showMenu) {
// reset our cached editorRange
this._editorRange = null;
// stop watching the body for clicks and keydown
window.removeEventListener('mousedown', this._onWindowMousedownHandler);
window.removeEventListener('keydown', this._onKeydownHandler);
// hide the menu
this.set('showMenu', false);
}
},
_focusSearch() {
let search = this.element.querySelector('input');
if (search) {
search.focus();
}
},
_handleWindowMousedown(event) {
if (
!event.target.closest(`#${this.elementId}, .fullscreen-modal-container`)
) {
this._hideMenu();
}
},
_mousemoveRaf(event) {
if (!this._mousemoveTicking) {
requestAnimationFrame(run.bind(this, this._handleMousemove, event));
}
this._mousemoveTicking = true;
},
// show the (+) button when the mouse is over a blank P tag
_handleMousemove(event) {
if (!this.showMenu && this.element) {
let {pageX, pageY} = event;
let editor = this.editor;
// add a horizontal buffer to the pointer position so that the
// (+) button doesn't disappear when the mouse hovers over it due
// to it being outside of the editor canvas
let containerRect = this.element.parentNode.getBoundingClientRect();
if (pageX < containerRect.left) {
pageX = pageX + 40;
}
// grab a range from the editor position under the pointer. We can
// rely on the same show/hide behaviour of our cursor implementation
try {
let position = editor.positionAtPoint(pageX, pageY);
if (position) {
let pointerRange = position.toRange();
this._showOrHideButton(pointerRange);
}
} catch (e) {
// mobiledoc-kit can generate the following harmless error
// from positionAtPoint(x,y) whilst dragging a selection
// TypeError: Failed to execute 'compareDocumentPosition' on 'Node': parameter 1 is not of type 'Node'.
if (e instanceof TypeError === false) {
throw e;
}
}
// if the button is hidden due to the pointer not being over a blank
// P but we have a valid cursor position then fall back to the cursor
// positioning
if (!this.showButton && this._hasCursorButton) {
this._showOrHideButton(this.editorRange);
}
}
this._mousemoveTicking = false;
},
_handleKeydown(event) {
if (event.key === 'Escape') {
// reset the caret position so we have a caret after closing
this._moveCaretToCachedEditorRange();
this._hideMenu();
return;
}
let arrowKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'];
if (arrowKeys.includes(event.key)) {
this._hideMenu();
}
},
_handleResize() {
if (this.showButton) {
this._throttleResize = run.throttle(this, this._positionMenu, 100);
}
},
_moveCaretToCachedEditorRange() {
this._ignoreRangeChange = true;
this.set('editorRange', this._editorRange);
this.editor.selectRange(this._editorRange);
}
});