Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement text expansions #110

Merged
merged 1 commit into from
Sep 3, 2015
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 59 additions & 23 deletions src/js/editor/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,18 @@ import mixin from '../utils/mixin';
import EventListenerMixin from '../utils/event-listener';
import Cursor from '../utils/cursor';
import PostNodeBuilder from '../models/post-node-builder';
import {
DEFAULT_TEXT_EXPANSIONS,
findExpansion,
validateExpansion
} from './text-expansions';

export const EDITOR_ELEMENT_CLASS_NAME = 'ck-editor';

const defaults = {
placeholder: 'Write here...',
spellcheck: true,
autofocus: true,
post: null,
// FIXME PhantomJS has 'ontouchstart' in window,
// causing the stickyToolbar to accidentally be auto-activated
// in tests
Expand Down Expand Up @@ -99,8 +103,8 @@ function bindSelectionEvent(editor) {
*/

const toggleSelection = () => {
return editor.cursor.hasSelection() ? editor.hasSelection() :
editor.hasNoSelection();
return editor.cursor.hasSelection() ? editor.reportSelection() :
editor.reportNoSelection();
};

// mouseup will not properly report a selection until the next tick, so add a timeout:
Expand All @@ -122,6 +126,10 @@ function bindKeyListeners(editor) {
}
});

editor.addEventListener(editor.element, 'keydown', (event) => {
editor.handleExpansion(event);
});

editor.addEventListener(document, 'keydown', (event) => {
if (!editor.isEditable) {
return;
Expand Down Expand Up @@ -199,8 +207,6 @@ class Editor {
this._views = [];
this.isEditable = null;

this.builder = new PostNodeBuilder();

this._didUpdatePostCallbacks = [];
this._willRenderCallbacks = [];
this._didRenderCallbacks = [];
Expand All @@ -210,34 +216,44 @@ class Editor {

this.cards.push(ImageCard);

DEFAULT_TEXT_EXPANSIONS.forEach(e => this.registerExpansion(e));

this._parser = new PostParser(this.builder);
this._renderer = new Renderer(this, this.cards, this.unknownCardHandler, this.cardOptions);

if (this.mobiledoc) {
this.post = new MobiledocParser(this.builder).parse(this.mobiledoc);
} else if (this.html) {
if (typeof this.html === 'string') {
this.html = parseHTML(this.html);
}
this.post = new DOMParser(this.builder).parse(this.html);
} else {
this.post = this.builder.createBlankPost();
}

this.post = this.loadPost();
this._renderTree = this.prepareRenderTree(this.post);
}

addView(view) {
this._views.push(view);
}

get builder() {
if (!this._builder) { this._builder = new PostNodeBuilder(); }
return this._builder;
}

prepareRenderTree(post) {
let renderTree = new RenderTree();
let node = renderTree.buildRenderNode(post);
renderTree.node = node;
return renderTree;
}

loadPost() {
if (this.mobiledoc) {
return new MobiledocParser(this.builder).parse(this.mobiledoc);
} else if (this.html) {
if (typeof this.html === 'string') {
this.html = parseHTML(this.html);
}
return new DOMParser(this.builder).parse(this.html);
} else {
return this.builder.createBlankPost();
}
}

rerender() {
let postRenderNode = this.post.renderNode;

Expand Down Expand Up @@ -305,6 +321,26 @@ class Editor {
}
}

get expansions() {
if (!this._expansions) { this._expansions = []; }
return this._expansions;
}

registerExpansion(expansion) {
if (!validateExpansion(expansion)) {
throw new Error('Expansion is not valid');
}
this.expansions.push(expansion);
}

handleExpansion(event) {
const expansion = findExpansion(this.expansions, event, this);
if (expansion) {
event.preventDefault();
expansion.run(this);
}
}

handleDeletion(event) {
event.preventDefault();

Expand Down Expand Up @@ -346,7 +382,7 @@ class Editor {
this.cursor.moveToSection(cursorSection);
}

hasSelection() {
reportSelection() {
if (!this._hasSelection) {
this.trigger('selection');
} else {
Expand All @@ -355,7 +391,7 @@ class Editor {
this._hasSelection = true;
}

hasNoSelection() {
reportNoSelection() {
if (this._hasSelection) {
this.trigger('selectionEnded');
}
Expand All @@ -366,7 +402,7 @@ class Editor {
if (this._hasSelection) {
// FIXME perhaps restore cursor position to end of the selection?
this.cursor.clearSelection();
this.hasNoSelection();
this.reportNoSelection();
}
}

Expand All @@ -376,12 +412,12 @@ class Editor {

selectSections(sections) {
this.cursor.selectSections(sections);
this.hasSelection();
this.reportSelection();
}

selectMarkers(markers) {
this.cursor.selectMarkers(markers);
this.hasSelection();
this.reportSelection();
}

get cursor() {
Expand Down Expand Up @@ -571,8 +607,8 @@ class Editor {
* @public
*/
run(callback) {
let postEditor = new PostEditor(this);
let result = callback(postEditor);
const postEditor = new PostEditor(this);
const result = callback(postEditor);
runCallbacks(this._didUpdatePostCallbacks, [postEditor]);
postEditor.complete();
return result;
Expand Down
92 changes: 92 additions & 0 deletions src/js/editor/text-expansions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import Keycodes from '../utils/keycodes';
import Key from '../utils/key';
import { detect } from '../utils/array-utils';
import { MARKUP_SECTION_TYPE } from '../models/markup-section';

const { SPACE } = Keycodes;

function replaceWithListSection(editor, listTagName) {
const {head: {section}} = editor.cursor.offsets;

const newSection = editor.run(postEditor => {
const {builder} = postEditor;
const listItem = builder.createListItem();
const listSection = builder.createListSection(listTagName, [listItem]);

postEditor.replaceSection(section, listSection);
return listItem;
});

editor.cursor.moveToSection(newSection);
}

function replaceWithHeaderSection(editor, headingTagName) {
const {head: {section}} = editor.cursor.offsets;

const newSection = editor.run(postEditor => {
const {builder} = postEditor;
const newSection = builder.createMarkupSection(headingTagName);
postEditor.replaceSection(section, newSection);
return newSection;
});

editor.cursor.moveToSection(newSection);
}

export function validateExpansion(expansion) {
return !!expansion.trigger && !!expansion.text && !!expansion.run;
}

export const DEFAULT_TEXT_EXPANSIONS = [
{
trigger: SPACE,
text: '*',
run: (editor) => {
replaceWithListSection(editor, 'ul');
}
},
{
trigger: SPACE,
text: '1',
run: (editor) => {
replaceWithListSection(editor, 'ol');
}
},
{
trigger: SPACE,
text: '1.',
run: (editor) => {
replaceWithListSection(editor, 'ol');
}
},
{
trigger: SPACE,
text: '##',
run: (editor) => {
replaceWithHeaderSection(editor, 'h2');
}
},
{
trigger: SPACE,
text: '###',
run: (editor) => {
replaceWithHeaderSection(editor, 'h3');
}
}
];

export function findExpansion(expansions, keyEvent, editor) {
const key = Key.fromEvent(keyEvent);
if (!key.isPrintable()) { return; }

const {head:{section, offset}} = editor.cursor.offsets;
if (section.type !== MARKUP_SECTION_TYPE) { return; }

// FIXME this is potentially expensive to calculate and might be better
// perf to first find expansions matching the trigger and only if matches
// are found then calculating the _text
const _text = section.textUntil(offset);
return detect(
expansions,
({trigger, text}) => key.keyCode === trigger && _text === text);
}
4 changes: 4 additions & 0 deletions src/js/models/_markerable.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ export default class Markerable extends LinkedItem {
return {marker:currentMarker, offset:currentOffset};
}

textUntil(offset) {
return this.text.slice(0, offset);
}

get text() {
return reduce(this.markers, (prev, m) => prev + m.value, '');
}
Expand Down
2 changes: 1 addition & 1 deletion src/js/renderers/editor-dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { startsWith, endsWith } from '../utils/string-utils';
import { addClassName } from '../utils/dom-utils';

export const NO_BREAK_SPACE = '\u00A0';
const SPACE = ' ';
export const SPACE = ' ';

function createElementFromMarkup(doc, markup) {
var element = doc.createElement(markup.tagName);
Expand Down
16 changes: 16 additions & 0 deletions src/js/utils/event-listener.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { filter } from './array-utils';

export default class EventListenerMixin {
addEventListener(context, eventName, listener) {
if (!this._eventListeners) { this._eventListeners = []; }
Expand All @@ -11,4 +13,18 @@ export default class EventListenerMixin {
context.removeEventListener(...args);
});
}

// This is primarily useful for programmatically simulating events on the
// editor from the tests.
triggerEvent(context, eventName, event) {
let matches = filter(
this._eventListeners,
([_context, _eventName]) => {
return context === _context && eventName === _eventName;
}
);
matches.forEach(([context, eventName, listener]) => {
listener.call(context, event);
});
}
}
6 changes: 3 additions & 3 deletions tests/acceptance/editor-commands-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ test('highlight text, click "bold", type more text, re-select text, bold button
assert.equal(textNode.textContent, 'IS A', 'precond - correct node');

Helpers.dom.moveCursorTo(textNode, 'IS'.length);
Helpers.dom.insertText('X');
Helpers.dom.insertText(editor, 'X');

assert.hasElement('strong:contains(ISX A)', 'adds text to bold');

Expand Down Expand Up @@ -233,14 +233,14 @@ Helpers.skipInPhantom('highlight text, click "link" button shows input for URL,
setTimeout(() => {
assert.hasElement(`#editor a[href="${url}"]:contains(${selectedText})`);

Helpers.dom.insertText('X');
Helpers.dom.insertText(editor, 'X');

assert.hasElement(`#editor p:contains(${selectedText}X)`,
'inserts text after selected text');
assert.hasNoElement(`#editor a:contains(${selectedText}X)`,
'inserted text does not extend "a" tag');

Helpers.dom.insertText('X');
Helpers.dom.insertText(editor, 'X');
assert.hasElement(`#editor p:contains(${selectedText}XX)`,
'inserts text after selected text again');

Expand Down
Loading