diff --git a/src/js/commands/link.js b/src/js/commands/link.js index e0be85d5a..54ab18fb3 100644 --- a/src/js/commands/link.js +++ b/src/js/commands/link.js @@ -1,5 +1,4 @@ import TextFormatCommand from './text-format'; -import { any } from 'content-kit-editor/utils/array-utils'; export default class LinkCommand extends TextFormatCommand { constructor(editor) { @@ -10,24 +9,10 @@ export default class LinkCommand extends TextFormatCommand { }); } - isActive() { - return any(this.editor.markupsInSelection, m => m.hasTag(this.tag)); - } - exec(url) { - const range = this.editor.cursor.offsets; this.editor.run(postEditor => { const markup = postEditor.builder.createMarkup('a', ['href', url]); - postEditor.applyMarkupToRange(range, markup); - }); - this.editor.moveToPosition(range.tail); - } - - unexec() { - const range = this.editor.cursor.offsets; - this.editor.run(postEditor => { - postEditor.removeMarkupFromRange(range, markup => markup.hasTag('a')); + this.editor.run(postEditor => postEditor.toggleMarkup(markup)); }); - this.editor.selectRange(range); } } diff --git a/src/js/commands/text-format.js b/src/js/commands/text-format.js index cc330bc59..fee659b2f 100644 --- a/src/js/commands/text-format.js +++ b/src/js/commands/text-format.js @@ -8,27 +8,15 @@ export default class TextFormatCommand extends Command { this.tag = options.tag; } - get markup() { - if (this._markup) { return this._markup; } - this._markup = this.editor.builder.createMarkup(this.tag); - return this._markup; - } - isActive() { - return any(this.editor.markupsInSelection, m => m === this.markup); + return any(this.editor.markupsInSelection, m => m.hasTag(this.tag)); } exec() { - const range = this.editor.cursor.offsets, { markup } = this; - this.editor.run( - postEditor => postEditor.applyMarkupToRange(range, markup)); - this.editor.selectRange(range); + this.editor.run(postEditor => postEditor.toggleMarkup(this.tag)); } unexec() { - const range = this.editor.cursor.offsets, { markup } = this; - this.editor.run( - postEditor => postEditor.removeMarkupFromRange(range, markup)); - this.editor.selectRange(range); + this.editor.run(postEditor => postEditor.toggleMarkup(this.tag)); } } diff --git a/src/js/editor/editor.js b/src/js/editor/editor.js index 3cca7cfd5..3e954bf52 100644 --- a/src/js/editor/editor.js +++ b/src/js/editor/editor.js @@ -367,6 +367,11 @@ class Editor { return this.cursor.activeSections; } + get activeSection() { + const { activeSections } = this; + return activeSections[activeSections.length - 1]; + } + get markupsInSelection() { if (this.cursor.hasSelection()) { const range = this.cursor.offsets; diff --git a/src/js/editor/post.js b/src/js/editor/post.js index ba53df6eb..7af498da0 100644 --- a/src/js/editor/post.js +++ b/src/js/editor/post.js @@ -1,6 +1,6 @@ import { POST_TYPE, MARKUP_SECTION_TYPE, LIST_ITEM_TYPE } from '../models/types'; import Position from '../utils/cursor/position'; -import { filter, compact } from '../utils/array-utils'; +import { any, filter, compact } from '../utils/array-utils'; import { DIRECTION } from '../utils/key'; function isMarkupSection(section) { @@ -24,6 +24,7 @@ class PostEditor { this.editor = editor; this.builder = this.editor.builder; this._completionWorkQueue = []; + this._afterRenderQueue = []; this._didRerender = false; this._didUpdate = false; this._didComplete = false; @@ -544,7 +545,7 @@ class PostEditor { * @param {Range} range Object with offsets * @param {Markup} markup A markup post abstract node * @return {Array} of markers that are inside the split - * @public + * @private */ removeMarkupFromRange(range, markupOrMarkupCallback) { const markers = this.splitMarkers(range); @@ -556,6 +557,45 @@ class PostEditor { return markers; } + /** + * Toggle the given markup on the current selection. If anything in the current + * selection has the markup, it will be removed. If nothing in the selection + * has the markup, it will be added to everything in the selection. + * + * Usage: + * + * // Remove any 'strong' markup if it exists in the selection, otherwise + * // make it all 'strong' + * editor.run(postEditor => postEditor.toggleMarkup('strong')); + * + * // add/remove a link to 'bustle.com' to the selection + * editor.run(postEditor => { + * const linkMarkup = postEditor.builder.createMarkup('a', ['href', 'http://bustle.com']); + * postEditor.toggleMarkup(linkMarkup); + * }); + * + * @method toggleMarkup + * @param {Markup|String} markupOrString Either a markup object created using + * the builder (useful when adding a markup with attributes, like an 'a' markup), + * or, if a string, the tag name of the markup (e.g. 'strong', 'em') to toggle. + */ + toggleMarkup(markupOrMarkupString) { + const markup = typeof markupOrMarkupString === 'string' ? + this.builder.createMarkup(markupOrMarkupString) : + markupOrMarkupString; + + const range = this.editor.cursor.offsets; + const hasMarkup = m => m.hasTag(markup.tagName); + const rangeHasMarkup = any(this.editor.markupsInSelection, hasMarkup); + + if (rangeHasMarkup) { + this.removeMarkupFromRange(range, hasMarkup); + } else { + this.applyMarkupToRange(range, markup); + } + this.scheduleAfterRender(() => this.editor.selectRange(range)); + } + /** * Insert a given section before another one, updating the post abstract * and the rendered UI. @@ -582,6 +622,31 @@ class PostEditor { this._markDirty(section.parent); } + /** + * Insert the given section after the current active section, or, if no + * section is active, at the end of the document. + * @method insertSection + * @param {Section} section + * @public + */ + insertSection(section) { + const activeSection = this.editor.activeSection; + const nextSection = activeSection && activeSection.next; + + const collection = this.editor.post.sections; + this.insertSectionBefore(collection, section, nextSection); + } + + /** + * Insert the given section at the end of the document. + * @method insertSectionAtEnd + * @param {Section} section + * @public + */ + insertSectionAtEnd(section) { + this.insertSectionBefore(this.editor.post.sections, section, null); + } + /** * Remove a given section from the post abstract and the rendered UI. * @@ -661,6 +726,10 @@ class PostEditor { }); } + scheduleAfterRender(callback) { + this._afterRenderQueue.push(callback); + } + /** * Flush any work on the queue. `editor.run` already does this, calling this * method directly should not be needed outside `editor.run`. @@ -673,9 +742,8 @@ class PostEditor { throw new Error('Post editing can only be completed once'); } this._didComplete = true; - this._completionWorkQueue.forEach(callback => { - callback(); - }); + this._completionWorkQueue.forEach(cb => cb()); + this._afterRenderQueue.forEach(cb => cb()); } } diff --git a/tests/acceptance/editor-commands-test.js b/tests/acceptance/editor-commands-test.js index 6c5bdc8dd..1518cdf7a 100644 --- a/tests/acceptance/editor-commands-test.js +++ b/tests/acceptance/editor-commands-test.js @@ -232,19 +232,7 @@ Helpers.skipInPhantom('highlight text, click "link" button shows input for URL, setTimeout(() => { assert.hasElement(`#editor a[href="${url}"]:contains(${selectedText})`); - - 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(editor, 'X'); - assert.hasElement(`#editor p:contains(${selectedText}XX)`, - 'inserts text after selected text again'); - - Helpers.dom.selectText(selectedText, editorElement); + assert.selectedText(selectedText, 'text remains selected'); Helpers.dom.triggerEvent(document, 'mouseup'); setTimeout(() => { diff --git a/tests/acceptance/editor-post-editor-test.js b/tests/acceptance/editor-post-editor-test.js new file mode 100644 index 000000000..e2be5e0a6 --- /dev/null +++ b/tests/acceptance/editor-post-editor-test.js @@ -0,0 +1,119 @@ +import { Editor } from 'content-kit-editor'; +import Helpers from '../test-helpers'; + +const { module, test } = Helpers; + +let editor, editorElement; + +module('Acceptance: Editor - PostEditor', { + beforeEach() { + editorElement = $('
')[0]; + $('#qunit-fixture').append($(editorElement)); + }, + afterEach() { + if (editor) { editor.destroy(); } + } +}); + +test('#insertSectionAtEnd inserts the section at the end', (assert) => { + let newSection; + const mobiledoc = Helpers.mobiledoc.build(({post, markupSection, marker}) => { + newSection = markupSection('p', [marker('123')]); + return post([markupSection('p', [marker('abc')])]); + }); + editor = new Editor({mobiledoc}); + editor.render(editorElement); + + //precond + assert.hasElement('#editor p:contains(abc)'); + assert.hasNoElement('#editor p:contains(123)'); + + editor.run(postEditor => postEditor.insertSectionAtEnd(newSection)); + assert.hasElement('#editor p:eq(1):contains(123)', 'new section added at end'); +}); + +test('#insertSection inserts after the cursor active section', (assert) => { + let newSection; + const mobiledoc = Helpers.mobiledoc.build(({post, markupSection, marker}) => { + newSection = markupSection('p', [marker('123')]); + return post([ + markupSection('p', [marker('abc')]), + markupSection('p', [marker('def')]) + ]); + }); + editor = new Editor({mobiledoc}); + editor.render(editorElement); + + //precond + assert.hasElement('#editor p:eq(0):contains(abc)'); + assert.hasElement('#editor p:eq(1):contains(def)'); + assert.hasNoElement('#editor p:contains(123)'); + + Helpers.dom.selectText('b', editorElement); + + editor.run(postEditor => postEditor.insertSection(newSection)); + assert.hasElement('#editor p:eq(0):contains(abc)', 'still has 1st section'); + assert.hasElement('#editor p:eq(1):contains(123)', + 'new section added after active section'); + assert.hasElement('#editor p:eq(2):contains(def)', '2nd section -> 3rd spot'); +}); + +test('#insertSection inserts at end when no active cursor section', (assert) => { + let newSection; + const mobiledoc = Helpers.mobiledoc.build(({post, markupSection, marker}) => { + newSection = markupSection('p', [marker('123')]); + return post([ + markupSection('p', [marker('abc')]), + markupSection('p', [marker('def')]) + ]); + }); + editor = new Editor({mobiledoc}); + editor.render(editorElement); + + //precond + assert.hasElement('#editor p:eq(0):contains(abc)'); + assert.hasElement('#editor p:eq(1):contains(def)'); + assert.hasNoElement('#editor p:contains(123)'); + + Helpers.dom.clearSelection(); + editor.run(postEditor => postEditor.insertSection(newSection)); + assert.hasElement('#editor p:eq(0):contains(abc)', 'still has 1st section'); + assert.hasElement('#editor p:eq(2):contains(123)', 'new section added at end'); + assert.hasElement('#editor p:eq(1):contains(def)', '2nd section -> same spot'); +}); + +test('#toggleMarkup adds markup by tag name', (assert) => { + const mobiledoc = Helpers.mobiledoc.build(({post, markupSection, marker}) => { + return post([ + markupSection('p', [marker('abc'), marker('def')]) + ]); + }); + editor = new Editor({mobiledoc}); + editor.render(editorElement); + + //precond + assert.hasNoElement('#editor strong'); + + Helpers.dom.selectText('bc', editorElement, 'd', editorElement); + editor.run(postEditor => postEditor.toggleMarkup('strong')); + assert.hasElement('#editor strong:contains(bcd)'); +}); + +test('#toggleMarkup removes markup by tag name', (assert) => { + const mobiledoc = Helpers.mobiledoc.build(({post, markupSection, marker, markup}) => { + const strong = markup('strong'); + return post([ + markupSection('p', [marker('a'), marker('bcde', [strong]), marker('f')]) + ]); + }); + editor = new Editor({mobiledoc}); + editor.render(editorElement); + + //precond + assert.hasElement('#editor strong:contains(bcde)'); + + Helpers.dom.selectText('bc', editorElement, 'd', editorElement); + editor.run(postEditor => postEditor.toggleMarkup('strong')); + assert.hasNoElement('#editor strong:contains(bcd)', 'markup removed from selection'); + assert.hasElement('#editor strong:contains(e)', 'unselected text still bold'); +}); diff --git a/tests/unit/editor/post-test.js b/tests/unit/editor/post-test.js index 11555c249..f4eddf47c 100644 --- a/tests/unit/editor/post-test.js +++ b/tests/unit/editor/post-test.js @@ -581,3 +581,18 @@ test('#replaceSection when section is null appends new section', (assert) => { assert.equal(post.sections.length, 1, 'has 1 section'); assert.equal(post.sections.head.text, '', 'no text in new section'); }); + +test('#insertSectionAtEnd inserts the section at the end of the mobiledoc', (assert) => { + let newSection; + const post = Helpers.postAbstract.build(({post, markupSection, marker}) => { + newSection = markupSection('p', [marker('123')]); + return post([markupSection('p', [marker('abc')])]); + }); + renderBuiltAbstract(post); + + postEditor.insertSectionAtEnd(newSection); + postEditor.complete(); + + assert.equal(post.sections.length, 2, 'new section added'); + assert.equal(post.sections.tail.text, '123', 'new section added at end'); +});