From b14d7be7f774be02e3d381b04ce025ccbe61be84 Mon Sep 17 00:00:00 2001 From: Cory Forsyth Date: Tue, 19 Apr 2016 17:17:06 -0400 Subject: [PATCH] Add #insertAtom and #insertCard to Editor, fix #insertText Adds `insertAtom` and `insertCard` as first-class APIs on `Editor`. Fixes an issue when `Editor#insertText` would fail when the post is blank. --- src/js/editor/editor.js | 86 ++++++++++- tests/unit/editor/editor-test.js | 242 +++++++++++++++++++++++++++++++ 2 files changed, 327 insertions(+), 1 deletion(-) diff --git a/src/js/editor/editor.js b/src/js/editor/editor.js index 05fe2c434..50db0580c 100644 --- a/src/js/editor/editor.js +++ b/src/js/editor/editor.js @@ -853,11 +853,17 @@ class Editor { /** * Inserts the text at the current cursor position. If the editor has - * no current cursor position, nothing will be inserted. + * no current cursor position, nothing will be inserted. If the editor's + * range is not collapsed, it will be deleted before insertion. + * * @param {String} text * @public */ insertText(text) { + if (!this.hasCursor()) { return; } + if (this.post.isBlank) { + this._insertEmptyMarkupSectionAtCursor(); + } let { activeMarkups, range, range: { head: position } } = this; this.run(postEditor => { @@ -869,6 +875,84 @@ class Editor { }); } + /** + * Inserts an atom at the current cursor position. If the editor has + * no current cursor position, nothing will be inserted. If the editor's + * range is not collapsed, it will be deleted before insertion. + * @param {String} atomName + * @param {String} [atomText=''] + * @param {Object} [atomPayload={}] + * @public + */ + insertAtom(atomName, atomText='', atomPayload={}) { + if (!this.hasCursor()) { return; } + if (this.post.isBlank) { + this._insertEmptyMarkupSectionAtCursor(); + } + let { range } = this; + this.run(postEditor => { + let position = range.head; + + let atom = postEditor.builder.createAtom(atomName, atomText, atomPayload); + if (!range.isCollapsed) { + position = postEditor.deleteRange(range); + } + + postEditor.insertMarkers(position, [atom]); + }); + } + + /** + * Inserts a card at the section after the current cursor position. If the editor has + * no current cursor position, nothing will be inserted. If the editor's + * range is not collapsed, it will be deleted before insertion. If the cursor is in + * a blank section, it will be replaced with a card section. + * The editor's cursor will be placed at the end of the inserted card. + * @param {String} cardName + * @param {Object} [cardPayload={}] + * @param {Boolean} [inEditMode=false] Whether the card should be inserted in edit mode. + * @public + */ + insertCard(cardName, cardPayload={}, inEditMode=false) { + if (!this.hasCursor()) { return; } + if (this.post.isBlank) { + this._insertEmptyMarkupSectionAtCursor(); + } + + let { range } = this; + this.run(postEditor => { + let position = range.tail; + let card = postEditor.builder.createCardSection(cardName, cardPayload); + if (inEditMode) { + this.editCard(card); + } + + if (!range.isCollapsed) { + position = postEditor.deleteRange(range); + } + + let section = position.section; + if (section.isNested) { section = section.parent; } + + if (section.isBlank) { + postEditor.replaceSection(section, card); + } else { + let collection = this.post.sections; + postEditor.insertSectionBefore(collection, card, section.next); + } + + // It is important to explicitly set the range to the end of the card. + // Otherwise it is possible to create an inconsistent state in the + // browser. For instance, if the user clicked a button that + // called `editor.insertCard`, the editor surface may retain + // the selection but lose focus, and the next keystroke by the user + // will cause an unexpected DOM mutation (which can wipe out the + // card). + // See: https://github.com/bustlelabs/mobiledoc-kit/issues/286 + postEditor.setRange(new Range(card.tailPosition())); + }); + } + /** * @param {integer} x x-position in viewport * @param {integer} y y-position in viewport diff --git a/tests/unit/editor/editor-test.js b/tests/unit/editor/editor-test.js index bc0e6a6fb..db5e2f6c6 100644 --- a/tests/unit/editor/editor-test.js +++ b/tests/unit/editor/editor-test.js @@ -363,3 +363,245 @@ test('#hasActiveMarkup returns true for complex markups', (assert) => { editor.selectRange(Range.create(head, 'abcdefg'.length)); assert.equal(editor.activeMarkups.length, 0, 'no active markups after end of linked text'); }); + +test('#insertText inserts text at cursor position, replacing existing range if non-collapsed', (assert) => { + editor = Helpers.mobiledoc.renderInto(editorElement, ({post, markupSection, marker}) => { + return post([markupSection('p', [ marker('b') ])]); + }); + + editor.selectRange(new Range(editor.post.tailPosition())); + editor.insertText('Z'); + assert.equal(editor.post.sections.head.text, 'bZ'); + + editor.selectRange(new Range(editor.post.headPosition())); + editor.insertText('A'); + assert.equal(editor.post.sections.head.text, 'AbZ'); + + editor.selectRange(Range.create(editor.post.sections.head, 'A'.length)); + editor.insertText('B'); + assert.equal(editor.post.sections.head.text, 'ABbZ'); + + editor.selectRange(new Range(editor.post.headPosition(), editor.post.tailPosition())); + editor.insertText('new stuff'); + assert.equal(editor.post.sections.head.text, 'new stuff'); +}); + +test('#insertText inserts text at cursor position, inheriting active markups', (assert) => { + editor = Helpers.mobiledoc.renderInto(editorElement, ({post, markupSection, marker, markup}) => { + return post([markupSection('p', [ + marker('a'), + marker('b', [markup('b')]) + ])]); + }); + + editor.selectRange(new Range(editor.post.tailPosition())); + assert.equal(editor.activeMarkups.length, 1, 'precond - 1 active markup'); + editor.insertText('Z'); + assert.hasElement('#editor b:contains(bZ)'); + + editor.selectRange(new Range(editor.post.headPosition())); + assert.equal(editor.activeMarkups.length, 0, 'precond - 0 active markups at start'); + editor.toggleMarkup('b'); + editor.insertText('A'); + + assert.hasElement('#editor b:contains(A)'); +}); + +test('#insertText is no-op when editor does not have cursor', (assert) => { + let expected; + editor = Helpers.mobiledoc.renderInto(editorElement, ({post, markupSection, marker}) => { + expected = post([markupSection('p', [marker('abc')])]); + return post([markupSection('p', [marker('abc')])]); + }, {autofocus: false}); + + assert.ok(!editor.hasCursor(), 'precond - editor has no cursor'); + editor.insertText('blah blah blah'); + + assert.postIsSimilar(editor.post, expected, 'post is not changed'); +}); + +test('#insertText when post is blank', (assert) => { + let expected; + editor = Helpers.mobiledoc.renderInto(editorElement, ({post, markupSection, marker}) => { + expected = post([markupSection('p', [marker('blah blah')])]); + return post(); + }); + + assert.ok(editor.hasCursor(), 'precond - editor has no cursor'); + assert.ok(editor.post.isBlank, 'precond - editor has blank post'); + editor.insertText('blah blah'); + + assert.postIsSimilar(editor.post, expected, 'text is added to post'); +}); + +test('#insertAtom inserts atom at cursor position, replacing range if non-collapsed', (assert) => { + let atom = { + name: 'the-atom', + type: 'dom', + render() { + } + }; + + editor = Helpers.mobiledoc.renderInto(editorElement, ({post, markupSection, marker}) => { + return post([markupSection('p', [ marker('b') ])]); + }, {atoms: [atom]}); + + editor.selectRange(new Range(editor.post.tailPosition())); + editor.insertAtom('the-atom', 'END'); + + assert.equal(editor.post.sections.head.text, 'bEND'); + + editor.selectRange(new Range(editor.post.headPosition())); + editor.insertAtom('the-atom', 'START'); + assert.equal(editor.post.sections.head.text, 'STARTbEND'); + + editor.selectRange(new Range(editor.post.headPosition(), editor.post.tailPosition())); + editor.insertAtom('the-atom', 'REPLACE-ALL'); + assert.equal(editor.post.sections.head.text, 'REPLACE-ALL'); +}); + +test('#insertAtom is no-op when editor does not have cursor', (assert) => { + let atom = { + name: 'the-atom', + type: 'dom', + render() { + } + }; + + let expected; + editor = Helpers.mobiledoc.renderInto(editorElement, ({post, markupSection, marker}) => { + expected = post([markupSection('p', [marker('abc')])]); + return post([markupSection('p', [marker('abc')])]); + }, {atoms: [atom], autofocus: false}); + + assert.ok(!editor.hasCursor(), 'precond - editor has no cursor'); + editor.insertAtom('the-atom'); + + assert.postIsSimilar(editor.post, expected, 'post is not changed'); +}); + +test('#insertAtom when post is blank', (assert) => { + let atom = { + name: 'the-atom', + type: 'dom', + render() { + } + }; + + let expected; + editor = Helpers.mobiledoc.renderInto(editorElement, ({post, atom, markupSection}) => { + expected = post([markupSection('p', [atom('the-atom', 'THEATOMTEXT')])]); + return post(); + }, {atoms: [atom]}); + + assert.ok(editor.hasCursor(), 'precond - editor has cursor'); + assert.ok(editor.post.isBlank, 'precond - post is blank'); + editor.insertAtom('the-atom', 'THEATOMTEXT'); + + assert.postIsSimilar(editor.post, expected); +}); + +test('#insertCard inserts card at section after cursor position, replacing range if non-collapsed', (assert) => { + let card = { + name: 'the-card', + type: 'dom', + render() { + } + }; + + editor = Helpers.mobiledoc.renderInto(editorElement, ({post, markupSection, marker}) => { + return post([markupSection('p', [ marker('b') ])]); + }, {cards: [card]}); + + editor.selectRange(new Range(editor.post.tailPosition())); + editor.insertCard('the-card'); + + assert.equal(editor.post.sections.length, 2, 'adds a section at end'); + assert.ok(editor.post.sections.tail.isCardSection, 'added section is card section'); + + editor.run(postEditor => { + let blankSection = postEditor.builder.createMarkupSection(); + + let firstSection = editor.post.sections.head; + let collection = editor.post.sections; + postEditor.insertSectionBefore(collection, blankSection, firstSection); + }); + + assert.equal(editor.post.sections.length, 3, 'precond - adds blank section at start'); + assert.ok(!editor.post.sections.head.isCardSection, 'precond - initial section is not card section'); + + editor.selectRange(new Range(editor.post.headPosition())); + editor.insertCard('the-card'); + + assert.equal(editor.post.sections.length, 3, 'replaced initial blank section with card'); + assert.ok(editor.post.sections.head.isCardSection, 'initial section is card section'); + + editor.selectRange(new Range(editor.post.headPosition(), editor.post.tailPosition())); + editor.insertCard('the-card'); + assert.equal(editor.post.sections.length, 1, 'replaces range with card section'); + assert.ok(editor.post.sections.head.isCardSection, 'initial section is card section'); +}); + +test('#insertCard when cursor is in list item', (assert) => { + let card = { + name: 'the-card', + type: 'dom', + render() { + } + }; + + editor = Helpers.mobiledoc.renderInto(editorElement, ({post, markupSection, marker, listItem, listSection}) => { + return post([listSection('ul', [ + listItem([marker('abc')]), + listItem([marker('def')]) + ])]); + }, {cards: [card]}); + + editor.selectRange(Range.create(editor.post.sections.head.items.head, 'ab'.length)); + editor.insertCard('the-card'); + + assert.equal(editor.post.sections.length, 2, 'adds a second section'); + assert.ok(editor.post.sections.tail.isCardSection, 'tail section is card section'); +}); + +test('#insertCard is no-op when editor does not have cursor', (assert) => { + let card = { + name: 'the-card', + type: 'dom', + render() { + } + }; + + let expected; + editor = Helpers.mobiledoc.renderInto(editorElement, ({post, markupSection, marker}) => { + expected = post([markupSection('p', [marker('abc')])]); + return post([markupSection('p', [marker('abc')])]); + }, {cards: [card], autofocus: false}); + + assert.ok(!editor.hasCursor(), 'precond - editor has no cursor'); + editor.insertCard('the-card'); + + assert.postIsSimilar(editor.post, expected, 'post is not changed'); +}); + +test('#insertCard when post is blank', (assert) => { + let card = { + name: 'the-card', + type: 'dom', + render() { + } + }; + + let expected; + editor = Helpers.mobiledoc.renderInto(editorElement, ({post, cardSection}) => { + expected = post([cardSection('the-card')]); + return post(); + }, {cards: [card]}); + + assert.ok(editor.hasCursor(), 'precond - editor has cursor'); + assert.ok(editor.post.isBlank, 'precond - post is blank'); + + editor.insertCard('the-card'); + + assert.postIsSimilar(editor.post, expected, 'adds card section'); +});