Skip to content

Commit

Permalink
Implement text expansions
Browse files Browse the repository at this point in the history
  * Trigger is "space"
  * "*" -> ul>li
  * "1" and "1." -> ol>li
  * "##" -> h2
  * "###" -> h3

fixes #87
  • Loading branch information
bantic committed Sep 3, 2015
1 parent 413144b commit 7c4c315
Show file tree
Hide file tree
Showing 16 changed files with 391 additions and 53 deletions.
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
3 changes: 2 additions & 1 deletion src/js/parsers/post.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import SectionParser from 'content-kit-editor/parsers/section';
import { forEach } from 'content-kit-editor/utils/array-utils';
import { getAttributesArray, walkTextNodes } from '../utils/dom-utils';
import Markup from 'content-kit-editor/models/markup';
import { sanitizeText } from './section';

export default class PostParser {
constructor(builder) {
Expand Down Expand Up @@ -88,7 +89,7 @@ export default class PostParser {
let previousMarker;

walkTextNodes(element, (textNode) => {
const text = textNode.textContent;
const text = sanitizeText(textNode.textContent);
let markups = this.collectMarkups(textNode, element);

let marker;
Expand Down
9 changes: 8 additions & 1 deletion src/js/parsers/section.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ import {
normalizeTagName
} from 'content-kit-editor/utils/dom-utils';
import { forEach } from 'content-kit-editor/utils/array-utils';
import { SPACE, NO_BREAK_SPACE } from '../renderers/editor-dom';

const noBreakSpaceRegex = new RegExp(NO_BREAK_SPACE, 'g');
function sanitizeText(text) {
return text.replace(noBreakSpaceRegex, SPACE);
}
export { sanitizeText };

/**
* parses an element into a section, ignoring any non-markup
Expand Down Expand Up @@ -82,7 +89,7 @@ export default class SectionParser {
}

parseTextNode(textNode, state) {
state.text += textNode.textContent;
state.text += sanitizeText(textNode.textContent);
}

isSectionElement(element) {
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

0 comments on commit 7c4c315

Please sign in to comment.