Koenig - Card selection and deletion

refs https://github.com/TryGhost/Ghost/issues/9311
- cursor based card selection
- handling of delete/backspace when cards are involved
- add `cursorDidExitAtTop` closure action to `{{koenig-editor}}` to consolidate editor cursor behaviour in the editor
  - added extra behaviour for LEFT in editor and RIGHT in title to switch focus between title and editor
- fixed incorrect icon in the slash menu
This commit is contained in:
Kevin Ansfield 2018-02-04 20:35:44 +01:00
parent 5d0d98738d
commit 8efb2e485c
5 changed files with 225 additions and 22 deletions

View File

@ -20,6 +20,10 @@ export default Component.extend({
onBodyChange() {},
actions: {
focusTitle() {
this._title.focus();
},
// triggered when a click is registered on .gh-koenig-editor-pane
focusEditor(event) {
// if a click occurs on the editor canvas, focus the editor and put
@ -65,7 +69,7 @@ export default Component.extend({
if (
event.key === 'Enter' ||
event.key === 'Tab' ||
(event.key === 'ArrowDown' && (!value || selectionStart === value.length))
((event.key === 'ArrowDown' || event.key === 'ArrowRight') && (!value || selectionStart === value.length))
) {
event.preventDefault();
this._editor.focus();
@ -92,30 +96,11 @@ export default Component.extend({
this._editor = editor;
// focus the title when pressing UP if cursor is at the beginning of doc
editor.registerKeyCommand({
str: 'UP',
run(editor) {
let cursorHead = editor.cursor.offsets.head;
if (
editor.hasCursor()
&& cursorHead.offset === 0
&& (!cursorHead.section || !cursorHead.section.prev)
) {
component._title.focus();
return true;
}
return false;
}
});
// focus the title when pressing SHIFT+TAB
editor.registerKeyCommand({
str: 'SHIFT+TAB',
run() {
component._title.focus();
component.send('focusTitle');
return true;
}
});

View File

@ -18,5 +18,6 @@
spellcheck=true
onChange=(action "onBodyChange")
didCreateEditor=(action "onEditorCreated")
cursorDidExitAtTop=(action "focusTitle")
}}
</div>

View File

@ -7,6 +7,7 @@ import Component from '@ember/component';
import Editor from 'mobiledoc-kit/editor/editor';
import Ember from 'ember';
import EmberObject from '@ember/object';
import Range from 'mobiledoc-kit/utils/cursor/range';
import defaultAtoms from '../options/atoms';
import defaultCards from '../options/cards';
import layout from '../templates/components/koenig-editor';
@ -49,6 +50,9 @@ export const CARD_COMPONENT_MAP = {
html: 'koenig-card-html'
};
const CURSOR_BEFORE = -1;
const CURSOR_AFTER = 1;
function arrayToMap(array) {
let map = Object.create(null);
array.forEach((key) => {
@ -87,11 +91,13 @@ export default Component.extend({
_startedRunLoop: false,
_lastIsEditingDisabled: false,
_isRenderingEditor: false,
_selectedCard: null,
// closure actions
willCreateEditor() {},
didCreateEditor() {},
onChange() {},
cursorDidExitAtTop() {},
/* computed properties -------------------------------------------------- */
@ -222,6 +228,32 @@ export default Component.extend({
registerKeyCommands(editor);
registerTextExpansions(editor);
// the cursor is always positioned after a selected card so DELETE wont
// work to remove the card like BACKSPACE does. Add a custom command to
// override the default behaviour when a card is selected
editor.registerKeyCommand({
str: 'DEL',
run: run.bind(this, this.handleDelKey)
}),
// by default mobiledoc-kit will remove the selected card but replace it
// with a blank paragraph, we want the cursor to go to the previous
// section instead
editor.registerKeyCommand({
str: 'BACKSPACE',
run: run.bind(this, this.handleBackspaceKey)
}),
editor.registerKeyCommand({
str: 'UP',
run: run.bind(this, this.handleUpKey)
});
editor.registerKeyCommand({
str: 'LEFT',
run: run.bind(this, this.handleLeftKey)
});
// set up editor hooks
editor.willRender(() => {
// The editor's render/rerender will happen after this `editor.willRender`,
@ -331,6 +363,39 @@ export default Component.extend({
postEditor.replaceSection(section, listSection);
postEditor.setRange(listSection.headPosition());
});
},
selectCard(card) {
// no-op if card is already selected
if (card === this._selectedCard) {
return;
}
// deselect any already selected card
if (this._selectedCard) {
this.send('deselectCard', this._selectedCard);
}
// setting a card as selected trigger's the cards didReceiveAttrs
// hook where the actual selection state change happens
card.set('isSelected', true);
this._selectedCard = card;
// hide the cursor and place it after the card so that ENTER can
// create a new paragraph and cursorDidExitAtTop gets fired on LEFT
// if the card is at the top of the document
this._hideCursor();
let section = this._getSectionFromCard(card);
this.editor.run((postEditor) => {
let range = section.tailPosition().toRange();
postEditor.setRange(range);
});
},
deselectCard(card) {
card.set('isSelected', false);
this._selectedCard = null;
this._showCursor();
}
},
@ -346,6 +411,43 @@ export default Component.extend({
},
cursorDidChange(editor) {
let {head, isCollapsed, head: {section}} = editor.range;
// if we have a selected card but cursor has moved to the left then
// deselect and move cursor to end of the previous section
if (this._selectedCard && section && isCollapsed && section.type === 'card-section' && head.offset === 0) {
this.send('deselectCard', this._selectedCard);
if (section.prev) {
editor.run((postEditor) => {
postEditor.setRange(section.prev.tailPosition().toRange());
});
} else {
// card was at the top of the doc so we should trigger an external
// action - gh-koenig-editor uses it to move focus to the title input
this.cursorDidExitAtTop();
}
this.set('selectedRange', editor.range);
return;
}
// select the card if the cursor is on the before/after &zwnj; char
if (section && isCollapsed && section.type === 'card-section') {
if (head.offset === 0 || head.offset === 1) {
let card = this._getCardFromSection(section);
this.send('selectCard', card);
this.set('selectedRange', editor.range);
return;
}
}
// deselect any selected card because the cursor is no longer on a card
if (this._selectedCard) {
this.send('deselectCard', this._selectedCard);
}
// pass the selected range through to the toolbar + menu components
this.set('selectedRange', editor.range);
},
@ -376,8 +478,121 @@ export default Component.extend({
}
},
handleBackspaceKey() {
let {isCollapsed, head: {offset, section}} = this.editor.range;
// if a card is selected we should delete the card then place the cursor
// at the end of the previous section
if (this._selectedCard) {
let cursorPosition = section.prev ? CURSOR_BEFORE : CURSOR_AFTER;
this._deleteCard(this._selectedCard, cursorPosition);
return;
}
// if the section about to be deleted by a backspace is a card then
// actually delete the card rather than selecting it
if (isCollapsed && offset === 0 && section.prev && section.prev.type === 'card-section') {
let card = this._getCardFromSection(section.prev);
this._deleteCard(card, CURSOR_BEFORE);
return;
}
return false;
},
handleDelKey() {
let {isCollapsed, head: {offset, section}} = this.editor.range;
// if a card is selected we should delete the card then place the cursor
// at the beginning of the next section or select the following card
if (this._selectedCard) {
let selectNextCard = section.next.type === 'card-section';
let nextCard = this._getCardFromSection(section.next);
this._deleteCard(this._selectedCard, CURSOR_AFTER);
if (selectNextCard) {
this.send('selectCard', nextCard);
}
return;
}
// if the section about to be deleted by a DEL is a card then actually
// delete the card
if (isCollapsed && offset === section.length && section.next && section.next.type === 'card-section') {
let card = this._getCardFromSection(section.next);
this._deleteCard(card, CURSOR_BEFORE);
return;
}
return false;
},
handleUpKey(editor) {
let {isCollapsed, head: {offset, section}} = editor.range;
if (isCollapsed && !section.prev && offset === 0) {
this.cursorDidExitAtTop();
}
return false;
},
handleLeftKey(editor) {
let {isCollapsed, head: {offset, section}} = editor.range;
if (isCollapsed && !section.prev && offset === 0) {
this.cursorDidExitAtTop();
return;
}
return false;
},
/* internal methods ----------------------------------------------------- */
_getCardFromSection(section) {
if (!section || section.type !== 'card-section') {
return;
}
let cardId = section.renderNode.element.querySelector('.__mobiledoc-card').firstChild.id;
let cards = this.get('componentCards');
return cards.findBy('destinationElementId', cardId);
},
_getSectionFromCard(card) {
return card.env.postModel;
},
_deleteCard(card, cursorDirection) {
this.editor.run((postEditor) => {
let section = card.env.postModel;
let rangeStart, rangeEnd;
if (cursorDirection === CURSOR_BEFORE) {
rangeStart = section.prev ? section.prev.tailPosition() : section.headPosition();
rangeEnd = section.tailPosition();
} else {
rangeStart = section.headPosition();
rangeEnd = section.next ? section.next.headPosition() : section.tailPosition();
}
let range = new Range(rangeStart, rangeEnd);
let nextPosition = postEditor.deleteRange(range);
postEditor.setRange(nextPosition);
});
},
_hideCursor() {
this.editor.element.style.caretColor = 'transparent';
},
_showCursor() {
this.editor.element.style.caretColor = 'auto';
},
// store a reference to the editor for the acceptance test helpers
_setExpandoProperty(editor) {
if (this.element && Ember.testing) {

View File

@ -25,7 +25,7 @@ const ITEM_MAP = [
},
{
label: 'Embed',
icon: 'koenig/image',
icon: 'koenig/embed',
matches: ['embed', 'html'],
type: 'card',
replaceArg: 'html'

View File

@ -100,6 +100,8 @@
saveCard=(action card.env.save)
cancelCard=(action card.env.cancel)
removeCard=(action card.env.remove)
isSelected=card.isSelected
selectCard=(action "selectCard")
}}
{{/-in-element}}
{{/each}}