diff --git a/CARDS.md b/CARDS.md index 6f1d9d100..392a593ab 100644 --- a/CARDS.md +++ b/CARDS.md @@ -66,7 +66,8 @@ var exampleCard = { The content for the card should be pushed on that array as a string. * `options` is the `cardOptions` argument passed to the editor or renderer. * `env` contains information about the running of this hook. It may contain - the following functions: + the following properties: + * `env.name` The name of this card * `env.save(payload)` will save a new payload for a card instance, then swap a card in edit mode to display. * `env.cancel()` will swap a card in edit mode to display without changing @@ -76,6 +77,9 @@ var exampleCard = { * `env.remove()` remove this card. This calls the current mode's `teardown()` hook and removes the card from DOM and from the post abstract. the instance to edit mode. + * `env.section` the CardSection from the Post -- this can be used when + programmatically interacting with the card, for example to move the card + using `editor.run(postEditor => postEditor.moveSectionUp(section)`. * `payload` is the payload for this card instance. It was either loaded from a Mobiledoc or generated and passed into an `env.save` call. diff --git a/src/js/editor/editor.js b/src/js/editor/editor.js index b97d2633a..7777ed9c0 100644 --- a/src/js/editor/editor.js +++ b/src/js/editor/editor.js @@ -108,7 +108,7 @@ class Editor { } return new DOMParser(this.builder).parse(this.html); } else { - return this.builder.createBlankPost(); + return this.builder.createPost(); } } diff --git a/src/js/editor/post.js b/src/js/editor/post.js index c0becee4f..e2dfa827d 100644 --- a/src/js/editor/post.js +++ b/src/js/editor/post.js @@ -509,6 +509,40 @@ class PostEditor { } } + moveSectionBefore(collection, renderedSection, beforeSection) { + const newSection = renderedSection.clone(); + this.removeSection(renderedSection); + this.insertSectionBefore(collection, newSection, beforeSection); + } + + /** + * @method moveSectionUp + * @param {Section} section A section that is already in DOM + * @public + */ + moveSectionUp(renderedSection) { + const isFirst = !renderedSection.prev; + if (isFirst) { return; } + + const collection = renderedSection.parent.sections; + const beforeSection = renderedSection.prev; + this.moveSectionBefore(collection, renderedSection, beforeSection); + } + + /** + * @method moveSectionDown + * @param {Section} section A section that is already in DOM + * @public + */ + moveSectionDown(renderedSection) { + const isLast = !renderedSection.next; + if (isLast) { return; } + + const beforeSection = renderedSection.next.next; + const collection = renderedSection.parent.sections; + this.moveSectionBefore(collection, renderedSection, beforeSection); + } + _replaceSection(section, newSections) { let nextSection = section.next; let collection = section.parent.sections; diff --git a/src/js/models/_markerable.js b/src/js/models/_markerable.js index 8a1c83580..875cc6c7e 100644 --- a/src/js/models/_markerable.js +++ b/src/js/models/_markerable.js @@ -16,6 +16,12 @@ export default class Markerable extends Section { markers.forEach(m => this.markers.append(m)); } + clone() { + const newMarkers = this.markers.map(m => m.clone()); + return this.builder.createMarkerableSection( + this.type, this.tagName, newMarkers); + } + get isBlank() { if (!this.markers.length) { return true; diff --git a/src/js/models/_section.js b/src/js/models/_section.js index fe0bf85d7..388c41b3a 100644 --- a/src/js/models/_section.js +++ b/src/js/models/_section.js @@ -41,6 +41,10 @@ export default class Section extends LinkedItem { return this._tagName; } + clone() { + throw new Error('clone() must be implemented by subclass'); + } + immediatelyNextMarkerableSection() { const next = this.next; if (next) { diff --git a/src/js/models/card-node.js b/src/js/models/card-node.js index b09e41f4d..cabfadf15 100644 --- a/src/js/models/card-node.js +++ b/src/js/models/card-node.js @@ -35,7 +35,8 @@ export default class CardNode { this.display(); }, cancel: () => this.display(), - remove: () => this.remove() + remove: () => this.remove(), + section: this.section }; } diff --git a/src/js/models/card.js b/src/js/models/card.js index 1ea23ce46..1328f0aa6 100644 --- a/src/js/models/card.js +++ b/src/js/models/card.js @@ -7,4 +7,8 @@ export default class Card extends Section { this.name = name; this.payload = payload; } + + clone() { + return this.builder.createCardSection(this.name, this.payload); + } } diff --git a/src/js/models/post-node-builder.js b/src/js/models/post-node-builder.js index 7abb97304..7f81afbbd 100644 --- a/src/js/models/post-node-builder.js +++ b/src/js/models/post-node-builder.js @@ -8,6 +8,10 @@ import Markup from '../models/markup'; import Card from '../models/card'; import { normalizeTagName } from '../utils/dom-utils'; import { objectToSortedKVArray } from '../utils/array-utils'; +import { + LIST_ITEM_TYPE, + MARKUP_SECTION_TYPE +} from '../models/types'; import { DEFAULT_TAG_NAME as DEFAULT_MARKUP_SECTION_TAG_NAME } from '../models/markup-section'; @@ -43,8 +47,15 @@ export default class PostNodeBuilder { return post; } - createBlankPost() { - return this.createPost([this.createMarkupSection()]); + createMarkerableSection(type, tagName, markers=[]) { + switch (type) { + case LIST_ITEM_TYPE: + return this.createListItem(tagName, markers); + case MARKUP_SECTION_TYPE: + return this.createMarkupSection(tagName, markers); + default: + throw new Error(`Cannot create markerable section of type ${type}`); + } } createMarkupSection(tagName=DEFAULT_MARKUP_SECTION_TAG_NAME, markers=[], isGenerated=false) { @@ -80,7 +91,9 @@ export default class PostNodeBuilder { } createCardSection(name, payload={}) { - return new Card(name, payload); + const card = new Card(name, payload); + card.builder = this; + return card; } createMarker(value, markups=[]) { diff --git a/src/js/utils/linked-list.js b/src/js/utils/linked-list.js index 2ec45062e..b1671af91 100644 --- a/src/js/utils/linked-list.js +++ b/src/js/utils/linked-list.js @@ -111,6 +111,11 @@ export default class LinkedList { item = item.next; } } + map(callback) { + let result = []; + this.forEach(i => result.push(callback(i))); + return result; + } walk(startItem, endItem, callback) { let item = startItem || this.head; while (item) { diff --git a/tests/acceptance/basic-editor-test.js b/tests/acceptance/basic-editor-test.js index 29eeb891d..42ac92711 100644 --- a/tests/acceptance/basic-editor-test.js +++ b/tests/acceptance/basic-editor-test.js @@ -24,8 +24,6 @@ test('sets element as contenteditable', (assert) => { assert.equal(editorElement.getAttribute('contenteditable'), 'true', 'element is contenteditable'); - assert.equal(editorElement.firstChild.tagName, 'P', - `editor element has a P as its first child`); }); test('#disableEditing before render is meaningful', (assert) => { diff --git a/tests/unit/editor/card-lifecycle-test.js b/tests/unit/editor/card-lifecycle-test.js index 0c1e1a730..c3b4065b3 100644 --- a/tests/unit/editor/card-lifecycle-test.js +++ b/tests/unit/editor/card-lifecycle-test.js @@ -48,6 +48,34 @@ test('rendering a mobiledoc for editing calls card#setup', (assert) => { editor.render(editorElement); }); +test('rendered card env has `name`, `edit`, `save`, `remove`, `section', (assert) => { + let cardEnv; + + const cardName = 'test-card'; + const cards = [{ + name: cardName, + display: { + setup(element, options, env) { cardEnv = env; }, + teardown() {} + } + }]; + + const mobiledoc = Helpers.mobiledoc.build(({post, cardSection}) => { + return post([cardSection('test-card')]); + }); + editor = new Editor({mobiledoc, cards}); + editor.render(editorElement); + + assert.ok(!!cardEnv, 'card env is present'); + assert.equal(cardEnv.name, cardName, 'env name is correct'); + assert.ok(!!cardEnv.edit, 'has edit hook'); + assert.ok(!!cardEnv.save, 'has save hook'); + assert.ok(!!cardEnv.cancel, 'has cancel hook'); + assert.ok(!!cardEnv.remove, 'has remove hook'); + const cardSection = editor.post.sections.head; + assert.ok(cardEnv.section && cardEnv.section === cardSection, 'has `section`'); +}); + test('rendering a mobiledoc for editing calls #unknownCardHandler when it encounters an unknown card', (assert) => { assert.expect(1); diff --git a/tests/unit/editor/editor-test.js b/tests/unit/editor/editor-test.js index d18129216..3034c5bd4 100644 --- a/tests/unit/editor/editor-test.js +++ b/tests/unit/editor/editor-test.js @@ -3,8 +3,9 @@ import { EDITOR_ELEMENT_CLASS_NAME } from 'content-kit-editor/editor/editor'; import { normalizeTagName } from 'content-kit-editor/utils/dom-utils'; import { MOBILEDOC_VERSION } from 'content-kit-editor/renderers/mobiledoc'; import Range from 'content-kit-editor/utils/cursor/range'; +import Helpers from '../../test-helpers'; -const { module, test } = window.QUnit; +const { module, test } = Helpers; let fixture, editorElement, editor; @@ -29,10 +30,6 @@ test('can render an editor via dom node reference', (assert) => { editor.render(editorElement); assert.equal(editor.element, editorElement); assert.ok(editor.post); - assert.equal(editor.post.sections.length, 1); - assert.equal(editor.post.sections.head.tagName, 'p'); - assert.equal(editor.post.sections.head.markers.length, 0); - assert.equal(editor.post.sections.head.text, ''); }); test('creating an editor with DOM node throws', (assert) => { @@ -84,7 +81,10 @@ test('editor fires lifecycle hooks', (assert) => { test('editor fires lifecycle hooks for edit', (assert) => { assert.expect(4); - editor = new Editor(); + const mobiledoc = Helpers.mobiledoc.build(({post, markupSection}) => { + return post([markupSection()]); + }); + editor = new Editor({mobiledoc}); editor.render(editorElement); let didCallUpdatePost, didCallWillRender, didCallDidRender; diff --git a/tests/unit/editor/post-test.js b/tests/unit/editor/post-test.js index 434095fbf..b47f6f7ea 100644 --- a/tests/unit/editor/post-test.js +++ b/tests/unit/editor/post-test.js @@ -668,7 +668,7 @@ test('markers with identical markups get coalesced after deletion', (assert) => section = markupSection('p', [marker('a'), marker('b',[strong]), marker('c')]); return post([section]); }); - renderBuiltAbstract(post); + let mockEditor = renderBuiltAbstract(post); let range = Range.create(section, 1, section, 2); postEditor = new PostEditor(mockEditor); @@ -678,3 +678,100 @@ test('markers with identical markups get coalesced after deletion', (assert) => assert.equal(section.markers.length, 1, 'similar markers are coalesced'); assert.equal(section.markers.head.value, 'ac', 'marker value is correct'); }); + +test('#moveSectionBefore moves the section as expected', (assert) => { + const post = Helpers.postAbstract.build(({post, markupSection, marker}) => { + return post([ + markupSection('p', [marker('abc')]), + markupSection('p', [marker('123')]) + ]); + }); + let mockEditor = renderBuiltAbstract(post); + + const [headSection, tailSection] = post.sections.toArray(); + const collection = post.sections; + postEditor = new PostEditor(mockEditor); + postEditor.moveSectionBefore(collection, tailSection, headSection); + postEditor.complete(); + + assert.equal(post.sections.head.text, '123', 'tail section is now head'); + assert.equal(post.sections.tail.text, 'abc', 'head section is now tail'); +}); + +test('#moveSectionBefore moves card sections', (assert) => { + const listiclePayload = {some:'thing'}; + const otherPayload = {some:'other thing'}; + const post = Helpers.postAbstract.build(({post, cardSection}) => { + return post([ + cardSection('listicle-card', listiclePayload), + cardSection('other-card', otherPayload) + ]); + }); + let mockEditor = renderBuiltAbstract(post); + + const collection = post.sections; + let [headSection, tailSection] = post.sections.toArray(); + postEditor = new PostEditor(mockEditor); + postEditor.moveSectionBefore(collection, tailSection, headSection); + postEditor.complete(); + + ([headSection, tailSection] = post.sections.toArray()); + assert.equal(headSection.name, 'other-card', 'other-card moved to first spot'); + assert.equal(tailSection.name, 'listicle-card', 'listicle-card moved to last spot'); + assert.deepEqual(headSection.payload, otherPayload, 'payload is correct for other-card'); + assert.deepEqual(tailSection.payload, listiclePayload, 'payload is correct for listicle-card'); +}); + +test('#moveSectionUp moves it up', (assert) => { + const post = Helpers.postAbstract.build(({post, cardSection}) => { + return post([ + cardSection('listicle-card'), + cardSection('other-card') + ]); + }); + let mockEditor = renderBuiltAbstract(post); + + let [headSection, tailSection] = post.sections.toArray(); + postEditor = new PostEditor(mockEditor); + postEditor.moveSectionUp(tailSection); + postEditor.complete(); + + ([headSection, tailSection] = post.sections.toArray()); + assert.equal(headSection.name, 'other-card', 'other-card moved to first spot'); + assert.equal(tailSection.name, 'listicle-card', 'listicle-card moved to last spot'); + + postEditor = new PostEditor(mockEditor); + postEditor.moveSectionUp(headSection); + postEditor.complete(); + + ([headSection, tailSection] = post.sections.toArray()); + assert.equal(headSection.name, 'other-card', 'moveSectionUp is no-op when card is at top'); +}); + +test('moveSectionDown moves it down', (assert) => { + const post = Helpers.postAbstract.build(({post, cardSection}) => { + return post([ + cardSection('listicle-card'), + cardSection('other-card') + ]); + }); + let mockEditor = renderBuiltAbstract(post); + + let [headSection, tailSection] = post.sections.toArray(); + postEditor = new PostEditor(mockEditor); + postEditor.moveSectionDown(headSection); + postEditor.complete(); + + ([headSection, tailSection] = post.sections.toArray()); + assert.equal(headSection.name, 'other-card', 'other-card moved to first spot'); + assert.equal(tailSection.name, 'listicle-card', 'listicle-card moved to last spot'); + + postEditor = new PostEditor(mockEditor); + postEditor.moveSectionDown(tailSection); + postEditor.complete(); + + ([headSection, tailSection] = post.sections.toArray()); + assert.equal(tailSection.name, 'listicle-card', + 'moveSectionDown is no-op when card is at bottom'); + +}); diff --git a/tests/unit/models/post-node-builder-test.js b/tests/unit/models/post-node-builder-test.js index f4d7b5db3..70681cec0 100644 --- a/tests/unit/models/post-node-builder-test.js +++ b/tests/unit/models/post-node-builder-test.js @@ -41,3 +41,8 @@ test('#createMarkup normalizes tagName', (assert) => { m3 === m4, 'all markups are the same'); }); +test('#createCardSection creates card with builder', (assert) => { + const builder = new PostNodeBuilder(); + const cardSection = builder.createCardSection('test-card'); + assert.ok(cardSection.builder === builder, 'card section has builder'); +});