mirror of
https://github.com/TryGhost/Ghost-Admin.git
synced 2023-12-14 02:33:04 +01:00
Koenig - Slash menu
refs https://github.com/TryGhost/Ghost/issues/9311 - adds `{{koenig-slash-menu}}` component that renders a quick-access card/block menu when typing `/` at the beginning of a new paragraph
This commit is contained in:
parent
f949c2cef6
commit
2d95392624
7 changed files with 385 additions and 1 deletions
|
@ -235,6 +235,10 @@
|
||||||
|
|
||||||
/* Slash shortcut menu ------------------------------------------------------ */
|
/* Slash shortcut menu ------------------------------------------------------ */
|
||||||
|
|
||||||
|
.koenig-slash-menu {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
/* Menu items --------------------------------------------------------------- */
|
/* Menu items --------------------------------------------------------------- */
|
||||||
|
|
||||||
/* Chrome has a bug with its scrollbars on this element which has been reported here: https://bugs.chromium.org/p/chromium/issues/detail?id=697381 */
|
/* Chrome has a bug with its scrollbars on this element which has been reported here: https://bugs.chromium.org/p/chromium/issues/detail?id=697381 */
|
||||||
|
|
|
@ -48,7 +48,7 @@ export default Component.extend({
|
||||||
|
|
||||||
let editorRange = this.get('editorRange');
|
let editorRange = this.get('editorRange');
|
||||||
|
|
||||||
// show the (+) button when the cursor as on a blank P tag
|
// show the (+) button when the cursor is on a blank P tag
|
||||||
if (!this.get('showMenu') && editorRange !== this._lastEditorRange) {
|
if (!this.get('showMenu') && editorRange !== this._lastEditorRange) {
|
||||||
this._showOrHideButton(editorRange);
|
this._showOrHideButton(editorRange);
|
||||||
this._hasCursorButton = this.get('showButton');
|
this._hasCursorButton = this.get('showButton');
|
||||||
|
|
337
lib/koenig-editor/addon/components/koenig-slash-menu.js
Normal file
337
lib/koenig-editor/addon/components/koenig-slash-menu.js
Normal file
|
@ -0,0 +1,337 @@
|
||||||
|
import Component from '@ember/component';
|
||||||
|
import layout from '../templates/components/koenig-slash-menu';
|
||||||
|
import {computed} from '@ember/object';
|
||||||
|
import {copy} from '@ember/object/internals';
|
||||||
|
import {htmlSafe} from '@ember/string';
|
||||||
|
import {run} from '@ember/runloop';
|
||||||
|
import {set} from '@ember/object';
|
||||||
|
|
||||||
|
const ROW_LENGTH = 4;
|
||||||
|
|
||||||
|
const ITEM_MAP = [
|
||||||
|
{
|
||||||
|
label: 'Markdown',
|
||||||
|
icon: 'koenig/markdown',
|
||||||
|
matches: ['markdown', 'md'],
|
||||||
|
type: 'card',
|
||||||
|
replaceArg: 'markdown'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Image',
|
||||||
|
icon: 'koenig/image',
|
||||||
|
matches: ['image', 'img'],
|
||||||
|
type: 'card',
|
||||||
|
replaceArg: 'image'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Embed',
|
||||||
|
icon: 'koenig/image',
|
||||||
|
matches: ['embed', 'html'],
|
||||||
|
type: 'card',
|
||||||
|
replaceArg: 'html'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Divider',
|
||||||
|
icon: 'koenig/divider',
|
||||||
|
matches: ['divider', 'horizontal-rule', 'hr'],
|
||||||
|
type: 'card',
|
||||||
|
replaceArg: 'hr'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Bullet list',
|
||||||
|
icon: 'koenig/list-bullets',
|
||||||
|
matches: ['list-bullet', 'bullet', 'ul'],
|
||||||
|
type: 'list',
|
||||||
|
replaceArg: 'ul'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Number list',
|
||||||
|
icon: 'koenig/list-number',
|
||||||
|
matches: ['list-number', 'number', 'ol'],
|
||||||
|
type: 'list',
|
||||||
|
replaceArg: 'ol'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export default Component.extend({
|
||||||
|
layout,
|
||||||
|
|
||||||
|
// public attrs
|
||||||
|
classNames: 'koenig-slash-menu',
|
||||||
|
attributeBindings: ['style'],
|
||||||
|
editor: null,
|
||||||
|
editorRange: null,
|
||||||
|
|
||||||
|
// public properties
|
||||||
|
showMenu: false,
|
||||||
|
top: 0,
|
||||||
|
icons: null,
|
||||||
|
|
||||||
|
// private properties
|
||||||
|
_openRange: null,
|
||||||
|
_query: '',
|
||||||
|
_onWindowMousedownHandler: null,
|
||||||
|
|
||||||
|
// closure actions
|
||||||
|
replaceWithCardSection() {},
|
||||||
|
replaceWithListSection() {},
|
||||||
|
|
||||||
|
style: computed('top', function () {
|
||||||
|
return htmlSafe(`top: ${this.get('top')}px`);
|
||||||
|
}),
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this._super(...arguments);
|
||||||
|
let editor = this.get('editor');
|
||||||
|
|
||||||
|
// register `/` text input for positioning & showing the menu
|
||||||
|
editor.onTextInput({
|
||||||
|
name: 'slash_menu',
|
||||||
|
text: '/',
|
||||||
|
run: run.bind(this, this._showMenu)
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
didReceiveAttrs() {
|
||||||
|
this._super(...arguments);
|
||||||
|
let editorRange = this.get('editorRange');
|
||||||
|
|
||||||
|
// re-position the menu and update the query if necessary when the
|
||||||
|
// cursor position changes
|
||||||
|
if (editorRange !== this._lastEditorRange) {
|
||||||
|
this._handleCursorChange(editorRange);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._lastEditorRange = editorRange;
|
||||||
|
},
|
||||||
|
|
||||||
|
willDestroyElement() {
|
||||||
|
this._super(...arguments);
|
||||||
|
window.removeEventListener('mousedown', this._onMousedownHandler);
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
itemClicked(item) {
|
||||||
|
let range = this._openRange.head.section.toRange();
|
||||||
|
|
||||||
|
if (item.type === 'card') {
|
||||||
|
this.replaceWithCardSection(item.replaceArg, range);
|
||||||
|
} else if (item.type === 'list') {
|
||||||
|
this.replaceWithListSection(item.replaceArg, range);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._hideMenu();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_handleCursorChange(editorRange) {
|
||||||
|
// update menu position to match cursor position
|
||||||
|
this._positionMenu(editorRange);
|
||||||
|
|
||||||
|
// close the menu if we're on a non-slash section (eg, when / is deleted)
|
||||||
|
if (this.get('showMenu') && editorRange.head.section && editorRange.head.section.text.indexOf('/') !== 0) {
|
||||||
|
this._hideMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// update the query when the menu is open and cursor is in our open range
|
||||||
|
if (this.get('showMenu') && editorRange.head.section === this._openRange.head.section) {
|
||||||
|
let query = editorRange.head.section.text.substring(
|
||||||
|
this._openRange.head.offset,
|
||||||
|
editorRange.head.offset
|
||||||
|
);
|
||||||
|
this._updateQuery(query);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_updateQuery(query) {
|
||||||
|
let matchedItems = ITEM_MAP.filter((item) => {
|
||||||
|
// show all items before anything is typed
|
||||||
|
if (!query) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// show icons where there's a match of the begining of one of the
|
||||||
|
// "item.matches" strings
|
||||||
|
let matches = item.matches.filter(match => match.indexOf(query) === 0);
|
||||||
|
return matches.length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// we need a copy to avoid modifying the object references
|
||||||
|
let items = copy(matchedItems, true);
|
||||||
|
|
||||||
|
if (items.length) {
|
||||||
|
set(items[0], 'selected', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.set('items', items);
|
||||||
|
},
|
||||||
|
|
||||||
|
_showMenu() {
|
||||||
|
let editorRange = this.get('editorRange');
|
||||||
|
let {head: {section}} = editorRange;
|
||||||
|
|
||||||
|
// only show the menu if the slash is on an otherwise empty paragraph
|
||||||
|
if (!this.get('showMenu') && editorRange.isCollapsed && section && !section.isListItem && section.text === '/') {
|
||||||
|
this.set('showMenu', true);
|
||||||
|
|
||||||
|
// ensure all items are shown before we have a query filter
|
||||||
|
this._updateQuery('');
|
||||||
|
|
||||||
|
// store a ref to the range when the menu was triggered so that we
|
||||||
|
// can query text after the slash
|
||||||
|
this._openRange = this.get('editorRange');
|
||||||
|
|
||||||
|
// set up key handlers for selection & closing
|
||||||
|
this._registerKeyboardNavHandlers();
|
||||||
|
|
||||||
|
// watch the window for mousedown events so that we can close the
|
||||||
|
// menu when we detect a click outside. This is preferable to
|
||||||
|
// watching the range because the range will change and remove the
|
||||||
|
// menu before click events on the buttons are registered
|
||||||
|
this._onWindowMousedownHandler = run.bind(this, (event) => {
|
||||||
|
this._handleWindowMousedown(event);
|
||||||
|
});
|
||||||
|
window.addEventListener('mousedown', this._onWindowMousedownHandler);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_hideMenu() {
|
||||||
|
if (this.get('showMenu')) {
|
||||||
|
this.set('showMenu', false);
|
||||||
|
this._unregisterKeyboardNavHandlers();
|
||||||
|
window.removeEventListener('mousedown', this._onWindowMousedownHandler);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_handleWindowMousedown(event) {
|
||||||
|
// clicks outside the menu should always close
|
||||||
|
if (!event.target.closest(`#${this.elementId}`)) {
|
||||||
|
this._hideMenu();
|
||||||
|
|
||||||
|
// clicks on the menu but not on a button should be ignored so that the
|
||||||
|
// cursor position isn't lost
|
||||||
|
} else if (!event.target.closest('.koenig-cardmenu-card')) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_positionMenu(range) {
|
||||||
|
if (!range) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {head: {section}} = range;
|
||||||
|
|
||||||
|
if (section) {
|
||||||
|
let containerRect = this.element.parentNode.getBoundingClientRect();
|
||||||
|
let selectedElement = section.renderNode.element;
|
||||||
|
let selectedElementRect = selectedElement.getBoundingClientRect();
|
||||||
|
let top = selectedElementRect.top + selectedElementRect.height - containerRect.top;
|
||||||
|
|
||||||
|
this.set('top', top);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_registerKeyboardNavHandlers() {
|
||||||
|
// ESC = close menu
|
||||||
|
// ARROWS = selection
|
||||||
|
let editor = this.get('editor');
|
||||||
|
|
||||||
|
editor.registerKeyCommand({
|
||||||
|
str: 'ESC',
|
||||||
|
name: 'slash-menu',
|
||||||
|
run: run.bind(this, this._hideMenu)
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.registerKeyCommand({
|
||||||
|
str: 'ENTER',
|
||||||
|
name: 'slash-menu',
|
||||||
|
run: run.bind(this, this._performAction)
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.registerKeyCommand({
|
||||||
|
str: 'UP',
|
||||||
|
name: 'slash-menu',
|
||||||
|
run: run.bind(this, this._moveSelection, 'up')
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.registerKeyCommand({
|
||||||
|
str: 'DOWN',
|
||||||
|
name: 'slash-menu',
|
||||||
|
run: run.bind(this, this._moveSelection, 'down')
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.registerKeyCommand({
|
||||||
|
str: 'LEFT',
|
||||||
|
name: 'slash-menu',
|
||||||
|
run: run.bind(this, this._moveSelection, 'left')
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.registerKeyCommand({
|
||||||
|
str: 'RIGHT',
|
||||||
|
name: 'slash-menu',
|
||||||
|
run: run.bind(this, this._moveSelection, 'right')
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_performAction() {
|
||||||
|
let selectedItem = this._getSelectedItem();
|
||||||
|
|
||||||
|
if (selectedItem) {
|
||||||
|
this.send('itemClicked', selectedItem);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_getSelectedItem() {
|
||||||
|
let items = this.get('items');
|
||||||
|
|
||||||
|
if (items.length <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.find(item => item.selected);
|
||||||
|
},
|
||||||
|
|
||||||
|
_moveSelection(direction) {
|
||||||
|
let items = this.get('items');
|
||||||
|
let selectedItem = this._getSelectedItem();
|
||||||
|
let selectedIndex = items.indexOf(selectedItem);
|
||||||
|
let lastIndex = items.length - 1;
|
||||||
|
|
||||||
|
if (lastIndex <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(selectedItem, 'selected', false);
|
||||||
|
|
||||||
|
if (direction === 'right') {
|
||||||
|
selectedIndex += 1;
|
||||||
|
if (selectedIndex > lastIndex) {
|
||||||
|
selectedIndex = 0;
|
||||||
|
}
|
||||||
|
} else if (direction === 'left') {
|
||||||
|
selectedIndex -= 1;
|
||||||
|
if (selectedIndex < 0) {
|
||||||
|
selectedIndex = lastIndex;
|
||||||
|
}
|
||||||
|
} else if (direction === 'up') {
|
||||||
|
selectedIndex -= ROW_LENGTH;
|
||||||
|
if (selectedIndex < 0) {
|
||||||
|
selectedIndex += ROW_LENGTH;
|
||||||
|
}
|
||||||
|
} else if (direction === 'down') {
|
||||||
|
selectedIndex += ROW_LENGTH;
|
||||||
|
if (selectedIndex > lastIndex) {
|
||||||
|
selectedIndex -= ROW_LENGTH;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set(items[selectedIndex], 'selected', true);
|
||||||
|
},
|
||||||
|
|
||||||
|
_unregisterKeyboardNavHandlers() {
|
||||||
|
let editor = this.get('editor');
|
||||||
|
editor.unregisterKeyCommands('slash-menu');
|
||||||
|
}
|
||||||
|
});
|
|
@ -73,8 +73,16 @@
|
||||||
replaceWithCardSection=(action "replaceWithCardSection")
|
replaceWithCardSection=(action "replaceWithCardSection")
|
||||||
replaceWithListSection=(action "replaceWithListSection")
|
replaceWithListSection=(action "replaceWithListSection")
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
{{!-- slash menu popup --}}
|
||||||
|
{{koenig-slash-menu
|
||||||
|
editor=editor
|
||||||
|
editorRange=selectedRange
|
||||||
|
replaceWithCardSection=(action "replaceWithCardSection")
|
||||||
|
replaceWithListSection=(action "replaceWithListSection")
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
{{!-- all component cards wormholed into the editor canvas --}}
|
||||||
{{#each componentCards as |card|}}
|
{{#each componentCards as |card|}}
|
||||||
{{!--
|
{{!--
|
||||||
TODO: move to the public {{in-element}} API when it's available
|
TODO: move to the public {{in-element}} API when it's available
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
{{#if showMenu}}
|
||||||
|
<div class="koenig-cardmenu">
|
||||||
|
{{#each items as |item|}}
|
||||||
|
<div class="koenig-cardmenu-card {{if item.selected "selected"}}" {{action "itemClicked" item on="click"}}>
|
||||||
|
<div class="koenig-cardmenu-icon">{{inline-svg item.icon}}</div>
|
||||||
|
<div class="koenig-cardmenu-label">{{item.label}}</div>
|
||||||
|
</div>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
1
lib/koenig-editor/app/components/koenig-slash-menu.js
Normal file
1
lib/koenig-editor/app/components/koenig-slash-menu.js
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export {default} from 'koenig-editor/components/koenig-slash-menu';
|
24
tests/integration/components/koenig-slash-menu-test.js
Normal file
24
tests/integration/components/koenig-slash-menu-test.js
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import hbs from 'htmlbars-inline-precompile';
|
||||||
|
import {describe, it} from 'mocha';
|
||||||
|
import {expect} from 'chai';
|
||||||
|
import {setupComponentTest} from 'ember-mocha';
|
||||||
|
|
||||||
|
describe('Integration: Component: koenig-slash-menu', function () {
|
||||||
|
setupComponentTest('koenig-slash-menu', {
|
||||||
|
integration: true
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders', function () {
|
||||||
|
// Set any properties with this.set('myProperty', 'value');
|
||||||
|
// Handle any actions with this.on('myAction', function(val) { ... });
|
||||||
|
// Template block usage:
|
||||||
|
// this.render(hbs`
|
||||||
|
// {{#koenig-slash-menu}}
|
||||||
|
// template content
|
||||||
|
// {{/koenig-slash-menu}}
|
||||||
|
// `);
|
||||||
|
|
||||||
|
this.render(hbs`{{koenig-slash-menu}}`);
|
||||||
|
expect(this.$()).to.have.length(1);
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in a new issue