diff --git a/lib/koenig-editor/addon/components/koenig-editor.js b/lib/koenig-editor/addon/components/koenig-editor.js index e4b27448e9..d7c1405421 100644 --- a/lib/koenig-editor/addon/components/koenig-editor.js +++ b/lib/koenig-editor/addon/components/koenig-editor.js @@ -54,13 +54,13 @@ export const CARD_COMPONENT_MAP = { html: 'koenig-card-html' }; -const CURSOR_BEFORE = -1; -const CURSOR_AFTER = 1; -const NO_CURSOR_MOVEMENT = 0; +export const CURSOR_BEFORE = -1; +export const CURSOR_AFTER = 1; +export const NO_CURSOR_MOVEMENT = 0; // markups that should not be continued when typing and reverted to their -// text expansion style when backspacing over findal char of markup -const SPECIAL_MARKUPS = { +// text expansion style when backspacing over final char of markup +export const SPECIAL_MARKUPS = { S: '~~', CODE: '`' }; @@ -98,6 +98,7 @@ export default Component.extend({ selectedRange: null, componentCards: null, linkRange: null, + selectedCard: null, // private properties _localMobiledoc: null, @@ -105,7 +106,7 @@ export default Component.extend({ _startedRunLoop: false, _lastIsEditingDisabled: false, _isRenderingEditor: false, - _selectedCard: null, + _skipCursorChange: false, // closure actions willCreateEditor() {}, @@ -250,42 +251,6 @@ export default Component.extend({ registerKeyCommands(editor, this); registerTextExpansions(editor, this); - editor.registerKeyCommand({ - str: 'ENTER', - run: run.bind(this, this.handleEnterKey, 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, editor) - }), - - // 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) - }), - - editor.registerKeyCommand({ - str: 'UP', - run: run.bind(this, this.handleUpKey, editor) - }); - - editor.registerKeyCommand({ - str: 'LEFT', - run: run.bind(this, this.handleLeftKey, editor) - }); - - editor.registerKeyCommand({ - str: 'META+ENTER', - run: run.bind(this, this.handleCmdEnter, editor) - }); - // set up editor hooks editor.willRender(() => { // The editor's render/rerender will happen after this `editor.willRender`, @@ -443,24 +408,24 @@ export default Component.extend({ }, deleteCard(card, cursorMovement = NO_CURSOR_MOVEMENT) { - this._deleteCard(card, cursorMovement); + this.deleteCard(card, cursorMovement); }, moveCursorToPrevSection(card) { - let section = this._getSectionFromCard(card); + let section = this.getSectionFromCard(card); if (section.prev) { this.deselectCard(card); - this._moveCaretToTailOfSection(section.prev, false); + this.moveCaretToTailOfSection(section.prev, false); } }, moveCursorToNextSection(card) { - let section = this._getSectionFromCard(card); + let section = this.getSectionFromCard(card); if (section.next) { this.deselectCard(card); - this._moveCaretToHeadOfSection(section.next, false); + this.moveCaretToHeadOfSection(section.next, false); } else { this.send('addParagraphAfterCard', card); } @@ -468,7 +433,7 @@ export default Component.extend({ addParagraphAfterCard(card) { let editor = this.editor; - let section = this._getSectionFromCard(card); + let section = this.getSectionFromCard(card); let collection = section.parent.sections; let nextSection = section.next; @@ -489,7 +454,7 @@ export default Component.extend({ } }, - /* public methods ------------------------------------------------------- */ + /* mobiledoc event handlers --------------------------------------------- */ postDidChange(editor) { let serializeVersion = this.serializeVersion; @@ -516,7 +481,7 @@ export default Component.extend({ // card section, clicking and other interactions within a card can cause // this to happen and we don't want to select/deselect accidentally. // See the up/down/left/right key handlers for the card selection - if (this._selectedCard && this._selectedCard.postModel === section) { + if (this.selectedCard && this.selectedCard.postModel === section) { return; } @@ -526,7 +491,7 @@ export default Component.extend({ // select card after render to ensure that our componentCards // attr is populated run.schedule('afterRender', this, () => { - let card = this._getCardFromSection(section); + let card = this.getCardFromSection(section); this.selectCard(card); this.set('selectedRange', editor.range); }); @@ -535,8 +500,8 @@ export default Component.extend({ } // deselect any selected card because the cursor is no longer on a card - if (this._selectedCard && !editor.range.isBlank) { - this.deselectCard(this._selectedCard); + if (this.selectedCard && !editor.range.isBlank) { + this.deselectCard(this.selectedCard); } // if we have `code` or ~strike~ formatting to the left but not the right @@ -605,181 +570,7 @@ export default Component.extend({ } }, - handleEnterKey(editor) { - let {isCollapsed, head: {offset, section}} = editor.range; - - // if cursor is at beginning of a heading, insert a blank paragraph above - if (isCollapsed && offset === 0 && section.tagName && section.tagName.match(/^h\d$/)) { - editor.run((postEditor) => { - let newPara = postEditor.builder.createMarkupSection('p'); - let collection = section.parent.sections; - postEditor.insertSectionBefore(collection, newPara, section); - }); - return; - } - - return false; - }, - - handleBackspaceKey(editor) { - let {head, isCollapsed, head: {marker, offset, section}} = 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 caret is at the beginning of the doc, on a blank para, and - // there are more sections then delete the para and trigger the - // `cursorDidExitAtTop` closure action - let isFirstSection = section === section.parent.sections.head; - if (isFirstSection && isCollapsed && offset === 0 && (section.isBlank || section.text === '') && section.next) { - this.editor.run((postEditor) => { - postEditor.removeSection(section); - }); - - // allow default behaviour which will trigger `cursorDidChange` and - // fire our `cursorDidExitAtTop` action - return; - } - - // if the section about to be deleted by a backspace is a card then - // actually delete the card rather than selecting it. - // However, if the current paragraph is blank then delete the paragraph - // instead - allows blank paragraphs between cards to be deleted and - // feels more natural - if (isCollapsed && offset === 0 && section.prev && section.prev.type === 'card-section' && !section.isBlank) { - let card = this._getCardFromSection(section.prev); - this._deleteCard(card, CURSOR_AFTER); - return; - } - - // if cursor is at the beginning of a heading and previous section is a - // blank paragraph, delete the blank paragraph - if (isCollapsed && offset === 0 && section.tagName.match(/^h\d$/) && section.prev && section.prev.tagName === 'p' && section.prev.isBlank) { - editor.run((postEditor) => { - postEditor.removeSection(section.prev); - }); - return; - } - - // if the markup about to be deleted is a special format (code, strike) - // then undo the text expansion to allow it to be extended - if (isCollapsed && marker) { - let specialMarkupTagNames = Object.keys(SPECIAL_MARKUPS); - let hasReversed = false; - specialMarkupTagNames.forEach((tagName) => { - // only continue if we're about to delete a special markup - let markup = marker.markups.find(markup => markup.tagName.toUpperCase() === tagName); - if (markup) { - let nextMarker = head.markerIn(1); - // ensure we're at the end of the markup not inside it - if (!nextMarker || !nextMarker.hasMarkup(tagName)) { - // wrap with the text expansion, remove formatting, then delete the last char - editor.run((postEditor) => { - let markdown = SPECIAL_MARKUPS[tagName]; - let range = editor.range.expandByMarker(marker => !!marker.markups.includes(markup)); - postEditor.insertText(range.head, markdown); - range = range.extend(markdown.length); - let endPos = postEditor.insertText(range.tail, markdown); - range = range.extend(markdown.length); - postEditor.toggleMarkup(tagName, range); - endPos = postEditor.deleteAtPosition(endPos, -1); - postEditor.setRange(endPos); - }); - hasReversed = true; - } - } - }); - if (hasReversed) { - return; - } - } - - return false; - }, - - handleDelKey(editor) { - let {isCollapsed, head: {offset, section}} = 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.selectCard(nextCard); - } - return; - } - - // if the section about to be deleted by a DEL is a card then actually - // delete the card rather than selecting it - // However, if the current paragraph is blank then delete the paragraph - // instead - allows blank paragraphs between cards to be deleted and - // feels more natural - if (isCollapsed && offset === section.length && section.next && section.next.type === 'card-section' && !section.isBlank) { - let card = this._getCardFromSection(section.next); - this._deleteCard(card, CURSOR_BEFORE); - return; - } - - return false; - }, - - // trigger a closure action to indicate that the caret "left" the top of - // the editor canvas when pressing UP with the caret at the beginning of - // the doc - handleUpKey(editor) { - let {isCollapsed, head: {offset, section}} = editor.range; - let prevSection = section.isListItem ? section.parent.prev : section.prev; - - if (isCollapsed && offset === 0 && !prevSection) { - this.cursorDidExitAtTop(); - } - - return false; - }, - - handleLeftKey(editor) { - let {isCollapsed, head: {offset, section}} = editor.range; - - // trigger a closure action to indicate that the caret "left" the top of - // the editor canvas if the caret is at the very beginning of the doc - let prevSection = section.isListItem ? section.parent.prev : section.prev; - if (isCollapsed && offset === 0 && !prevSection) { - this.cursorDidExitAtTop(); - return; - } - - // if we have a selected card move the caret to end of the previous - // section because the cursor will likely be at the end of the card - // section meaning the default behaviour would move the cursor to the - // beginning and require two key presses instead of one - if (this._selectedCard && this._selectedCard.postModel === section) { - this._moveCaretToTailOfSection(section.prev, false); - return; - } - - return false; - }, - - // CMD+ENTER is our keyboard shortcut for putting a selected card into - // edit mode - handleCmdEnter() { - if (this._selectedCard) { - this.editCard(this._selectedCard); - return; - } - - return false; - }, + /* custom event handlers ------------------------------------------------ */ // if a URL is pasted and we have a selection, make that selection a link handlePaste(event) { @@ -802,15 +593,25 @@ export default Component.extend({ } }, + /* Ember event handlers ------------------------------------------------- */ + + // disable dragging + // TODO: needs testing for how this interacts with cards that have drag behaviour + dragStart(event) { + event.preventDefault(); + }, + + /* public methods ------------------------------------------------------- */ + selectCard(card, isEditing = false) { // no-op if card is already selected - if (card === this._selectedCard && isEditing === card.isEditing) { + if (card === this.selectedCard && isEditing === card.isEditing) { return; } // deselect any already selected card - if (this._selectedCard && card !== this._selectedCard) { - this.deselectCard(this._selectedCard); + if (this.selectedCard && card !== this.selectedCard) { + this.deselectCard(this.selectedCard); } // setting a card as selected trigger's the cards didReceiveAttrs @@ -820,19 +621,19 @@ export default Component.extend({ isEditing, isSelected: true }); - this._selectedCard = card; + 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._moveCaretToTailOfSection(section); + let section = this.getSectionFromCard(card); + this.moveCaretToTailOfSection(section); }, editCard(card) { // no-op if card is already being edited - if (card === this._selectedCard && card.isEditing) { + if (card === this.selectedCard && card.isEditing) { return; } @@ -843,44 +644,61 @@ export default Component.extend({ deselectCard(card) { card.set('isEditing', false); card.set('isSelected', false); - this._selectedCard = null; + this.selectedCard = null; this._showCursor(); }, - /* Ember event handlers ------------------------------------------------- */ + deleteCard(card, cursorDirection) { + this.editor.run((postEditor) => { + let section = card.env.postModel; + let nextPosition; - // disable dragging - // TODO: needs testing for how this interacts with cards that have drag behaviour - dragStart(event) { - event.preventDefault(); - }, + if (cursorDirection === CURSOR_BEFORE) { + nextPosition = section.prev && section.prev.tailPosition(); + } else { + nextPosition = section.next && section.next.headPosition(); + } - /* internal methods ----------------------------------------------------- */ + postEditor.removeSection(section); - _getCardFromSection(section) { + // if there's no prev or next section then the doc is empty, we want + // to add a blank paragraph and place the cursor in it + if (cursorDirection !== NO_CURSOR_MOVEMENT && !nextPosition) { + let {builder} = postEditor; + let newPara = builder.createMarkupSection('p'); + postEditor.insertSectionAtEnd(newPara); + return postEditor.setRange(newPara.tailPosition()); + } + + if (cursorDirection !== NO_CURSOR_MOVEMENT) { + return postEditor.setRange(nextPosition); + } + }); + }, + + getCardFromSection(section) { if (!section || section.type !== 'card-section') { return; } let cardId = section.renderNode.element.querySelector('.__mobiledoc-card').firstChild.id; - let cards = this.componentCards; - return cards.findBy('destinationElementId', cardId); + return this.componentCards.findBy('destinationElementId', cardId); }, - _getSectionFromCard(card) { + getSectionFromCard(card) { return card.env.postModel; }, - _moveCaretToHeadOfSection(section, skipCursorChange = true) { - this._moveCaretToSection('head', section, skipCursorChange); + moveCaretToHeadOfSection(section, skipCursorChange = true) { + this.moveCaretToSection(section, 'head', skipCursorChange); }, - _moveCaretToTailOfSection(section, skipCursorChange = true) { - this._moveCaretToSection('tail', section, skipCursorChange); + moveCaretToTailOfSection(section, skipCursorChange = true) { + this.moveCaretToSection(section, 'tail', skipCursorChange); }, - _moveCaretToSection(position, section, skipCursorChange = true) { + moveCaretToSection(section, position, skipCursorChange = true) { this.editor.run((postEditor) => { let sectionPosition = position === 'head' ? section.headPosition() : section.tailPosition(); let range = sectionPosition.toRange(); @@ -894,33 +712,7 @@ export default Component.extend({ }); }, - _deleteCard(card, cursorDirection) { - this.editor.run((postEditor) => { - let section = card.env.postModel; - let nextPosition; - - if (cursorDirection === CURSOR_BEFORE) { - nextPosition = section.prev && section.prev.tailPosition(); - } else { - nextPosition = section.next && section.next.headPosition(); - } - - postEditor.removeSection(section); - - // if there's no prev or next section then the doc is empty, we want - // to add a blank paragraph and place the cursor in it - if (cursorDirection !== NO_CURSOR_MOVEMENT && !nextPosition) { - let {builder} = postEditor; - let newPara = builder.createMarkupSection('p'); - postEditor.insertSectionAtEnd(newPara); - return postEditor.setRange(newPara.tailPosition()); - } - - if (cursorDirection !== NO_CURSOR_MOVEMENT) { - return postEditor.setRange(nextPosition); - } - }); - }, + /* internal methods ----------------------------------------------------- */ _hideCursor() { this.editor.element.style.caretColor = 'transparent'; diff --git a/lib/koenig-editor/addon/options/key-commands.js b/lib/koenig-editor/addon/options/key-commands.js index b9bb4dd6fc..87680c93e1 100644 --- a/lib/koenig-editor/addon/options/key-commands.js +++ b/lib/koenig-editor/addon/options/key-commands.js @@ -1,9 +1,53 @@ import Browser from 'mobiledoc-kit/utils/browser'; +import { + CURSOR_AFTER, + CURSOR_BEFORE, + SPECIAL_MARKUPS +} from '../components/koenig-editor'; // Key commands will run any time a particular key or key combination is pressed // https://github.com/bustlelabs/mobiledoc-kit#configuring-hot-keys export const DEFAULT_KEY_COMMANDS = [{ + str: 'ENTER', + run(editor) { + let {isCollapsed, head: {offset, section}} = editor.range; + + // if cursor is at beginning of a heading, insert a blank paragraph above + if (isCollapsed && offset === 0 && section.tagName && section.tagName.match(/^h\d$/)) { + editor.run((postEditor) => { + let newPara = postEditor.builder.createMarkupSection('p'); + let collection = section.parent.sections; + postEditor.insertSectionBefore(collection, newPara, section); + }); + return; + } + + return false; + } +}, { + // CMD+ENTER is our keyboard shortcut for putting a selected card into edit mode + str: 'META+ENTER', + run(editor, koenig) { + if (koenig.selectedCard) { + koenig.editCard(koenig.selectedCard); + return; + } + + return false; + } +}, { + // CTRL+ENTER is our keyboard shortcut for putting a selected card into edit mode + str: 'CTRL+ENTER', + run(editor, koenig) { + if (Browser.isWin() && koenig.selectedCard) { + koenig.editCard(koenig.selectedCard); + return; + } + + return false; + } +}, { str: 'SHIFT+ENTER', run(editor) { if (!editor.range.headSection.isMarkerable) { @@ -15,6 +59,159 @@ export const DEFAULT_KEY_COMMANDS = [{ postEditor.insertMarkers(editor.range.head, [softReturn]); }); } +}, { + str: 'BACKSPACE', + run(editor, koenig) { + let {head, isCollapsed, head: {marker, offset, section}} = editor.range; + + // if a card is selected we should delete the card then place the cursor + // at the end of the previous section + if (koenig.selectedCard) { + let cursorPosition = section.prev ? CURSOR_BEFORE : CURSOR_AFTER; + koenig.deleteCard(koenig.selectedCard, cursorPosition); + return; + } + + // if the caret is at the beginning of the doc, on a blank para, and + // there are more sections then delete the para and trigger the + // `cursorDidExitAtTop` closure action + let isFirstSection = section === section.parent.sections.head; + if (isFirstSection && isCollapsed && offset === 0 && (section.isBlank || section.text === '') && section.next) { + editor.run((postEditor) => { + postEditor.removeSection(section); + }); + + // allow default behaviour which will trigger `cursorDidChange` and + // fire our `cursorDidExitAtTop` action + return; + } + + // if the section about to be deleted by a backspace is a card then + // actually delete the card rather than selecting it. + // However, if the current paragraph is blank then delete the paragraph + // instead - allows blank paragraphs between cards to be deleted and + // feels more natural + if (isCollapsed && offset === 0 && section.prev && section.prev.type === 'card-section' && !section.isBlank) { + let card = koenig.getCardFromSection(section.prev); + koenig.deleteCard(card, CURSOR_AFTER); + return; + } + + // if cursor is at the beginning of a heading and previous section is a + // blank paragraph, delete the blank paragraph + if (isCollapsed && offset === 0 && section.tagName.match(/^h\d$/) && section.prev && section.prev.tagName === 'p' && section.prev.isBlank) { + editor.run((postEditor) => { + postEditor.removeSection(section.prev); + }); + return; + } + + // if the markup about to be deleted is a special format (code, strike) + // then undo the text expansion to allow it to be extended + if (isCollapsed && marker) { + let specialMarkupTagNames = Object.keys(SPECIAL_MARKUPS); + let hasReversed = false; + specialMarkupTagNames.forEach((tagName) => { + // only continue if we're about to delete a special markup + let markup = marker.markups.find(markup => markup.tagName.toUpperCase() === tagName); + if (markup) { + let nextMarker = head.markerIn(1); + // ensure we're at the end of the markup not inside it + if (!nextMarker || !nextMarker.hasMarkup(tagName)) { + // wrap with the text expansion, remove formatting, then delete the last char + editor.run((postEditor) => { + let markdown = SPECIAL_MARKUPS[tagName]; + let range = editor.range.expandByMarker(marker => !!marker.markups.includes(markup)); + postEditor.insertText(range.head, markdown); + range = range.extend(markdown.length); + let endPos = postEditor.insertText(range.tail, markdown); + range = range.extend(markdown.length); + postEditor.toggleMarkup(tagName, range); + endPos = postEditor.deleteAtPosition(endPos, -1); + postEditor.setRange(endPos); + }); + hasReversed = true; + } + } + }); + if (hasReversed) { + return; + } + } + + return false; + } +}, { + str: 'DEL', + run(editor, koenig) { + let {isCollapsed, head: {offset, section}} = 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 (koenig.selectedCard) { + let selectNextCard = section.next.type === 'card-section'; + let nextCard = koenig.getCardFromSection(section.next); + + koenig.deleteCard(koenig.selectedCard, CURSOR_AFTER); + + if (selectNextCard) { + koenig.selectCard(nextCard); + } + return; + } + + // if the section about to be deleted by a DEL is a card then actually + // delete the card rather than selecting it + // However, if the current paragraph is blank then delete the paragraph + // instead - allows blank paragraphs between cards to be deleted and + // feels more natural + if (isCollapsed && offset === section.length && section.next && section.next.type === 'card-section' && !section.isBlank) { + let card = koenig.getCardFromSection(section.next); + koenig.deleteCard(card, CURSOR_BEFORE); + return; + } + + return false; + } +}, { + // trigger a closure action to indicate that the caret "left" the top of + // the editor canvas when pressing UP with the caret at the beginning of + // the doc + str: 'UP', + run(editor, koenig) { + let {isCollapsed, head: {offset, section}} = editor.range; + let prevSection = section.isListItem ? section.parent.prev : section.prev; + + if (isCollapsed && offset === 0 && !prevSection) { + koenig.cursorDidExitAtTop(); + } + + return false; + } +}, { + str: 'LEFT', + run(editor, koenig) { + let {isCollapsed, head: {offset, section}} = editor.range; + + // trigger a closure action to indicate that the caret "left" the top of + // the editor canvas if the caret is at the very beginning of the doc + let prevSection = section.isListItem ? section.parent.prev : section.prev; + if (isCollapsed && offset === 0 && !prevSection) { + koenig.cursorDidExitAtTop(); + return; + } + + // if we have a selected card move the caret to end of the previous + // section because the cursor will likely be at the end of the card + // section meaning the default behaviour would move the cursor to the + // beginning and require two key presses instead of one + if (koenig.selectedCard && koenig.selectedCard.postModel === section) { + koenig.moveCaretToTailOfSection(section.prev, false); + return; + } + + return false; + } }, { str: 'CTRL+K', run(editor, koenig) { @@ -37,7 +234,7 @@ export default function registerKeyCommands(editor, koenig) { editor.registerKeyCommand({ str: keyCommand.str, run() { - keyCommand.run(editor, koenig); + return keyCommand.run(editor, koenig); } }); });