mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-28 22:43:30 +03:00
c6753a0efd
no issue - snippets can only be created and deleted by owners/admins/editors - added a property in the editor controller to determine if the logged in user has sufficient permissions, then only pass the appropriate save/delete snippet actions to the editor component if the check is passed - updates koenig menus and toolbars to skip rendering of buttons if the associated action function is not available
330 lines
11 KiB
JavaScript
330 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/string';
|
|
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,
|
|
developerExperiment: true
|
|
};
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
});
|