Skip to content

Commit

Permalink
Add keyboard shortcuts via editor#registerKeyCommand
Browse files Browse the repository at this point in the history
fixes #112
  • Loading branch information
bantic committed Sep 8, 2015
1 parent a5b6b50 commit f5487b0
Show file tree
Hide file tree
Showing 6 changed files with 217 additions and 5 deletions.
36 changes: 32 additions & 4 deletions src/js/editor/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -228,13 +232,25 @@ 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');
}
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) {
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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) {
Expand Down
72 changes: 72 additions & 0 deletions src/js/editor/key-commands.js
Original file line number Diff line number Diff line change
@@ -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);
});
}
4 changes: 4 additions & 0 deletions src/js/utils/cursor.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ const Cursor = class Cursor {
return window.getSelection();
}

selectedText() {
return this.selection.toString();
}

/**
* @private
* @param {textNode} node
Expand Down
20 changes: 20 additions & 0 deletions src/js/utils/key.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand All @@ -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
Expand Down
74 changes: 74 additions & 0 deletions tests/acceptance/editor-key-commands-test.js
Original file line number Diff line number Diff line change
@@ -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');
});
16 changes: 15 additions & 1 deletion tests/helpers/dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -185,7 +198,8 @@ const DOMHelper = {
getSelectedText,
triggerDelete,
triggerEnter,
insertText
insertText,
triggerKeyCommand
};

export { triggerEvent };
Expand Down

0 comments on commit f5487b0

Please sign in to comment.