mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-23 10:53:34 +03:00
e79120c339
refs https://github.com/TryGhost/Ghost/issues/9623 - close menu if it's open and the cursor position changes within the document - closes when you start typing rather than hiding text behind the menu - watch for arrow keys and close the menu if pressed - closes when <kbd>Up</kbd> is pressed and the title is selected rather than the cursor being moved within the document
281 lines
8.9 KiB
JavaScript
281 lines
8.9 KiB
JavaScript
import Component from '@ember/component';
|
|
import layout from '../templates/components/koenig-plus-menu';
|
|
import {CARD_MENU} from '../options/cards';
|
|
import {computed} from '@ember/object';
|
|
import {htmlSafe} from '@ember/string';
|
|
import {run} from '@ember/runloop';
|
|
|
|
export default Component.extend({
|
|
layout,
|
|
|
|
// public attrs
|
|
classNames: ['absolute'],
|
|
attributeBindings: ['style', 'data-kg'],
|
|
editor: null,
|
|
editorRange: null,
|
|
|
|
// internal properties
|
|
itemSections: null,
|
|
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`);
|
|
}),
|
|
|
|
init() {
|
|
this._super(...arguments);
|
|
|
|
this.itemSections = CARD_MENU;
|
|
|
|
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.showMenu && editorRange && !editorRange.isBlank && !editorRange.isEqual(this._lastEditorRange)) {
|
|
this._hideMenu();
|
|
}
|
|
|
|
this._lastEditorRange = editorRange;
|
|
},
|
|
|
|
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) {
|
|
let range = this._editorRange;
|
|
|
|
if (item.type === 'card') {
|
|
this.replaceWithCardSection(item.replaceArg, 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}`)) {
|
|
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) {
|
|
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.code === '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.code)) {
|
|
this._hideMenu();
|
|
}
|
|
},
|
|
|
|
_handleResize() {
|
|
if (this.showButton) {
|
|
this._throttleResize = run.throttle(this, this._positionMenu, 100);
|
|
}
|
|
},
|
|
|
|
_moveCaretToCachedEditorRange() {
|
|
this.set('editorRange', this._editorRange);
|
|
this.editor.selectRange(this._editorRange);
|
|
}
|
|
|
|
});
|