From 5d4053dec202f28ffaf320ff18fcaa7f3eb0c4fc Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Sat, 3 Feb 2018 17:54:19 +0100 Subject: [PATCH] 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 --- ghost/admin/app/styles/components/koenig.css | 4 + .../addon/components/koenig-plus-menu.js | 2 +- .../addon/components/koenig-slash-menu.js | 337 ++++++++++++++++++ .../templates/components/koenig-editor.hbs | 8 + .../components/koenig-slash-menu.hbs | 10 + .../app/components/koenig-slash-menu.js | 1 + .../components/koenig-slash-menu-test.js | 24 ++ 7 files changed, 385 insertions(+), 1 deletion(-) create mode 100644 ghost/admin/lib/koenig-editor/addon/components/koenig-slash-menu.js create mode 100644 ghost/admin/lib/koenig-editor/addon/templates/components/koenig-slash-menu.hbs create mode 100644 ghost/admin/lib/koenig-editor/app/components/koenig-slash-menu.js create mode 100644 ghost/admin/tests/integration/components/koenig-slash-menu-test.js diff --git a/ghost/admin/app/styles/components/koenig.css b/ghost/admin/app/styles/components/koenig.css index 9e40bcc058..4e63b7e459 100644 --- a/ghost/admin/app/styles/components/koenig.css +++ b/ghost/admin/app/styles/components/koenig.css @@ -235,6 +235,10 @@ /* Slash shortcut menu ------------------------------------------------------ */ +.koenig-slash-menu { + position: absolute; +} + /* 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 */ diff --git a/ghost/admin/lib/koenig-editor/addon/components/koenig-plus-menu.js b/ghost/admin/lib/koenig-editor/addon/components/koenig-plus-menu.js index c1e51331d5..b966c7f4ce 100644 --- a/ghost/admin/lib/koenig-editor/addon/components/koenig-plus-menu.js +++ b/ghost/admin/lib/koenig-editor/addon/components/koenig-plus-menu.js @@ -48,7 +48,7 @@ export default Component.extend({ 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) { this._showOrHideButton(editorRange); this._hasCursorButton = this.get('showButton'); diff --git a/ghost/admin/lib/koenig-editor/addon/components/koenig-slash-menu.js b/ghost/admin/lib/koenig-editor/addon/components/koenig-slash-menu.js new file mode 100644 index 0000000000..944a97c41b --- /dev/null +++ b/ghost/admin/lib/koenig-editor/addon/components/koenig-slash-menu.js @@ -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'); + } +}); diff --git a/ghost/admin/lib/koenig-editor/addon/templates/components/koenig-editor.hbs b/ghost/admin/lib/koenig-editor/addon/templates/components/koenig-editor.hbs index 1b4a50e81e..806e610c5e 100644 --- a/ghost/admin/lib/koenig-editor/addon/templates/components/koenig-editor.hbs +++ b/ghost/admin/lib/koenig-editor/addon/templates/components/koenig-editor.hbs @@ -73,8 +73,16 @@ replaceWithCardSection=(action "replaceWithCardSection") 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|}} {{!-- TODO: move to the public {{in-element}} API when it's available diff --git a/ghost/admin/lib/koenig-editor/addon/templates/components/koenig-slash-menu.hbs b/ghost/admin/lib/koenig-editor/addon/templates/components/koenig-slash-menu.hbs new file mode 100644 index 0000000000..8612cd6b0d --- /dev/null +++ b/ghost/admin/lib/koenig-editor/addon/templates/components/koenig-slash-menu.hbs @@ -0,0 +1,10 @@ +{{#if showMenu}} +
+ {{#each items as |item|}} +
+
{{inline-svg item.icon}}
+
{{item.label}}
+
+ {{/each}} +
+{{/if}} diff --git a/ghost/admin/lib/koenig-editor/app/components/koenig-slash-menu.js b/ghost/admin/lib/koenig-editor/app/components/koenig-slash-menu.js new file mode 100644 index 0000000000..27904835d8 --- /dev/null +++ b/ghost/admin/lib/koenig-editor/app/components/koenig-slash-menu.js @@ -0,0 +1 @@ +export {default} from 'koenig-editor/components/koenig-slash-menu'; diff --git a/ghost/admin/tests/integration/components/koenig-slash-menu-test.js b/ghost/admin/tests/integration/components/koenig-slash-menu-test.js new file mode 100644 index 0000000000..e78e70896b --- /dev/null +++ b/ghost/admin/tests/integration/components/koenig-slash-menu-test.js @@ -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); + }); +});