diff --git a/src/js/editor/editor.js b/src/js/editor/editor.js index dd01d9f3c..e84d499d4 100644 --- a/src/js/editor/editor.js +++ b/src/js/editor/editor.js @@ -43,6 +43,11 @@ 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'; @@ -50,7 +55,6 @@ 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 @@ -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: @@ -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; @@ -199,8 +207,6 @@ class Editor { this._views = []; this.isEditable = null; - this.builder = new PostNodeBuilder(); - this._didUpdatePostCallbacks = []; this._willRenderCallbacks = []; this._didRenderCallbacks = []; @@ -210,20 +216,12 @@ 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); } @@ -231,6 +229,11 @@ class Editor { 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); @@ -238,6 +241,19 @@ class Editor { 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; @@ -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(); @@ -346,7 +382,7 @@ class Editor { this.cursor.moveToSection(cursorSection); } - hasSelection() { + reportSelection() { if (!this._hasSelection) { this.trigger('selection'); } else { @@ -355,7 +391,7 @@ class Editor { this._hasSelection = true; } - hasNoSelection() { + reportNoSelection() { if (this._hasSelection) { this.trigger('selectionEnded'); } @@ -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(); } } @@ -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() { @@ -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; diff --git a/src/js/editor/text-expansions.js b/src/js/editor/text-expansions.js new file mode 100644 index 000000000..9edb7bde8 --- /dev/null +++ b/src/js/editor/text-expansions.js @@ -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); +} diff --git a/src/js/models/_markerable.js b/src/js/models/_markerable.js index c4b3118c2..095e9292f 100644 --- a/src/js/models/_markerable.js +++ b/src/js/models/_markerable.js @@ -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, ''); } diff --git a/src/js/renderers/editor-dom.js b/src/js/renderers/editor-dom.js index 87cccd35d..be0e5ff92 100644 --- a/src/js/renderers/editor-dom.js +++ b/src/js/renderers/editor-dom.js @@ -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); diff --git a/src/js/utils/event-listener.js b/src/js/utils/event-listener.js index aac0b8151..be6492ae7 100644 --- a/src/js/utils/event-listener.js +++ b/src/js/utils/event-listener.js @@ -1,3 +1,5 @@ +import { filter } from './array-utils'; + export default class EventListenerMixin { addEventListener(context, eventName, listener) { if (!this._eventListeners) { this._eventListeners = []; } @@ -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); + }); + } } diff --git a/tests/acceptance/editor-commands-test.js b/tests/acceptance/editor-commands-test.js index 5a6835e3d..3e68af8eb 100644 --- a/tests/acceptance/editor-commands-test.js +++ b/tests/acceptance/editor-commands-test.js @@ -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'); @@ -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'); diff --git a/tests/acceptance/editor-list-test.js b/tests/acceptance/editor-list-test.js index ad716fd12..5525c60d9 100644 --- a/tests/acceptance/editor-list-test.js +++ b/tests/acceptance/editor-list-test.js @@ -37,7 +37,7 @@ test('can type in middle of a list item', (assert) => { assert.ok(!!listItem, 'precond - has li'); Helpers.dom.moveCursorTo(listItem.childNodes[0], 'first'.length); - Helpers.dom.insertText('X'); + Helpers.dom.insertText(editor, 'X'); assert.hasElement('#editor li:contains(firstX item)', 'inserts text at right spot'); }); @@ -49,7 +49,7 @@ test('can type at end of a list item', (assert) => { assert.ok(!!listItem, 'precond - has li'); Helpers.dom.moveCursorTo(listItem.childNodes[0], 'first item'.length); - Helpers.dom.insertText('X'); + Helpers.dom.insertText(editor, 'X'); assert.hasElement('#editor li:contains(first itemX)', 'inserts text at right spot'); }); @@ -61,7 +61,7 @@ test('can type at start of a list item', (assert) => { assert.ok(!!listItem, 'precond - has li'); Helpers.dom.moveCursorTo(listItem.childNodes[0], 0); - Helpers.dom.insertText('X'); + Helpers.dom.insertText(editor, 'X'); assert.hasElement('#editor li:contains(Xfirst item)', 'inserts text at right spot'); }); @@ -95,8 +95,8 @@ test('can exit list section altogether by deleting', (assert) => { assert.hasNoElement('#editor li:contains(second item)', 'second li is gone'); assert.hasElement('#editor p:contains(second item)', 'second li becomes p'); - Helpers.dom.insertText('X'); - + Helpers.dom.insertText(editor, 'X'); + assert.hasElement('#editor p:contains(Xsecond item)', 'new text is in right spot'); }); @@ -142,7 +142,7 @@ test('can hit enter at end of list item to add new item', (assert) => { let newLi = $('#editor li:eq(1)'); assert.equal(newLi.text(), '', 'new li has no text'); - Helpers.dom.insertText('X'); + Helpers.dom.insertText(editor, 'X'); assert.hasElement('#editor li:contains(X)', 'text goes in right spot'); const liCount = $('#editor li').length; @@ -179,7 +179,7 @@ test('hitting enter to add list item, deleting to remove it, adding new list ite assert.equal($('#editor li').length, 2, 'removes newly added li after enter on last list item'); assert.equal($('#editor p').length, 2, 'adds a second p section'); - Helpers.dom.insertText('X'); + Helpers.dom.insertText(editor, 'X'); assert.hasElement('#editor p:eq(0):contains(X)', 'inserts text in right spot'); }); @@ -203,6 +203,6 @@ test('hitting enter at empty last list item exists list', (assert) => { assert.equal($('#editor p').length, 1, 'adds 1 new p'); assert.equal($('#editor p').text(), '', 'p has no text'); - Helpers.dom.insertText('X'); + Helpers.dom.insertText(editor, 'X'); assert.hasElement('#editor p:contains(X)', 'text goes in right spot'); }); diff --git a/tests/acceptance/editor-sections-test.js b/tests/acceptance/editor-sections-test.js index 0c367c3f5..f70edc8bb 100644 --- a/tests/acceptance/editor-sections-test.js +++ b/tests/acceptance/editor-sections-test.js @@ -173,7 +173,7 @@ test('hitting enter at end of a section creates new empty section', (assert) => assert.hasElement('#editor p:eq(0):contains(only section)', 'has same first section text'); assert.hasElement('#editor p:eq(1):contains()', 'second section has no text'); - Helpers.dom.insertText('X'); + Helpers.dom.insertText(editor, 'X'); assert.hasElement('#editor p:eq(1):contains(X)', 'text is inserted in the new section'); }); @@ -191,7 +191,7 @@ test('hitting enter in a section creates a new basic section', (assert) => { Helpers.dom.moveCursorTo($('#editor h2')[0].childNodes[0], 'abc'.length); Helpers.dom.triggerEnter(editor); - Helpers.dom.insertText('X'); + Helpers.dom.insertText(editor, 'X'); assert.hasElement('#editor h2:contains(abc)', 'h2 still there'); assert.hasElement('#editor p:contains(X)', 'p tag instead of h2 generated'); @@ -308,7 +308,7 @@ test('keystroke of delete when cursor is after only char in only marker of secti assert.hasElement('#editor p:eq(0):contains()', 'first p is empty'); - Helpers.dom.insertText('X'); + Helpers.dom.insertText(editor, 'X'); assert.hasElement('#editor p:eq(0):contains(X)', 'text is added back to section'); }); @@ -324,7 +324,7 @@ test('keystroke of character in empty section adds character, moves cursor', (as Helpers.dom.moveCursorTo(textNode, 0); const letter = 'M'; - Helpers.dom.insertText(letter); + Helpers.dom.insertText(editor, letter); assert.equal(getTextNode().textContent, letter, 'adds character'); assert.equal(getTextNode().textContent.length, 1); @@ -334,7 +334,7 @@ test('keystroke of character in empty section adds character, moves cursor', (as `cursor moves to end of ${letter} text node`); const otherLetter = 'X'; - Helpers.dom.insertText(otherLetter); + Helpers.dom.insertText(editor, otherLetter); assert.equal(getTextNode().textContent, `${letter}${otherLetter}`, 'adds character in the correct spot'); @@ -490,7 +490,7 @@ test('deleting when after deletion there is a trailing space positions cursor at assert.equal($('#editor p:eq(0)').text(), `first${NO_BREAK_SPACE}`, 'precond - correct text after deleting last char before space'); let text = 'e'; - Helpers.dom.insertText(text); + Helpers.dom.insertText(editor, text); setTimeout(() => { assert.equal($('#editor p:eq(0)').text(), `first ${text}`, 'character is placed after space'); @@ -510,7 +510,7 @@ test('deleting when after deletion there is a leading space positions cursor at assert.equal($('#editor p:eq(1)').text(), `${NO_BREAK_SPACE}section`, 'correct text after deletion'); let text = 'e'; - Helpers.dom.insertText(text); + Helpers.dom.insertText(editor, text); setTimeout(() => { assert.equal($('#editor p:eq(1)').text(), `${text} section`, 'correct text after insertion'); diff --git a/tests/acceptance/editor-selections-test.js b/tests/acceptance/editor-selections-test.js index 02ed3408e..f42d2df22 100644 --- a/tests/acceptance/editor-selections-test.js +++ b/tests/acceptance/editor-selections-test.js @@ -67,7 +67,7 @@ test('selecting an entire section and deleting removes it', (assert) => { assert.hasNoElement('p:contains(second section)', 'deletes contents of second section'); assert.equal($('#editor p').length, 2, 'still has 2 sections'); - Helpers.dom.insertText('X'); + Helpers.dom.insertText(editor, 'X'); assert.hasElement('#editor p:eq(1):contains(X)', 'inserts text in correct spot'); diff --git a/tests/acceptance/editor-text-expansions-test.js b/tests/acceptance/editor-text-expansions-test.js new file mode 100644 index 000000000..dcabddc4e --- /dev/null +++ b/tests/acceptance/editor-text-expansions-test.js @@ -0,0 +1,145 @@ +import { Editor } from 'content-kit-editor'; +import Helpers from '../test-helpers'; + +const { module, test } = Helpers; + +let editor, editorElement; + +function insertText(text, cursorNode) { + if (!cursorNode) { + cursorNode = $('#editor p:eq(0)')[0].firstChild; + } + Helpers.dom.moveCursorTo(cursorNode); + Helpers.dom.insertText(editor, text); +} + +module('Acceptance: Editor: Text Expansions', { + beforeEach() { + editorElement = document.createElement('div'); + editorElement.setAttribute('id', 'editor'); + $('#qunit-fixture').append(editorElement); + }, + afterEach() { + if (editor) { editor.destroy(); } + } +}); + +test('typing "## " converts to h2', (assert) => { + const mobiledoc = Helpers.mobiledoc.build( + ({post, markupSection}) => post([markupSection()])); + + editor = new Editor({mobiledoc}); + editor.render(editorElement); + insertText('## '); + + assert.hasNoElement('#editor p', 'p is gone'); + assert.hasElement('#editor h2', 'p -> h2'); + + Helpers.dom.insertText(editor, 'X'); + assert.hasElement('#editor h2:contains(X)', 'text is inserted correctly'); +}); + +test('space is required to trigger "## " expansion', (assert) => { + const mobiledoc = Helpers.mobiledoc.build( + ({post, markupSection}) => post([markupSection()])); + + editor = new Editor({mobiledoc}); + editor.render(editorElement); + insertText('##X'); + + assert.hasElement('#editor p:contains(##X)', 'text inserted, no expansion'); +}); + +test('typing "### " converts to h3', (assert) => { + const mobiledoc = Helpers.mobiledoc.build( + ({post, markupSection}) => post([markupSection()])); + + editor = new Editor({mobiledoc}); + editor.render(editorElement); + insertText('### '); + + assert.hasNoElement('#editor p', 'p is gone'); + assert.hasElement('#editor h3', 'p -> h3'); + + Helpers.dom.insertText(editor, 'X'); + assert.hasElement('#editor h3:contains(X)', 'text is inserted correctly'); +}); + +test('typing "* " converts to ul > li', (assert) => { + const mobiledoc = Helpers.mobiledoc.build( + ({post, markupSection}) => post([markupSection()])); + + editor = new Editor({mobiledoc}); + editor.render(editorElement); + insertText('* '); + + assert.hasNoElement('#editor p', 'p is gone'); + assert.hasElement('#editor ul > li', 'p -> "ul > li"'); + + Helpers.dom.insertText(editor, 'X'); + assert.hasElement('#editor li:contains(X)', 'text is inserted correctly'); +}); + +test('typing "* " inside of a list section does not create a new list section', (assert) => { + const mobiledoc = Helpers.mobiledoc.build( + ({post, listSection, listItem}) => post([listSection('ul', [listItem()])])); + + editor = new Editor({mobiledoc}); + editor.render(editorElement); + + assert.hasElement('#editor ul > li', 'precond - has li'); + + const cursorNode = $('#editor li:eq(0)')[0].firstChild; + insertText('* ', cursorNode); + + // note: the actual text is "* ", so only check that the "*" is there, + // because checking for "* " will fail + assert.hasElement('#editor ul > li:contains(*)', 'adds text without expanding it'); +}); + +test('typing "1 " converts to ol > li', (assert) => { + const mobiledoc = Helpers.mobiledoc.build( + ({post, markupSection}) => post([markupSection()])); + + editor = new Editor({mobiledoc}); + editor.render(editorElement); + insertText('1 '); + + assert.hasNoElement('#editor p', 'p is gone'); + assert.hasElement('#editor ol > li', 'p -> "ol > li"'); + + Helpers.dom.insertText(editor, 'X'); + assert.hasElement('#editor li:contains(X)', 'text is inserted correctly'); +}); + +test('typing "1. " converts to ol > li', (assert) => { + const mobiledoc = Helpers.mobiledoc.build( + ({post, markupSection}) => post([markupSection()])); + + editor = new Editor({mobiledoc}); + editor.render(editorElement); + insertText('1. '); + + assert.hasNoElement('#editor p', 'p is gone'); + assert.hasElement('#editor ol > li', 'p -> "ol > li"'); + + Helpers.dom.insertText(editor, 'X'); + assert.hasElement('#editor li:contains(X)', 'text is inserted correctly'); +}); + +test('a new expansion can be registered', (assert) => { + const mobiledoc = Helpers.mobiledoc.build( + ({post, markupSection}) => post([markupSection()])); + + let didExpand = false; + editor = new Editor({mobiledoc}); + editor.registerExpansion({ + trigger: ' '.charCodeAt(0), + text: 'quote', + run: () => didExpand = true + }); + editor.render(editorElement); + insertText('quote '); + + assert.ok(didExpand, 'expansion was run'); +}); diff --git a/tests/acceptance/embed-intent-test.js b/tests/acceptance/embed-intent-test.js index 83d7d0a34..81b39f297 100644 --- a/tests/acceptance/embed-intent-test.js +++ b/tests/acceptance/embed-intent-test.js @@ -97,7 +97,7 @@ test('inserting unordered list at cursor', (assert) => { assert.hasElement('#editor ul li', 'adds a ul li'); assert.equal($('#editor ul li').text(), '', 'li has no text'); - Helpers.dom.insertText('X'); + Helpers.dom.insertText(editor, 'X'); assert.hasElement('#editor ul li:contains(X)', 'inserts text at correct spot'); done(); diff --git a/tests/helpers/dom.js b/tests/helpers/dom.js index 63f0bf70c..54ce26558 100644 --- a/tests/helpers/dom.js +++ b/tests/helpers/dom.js @@ -161,8 +161,16 @@ function triggerEnter(editor) { } } -function insertText(string) { - document.execCommand('insertText', false, string); +function insertText(editor, string) { + if (!string && editor) { throw new Error('Must pass `editor` to `insertText`'); } + + string.split('').forEach(letter => { + const keyEvent = {keyCode: letter.charCodeAt(0), preventDefault: () =>{}}; + editor.triggerEvent(editor.element, 'keydown', keyEvent); + document.execCommand('insertText', false, letter); + editor.triggerEvent(editor.element, 'input'); + editor.triggerEvent(editor.element, 'keyup', keyEvent); + }); } const DOMHelper = { diff --git a/tests/unit/parsers/section-test.js b/tests/unit/parsers/section-test.js index f76af4dc3..e6d5c3f5b 100644 --- a/tests/unit/parsers/section-test.js +++ b/tests/unit/parsers/section-test.js @@ -114,10 +114,3 @@ test('#parse joins contiguous text nodes separated by non-markup elements', (ass assert.equal(m1.value, 'span 1span 2'); }); - -// test: a section can parse dom - -// test: a section can clear a range: -// * truncating the markers on the boundaries -// * removing the intermediate markers -// * connecting (but not joining) the truncated boundary markers