diff --git a/src/js/editor/editor.js b/src/js/editor/editor.js index 95d16a81d..ae7e7686b 100644 --- a/src/js/editor/editor.js +++ b/src/js/editor/editor.js @@ -39,6 +39,9 @@ import PostNodeBuilder from '../models/post-node-builder'; import { DEFAULT_TEXT_EXPANSIONS, findExpansion, validateExpansion } from './text-expansions'; +import { + DEFAULT_KEY_COMMANDS, findKeyCommand, validateKeyCommand +} from './key-commands'; import { capitalize } from '../utils/string-utils'; export const EDITOR_ELEMENT_CLASS_NAME = 'ck-editor'; @@ -121,6 +124,7 @@ class Editor { this.cards.push(ImageCard); DEFAULT_TEXT_EXPANSIONS.forEach(e => this.registerExpansion(e)); + DEFAULT_KEY_COMMANDS.forEach(kc => this.registerKeyCommand(kc)); this._parser = new PostParser(this.builder); this._renderer = new Renderer(this, this.cards, this.unknownCardHandler, this.cardOptions); @@ -228,6 +232,11 @@ class Editor { return this._expansions; } + get keyCommands() { + if (!this._keyCommands) { this._keyCommands = []; } + return this._keyCommands; + } + registerExpansion(expansion) { if (!validateExpansion(expansion)) { throw new Error('Expansion is not valid'); @@ -235,6 +244,13 @@ class Editor { this.expansions.push(expansion); } + registerKeyCommand(keyCommand) { + if (!validateKeyCommand(keyCommand)) { + throw new Error('Key Command is not valid'); + } + this.keyCommands.push(keyCommand); + } + handleExpansion(event) { const expansion = findExpansion(this.expansions, event, this); if (expansion) { @@ -284,6 +300,11 @@ class Editor { this.cursor.moveToSection(cursorSection); } + // FIXME it might be nice to use the toolbar's prompt instead + showPrompt(message, defaultValue, callback) { + callback(window.prompt(message, defaultValue)); + } + reportSelection() { if (!this._hasSelection) { this.trigger('selection'); @@ -617,15 +638,22 @@ class Editor { this.handleNewline(event); } else if (key.isPrintable()) { if (this.cursor.hasSelection()) { - let offsets = this.cursor.offsets; - this.run((postEditor) => { - postEditor.deleteRange(this.cursor.offsets); - }); + const offsets = this.cursor.offsets; + this.run(postEditor => postEditor.deleteRange(offsets)); this.cursor.moveToSection(offsets.headSection, offsets.headSectionOffset); } } this.handleExpansion(event); + this.handleKeyCommand(event); + } + + handleKeyCommand(event) { + const keyCommand = findKeyCommand(this.keyCommands, event); + if (keyCommand) { + event.preventDefault(); + keyCommand.run(this); + } } handlePaste(event) { diff --git a/src/js/editor/key-commands.js b/src/js/editor/key-commands.js new file mode 100644 index 000000000..ccb88bc82 --- /dev/null +++ b/src/js/editor/key-commands.js @@ -0,0 +1,72 @@ +import Key from '../utils/key'; +import { MODIFIERS } from '../utils/key'; +import { detect } from '../utils/array-utils'; +import LinkCommand from '../commands/link'; +import BoldCommand from '../commands/bold'; +import ItalicCommand from '../commands/italic'; + +function runSelectionCommand(editor, CommandKlass) { + if (editor.cursor.hasSelection()) { + const cmd = new CommandKlass(editor); + if (cmd.isActive()) { + cmd.unexec(); + } else { + cmd.exec(); + } + } +} + +export const DEFAULT_KEY_COMMANDS = [{ + modifier: MODIFIERS.META, + str: 'B', + run(editor) { + runSelectionCommand(editor, BoldCommand); + } +}, { + modifier: MODIFIERS.CTRL, + str: 'B', + run(editor) { + runSelectionCommand(editor, BoldCommand); + } +}, { + modifier: MODIFIERS.META, + str: 'I', + run(editor) { + runSelectionCommand(editor, ItalicCommand); + } +}, { + modifier: MODIFIERS.CTRL, + str: 'I', + run(editor) { + runSelectionCommand(editor, ItalicCommand); + } +}, { + modifier: MODIFIERS.META, + str: 'K', + run(editor) { + if (!editor.cursor.hasSelection()) { return; } + + let selectedText = editor.cursor.selectedText(); + let defaultUrl = ''; + if (selectedText.indexOf('http') !== -1) { defaultUrl = selectedText; } + + editor.showPrompt('Enter a URL', defaultUrl, url => { + if (!url) { return; } + + const linkCommand = new LinkCommand(editor); + linkCommand.exec(url); + }); + } +}]; + +export function validateKeyCommand(keyCommand) { + return !!keyCommand.modifier && !!keyCommand.str && !!keyCommand.run; +} + +export function findKeyCommand(keyCommands, keyEvent) { + const key = Key.fromEvent(keyEvent); + + return detect(keyCommands, ({modifier, str}) => { + return key.hasModifier(modifier) && key.isChar(str); + }); +} diff --git a/src/js/utils/cursor.js b/src/js/utils/cursor.js index e534da4a7..794d9abc8 100644 --- a/src/js/utils/cursor.js +++ b/src/js/utils/cursor.js @@ -105,6 +105,10 @@ const Cursor = class Cursor { return window.getSelection(); } + selectedText() { + return this.selection.toString(); + } + /** * @private * @param {textNode} node diff --git a/src/js/utils/key.js b/src/js/utils/key.js index 1c4a1a7e9..d7071b7b8 100644 --- a/src/js/utils/key.js +++ b/src/js/utils/key.js @@ -4,6 +4,11 @@ export const DIRECTION = { BACKWARD: 2 }; +export const MODIFIERS = { + META: 1, // also called "command" on OS X + CTRL: 2 +}; + /** * An abstraction around a KeyEvent * that key listeners in the editor can use @@ -44,6 +49,17 @@ const Key = class Key { return this.keyCode === Keycodes.ENTER; } + hasModifier(modifier) { + switch (modifier) { + case MODIFIERS.META: + return this.metaKey; + case MODIFIERS.CTRL: + return this.ctrlKey; + default: + throw new Error(`Cannot check for unknown modifier ${modifier}`); + } + } + get ctrlKey() { return this.event.ctrlKey; } @@ -52,6 +68,10 @@ const Key = class Key { return this.event.metaKey; } + isChar(string) { + return this.keyCode === string.toUpperCase().charCodeAt(0); + } + /** * See https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/keyCode#Printable_keys_in_standard_position * and http://stackoverflow.com/a/12467610/137784 diff --git a/tests/acceptance/editor-key-commands-test.js b/tests/acceptance/editor-key-commands-test.js new file mode 100644 index 000000000..29c59db11 --- /dev/null +++ b/tests/acceptance/editor-key-commands-test.js @@ -0,0 +1,74 @@ +import { Editor } from 'content-kit-editor'; +import { MODIFIERS } from 'content-kit-editor/utils/key'; +import Helpers from '../test-helpers'; + +const { module, test } = Helpers; + +let editor, editorElement; + +module('Acceptance: Editor: Key Commands', { + beforeEach() { + editorElement = document.createElement('div'); + editorElement.setAttribute('id', 'editor'); + $('#qunit-fixture').append(editorElement); + }, + afterEach() { + if (editor) { editor.destroy(); } + } +}); + +test('typing command-B bolds highlighted text', (assert) => { + const mobiledoc = Helpers.mobiledoc.build( + ({post, markupSection, marker}) => post([ + markupSection('p', [marker('something')]) + ])); + + editor = new Editor({mobiledoc}); + editor.render(editorElement); + + assert.hasNoElement('#editor strong', 'precond - no strong text'); + Helpers.dom.selectText('something', editorElement); + Helpers.dom.triggerKeyCommand(editor, 'B', MODIFIERS.META); + + assert.hasElement('#editor strong:contains(something)', 'text is strengthened'); +}); + +test('typing command-I italicizes highlighted text', (assert) => { + const mobiledoc = Helpers.mobiledoc.build( + ({post, markupSection, marker}) => post([ + markupSection('p', [marker('something')]) + ])); + + editor = new Editor({mobiledoc}); + editor.render(editorElement); + + assert.hasNoElement('#editor em', 'precond - no strong text'); + Helpers.dom.selectText('something', editorElement); + Helpers.dom.triggerKeyCommand(editor, 'I', MODIFIERS.META); + + assert.hasElement('#editor em:contains(something)', 'text is emphasized'); +}); + +test('new key commands can be registered', (assert) => { + const mobiledoc = Helpers.mobiledoc.build( + ({post, markupSection, marker}) => post([ + markupSection('p', [marker('something')]) + ])); + + let passedEditor; + editor = new Editor({mobiledoc}); + editor.registerKeyCommand({ + modifier: MODIFIERS.CTRL, + str: 'X', + run(editor) { passedEditor = editor; } + }); + editor.render(editorElement); + + Helpers.dom.triggerKeyCommand(editor, 'Y', MODIFIERS.CTRL); + + assert.ok(!passedEditor, 'incorrect key combo does not trigger key command'); + + Helpers.dom.triggerKeyCommand(editor, 'X', MODIFIERS.CTRL); + + assert.ok(!!passedEditor && passedEditor === editor, 'run method is called'); +}); diff --git a/tests/helpers/dom.js b/tests/helpers/dom.js index b2145b86f..c2c9170c8 100644 --- a/tests/helpers/dom.js +++ b/tests/helpers/dom.js @@ -3,6 +3,7 @@ const TEXT_NODE = 3; import { clearSelection } from 'content-kit-editor/utils/selection-utils'; import { walkDOMUntil } from 'content-kit-editor/utils/dom-utils'; import KEY_CODES from 'content-kit-editor/utils/keycodes'; +import { MODIFIERS } from 'content-kit-editor/utils/key'; import isPhantom from './is-phantom'; function selectRange(startNode, startOffset, endNode, endOffset) { @@ -173,6 +174,18 @@ function insertText(editor, string) { }); } +// triggers a key sequence like cmd-B on the editor, to test out +// registered keyCommands +function triggerKeyCommand(editor, string, modifier) { + const keyEvent = { + preventDefault() {}, + keyCode: string.toUpperCase().charCodeAt(0), + metaKey: modifier === MODIFIERS.META, + ctrlKey: modifier === MODIFIERS.CTRL + }; + editor.triggerEvent(editor.element, 'keydown', keyEvent); +} + const DOMHelper = { moveCursorTo, selectText, @@ -185,7 +198,8 @@ const DOMHelper = { getSelectedText, triggerDelete, triggerEnter, - insertText + insertText, + triggerKeyCommand }; export { triggerEvent };